diff --git a/CHANGELOG.md b/CHANGELOG.md index 018cd5d..069e65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Added + +* [`docker:build --parallel`](tasks.md#castor-dockerbuild) and the `build_parallel` context variable: each service builds on its own, in parallel, so services sharing a stage keep their build cache ([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). `docker:push` follows them too. + +### Removed + +* The GitHub Actions cache 0.9.0 added to every build on GitHub Actions. The registry cache misses it made up for come from a BuildKit race between caches describing the same stages, which a second cache only makes worse, and the `docker` driver of the runners failed the build exporting it. A service still takes any cache given to its `cacheFrom()`. + ## 0.9.0 - 2026-10-06 ### Added diff --git a/doc/configuration.md b/doc/configuration.md index 027ff1f..18abe46 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -33,6 +33,7 @@ function default_context(): Context | `resolve_domains_via_host` | Whether the containers resolve the project's own public domains, `true` by default — see [below](#resolving-your-own-domains-from-a-container) | | `docker_profiles` | The compose profiles every task activates by default, `['default']` otherwise | | `router_autostart` | Whether `docker:up` starts the global router and `docker:stop` stops it, `true` by default — see [below](#starting-and-stopping-the-router-with-your-projects) | +| `build_parallel` | Whether the services are built on their own, in parallel, so services sharing a stage keep their build cache, `false` by default — see [`docker:build`](tasks.md#castor-dockerbuild) | | `worktree` | The git worktree this checkout is, detected on boot — see [git worktrees](going-further/worktrees.md) | | `worktree_isolation` | Whether a git worktree runs a stack of its own, `true` by default | | `worktree_directory` | Where `worktree:create` checks a worktree out | diff --git a/doc/tasks.md b/doc/tasks.md index bb0429a..b84e134 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -58,6 +58,19 @@ castor docker:build app # a single service castor docker:build --profiles builder # restrict to a profile ``` +`--parallel` builds each service on its own, all of them in parallel, and +prints the log of each one once it is done. Built together, services sharing a +stage — an application and its builder sharing `php-base` — race their build +caches, and BuildKit randomly rebuilds the steps of all but one of them +([moby/buildkit#6418](https://github.com/moby/buildkit/issues/6418)). Turn it on +for a whole project with the `build_parallel` context variable: `castor docker:up +--build` and `castor docker:push` follow it too, `--no-parallel` turns it off +for a single command. + +```bash +castor docker:build --parallel +``` + Alias: `castor build`. ### `castor docker:up` @@ -260,37 +273,6 @@ castor docker:push --tag "$(git rev-parse --short HEAD)" castor docker:push --dry-run ``` -#### On GitHub Actions - -When `GITHUB_ACTIONS` is set, every registry cache gets a -[GitHub Actions cache](https://docs.docker.com/build/cache/backends/gha/) next -to it, scoped by the cache name: every build reads both and writes the GitHub -one, `castor docker:push` writes both. BuildKit sometimes misses layers of a -registry cache without a word, the second one makes up for it. - -What a pull request writes stays with it: its next runs reuse it, the default -branch never sees it. The layers it shares with the default branch are not -stored twice. - -Buildx reaches that cache with variables Actions only hands to JavaScript -actions, so every job that builds has to expose them first, reading the cache -needing them as much as writing it: - -```yaml -- uses: actions/github-script@v9 - with: - script: | - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN) - core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL) - core.exportVariable('ACTIONS_CACHE_SERVICE_V2', process.env.ACTIONS_CACHE_SERVICE_V2 || 'true') - -- run: castor docker:build -``` - -Without them buildx skips the cache silently, and the build only uses the -registry. A matrix building several variants of the same service has to give -each one its own cache name, or the jobs overwrite each other's cache. - #### Publishing to ghcr.io GitHub attaches a package to a repository in two cases only: a push from a diff --git a/phpstan.neon b/phpstan.neon index 4154bd7..2c296c6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -13,6 +13,7 @@ parameters: docker_profiles?: string[], resolve_domains_via_host?: bool, router_autostart?: bool, + build_parallel?: bool, worktree?: ?string, worktree_isolation?: bool, worktree_directory?: string, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7123d00..ab06bc6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,11 +6,6 @@ cacheDirectory=".phpunit.cache" failOnRisky="true" > - - - - - tests/Unit diff --git a/src/Service/Builder/BuildBuilder.php b/src/Service/Builder/BuildBuilder.php index 38b4f2b..68c31fd 100644 --- a/src/Service/Builder/BuildBuilder.php +++ b/src/Service/Builder/BuildBuilder.php @@ -26,8 +26,6 @@ final class BuildBuilder private array $additionalContexts = []; /** @var array */ private array $cacheFrom = []; - /** @var array */ - private array $cacheTo = []; public function __construct(private readonly ServiceBuilder $serviceBuilder) {} @@ -40,7 +38,6 @@ public function clone(ServiceBuilder $serviceBuilder): self $new->args = $this->args; $new->additionalContexts = $this->additionalContexts; $new->cacheFrom = $this->cacheFrom; - $new->cacheTo = $this->cacheTo; return $new; } @@ -101,7 +98,6 @@ public function cacheFrom(string $image): self public function noCacheFrom(): self { $this->cacheFrom = []; - $this->cacheTo = []; return $this; } @@ -109,16 +105,6 @@ public function noCacheFrom(): self public function withRegistryCache(string $image): self { $this->cacheFrom = ['type=registry,ref=${REGISTRY:-}/' . $image . ':cache']; - $this->cacheTo = []; - - // BuildKit silently misses layers of a registry cache on CI - // (jolicode/docker-starter#430), the GitHub Actions one backs it up. - // Every build writes it: GitHub keeps what a pull request writes to - // that pull request, whose next runs reuse it. - if ('true' === getenv('GITHUB_ACTIONS')) { - $this->cacheFrom[] = 'type=gha,scope=' . $image; - $this->cacheTo = ['type=gha,scope=' . $image . ',mode=max,ignore-error=true']; - } return $this; } @@ -159,10 +145,6 @@ public function toArray(): array $build['cache_from'] = $this->cacheFrom; } - if (!empty($this->cacheTo)) { - $build['cache_to'] = $this->cacheTo; - } - return $build; } } diff --git a/src/tasks.php b/src/tasks.php index bb6af59..9a9ccdc 100644 --- a/src/tasks.php +++ b/src/tasks.php @@ -8,6 +8,7 @@ use Castor\Attribute\AsOption; use Castor\Attribute\AsRawTokens; use Castor\Attribute\AsTask; +use Castor\Context; use Castor\Console\Output\VerbosityLevel; use Castor\Docker\Doctor\Doctor; use Castor\Docker\Doctor\HostSystemProbe; @@ -19,6 +20,7 @@ use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Terminal; use Symfony\Component\Process\Exception\ExceptionInterface; use Symfony\Component\Process\Process; @@ -27,6 +29,7 @@ use function Castor\capture; use function Castor\context; use function Castor\io; +use function Castor\parallel; use function Castor\variable; use function Castor\run; @@ -39,6 +42,8 @@ function build( ?string $service = null, #[AsOption(mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] array $profiles = [], + #[AsOption(description: 'Build each service on its own, in parallel, so services sharing a stage keep their build cache', mode: InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE)] + ?bool $parallel = null, ): void { io()->title('Building infrastructure'); @@ -56,16 +61,96 @@ function build( $command[] = "{$key}={$value}"; } - if ($service) { - $command[] = $service; - } - if (!$profiles) { $profiles = get_default_profiles(); $profiles[] = 'builder'; } - docker_compose($command, profiles: $profiles); + $services = $service || !is_build_parallel($parallel) ? [] : get_buildable_services($profiles); + + if (\count($services) < 2) { + docker_compose($service ? [...$command, $service] : $command, profiles: $profiles); + + return; + } + + build_separately($command, $services, $profiles); +} + +/** + * Off by default: the logs of the services only show once each one is built. + */ +function is_build_parallel(?bool $option = null, ?Context $c = null): bool +{ + return $option ?? (($c ?? context())->data['build_parallel'] ?? false); +} + +/** + * One build per service, run in parallel: in a single session BuildKit merges + * the stages services share, then races the caches of those services over them + * and randomly loses the steps of all but one (moby/buildkit#6418). + * + * @param list $command + * @param list $services + * @param list $profiles + */ +function build_separately(array $command, array $services, array $profiles): void +{ + $c = context()->withQuiet()->withAllowFailure()->withTty(false)->withPty(false); + + $processes = parallel(...array_map( + static fn(string $service): \Closure => static fn(): Process => docker_compose([...$command, $service], $c, $profiles, 'plain'), + $services, + )); + + report_separate_builds($services, $processes); +} + +/** + * @param list $services + * @param array $processes + */ +function report_separate_builds(array $services, array $processes): void +{ + $failed = []; + + foreach ($services as $i => $service) { + $process = $processes[$i]; + + if (!$process instanceof Process) { + $failed[] = $service; + + continue; + } + + io()->section($service); + io()->write($process->getOutput() . $process->getErrorOutput(), false, OutputInterface::OUTPUT_RAW); + + if (!$process->isSuccessful()) { + $failed[] = $service; + } + } + + if ($failed) { + throw new \RuntimeException(\sprintf('The build of %s failed.', implode(', ', $failed))); + } +} + +/** + * @param list $profiles + * + * @return list + */ +function get_buildable_services(array $profiles): array +{ + $config = json_decode( + docker_compose(['config', '--format', 'json'], context()->withQuiet(), profiles: $profiles)->getOutput(), + true, + ); + + $services = \is_array($config['services'] ?? null) ? $config['services'] : []; + + return array_map('strval', array_keys(array_filter($services, static fn(mixed $config): bool => \is_array($config) && isset($config['build'])))); } /** @@ -689,6 +774,8 @@ function push( bool $dryRun = false, #[AsOption(description: 'The tag the images are published under')] string $tag = 'latest', + #[AsOption(description: 'Build each target on its own, in parallel, so targets sharing a stage keep their build cache', mode: InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE)] + ?bool $parallel = null, ): void { $registry = variable('registry'); @@ -730,16 +817,17 @@ function push( // apply: bake sees every service that has a "build". $command = ['docker', 'buildx', 'bake', '-f', $c->workingDirectory . '/compose.yaml']; + $options = []; + foreach ($targets as $service => $cacheEntries) { $images = []; + $options[$service] = []; foreach ($cacheEntries as $cacheTo) { $cacheRef = get_cache_reference($cacheTo); - $command[] = '--set'; - // A GitHub Actions cache only backs the registry one up: its - // outage must not keep the images from being pushed. - $command[] = \sprintf('%s.cache-to=%s,mode=max%s', $service, $cacheTo, str_contains($cacheTo, 'type=gha') ? ',ignore-error=true' : ''); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.cache-to=%s,mode=max', $service, $cacheTo); // "type=gha" or "type=local" names no repository to publish an // image to. @@ -763,32 +851,26 @@ function push( } foreach ($images as $image) { - $command[] = '--set'; - $command[] = \sprintf('%s.tags=%s', $service, $image); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.tags=%s', $service, $image); } // Per target, rather than a global "--push": the services whose cache // is not a registry one must not be pushed anywhere. - $command[] = '--set'; - $command[] = \sprintf('%s.output=type=registry', $service); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.output=type=registry', $service); if (null !== $source) { - $command[] = '--set'; - $command[] = \sprintf('%s.labels.org.opencontainers.image.source=%s', $service, $source); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.labels.org.opencontainers.image.source=%s', $service, $source); } if (null !== $revision) { - $command[] = '--set'; - $command[] = \sprintf('%s.labels.org.opencontainers.image.revision=%s', $service, $revision); + $options[$service][] = '--set'; + $options[$service][] = \sprintf('%s.labels.org.opencontainers.image.revision=%s', $service, $revision); } } - if ($dryRun) { - $command[] = '--print'; - } - - // Naming the targets keeps out the services that build without a cache: - // bake's default group is every buildable service of the project. - run([...$command, ...array_keys($targets)], context: $c->withEnvironment([ + $c = $c->withEnvironment([ // bake does not go through docker_compose(), so the variables the // generated compose file interpolates have to be given to it here. 'COMPOSE_PROJECT_NAME' => get_project_name($c), @@ -797,7 +879,26 @@ function push( // The build contexts live in the plugin, outside of the project // directory, which bake asks to confirm on every run otherwise. 'BUILDX_BAKE_ENTITLEMENTS_FS' => '0', - ])); + ]); + + // Naming the targets keeps out the services that build without a cache: + // bake's default group is every buildable service of the project. + if ($dryRun || !is_build_parallel($parallel)) { + run([...$command, ...array_merge(...array_values($options)), ...($dryRun ? ['--print'] : []), ...array_keys($targets)], context: $c); + + return; + } + + // One bake per target, for the reason build_separately() gives. + $quiet = $c->withQuiet()->withAllowFailure()->withTty(false)->withPty(false); + $services = array_keys($targets); + + $processes = parallel(...array_map( + static fn(string $service): \Closure => static fn(): Process => run([...$command, ...$options[$service], '--progress', 'plain', $service], context: $quiet), + $services, + )); + + report_separate_builds($services, $processes); } /** diff --git a/tests/Integration/DockerPushTest.php b/tests/Integration/DockerPushTest.php index 63a4958..192f45c 100644 --- a/tests/Integration/DockerPushTest.php +++ b/tests/Integration/DockerPushTest.php @@ -74,35 +74,6 @@ public function testEveryCacheOfAServiceIsPushed(): void static::assertSame(['registry.invalid/ns/mirrored:latest', 'registry.invalid/ns/mirror:latest'], $target['tags']); } - /** - * The GitHub Actions cache comes on top of the registry one, which the - * images still go to. The cache_to every build writes the GitHub one with - * gives way to the one exporting both. - */ - public function testOnGithubActionsTheCacheIsAlsoPushedToGithub(): void - { - $castor = $this->castorOrSkip(); - - $push = $this->castor($castor, ['docker:push', '--dry-run'], [ - 'CASTOR_DOCKER_TEST_REGISTRY' => 'registry.invalid/ns', - 'GITHUB_ACTIONS' => 'true', - ]); - - static::assertTrue($push->isSuccessful(), "castor docker:push --dry-run failed:\n" . $push->getOutput() . $push->getErrorOutput()); - - $target = json_decode($push->getOutput(), true)['target']['cached']; - - static::assertSame([ - ['ref' => 'registry.invalid/ns/cached:cache', 'type' => 'registry'], - ['scope' => 'cached', 'type' => 'gha'], - ], $target['cache-from']); - static::assertSame([ - ['mode' => 'max', 'ref' => 'registry.invalid/ns/cached:cache', 'type' => 'registry'], - ['ignore-error' => 'true', 'mode' => 'max', 'scope' => 'cached', 'type' => 'gha'], - ], $target['cache-to']); - static::assertSame(['registry.invalid/ns/cached:latest'], $target['tags']); - } - /** * The cache alone carries no label, so a registry reading one to attach a * package to a repository — ghcr.io — would file every push under nobody. diff --git a/tests/Unit/Builder/BuildBuilderTest.php b/tests/Unit/Builder/BuildBuilderTest.php index fa282db..4fd298d 100644 --- a/tests/Unit/Builder/BuildBuilderTest.php +++ b/tests/Unit/Builder/BuildBuilderTest.php @@ -14,35 +14,20 @@ protected function tearDown(): void putenv('GITHUB_ACTIONS='); } - public function testARegistryCacheIsTheOnlyCacheOutsideOfGithubActions(): void - { - static::assertSame( - ['cache_from' => ['type=registry,ref=${REGISTRY:-}/app:cache']], - $this->buildWithRegistryCache(), - ); - } - - public function testOnGithubActionsEveryBuildReadsAndWritesTheGithubCache(): void - { - putenv('GITHUB_ACTIONS=true'); - - static::assertSame( - [ - 'cache_from' => ['type=registry,ref=${REGISTRY:-}/app:cache', 'type=gha,scope=app'], - 'cache_to' => ['type=gha,scope=app,mode=max,ignore-error=true'], - ], - $this->buildWithRegistryCache(), - ); - } - /** - * @return array + * A second cache describing the same stages makes BuildKit race them and + * lose steps (moby/buildkit#6418): GitHub Actions gets no cache of its own. */ - private function buildWithRegistryCache(): array + public function testTheRegistryCacheIsTheOnlyCacheEvenOnGithubActions(): void { + putenv('GITHUB_ACTIONS=true'); + $builder = new ComposeBuilder(); $builder->service('app')->build()->withRegistryCache('app'); - return $builder->toArray()['services']['app']['build']; + static::assertSame( + ['cache_from' => ['type=registry,ref=${REGISTRY:-}/app:cache']], + $builder->toArray()['services']['app']['build'], + ); } }