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
26 changes: 26 additions & 0 deletions .castor/context.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ function create_default_context(): Context
'docker-compose.dev.yml',
],
'docker_compose_run_environment' => [],
// Service in which `docker_compose_run()` executes commands (composer, bin/console...)
'docker_compose_run_service' => 'builder',
'macos' => false,
'power_shell' => false,
// check if posix_geteuid is available, if not, use getmyuid (windows)
Expand Down Expand Up @@ -90,3 +92,27 @@ function create_ci_context(): Context
])
;
}

/**
* Production images (see the "Production stages" of the Dockerfile): the same tasks as in
* dev, on a dedicated compose stack. E.g. `castor start -c prod`, `castor docker:push -c prod`.
*/
#[AsContext(name: 'prod')]
function create_prod_context(): Context
{
$c = create_default_context();

return $c->withData(
[
// Dedicated compose project: never collides with the dev stack (containers, volumes)
'project_name' => $c['project_name'] . '-prod',
'registry' => getenv('REGISTRY') ?: $c['registry'],
'docker_compose_files' => [
'docker-compose.prod.yml',
],
// There is no builder service in this stack: commands run in the application image
'docker_compose_run_service' => 'php',
],
recursive: false,
);
}
59 changes: 54 additions & 5 deletions .castor/docker.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ function about(): void
io()->comment('Run <comment>castor help [command]</comment> to display Castor help.');

io()->section('Available URLs for this project:');

if (!has_router()) {
io()->listing([\sprintf('http://127.0.0.1:%s', getenv('HTTP_PORT') ?: '8080')]);

return;
}

$urls = [variable('root_domain'), ...variable('extra_domains')];

try {
Expand Down Expand Up @@ -215,6 +222,11 @@ function destroy(
}

docker_compose(['down', '--remove-orphans', '--volumes', '--rmi=local'], profiles: ['*']);

if (!has_router()) {
return;
}

$files = finder()
->in(variable('root_dir') . '/infrastructure/docker/services/router/certs/')
->name('*.pem')
Expand All @@ -228,6 +240,12 @@ function generate_certificates(
#[AsOption(description: 'Force the certificates re-generation without confirmation', shortcut: 'f')]
bool $force = false,
): void {
if (!has_router()) {
io()->comment('No router in this stack, no SSL certificates to generate.');

return;
}

$sslDir = variable('root_dir') . '/infrastructure/docker/services/router/certs';

if (file_exists("{$sslDir}/cert.pem") && !$force) {
Expand Down Expand Up @@ -408,12 +426,13 @@ function docker_compose(array $subCommand, ?Context $c = null, array $profiles =
function docker_compose_run(
array $params,
?Context $c = null,
string $service = 'builder',
?string $service = null,
bool $noDeps = true,
?string $workDir = null,
bool $portMapping = false,
): Process {
$c ??= context();
$service ??= $c['docker_compose_run_service'];

$command = [
'run',
Expand Down Expand Up @@ -478,9 +497,27 @@ function docker_exit_code(
return $process->getExitCode() ?? 0;
}

#[AsTask(description: 'Push images cache to the registry', namespace: 'docker', name: 'push', aliases: ['push'])]
function push(bool $dryRun = false): void
/**
* Whether the current stack includes the traefik router (dev), which needs SSL certificates
* and gives the project its URLs.
*/
function has_router(): bool
{
return isset(get_services()['router']);
}

/**
* Pushes the build cache of every service declaring a `cache_from`. With `--tag`, the images
* themselves are pushed too, e.g. `castor docker:push -c prod --tag=abc1234 --tag=latest`.
*
* @param list<string> $tag
*/
#[AsTask(description: 'Push images cache (and images, with --tag) to the registry', namespace: 'docker', name: 'push')]
function push(
bool $dryRun = false,
#[AsOption(description: 'Also push the images, with this tag (repeatable)', mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)]
array $tag = [],
): void {
$registry = variable('registry');

if (!$registry) {
Expand Down Expand Up @@ -522,12 +559,17 @@ function push(bool $dryRun = false): void
}
}

// 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),
];
}

Expand All @@ -542,21 +584,28 @@ function push(bool $dryRun = false): void
);

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'], $target['dockerfile'], $target['reference'], $target['type'], $target['reference'], $target['target'], variable('php_version')
$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')
);
}

Expand All @@ -575,7 +624,7 @@ function push(bool $dryRun = false): void
}

