diff --git a/src/Microsoft.Tye.Core/ApplicationExecutor.cs b/src/Microsoft.Tye.Core/ApplicationExecutor.cs index 429e8da8..c18ebb5d 100644 --- a/src/Microsoft.Tye.Core/ApplicationExecutor.cs +++ b/src/Microsoft.Tye.Core/ApplicationExecutor.cs @@ -19,7 +19,9 @@ namespace Microsoft.Tye this.output = output; } - public List ApplicationSteps = new List(); + public List ApplicationSteps { get; } = new List(); + + public List IngressSteps { get; } = new List(); public List ServiceSteps { get; } = new List(); @@ -37,6 +39,18 @@ namespace Microsoft.Tye tracker.MarkComplete(); } + foreach (var ingress in application.Ingress) + { + using var tracker = output.BeginStep($"Processing Ingress '{ingress.Name}'..."); + foreach (var step in IngressSteps) + { + using var stepTracker = output.BeginStep(step.DisplayText); + await step.ExecuteAsync(output, application, ingress); + stepTracker.MarkComplete(); + } + tracker.MarkComplete(); + } + { foreach (var step in ApplicationSteps) { @@ -54,6 +68,13 @@ namespace Microsoft.Tye public abstract Task ExecuteAsync(OutputContext output, ApplicationBuilder application); } + public abstract class IngressStep + { + public abstract string DisplayText { get; } + + public abstract Task ExecuteAsync(OutputContext output, ApplicationBuilder application, IngressBuilder ingres); + } + public abstract class ServiceStep { public abstract string DisplayText { get; } diff --git a/src/Microsoft.Tye.Core/ApplicationYamlWriter.cs b/src/Microsoft.Tye.Core/ApplicationYamlWriter.cs index 4dd5c167..50350f83 100644 --- a/src/Microsoft.Tye.Core/ApplicationYamlWriter.cs +++ b/src/Microsoft.Tye.Core/ApplicationYamlWriter.cs @@ -2,6 +2,7 @@ // 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 System.IO; using System.Linq; using System.Threading.Tasks; @@ -13,8 +14,12 @@ namespace Microsoft.Tye { public static Task WriteAsync(OutputContext output, StreamWriter writer, ApplicationBuilder application) { - var yaml = application.Services.SelectMany(s => s.Outputs.OfType()).ToArray(); - if (yaml.Length == 0) + var yaml = new List(); + + yaml.AddRange(application.Services.SelectMany(s => s.Outputs.OfType())); + yaml.AddRange(application.Ingress.SelectMany(i => i.Outputs.OfType())); + + if (yaml.Count == 0) { output.WriteDebugLine($"No yaml manifests found. Skipping."); return Task.CompletedTask; diff --git a/src/Microsoft.Tye.Core/GenerateIngressKubernetesManifestStep.cs b/src/Microsoft.Tye.Core/GenerateIngressKubernetesManifestStep.cs new file mode 100644 index 00000000..c62d0da0 --- /dev/null +++ b/src/Microsoft.Tye.Core/GenerateIngressKubernetesManifestStep.cs @@ -0,0 +1,21 @@ +// 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.Threading.Tasks; + +namespace Microsoft.Tye +{ + public sealed class GenerateIngressKubernetesManifestStep : ApplicationExecutor.IngressStep + { + public override string DisplayText => "Generating Manifests..."; + + public string Environment { get; set; } = "production"; + + public override Task ExecuteAsync(OutputContext output, ApplicationBuilder application, IngressBuilder ingress) + { + ingress.Outputs.Add(KubernetesManifestGenerator.CreateIngress(output, application, ingress)); + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.Tye.Core/GenerateKubernetesManifestStep.cs b/src/Microsoft.Tye.Core/GenerateServiceKubernetesManifestStep.cs similarity index 95% rename from src/Microsoft.Tye.Core/GenerateKubernetesManifestStep.cs rename to src/Microsoft.Tye.Core/GenerateServiceKubernetesManifestStep.cs index 94eb5302..270bee71 100644 --- a/src/Microsoft.Tye.Core/GenerateKubernetesManifestStep.cs +++ b/src/Microsoft.Tye.Core/GenerateServiceKubernetesManifestStep.cs @@ -6,12 +6,13 @@ using System.Threading.Tasks; namespace Microsoft.Tye { - public sealed class GenerateKubernetesManifestStep : ApplicationExecutor.ServiceStep + public sealed class GenerateServiceKubernetesManifestStep : ApplicationExecutor.ServiceStep { public override string DisplayText => "Generating Manifests..."; public string Environment { get; set; } = "production"; + public override Task ExecuteAsync(OutputContext output, ApplicationBuilder application, ServiceBuilder service) { if (SkipWithoutContainerOutput(output, service)) diff --git a/src/Microsoft.Tye.Core/IngressBuilder.cs b/src/Microsoft.Tye.Core/IngressBuilder.cs index 456fdc43..b8fb9cc2 100644 --- a/src/Microsoft.Tye.Core/IngressBuilder.cs +++ b/src/Microsoft.Tye.Core/IngressBuilder.cs @@ -20,5 +20,7 @@ namespace Microsoft.Tye public List Bindings { get; set; } = new List(); public List Rules { get; set; } = new List(); + + public List Outputs { get; } = new List(); } } diff --git a/src/Microsoft.Tye.Core/IngressOutput.cs b/src/Microsoft.Tye.Core/IngressOutput.cs new file mode 100644 index 00000000..5c03c2f3 --- /dev/null +++ b/src/Microsoft.Tye.Core/IngressOutput.cs @@ -0,0 +1,10 @@ +// 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. + +namespace Microsoft.Tye +{ + public abstract class IngressOutput + { + } +} diff --git a/src/Microsoft.Tye.Core/KubernetesIngressOutput.cs b/src/Microsoft.Tye.Core/KubernetesIngressOutput.cs new file mode 100644 index 00000000..34a14b28 --- /dev/null +++ b/src/Microsoft.Tye.Core/KubernetesIngressOutput.cs @@ -0,0 +1,31 @@ +// 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.RepresentationModel; + +namespace Microsoft.Tye +{ + internal sealed class KubernetesIngressOutput : IngressOutput, IYamlManifestOutput + { + public KubernetesIngressOutput(string name, YamlDocument yaml) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (yaml is null) + { + throw new ArgumentNullException(nameof(yaml)); + } + + Name = name; + Yaml = yaml; + } + + public string Name { get; } + public YamlDocument Yaml { get; } + } +} diff --git a/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs b/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs index 60c1e768..5c237097 100644 --- a/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs +++ b/src/Microsoft.Tye.Core/KubernetesManifestGenerator.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Globalization; using System.Linq; using YamlDotNet.Core; using YamlDotNet.RepresentationModel; @@ -11,7 +12,99 @@ namespace Microsoft.Tye { internal static class KubernetesManifestGenerator { - public static ServiceOutput CreateService( + public static KubernetesIngressOutput CreateIngress( + OutputContext output, + ApplicationBuilder application, + IngressBuilder ingress) + { + var root = new YamlMappingNode(); + + root.Add("kind", "Ingress"); + root.Add("apiVersion", "extensions/v1beta1"); + + var metadata = new YamlMappingNode(); + root.Add("metadata", metadata); + metadata.Add("name", ingress.Name); + + var annotations = new YamlMappingNode(); + metadata.Add("annotations", annotations); + annotations.Add("kubernetes.io/ingress.class", new YamlScalarNode("nginx") { Style = ScalarStyle.SingleQuoted, }); + annotations.Add("nginx.ingress.kubernetes.io/rewrite-target", new YamlScalarNode("/$2") { Style = ScalarStyle.SingleQuoted, }); + + var labels = new YamlMappingNode(); + metadata.Add("labels", labels); + labels.Add("app.kubernetes.io/part-of", new YamlScalarNode(application.Name) { Style = ScalarStyle.SingleQuoted, }); + + var spec = new YamlMappingNode(); + root.Add("spec", spec); + + if (ingress.Rules.Count > 0) + { + var rules = new YamlSequenceNode(); + spec.Add("rules", rules); + + // k8s ingress is grouped by host first, then grouped by path + foreach (var hostgroup in ingress.Rules.GroupBy(r => r.Host)) + { + var rule = new YamlMappingNode(); + rules.Add(rule); + + if (!string.IsNullOrEmpty(hostgroup.Key)) + { + rule.Add("host", hostgroup.Key); + } + + var http = new YamlMappingNode(); + rule.Add("http", http); + + var paths = new YamlSequenceNode(); + http.Add("paths", paths); + + foreach (var ingressRule in hostgroup) + { + var path = new YamlMappingNode(); + paths.Add(path); + + var backend = new YamlMappingNode(); + path.Add("backend", backend); + backend.Add("serviceName", ingressRule.Service); + + var service = application.Services.FirstOrDefault(s => s.Name == ingressRule.Service); + if (service is null) + { + throw new InvalidOperationException($"Could not resolve service '{ingressRule.Service}'."); + } + + var binding = service.Bindings.FirstOrDefault(b => b.Name is null || b.Name == "http"); + if (binding is null) + { + throw new InvalidOperationException($"Could not resolve an http binding for service '{service.Name}'."); + } + + backend.Add("servicePort", (binding.Port ?? 80).ToString(CultureInfo.InvariantCulture)); + + // Tye implements path matching similar to this example: + // https://kubernetes.github.io/ingress-nginx/examples/rewrite/ + // + // Therefore our rewrite-target is set to $2 - we want to make sure we have + // two capture groups. + if (string.IsNullOrEmpty(ingressRule.Path)) + { + path.Add("path", "/()(.*)"); // () is an empty capture group. + } + else + { + var regex = $"{ingressRule.Path.TrimEnd('/')}(/|$)(.*)"; + path.Add("path", regex); + } + } + } + } + + return new KubernetesIngressOutput(ingress.Name, new YamlDocument(root)); + } + + public static KubernetesServiceOutput CreateService( OutputContext output, ApplicationBuilder application, ProjectServiceBuilder project, @@ -93,7 +186,7 @@ namespace Microsoft.Tye return new KubernetesServiceOutput(project.Name, new YamlDocument(root)); } - public static ServiceOutput CreateDeployment( + public static KubernetesDeploymentOutput CreateDeployment( OutputContext output, ApplicationBuilder application, ProjectServiceBuilder project, diff --git a/src/tye/GenerateHost.cs b/src/tye/GenerateHost.cs index 4d317716..3490c1b6 100644 --- a/src/tye/GenerateHost.cs +++ b/src/tye/GenerateHost.cs @@ -43,7 +43,12 @@ namespace Microsoft.Tye new CombineStep() { Environment = environment, }, new PublishProjectStep(), new BuildDockerImageStep() { Environment = environment, }, // Make an image but don't push it - new GenerateKubernetesManifestStep() { Environment = environment, }, + new GenerateServiceKubernetesManifestStep() { Environment = environment, }, + }, + + IngressSteps = + { + new GenerateIngressKubernetesManifestStep(), }, ApplicationSteps = diff --git a/src/tye/Program.DeployCommand.cs b/src/tye/Program.DeployCommand.cs index 5f616c4f..ffcc65ea 100644 --- a/src/tye/Program.DeployCommand.cs +++ b/src/tye/Program.DeployCommand.cs @@ -82,7 +82,12 @@ namespace Microsoft.Tye new BuildDockerImageStep() { Environment = environment, }, new PushDockerImageStep() { Environment = environment, }, new ValidateSecretStep() { Environment = environment, Interactive = interactive, Force = force, }, - new GenerateKubernetesManifestStep() { Environment = environment, }, + new GenerateServiceKubernetesManifestStep() { Environment = environment, }, + }, + + IngressSteps = + { + new GenerateIngressKubernetesManifestStep(), }, ApplicationSteps = diff --git a/test/E2ETest/TyeGenerateTests.cs b/test/E2ETest/TyeGenerateTests.cs index 06eec30e..adf23475 100644 --- a/test/E2ETest/TyeGenerateTests.cs +++ b/test/E2ETest/TyeGenerateTests.cs @@ -317,5 +317,42 @@ namespace E2ETest await DockerAssert.DeleteDockerImagesAsync(output, projectName); } } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task Generate_Ingress() + { + var applicationName = "apps-with-ingress"; + var environment = "production"; + + await DockerAssert.DeleteDockerImagesAsync(output, "app-a"); + await DockerAssert.DeleteDockerImagesAsync(output, "app-b"); + + using var projectDirectory = TestHelpers.CopyTestProjectDirectory(applicationName); + + var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye.yaml")); + + var outputContext = new OutputContext(sink, Verbosity.Debug); + var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); + + try + { + await GenerateHost.ExecuteGenerateAsync(outputContext, application, environment, interactive: false); + + // name of application is the folder + var content = await File.ReadAllTextAsync(Path.Combine(projectDirectory.DirectoryPath, $"{applicationName}-generate-{environment}.yaml")); + var expectedContent = await File.ReadAllTextAsync($"testassets/generate/{applicationName}.yaml"); + + YamlAssert.Equals(expectedContent, content, output); + + await DockerAssert.AssertImageExistsAsync(output, "app-a"); + await DockerAssert.AssertImageExistsAsync(output, "app-b"); + } + finally + { + await DockerAssert.DeleteDockerImagesAsync(output, "app-a"); + await DockerAssert.DeleteDockerImagesAsync(output, "app-b"); + } + } } } diff --git a/test/E2ETest/testassets/generate/apps-with-ingress.yaml b/test/E2ETest/testassets/generate/apps-with-ingress.yaml new file mode 100644 index 00000000..0c707c35 --- /dev/null +++ b/test/E2ETest/testassets/generate/apps-with-ingress.yaml @@ -0,0 +1,146 @@ +kind: Deployment +apiVersion: apps/v1 +metadata: + name: app-a + labels: + app.kubernetes.io/name: 'app-a' + app.kubernetes.io/part-of: 'apps-with-ingress' +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: app-a + template: + metadata: + labels: + app.kubernetes.io/name: 'app-a' + app.kubernetes.io/part-of: 'apps-with-ingress' + spec: + containers: + - name: app-a + image: app-a:1.0.0 + imagePullPolicy: Always + env: + - name: ASPNETCORE_URLS + value: 'http://*' + - name: PORT + value: '80' + - name: SERVICE__APP-B__PROTOCOL + value: 'http' + - name: SERVICE__APP-B__PORT + value: '80' + - name: SERVICE__APP-B__HOST + value: 'app-b' + ports: + - containerPort: 80 +... +--- +kind: Service +apiVersion: v1 +metadata: + name: app-a + labels: + app.kubernetes.io/name: 'app-a' + app.kubernetes.io/part-of: 'apps-with-ingress' +spec: + selector: + app.kubernetes.io/name: app-a + type: ClusterIP + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 80 +... +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: app-b + labels: + app.kubernetes.io/name: 'app-b' + app.kubernetes.io/part-of: 'apps-with-ingress' +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: app-b + template: + metadata: + labels: + app.kubernetes.io/name: 'app-b' + app.kubernetes.io/part-of: 'apps-with-ingress' + spec: + containers: + - name: app-b + image: app-b:1.0.0 + imagePullPolicy: Always + env: + - name: ASPNETCORE_URLS + value: 'http://*' + - name: PORT + value: '80' + - name: SERVICE__APP-A__PROTOCOL + value: 'http' + - name: SERVICE__APP-A__PORT + value: '80' + - name: SERVICE__APP-A__HOST + value: 'app-a' + ports: + - containerPort: 80 +... +--- +kind: Service +apiVersion: v1 +metadata: + name: app-b + labels: + app.kubernetes.io/name: 'app-b' + app.kubernetes.io/part-of: 'apps-with-ingress' +spec: + selector: + app.kubernetes.io/name: app-b + type: ClusterIP + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 80 +... +--- +kind: Ingress +apiVersion: extensions/v1beta1 +metadata: + name: ingress + annotations: + kubernetes.io/ingress.class: 'nginx' + nginx.ingress.kubernetes.io/rewrite-target: '/$2' + labels: + app.kubernetes.io/part-of: 'apps-with-ingress' +spec: + rules: + - http: + paths: + - backend: + serviceName: app-a + servicePort: 80 + path: /A(/|$)(.*) + - backend: + serviceName: app-b + servicePort: 80 + path: /B(/|$)(.*) + - host: a.example.com + http: + paths: + - backend: + serviceName: app-a + servicePort: 80 + path: /()(.*) + - host: b.example.com + http: + paths: + - backend: + serviceName: app-b + servicePort: 80 + path: /()(.*) +... \ No newline at end of file