Editor/Window/Containers/ConfigureDCIStep.cs (296 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
using AmazonGameLift.Runtime;
using AmazonGameLiftPlugin.Core.Shared.FileSystem;
using System;
using System.IO;
using System.ComponentModel;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.Build;
using UnityEngine;
using UnityEngine.UIElements;
using static AmazonGameLift.Editor.GameLiftPlugin;
namespace AmazonGameLift.Editor
{
public class ConfigureDCIStep : ContainerStepComponent
{
private const string DefaultImageTag = "unity-gamelift-plugin";
private const string DockerBuildLogFileName = "BuildDockerContainerImageOutput.txt";
private DeploymentStepTemplate _stepContent;
private GameLiftSynchronizationContext _mainThreadContext;
private string _logOutputDirectory;
private string _logFilePath;
private string _dockerfilePath;
private string _imageTag;
private Button _viewDockerfileButton;
private Button _proceedButton;
private Button _tryAgainButton;
private Button _viewLogButton;
public ConfigureDCIStep(VisualElement container, StateManager stateManager): base(container, stateManager, "EditorWindow/Components/Containers/ConfigureDCIStep")
{
_stepContent = new DeploymentStepTemplate.Builder(Strings.ContainerConfigureDciStepTitle, Strings.ContainerConfigureDciStepDescription)
.WithHelpLinks(
new DeploymentStepTemplateLink(Urls.DockerHomepage, Strings.ContainerLinksDockerDocumentationLabel),
new DeploymentStepTemplateLink(Urls.InstallDockerEngine, Strings.ContainerLinksDockerInstallLabel))
.WithBaseButtons()
.Build(container);
_proceedButton = _stepContent.ButtonContainer.Q<Button>(DeploymentStepTemplate.BaseButtonProceed);
_tryAgainButton = _stepContent.ButtonContainer.Q<Button>(DeploymentStepTemplate.BaseButtonTryAgain);
_viewLogButton = _stepContent.ButtonContainer.Q<Button>(DeploymentStepTemplate.BaseButtonViewLogs);
_viewDockerfileButton = _stepContent.ContentContainer.Q<Button>("ViewDockerfileButton");
_proceedButton.RegisterCallback<ClickEvent>(_ => { SaveImageTagAndCompleteStep(); });
_tryAgainButton.RegisterCallback<ClickEvent>(_ => { base.ResetAndTryStart(); });
_viewLogButton.RegisterCallback<ClickEvent>(_ => { Process.Start($"\"{_logFilePath}\""); });
_viewDockerfileButton.RegisterCallback<ClickEvent>(_ => { Process.Start($"\"{GetDockerfileFilePath()}\""); });
Hide(_proceedButton);
Hide(_tryAgainButton);
Hide(_viewLogButton);
Hide(_stepContent.ButtonContainer);
PopulateContent();
// Some Unity functions needs to be executed in main thread (called from async functions)
_mainThreadContext = GameLiftSynchronizationContext.Current;
}
protected sealed override void ResetStep()
{
Hide(_stepContent.ButtonContainer);
_stateManager.IsContainerImageBuilt = false;
_stateManager.IsContainerImageBuilding = false;
_stateManager.ContainerDockerImageId = null;
Show(_viewDockerfileButton);
}
protected sealed override Task StartOrResumeStep()
{
try
{
InitializeArgumentsForDocker();
PopulateContent();
VerifyDockerArguments();
if (_stateManager.IsContainerImageBuilt)
{
_mainThreadContext.Send(_ => CompleteStep(), null);
return Task.CompletedTask;
}
if (_stateManager.IsContainerImageBuilding)
{
ProcessLogFile();
return Task.CompletedTask;
}
StartDockerBuild();
}
catch (Exception e)
{
_mainThreadContext.LogError($"Failed to build docker image due to unexpected exception:\n{e}.");
_mainThreadContext.Send(_ => FailStep(
$"Failed to build docker image due to unexpected exception: {e.Message}." +
$"\nSee Console for full exception.", false), null);
}
return Task.CompletedTask;
}
private void InitializeArgumentsForDocker()
{
_imageTag = DefaultImageTag;
_logOutputDirectory = SetupOutputDirectory();
_logFilePath = Path.Combine(_logOutputDirectory, DockerBuildLogFileName);
_dockerfilePath = GetDockerfileFilePath();
}
private Process StartDockerBuild()
{
var baseDirectory = GetBaseDirectory(_stateManager.ContainerGameServerBuildPath);
var buildDirectory = _stateManager.ContainerGameServerBuildPath.Substring(baseDirectory.Length);
// Execute docker build
Process process = new System.Diagnostics.Process();
process.EnableRaisingEvents = true;
process.Exited += new EventHandler((_, _) => ProcessLogFile());
process.StartInfo.FileName = "powershell";
process.StartInfo.Arguments = $"docker build " +
$"-f \"{_dockerfilePath}\" " +
$"--build-arg GAME_BUILD_DIRECTORY=\"{buildDirectory}\" " +
$"--build-arg GAME_EXECUTABLE=\"{GetRelativeExecutablePath()}\" " +
$"-t \"{_imageTag}\" " +
$"{baseDirectory} 2>&1 " +
$"| tee -filePath \"{_logFilePath}\"";
process.StartInfo.WindowStyle = ProcessWindowStyle.Normal;
_mainThreadContext.Log($"Kicking off docker build process with following command:" +
$"\n{process.StartInfo.Arguments}\n");
process.Start();
_mainThreadContext.Send(_ => UpdateToInProgress(), null);
Hide(_viewDockerfileButton);
return process;
}
private void ProcessLogFile()
{
try
{
if (!File.Exists(_logFilePath))
{
_mainThreadContext.Send(_ => TriggerWarning(
$"Could not find the logs for build process. The file may have been moved." +
$"\nExpected location: {_logFilePath}", _logOutputDirectory), null);
return;
}
var lines = File.ReadLines(_logFilePath);
Regex errorRegex = new Regex("^#\\d+ ERROR");
Regex writingImageRegex = new Regex("^#\\d+ writing image|exporting manifest list sha256:");
bool hitAnError = lines.FirstOrDefault(line => errorRegex.IsMatch(line)) != null;
bool imageWritten = lines.FirstOrDefault(line => writingImageRegex.IsMatch(line)) != null;
if (!hitAnError && imageWritten)
{
_mainThreadContext.Send(_ => SaveImageTagAndCompleteStep(), null);
}
else if (hitAnError && imageWritten)
{
_mainThreadContext.Send(_ => TriggerWarning(
$"Image built with errors. Please check the logs for details." +
$"\nLocation: {_logFilePath}"), null);
}
else if (hitAnError)
{
_mainThreadContext.Send(_ => FailStep(
$"Failed to build docker image due to execution failure. Please check" +
$" the logs for details.\nLocation: {_logFilePath}"), null);
}
else
{
_mainThreadContext.Send(_ => TriggerWarning(
$"Unknown result of docker build process. Please check the logs for details." +
$"\nLocation: {_logFilePath}"), null);
}
}
catch (Exception e)
{
_mainThreadContext.LogError($"Failed to build docker image due to unexpected exception:\n{e}.");
_mainThreadContext.Send(_ => FailStep(
$"Failed to build docker image due to unexpected exception: {e.Message}." +
$"\nSee Console for full exception.", false), null);
}
}
private void PopulateContent()
{
Show(_stepContent.ContentContainer);
_stepContent.ContentContainer.Q<Label>("BuildDirectoryValue").text = DashIfEmpty(_stateManager.ContainerGameServerBuildPath);
_stepContent.ContentContainer.Q<Label>("BuildExecutableValue").text = DashIfEmpty(GetRelativeExecutablePath());
}
private void SaveImageTagAndCompleteStep()
{
PopulateContent();
_stateManager.IsContainerImageBuilt = true;
_stateManager.ContainerDockerImageId = _imageTag;
Show(_viewDockerfileButton);
base.CompleteStep();
}
private void TriggerWarning(string warningMessage, string logPath = null)
{
base.EncounteredException(
statusBoxType: StatusBox.StatusBoxType.Warning,
text: warningMessage,
externalButtonText: "Show logs",
externalButtonLink: logPath ?? _logFilePath,
externalTargetType: StatusBox.StatusBoxExternalTargetType.File
);
Show(_stepContent.ButtonContainer);
Show(_tryAgainButton);
// If a log path is provided, defer to the alert's button
ShowHide(_viewLogButton, logPath == null);
Show(_proceedButton);
Hide(_viewDockerfileButton);
PopulateContent();
}
private void FailStep(string errorMessage, bool withLogs = true)
{
base.EncounteredException(
statusBoxType: StatusBox.StatusBoxType.Error,
text: errorMessage,
externalButtonText: withLogs ? "Show logs" : null,
externalButtonLink: withLogs ? _logFilePath : null,
externalTargetType: StatusBox.StatusBoxExternalTargetType.File
);
Show(_stepContent.ButtonContainer);
Show(_tryAgainButton);
ShowHide(_viewLogButton, withLogs);
Hide(_proceedButton);
Hide(_viewDockerfileButton);
PopulateContent();
}
private void UpdateToInProgress()
{
Show(_stepContent.ButtonContainer);
Hide(_tryAgainButton);
Show(_viewLogButton);
Hide(_proceedButton);
_stateManager.IsContainerImageBuilt = false;
_stateManager.IsContainerImageBuilding = true;
PopulateContent();
}
/**
* Throw exceptions if any state isn't as expected when starting this step.
*/
private void VerifyDockerArguments()
{
var buildPath = _stateManager.ContainerGameServerBuildPath;
var buildPathName = "game server build directory";
var execPath = _stateManager.ContainerGameServerExecutable;
var execPathName = "game server executable";
VerifyNonEmpty(buildPath, buildPathName);
VerifyNonEmpty(execPath, execPathName);
VerifyAbsolutePath(buildPath, buildPathName);
// If the executable is an absolute path, it has to be relative to the build directory
if (Path.IsPathRooted(execPath))
{
VerifyPathsAreRelated(buildPath, buildPathName, execPath, execPathName);
}
var fullExecPath = Path.Combine(buildPath, GetRelativeExecutablePath());
VerifyExists(fullExecPath, execPathName);
VerifyExists(_dockerfilePath, "dockerfile");
}
private void VerifyNonEmpty(string path, string pathName)
=> Verify(() => path == null || path == "", $"The {pathName} is missing");
private void VerifyAbsolutePath(string path, string pathName)
=> Verify(() => !Path.IsPathRooted(path), $"The {pathName} must be an absolute path: {path}");
private void VerifyPathsAreRelated(string path, string pathName, string relatedPath, string relatedPathName)
=> Verify(() => !relatedPath.Contains(path), $"The {relatedPathName} '{relatedPath}' is not relative to the {pathName} '{path}'");
private void VerifyExists(string path, string pathName)
=> Verify(() => !File.Exists(path), $"Could not find {pathName} at path '{path}'");
private void Verify(Func<bool> check, string messageIfFailsCheck)
{
if (check.Invoke())
{
throw new InvalidOperationException(messageIfFailsCheck);
}
}
private string GetRelativeExecutablePath()
{
var buildDirectory = _stateManager.ContainerGameServerBuildPath;
var executablePath = _stateManager.ContainerGameServerExecutable;
if (buildDirectory == null || executablePath == null)
{
return null;
}
/**
* If the executable is an absolute path, split off the build directory from it's path.
* Note, there is verification separately for whether the paths are relative to each other,
* for now just avoid exceptions by also checking that the directory is contained in the exec path.
*/
if (Path.IsPathRooted(executablePath) && executablePath.Contains(buildDirectory))
{
return "." + executablePath.Substring(buildDirectory.Length);
}
return executablePath;
}
private string SetupOutputDirectory()
{
var fileWrapper = new FileWrapper();
string containersPath = PathConverter.SharedInstance.GetContainersAbsolutePath();
// Prepare output directory
string containersOutputDirectory = Path.Combine(containersPath, Paths.ContainersOutputFolderName);
if (!fileWrapper.DirectoryExists(containersOutputDirectory))
{
fileWrapper.CreateDirectory(containersOutputDirectory);
}
return containersOutputDirectory;
}
private string GetDockerfileFilePath()
{
string containersPath = PathConverter.SharedInstance.GetContainersAbsolutePath();
return Path.Combine(containersPath, Paths.ContainerDockerfileFileName);
}
/**
* Returns the directory one level below the provided directory.
* Similar to 'Path.GetDirectoryName' except for the case with trailing /.
* Example:
* GetBaseDirectory('/root/mydir/') -> '/root'
* GetBaseDirectory('/root/mydir') -> '/root'
* Path.GetDirectoryName('/root/mydir/') -> '/root/mydir'
* Path.GetDirectoryName('/root/mydir') -> '/root'
*/
private string GetBaseDirectory(string directoryPath)
{
var potentialContainingDirectory = Path.GetDirectoryName(directoryPath);
/**
* GetDirectoryName('/root/mydir/') -> '/root/mydir'
* GetDirectoryName('/root/mydir') -> '/root'
* If the string changed by 1 char, we know the method removed just the /
* and not a level of the directory. Calling once more will remove the top
* level directory.
* Note even if the top dir has 1 char, the first call would remove 2 char.
* GetDirectoryName('/root/a') -> '/root'
*/
if (potentialContainingDirectory.Length + 1 == directoryPath.Length)
{
return Path.GetDirectoryName(potentialContainingDirectory);
}
return potentialContainingDirectory;
}
}
}