diff --git a/docs/reference/schema.md b/docs/reference/schema.md index 66b73e4e..d525f451 100644 --- a/docs/reference/schema.md +++ b/docs/reference/schema.md @@ -45,7 +45,7 @@ services: ```yaml name: myapplication -registry: exampleuser +registry: ... namespace: examplenamespace network: examplenetwork ingress: ... @@ -60,16 +60,9 @@ Configures the name of the application. This will appear in some Kubernetes labe If the name name is not specified, then the lowercased directory name containing the `tye.yaml` file will be used as the default. -#### `registry` (string) +#### `registry` (`Registry`) -Allows storing the name of the container registry in configuration. This is used when building and deploying images for two purposes: - -- Determining how to tag the images -- Determining where to push the images - -The registry could be a DockerHub username (`exampleuser`) or the hostname of a container registry (`example.azurecr.io`). - -If this is not specified in configuration, interactive deployments will prompt for it. +Allows storing the name of the container registry and optional credentials secret in configuration. #### `namespace` (string) @@ -105,6 +98,33 @@ Specifies the list of services. Applications must have at least one service. Indicates the solution file (.sln) or filter (.slnf) to use when building project-based services in watch mode. If omitted, those services will be built individually. Specifying the solution [filter] can help reduce repeated builds of shared libraries when in watch mode. +## Registry + +Allows storing the name of the container registry in configuration. This is used when building and deploying images for two purposes: + +- Determining how to tag the images +- Determining where to push the images + +If this is not specified in configuration, interactive deployments will prompt for it. + +### Registry Example + +```yaml +registry: + name: example.azurecr.io + pullSecret: acr-secret +``` + +### Registry Properties + +#### `name` (string) *required* + +The `name` could be a DockerHub username (`exampleuser`) or the hostname of a container registry (`example.azurecr.io`). + +#### `pullSecret` (string) + +Specifies the optional secret, that Kubernetes should get the credentials from to pull the image from a private registry. + ## Service `Service` elements appear in a list within the `services` root property. diff --git a/src/Microsoft.Tye.Core/ApplicationFactory.cs b/src/Microsoft.Tye.Core/ApplicationFactory.cs index 6e7fdc7f..5345d78c 100644 --- a/src/Microsoft.Tye.Core/ApplicationFactory.cs +++ b/src/Microsoft.Tye.Core/ApplicationFactory.cs @@ -48,9 +48,9 @@ namespace Microsoft.Tye continue; } - if (config == rootConfig && !string.IsNullOrEmpty(config.Registry)) + if (config == rootConfig && config.Registry != null) { - root.Registry = new ContainerRegistry(config.Registry); + root.Registry = new ContainerRegistry(config.Registry.Hostname, config.Registry.PullSecret); } if (config == rootConfig) diff --git a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs index 309f3cdb..d74cc0c2 100644 --- a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs +++ b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs @@ -30,7 +30,7 @@ namespace Microsoft.Tye.ConfigModel public string? Namespace { get; set; } - public string? Registry { get; set; } + public ConfigRegistry? Registry { get; set; } public ContainerEngineType? ContainerEngineType { get; set; } @@ -64,6 +64,16 @@ namespace Microsoft.Tye.ConfigModel } } + if (config.Registry != null) + { + if (!Validator.TryValidateObject(config.Registry, new ValidationContext(config.Registry), results, validateAllProperties: true)) + { + throw new TyeYamlException( + "Registry validation failed." + Environment.NewLine + + string.Join(Environment.NewLine, results.Select(r => r.ErrorMessage))); + } + } + foreach (var service in config.Services) { context = new ValidationContext(service); diff --git a/src/Microsoft.Tye.Core/ConfigModel/ConfigRegistry.cs b/src/Microsoft.Tye.Core/ConfigModel/ConfigRegistry.cs new file mode 100644 index 00000000..15e4c62d --- /dev/null +++ b/src/Microsoft.Tye.Core/ConfigModel/ConfigRegistry.cs @@ -0,0 +1,16 @@ +// 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.ComponentModel.DataAnnotations; + +namespace Microsoft.Tye.ConfigModel +{ + public class ConfigRegistry + { + [Required] + public string Hostname { get; set; } = null!; + + public string? PullSecret { get; set; } + } +} diff --git a/src/Microsoft.Tye.Core/ContainerRegistry.cs b/src/Microsoft.Tye.Core/ContainerRegistry.cs index 6ee6efb6..377e7e26 100644 --- a/src/Microsoft.Tye.Core/ContainerRegistry.cs +++ b/src/Microsoft.Tye.Core/ContainerRegistry.cs @@ -8,11 +8,13 @@ namespace Microsoft.Tye { public sealed class ContainerRegistry { - public ContainerRegistry(string hostname) + public ContainerRegistry(string hostname, string? pullSecret) { Hostname = hostname ?? throw new ArgumentNullException(nameof(hostname)); + PullSecret = pullSecret; } public string Hostname { get; } + public string? PullSecret { get; } } } diff --git a/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs b/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs index 2e28dc93..7b82ba3b 100644 --- a/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs +++ b/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs @@ -491,6 +491,16 @@ namespace Microsoft.Tye volumeMount.Add("mountPath", "/var/tye/diagnostics"); } + if (!string.IsNullOrWhiteSpace(application.Registry?.PullSecret)) + { + var imagePullSecrets = new YamlSequenceNode(); + spec.Add("imagePullSecrets", imagePullSecrets); + + var secretNode = new YamlMappingNode(); + imagePullSecrets.Add(secretNode); + secretNode.Add("name", application.Registry.PullSecret); + } + if (!project.RelocateDiagnosticsDomainSockets) { return new KubernetesDeploymentOutput(project.Name, new YamlDocument(root)); diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs index a7ccc6ae..3b9b724b 100644 --- a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs +++ b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs @@ -31,7 +31,7 @@ namespace Tye.Serialization app.Network = YamlParser.GetScalarValue(key, child.Value); break; case "registry": - app.Registry = YamlParser.GetScalarValue(key, child.Value); + app.Registry = ConfigRegistryParser.HandleRegistry(key, child.Value); break; case "containerEngine": string engine = YamlParser.GetScalarValue(key, child.Value); diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigRegistryParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigRegistryParser.cs new file mode 100644 index 00000000..00ae9cbd --- /dev/null +++ b/src/Microsoft.Tye.Core/Serialization/ConfigRegistryParser.cs @@ -0,0 +1,58 @@ +// 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 class ConfigRegistryParser + { + public static ConfigRegistry HandleRegistry(string key, YamlNode node) + { + ConfigRegistry configRegistry; + if (node.NodeType == YamlNodeType.Scalar) + { + configRegistry = new ConfigRegistry + { + Hostname = ((YamlScalarNode)node).Value! + }; + } + else if (node.NodeType == YamlNodeType.Mapping) + { + configRegistry = HandleRegistryMapping((YamlMappingNode)node); + } + else + { + throw new TyeYamlException(node.Start, CoreStrings.FormatExpectedYamlScalar(key)); + } + + return configRegistry; + } + + private static ConfigRegistry HandleRegistryMapping(YamlMappingNode mappingNode) + { + var configRegistry = new ConfigRegistry(); + + foreach (var child in mappingNode.Children) + { + var key = YamlParser.GetScalarValue(child.Key); + + switch (key) + { + case "name": + configRegistry.Hostname = YamlParser.GetScalarValue(key, child.Value); + break; + case "pullSecret": + configRegistry.PullSecret = YamlParser.GetScalarValue(key, child.Value); + break; + default: + throw new TyeYamlException(child.Key.Start, CoreStrings.FormatUnrecognizedKey(key)); + } + } + + return configRegistry; + } + } +} diff --git a/src/tye/Program.DeployCommand.cs b/src/tye/Program.DeployCommand.cs index d8527994..85aea5e4 100644 --- a/src/tye/Program.DeployCommand.cs +++ b/src/tye/Program.DeployCommand.cs @@ -118,7 +118,7 @@ namespace Microsoft.Tye var registry = output.Prompt("Enter the Container Registry (ex: 'example.azurecr.io' for Azure or 'example' for dockerhub)", allowEmpty: !requireRegistry); if (!string.IsNullOrWhiteSpace(registry)) { - application.Registry = new ContainerRegistry(registry.Trim()); + application.Registry = new ContainerRegistry(registry.Trim(), null); } } else if (application.Registry is null && requireRegistry) diff --git a/test/E2ETest/TyeBuildTests.Dockerfile.cs b/test/E2ETest/TyeBuildTests.Dockerfile.cs index d81c94c4..290eaaa2 100644 --- a/test/E2ETest/TyeBuildTests.Dockerfile.cs +++ b/test/E2ETest/TyeBuildTests.Dockerfile.cs @@ -34,7 +34,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -70,7 +70,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -105,7 +105,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -143,7 +143,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -181,7 +181,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -228,7 +228,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile, "netcoreapp3.1"); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -262,7 +262,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile, "netcoreapp3.1"); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { diff --git a/test/E2ETest/TyeBuildTests.cs b/test/E2ETest/TyeBuildTests.cs index c817fa03..b0146d16 100644 --- a/test/E2ETest/TyeBuildTests.cs +++ b/test/E2ETest/TyeBuildTests.cs @@ -38,7 +38,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -69,7 +69,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -103,7 +103,7 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { diff --git a/test/E2ETest/TyeGenerateTests.cs b/test/E2ETest/TyeGenerateTests.cs index 7077feb3..d2fd6c9d 100644 --- a/test/E2ETest/TyeGenerateTests.cs +++ b/test/E2ETest/TyeGenerateTests.cs @@ -41,7 +41,7 @@ namespace E2ETest var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); // Need to add docker registry for generate - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -79,7 +79,7 @@ namespace E2ETest var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); // Need to add docker registry for generate - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -120,7 +120,7 @@ namespace E2ETest var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); // Need to add docker registry for generate - application.Registry = new ContainerRegistry("test"); + application.Registry = new ContainerRegistry("test", null); try { @@ -146,9 +146,9 @@ namespace E2ETest [ConditionalFact] [SkipIfDockerNotRunning] - public async Task GenerateWorksWithoutRegistry() + public async Task GenerateWorksWithRegistryPullSecret() { - await DockerAssert.DeleteDockerImagesAsync(output, "test-project"); + await DockerAssert.DeleteDockerImagesAsync(output, "test/test-project"); var projectName = "single-project"; var environment = "production"; @@ -160,21 +160,61 @@ namespace E2ETest var outputContext = new OutputContext(sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); + // Need to add docker registry with pull secret for generate + application.Registry = new ContainerRegistry("test", "credsecret"); + try { await GenerateHost.ExecuteGenerateAsync(outputContext, application, environment, interactive: false); // name of application is the folder var content = await File.ReadAllTextAsync(Path.Combine(projectDirectory.DirectoryPath, $"{projectName}-generate-{environment}.yaml")); - var expectedContent = await File.ReadAllTextAsync($"testassets/generate/{projectName}-noregistry.yaml"); + var expectedContent = await File.ReadAllTextAsync($"testassets/generate/{projectName}-registrypullsecret.yaml"); YamlAssert.Equals(expectedContent, content, output); - await DockerAssert.AssertImageExistsAsync(output, "test-project"); + await DockerAssert.AssertImageExistsAsync(output, "test/test-project"); } finally { - await DockerAssert.DeleteDockerImagesAsync(output, "test-project"); + await DockerAssert.DeleteDockerImagesAsync(output, "test/test-project"); + } + } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task GenerateWorksWithoutRegistry() + { + await DockerAssert.DeleteDockerImagesAsync(output, "test/test-project"); + + var projectName = "single-project"; + var environment = "production"; + + using var projectDirectory = CopyTestProjectDirectory(projectName); + + var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye.yaml")); + + var outputContext = new OutputContext(sink, Verbosity.Debug); + var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); + + // Need to add docker registry for generate + application.Registry = new ContainerRegistry("test", null); + + try + { + await GenerateHost.ExecuteGenerateAsync(outputContext, application, environment, interactive: false); + + // name of application is the folder + var content = await File.ReadAllTextAsync(Path.Combine(projectDirectory.DirectoryPath, $"{projectName}-generate-{environment}.yaml")); + var expectedContent = await File.ReadAllTextAsync($"testassets/generate/{projectName}.yaml"); + + YamlAssert.Equals(expectedContent, content, output); + + await DockerAssert.AssertImageExistsAsync(output, "test/test-project"); + } + finally + { + await DockerAssert.DeleteDockerImagesAsync(output, "test/test-project"); } } diff --git a/test/E2ETest/testassets/generate/single-project-registrypullsecret.yaml b/test/E2ETest/testassets/generate/single-project-registrypullsecret.yaml new file mode 100644 index 00000000..d539e791 --- /dev/null +++ b/test/E2ETest/testassets/generate/single-project-registrypullsecret.yaml @@ -0,0 +1,58 @@ +kind: Deployment +apiVersion: apps/v1 +metadata: + name: test-project + labels: + app.kubernetes.io/name: 'test-project' + app.kubernetes.io/part-of: 'single-project' +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: test-project + template: + metadata: + labels: + app.kubernetes.io/name: 'test-project' + app.kubernetes.io/part-of: 'single-project' + spec: + containers: + - name: test-project + image: test/test-project:1.0.0 + imagePullPolicy: Always + env: + - name: DOTNET_LOGGING__CONSOLE__DISABLECOLORS + value: 'true' + - name: ASPNETCORE_URLS + value: 'http://*' + - name: PORT + value: '80' + - name: SERVICE__TEST-PROJECT__PROTOCOL + value: 'http' + - name: SERVICE__TEST-PROJECT__PORT + value: '80' + - name: SERVICE__TEST-PROJECT__HOST + value: 'test-project' + ports: + - containerPort: 80 + imagePullSecrets: + - name: credsecret +... +--- +kind: Service +apiVersion: v1 +metadata: + name: test-project + labels: + app.kubernetes.io/name: 'test-project' + app.kubernetes.io/part-of: 'single-project' +spec: + selector: + app.kubernetes.io/name: test-project + type: ClusterIP + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 80 +...