diff --git a/src/tye/BuildHost.cs b/src/tye/BuildHost.cs new file mode 100644 index 00000000..bfa6f1ab --- /dev/null +++ b/src/tye/BuildHost.cs @@ -0,0 +1,39 @@ +// 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 System.CommandLine; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Tye.ConfigModel; + +namespace Tye +{ + public static class BuildHost + { + public static Task BuildAsync(IConsole console, FileInfo path, Verbosity verbosity, bool interactive) + { + var application = ConfigFactory.FromFile(path); + return ExecuteBuildAsync(new OutputContext(console, verbosity), application, environment: "production", interactive); + } + + public static async Task ExecuteBuildAsync(OutputContext output, ConfigApplication application, string environment, bool interactive) + { + var temporaryApplication = await Program.CreateApplicationAdapterAsync(output, application, interactive, requireRegistry: false); + var steps = new List() + { + new CombineStep() { Environment = environment, }, + new PublishProjectStep(), + new BuildDockerImageStep() { Environment = environment, }, + }; + + var executor = new ServiceExecutor(output, temporaryApplication, steps); + foreach (var service in temporaryApplication.Services) + { + await executor.ExecuteAsync(service); + } + } + } +} diff --git a/src/tye/GenerateHost.cs b/src/tye/GenerateHost.cs index 03232669..fb6382f7 100644 --- a/src/tye/GenerateHost.cs +++ b/src/tye/GenerateHost.cs @@ -11,7 +11,7 @@ using Tye.ConfigModel; namespace Tye { - public class GenerateHost + public static class GenerateHost { public static Task GenerateAsync(IConsole console, FileInfo path, Verbosity verbosity, bool interactive) { @@ -21,7 +21,7 @@ namespace Tye public static async Task ExecuteGenerateAsync(OutputContext output, ConfigApplication application, string environment, bool interactive) { - var temporaryApplication = await Program.CreateApplicationAdapterAsync(output, application, interactive); + var temporaryApplication = await Program.CreateApplicationAdapterAsync(output, application, interactive, requireRegistry: false); var steps = new List() { new CombineStep() { Environment = environment, }, diff --git a/src/tye/Program.BuildCommand.cs b/src/tye/Program.BuildCommand.cs new file mode 100644 index 00000000..9c0ac21d --- /dev/null +++ b/src/tye/Program.BuildCommand.cs @@ -0,0 +1,40 @@ +// 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 System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Tye.ConfigModel; + +namespace Tye +{ + static partial class Program + { + public static Command CreateBuildCommand() + { + var command = new Command("build", "build container for the application") + { + CommonArguments.Path_Required, + StandardOptions.Interactive, + StandardOptions.Verbosity, + }; + + command.Handler = CommandHandler.Create((console, path, verbosity, interactive) => + { + // Workaround for https://github.com/dotnet/command-line-api/issues/723#issuecomment-593062654 + if (path is null) + { + throw new CommandException("No project or solution file was found."); + } + + return BuildHost.BuildAsync(console, path, verbosity, interactive); + }); + + return command; + } + } +} diff --git a/src/tye/Program.DeployCommand.cs b/src/tye/Program.DeployCommand.cs index aeb41fb4..05a27b4f 100644 --- a/src/tye/Program.DeployCommand.cs +++ b/src/tye/Program.DeployCommand.cs @@ -16,7 +16,7 @@ namespace Tye { public static Command CreateDeployCommand() { - var command = new Command("deploy", "Deploy the application") + var command = new Command("deploy", "deploy the application") { CommonArguments.Path_Required, StandardOptions.Interactive, @@ -56,7 +56,7 @@ namespace Tye throw new CommandException($"Cannot apply manifests because kubectl is not connected to a cluster."); } - var temporaryApplication = await CreateApplicationAdapterAsync(output, application, interactive); + var temporaryApplication = await CreateApplicationAdapterAsync(output, application, interactive, requireRegistry: true); var steps = new List() { new CombineStep() { Environment = environment, }, @@ -78,7 +78,7 @@ namespace Tye await DeployApplicationManifestAsync(output, temporaryApplication, application.Source.Directory.Name, environment); } - internal static async Task CreateApplicationAdapterAsync(OutputContext output, ConfigApplication application, bool interactive) + internal static async Task CreateApplicationAdapterAsync(OutputContext output, ConfigApplication application, bool interactive, bool requireRegistry) { var globals = new ApplicationGlobals() { @@ -145,13 +145,20 @@ namespace Tye var temporaryApplication = new TemporaryApplicationAdapter(application, globals, services); if (temporaryApplication.Globals.Registry?.Hostname == null && interactive) { - var registry = output.Prompt("Enter the Container Registry (ex: 'example.azurecr.io' for Azure or 'example' for dockerhub)"); - temporaryApplication.Globals.Registry = new ContainerRegistry(registry); + var registry = output.Prompt("Enter the Container Registry (ex: 'example.azurecr.io' for Azure or 'example' for dockerhub)", allowEmpty: !requireRegistry); + if (!string.IsNullOrWhiteSpace(registry)) + { + temporaryApplication.Globals.Registry = new ContainerRegistry(registry.Trim()); + } } - else if (temporaryApplication.Globals.Registry?.Hostname == null) + else if (temporaryApplication.Globals.Registry?.Hostname == null && requireRegistry) { throw new CommandException("A registry is required for deploy operations. Add the registry to 'tye.yaml' or use '-i' for interactive mode."); } + else + { + // No registry specified, and that's OK! + } foreach (var service in temporaryApplication.Services) { diff --git a/src/tye/Program.GenerateCommand.cs b/src/tye/Program.GenerateCommand.cs index ac883e98..a32a6cc7 100644 --- a/src/tye/Program.GenerateCommand.cs +++ b/src/tye/Program.GenerateCommand.cs @@ -16,7 +16,7 @@ namespace Tye { public static Command CreateGenerateCommand() { - var command = new Command("generate", "Generate kubernetes manifests") + var command = new Command("generate", "generate kubernetes manifests") { CommonArguments.Path_Required, StandardOptions.Interactive, diff --git a/src/tye/Program.cs b/src/tye/Program.cs index cd2a1f98..efc97e85 100644 --- a/src/tye/Program.cs +++ b/src/tye/Program.cs @@ -26,6 +26,7 @@ namespace Tye command.AddCommand(CreateInitCommand()); command.AddCommand(CreateGenerateCommand()); command.AddCommand(CreateRunCommand(args)); + command.AddCommand(CreateBuildCommand()); command.AddCommand(CreateDeployCommand()); // Show commandline help unless a subcommand was used. diff --git a/test/E2ETest/DockerAssert.cs b/test/E2ETest/DockerAssert.cs new file mode 100644 index 00000000..dfaf3dd5 --- /dev/null +++ b/test/E2ETest/DockerAssert.cs @@ -0,0 +1,53 @@ +// 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.CommandLine.Invocation; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace E2ETest +{ + public static class DockerAssert + { + // Repository is the "registry/image" format. Yeah Docker uses that term for it, and it's + // wierd and confusing. + public static async Task AssertImageExistsAsync(ITestOutputHelper output, string repository) + { + if (repository is null) + { + throw new ArgumentNullException(nameof(repository)); + } + + var builder = new StringBuilder(); + + var exitCode = await Process.ExecuteAsync( + "docker", + $"images \"{repository}\" --format \"{{{{.Repository}}}}\"", + stdOut: OnOutput, + stdErr: OnOutput); + if (exitCode != 0) + { + throw new XunitException($"Running `docker images \"{repository}\"` failed." + Environment.NewLine + builder.ToString()); + } + + var lines = builder.ToString().Split(new[] { '\r', '\n', }); + if (lines.Any(line => line == repository)) + { + return; + } + + throw new XunitException($"Image '{repository}' was not found."); + + void OnOutput(string text) + { + builder.AppendLine(text); + output.WriteLine(text); + } + } + } +} diff --git a/test/E2ETest/Properties/AssemblyInfo.cs b/test/E2ETest/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..5ba199a6 --- /dev/null +++ b/test/E2ETest/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// 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 Xunit; + +// We have numerous tests that manipulate machine-wide state (docker, ports, etc) +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly, DisableTestParallelization = true)] diff --git a/test/E2ETest/TestHelpers.cs b/test/E2ETest/TestHelpers.cs index 96690e0d..0e59d6dd 100644 --- a/test/E2ETest/TestHelpers.cs +++ b/test/E2ETest/TestHelpers.cs @@ -1,4 +1,8 @@ -using System; +// 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 System.Text; diff --git a/test/E2ETest/TyeBuildTests.cs b/test/E2ETest/TyeBuildTests.cs new file mode 100644 index 00000000..57ae44ca --- /dev/null +++ b/test/E2ETest/TyeBuildTests.cs @@ -0,0 +1,115 @@ +// 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.IO; +using System.Threading.Tasks; +using Tye; +using Tye.ConfigModel; +using Xunit; +using Xunit.Abstractions; + +namespace E2ETest +{ + public class TyeBuildTests + { + private readonly ITestOutputHelper output; + private readonly TestOutputLogEventSink sink; + + public TyeBuildTests(ITestOutputHelper output) + { + this.output = output; + sink = new TestOutputLogEventSink(output); + } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task SingleProjectBuildTest() + { + var projectName = "single-project"; + var environment = "production"; + + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", projectName)); + using var tempDirectory = TempDirectory.Create(); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + + var application = ConfigFactory.FromFile(projectFile); + + application.Registry = "test"; + + await BuildHost.ExecuteBuildAsync(new OutputContext(sink, Verbosity.Debug), application, environment, interactive: false); + + await DockerAssert.AssertImageExistsAsync(output, "test/test-project"); + } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task FrontendBackendBuildTest() + { + var projectName = "frontend-backend"; + var environment = "production"; + + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", projectName)); + using var tempDirectory = TempDirectory.Create(); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + + var application = ConfigFactory.FromFile(projectFile); + + application.Registry = "test"; + + await BuildHost.ExecuteBuildAsync(new OutputContext(sink, Verbosity.Debug), application, environment, interactive: false); + + await DockerAssert.AssertImageExistsAsync(output, "test/backend"); + await DockerAssert.AssertImageExistsAsync(output, "test/frontend"); + } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task MultipleProjectBuildTest() + { + + var projectName = "multi-project"; + var environment = "production"; + + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", projectName)); + using var tempDirectory = TempDirectory.Create(); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + + var application = ConfigFactory.FromFile(projectFile); + + application.Registry = "test"; + + await BuildHost.ExecuteBuildAsync(new OutputContext(sink, Verbosity.Debug), application, environment, interactive: false); + + await DockerAssert.AssertImageExistsAsync(output, "test/backend"); + await DockerAssert.AssertImageExistsAsync(output, "test/frontend"); + await DockerAssert.AssertImageExistsAsync(output, "test/worker"); + } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task BuildDoesNotRequireRegistry() + { + var projectName = "single-project"; + var environment = "production"; + + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", projectName)); + using var tempDirectory = TempDirectory.Create(); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + + var application = ConfigFactory.FromFile(projectFile); + + await BuildHost.ExecuteBuildAsync(new OutputContext(sink, Verbosity.Debug), application, environment, interactive: false); + + await DockerAssert.AssertImageExistsAsync(output, "test-project"); + } + } +} diff --git a/test/E2ETest/TyeGenerateTests.cs b/test/E2ETest/TyeGenerateTests.cs index f4d604a4..21ec0494 100644 --- a/test/E2ETest/TyeGenerateTests.cs +++ b/test/E2ETest/TyeGenerateTests.cs @@ -102,5 +102,29 @@ namespace E2ETest Assert.Equal(expectedContent, content); } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task GenerateWorksWithoutRegistry() + { + var projectName = "single-project"; + var environment = "production"; + + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", projectName)); + using var tempDirectory = TempDirectory.Create(); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + + var application = ConfigFactory.FromFile(projectFile); + + await GenerateHost.ExecuteGenerateAsync(new OutputContext(sink, Verbosity.Debug), application, environment, interactive: false); + + // name of application is the folder + var content = File.ReadAllText(Path.Combine(tempDirectory.DirectoryPath, $"{projectName}-generate-{environment}.yaml")); + var expectedContent = File.ReadAllText($"testassets/generate/{projectName}-noregistry.yaml"); + + Assert.Equal(expectedContent, content); + } } } diff --git a/test/E2ETest/testassets/generate/single-project-noregistry.yaml b/test/E2ETest/testassets/generate/single-project-noregistry.yaml new file mode 100644 index 00000000..72598a15 --- /dev/null +++ b/test/E2ETest/testassets/generate/single-project-noregistry.yaml @@ -0,0 +1,51 @@ +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-project:1.0.0 + imagePullPolicy: Always + env: + - name: ASPNETCORE_URLS + value: http://*:5000 + ports: + - containerPort: 5001 + - containerPort: 5000 +... +--- +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: test-project + protocol: TCP + port: 5001 + targetPort: 5001 + - name: test-project + protocol: TCP + port: 5000 + targetPort: 5000 +...