Use same directory for Dockerfile and app

Fixes: #189
Fixes: #153

This change rejiggers the docker build infrastructure to publish the project
and generate a Dockerfile in the same directory. This way they can't be on
separate drives!

Also added some focused tests for single-phase docker build.
This commit is contained in:
Ryan Nowak 2020-03-24 22:17:52 -07:00
Родитель 944027b30c
Коммит 97e2c6c0ba
24 изменённых файлов: 480 добавлений и 53 удалений

Просмотреть файл

@ -34,55 +34,93 @@ namespace Microsoft.Tye
throw new ArgumentNullException(nameof(container));
}
using var tempFile = TempFile.Create();
var dockerFilePath = Path.Combine(project.ProjectFile.DirectoryName, "Dockerfile");
if (File.Exists(dockerFilePath))
{
output.WriteDebugLine($"Using existing Dockerfile '{dockerFilePath}'.");
}
else
{
await DockerfileGenerator.WriteDockerfileAsync(output, application, project, container, tempFile.FilePath);
dockerFilePath = tempFile.FilePath;
}
// We need to know if this is a single-phase or multi-phase Dockerfile because the context directory will be
// different depending on that choice.
string contextDirectory;
if (container.UseMultiphaseDockerfile ?? true)
var dockerFilePath = Path.Combine(project.ProjectFile.DirectoryName, "Dockerfile");
TempFile? tempFile = null;
TempDirectory? tempDirectory = null;
try
{
contextDirectory = ".";
}
else
{
var publishOutput = project.Outputs.OfType<ProjectPublishOutput>().FirstOrDefault();
if (publishOutput is null)
// We need to know if this is a single-phase or multi-phase Dockerfile because the context directory will be
// different depending on that choice.
//
// For the cases where generate a Dockerfile, we have the constraint that we need
// to place it on the same drive (Windows) as the docker context.
if (container.UseMultiphaseDockerfile ?? true)
{
throw new InvalidOperationException("We should have published the project for a single-phase Dockerfile.");
// For a multi-phase Docker build, the context is always the project directory.
contextDirectory = ".";
if (File.Exists(dockerFilePath))
{
output.WriteDebugLine($"Using existing Dockerfile '{dockerFilePath}'.");
}
else
{
// We need to write the file, let's stick it under obj.
Directory.CreateDirectory(Path.Combine(project.ProjectFile.DirectoryName, "obj"));
dockerFilePath = Path.Combine(project.ProjectFile.DirectoryName, "obj", "Dockerfile");
// Clean up file when done building image
tempFile = new TempFile(dockerFilePath);
await DockerfileGenerator.WriteDockerfileAsync(output, application, project, container, tempFile.FilePath);
}
}
else
{
// For a single-phase Docker build the context is always the directory containing the publish
// output. We need to put the Dockerfile in the context directory so it's on the same drive (Windows).
var publishOutput = project.Outputs.OfType<ProjectPublishOutput>().FirstOrDefault();
if (publishOutput is null)
{
throw new InvalidOperationException("We should have published the project for a single-phase Dockerfile.");
}
contextDirectory = publishOutput.Directory.FullName;
// Clean up directory when done building image
tempDirectory = new TempDirectory(publishOutput.Directory);
if (File.Exists(dockerFilePath) & container.UseMultiphaseDockerfile == false)
{
output.WriteDebugLine($"Using existing Dockerfile '{dockerFilePath}'.");
File.Copy(dockerFilePath, Path.Combine(contextDirectory, "Dockerfile"));
dockerFilePath = Path.Combine(contextDirectory, "Dockerfile");
}
else
{
// No need to clean up, it's in a directory we're already cleaning up.
dockerFilePath = Path.Combine(contextDirectory, "Dockerfile");
await DockerfileGenerator.WriteDockerfileAsync(output, application, project, container, dockerFilePath);
}
}
contextDirectory = publishOutput.Directory.FullName;
output.WriteDebugLine("Running 'docker build'.");
output.WriteCommandLine("docker", $"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\"");
var capture = output.Capture();
var exitCode = await Process.ExecuteAsync(
$"docker",
$"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\"",
project.ProjectFile.DirectoryName,
stdOut: capture.StdOut,
stdErr: capture.StdErr);
output.WriteDebugLine($"Done running 'docker build' exit code: {exitCode}");
if (exitCode != 0)
{
throw new CommandException("'docker build' failed.");
}
output.WriteInfoLine($"Created Docker Image: '{container.ImageName}:{container.ImageTag}'");
project.Outputs.Add(new DockerImageOutput(container.ImageName!, container.ImageTag!));
}
output.WriteDebugLine("Running 'docker build'.");
output.WriteCommandLine("docker", $"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\"");
var capture = output.Capture();
var exitCode = await Process.ExecuteAsync(
$"docker",
$"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\"",
project.ProjectFile.DirectoryName,
stdOut: capture.StdOut,
stdErr: capture.StdErr);
output.WriteDebugLine($"Done running 'docker build' exit code: {exitCode}");
if (exitCode != 0)
finally
{
throw new CommandException("'docker build' failed.");
tempDirectory?.Dispose();
tempFile?.Dispose();
}
output.WriteInfoLine($"Created Docker Image: '{container.ImageName}:{container.ImageTag}'");
project.Outputs.Add(new DockerImageOutput(container.ImageName!, container.ImageTag!));
}
}
}

