Implement Keystone environment deployments
This commit is contained in:
192
app/Actions/Environments/BuildApplicationArtifact.php
Normal file
192
app/Actions/Environments/BuildApplicationArtifact.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\BuildStrategy;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Services\Operations\RemoteCommandRunner;
|
||||
use RuntimeException;
|
||||
|
||||
class BuildApplicationArtifact
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RemoteCommandRunner $remoteCommandRunner,
|
||||
) {}
|
||||
|
||||
public function execute(BuildArtifact $artifact, ?Operation $operation = null): BuildArtifact
|
||||
{
|
||||
$artifact->loadMissing('environment.application', 'builtByService.server', 'builtByService.replicas.server');
|
||||
|
||||
$application = $artifact->environment->application;
|
||||
$strategy = BuildStrategy::tryFrom($artifact->metadata['build_strategy'] ?? BuildStrategy::TARGET_SERVER->value)
|
||||
?? BuildStrategy::TARGET_SERVER;
|
||||
|
||||
$server = $this->buildServer($artifact);
|
||||
|
||||
$artifact->update([
|
||||
'status' => BuildArtifactStatus::BUILDING,
|
||||
'built_by_operation_id' => $operation?->id,
|
||||
]);
|
||||
|
||||
try {
|
||||
$output = $this->remoteCommandRunner->run(
|
||||
$server,
|
||||
$strategy === BuildStrategy::EXTERNAL_REGISTRY
|
||||
? $this->manifestDigestScript($artifact)
|
||||
: $this->buildScript($artifact, $strategy)
|
||||
);
|
||||
|
||||
$artifact->update([
|
||||
'image_digest' => $this->digestFromOutput($output),
|
||||
'status' => BuildArtifactStatus::AVAILABLE,
|
||||
]);
|
||||
|
||||
return $artifact->refresh();
|
||||
} catch (\Throwable $exception) {
|
||||
$artifact->update([
|
||||
'status' => BuildArtifactStatus::FAILED,
|
||||
'metadata' => [
|
||||
...($artifact->metadata ?? []),
|
||||
'error' => $exception->getMessage(),
|
||||
],
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildServer(BuildArtifact $artifact): Server
|
||||
{
|
||||
if ($artifact->builtByService instanceof Service) {
|
||||
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
|
||||
|
||||
if ($server instanceof Server) {
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
|
||||
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
|
||||
throw new RuntimeException('Dedicated builder strategy requires a builder service.');
|
||||
}
|
||||
|
||||
$services = $artifact->environment->services()
|
||||
->with(['server', 'replicas.server'])
|
||||
->get();
|
||||
|
||||
$server = $services
|
||||
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
|
||||
->first() ?: $services->pluck('server')->filter()->first();
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
$serverId = $services
|
||||
->flatMap(fn (Service $service) => collect($service->config['server_ids'] ?? []))
|
||||
->filter()
|
||||
->first();
|
||||
|
||||
$server = $serverId ? Server::find($serverId) : null;
|
||||
}
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
throw new RuntimeException('A target server is required to build this artifact over SSH.');
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
private function buildScript(BuildArtifact $artifact, BuildStrategy $strategy): string
|
||||
{
|
||||
$application = $artifact->environment->application;
|
||||
|
||||
if (! $application->deploy_key_private) {
|
||||
throw new RuntimeException('Application does not have a deploy key.');
|
||||
}
|
||||
|
||||
$operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8);
|
||||
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
||||
$pushCommand = $strategy === BuildStrategy::DEDICATED_BUILDER && $artifact->registry_ref
|
||||
? "\ndocker push ".escapeshellarg($imageReference)
|
||||
: '';
|
||||
|
||||
return implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'operation_dir='.escapeshellarg($operationDirectory),
|
||||
'source_dir="$operation_dir/source"',
|
||||
'rm -rf "$operation_dir"',
|
||||
'mkdir -p "$operation_dir"',
|
||||
'chmod 700 "$operation_dir"',
|
||||
'cleanup() { rm -rf "$operation_dir"; }',
|
||||
'trap cleanup EXIT',
|
||||
$this->writeFileCommand('$operation_dir/deploy_key', $application->deploy_key_private),
|
||||
'chmod 600 "$operation_dir/deploy_key"',
|
||||
'export GIT_SSH_COMMAND="ssh -i $operation_dir/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no"',
|
||||
'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"',
|
||||
$this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)),
|
||||
'cd "$source_dir"',
|
||||
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .'.$pushCommand,
|
||||
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
|
||||
'printf "image_digest=%s\n" "$digest"',
|
||||
]);
|
||||
}
|
||||
|
||||
private function manifestDigestScript(BuildArtifact $artifact): string
|
||||
{
|
||||
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
||||
|
||||
return implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'manifest=$(docker manifest inspect '.escapeshellarg($imageReference).')',
|
||||
'digest=$(printf "%s" "$manifest" | sed -n '.escapeshellarg('s/.*"digest": "\(sha256:[^"]*\)".*/\1/p').' | head -n 1)',
|
||||
'test -n "$digest"',
|
||||
'printf "image_digest=%s\n" "$digest"',
|
||||
]);
|
||||
}
|
||||
|
||||
private function dockerfile(BuildArtifact $artifact): string
|
||||
{
|
||||
$service = $artifact->environment->services()
|
||||
->where('type', \App\Enums\ServiceType::LARAVEL)
|
||||
->first();
|
||||
|
||||
if ($service && method_exists($service->driver(), 'dockerfileTemplate')) {
|
||||
return $service->driver()->dockerfileTemplate();
|
||||
}
|
||||
|
||||
return <<<'DOCKERFILE'
|
||||
FROM serversideup/php:8.4-frankenphp
|
||||
WORKDIR /var/www/html
|
||||
COPY --chown=www-data:www-data . .
|
||||
RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
|
||||
ENV SERVER_DOCUMENT_ROOT=/var/www/html/public
|
||||
DOCKERFILE;
|
||||
}
|
||||
|
||||
private function writeFileCommand(string $path, string $contents): string
|
||||
{
|
||||
return implode("\n", [
|
||||
'cat > '.$path." <<'KEYSTONE_FILE'",
|
||||
rtrim($contents),
|
||||
'KEYSTONE_FILE',
|
||||
]);
|
||||
}
|
||||
|
||||
private function digestFromOutput(string $output): string
|
||||
{
|
||||
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
|
||||
return $this->digestFromOutput($matches['digest']);
|
||||
}
|
||||
|
||||
if (str_contains($output, '@')) {
|
||||
return str($output)->after('@')->trim()->value();
|
||||
}
|
||||
|
||||
if (str_starts_with($output, 'sha256:')) {
|
||||
return $output;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to resolve built image digest.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user