Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Changed

* The parallel builds show their progress live: one line per service in a terminal, every line prefixed by its service anywhere else.

## 0.10.0 - 2026-10-06

### Added
Expand Down
6 changes: 4 additions & 2 deletions doc/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ 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
`--parallel` builds each service on its own, all of them in parallel. A
terminal shows one line per service with the step it is at, and the full log of
the builds that failed once they are all done; anywhere else, a CI log or a
pipe, every line streams as it comes, prefixed by its service. 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
Expand Down
152 changes: 152 additions & 0 deletions src/Build/BoardBuildReporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

declare(strict_types=1);

namespace Castor\Docker\Build;

use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Terminal;

/**
* One line per service, redrawn in place with the step it is at, the way
* BuildKit shows a single build in a terminal. The log of a failed build is
* printed in full once they are all done.
*/
final class BoardBuildReporter implements BuildReporter
{
private const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
private const REFRESH = 0.05;
private const SPIN = 0.08;

/** @var array<string, array{start: float, end: ?float, successful: ?bool, steps: array<int, string>, active: array<int, true>, log: list<string>}> */
private array $builds = [];
private int $width;
private float $renderedAt = 0;

/**
* @param non-empty-list<string> $services
*/
public function __construct(
private readonly ConsoleSectionOutput $section,
private readonly OutputInterface $output,
array $services,
private readonly ?int $terminalWidth = null,
) {
$this->width = max(array_map(strlen(...), $services));
$now = microtime(true);

foreach ($services as $service) {
$this->builds[$service] = ['start' => $now, 'end' => null, 'successful' => null, 'steps' => [], 'active' => [], 'log' => []];
}
}

public function line(string $service, string $line): void
{
$build = &$this->builds[$service];
$build['log'][] = $line;

// "#5 [app 2/8] RUN ..." names step 5, then "#5 0.42 <output>" and
// "#5 DONE 1.2s" follow it.
if (!preg_match('{^#(\d+) (.+)$}', $line, $matches)) {
return;
}

$id = (int) $matches[1];
$rest = $matches[2];

if (preg_match('{^(DONE|CACHED|ERROR|CANCELED)\b}', $rest)) {
unset($build['active'][$id]);
} elseif (!isset($build['steps'][$id])) {
$build['steps'][$id] = $rest;
$build['active'][$id] = true;
}
}

public function tick(): void
{
$now = microtime(true);

if ($now - $this->renderedAt < self::REFRESH) {
return;
}

$this->renderedAt = $now;
$this->section->overwrite($this->lines($now));
}

public function finish(string $service, bool $successful): void
{
$build = &$this->builds[$service];
$build['end'] = microtime(true);
$build['successful'] = $successful;
}

public function end(): void
{
$this->section->overwrite($this->lines(microtime(true)));

foreach ($this->builds as $service => $build) {
if (false !== $build['successful']) {
continue;
}

$this->output->writeln('');
$this->output->writeln(\sprintf('<fg=red>%s</>', $service));

foreach ($build['log'] as $line) {
$this->output->writeln(OutputFormatter::escape($line));
}
}
}

/**
* @return list<string>
*/
public function lines(float $now): array
{
$frame = self::SPINNER[(int) ($now / self::SPIN) % \count(self::SPINNER)];
$columns = $this->terminalWidth ?? (new Terminal())->getWidth();
$lines = [];

foreach ($this->builds as $service => $build) {
$elapsed = \sprintf('%.1fs', ($build['end'] ?? $now) - $build['start']);

[$icon, $text] = match ($build['successful']) {
true => ['<fg=green>✔</>', 'built'],
false => ['<fg=red>✘</>', 'failed'],
null => ["<fg=yellow>{$frame}</>", $this->currentStep($build['steps'], $build['active'])],
};

// Icon, two spaces, the name, two spaces, the step, a space, the time.
$room = max(0, $columns - 1 - 2 - $this->width - 2 - 1 - \strlen($elapsed) - 1);
$text = Helper::width($text) > $room ? mb_substr($text, 0, max(0, $room - 1)) . '…' : $text;

$lines[] = \sprintf(
'%s %s %s %s<fg=gray>%s</>',
$icon,
str_pad($service, $this->width),
OutputFormatter::escape($text),
str_repeat(' ', max(0, $room - Helper::width($text))),
$elapsed,
);
}

return $lines;
}

/**
* @param array<int, string> $steps
* @param array<int, true> $active
*/
private function currentStep(array $steps, array $active): string
{
if ($active) {
return $steps[array_key_last($active)];
}

return $steps ? $steps[array_key_last($steps)] : 'starting';
}
}
22 changes: 22 additions & 0 deletions src/Build/BuildReporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Castor\Docker\Build;

/**
* Shows several builds running at once, fed line by line.
*/
interface BuildReporter
{
public function line(string $service, string $line): void;

/**
* Called every few milliseconds while the builds run, to animate what needs to be.
*/
public function tick(): void;

public function finish(string $service, bool $successful): void;

public function end(): void;
}
43 changes: 43 additions & 0 deletions src/Build/StreamedBuildReporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Castor\Docker\Build;

use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Every line as it comes, prefixed by its service the way "docker compose
* logs" does: what a CI log, or any output that is not a terminal, can show.
*/
final class StreamedBuildReporter implements BuildReporter
{
private const COLORS = ['cyan', 'yellow', 'green', 'magenta', 'blue', 'red'];

/** @var array<string, string> */
private array $prefixes = [];

/**
* @param non-empty-list<string> $services
*/
public function __construct(private readonly OutputInterface $output, array $services)
{
$width = max(array_map(strlen(...), $services));

foreach ($services as $i => $service) {
$this->prefixes[$service] = \sprintf('<fg=%s>%s</> | ', self::COLORS[$i % \count(self::COLORS)], str_pad($service, $width));
}
}

public function line(string $service, string $line): void
{
$this->output->writeln($this->prefixes[$service] . OutputFormatter::escape($line));
}

public function tick(): void {}

public function finish(string $service, bool $successful): void {}

public function end(): void {}
}
19 changes: 17 additions & 2 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ function get_default_profiles(?Context $c = null): array
}

/**
* The command docker_compose() runs, and the context to run it in.
*
* @param list<string> $subCommand
* @param list<string> $profiles
* @param ?string $progress "auto", "tty", "plain", "json" or "quiet", left to compose when null
*
* @return array{list<string>, Context}
*/
function docker_compose(array $subCommand, ?Context $c = null, array $profiles = [], ?string $progress = null): Process
function docker_compose_command(array $subCommand, ?Context $c = null, array $profiles = [], ?string $progress = null): array
{
$c ??= context();
$profiles = $profiles ?: get_default_profiles($c);
Expand Down Expand Up @@ -120,6 +123,18 @@ function docker_compose(array $subCommand, ?Context $c = null, array $profiles =

$command = array_merge($command, $subCommand);

return [$command, $c];
}

/**
* @param list<string> $subCommand
* @param list<string> $profiles
* @param ?string $progress "auto", "tty", "plain", "json" or "quiet", left to compose when null
*/
function docker_compose(array $subCommand, ?Context $c = null, array $profiles = [], ?string $progress = null): Process
{
[$command, $c] = docker_compose_command($subCommand, $c, $profiles, $progress);

// The router is not a service of this file: it joins the project network
// from the outside, so it is attached after "up" and detached before "down".
$network = get_project_network($c);
Expand Down
Loading
Loading