Support IgnoreUnmatchedProperties in YAML serialization (#574)

* Support IgnoreUnmatchedProperties in YAML serialization

* Remove unnecessary null-conditional operator
This commit is contained in:
Alex Meyer-Gleaves
2021-03-03 06:35:20 +10:00
committed by GitHub
parent a4350d6c8f
commit d48e93c1f6
4 changed files with 181 additions and 124 deletions

View File

@@ -3,6 +3,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using YamlDotNet.Core;
@@ -18,6 +19,26 @@ namespace k8s
/// </summary>
public static class Yaml
{
private static readonly IDeserializer Deserializer =
new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new IntOrStringYamlConverter())
.WithTypeConverter(new ByteArrayStringYamlConverter())
.WithOverridesFromJsonPropertyAttributes()
.IgnoreUnmatchedProperties()
.Build();
private static readonly IValueSerializer Serializer =
new SerializerBuilder()
.DisableAliases()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new IntOrStringYamlConverter())
.WithTypeConverter(new ByteArrayStringYamlConverter())
.WithEventEmitter(e => new StringQuotingEmitter(e))
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.WithOverridesFromJsonPropertyAttributes()
.BuildValueSerializer();
public class ByteArrayStringYamlConverter : IYamlTypeConverter
{
public bool Accepts(Type type)
@@ -105,30 +126,15 @@ namespace k8s
throw new ArgumentNullException(nameof(typeMap));
}
var deserializer =
new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeInspector(ti => new AutoRestTypeInspector(ti))
.WithTypeConverter(new IntOrStringYamlConverter())
.WithTypeConverter(new ByteArrayStringYamlConverter())
.IgnoreUnmatchedProperties()
.Build();
var types = new List<Type>();
var parser = new Parser(new StringReader(content));
parser.Consume<StreamStart>();
while (parser.Accept<DocumentStart>(out _))
{
var obj = deserializer.Deserialize<KubernetesObject>(parser);
var obj = Deserializer.Deserialize<KubernetesObject>(parser);
types.Add(typeMap[obj.ApiVersion + "/" + obj.Kind]);
}
deserializer =
new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeInspector(ti => new AutoRestTypeInspector(ti))
.WithTypeConverter(new IntOrStringYamlConverter())
.WithTypeConverter(new ByteArrayStringYamlConverter())
.Build();
parser = new Parser(new StringReader(content));
parser.Consume<StreamStart>();
var ix = 0;
@@ -136,7 +142,7 @@ namespace k8s
while (parser.Accept<DocumentStart>(out _))
{
var objType = types[ix++];
var obj = deserializer.Deserialize(parser, objType);
var obj = Deserializer.Deserialize(parser, objType);
results.Add(obj);
}
@@ -160,14 +166,7 @@ namespace k8s
public static T LoadFromString<T>(string content)
{
var deserializer =
new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeInspector(ti => new AutoRestTypeInspector(ti))
.WithTypeConverter(new IntOrStringYamlConverter())
.WithTypeConverter(new ByteArrayStringYamlConverter())
.Build();
var obj = deserializer.Deserialize<T>(content);
var obj = Deserializer.Deserialize<T>(content);
return obj;
}
@@ -177,119 +176,44 @@ namespace k8s
var writer = new StringWriter(stringBuilder);
var emitter = new Emitter(writer);
var serializer =
new SerializerBuilder()
.DisableAliases()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeInspector(ti => new AutoRestTypeInspector(ti))
.WithTypeConverter(new IntOrStringYamlConverter())
.WithTypeConverter(new ByteArrayStringYamlConverter())
.WithEventEmitter(e => new StringQuotingEmitter(e))
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.BuildValueSerializer();
emitter.Emit(new StreamStart());
emitter.Emit(new DocumentStart());
serializer.SerializeValue(emitter, value, typeof(T));
Serializer.SerializeValue(emitter, value, typeof(T));
return stringBuilder.ToString();
}
private class AutoRestTypeInspector : ITypeInspector
private static TBuilder WithOverridesFromJsonPropertyAttributes<TBuilder>(this TBuilder builder)
where TBuilder : BuilderSkeleton<TBuilder>
{
private readonly ITypeInspector _inner;
// Use VersionInfo from the model namespace as that should be stable.
// If this is not generated in the future we will get an obvious compiler error.
var targetNamespace = typeof(VersionInfo).Namespace;
public AutoRestTypeInspector(ITypeInspector inner)
{
_inner = inner;
}
// Get all the concrete model types from the code generated namespace.
var types = typeof(KubernetesEntityAttribute).Assembly
.ExportedTypes
.Where(type => type.Namespace == targetNamespace &&
!type.IsInterface &&
!type.IsAbstract);
public IEnumerable<IPropertyDescriptor> GetProperties(Type type, object container)
// Map any JsonPropertyAttribute instances to YamlMemberAttribute instances.
foreach (var type in types)
{
var pds = _inner.GetProperties(type, container);
return pds.Select(pd => TrimPropertySuffix(pd, type)).ToList();
}
public IPropertyDescriptor GetProperty(Type type, object container, string name, bool ignoreUnmatched)
{
try
foreach (var property in type.GetProperties())
{
return _inner.GetProperty(type, container, name, ignoreUnmatched);
}
catch (System.Runtime.Serialization.SerializationException)
{
return _inner.GetProperty(type, container, name + "Property", ignoreUnmatched);
var jsonAttribute = property.GetCustomAttribute<JsonPropertyAttribute>();
if (jsonAttribute == null)
{
continue;
}
var yamlAttribute = new YamlMemberAttribute { Alias = jsonAttribute.PropertyName };
builder.WithAttributeOverride(type, property.Name, yamlAttribute);
}
}
private IPropertyDescriptor TrimPropertySuffix(IPropertyDescriptor pd, Type type)
{
if (!pd.Name.EndsWith("Property", StringComparison.InvariantCulture))
{
return pd;
}
// This might have been renamed by AutoRest. See if there is a
// JsonPropertyAttribute.PropertyName and use that instead if there is.
var jpa = pd.GetCustomAttribute<JsonPropertyAttribute>();
if (jpa == null || string.IsNullOrEmpty(jpa.PropertyName))
{
return pd;
}
return new RenamedPropertyDescriptor(pd, jpa.PropertyName);
}
private class RenamedPropertyDescriptor : IPropertyDescriptor
{
private readonly IPropertyDescriptor _inner;
private readonly string _name;
public RenamedPropertyDescriptor(IPropertyDescriptor inner, string name)
{
_inner = inner;
_name = name;
}
public string Name => _name;
public bool CanWrite => _inner.CanWrite;
public Type Type => _inner.Type;
public Type TypeOverride
{
get => _inner.TypeOverride;
set => _inner.TypeOverride = value;
}
public int Order
{
get => _inner.Order;
set => _inner.Order = value;
}
public ScalarStyle ScalarStyle
{
get => _inner.ScalarStyle;
set => _inner.ScalarStyle = value;
}
public T GetCustomAttribute<T>()
where T : Attribute
{
return _inner.GetCustomAttribute<T>();
}
public IObjectDescriptor Read(object target)
{
return _inner.Read(target);
}
public void Write(object target, object value)
{
_inner.Write(target, value);
}
}
return builder;
}
}
}

