diff --git a/docs/reference/schema.md b/docs/reference/schema.md index 6b700697..c089da69 100644 --- a/docs/reference/schema.md +++ b/docs/reference/schema.md @@ -184,10 +184,14 @@ Including `external: true` marks the service as *external*: External services are useful to provide bindings without any run or deployment behavior. -#### `env` (`EnvironmentVariable[]`) +#### `env` (`EnvironmentVariable[] | string[]`) A list of environment variable mappings for the service. Does not apply when the service is external. +#### `env_file` (`string[]`) + +A list of files from which environment variables are taken. Does not apply when the service is external. + #### `args` (string) Command-line arguments to use when launching the service. Does not apply when the service is external. @@ -256,6 +260,55 @@ The name of the environment variable. The value of the environment variable. +Environment variables can also be provided using a compact syntax (similar to that of [docker-compose](https://docs.docker.com/compose/environment-variables/)). + +### Environment Variable Compact Syntax Example + +```yaml +name: myapplication +services: +- name: backend + project: backend/backend.csproj + + # environment variables appear here + env: + - SOME_KEY=SOME_VALUE + - SOME_KEY2="SOME VALUE" + - SOME_KEY3 +``` + +Using the compact syntax, you provide environment variable name and value via a single string, separated by a `=` sign. + +In the absence of an `=` sign, the value of the environment variable will be taken from the operating system/shell. + +## Environment Variables Files + +`string` elements appear in a list inside the `env_file` property of a `Service`. + +These strings reference [`.env` files](https://docs.docker.com/compose/env-file/) from which the environment variables will be injected. + +### Environment Variables File Example + +```yaml +name: myapplication +services: +- name: backend + project: backend/backend.csproj + + # environment variables files appear here + env_file: + - ./envfile_a.env + - ./envfile_b.env +``` + +### .env File Example + +``` +SOME_KEY=SOME_VALUE +# This line is ignored because it start with '#' +SOME_KEY2="SOME VALUE" +``` + ## Build Properties Configuration that can be specified when building a project. These will be passed in as MSBuild properties when building a project. It appears in the list `buildProperties` of a `Service`. diff --git a/src/Microsoft.Tye.Core/CoreStrings.resx b/src/Microsoft.Tye.Core/CoreStrings.resx index 4d85da23..350964f0 100644 --- a/src/Microsoft.Tye.Core/CoreStrings.resx +++ b/src/Microsoft.Tye.Core/CoreStrings.resx @@ -141,12 +141,12 @@ Cannot have multiple {0} bindings with the same port. - - A prober must be configured for the {0} probe. - - - "successThreshold" for {0} probe must be set to "1". - + + A prober must be configured for the {0} probe. + + + "successThreshold" for {0} probe must be set to "1". + "{value}" must be a boolean value (true/false). @@ -156,9 +156,9 @@ "{value}" value cannot be negative. - - "{value}" value must be greater than zero. - + + "{value}" value must be greater than zero. + Cannot have both "{0}" and "{1}" set for a service. Only one of project, image, and executable can be set for a given service. @@ -171,4 +171,13 @@ Unexpected key "{key}" in tye.yaml. - + + Unexpected node type in tye.yaml. Expected one of ({expected}) but got "{actual}". + + + Path "{path}" was not found. + + + Expected a value for environment variable "{key}". + + \ No newline at end of file diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs index 4988d2e1..c9a1038c 100644 --- a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs +++ b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs @@ -35,7 +35,7 @@ namespace Tye.Serialization break; case "services": YamlParser.ThrowIfNotYamlSequence(key, child.Value); - ConfigServiceParser.HandleServiceMapping((child.Value as YamlSequenceNode)!, app.Services); + ConfigServiceParser.HandleServiceMapping((child.Value as YamlSequenceNode)!, app.Services, app); break; case "extensions": YamlParser.ThrowIfNotYamlSequence(key, child.Value); diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigServiceParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigServiceParser.cs index e719bb9f..014bce75 100644 --- a/src/Microsoft.Tye.Core/Serialization/ConfigServiceParser.cs +++ b/src/Microsoft.Tye.Core/Serialization/ConfigServiceParser.cs @@ -2,7 +2,9 @@ // 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 System.Linq; using Microsoft.Tye.ConfigModel; using YamlDotNet.RepresentationModel; @@ -11,18 +13,18 @@ namespace Tye.Serialization { public static class ConfigServiceParser { - public static void HandleServiceMapping(YamlSequenceNode yamlSequenceNode, List services) + public static void HandleServiceMapping(YamlSequenceNode yamlSequenceNode, List services, ConfigApplication application) { foreach (var child in yamlSequenceNode.Children) { YamlParser.ThrowIfNotYamlMapping(child); var service = new ConfigService(); - HandleServiceNameMapping((YamlMappingNode)child, service); + HandleServiceNameMapping((YamlMappingNode)child, service, application); services.Add(service); } } - private static void HandleServiceNameMapping(YamlMappingNode yamlMappingNode, ConfigService service) + private static void HandleServiceNameMapping(YamlMappingNode yamlMappingNode, ConfigService service, ConfigApplication application) { foreach (var child in yamlMappingNode!.Children) { @@ -130,6 +132,14 @@ namespace Tye.Serialization HandleServiceConfiguration((child.Value as YamlSequenceNode)!, service.Configuration); break; + case "env_file": + if (child.Value.NodeType != YamlNodeType.Sequence) + { + throw new TyeYamlException(child.Value.Start, CoreStrings.FormatExpectedYamlSequence(key)); + } + + HandleServiceEnvFiles((child.Value as YamlSequenceNode)!, service.Configuration, application); + break; case "liveness": service.Liveness = new ConfigProbe(); HandleServiceProbe((YamlMappingNode)child.Value, service.Liveness!); @@ -430,9 +440,25 @@ namespace Tye.Serialization { foreach (var child in yamlSequenceNode.Children) { - YamlParser.ThrowIfNotYamlMapping(child); var config = new ConfigConfigurationSource(); - HandleServiceConfigurationNameMapping((YamlMappingNode)child, config); + switch (child) + { + case YamlMappingNode childMappingNode: + HandleServiceConfigurationNameMapping(childMappingNode, config); + break; + case YamlScalarNode childScalarNode: + HandleServiceConfigurationCompact(childScalarNode, config); + break; + default: + throw new TyeYamlException(child.Start, CoreStrings.FormatUnexpectedTypes($"\"{YamlNodeType.Mapping.ToString()}\", \"{YamlNodeType.Scalar.ToString()}\"", child.NodeType.ToString())); + } + + // if no value is given, we take the value from the system/shell environment variables + if (config.Value == null) + { + config.Value = Environment.GetEnvironmentVariable(config.Name) ?? string.Empty; + } + configuration.Add(config); } } @@ -457,6 +483,66 @@ namespace Tye.Serialization } } + private static void HandleServiceConfigurationCompact(YamlScalarNode yamlScalarNode, ConfigConfigurationSource config) + { + var nodeValue = YamlParser.GetScalarValue(yamlScalarNode); + var keyValueSeparator = nodeValue.IndexOf('='); + + if (keyValueSeparator != -1) + { + var key = nodeValue.Substring(0, keyValueSeparator).Trim(); + var value = nodeValue.Substring(keyValueSeparator + 1)?.Trim(); + + config.Name = key; + config.Value = value?.Trim(new[] { ' ', '"' }) ?? string.Empty; + } + else + { + config.Name = nodeValue.Trim(); + } + } + + private static void HandleServiceEnvFiles(YamlSequenceNode yamlSequenceNode, List configuration, ConfigApplication application) + { + foreach (var child in yamlSequenceNode.Children) + { + switch (child) + { + case YamlScalarNode childScalarNode: + var envFile = new FileInfo(Path.Combine(application.Source?.DirectoryName ?? Directory.GetCurrentDirectory(), YamlParser.GetScalarValue(childScalarNode))); + if (!envFile.Exists) + throw new TyeYamlException(child.Start, CoreStrings.FormatPathNotFound(envFile.FullName)); + HandleServiceEnvFile(childScalarNode, File.ReadAllLines(envFile.FullName), configuration); + break; + default: + throw new TyeYamlException(child.Start, CoreStrings.FormatUnexpectedType(YamlNodeType.Scalar.ToString(), child.NodeType.ToString())); + } + } + } + + private static void HandleServiceEnvFile(YamlScalarNode yamlScalarNode, string[] envLines, List configuration) + { + foreach (var line in envLines) + { + var lineTrim = line?.Trim(); + if (string.IsNullOrEmpty(lineTrim) || lineTrim[0] == '#') + { + continue; + } + + var keyValueSeparator = lineTrim.IndexOf('='); + + if (keyValueSeparator == -1) + throw new TyeYamlException(yamlScalarNode.Start, CoreStrings.FormatExpectedEnvironmentVariableValue(lineTrim)); + + configuration.Add(new ConfigConfigurationSource + { + Name = lineTrim.Substring(0, keyValueSeparator).Trim(), + Value = lineTrim.Substring(keyValueSeparator + 1)?.Trim(new[] { ' ', '"' }) ?? string.Empty + }); + } + } + private static void HandleServiceBuildPropertyNameMapping(YamlMappingNode yamlMappingNode, BuildProperty buildProperty) { foreach (var child in yamlMappingNode!.Children) diff --git a/src/Microsoft.Tye.Core/Serialization/YamlParser.cs b/src/Microsoft.Tye.Core/Serialization/YamlParser.cs index e5754b74..92f38e6d 100644 --- a/src/Microsoft.Tye.Core/Serialization/YamlParser.cs +++ b/src/Microsoft.Tye.Core/Serialization/YamlParser.cs @@ -17,21 +17,21 @@ namespace Tye.Serialization private FileInfo? _fileInfo; private TextReader _reader; - public YamlParser(string yamlContent) - : this(new StringReader(yamlContent)) + public YamlParser(string yamlContent, FileInfo? fileInfo = null) + : this(new StringReader(yamlContent), fileInfo) { } public YamlParser(FileInfo fileInfo) - : this(fileInfo.OpenText()) + : this(fileInfo.OpenText(), fileInfo) { - _fileInfo = fileInfo; } - internal YamlParser(TextReader reader) + internal YamlParser(TextReader reader, FileInfo? fileInfo = null) { _reader = reader; _yamlStream = new YamlStream(); + _fileInfo = fileInfo; } public ConfigApplication ParseConfigApplication() @@ -51,9 +51,11 @@ namespace Tye.Serialization var document = _yamlStream.Documents[0]; var node = document.RootNode; ThrowIfNotYamlMapping(node); - ConfigApplicationParser.HandleConfigApplication((YamlMappingNode)node, app); app.Source = _fileInfo!; + + ConfigApplicationParser.HandleConfigApplication((YamlMappingNode)node, app); + app.Name ??= NameInferer.InferApplicationName(_fileInfo!); // TODO confirm if these are ever null. diff --git a/test/UnitTests/Microsoft.Tye.UnitTests.csproj b/test/UnitTests/Microsoft.Tye.UnitTests.csproj index cda06b56..c526b119 100644 --- a/test/UnitTests/Microsoft.Tye.UnitTests.csproj +++ b/test/UnitTests/Microsoft.Tye.UnitTests.csproj @@ -24,4 +24,13 @@ + + + + Always + + + Always + + diff --git a/test/UnitTests/TyeDeserializationTests.cs b/test/UnitTests/TyeDeserializationTests.cs index 82c9c346..c03c1713 100644 --- a/test/UnitTests/TyeDeserializationTests.cs +++ b/test/UnitTests/TyeDeserializationTests.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Linq; using Microsoft.Tye.ConfigModel; using Test.Infrastructure; @@ -228,6 +229,121 @@ services: var app = parser.ParseConfigApplication(); } + [Theory] + [InlineData("env")] + [InlineData("configuration")] + public void EnvSimpleSyntaxTest(string rootKeyName) + { + using var parser = new YamlParser( +@$" +services: + - {rootKeyName}: + - name: env1 + value: value1 + - name: env2 + value: value2 + - name: env3 + value: ""long string"" + - name: env4 + value: +"); + + var app = parser.ParseConfigApplication(); + var serviceConfig = app.Services.First().Configuration; + + Assert.Equal(4, serviceConfig.Count); + Assert.Equal("value1", serviceConfig.Where(env => env.Name == "env1").First().Value); + Assert.Equal("value2", serviceConfig.Where(env => env.Name == "env2").First().Value); + Assert.Equal("long string", serviceConfig.Where(env => env.Name == "env3").First().Value); + Assert.Equal(string.Empty, serviceConfig.Where(env => env.Name == "env4").First().Value); + } + + [Fact] + public void EnvCompactSyntaxTest() + { + using var parser = new YamlParser( +@" +services: + - env: + - env1=value1 + - env2=value2 + - env3 = value3 + - env4 = ""long string"" + - name: env5 + value: value5 + - env6 = +"); + + var app = parser.ParseConfigApplication(); + var serviceConfig = app.Services.First().Configuration; + + Assert.Equal(6, serviceConfig.Count); + Assert.Equal("value1", serviceConfig.Where(env => env.Name == "env1").First().Value); + Assert.Equal("value2", serviceConfig.Where(env => env.Name == "env2").First().Value); + Assert.Equal("value3", serviceConfig.Where(env => env.Name == "env3").First().Value); + Assert.Equal("long string", serviceConfig.Where(env => env.Name == "env4").First().Value); + Assert.Equal("value5", serviceConfig.Where(env => env.Name == "env5").First().Value); + Assert.Equal(string.Empty, serviceConfig.Where(env => env.Name == "env6").First().Value); + } + + [Fact] + public void EnvTakeValueFromEnvironmentTest() + { + using var parser = new YamlParser( +@" +services: + - env: + - env1 + - name: env2 + - env3 +"); + + Environment.SetEnvironmentVariable("env1", "value1"); + Environment.SetEnvironmentVariable("env2", "value2"); + + var app = parser.ParseConfigApplication(); + var serviceConfig = app.Services.First().Configuration; + + Assert.Equal(3, serviceConfig.Count); + Assert.Equal("value1", serviceConfig.Where(env => env.Name == "env1").First().Value); + Assert.Equal("value2", serviceConfig.Where(env => env.Name == "env2").First().Value); + Assert.Equal(string.Empty, serviceConfig.Where(env => env.Name == "env3").First().Value); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EnvFromFile(bool setWorkingDirectory) + { + var subDirectory = setWorkingDirectory ? string.Empty : "testassets/"; + using var parser = new YamlParser( +@$" +services: + - env_file: + - ./{subDirectory}envfile_a.env + - ./{subDirectory}envfile_b.env +", setWorkingDirectory ? new FileInfo(Path.Join(Directory.GetCurrentDirectory(), "testassets", "tye.yaml")) : null); + + + var app = parser.ParseConfigApplication(); + var serviceConfig = app.Services.First().Configuration; + + Assert.Equal(3, serviceConfig.Count); + } + + [Fact] + public void PathNotFound_ThrowException() + { + using var parser = new YamlParser( +@" +services: + - env_file: + - ./envfile_c.env +", new FileInfo(Path.Join(Directory.GetCurrentDirectory(), "testassets", "tye.yaml"))); + + var exception = Assert.Throws(() => parser.ParseConfigApplication()); + Assert.Contains(CoreStrings.FormatPathNotFound(Path.Join(Directory.GetCurrentDirectory(), "testassets", "envfile_c.env")), exception.Message); + } [Fact] public void UnrecognizedConfigApplicationField_ThrowException() diff --git a/test/UnitTests/testassets/envfile_a.env b/test/UnitTests/testassets/envfile_a.env new file mode 100644 index 00000000..38549f51 --- /dev/null +++ b/test/UnitTests/testassets/envfile_a.env @@ -0,0 +1,3 @@ +env1=value1 +# Ignore comment +env2 = value2 \ No newline at end of file diff --git a/test/UnitTests/testassets/envfile_b.env b/test/UnitTests/testassets/envfile_b.env new file mode 100644 index 00000000..599cef5f --- /dev/null +++ b/test/UnitTests/testassets/envfile_b.env @@ -0,0 +1 @@ +env3 = "long string" \ No newline at end of file