Просмотреть файл

@ -30,14 +30,16 @@ namespace Microsoft.Tye
return;
}
var outputDirectory = Path.Combine(project.ProjectFile.DirectoryName, "bin", "Release", project.TargetFramework, "publish");
// NOTE: we're intentionally not cleaning up here. It's the responsibility of whomever consumes
// the publish output to do cleanup.
var outputDirectory = TempDirectory.Create();
output.WriteDebugLine("Running 'dotnet publish'.");
output.WriteCommandLine("dotnet", $"publish \"{project.ProjectFile.FullName}\" -c Release -o \"{outputDirectory}\"");
output.WriteCommandLine("dotnet", $"publish \"{project.ProjectFile.FullName}\" -c Release -o \"{outputDirectory.DirectoryPath}\"");
var capture = output.Capture();
var exitCode = await Process.ExecuteAsync(
$"dotnet",
$"publish \"{project.ProjectFile.FullName}\" -c Release -o \"{outputDirectory}\"",
$"publish \"{project.ProjectFile.FullName}\" -c Release -o \"{outputDirectory.DirectoryPath}\"",
project.ProjectFile.DirectoryName,
stdOut: capture.StdOut,
stdErr: capture.StdErr);
@ -45,11 +47,12 @@ namespace Microsoft.Tye
output.WriteDebugLine($"Done running 'dotnet publish' exit code: {exitCode}");
if (exitCode != 0)
{
outputDirectory.Dispose();
throw new CommandException("'dotnet publish' failed.");
}
output.WriteInfoLine($"Created Publish Output: '{outputDirectory}'");
service.Outputs.Add(new ProjectPublishOutput(new DirectoryInfo(outputDirectory)));
output.WriteDebugLine($"Created Publish Output: '{outputDirectory}'");
service.Outputs.Add(new ProjectPublishOutput(outputDirectory.DirectoryInfo));
}
}
}

Просмотреть файл

@ -20,20 +20,21 @@ namespace Microsoft.Tye
var directoryPath = Path.Combine(baseDirectory, Path.GetRandomFileName());
var directoryInfo = Directory.CreateDirectory(directoryPath);
return new TempDirectory(directoryPath, directoryInfo);
return new TempDirectory(directoryInfo);
}
else
{
var directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
var directoryInfo = Directory.CreateDirectory(directoryPath);
return new TempDirectory(directoryPath, directoryInfo);
return new TempDirectory(directoryInfo);
}
}
private TempDirectory(string directoryPath, DirectoryInfo directoryInfo)
internal TempDirectory(DirectoryInfo directoryInfo)
{
DirectoryPath = directoryPath;
DirectoryInfo = directoryInfo;
DirectoryPath = directoryInfo.FullName;
}
public string DirectoryPath { get; }

Просмотреть файл

@ -19,7 +19,7 @@ namespace Microsoft.Tye
File.Delete(FilePath);
}
private TempFile(string filePath)
public TempFile(string filePath)
{
FilePath = filePath;
}

Просмотреть файл

@ -31,6 +31,7 @@
<ItemGroup>
<Content Include="testassets\**\*" CopyToOutputDirectory="PreserveNewest" />
<Compile Remove="testassets\**\*" />
</ItemGroup>
<ItemGroup>

Просмотреть файл

@ -6,6 +6,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Microsoft.Tye;
using Xunit;
namespace E2ETest
{
@ -33,5 +35,34 @@ namespace E2ETest
throw new Exception($"Solution file {solution}.sln could not be found in {applicationBasePath} or its parent directories.");
}
public static DirectoryInfo GetTestAssetsDirectory()
{
return new DirectoryInfo(Path.Combine(
TestHelpers.GetSolutionRootDirectory("tye"),
"test",
"E2ETest",
"testassets"));
}
public static DirectoryInfo GetTestProjectDirectory(string projectName)
{
var directory = new DirectoryInfo(Path.Combine(
TestHelpers.GetSolutionRootDirectory("tye"),
"test",
"E2ETest",
"testassets",
"projects",
projectName));
Assert.True(directory.Exists, $"Project {projectName} not found.");
return directory;
}
internal static TempDirectory CopyTestProjectDirectory(string projectName)
{
var temp = TempDirectory.Create();
DirectoryCopy.Copy(GetTestProjectDirectory(projectName).FullName, temp.DirectoryPath);
return temp;
}
}
}

Просмотреть файл

