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 { $buildServerId = (int) ($artifact->metadata['build_server_id'] ?? 0); if ($buildServerId > 0) { $server = Server::find($buildServerId); if ($server instanceof Server && $server->build_enabled) { return $server; } throw new RuntimeException('Configured build server is missing or not build-enabled.'); } 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 or build-enabled server.'); } $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; $publishCommands = $artifact->registry_ref && $strategy !== BuildStrategy::EXTERNAL_REGISTRY ? [ ...$this->pushDigestCommands($imageReference), ] : [ 'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.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"', ...$this->registryMaintenanceLockCommands($artifact), ...$this->buildAuthCommands($artifact), 'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .', ...$publishCommands, 'printf "image_digest=%s\n" "$digest"', ]); } /** * @return array */ private function registryMaintenanceLockCommands(BuildArtifact $artifact): array { if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) { return []; } return [ 'install -d -m 700 -o root -g root /home/keystone/registry', 'exec 9>/home/keystone/registry/maintenance.lock', 'flock 9', ]; } /** * @return array */ private function buildAuthCommands(BuildArtifact $artifact): array { if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) { return []; } $registry = app(RegistryResolver::class)->buildRegistryFor($artifact->environment->application->organisation); if (! $registry instanceof Registry || $registry->type !== RegistryType::MANAGED || ! $registry->credentials) { throw new RuntimeException('Managed registry build credentials are not configured.'); } $auth = app(RegistryDockerAuthScript::class)->forBuild($registry, 'root'); $script = $auth['script']; foreach ($auth['secrets'] as $key => $value) { $script = str_replace("[!{$key}!]", $value, $script); } return [$script]; } private function manifestDigestScript(BuildArtifact $artifact): string { $imageReference = $artifact->registry_ref ?: $artifact->image_tag; return implode("\n", [ 'set -euo pipefail', ...$this->manifestDigestCommands($imageReference), 'printf "image_digest=%s\n" "$digest"', ]); } /** * @return array */ private function manifestDigestCommands(string $imageReference): array { return [ 'inspect_output=$(docker buildx imagetools inspect '.escapeshellarg($imageReference).')', 'digest=$(printf "%s\n" "$inspect_output" | sed -n '.escapeshellarg('s/^Digest:[[:space:]]*\(sha256:[^[:space:]]*\).*/\1/p').' | head -n 1)', 'test -n "$digest"', ]; } /** * @return array */ private function pushDigestCommands(string $imageReference): array { return [ 'push_output=$(docker push '.escapeshellarg($imageReference).')', 'printf "%s\n" "$push_output"', 'digest=$(printf "%s\n" "$push_output" | sed -n '.escapeshellarg('s/.*digest: \(sha256:[^[:space:]]*\).*/\1/p').' | tail -n 1)', 'test -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=(?\S+)/', $output, $matches)) { $output = $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.'); } }