/**
* @return array<string, array{profiles?: list<string>, build: array{context: string, dockerfile?: string, cache_from?: list<string>, target?: string}}>
* @return array<string, array{image?: string, profiles?: list<string>, build: array{context: string, dockerfile?: string, cache_from?: list<string>, target?: string, additional_contexts?: array<string, string>}}>
*/
function get_services(?string $profile = null): array
{
Expand Down
41 changes: 41 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Used by production builds (additional build context "app" pointing here).
# Only source files needed at runtime should be sent to the build context.
.git
.github
.castor
.home
doc
infrastructure
node_modules
tests
tools
var
vendor
.editorconfig
.gitattributes
.php-cs-fixer.php
phpstan-baseline.neon
phpstan.neon
rector.php
.env.dev
.env.test
*.md
castor.php
.gitignore
LICENSE
phpunit.xml.dist

# Mirrors .gitignore: local/generated files that must never end up baked
# into the image (some carry secrets, e.g. the prod secrets decryption key)
.castor.stub.php
.php-cs-fixer.cache
.env.local
.env.local.php
.env.*.local
.phpunit.result.cache
phpunit.xml
config/secrets/prod/prod.decrypt.private.php
public/assets
public/bundles
public/uploads
assets/vendor
42 changes: 42 additions & 0 deletions .github/workflows/build-push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Build and push production images

"on":
push:
branches: ["main"]
tags: ["*"]

permissions:
contents: read
packages: write

env:
# Fix for symfony/color detection. We know GitHub Actions can handle it
ANSICON: 1
CASTOR_CONTEXT: prod
REGISTRY: ghcr.io/${{ github.repository }}

jobs:
build-push:
name: Build and push production images
runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Log in to registry
shell: bash
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- uses: actions/checkout@v7

- name: setup-castor
uses: castor-php/setup-castor@v1.1.0

# Images are tagged with the short commit sha (what a deployment should
# reference), "latest" on main, and the git tag when there is one
- name: "Build and push the production images"
run: >-
castor docker:push
--tag="${GITHUB_SHA::7}"
${{ github.ref_name == 'main' && '--tag=latest' || '' }}
${{ github.ref_type == 'tag' && format('--tag={0}', github.ref_name) || '' }}
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ To make the application available locally at the address
services:
frontend:
ports:
- "8000:80"
- "8000:8080"
```

> [!NOTE]
Expand Down Expand Up @@ -79,9 +79,56 @@ services:
volumes:
- .:/app
ports:
- 8888:80
- 8888:8080
```

## Production

The application ships as two self-contained Docker images, built from the
"production stages" of `infrastructure/docker/services/php/Dockerfile`:

* `php`: php-fpm listening on the unix socket `/var/run/php/php-fpm.sock`,
with the code, the vendors and the compiled assets baked in, `APP_ENV=prod`.
It is also the CLI image: the cron job (`bin/console qotd:run`) and the
database migrations run with it;
* `nginx`: the official nginx image, the compiled `public/` directory and the
site configuration, forwarding PHP requests to that socket.

Both use the php-fpm and nginx configuration of the dev container
(`services/php/php/` and `services/php/nginx/`); what production does
differently is in `services/php/php/mods-available/app-prod.ini`. Everything
else (secrets, database,
Slack and Google credentials) is provided through environment variables at
runtime, and `public/uploads` (medias downloaded from Slack) must be a volume
shared by all the containers.

On every push to `main` (and on every git tag), the "Build and push production
images" workflow pushes both images to `ghcr.io/<repository>/php` and
`ghcr.io/<repository>/nginx`, tagged with the short commit sha, `latest` on
`main`, and the tag name when there is one.

### Testing the production images locally

The `prod` castor context runs the usual tasks on a dedicated compose stack
(`docker-compose.prod.yml`: postgres + the two images, no bind mount, no
router), independent from the development one:

castor build -c prod # builds the php and nginx images
castor start -c prod # starts the stack and runs the migrations
# -> http://127.0.0.1:8080 (HTTP_PORT=18080 castor start -c prod to change the port)
castor destroy -c prod # removes the containers and the volumes

It uses dummy secrets: to test a real Google login or a real Slack workspace,
put the corresponding variables in a `.env.prod.local` file at the root of the
repository (ignored by git, loaded by the php container). `APP_DEFAULT_URI`
and the Google callback URL must then match your local URL.

To push images from your machine instead of waiting for the CI (you need to be
logged in to the registry with `docker login ghcr.io`, and a buildx builder
able to export a registry cache, e.g. `docker buildx create --use`):

REGISTRY=ghcr.io/<org>/<repo> castor docker:push -c prod --tag=my-test

## Usage

In slack you have one commands
Expand Down
7 changes: 7 additions & 0 deletions castor.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use function docker\about;
use function docker\build;
use function docker\docker_compose_run;
use function docker\get_services;
use function docker\up;

guard_min_version('1.5.0');
Expand Down Expand Up @@ -51,6 +52,12 @@ function install(): void
{
io()->title('Installing the application');

if (!isset(get_services()['builder'])) {
io()->comment('No builder service in this stack: the application is embedded in the images, nothing to install.');

return;
}

$basePath = variable('root_dir');

if (is_file("{$basePath}/composer.json")) {
Expand Down
Loading
Loading