diff --git a/.castor/context.php b/.castor/context.php index e8d1230..49c2e1e 100644 --- a/.castor/context.php +++ b/.castor/context.php @@ -7,6 +7,7 @@ use Symfony\Component\Process\Process; use function Castor\log; +use function worktree\get_worktree_name; #[AsContext(default: true)] function create_default_context(): Context @@ -52,6 +53,11 @@ function create_default_context(): Context $data['user_id'] = 1000; } + $worktreeName = get_worktree_name($data['root_dir']); + if (null !== $worktreeName) { + $data['project_name'] .= '-wt-' . $worktreeName; + } + return new Context( $data, pty: Process::isPtySupported(), diff --git a/.castor/database.php b/.castor/database.php deleted file mode 100644 index ea1f761..0000000 --- a/.castor/database.php +++ /dev/null @@ -1,17 +0,0 @@ -title('Connecting to the PostgreSQL database'); - - docker_compose(['exec', 'postgres', 'psql', '-U', 'qotd', 'qotd'], context()->toInteractive()); -} diff --git a/.castor/docker.php b/.castor/docker.php index 76834d6..36da635 100644 --- a/.castor/docker.php +++ b/.castor/docker.php @@ -6,6 +6,8 @@ use Castor\Attribute\AsOption; use Castor\Attribute\AsTask; use Castor\Context; +use Castor\Helper\PathHelper; +use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Process\Exception\ExceptionInterface; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -22,6 +24,18 @@ use function Castor\open; use function Castor\run; use function Castor\variable; +use function worktree\get_worktree_name; +use function worktree\get_worktree_ports; + +/** @return array */ +function get_port_specs(): array +{ + return [ + 'http' => ['env' => 'PROJECT_HTTP_PORT', 'default' => 80, 'label' => 'HTTP'], + 'https' => ['env' => 'PROJECT_HTTPS_PORT', 'default' => 443, 'label' => 'HTTPS'], + 'admin' => ['env' => 'PROJECT_ADMIN_PORT', 'default' => 8080, 'label' => 'Traefik admin'], + ]; +} #[AsTask(description: 'Displays some help and available urls for the current project', namespace: '')] function about(): void @@ -42,9 +56,12 @@ function about(): void $urls = [variable('root_domain'), ...variable('extra_domains')]; + $worktreeName = get_worktree_name(); + $adminPort = get_worktree_ports($worktreeName)['admin']; + try { $routers = http_client() - ->request('GET', \sprintf('http://%s:8080/api/http/routers', variable('root_domain'))) + ->request('GET', \sprintf('http://%s:%d/api/http/routers', variable('root_domain'), $adminPort)) ->toArray() ; $projectName = variable('project_name'); @@ -64,7 +81,12 @@ function about(): void } catch (HttpExceptionInterface) { } - io()->listing(array_map(static fn ($url) => "https://{$url}", array_unique($urls))); + $httpsPort = null !== $worktreeName ? get_worktree_ports($worktreeName)['https'] : null; + $formatUrl = static function (string $host) use ($httpsPort): string { + return null !== $httpsPort ? "https://{$host}:{$httpsPort}" : "https://{$host}"; + }; + + io()->listing(array_map($formatUrl, array_unique($urls))); } #[AsTask(description: 'Opens the project in your browser', namespace: '', aliases: ['open'])] @@ -75,7 +97,7 @@ function open_project(): void #[AsTask(description: 'Builds the infrastructure', aliases: ['build'])] function build( - #[AsOption(description: 'The service to build (default: all services)')] + #[AsOption(description: 'The service to build (default: all services)', autocomplete: 'docker\complete_service_names')] ?string $service = null, ?string $profile = null, ): void { @@ -111,6 +133,7 @@ function build( */ #[AsTask(description: 'Builds and starts the infrastructure', aliases: ['up'])] function up( + #[AsOption(description: 'The service to start (default: all services)', autocomplete: 'docker\complete_service_names')] ?string $service = null, #[AsOption(mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] array $profiles = [], @@ -141,6 +164,7 @@ function up( */ #[AsTask(description: 'Stops the infrastructure', aliases: ['stop'])] function stop( + #[AsOption(description: 'The service to stop (default: all services)', autocomplete: 'docker\complete_service_names')] ?string $service = null, #[AsOption(mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] array $profiles = [], @@ -158,6 +182,9 @@ function stop( docker_compose($command, profiles: $profiles); } +/** + * @param list $params + */ #[AsTask(description: 'Opens a shell (bash) or proxy any command to the builder container', aliases: ['builder'])] function builder(#[AsArgsAfterOptionEnd] array $params = []): int { @@ -341,7 +368,7 @@ function workers_start(): void $matches[1], $matches[2], implode(', ', $profiles), - $r->getFileName(), + PathHelper::makeRelative((string) $r->getFileName()), $r->getStartLine(), )); } @@ -351,48 +378,55 @@ function workers_start(): void function workers_stop(): void { io()->title('Stopping workers'); + $workers = get_service_names(profile: 'worker'); - // Docker compose cannot stop a single service in a profile, if it depends - // on another service in another profile. To make it work, we need to select - // both profiles, and so stop both services - - // So we find all services, in all profiles, and manually filter the one - // that has the "worker" profile, then we stop it - $command = ['stop']; - - foreach (get_services() as $name => $service) { - foreach ($service['profiles'] ?? [] as $profile) { - if ('worker' === $profile) { - $command[] = $name; + if ([] === $workers) { + io()->error('No worker service found.'); - continue 2; - } - } + return; } - docker_compose($command, profiles: ['*']); + docker_compose(['stop', ...$workers], profiles: ['*']); } /** - * @param list $subCommand - * @param list $profiles + * @return array */ -function docker_compose(array $subCommand, ?Context $c = null, array $profiles = []): Process +function get_compose_environment(Context $c): array { - $c ??= context(); - $profiles = $profiles ?: ['default']; - $domains = [$c['root_domain'], ...$c['extra_domains']]; $domains = '`' . implode('`) || Host(`', $domains) . '`'; - $c = $c->withEnvironment([ + return [ 'PROJECT_NAME' => $c['project_name'], 'PROJECT_ROOT_DOMAIN' => $c['root_domain'], 'PROJECT_DOMAINS' => $domains, 'USER_ID' => $c['user_id'], 'PHP_VERSION' => $c['php_version'], 'REGISTRY' => $c['registry'] ?? '', - ]); + ]; +} + +/** + * @param list $subCommand + * @param list $profiles + */ +function docker_compose(array $subCommand, ?Context $c = null, array $profiles = []): Process +{ + $c ??= context(); + $profiles = $profiles ?: ['default']; + + $c = $c->withEnvironment(get_compose_environment($c)); + + $worktreeName = get_worktree_name(); + if ($worktreeName) { + $ports = get_worktree_ports($worktreeName); + $portsEnv = []; + foreach (get_port_specs() as $key => $spec) { + $portsEnv[$spec['env']] = (string) $ports[$key]; + } + $c = $c->withEnvironment($portsEnv); + } if ($c['APP_ENV'] ?? false) { $c = $c->withEnvironment([ @@ -474,6 +508,36 @@ function docker_compose_run( return docker_compose($command, c: $c, profiles: ['*']); } +/** + * @param list $params + */ +function docker_compose_exec( + array $params, + ?Context $context = null, + string $service = 'builder', +): Process { + $context ??= context(); + + $command = [ + 'exec', + ]; + + if (0 === \count($params)) { + $params = ['bash']; + $context = $context->toInteractive(); + } else { + $context = $context->withTty(false)->withPty(false)->withInput(\STDIN)->withAllowFailure(); + $params = array_map(escapeshellarg(...), $params); + } + + $command[] = $service; + $command[] = '/bin/bash'; + $command[] = '-c'; + $command[] = implode(' ', $params); + + return docker_compose($command, c: $context, profiles: ['*']); +} + /** * @param list $params */ @@ -524,103 +588,46 @@ function push( throw new \RuntimeException('You must define a registry to push images.'); } - // Generate bake file - $targets = []; - - foreach (get_services() as $service => $config) { - $cacheFrom = $config['build']['cache_from'][0] ?? null; + // Only services with a cache_from can push their build cache back to the registry. + $services = array_filter(get_services(), static fn (array $config) => isset($config['build']['cache_from'][0])); - if (null === $cacheFrom) { - continue; - } - - $cacheFrom = explode(',', $cacheFrom); - $reference = null; - $type = null; + $c = context() + ->withEnvironment(get_compose_environment(context())) + ->withWorkingDirectory(variable('root_dir') . '/infrastructure/docker') + ; - if (1 === \count($cacheFrom)) { - $reference = $cacheFrom[0]; - $type = 'registry'; - } else { - foreach ($cacheFrom as $part) { - $from = explode('=', $part); + $command = ['docker', 'buildx', 'bake']; - if (2 !== \count($from)) { - continue; - } + foreach ($c['docker_compose_files'] as $file) { + $command[] = '-f'; + $command[] = $file; + } - if ('type' === $from[0]) { - $type = $from[1]; - } + $command[] = '--set'; + $command[] = '*.args.PHP_VERSION=' . $c['php_version']; - if ('ref' === $from[0]) { - $reference = $from[1]; - } - } - } + foreach ($services as $service => $config) { + $command[] = '--set'; + $command[] = "{$service}.cache-to={$config['build']['cache_from'][0]},mode=max"; // Image name without its tag, e.g. "ghcr.io/jolicode/qotd/php" $image = isset($config['image']) ? preg_replace('{:[^/]+$}', '', $config['image']) : "{$registry}/{$service}"; - $targets[] = [ - 'reference' => $reference, - 'type' => $type, - 'context' => $config['build']['context'], - 'additional_contexts' => $config['build']['additional_contexts'] ?? [], - 'dockerfile' => $config['build']['dockerfile'] ?? 'Dockerfile', - 'target' => $config['build']['target'] ?? null, - 'tags' => array_map(static fn (string $t) => "{$image}:{$t}", $tag), - ]; - } - - $content = \sprintf( - <<<'EOHCL' - group "default" { - targets = [%s] - } - - EOHCL, - implode(', ', array_map(static fn ($target) => \sprintf('"%s"', $target['target']), $targets)) - ); + foreach ($tag as $t) { + $command[] = '--set'; + $command[] = "{$service}.tags={$image}:{$t}"; + } + } - foreach ($targets as $target) { - $hclList = static fn (array $values) => implode(', ', array_map(static fn ($v) => \sprintf('"%s"', $v), $values)); - $contexts = implode('', array_map(static fn ($name, $path) => \sprintf("\n %s = \"%s\"", $name, $path), array_keys($target['additional_contexts']), $target['additional_contexts'])); - - $content .= \sprintf( - <<<'EOHCL' - target "%s" { - context = "%s" - contexts = {%s - } - dockerfile = "%s" - cache-from = ["%s"] - cache-to = ["type=%s,ref=%s,mode=max"] - target = "%s" - tags = [%s] - output = [%s] - args = { - PHP_VERSION = "%s" - } - } - - EOHCL, - $target['target'], $target['context'], $contexts, $target['dockerfile'], $target['reference'], $target['type'], $target['reference'], $target['target'], $hclList($target['tags']), $hclList($target['tags'] ? ['type=registry'] : []), variable('php_version') - ); + if ($tag) { + $command[] = '--push'; } if ($dryRun) { - io()->write($content); - - return; + $command[] = '--print'; } - // write bake file in tmp file - $bakeFile = tempnam(sys_get_temp_dir(), 'bake'); - file_put_contents($bakeFile, $content); - - // Run bake - run(['docker', 'buildx', 'bake', '-f', $bakeFile]); + run([...$command, ...array_keys($services)], context: $c); } /** @@ -656,3 +663,36 @@ function get_service_names(?string $profile = null): array { return array_keys(get_services($profile)); } + +/** + * Autocompletion of the --service options. + * + * @return list + */ +function complete_service_names(CompletionInput $input): array +{ + return get_service_names(); +} + +#[AsTask(description: 'Displays the ports allocated for the current project', namespace: 'docker')] +function ports(): void +{ + $project = variable('project_name'); + $domain = variable('root_domain'); + $name = get_worktree_name(); + $ports = get_worktree_ports($name); + + io()->title('Ports'); + $list = [['Project' => $project]]; + foreach (get_port_specs() as $key => $spec) { + $scheme = 'admin' === $key ? 'http' : $key; + $host = 'admin' === $key ? 'localhost' : $domain; + $list[] = [$spec['label'] => "{$scheme}://{$host}:{$ports[$key]}"]; + } + + if ($name) { + array_unshift($list, ['Worktree' => $name]); + } + + io()->definitionList(...$list); +} diff --git a/.castor/postgres.php b/.castor/postgres.php new file mode 100644 index 0000000..3ad2dd2 --- /dev/null +++ b/.castor/postgres.php @@ -0,0 +1,44 @@ +": proxies anything after "--" to + * psql inside the postgres container, in a non-interactive way with the exit + * code propagated. + * + * castor pg # interactive psql + * castor pg -- "SELECT now();" # one-shot query + * castor pg -- -c "\dt" # raw psql arguments + * + * @param list $params + */ +#[AsTask(description: 'Connect to the PostgreSQL database', aliases: ['postgres', 'pg'])] +function client(#[AsArgsAfterOptionEnd] array $params = []): int +{ + $command = ['psql', '-U', 'qotd', 'qotd']; + + if (!$params) { + io()->title('Connecting to the PostgreSQL database'); + + docker_compose(['exec', 'postgres', ...$command], context()->toInteractive()); + + return 0; + } + + // Rebuild the query as a single psql argument, as the shell splits it + // into multiple words ("castor pg -- SELECT 1;" gives ['SELECT', '1;']) + if (!str_starts_with((string) reset($params), '-')) { + $params = ['-c', implode(' ', $params)]; + } + + return (int) docker_compose_exec([...$command, ...$params], service: 'postgres')->getExitCode(); +} diff --git a/.castor/worktree.php b/.castor/worktree.php new file mode 100644 index 0000000..c339e31 --- /dev/null +++ b/.castor/worktree.php @@ -0,0 +1,62 @@ + */ +function get_worktree_ports(?string $worktreeName): array +{ + $specs = get_port_specs(); + + if (null === $worktreeName) { + $defaults = []; + foreach ($specs as $key => $spec) { + $defaults[$key] = $spec['default']; + } + + return $defaults; + } + + $hash = crc32($worktreeName) & 0x7FFFFFFF; + $base = 10000 + ($hash % 50000); + + $i = 0; + $ports = []; + foreach ($specs as $key => $spec) { + $ports[$key] = $base + $i; + ++$i; + } + + return $ports; +} diff --git a/AGENTS.md b/AGENTS.md index ab430e2..91457e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ castor app:db:migrate # Doctrine migrations (alias: casto castor app:db:fixtures # fixtures (alias: castor fixtures) castor app:cache-clear # clears var/cache (alias: castor cache-clear) castor postgres # opens a psql shell (alias: castor pg) +castor pg -- "SELECT now();" # one-shot psql query (or raw psql args: castor pg -- -c "\dt") ``` Docker / workers: @@ -76,7 +77,8 @@ After any PHP/Twig code change: `castor qa:cs --dry-run`, 1. **Never invoke `docker compose` by hand**: use the `docker_compose()` / `docker_compose_run()` functions from `.castor/docker.php` to write new tasks. -2. **Never hardcode ports or project names**: use `variable('project_name')` etc. +2. **Never hardcode ports or project names**: git worktree support automatically + isolates project/volumes/ports (`castor docker:ports`). Use `variable('project_name')` etc. 3. New recurring task? Make it a Castor task (`castor.php` or `.castor/*.php`), not a shell script. 4. QA tool dependencies live in `tools//composer.json` diff --git a/infrastructure/docker/services/php/Dockerfile b/infrastructure/docker/services/php/Dockerfile index 238cda3..fa82920 100644 --- a/infrastructure/docker/services/php/Dockerfile +++ b/infrastructure/docker/services/php/Dockerfile @@ -68,7 +68,7 @@ FROM php-base AS builder SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update \ - && apt-get install -y --no-install-recommends git make php${PHP_VERSION}-dev sudo unzip \ + && apt-get install -y --no-install-recommends git make php${PHP_VERSION}-dev unzip \ && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* RUN phpenmod app-default && phpenmod app-builder @@ -84,10 +84,13 @@ RUN sed /tmp/completion.bash -e "s/{{ COMMAND_NAME }}/composer/g" -e 's/"-a{{ VE ENV PATH="$PATH:/var/www/tools/bin" -RUN cat >> /etc/bash.bashrc <> /etc/bash.bashrc <<'EOF' . /etc/bash_completion -PS1='\[\e[01;33m\]\u \[\e[00;32m\]\w\[\e[0m\] ' +PS1='\[\e[01;33m\]$([ "$EUID" = 0 ] && echo root || echo builder) \[\e[00;32m\]\w\[\e[0m\] ' EOF WORKDIR /var/www