diff --git a/Directory.Build.targets b/Directory.Build.targets index 2a8b1304..1c235e19 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,6 +1,11 @@ + + true + true + + diff --git a/samples/nginx-ingress/tye.yaml b/samples/nginx-ingress/tye.yaml index 41d290c1..eea27558 100644 --- a/samples/nginx-ingress/tye.yaml +++ b/samples/nginx-ingress/tye.yaml @@ -8,9 +8,7 @@ services: target: /etc/nginx/conf.d/default.conf - name: appA project: ApplicationA/ApplicationA.csproj - bindings: replicas: 2 - name: appB project: ApplicationB/ApplicationB.csproj - bindings: replicas: 2 diff --git a/src/Microsoft.Tye.Core/ApplicationFactory.cs b/src/Microsoft.Tye.Core/ApplicationFactory.cs index 6cb67e67..7d77c857 100644 --- a/src/Microsoft.Tye.Core/ApplicationFactory.cs +++ b/src/Microsoft.Tye.Core/ApplicationFactory.cs @@ -22,8 +22,7 @@ namespace Microsoft.Tye } var config = ConfigFactory.FromFile(source); - ValidateConfigApplication(config); - + config.Validate(); var builder = new ApplicationBuilder(source, config.Name ?? source.Directory.Name.ToLowerInvariant()); if (!string.IsNullOrEmpty(config.Registry)) { @@ -55,7 +54,7 @@ namespace Microsoft.Tye { var expandedProject = Environment.ExpandEnvironmentVariables(configService.Project); var projectFile = new FileInfo(Path.Combine(builder.Source.DirectoryName, expandedProject)); - var project = new ProjectServiceBuilder(configService.Name, projectFile); + var project = new ProjectServiceBuilder(configService.Name!, projectFile); service = project; project.Build = configService.Build ?? true; @@ -76,7 +75,7 @@ namespace Microsoft.Tye } else if (!string.IsNullOrEmpty(configService.Image)) { - var container = new ContainerServiceBuilder(configService.Name, configService.Image) + var container = new ContainerServiceBuilder(configService.Name!, configService.Image) { Args = configService.Args, Replicas = configService.Replicas ?? 1 @@ -95,7 +94,7 @@ namespace Microsoft.Tye workingDirectory = Path.GetDirectoryName(expandedExecutable)!; } - var executable = new ExecutableServiceBuilder(configService.Name, expandedExecutable) + var executable = new ExecutableServiceBuilder(configService.Name!, expandedExecutable) { Args = configService.Args, WorkingDirectory = configService.WorkingDirectory != null ? @@ -107,7 +106,7 @@ namespace Microsoft.Tye } else if (configService.External) { - var external = new ExternalServiceBuilder(configService.Name); + var external = new ExternalServiceBuilder(configService.Name!); service = external; } else @@ -214,7 +213,7 @@ namespace Microsoft.Tye foreach (var configIngress in config.Ingress) { - var ingress = new IngressBuilder(configIngress.Name); + var ingress = new IngressBuilder(configIngress.Name!); ingress.Replicas = configIngress.Replicas ?? 1; builder.Ingress.Add(ingress); @@ -244,84 +243,5 @@ namespace Microsoft.Tye return builder; } - - private static void ValidateConfigApplication(ConfigApplication config) - { - var context = new ValidationContext(config); - var results = new List(); - if (!Validator.TryValidateObject(config, context, results, validateAllProperties: true)) - { - throw new CommandException( - "Configuration validation failed." + Environment.NewLine + - string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); - } - - foreach (var extension in config.Extensions) - { - if (!extension.TryGetValue("name", out var name) || string.IsNullOrWhiteSpace(name as string)) - { - throw new CommandException( - "Configuration validation failed." + Environment.NewLine + - "Extensions must provide a name."); - } - } - - foreach (var service in config.Services) - { - context = new ValidationContext(service); - if (!Validator.TryValidateObject(service, context, results, validateAllProperties: true)) - { - throw new CommandException( - $"Service '{service.Name}' validation failed." + Environment.NewLine + - string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); - } - - foreach (var binding in service.Bindings) - { - context = new ValidationContext(binding); - if (!Validator.TryValidateObject(binding, context, results, validateAllProperties: true)) - { - throw new CommandException( - $"Binding '{binding.Name}' of service '{service.Name}' validation failed." + Environment.NewLine + - string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); - } - } - - foreach (var envVar in service.Configuration) - { - context = new ValidationContext(service); - if (!Validator.TryValidateObject(service, context, results, validateAllProperties: true)) - { - throw new CommandException( - $"Environment variable '{envVar.Name}' of service '{service.Name}' validation failed." + Environment.NewLine + - string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); - } - } - - foreach (var volume in service.Volumes) - { - context = new ValidationContext(service); - if (!Validator.TryValidateObject(service, context, results, validateAllProperties: true)) - { - throw new CommandException( - $"Volume '{volume.Source}' of service '{service.Name}' validation failed." + Environment.NewLine + - string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); - } - } - } - - foreach (var ingress in config.Ingress) - { - // We don't currently recurse into ingress rules or ingress bindings right now. - // There's nothing to validate there. - context = new ValidationContext(ingress); - if (!Validator.TryValidateObject(ingress, context, results, validateAllProperties: true)) - { - throw new CommandException( - $"Ingress '{ingress.Name}' validation failed." + Environment.NewLine + - string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); - } - } - } } } diff --git a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs index 0e5a77f4..1144d879 100644 --- a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs +++ b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs @@ -2,8 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; +using System.Linq; +using Tye; +using Tye.Serialization; using YamlDotNet.Serialization; namespace Microsoft.Tye.ConfigModel @@ -28,5 +33,137 @@ namespace Microsoft.Tye.ConfigModel public List Services { get; set; } = new List(); public List Ingress { get; set; } = new List(); + + public void Validate() + { + var config = this; + + var context = new ValidationContext(config); + var results = new List(); + + if (!Validator.TryValidateObject(config, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + "Configuration validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + + foreach (var extension in config.Extensions) + { + if (!extension.TryGetValue("name", out var name) || string.IsNullOrWhiteSpace(name as string)) + { + throw new TyeYamlException(CoreStrings.ExtensionMustProvideAName); + } + } + + foreach (var service in config.Services) + { + context = new ValidationContext(service); + if (!Validator.TryValidateObject(service, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + $"Service '{service.Name}' validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + + if (config.Services.Where(o => o.Name == service.Name).Count() > 1) + { + throw new TyeYamlException(CoreStrings.ServiceMustHaveUniqueNames); + } + + foreach (var binding in service.Bindings) + { + context = new ValidationContext(binding); + if (!Validator.TryValidateObject(binding, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + $"Binding '{binding.Name}' of service '{service.Name}' validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + + if (string.IsNullOrEmpty(binding.Name) && service.Bindings.Count > 1) + { + throw new TyeYamlException(CoreStrings.MultipleServiceBindingsWithoutName); + } + if (service.Bindings.Where(o => o.Name == binding.Name).Count() > 1) + { + throw new TyeYamlException(CoreStrings.MultipleServiceBindingsWithSameName); + } + } + + foreach (var envVar in service.Configuration) + { + context = new ValidationContext(service); + if (!Validator.TryValidateObject(service, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + $"Environment variable '{envVar.Name}' of service '{service.Name}' validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + } + + foreach (var volume in service.Volumes) + { + context = new ValidationContext(service); + if (!Validator.TryValidateObject(service, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + $"Volume '{volume.Source}' of service '{service.Name}' validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + } + } + + foreach (var ingress in config.Ingress) + { + context = new ValidationContext(ingress); + if (!Validator.TryValidateObject(ingress, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + $"Ingress '{ingress.Name}' validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + + foreach (var binding in ingress.Bindings) + { + context = new ValidationContext(binding); + if (!Validator.TryValidateObject(binding, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + $"Binding '{binding.Name}' of ingress '{ingress.Name}' validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + if (string.IsNullOrEmpty(binding.Name) && ingress.Bindings.Count > 1) + { + throw new TyeYamlException(CoreStrings.MultipleIngressBindingWithoutName); + } + if (ingress.Bindings.Where(o => o.Name == binding.Name).Count() > 1) + { + throw new TyeYamlException(CoreStrings.MultipleIngressBindingWithSameName); + } + if (binding.Protocol != "http" && binding.Protocol != "https" && binding.Protocol != null) + { + throw new TyeYamlException(CoreStrings.IngressBindingMustBeHttpOrHttps); + } + } + + // Make sure all ingress rules have an associated service + foreach (var rule in ingress.Rules) + { + context = new ValidationContext(rule); + if (!Validator.TryValidateObject(rule, context, results, validateAllProperties: true)) + { + throw new TyeYamlException( + $"Rule '{rule.Path}' of ingress '{ingress.Name}' validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + + if (config.Services.Where(o => o.Name == rule.Service).Count() != 1) + { + throw new TyeYamlException(CoreStrings.IngressRuleMustReferenceService); + } + } + } + } } } diff --git a/src/Microsoft.Tye.Core/ConfigModel/ConfigFactory.cs b/src/Microsoft.Tye.Core/ConfigModel/ConfigFactory.cs index 375ebc50..0fbc19d3 100644 --- a/src/Microsoft.Tye.Core/ConfigModel/ConfigFactory.cs +++ b/src/Microsoft.Tye.Core/ConfigModel/ConfigFactory.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using Microsoft.Tye.Serialization; +using Tye.Serialization; namespace Microsoft.Tye.ConfigModel { @@ -74,28 +75,8 @@ namespace Microsoft.Tye.ConfigModel private static ConfigApplication FromYaml(FileInfo file) { - var deserializer = YamlSerializer.CreateDeserializer(); - - using var reader = file.OpenText(); - var application = deserializer.Deserialize(reader); - application.Source = file; - - // Deserialization makes all collection properties null so make sure they are non-null so - // other code doesn't need to react - foreach (var service in application.Services) - { - service.Bindings ??= new List(); - service.Configuration ??= new List(); - service.Volumes ??= new List(); - } - - foreach (var ingress in application.Ingress) - { - ingress.Bindings ??= new List(); - ingress.Rules ??= new List(); - } - - return application; + using var parser = new YamlParser(file); + return parser.ParseConfigApplication(); } } } diff --git a/src/Microsoft.Tye.Core/CoreStrings.resx b/src/Microsoft.Tye.Core/CoreStrings.resx new file mode 100644 index 00000000..6c9b508d --- /dev/null +++ b/src/Microsoft.Tye.Core/CoreStrings.resx @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Excpeted scalar value for key: "{key}". + + + Excpeted yaml sequence for key: "{key}". + + + Configuration validation failed. Extensions must provide a name. + + + Ingress bindings must be http or https. + + + Ingress rules references a service that does not exist. + + + Cannot have multiple ingress bindings without names. Please specify names for each ingress binding. + + + Cannot have multiple ingress bindings with the same name. + + + Cannot have multiple service bindings without names. Please specify names for each service binding. + + + Cannot have multiple service bindings with the same name. + + + "{value}" must be a boolean value (true/false). + + + "{value}" value must be an integer. + + + "{value}" value cannot be negative. + + + Services must have unique names. + + + Unexpected node type in tye.yaml. Expected "{expected}" but got "{actual}". + + + Unexpected key "{key}" in tye.yaml. + + \ No newline at end of file diff --git a/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj b/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj index 4fd4e6ea..76d0da6a 100644 --- a/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj +++ b/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj @@ -29,6 +29,11 @@ + + + + + diff --git a/src/Microsoft.Tye.Core/Properties/AssemblyInfo.cs b/src/Microsoft.Tye.Core/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..b27e7aca --- /dev/null +++ b/src/Microsoft.Tye.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Tye.UnitTests, PublicKey=002400000480000094000000060200000024000052534131000400000100010007d1fa57c4aed9f0a32e84aa0faefd0de9e8fd6aec8f87fb03766c834c99921eb23be79ad9d5dcc1dd9ad236132102900b723cf980957fc4e177108fc607774f29e8320e92ea05ece4e821c0a5efe8f1645c4c0c93c1ab99285d622caa652c1dfad63d745d6f2de5f17e5eaf0fc4963d261c8a12436518206dc093344d5ad293")] diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs new file mode 100644 index 00000000..601e3267 --- /dev/null +++ b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Tye.ConfigModel; +using YamlDotNet.RepresentationModel; + +namespace Tye.Serialization +{ + public static class ConfigApplicationParser + { + public static void HandleConfigApplication(YamlMappingNode yamlMappingNode, ConfigApplication app) + { + foreach (var child in yamlMappingNode.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + app.Name = YamlParser.GetScalarValue(key, child.Value); + break; + case "registry": + app.Registry = YamlParser.GetScalarValue(key, child.Value); + break; + case "ingress": + YamlParser.ThrowIfNotYamlSequence(key, child.Value); + ConfigIngressParser.HandleIngress((child.Value as YamlSequenceNode)!, app.Ingress); + break; + case "services": + YamlParser.ThrowIfNotYamlSequence(key, child.Value); + ConfigServiceParser.HandleServiceMapping((child.Value as YamlSequenceNode)!, app.Services); + break; + case "extensions": + YamlParser.ThrowIfNotYamlSequence(key, child.Value); + ConfigExtensionsParser.HandleExtensionsMapping((child.Value as YamlSequenceNode)!, app.Extensions); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + } +} diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigExtensionsParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigExtensionsParser.cs new file mode 100644 index 00000000..27d0f449 --- /dev/null +++ b/src/Microsoft.Tye.Core/Serialization/ConfigExtensionsParser.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using YamlDotNet.RepresentationModel; + +namespace Tye.Serialization +{ + public static class ConfigExtensionsParser + { + public static void HandleExtensionsMapping(YamlSequenceNode yamlSequenceNode, List> extensions) + { + foreach (var child in yamlSequenceNode.Children) + { + switch (child.NodeType) + { + case YamlNodeType.Mapping: + var extensionDictionary = new Dictionary(); + foreach (var mapping in (YamlMappingNode)child) + { + var key = YamlParser.GetScalarValue(mapping.Key); + extensionDictionary[key] = YamlParser.GetScalarValue(key, mapping.Value)!; + } + + extensions.Add(extensionDictionary); + break; + default: + throw new TyeYamlException(child.Start, + CoreStrings.FormatUnexpectedType(YamlNodeType.Mapping.ToString(), child.NodeType.ToString())); + } + } + } + } +} diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigIngressParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigIngressParser.cs new file mode 100644 index 00000000..6ccc391c --- /dev/null +++ b/src/Microsoft.Tye.Core/Serialization/ConfigIngressParser.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.Tye.ConfigModel; +using YamlDotNet.RepresentationModel; + +namespace Tye.Serialization +{ + public static class ConfigIngressParser + { + public static void HandleIngress(YamlSequenceNode yamlSequenceNode, List ingress) + { + foreach (var child in yamlSequenceNode.Children) + { + YamlParser.ThrowIfNotYamlMapping(child); + var configIngress = new ConfigIngress(); + HandleIngressMapping((YamlMappingNode)child, configIngress); + ingress.Add(configIngress); + } + } + + private static void HandleIngressMapping(YamlMappingNode yamlMappingNode, ConfigIngress configIngress) + { + foreach (var child in yamlMappingNode!.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + configIngress.Name = YamlParser.GetScalarValue(key, child.Value); + break; + case "replicas": + if (!int.TryParse(YamlParser.GetScalarValue(key, child.Value), out var replicas)) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeAnInteger(key)); + } + + if (replicas < 0) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBePositive(key)); + } + + configIngress.Replicas = replicas; + break; + case "rules": + if (child.Value.NodeType != YamlNodeType.Sequence) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatExpectedYamlSequence(key)); + } + HandleIngressRules((child.Value as YamlSequenceNode)!, configIngress.Rules); + break; + case "bindings": + if (child.Value.NodeType != YamlNodeType.Sequence) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatExpectedYamlSequence(key)); + } + HandleIngressBindings((child.Value as YamlSequenceNode)!, configIngress.Bindings); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + + private static void HandleIngressRules(YamlSequenceNode yamlSequenceNode, List rules) + { + foreach (var child in yamlSequenceNode.Children) + { + YamlParser.ThrowIfNotYamlMapping(child); + var rule = new ConfigIngressRule(); + HandleIngressRuleMapping((YamlMappingNode)child, rule); + rules.Add(rule); + } + } + + private static void HandleIngressRuleMapping(YamlMappingNode yamlMappingNode, ConfigIngressRule rule) + { + foreach (var child in yamlMappingNode!.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "host": + rule.Host = YamlParser.GetScalarValue(key, child.Value); + break; + case "path": + rule.Path = YamlParser.GetScalarValue(key, child.Value); + break; + case "service": + rule.Service = YamlParser.GetScalarValue(key, child.Value); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + + private static void HandleIngressBindings(YamlSequenceNode yamlSequenceNode, List bindings) + { + foreach (var child in yamlSequenceNode.Children) + { + YamlParser.ThrowIfNotYamlMapping(child); + var binding = new ConfigIngressBinding(); + HandleIngressBindingMapping((YamlMappingNode)child, binding); + bindings.Add(binding); + } + } + + private static void HandleIngressBindingMapping(YamlMappingNode yamlMappingNode, ConfigIngressBinding binding) + { + foreach (var child in yamlMappingNode!.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + binding.Name = YamlParser.GetScalarValue(key, child.Value); + break; + case "port": + if (!int.TryParse(YamlParser.GetScalarValue(key, child.Value), out var port)) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeAnInteger(key)); + } + + binding.Port = port; + break; + case "protocol": + binding.Protocol = YamlParser.GetScalarValue(key, child.Value); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + } +} diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigServiceParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigServiceParser.cs new file mode 100644 index 00000000..6dd43491 --- /dev/null +++ b/src/Microsoft.Tye.Core/Serialization/ConfigServiceParser.cs @@ -0,0 +1,225 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.Tye.ConfigModel; +using YamlDotNet.RepresentationModel; + +namespace Tye.Serialization +{ + public static class ConfigServiceParser + { + public static void HandleServiceMapping(YamlSequenceNode yamlSequenceNode, List services) + { + foreach (var child in yamlSequenceNode.Children) + { + YamlParser.ThrowIfNotYamlMapping(child); + var service = new ConfigService(); + HandleServiceNameMapping((YamlMappingNode)child, service); + services.Add(service); + } + } + + private static void HandleServiceNameMapping(YamlMappingNode yamlMappingNode, ConfigService service) + { + foreach (var child in yamlMappingNode!.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + service.Name = YamlParser.GetScalarValue(key, child.Value); + break; + case "external": + if (!bool.TryParse(YamlParser.GetScalarValue(key, child.Value), out var external)) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeABoolean(key)); + } + service.External = external; + break; + case "image": + service.Image = YamlParser.GetScalarValue(key, child.Value); + break; + case "project": + service.Project = YamlParser.GetScalarValue(key, child.Value); + break; + case "build": + if (!bool.TryParse(YamlParser.GetScalarValue(key, child.Value), out var build)) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeABoolean(key)); + } + service.Build = build; + break; + case "executable": + service.Executable = YamlParser.GetScalarValue(key, child.Value); + break; + case "workingDirectory": + service.WorkingDirectory = YamlParser.GetScalarValue(key, child.Value); + break; + case "args": + service.Args = YamlParser.GetScalarValue(key, child.Value); + break; + case "replicas": + if (!int.TryParse(YamlParser.GetScalarValue(key, child.Value), out var replicas)) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeAnInteger(key)); + } + + if (replicas < 0) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBePositive(key)); + } + + service.Replicas = replicas; + break; + case "bindings": + if (child.Value.NodeType != YamlNodeType.Sequence) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatExpectedYamlSequence(key)); + } + + HandleServiceBindings((child.Value as YamlSequenceNode)!, service.Bindings); + break; + case "volumes": + if (child.Value.NodeType != YamlNodeType.Sequence) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatExpectedYamlSequence(key)); + } + + HandleServiceVolumes((child.Value as YamlSequenceNode)!, service.Volumes); + break; + case "env": + case "configuration": + if (child.Value.NodeType != YamlNodeType.Sequence) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatExpectedYamlSequence(key)); + } + HandleServiceConfiguration((child.Value as YamlSequenceNode)!, service.Configuration); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + + private static void HandleServiceBindings(YamlSequenceNode yamlSequenceNode, List bindings) + { + foreach (var child in yamlSequenceNode.Children) + { + YamlParser.ThrowIfNotYamlMapping(child); + var binding = new ConfigServiceBinding(); + HandleServiceBindingNameMapping((YamlMappingNode)child, binding); + bindings.Add(binding); + } + } + + private static void HandleServiceBindingNameMapping(YamlMappingNode yamlMappingNode, ConfigServiceBinding binding) + { + foreach (var child in yamlMappingNode.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + binding.Name = YamlParser.GetScalarValue(key, child.Value); + break; + case "connectionString": + binding.ConnectionString = YamlParser.GetScalarValue(key, child.Value); + break; + case "port": + if (!int.TryParse(YamlParser.GetScalarValue(key, child.Value), out var port)) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeAnInteger(key)); + } + + binding.Port = port; + break; + case "containerPort": + if (!int.TryParse(YamlParser.GetScalarValue(key, child.Value), out var containerPort)) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeAnInteger(key)); + } + + binding.ContainerPort = containerPort; + break; + case "host": + binding.Host = YamlParser.GetScalarValue(key, child.Value); + break; + case "protocol": + binding.Protocol = YamlParser.GetScalarValue(key, child.Value); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + + private static void HandleServiceVolumes(YamlSequenceNode yamlSequenceNode, List volumes) + { + foreach (var child in yamlSequenceNode.Children) + { + YamlParser.ThrowIfNotYamlMapping(child); + var volume = new ConfigVolume(); + HandleServiceVolumeNameMapping((YamlMappingNode)child, volume); + volumes.Add(volume); + } + } + + private static void HandleServiceVolumeNameMapping(YamlMappingNode yamlMappingNode, ConfigVolume volume) + { + foreach (var child in yamlMappingNode!.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + volume.Name = YamlParser.GetScalarValue(key, child.Value); + break; + case "source": + volume.Source = YamlParser.GetScalarValue(key, child.Value); + break; + case "target": + volume.Target = YamlParser.GetScalarValue(key, child.Value); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + + private static void HandleServiceConfiguration(YamlSequenceNode yamlSequenceNode, List configuration) + { + foreach (var child in yamlSequenceNode.Children) + { + YamlParser.ThrowIfNotYamlMapping(child); + var config = new ConfigConfigurationSource(); + HandleServiceConfigurationNameMapping((YamlMappingNode)child, config); + configuration.Add(config); + } + } + + private static void HandleServiceConfigurationNameMapping(YamlMappingNode yamlMappingNode, ConfigConfigurationSource config) + { + foreach (var child in yamlMappingNode!.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + config.Name = YamlParser.GetScalarValue(key, child.Value); + break; + case "value": + config.Value = YamlParser.GetScalarValue(key, child.Value); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + } + } +} diff --git a/src/Microsoft.Tye.Core/Serialization/TyeYamlException.cs b/src/Microsoft.Tye.Core/Serialization/TyeYamlException.cs new file mode 100644 index 00000000..e67575be --- /dev/null +++ b/src/Microsoft.Tye.Core/Serialization/TyeYamlException.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using YamlDotNet.Core; + +namespace Tye.Serialization +{ + public class TyeYamlException : Exception + { + public TyeYamlException(string message) + : base(message) + { + } + + public TyeYamlException(Mark start, string message) + : this(start, message, null) + { + } + + public TyeYamlException(Mark start, string message, Exception? innerException) + : base($"Error parsing tye.yaml: ({start.Line}, {start.Column}): {message}", innerException) + { + } + + public TyeYamlException(string message, Exception? inner) + : base(message, inner) + { + } + } +} diff --git a/src/Microsoft.Tye.Core/Serialization/YamlParser.cs b/src/Microsoft.Tye.Core/Serialization/YamlParser.cs new file mode 100644 index 00000000..78b22470 --- /dev/null +++ b/src/Microsoft.Tye.Core/Serialization/YamlParser.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Tye.ConfigModel; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Tye.Serialization +{ + public class YamlParser : IDisposable + { + private YamlStream _yamlStream; + private FileInfo? _fileInfo; + private TextReader _reader; + + public YamlParser(string yamlContent) + : this(new StringReader(yamlContent)) + { + } + + public YamlParser(FileInfo fileInfo) + : this(fileInfo.OpenText()) + { + _fileInfo = fileInfo; + } + + internal YamlParser(TextReader reader) + { + _reader = reader; + _yamlStream = new YamlStream(); + } + + public ConfigApplication ParseConfigApplication() + { + try + { + _yamlStream.Load(_reader); + } + catch (YamlException ex) + { + throw new TyeYamlException(ex.Start, "Unable to parse tye.yaml. See inner exception.", ex); + } + + var app = new ConfigApplication(); + + // TODO assuming first document. + var document = _yamlStream.Documents[0]; + var node = document.RootNode; + ThrowIfNotYamlMapping(node); + ConfigApplicationParser.HandleConfigApplication((YamlMappingNode)node, app); + + app.Source = _fileInfo!; + + // TODO confirm if these are ever null. + foreach (var service in app.Services) + { + service.Bindings ??= new List(); + service.Configuration ??= new List(); + service.Volumes ??= new List(); + } + + foreach (var ingress in app.Ingress) + { + ingress.Bindings ??= new List(); + ingress.Rules ??= new List(); + } + + return app; + } + + public static string GetScalarValue(YamlNode node) + { + if (node.NodeType != YamlNodeType.Scalar) + { + throw new TyeYamlException(node.Start, + CoreStrings.FormatUnexpectedType(YamlNodeType.Scalar.ToString(), node.NodeType.ToString())); + } + + return ((YamlScalarNode)node).Value!; + } + + public static string GetScalarValue(string key, YamlNode node) + { + if (node.NodeType != YamlNodeType.Scalar) + { + throw new TyeYamlException(node.Start, CoreStrings.FormatExpectedYamlScalar(key)); + } + + return ((YamlScalarNode)node).Value!; + } + + public static void ThrowIfNotYamlSequence(string key, YamlNode node) + { + if (node.NodeType != YamlNodeType.Sequence) + { + throw new TyeYamlException(node.Start, CoreStrings.FormatExpectedYamlSequence(key)); + } + } + + public static void ThrowIfNotYamlMapping(YamlNode node) + { + if (node.NodeType != YamlNodeType.Mapping) + { + throw new TyeYamlException(node.Start, + CoreStrings.FormatUnexpectedType(YamlNodeType.Mapping.ToString(), node.NodeType.ToString())); + } + } + + public void Dispose() + { + _reader.Dispose(); + } + } +} diff --git a/src/Microsoft.Tye.Core/Serialization/YamlSerializer.cs b/src/Microsoft.Tye.Core/Serialization/YamlSerializer.cs index bc331ae2..4fc55be5 100644 --- a/src/Microsoft.Tye.Core/Serialization/YamlSerializer.cs +++ b/src/Microsoft.Tye.Core/Serialization/YamlSerializer.cs @@ -17,12 +17,5 @@ namespace Microsoft.Tye.Serialization .WithEmissionPhaseObjectGraphVisitor(args => new OmitDefaultAndEmptyArrayObjectGraphVisitor(args.InnerVisitor)) .Build(); } - - public static IDeserializer CreateDeserializer() - { - return new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - } } } diff --git a/src/Microsoft.Tye.Hosting/Dashboard/Pages/_Host.cshtml b/src/Microsoft.Tye.Hosting/Dashboard/Pages/_Host.cshtml index e48ab330..d36ebbef 100644 --- a/src/Microsoft.Tye.Hosting/Dashboard/Pages/_Host.cshtml +++ b/src/Microsoft.Tye.Hosting/Dashboard/Pages/_Host.cshtml @@ -1,4 +1,5 @@ @page "/" + @namespace Microsoft.Tye.Hosting.Dashboard.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @{ @@ -17,7 +18,7 @@ - +
diff --git a/test/UnitTests/Microsoft.Tye.UnitTests.csproj b/test/UnitTests/Microsoft.Tye.UnitTests.csproj new file mode 100644 index 00000000..de1c9b8e --- /dev/null +++ b/test/UnitTests/Microsoft.Tye.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + netcoreapp3.1 + Microsoft.Tye.UnitTests + true + true + false + XUnit + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/UnitTests/TyeDeserializationTests.cs b/test/UnitTests/TyeDeserializationTests.cs new file mode 100644 index 00000000..fce81b84 --- /dev/null +++ b/test/UnitTests/TyeDeserializationTests.cs @@ -0,0 +1,582 @@ +using System.IO; +using System.Linq; +using Microsoft.Tye.ConfigModel; +using Tye; +using Tye.Serialization; +using Xunit; +using YamlDotNet.RepresentationModel; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Microsoft.Tye.UnitTests +{ + public class TyeDeserializationTests + { + private IDeserializer _deserializer; + + public TyeDeserializationTests() + { + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + } + + [Fact] + public void ComprehensionalTest() + { + var input = @" +name: apps-with-ingress +registry: myregistry +extensions: + - name: dapr +ingress: + - name: ingress + bindings: + - port: 8080 + protocol: http + name: foo + rules: + - path: /A + service: appA + - path: /B + service: appB + - host: a.example.com + service: appA + - host: b.example.com + service: appB + replicas: 2 +services: + - name: appA + project: ApplicationA/ApplicationA.csproj + replicas: 2 + external: false + image: abc + build: false + executable: test.exe + workingDirectory: ApplicationA/ + args: a b c + env: + - name: POSTGRES_PASSWORD + value: ""test"" + - name: POSTGRES_PASSWORD2 + value: ""test2"" + volumes: + - name: volume + source: /data + target: /data + bindings: + - name: test + port: 4444 + connectionString: asdf + containerPort: 80 + host: localhost + protocol: http + - name: appB + project: ApplicationB/ApplicationB.csproj + replicas: 2"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + } + + [Fact] + public void IngressIsSetCorrectly() + { + var input = @" +ingress: + - name: ingress + bindings: + - port: 8080 + protocol: http + name: foo + rules: + - path: /A + service: appA + - path: /B + service: appB + - host: a.example.com + service: appA + - host: b.example.com + service: appB + replicas: 2"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + + var expected = _deserializer.Deserialize(new StringReader(input)); + + foreach (var ingress in app.Ingress) + { + var otherIngress = expected + .Ingress + .Where(o => o.Name == ingress.Name) + .Single(); + Assert.NotNull(otherIngress); + Assert.Equal(otherIngress.Replicas, ingress.Replicas); + + foreach (var rule in ingress.Rules) + { + var otherRule = otherIngress + .Rules + .Where(o => o.Path == rule.Path && o.Host == rule.Host && o.Service == rule.Service) + .Single(); + Assert.NotNull(otherRule); + } + + foreach (var binding in ingress.Bindings) + { + var otherBinding = otherIngress + .Bindings + .Where(o => o.Name == binding.Name && o.Port == binding.Port && o.Protocol == binding.Protocol) + .Single(); + + Assert.NotNull(otherBinding); + } + } + } + + [Fact] + public void ServicesSetCorrectly() + { + var input = @"services: + - name: appA + project: ApplicationA/ApplicationA.csproj + replicas: 2 + external: false + image: abc + build: false + executable: test.exe + workingDirectory: ApplicationA/ + args: a b c + env: + - name: POSTGRES_PASSWORD + value: ""test"" + - name: POSTGRES_PASSWORD2 + value: ""test2"" + volumes: + - name: volume + source: /data + target: /data + bindings: + - name: test + port: 4444 + connectionString: asdf + containerPort: 80 + host: localhost + protocol: http + - name: appB + project: ApplicationB/ApplicationB.csproj + replicas: 2"; + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + + var expected = _deserializer.Deserialize(new StringReader(input)); + + foreach (var service in app.Services) + { + var otherService = expected + .Services + .Where(o => o.Name == service.Name) + .Single(); + Assert.NotNull(otherService); + Assert.Equal(otherService.Args, service.Args); + Assert.Equal(otherService.Build, service.Build); + Assert.Equal(otherService.Executable, service.Executable); + Assert.Equal(otherService.External, service.External); + Assert.Equal(otherService.Image, service.Image); + Assert.Equal(otherService.Project, service.Project); + Assert.Equal(otherService.Replicas, service.Replicas); + Assert.Equal(otherService.WorkingDirectory, service.WorkingDirectory); + + foreach (var binding in service.Bindings) + { + var otherBinding = otherService.Bindings + .Where(o => o.Name == binding.Name + && o.Port == binding.Port + && o.Protocol == binding.Protocol + && o.ConnectionString == binding.ConnectionString + && o.ContainerPort == binding.ContainerPort + && o.Host == binding.Host) + .Single(); + + Assert.NotNull(otherBinding); + } + + foreach (var binding in service.Bindings) + { + var otherBinding = otherService.Bindings + .Where(o => o.Name == binding.Name + && o.Port == binding.Port + && o.Protocol == binding.Protocol + && o.ConnectionString == binding.ConnectionString + && o.ContainerPort == binding.ContainerPort + && o.Host == binding.Host) + .Single(); + + Assert.NotNull(otherBinding); + } + + foreach (var config in service.Configuration) + { + var otherConfig = otherService.Configuration + .Where(o => o.Name == config.Name + && o.Value == config.Value) + .Single(); + + Assert.NotNull(otherConfig); + } + + foreach (var volume in service.Volumes) + { + var otherVolume = otherService.Volumes + .Where(o => o.Name == volume.Name + && o.Target == volume.Target + && o.Source == volume.Source) + .Single(); + Assert.NotNull(otherVolume); + } + } + } + + [Fact] + public void ExtensionsTest() + { + var input = @" +extensions: + - name: dapr"; + using var parser = new YamlParser(input); + + var app = parser.ParseConfigApplication(); + + Assert.Equal("dapr", app.Extensions.Single()["name"]); + + var expected = _deserializer.Deserialize(new StringReader(input)); + + Assert.Equal(expected.Extensions.Count, app.Extensions.Count); + } + + [Fact] + public void VotingTest() + { + using var parser = new YamlParser( +@"name: VotingSample +registry: myregistry +services: +- name: vote + project: vote/vote.csproj +- name: redis + image: redis + bindings: + - port: 6379 +- name: worker + project: worker/worker.csproj +- name: postgres + image: postgres + env: + - name: POSTGRES_PASSWORD + value: ""test"" + bindings: + - port: 5432 +- name: results + project: results/results.csproj"); + var app = parser.ParseConfigApplication(); + } + + + [Fact] + public void UnrecognizedConfigApplicationField_ThrowException() + { + using var parser = new YamlParser("asdf: 123"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnrecognizedKey("asdf"), exception.Message); + } + + [Fact] + public void Replicas_MustBeInteger() + { + using var parser = new YamlParser( +@"services: +- name: app + replicas: asdf"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatMustBeAnInteger("replicas"), exception.Message); + } + + [Fact] + public void Replicas_MustBePositive() + { + using var parser = new YamlParser( +@"services: +- name: app + replicas: -1"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatMustBePositive("replicas"), exception.Message); + } + + [Fact] + public void Name_MustBeScalar() + { + using var parser = new YamlParser( +@"name: +- a: b"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlScalar("name"), exception.Message); + } + + + [Fact] + public void YamlIsCaseSensitive() + { + using var parser = new YamlParser( +@"Name: abc"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnrecognizedKey("Name"), exception.Message); + } + + [Fact] + public void Registry_MustBeScalar() + { + using var parser = new YamlParser( +@"registry: +- a: b"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlScalar("registry"), exception.Message); + } + + [Fact] + public void Ingress_MustBeSequence() + { + using var parser = new YamlParser( +@"ingress: a"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("ingress"), exception.Message); + } + + [Fact] + public void Services_MustBeSequence() + { + using var parser = new YamlParser( +@"services: a"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("services"), exception.Message); + } + + [Fact] + public void ConfigApplication_MustBeMappings() + { + using var parser = new YamlParser( +@"- name: app + replicas: -1"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnexpectedType(YamlNodeType.Mapping.ToString(), YamlNodeType.Sequence.ToString()), exception.Message); + } + + [Fact] + public void Services_MustBeMappings() + { + using var parser = new YamlParser( +@"services: + - name"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnexpectedType(YamlNodeType.Mapping.ToString(), YamlNodeType.Scalar.ToString()), exception.Message); + } + + [Fact] + public void Ingress_MustBeMappings() + { + using var parser = new YamlParser( +@"ingress: + - name"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnexpectedType(YamlNodeType.Mapping.ToString(), YamlNodeType.Scalar.ToString()), exception.Message); + } + + [Fact] + public void Ingress_Replicas_MustBeInteger() + { + using var parser = new YamlParser( +@"ingress: + - replicas: asdf"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatMustBeAnInteger("replicas"), exception.Message); + } + + [Fact] + public void Ingress_Replicas_MustBePositive() + { + using var parser = new YamlParser( +@"ingress: + - replicas: -1"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatMustBePositive("replicas"), exception.Message); + } + + [Fact] + public void Ingress_UnrecognizedKey() + { + using var parser = new YamlParser( +@"ingress: + - abc: abc"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnrecognizedKey("abc"), exception.Message); + } + + [Fact] + public void Ingress_Rules_MustSequence() + { + using var parser = new YamlParser( +@"ingress: + - rules: abc"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("rules"), exception.Message); + } + + [Fact] + public void Ingress_Rules_MustBeMappings() + { + using var parser = new YamlParser( +@"ingress: + - rules: + - abc"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnexpectedType(YamlNodeType.Mapping.ToString(), YamlNodeType.Scalar.ToString()), exception.Message); + } + + [Fact] + public void Ingress_Bindings_MustBeMappings() + { + using var parser = new YamlParser( +@"ingress: + - bindings: + - abc"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnexpectedType(YamlNodeType.Mapping.ToString(), YamlNodeType.Scalar.ToString()), exception.Message); + } + + [Fact] + public void Ingress_RulesMapping_UnrecognizedKey() + { + using var parser = new YamlParser( +@"ingress: + - rules: + - abc: 123"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnrecognizedKey("abc"), exception.Message); + } + + [Fact] + public void Ingress_Bindings_MustSequence() + { + using var parser = new YamlParser( +@"ingress: + - bindings: abc"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("bindings"), exception.Message); + } + + [Fact] + public void Ingress_Bindings_Port_MustBeInteger() + { + using var parser = new YamlParser( +@"ingress: + - name: ingress + bindings: + - port: abc + protocol: http + name: foo"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatMustBeAnInteger("port"), exception.Message); + } + + [Fact] + public void Ingress_Bindings_UnrecognizedKey() + { + using var parser = new YamlParser( +@"ingress: + - name: ingress + bindings: + - abc: abc"); + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatUnrecognizedKey("abc"), exception.Message); + } + + [Fact] + public void Services_External_MustBeBool() + { + using var parser = new YamlParser( +@"services: + - name: ingress + external: abc"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatMustBeABoolean("external"), exception.Message); + } + + [Fact] + public void Services_Build_MustBeBool() + { + using var parser = new YamlParser( +@"services: + - name: ingress + build: abc"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatMustBeABoolean("build"), exception.Message); + } + + [Fact] + public void Services_Bindings_MustBeSequence() + { + using var parser = new YamlParser( +@"services: + - name: ingress + bindings: abc"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("bindings"), exception.Message); + } + + + [Fact] + public void Services_Volumes_MustBeSequence() + { + using var parser = new YamlParser( +@"services: + - name: ingress + volumes: abc"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("volumes"), exception.Message); + } + + [Fact] + public void Services_Env_MustBeSequence() + { + using var parser = new YamlParser( +@"services: + - name: ingress + env: abc"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("env"), exception.Message); + } + + [Fact] + public void Services_UnrecognizedKey() + { + using var parser = new YamlParser( +@"services: + - name: ingress + env: abc"); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatExpectedYamlSequence("env"), exception.Message); + } + } +} diff --git a/test/UnitTests/TyeDeserializationValidationTests.cs b/test/UnitTests/TyeDeserializationValidationTests.cs new file mode 100644 index 00000000..b246bc89 --- /dev/null +++ b/test/UnitTests/TyeDeserializationValidationTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Tye; +using Tye.Serialization; +using Xunit; + +namespace Microsoft.Tye.UnitTests +{ + public class TyeDeserializationValidationTests + { + [Fact] + public void MultipleIngressBindingsMustHaveNames() + { + var input = @" +ingress: + - name: ingress + bindings: + - port: 8080 + protocol: http + - port: 8080 + protocol: http"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + var exception = Assert.Throws(() => app.Validate()); + Assert.Contains(CoreStrings.MultipleIngressBindingWithoutName, exception.Message); + } + + [Fact] + public void MultipleServicesBindingsMustHaveNames() + { + var input = @" +services: + - name: app + bindings: + - port: 8080 + protocol: http + - port: 8080 + protocol: http"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + var exception = Assert.Throws(() => app.Validate()); + Assert.Contains(CoreStrings.MultipleServiceBindingsWithoutName, exception.Message); + } + + [Fact] + public void MultipleIngressBindingsMustUniqueNames() + { + var input = @" +ingress: + - name: ingress + bindings: + - port: 8080 + protocol: http + name: a + - port: 8080 + protocol: http + name: a"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + var exception = Assert.Throws(() => app.Validate()); + Assert.Contains(CoreStrings.MultipleIngressBindingWithSameName, exception.Message); + } + + + [Fact] + public void IngressProtocolsShouldBeHttpOrHttps() + { + var input = @" +ingress: + - name: ingress + bindings: + - port: 8080 + protocol: tls + name: a"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + var exception = Assert.Throws(() => app.Validate()); + Assert.Contains(CoreStrings.IngressBindingMustBeHttpOrHttps, exception.Message); + } + + [Fact] + public void MultipleServicesBindingsMustUniqueNames() + { + var input = @" +services: + - name: app + bindings: + - port: 8080 + protocol: http + name: a + - port: 8080 + protocol: http + name: a"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + var exception = Assert.Throws(() => app.Validate()); + Assert.Contains(CoreStrings.MultipleServiceBindingsWithSameName, exception.Message); + } + + [Fact] + public void IngressMustReferenceService() + { + var input = @" +ingress: + - name: ingress + bindings: + - port: 8080 + protocol: http + name: foo + rules: + - path: /A + service: appA + - path: /B + service: appB + - host: a.example.com + service: appA + - host: b.example.com + service: appB + replicas: 2"; + + using var parser = new YamlParser(input); + var app = parser.ParseConfigApplication(); + var exception = Assert.Throws(() => app.Validate()); + Assert.Contains(CoreStrings.IngressRuleMustReferenceService, exception.Message); + } + } +} diff --git a/tye.sln b/tye.sln index 572423bc..847ab0f0 100644 --- a/tye.sln +++ b/tye.sln @@ -19,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Tye.Hosting.Runti EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Tye.Core", "src\Microsoft.Tye.Core\Microsoft.Tye.Core.csproj", "{D0359C69-6EA9-4B03-9455-90E8E04F1CB0}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Tye.UnitTests", "test\UnitTests\Microsoft.Tye.UnitTests.csproj", "{2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Tye.Extensions", "src\Microsoft.Tye.Extensions\Microsoft.Tye.Extensions.csproj", "{AAF0CE0B-E53A-4E10-AA82-BF7200AB2B0C}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Tye.Extensions.Configuration", "src\Microsoft.Tye.Extensions.Configuration\Microsoft.Tye.Extensions.Configuration.csproj", "{B07394E4-30A7-429A-BC5A-747B54D5A447}" @@ -143,6 +145,18 @@ Global {7C9021B7-64BA-4DA9-88DA-5BC12A1C6233}.Release|x64.Build.0 = Release|Any CPU {7C9021B7-64BA-4DA9-88DA-5BC12A1C6233}.Release|x86.ActiveCfg = Release|Any CPU {7C9021B7-64BA-4DA9-88DA-5BC12A1C6233}.Release|x86.Build.0 = Release|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Debug|x64.ActiveCfg = Debug|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Debug|x64.Build.0 = Debug|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Debug|x86.ActiveCfg = Debug|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Debug|x86.Build.0 = Debug|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Release|Any CPU.Build.0 = Release|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Release|x64.ActiveCfg = Release|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Release|x64.Build.0 = Release|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Release|x86.ActiveCfg = Release|Any CPU + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -157,6 +171,7 @@ Global {AAF0CE0B-E53A-4E10-AA82-BF7200AB2B0C} = {8C662D59-A3CB-466F-8E85-A8E6BA5E7601} {B07394E4-30A7-429A-BC5A-747B54D5A447} = {8C662D59-A3CB-466F-8E85-A8E6BA5E7601} {7C9021B7-64BA-4DA9-88DA-5BC12A1C6233} = {8C662D59-A3CB-466F-8E85-A8E6BA5E7601} + {2233F4A8-10F9-40A6-BFD3-8D0C37F8359A} = {F19B02EB-A372-417A-B2C2-EA0D5A3C76D5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D8002603-BB27-4500-BF86-274A8E72D302}