@ -0,0 +1,89 @@
// 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.Linq;
using System.Threading.Tasks;
using Microsoft.Tye;
using Xunit;
using static E2ETest.TestHelpers;
namespace E2ETest
{
// Tests permutations of our Dockerfile-related behaviors.
public partial class TyeBuildTests
{
[ConditionalFact]
[SkipIfDockerNotRunning]
public async Task TyeBuild_SinglePhase_GeneratedDockerfile()
{
var projectName = "single-phase-dockerfile";
var environment = "production";
var imageName = "test/single-phase-dockerfile";
await DockerAssert.DeleteDockerImagesAsync(output, imageName);
using var projectDirectory = CopyTestProjectDirectory(projectName);
File.Delete(Path.Combine(projectDirectory.DirectoryPath, "Dockerfile"));
Assert.False(File.Exists(Path.Combine(projectDirectory.DirectoryPath, "Dockerfile")), "Dockerfile should be gone.");
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye.yaml"));
var outputContext = new OutputContext(sink, Verbosity.Debug);
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile);
application.Registry = new ContainerRegistry("test");
try
{
await BuildHost.ExecuteBuildAsync(outputContext, application, environment, interactive: false);
var publishOutput = Assert.Single(application.Services.Single().Outputs.OfType<ProjectPublishOutput>());
Assert.False(Directory.Exists(publishOutput.Directory.FullName), $"Directory {publishOutput.Directory.FullName} should be deleted.");
await DockerAssert.AssertImageExistsAsync(output, imageName);
}
finally
{
await DockerAssert.DeleteDockerImagesAsync(output, imageName);
}
}
[ConditionalFact]
[SkipIfDockerNotRunning]
public async Task TyeBuild_SinglePhase_ExistingDockerfile()
{
var projectName = "single-phase-dockerfile";
var environment = "production";
var imageName = "test/single-phase-dockerfile";
await DockerAssert.DeleteDockerImagesAsync(output, imageName);
using var projectDirectory = CopyTestProjectDirectory(projectName);
Assert.True(File.Exists(Path.Combine(projectDirectory.DirectoryPath, "Dockerfile")), "Dockerfile should exist.");
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye.yaml"));
var outputContext = new OutputContext(sink, Verbosity.Debug);
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile);
application.Registry = new ContainerRegistry("test");
try
{
await BuildHost.ExecuteBuildAsync(outputContext, application, environment, interactive: false);
var publishOutput = Assert.Single(application.Services.Single().Outputs.OfType<ProjectPublishOutput>());
Assert.False(Directory.Exists(publishOutput.Directory.FullName), $"Directory {publishOutput.Directory.FullName} should be deleted.");
await DockerAssert.AssertImageExistsAsync(output, imageName);
}
finally
{
await DockerAssert.DeleteDockerImagesAsync(output, imageName);
}
}
}
}

Просмотреть файл

@ -9,7 +9,7 @@ using Xunit.Abstractions;
namespace E2ETest
{
public class TyeBuildTests
public partial class TyeBuildTests
{
private readonly ITestOutputHelper output;
private readonly TestOutputLogEventSink sink;

Просмотреть файл

@ -226,7 +226,7 @@ namespace E2ETest
await host.StartAsync();
try
{
// Make sure we're runningn containers
// Make sure we're running containers
Assert.True(host.Application.Services.All(s => s.Value.Description.RunInfo is DockerRunInfo));
var handler = new HttpClientHandler

Просмотреть файл

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace multi_phase_dockerfile
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}

Просмотреть файл

@ -0,0 +1,27 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:39765",
"sslPort": 44389
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"multi_phase_dockerfile": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

Просмотреть файл

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace multi_phase_dockerfile
{
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});
});
}
}
}

Просмотреть файл

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

Просмотреть файл

@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

Просмотреть файл

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>multi_phase_dockerfile</RootNamespace>
</PropertyGroup>
</Project>

Просмотреть файл

@ -0,0 +1,10 @@
# tye application configuration file
# read all about it at https://github.com/dotnet/tye
#
# when you've given us a try, we'd love to know what you think:
# https://aka.ms/AA7q20u
#
name: multi-phase-dockerfile
services:
- name: multi-phase-dockerfile
project: multi-phase-dockerfile.csproj

Просмотреть файл

@ -0,0 +1,4 @@
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY . /app
ENTRYPOINT ["dotnet", "single-phase-dockerfile.dll"]

Просмотреть файл

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace single_phase_dockerfile
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}

Просмотреть файл

@ -0,0 +1,27 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:15313",
"sslPort": 44306
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"single_phase_dockerfile": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

Просмотреть файл

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace single_phase_dockerfile
{
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});
});
}
}
}

Просмотреть файл

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

Просмотреть файл

@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

Просмотреть файл

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>single_phase_dockerfile</RootNamespace>
</PropertyGroup>
</Project>

Просмотреть файл

@ -0,0 +1,10 @@
# tye application configuration file
# read all about it at https://github.com/dotnet/tye
#
# when you've given us a try, we'd love to know what you think:
# https://aka.ms/AA7q20u
#
name: single-phase-dockerfile
services:
- name: single-phase-dockerfile
project: single-phase-dockerfile.csproj