View File

@@ -503,6 +503,19 @@ namespace k8s.Tests
AssertConfigEqual(expectedCfg, cfg);
}
[Fact]
public void LoadKubeConfigWithAdditionalProperties()
{
var txt = File.ReadAllText("assets/kubeconfig.additional-properties.yml");
var expectedCfg = Yaml.LoadFromString<K8SConfiguration>(txt);
var fileInfo = new FileInfo(Path.GetFullPath("assets/kubeconfig.additional-properties.yml"));
var cfg = KubernetesClientConfiguration.LoadKubeConfig(new FileInfo[] { fileInfo, fileInfo });
AssertConfigEqual(expectedCfg, cfg);
}
[Fact]
public void MergeKubeConfigNoDuplicates()
{

View File

@@ -35,6 +35,35 @@ metadata:
Assert.Equal("ns", ((V1Namespace)objs[1]).Metadata.Name);
}
[Fact]
public void LoadAllFromStringWithAdditionalProperties()
{
var content = @"apiVersion: v1
kind: Pod
metadata:
name: foo
namespace: ns
youDontKnow: Me
---
apiVersion: v1
kind: Namespace
metadata:
name: ns
youDontKnow: Me";
var types = new Dictionary<string, Type>();
types.Add("v1/Pod", typeof(V1Pod));
types.Add("v1/Namespace", typeof(V1Namespace));
var objs = Yaml.LoadAllFromString(content, types);
Assert.Equal(2, objs.Count);
Assert.IsType<V1Pod>(objs[0]);
Assert.IsType<V1Namespace>(objs[1]);
Assert.Equal("foo", ((V1Pod)objs[0]).Metadata.Name);
Assert.Equal("ns", ((V1Pod)objs[0]).Metadata.NamespaceProperty);
Assert.Equal("ns", ((V1Namespace)objs[1]).Metadata.Name);
}
[Fact]
public async Task LoadAllFromFile()
{
@@ -87,6 +116,21 @@ metadata:
Assert.Equal("foo", obj.Metadata.Name);
}
[Fact]
public void LoadFromStringWithAdditionalProperties()
{
var content = @"apiVersion: v1
kind: Pod
metadata:
name: foo
youDontKnow: Me
";
var obj = Yaml.LoadFromString<V1Pod>(content);
Assert.Equal("foo", obj.Metadata.Name);
}
[Fact]
public void LoadNamespacedFromString()
{
@@ -172,6 +216,18 @@ metadata:
}
}
[Fact]
public void RoundtripTypeWithMismatchedPropertyName()
{
var content = @"namespace: foo";
var deserialized = Yaml.LoadFromString<V1ObjectMeta>(content);
Assert.Equal("foo", deserialized.NamespaceProperty);
var serialized = Yaml.SaveToString(deserialized);
Assert.Equal(content, serialized);
}
[Fact]
public void WriteToString()
{

View File

@@ -0,0 +1,64 @@
# Sample file based on https://kubernetes.io/docs/tasks/access-application-cluster/authenticate-across-clusters-kubeconfig/
# WARNING: File includes minor fixes
---
current-context: federal-context
apiVersion: v1
additionalProperty: foobar
clusters:
- cluster:
server: http://cow.org:8080
name: cow-cluster
farm: old-mac-donalds
- cluster:
certificate-authority: assets/ca.crt
server: https://horse.org:4443
name: horse-cluster
- cluster:
insecure-skip-tls-verify: true
server: https://pig.org:443
name: pig-cluster
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURERENDQWZTZ0F3SUJBZ0lSQUo5ZCtLeThkTDJVSzRjdXplMmo2WnN3RFFZSktvWklodmNOQVFFTEJRQXcKTHpFdE1Dc0dBMVVFQXhNa1lXRTBZVFV3T0RZdE0yVm1aaTAwWWpCa0xUbGxORGt0WmpNeVpXWXpabUpqWWpNNApNQjRYRFRFM01ESXlOakExTURRek5Gb1hEVEl5TURJeU5UQTFNRFF6TkZvd0x6RXRNQ3NHQTFVRUF4TWtZV0UwCllUVXdPRFl0TTJWbVppMDBZakJrTFRsbE5Ea3Raak15WldZelptSmpZak00TUlJQklqQU5CZ2txaGtpRzl3MEIKQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBM2dkandhdHNsdCsvQVpqV3hmbkNQeGZqMzNHUUxlOU00VU42VmEwRQpKd0FYL2R3L1ZVa0dvVjlDc3NKRUZMdEdTUnM2K2h0RTEvOUN3ak1USDh2WExKcURHTE9KdFQ5dW9sR2c2Q2k1ClBKNDNKelVLWmJlYVE4Z3hhZndzQjdQU05vTTJOYzROVm9lZzBVTUw0bndGeEhXeTNYWHlFZ0QxTWxTUnVrb3oKTTNoRUVxUjJNVFdrNm9KK3VJNFF4WVZWMnZuWXdXaEJwUDlDR3RWUTlyUW9MVFowcmFpOCtDYURBMVltTWRhbQpRYUVPdURlSFRqU2FYM2dyR0FBVVFWNWl6MC9qVVBuK3lJNm1iV0trbzFzNytPY1dZR2F1aDFaMzFYSjJsc0RTCnU4a3F0d215UEcyUVl2aUQ4YjNOWFAyY0dRK2EwZlpRZnBrbTF0U3IxQnhhaXdJREFRQUJveU13SVRBT0JnTlYKSFE4QkFmOEVCQU1DQWdRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQQpuVzFXVXlLbVJ0TlNzU1VzVFBSUnhFRzhhek9kdjdYeUhRL0R5VWNqWm9rUEJVVHY4VjdvNG96RHgyVHV6UEdYCmZ2YlMvT2g0VDd6ZlYxdjJadmU3dTBxelNiRTl5OGpsaDNxYXJEcEd5ZmlTamwycmhIOFBmay9sZGR0VFpVL04KSkVtYW5ReGl6R20xV2pCSklRSE5LZENneVIwN3A1c0MwNnR3K25YUytla1MxMlBUTG45WjBuRDBKVDdQSzRXQgpQc3ZXeDVXN0w5dnJIdVN5SGRSTkt5eEEvbWI1WHdXMDBkZUpmaHZub0p3ZWRYNDVKZVRiME5MczUzaURqVEU1CnRpdU03Z1RVSjlCcGZTL0gvYSt2SmovVWQ2bHM0QndrWmpUNHNhOTA1bnNzdnRqamlwZ1N5a0QzVkxCQ3VueTkKd1NnbE1vSnZNWmg0bC9FVFJPeFE3Zz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
server: https://llama.org:443
name: llama-cluster
contexts:
- context:
cluster: horse-cluster
namespace: chisel-ns
user: green-user
name: federal-context
- context:
cluster: pig-cluster
namespace: saw-ns
user: black-user
name: queen-anne-context
- context:
cluster: llama-cluster
namespace: saw-ns
user: red-user
name: victorian-context
- context:
cluster: llama-cluster
namespace: saw-ns
user: elliptic-user
name: elliptic-context
kind: Config
users:
- name: blue-user
user:
token: blue-token
- name: green-user
user:
client-certificate: assets/client.crt
client-key: assets/client.key
- name: black-user
user:
token: black-token
- name: red-user
user:
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURrVENDQW5tZ0F3SUJBZ0lVTzlVTkdpbHhmSHpMbVJXcWU2dVMyRVZ1NVhVd0RRWUpLb1pJaHZjTkFRRUwKQlFBd1FURUxNQWtHQTFVRUJoTUNWVk14RURBT0JnTlZCQWdUQjFKbFpHMXZibVF4Q3pBSkJnTlZCQWNUQWxkQgpNUk13RVFZRFZRUURFd3ByZFdKbGNtNWxkR1Z6TUNBWERURTRNVEl3T0RBNU5URXdNRm9ZRHpJeE1UZ3hNVEUwCk1EazFNVEF3V2pCQk1Rc3dDUVlEVlFRR0V3SlZVekVRTUE0R0ExVUVDQk1IVW1Wa2JXOXVaREVMTUFrR0ExVUUKQnhNQ1YwRXhFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQgpEd0F3Z2dFS0FvSUJBUUM4WkVRS296SE8zOExmRzRpcHFYWnRLTzNxSENjL1Z1WjUxWnZSeWJuTnpCYU5iMk9oClM0cU1nNDF6cFdzL3dMQnk4ZndPclpoOHpHb28wbHllQXVCSlBPWFc5SmswMmhOc1Y3ZHBqYXBZWFMrSXJvN04KcUxhWDB5amQxWWllSWFlb3NtV2xib2ZpcDVzS3dzVUI3bGREeXJpQklBYTEwNlhhTng5ZG82UEh1TDNibStldwpiaWRoRTlNUFRHY2V5WW9rZ3pNbGppanordk0yUnN5Z05ncmR4VDhROC9GRER2ZndTRmZUaEZlYXIzckQvRkJGCmVYb0Y5MHp6cG1aZlphTDlyZ2NjQ0pzQ2JSZlpiellVVERRVHo2dStaUVhUdGlrbVMvQ3d2d0hXa0w3bGhQNXYKQU0yVFpETEJhQXQwOU14dGlORFNWaFFTWVR2QWpKRmU4SzJoQWdNQkFBR2pmekI5TUE0R0ExVWREd0VCL3dRRQpBd0lGb0RBZEJnTlZIU1VFRmpBVUJnZ3JCZ0VGQlFjREFRWUlLd1lCQlFVSEF3SXdEQVlEVlIwVEFRSC9CQUl3CkFEQWRCZ05WSFE0RUZnUVVuQmo0T3Y3MnFlWWJ5YjVYSHJma1pkazd6VUF3SHdZRFZSMGpCQmd3Rm9BVUxwemQKRlplYkR6N0RmZUNkZkJqNmNkUWJmNk13RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQU1CL1RDaElEbTNkeDA4SApJeXlFS2dYUHh1d3A1cTB5QkFoT0pUS1JOVXNpayt6ZVUyUGpibnV0M3B1WnBENkZoTGFKQUZPbTdsMHhkNU1ZCkFZOVloSy9wa1U3Rmw1TFg0MitxQzRqK05JdGhBbFM5clNtNkRvNlN1b3JRcjdNajErY0syTjFkZHBtRWZya3kKZXZnMm02aFB5YTFlV0hNanVCd250bkJmV2EzWHBWMWVGZVBvd21zY3R6UmRJYUh3WDM3Yit3c2JmbkppczRvTgo2bHk0THBQb2xtYld3ZTRnaEtPWXNuL3lMT3FrUGJMZVpLeU5yaWJpSGhSQTM0NUVHUGJleXNmSlNIWWFqMnBaCmE0VmRTRFhnT2JlM3Vtdk9keXVEaGl5RFJ3TFozbVJLWlpIR0lzZC9ORzRncWo3a0gzV2N2Wm5mSkVHMXY2cG0KQTZFYTJiRT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBdkdSRUNxTXh6dC9DM3h1SXFhbDJiU2p0Nmh3blAxYm1lZFdiMGNtNXpjd1dqVzlqCm9VdUtqSU9OYzZWclA4Q3djdkg4RHEyWWZNeHFLTkpjbmdMZ1NUemwxdlNaTk5vVGJGZTNhWTJxV0YwdmlLNk8KemFpMmw5TW8zZFdJbmlHbnFMSmxwVzZINHFlYkNzTEZBZTVYUThxNGdTQUd0ZE9sMmpjZlhhT2p4N2k5MjV2bgpzRzRuWVJQVEQweG5Ic21LSklNekpZNG84L3J6TmtiTW9EWUszY1UvRVBQeFF3NzM4RWhYMDRSWG1xOTZ3L3hRClJYbDZCZmRNODZabVgyV2kvYTRISEFpYkFtMFgyVzgyRkV3MEU4K3J2bVVGMDdZcEprdndzTDhCMXBDKzVZVCsKYndETmsyUXl3V2dMZFBUTWJZalEwbFlVRW1FN3dJeVJYdkN0b1FJREFRQUJBb0lCQUNCYk9EUjdndnA5QkFNOQp2Mk1rYitxZnRQMFlpTVVnTDhXTklvNE5qNVFCRVg2Sk94dGcxaEw4SlRkUG1mUUJMRTBSc3JEeXI5WC9aZHhOCkJRcytnemNROW9qTXllT0I4UVFTckxXOFZ4MkdJN3ZkL3pqaldUa0tVMktHWWtpR2p6MHlKck1iSU11VTdkUVQKVDdMZE5LKzRDYWhqejhNNjdxbGova2NlNitwSlRLdHZKR2tNSDRYUXJyMFRmU3hLMmEwUUNmNkZwSXp2OFFEMApISE9HbFJaWlBYK1dXYVA2UFpFU3JSZG1vbmFKaTJlRG03b1Y1WU1DMExWTHJzMVprZS9FT01CODZYOVlraGJUCmJsRkxRZjlNR1hTdWtGeW1MSnFSd01DUjJxZytKSXpEU1BuYzkzbjNwcHl4Nys3UkhFeXZNMFVNOVhyeHQrNHcKUkd3ZVZlRUNnWUVBeElkaFRCdW4rRk52YkVhSHZUdlpzNkVCa1lJUXUyV0NDR3dWN1lPbTZzc0hzczJBaldrVgprQ0pMajhZMTFpMHd0Y21yOG9DcE1EN0FDRWxMSWFEK3RubW5CTzlkQ2NnTzJoNXFCSkY1UytJeFlGcVZ5ZVBsCnptQkE2VzFvdzB2Y1hsYmkrVUg4SWF3WnNLaFlMaklscmdmQ3YwaU9LNzBYaEF3YzZ6Vlk4TjhDZ1lFQTlXYUUKMklkNjJEaGxLWVRaNW5yY3g1aVN3WU1jaitMTE5FYTFZMlZFNEhZZktvbjFkRnNSa2NKV1YyRVpoRGlqcVIyaQpXZEdEVldiMVVaVEpiM3BZWW1NakJFQXhFbEJXTSt0SzhialRqMWhxbXJPQWhLU0ZYUjg4RkNEanVkcStSbmo4CklyZFlWanNCdDA5RkVkT2RqdVFseGgyRVdQWDNPdVFhRXd5WnNYOENnWUJzekNtZ0VadHVqUG9kTGZxTlZ5blIKR0t3ZW1xdWFvcnBXNFVkT1l0aXdHTS9kTzRrVVAvMlErbnRzVDZXVU9SWkRQUzgwbytlRjd1Y3VieXpwcEEvKwpndUJraWdLdW5KTWtTendUNVZrS0dtR05YdmlYZU5QSzZWeG1IWXltdVVONDhvN2F3SjNOSWxKaWl2K3VLMUxTCndqY2M0QlRjditUWjFEN2FNNEZXYndLQmdHY1MyNE96VEJiYmdTb3lRZS83OVJYazhPZFU4YjlCN0VZVjJRUloKdWRkcDVlZFJNUWJoWlh6S21zZHk0bXZWK25BRElYa0dkbHA5dDFhLzN1ZnpCSUsyenpOdTN1MnBUcnZaL1kyUQpLMVJQTjkrb3U3ZDYvd1ZCSkZQMENKSzgzU1R1bGtEaXI3andhZVViNTQvNFNYcUdPNU4rUEdPOVZFMnBGNGFlCnlVTnpBb0dCQUptMU1vcFQ4N0llelBmQ010ck51cXVqQjFpdStvb1lIYm9IbGJXZUNxS3RSRnNYMWhXOWpTY1UKQ0ZRanhSVCtPcENVWndjbDdkY2xsTUlxQTRWQ1NmUUFRMW9CMU41WENRWE8xVFlXOHU1K1BRTUdUcFEvQlVEawp6WmRJZzZqQkFXd3R6YkNzKzRjVkFoclN3cDRBSXFvcndTZU1qRmdsTmNoNzgxMEF6emNJCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==
- name: elliptic-user
user:
client-certificate: assets/client.crt
client-key: assets/elliptic-client.key