diff --git a/.github/workflows/Docker.yml b/.github/workflows/Docker.yml index 7999b77..48239ed 100644 --- a/.github/workflows/Docker.yml +++ b/.github/workflows/Docker.yml @@ -6,8 +6,39 @@ on: - '*' jobs: + shell_tests: + name: Rebuild App Shell Tests + runs-on: ubuntu-22.04 + + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Run rebuild-app tests + run: >- + npx --yes bats@1.12.0 + tests/rebuild-app-test-helper.bats + tests/rebuild-app-options.bats + tests/rebuild-app-plan.bats + tests/rebuild-app-lifecycle.bats + + - name: Check Bash syntax + run: bash -n rebuild-app lib/rebuild-app/common.sh lib/rebuild-app/plan.sh lib/rebuild-app/docker.sh + + - name: Check immutable image interpolation + run: | + community_images=$(ERAMBA_IMAGE_TAG=3.30.1-6 docker compose \ + -f docker-compose.simple-install.yml config --images) + enterprise_images=$(ERAMBA_IMAGE_TAG=3.30.1-6 docker compose \ + -f docker-compose.simple-install.yml \ + -f docker-compose.simple-install.enterprise.yml config --images) + + test "$(grep -c '^ghcr.io/eramba/eramba:3.30.1-6$' <<<"$community_images")" -eq 2 + test "$(grep -c '^ghcr.io/eramba/eramba-enterprise:3.30.1-6$' <<<"$enterprise_images")" -eq 2 + simple_install: name: Simple Installation [${{ matrix.build-type }}] + needs: shell_tests runs-on: ubuntu-22.04 strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index 925ffa6..d06d091 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ nbproject/* .vscode # Sass preprocessor .sass-cache/ +.rebuild-app/ diff --git a/README.md b/README.md index 72581bb..b889898 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ Welcome to eramba's official Github account, for Docker installs please review our website Learning Platform ([eramba.org](https://www.eramba.org/learning/courses/12/episodes/274)) under Docker Install. The bundled files in `apache/ssl/` are a branded local development certificate intended only for local or simple demo installs. It is signed by a local development CA and will only be trusted on machines where that CA has been installed. Replace it with your own CA-issued certificate and private key for any real deployment. + +## Rebuild the application image + +`./rebuild-app` replaces the application image and `/var/www/eramba` code volume using the canonical image-switch plan returned by the running Eramba application. The running image must include `bin/cake image_switch_plan`. Installations older than that baseline must follow the documented manual image-switch procedure once before using this command; there is no target-tag override. + +Review the complete plan without changing `.env`, containers, or volumes: + +```bash +./rebuild-app --dry-run +``` + +Run an interactive Community rebuild: + +```bash +./rebuild-app +``` + +Enterprise archives must be downloaded manually before the rebuild: + +```bash +./rebuild-app --edition enterprise \ + --image-file eramba-enterprise--amd64.tar +``` + +Update a clean, attached Docker checkout with `git pull --ff-only` before preflight: + +```bash +./rebuild-app --update-repo +``` + +For non-interactive execution, both the plan and a current recoverable backup must be confirmed explicitly: + +```bash +./rebuild-app --yes --backup-confirmed +``` + +The command pulls the exact Community image or validates the exact image loaded from an Enterprise archive before downtime. During a normal rebuild, MySQL and Redis stay running. Only the twice-verified volume mounted at `/var/www/eramba` is removed; database, application data, logs, and trigger-storage volumes are preserved. Eramba completes its migration-aware startup and health checks before cron and triggers are started. + +Failures before the first target `eramba` start restore the previous image tag and application services automatically. After that migration boundary, the command does not downgrade application code because database migrations may already have run. It keeps unverified dependent services stopped and writes private diagnostics under `.rebuild-app/runs/`; review those diagnostics and the database restore requirements before attempting recovery. diff --git a/docker-compose.simple-install.enterprise.yml b/docker-compose.simple-install.enterprise.yml index b977aab..2328a7f 100644 --- a/docker-compose.simple-install.enterprise.yml +++ b/docker-compose.simple-install.enterprise.yml @@ -1,5 +1,5 @@ services: eramba: - image: ghcr.io/eramba/eramba-enterprise:latest + image: ghcr.io/eramba/eramba-enterprise:${ERAMBA_IMAGE_TAG:-latest} cron: - image: ghcr.io/eramba/eramba-enterprise:latest + image: ghcr.io/eramba/eramba-enterprise:${ERAMBA_IMAGE_TAG:-latest} diff --git a/docker-compose.simple-install.yml b/docker-compose.simple-install.yml index cd6c348..10b16dc 100644 --- a/docker-compose.simple-install.yml +++ b/docker-compose.simple-install.yml @@ -24,7 +24,7 @@ services: - app_internal eramba: container_name: eramba - image: ghcr.io/eramba/eramba:latest + image: ghcr.io/eramba/eramba:${ERAMBA_IMAGE_TAG:-latest} restart: always ports: - 8443:443 @@ -65,7 +65,7 @@ services: - triggers_net cron: container_name: cron - image: ghcr.io/eramba/eramba:latest + image: ghcr.io/eramba/eramba:${ERAMBA_IMAGE_TAG:-latest} command: ["cron", "-f"] entrypoint: ["/docker-cron-entrypoint.sh"] restart: always diff --git a/docs/superpowers/plans/2026-07-10-rebuild-app-application-plan-command.md b/docs/superpowers/plans/2026-07-10-rebuild-app-application-plan-command.md new file mode 100644 index 0000000..ac04b76 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-rebuild-app-application-plan-command.md @@ -0,0 +1,488 @@ +# Rebuild App Application Plan Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only Cake command that securely returns the canonical Docker image-switch plan for the running Eramba installation. + +**Architecture:** Extend `AutoUpdateLib` with an optional Docker tag input and a forced-refresh image-plan method while keeping all existing no-argument updater callers compatible. A focused Cake command validates its one required option and prints only normalized plan JSON for the host executor. + +**Tech Stack:** PHP 8, CakePHP command framework, existing `AutoUpdateLib`, PHPUnit pure unit harness. + +## Global Constraints + +- Work in `/Users/shrkz1/Sites/eramba`. +- Read the relevant production and test files before RED and state the intended production change before applying it. +- Apply/save only the test change, run the narrow test for valid RED, then apply the stated production change and rerun for GREEN. +- Run application commands inside the `eramba` container; pure unit PHPUnit may use the documented host `composer cake-unit-test` path when dependencies are available. +- Preserve `AutoUpdateLib::check()` behavior for every existing no-argument caller. +- The image-plan lookup must not reuse a response cached for another Docker tag. +- Stdout on success contains one JSON object and no support credentials or package URL. +- Errors use a non-zero command exit code and stderr; they must not emit partial success JSON. + +--- + +## File Map + +- `app/upgrade/src/Lib/AutoUpdateLib.php`: adds request-tag propagation, forced refresh, and normalized plan extraction. +- `app/upgrade/src/Command/ImageSwitchPlanCommand.php`: owns CLI option validation and JSON output only. +- `app/upgrade/tests/Unit/Lib/AutoUpdateLibImageSwitchPlanTest.php`: proves request augmentation and plan normalization without Cake bootstrap. +- `app/upgrade/tests/Unit/Command/ImageSwitchPlanCommandTest.php`: proves stdout, stderr, and exit-code behavior with an injected client. + +## Task 1: Add a cache-safe image-switch plan method + +**Files:** +- Modify: `app/upgrade/src/Lib/AutoUpdateLib.php` +- Create: `app/upgrade/tests/Unit/Lib/AutoUpdateLibImageSwitchPlanTest.php` + +**Interfaces:** +- Produces: `AutoUpdateLib::imageSwitchPlan(string $currentImageTag): array|false`. +- Preserves: `AutoUpdateLib::check(?string $dockerImageTag = null, bool $forceRefresh = false): array|false` with no-argument compatibility. +- Produces protected helper: `appendDockerImageTag(array $requestBody, ?string $dockerImageTag): array`. + +- [ ] **Step 1: Read the narrow source and state the production change** + +Read: + +```bash +sed -n '1,380p' app/upgrade/src/Lib/AutoUpdateLib.php +``` + +State before editing production: “Extend the existing check request with an optional Docker tag, bypass the shared cache for image-plan calls, and normalize only `response.image_switch`.” + +- [ ] **Step 2: Write the failing pure unit tests** + +Create `AutoUpdateLibImageSwitchPlanTest.php` with tests using this test subclass: + +```php + '3.30.0']; + + $this->assertSame($base, $lib->runAppendDockerImageTag($base, null)); + $this->assertSame( + ['appVersion' => '3.30.0', 'dockerImageTag' => '3.30.0-23'], + $lib->runAppendDockerImageTag($base, '3.30.0-23'), + ); + } + + public function testImageSwitchPlanReturnsNormalizedPlan(): void + { + $lib = new TestableAutoUpdateLib([ + 'success' => true, + 'response' => [ + 'image_switch' => [ + 'required' => true, + 'source_app_version' => '3.30.0', + 'target_app_version' => '3.30.1', + 'current_image_tag' => '3.30.0-23', + 'target_image_tag' => '3.30.1-6', + 'edition' => 'community', + 'distribution' => 'registry', + ], + 'pending' => [['url' => 'https://secret-package.example/update.zip']], + ], + ]); + + $plan = $lib->imageSwitchPlan('3.30.0-23'); + + $this->assertIsArray($plan); + $this->assertSame('3.30.1-6', $plan['target_image_tag']); + $this->assertArrayNotHasKey('pending', $plan); + $this->assertSame(['3.30.0-23', true], $lib->checkArguments); + } + + public function testImageSwitchPlanFailsForMalformedContract(): void + { + $lib = new TestableAutoUpdateLib([ + 'success' => true, + 'response' => ['image_switch' => ['required' => 'yes']], + ]); + + $this->assertFalse($lib->imageSwitchPlan('3.30.0-23')); + $this->assertStringContainsString('invalid image switch plan', strtolower($lib->getErrorMessage())); + } +} + +class TestableAutoUpdateLib extends AutoUpdateLib +{ + public array $checkArguments = []; + + public function __construct(private array|false $response = false) + { + } + + public function check(?string $dockerImageTag = null, bool $forceRefresh = false) + { + $this->checkArguments = [$dockerImageTag, $forceRefresh]; + + return $this->response; + } + + public function runAppendDockerImageTag(array $requestBody, ?string $dockerImageTag): array + { + return $this->appendDockerImageTag($requestBody, $dockerImageTag); + } +} +``` + +- [ ] **Step 3: Run the narrow test and confirm valid RED** + +```bash +composer cake-unit-test -- \ + app/upgrade/tests/Unit/Lib/AutoUpdateLibImageSwitchPlanTest.php +``` + +If the Composer script does not forward a path, run: + +```bash +app/upgrade/vendor/bin/phpunit -c app/upgrade/phpunit.unit.xml.dist \ + app/upgrade/tests/Unit/Lib/AutoUpdateLibImageSwitchPlanTest.php +``` + +Expected: FAIL because the helper, new signature, and `imageSwitchPlan()` do not exist. + +- [ ] **Step 4: Extend the request without changing existing callers** + +Change the method signature to: + +```php +public function check(?string $dockerImageTag = null, bool $forceRefresh = false) +``` + +Change cache loading to: + +```php +$response = $forceRefresh ? null : Cache::read('server_response', 'updates'); +``` + +After the existing request body is created, call: + +```php +$requestBody = $this->appendDockerImageTag($requestBody, $dockerImageTag); +``` + +Add: + +```php +protected function appendDockerImageTag(array $requestBody, ?string $dockerImageTag): array +{ + if ($dockerImageTag !== null) { + $requestBody['dockerImageTag'] = $dockerImageTag; + } + + return $requestBody; +} +``` + +- [ ] **Step 5: Add strict plan extraction** + +Add this public method: + +```php +public function imageSwitchPlan(string $currentImageTag): array|false +{ + $response = $this->check($currentImageTag, true); + if ($response === false) { + return false; + } + + $plan = $response['response']['image_switch'] ?? null; + if (!is_array($plan) || !isset($plan['required']) || !is_bool($plan['required'])) { + $this->setError('Support server returned an invalid image switch plan.'); + + return false; + } + + if ($plan['required'] === false) { + return ['required' => false]; + } + + $requiredStrings = [ + 'source_app_version', + 'target_app_version', + 'current_image_tag', + 'target_image_tag', + 'edition', + 'distribution', + ]; + + foreach ($requiredStrings as $key) { + if (!isset($plan[$key]) || !is_string($plan[$key]) || trim($plan[$key]) === '') { + $this->setError('Support server returned an invalid image switch plan.'); + + return false; + } + } + + return array_intersect_key($plan, array_flip(array_merge(['required'], $requiredStrings))); +} +``` + +- [ ] **Step 6: Run the narrow test and confirm GREEN** + +Run the PHPUnit command from Step 3. + +Expected: all three tests PASS. + +- [ ] **Step 7: Run existing updater-adjacent unit coverage and commit** + +```bash +composer cake-unit-test +git add app/upgrade/src/Lib/AutoUpdateLib.php \ + app/upgrade/tests/Unit/Lib/AutoUpdateLibImageSwitchPlanTest.php +git commit -m "feat: resolve Docker image switch plans" +``` + +Expected: unit suite PASS; existing no-argument calls compile and run unchanged. + +## Task 2: Add the read-only Cake command + +**Files:** +- Create: `app/upgrade/src/Command/ImageSwitchPlanCommand.php` +- Create: `app/upgrade/tests/Unit/Command/ImageSwitchPlanCommandTest.php` + +**Interfaces:** +- Consumes: `AutoUpdateLib::imageSwitchPlan(string): array|false`. +- Produces command: `bin/cake image_switch_plan --current-image-tag --format json`. +- Produces stdout: one compact JSON object on success. +- Produces exit code `0` on valid required/no-op plan and non-zero on option or lookup failure. + +- [ ] **Step 1: Read the command/test pattern and state the production change** + +```bash +sed -n '1,130p' app/upgrade/src/Command/SystemHealthCheckCommand.php +sed -n '1,230p' app/upgrade/tests/Unit/Command/TranslationsAiFillMissingCommandTest.php +``` + +State: “Add one injected read-only command that validates a current tag and serializes only the normalized image plan.” + +- [ ] **Step 2: Write failing command tests** + +Create a test with injected `FakeImageSwitchAutoUpdateLib`, `StubConsoleOutput` for stdout/stderr, and `Arguments` instances. Cover these exact assertions: + +```php +public function testCommandPrintsOnlyPlanJson(): void +{ + $client = new FakeImageSwitchAutoUpdateLib([ + 'required' => true, + 'source_app_version' => '3.30.0', + 'target_app_version' => '3.30.1', + 'current_image_tag' => '3.30.0-23', + 'target_image_tag' => '3.30.1-6', + 'edition' => 'community', + 'distribution' => 'registry', + ]); + [$io, $stdout, $stderr] = $this->consoleIo(); + $command = new ImageSwitchPlanCommand(null, $client); + + $code = $command->execute(new Arguments([], [ + 'current-image-tag' => '3.30.0-23', + 'format' => 'json', + ], []), $io); + + $this->assertSame(Command::CODE_SUCCESS, $code); + $this->assertSame('3.30.1-6', json_decode($stdout->output(), true, flags: JSON_THROW_ON_ERROR)['target_image_tag']); + $this->assertSame('', $stderr->output()); + $this->assertSame('3.30.0-23', $client->receivedTag); +} +``` + +Also test missing/blank `current-image-tag`, unsupported format, and a fake client returning `false` with `getErrorMessage()` equal to `Support unavailable`; each must return `CODE_ERROR`, keep stdout empty, and write a concise stderr error. + +- [ ] **Step 3: Run the command test and confirm RED** + +```bash +app/upgrade/vendor/bin/phpunit -c app/upgrade/phpunit.unit.xml.dist \ + app/upgrade/tests/Unit/Command/ImageSwitchPlanCommandTest.php +``` + +Expected: FAIL because `ImageSwitchPlanCommand` is absent. + +- [ ] **Step 4: Implement the complete command contract** + +Create the command with this structure: + +```php +autoUpdateLib = $autoUpdateLib ?? new AutoUpdateLib(); + } + + public static function defaultName(): string + { + return 'image_switch_plan'; + } + + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + return parent::buildOptionParser($parser) + ->setDescription('Return the canonical Docker image switch plan.') + ->addOption('current-image-tag', [ + 'help' => 'Current immutable Docker image tag or latest for an unpinned install.', + 'required' => true, + ]) + ->addOption('format', [ + 'help' => 'Output format.', + 'choices' => ['json'], + 'default' => 'json', + ]); + } + + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $tag = trim((string)$args->getOption('current-image-tag')); + if ($tag === '') { + $io->err('Current Docker image tag is required.'); + + return static::CODE_ERROR; + } + + if ($args->getOption('format') !== 'json') { + $io->err('Only json output is supported.'); + + return static::CODE_ERROR; + } + + $plan = $this->autoUpdateLib->imageSwitchPlan($tag); + if ($plan === false) { + $io->err($this->autoUpdateLib->getErrorMessage()); + + return static::CODE_ERROR; + } + + try { + $io->out(json_encode($plan, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } catch (JsonException $exception) { + $io->err('Image switch plan could not be encoded.'); + + return static::CODE_ERROR; + } + + return static::CODE_SUCCESS; + } +} +``` + +- [ ] **Step 5: Run command tests and confirm GREEN** + +Run the command from Step 3. + +Expected: all command cases PASS. + +- [ ] **Step 6: Verify Cake discovers the command** + +With the local application container running: + +```bash +docker exec -w /var/www/eramba/app/upgrade -u www-data eramba \ + bin/cake image_switch_plan --help +``` + +Expected: help lists `--current-image-tag` and only `json` as the output choice. + +- [ ] **Step 7: Commit the command** + +```bash +git add app/upgrade/src/Command/ImageSwitchPlanCommand.php \ + app/upgrade/tests/Unit/Command/ImageSwitchPlanCommandTest.php +git commit -m "feat: expose image switch plan command" +``` + +## Task 3: Regression and baseline-image gate + +**Files:** +- Modify only if tests expose a defect: files from Tasks 1-2. + +**Interfaces:** +- Verifies the final `image_switch_plan` command consumed by the Docker executor plan. + +- [ ] **Step 1: Run the complete pure unit suite** + +```bash +composer cake-unit-test +``` + +Expected: PASS. + +- [ ] **Step 2: Run targeted static analysis** + +Use the repository's PHPStan command against the changed command and library paths. If the repository exposes only the standard script, run: + +```bash +composer phpstan -- app/upgrade/src/Lib/AutoUpdateLib.php \ + app/upgrade/src/Command/ImageSwitchPlanCommand.php +``` + +Expected: exit `0`; if the Composer wrapper does not forward paths, run its documented narrow equivalent rather than a broader unrelated suite. + +- [ ] **Step 3: Exercise a live no-secret output check** + +Against a test support endpoint containing Docker metadata: + +```bash +docker exec -w /var/www/eramba/app/upgrade -u www-data eramba \ + bin/cake image_switch_plan --current-image-tag latest --format json +``` + +Expected: one JSON object containing `required`; output contains neither `url`, `client_key`, `password`, nor authorization data. + +- [ ] **Step 4: Record the rollout floor** + +The first published Community and Enterprise images containing this command become the baseline supported by `./rebuild-app`. Record their immutable tags in the release handoff and populate those tags on the matching support release rows before the Docker executor ships. + +- [ ] **Step 5: Commit any test-driven corrections** + +If Steps 1-3 required changes: + +```bash +git add app/upgrade/src/Lib/AutoUpdateLib.php \ + app/upgrade/src/Command/ImageSwitchPlanCommand.php \ + app/upgrade/tests/Unit/Lib/AutoUpdateLibImageSwitchPlanTest.php \ + app/upgrade/tests/Unit/Command/ImageSwitchPlanCommandTest.php +git commit -m "fix: harden image switch plan output" +``` + +If no files changed, do not create an empty commit. + +## Plan Completion Gate + +- `image_switch_plan` is auto-discovered by Cake and requires a current tag. +- The command forces a cache-safe support request containing `dockerImageTag`. +- Existing no-argument `AutoUpdateLib::check()` consumers remain compatible. +- Required and no-op plans serialize as one compact JSON object. +- Malformed, unavailable, or unauthorized responses exit non-zero without partial JSON. +- Pure unit tests and targeted PHPStan pass. +- A baseline immutable image containing the command is published only after the support contract is deployed. diff --git a/docs/superpowers/plans/2026-07-10-rebuild-app-docker-executor.md b/docs/superpowers/plans/2026-07-10-rebuild-app-docker-executor.md new file mode 100644 index 0000000..d70bb9d --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-rebuild-app-docker-executor.md @@ -0,0 +1,435 @@ +# Rebuild App Docker Executor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a safe `./rebuild-app` host command that pins immutable application images and performs a phase-aware Community or Enterprise app-volume rebuild. + +**Architecture:** A small Bash launcher delegates focused behavior to common, plan, and Docker/Compose libraries. All target selection and artifact checks happen before mutation; the script automatically recovers only before the first target `eramba` start and records diagnostics instead of downgrading after the migration boundary. + +**Tech Stack:** Bash, Docker Engine, Docker Compose, Bats, existing Cake health commands, GitHub Actions. + +## Global Constraints + +- Work in `/Users/shrkz1/Sites/docker` on `codex/era-1706-rebuild-app`. +- Do not touch `/Users/shrkz1/Sites/eramba` in this implementation phase. +- The production plan provider is `bin/cake image_switch_plan`; until that command ships, real runs must fail during no-downtime preflight. +- Never run `docker compose down --volumes`, `docker system prune`, wildcard volume deletion, or automatic post-migration rollback. +- Delete only the Docker volume mounted at `/var/www/eramba` after verifying it twice. +- Keep MySQL and Redis running during the normal switch. +- Do not log, source, archive, or copy `.env` outside the atomic same-directory replacement operation. +- `--yes` never implies `--backup-confirmed`. +- Community accepts registry delivery only; Enterprise requires `--image-file` and archive delivery. +- Use explicit immutable tags for both `eramba` and `cron`. + +--- + +## File Map + +- `rebuild-app`: public CLI, option validation, phase transitions, and top-level traps. +- `lib/rebuild-app/common.sh`: logging, errors, lock, repo update, run metadata, and atomic `.env` tag update. +- `lib/rebuild-app/plan.sh`: invokes and validates the application JSON plan using PHP inside the running container. +- `lib/rebuild-app/docker.sh`: Compose selection, artifact validation, volume discovery, staged service control, and health checks. +- `tests/test_helper.bash`: isolated fake-command environment and common assertions. +- `tests/rebuild-app-options.bats`: CLI, repo-update, no-op, and confirmation behavior. +- `tests/rebuild-app-plan.bats`: plan parsing and target artifact validation. +- `tests/rebuild-app-lifecycle.bats`: exact volume deletion, recovery boundary, staged startup, and diagnostics. +- `docker-compose.simple-install.yml`: Community tag interpolation. +- `docker-compose.simple-install.enterprise.yml`: Enterprise tag interpolation. +- `.gitignore`: ignores runtime `.rebuild-app/` state. +- `.github/workflows/Docker.yml`: runs Bats and a non-destructive compose-config assertion before the existing install job. +- `README.md`: documents supported baseline, Community and Enterprise usage, dry-run, backup gate, and failure behavior. + +## Task 1: Pin application images and establish the tested CLI + +**Files:** +- Modify: `docker-compose.simple-install.yml` +- Modify: `docker-compose.simple-install.enterprise.yml` +- Create: `rebuild-app` +- Create: `lib/rebuild-app/common.sh` +- Create: `tests/test_helper.bash` +- Create: `tests/rebuild-app-options.bats` +- Modify: `.gitignore` + +**Interfaces:** +- Produces: `./rebuild-app [--edition community|enterprise] [--image-file PATH] [--update-repo] [--dry-run] [--yes] [--backup-confirmed]`. +- Produces: `ERAMBA_IMAGE_TAG` interpolation shared by `eramba` and `cron`. +- Produces common functions: `log`, `die`, `acquire_lock`, `release_lock`, `atomic_set_env`, `update_repo_and_reexec`. + +- [ ] **Step 1: Prepare RED by reading compose and stating the production change** + +Read both compose files and `.gitignore`. State: “Pin `eramba` and `cron` through one `.env` tag and add a no-mutation CLI skeleton whose invalid combinations are executable specifications.” Do not edit production files yet. + +- [ ] **Step 2: Create the Bats helper and failing option tests** + +`tests/test_helper.bash` creates a temporary copy of `.env`, prepends `tests/fakes/bin` to `PATH`, exports `REBUILD_APP_ROOT`, and removes temporary state in teardown. The option tests execute the real `rebuild-app` and assert: + +```bash +@test "community rejects an image archive" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --image-file target.tar --dry-run + [ "$status" -ne 0 ] + [[ "$output" == *"--image-file is only valid for enterprise"* ]] +} + +@test "enterprise requires an image archive" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition enterprise --dry-run + [ "$status" -ne 0 ] + [[ "$output" == *"--image-file is required for enterprise"* ]] +} + +@test "non-interactive mutation requires backup confirmation" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes + [ "$status" -ne 0 ] + [[ "$output" == *"--backup-confirmed is required with --yes"* ]] +} +``` + +- [ ] **Step 3: Run Bats and confirm valid RED** + +```bash +bats tests/rebuild-app-options.bats +``` + +Expected: FAIL because `rebuild-app` does not exist. + +- [ ] **Step 4: Implement compose interpolation and the CLI skeleton** + +Use these exact image declarations: + +```yaml +image: ghcr.io/eramba/eramba:${ERAMBA_IMAGE_TAG:-latest} +``` + +```yaml +image: ghcr.io/eramba/eramba-enterprise:${ERAMBA_IMAGE_TAG:-latest} +``` + +Apply each declaration to both `eramba` and `cron` in the relevant compose result. + +Create `rebuild-app` with `#!/usr/bin/env bash`, `set -Eeuo pipefail`, root-relative library loading, explicit defaults, a `while (($#)); do case "$1" ...` parser, and the validations represented by the tests. Unknown options call `die`. Export parsed values using names `EDITION`, `IMAGE_FILE`, `UPDATE_REPO`, `DRY_RUN`, `ASSUME_YES`, and `BACKUP_CONFIRMED`. + +Create `common.sh` with these stable interfaces: + +```bash +log() { printf '%s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +acquire_lock() { + LOCK_DIR="${ROOT_DIR}/.rebuild-app/lock" + mkdir -p "${ROOT_DIR}/.rebuild-app" + mkdir "$LOCK_DIR" 2>/dev/null || die "Another rebuild-app process is running." +} + +release_lock() { + [[ -n "${LOCK_DIR:-}" ]] && rmdir "$LOCK_DIR" 2>/dev/null || true +} +``` + +Add `.rebuild-app/` to `.gitignore` and make `rebuild-app` executable. + +- [ ] **Step 5: Confirm GREEN and compose parity** + +```bash +bats tests/rebuild-app-options.bats +ERAMBA_IMAGE_TAG=3.30.1-6 docker compose -f docker-compose.simple-install.yml config --images +ERAMBA_IMAGE_TAG=3.30.1-6 docker compose -f docker-compose.simple-install.yml \ + -f docker-compose.simple-install.enterprise.yml config --images +``` + +Expected: Bats PASS; Community lists `ghcr.io/eramba/eramba:3.30.1-6` twice and Enterprise lists `ghcr.io/eramba/eramba-enterprise:3.30.1-6` twice. + +- [ ] **Step 6: Commit the image and CLI boundary** + +```bash +git add .gitignore docker-compose.simple-install.yml \ + docker-compose.simple-install.enterprise.yml rebuild-app \ + lib/rebuild-app/common.sh tests/test_helper.bash tests/rebuild-app-options.bats +git commit -m "feat: pin rebuild app image tags" +``` + +## Task 2: Resolve and validate the canonical target before downtime + +**Files:** +- Modify: `rebuild-app` +- Create: `lib/rebuild-app/plan.sh` +- Create: `lib/rebuild-app/docker.sh` +- Create: `tests/rebuild-app-plan.bats` + +**Interfaces:** +- Produces globals: `CURRENT_IMAGE`, `CURRENT_IMAGE_TAG`, `CURRENT_APP_VERSION`, `TARGET_IMAGE_TAG`, `TARGET_APP_VERSION`, `TARGET_IMAGE`, `PLAN_REQUIRED`, `DISTRIBUTION`. +- Produces: `resolve_plan`, `acquire_target_image`, `validate_target_image`, `compose`, `detect_installation`. +- Consumes: one JSON object from `bin/cake image_switch_plan --current-image-tag TAG --format json`. + +- [ ] **Step 1: State the production change and add failing plan tests** + +State: “Discover the current deployment, request one canonical plan, and validate the complete target artifact before any `.env`, container, or volume mutation.” + +Use fake `docker` and `docker compose` binaries to assert: + +- a valid required Community plan yields exact target `ghcr.io/eramba/eramba:3.30.1-6`; +- `required:false` exits `0` with `nothing to do`; +- malformed JSON, missing fields, edition mismatch, distribution mismatch, wrong target `VERSION`, wrong repository/tag, or wrong architecture exits before any fake `stop`, `rm`, or `volume rm` call; +- Enterprise calls `docker load --input ` and Community calls `docker pull `. + +The valid fixture is: + +```json +{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"community","distribution":"registry"} +``` + +- [ ] **Step 2: Run the plan tests and confirm RED** + +```bash +bats tests/rebuild-app-plan.bats +``` + +Expected: FAIL because plan and Docker libraries are absent. + +- [ ] **Step 3: Implement compose and installation discovery** + +In `docker.sh`, define `COMPOSE_ARGS` from the edition and a wrapper: + +```bash +compose() { + docker compose "${COMPOSE_ARGS[@]}" "$@" +} +``` + +`detect_installation()` must inspect the running `eramba` container's configured image, parse only `ghcr.io/eramba/eramba:` or `ghcr.io/eramba/eramba-enterprise:`, set edition when not supplied, reject conflicts, and read `/var/www/eramba/app/upgrade/VERSION` with `docker exec`. A missing running container is a hard preflight failure. + +- [ ] **Step 4: Implement strict JSON extraction without a host parser** + +`resolve_plan()` runs the Cake command inside `eramba`, stores stdout, and extracts fields by piping JSON into PHP inside the same container: + +```bash +plan_field() { + local field=$1 + printf '%s' "$PLAN_JSON" | docker exec -i eramba php -r ' + $data = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR); + $field = $argv[1]; + if (!array_key_exists($field, $data) || (!is_string($data[$field]) && !is_bool($data[$field]))) { + exit(2); + } + echo is_bool($data[$field]) ? ($data[$field] ? "true" : "false") : $data[$field]; + ' "$field" +} +``` + +Required plans validate all six strings plus `required`; no-op validates only `required:false`. The current tag returned by support must equal the detected current tag. + +- [ ] **Step 5: Implement artifact acquisition and non-running inspection** + +Community calls `docker pull "$TARGET_IMAGE"`. Enterprise calls `docker load --input "$IMAGE_FILE"` and then requires the exact tag locally. Compare `docker image inspect` architecture to `docker info` architecture after normalizing `x86_64|amd64` and `aarch64|arm64`. + +Create a temporary container with its entrypoint replaced, copy `/var/www/eramba/app/upgrade/VERSION` to a `mktemp` file, and always remove both in a trap. Require the trimmed file value to equal `TARGET_APP_VERSION`. + +- [ ] **Step 6: Confirm GREEN and preflight purity** + +```bash +bats tests/rebuild-app-plan.bats +``` + +Expected: all plan cases PASS; fake command log contains no mutating service/volume call for every rejected plan. + +- [ ] **Step 7: Commit target resolution** + +```bash +git add rebuild-app lib/rebuild-app/plan.sh lib/rebuild-app/docker.sh \ + tests/rebuild-app-plan.bats +git commit -m "feat: validate canonical rebuild target" +``` + +## Task 3: Implement the exact volume and pre-migration recovery boundary + +**Files:** +- Modify: `rebuild-app` +- Modify: `lib/rebuild-app/common.sh` +- Modify: `lib/rebuild-app/docker.sh` +- Create: `tests/rebuild-app-lifecycle.bats` + +**Interfaces:** +- Produces phases: `preflight`, `mutating`, `migration-boundary`, `complete`. +- Produces: `volume_identity CONTAINER DESTINATION`, `atomic_set_env KEY VALUE`, `recover_before_migration`, `stop_application_services`, `remove_verified_app_volume`. + +- [ ] **Step 1: Add failing lifecycle safety tests** + +Tests assert exact command ordering: + +1. snapshot mount identities; +2. write target tag; +3. stop/remove `triggers_caddy`, `cron`, `eramba` only; +4. re-read `/var/www/eramba` mount name; +5. remove exactly that volume; +6. never stop/remove MySQL or Redis. + +Add failure fixtures before and after volume removal. Both must restore the prior tag and recreate original application services because the target `eramba` start has not yet been attempted. A changed second-read volume name must abort without `docker volume rm`. + +- [ ] **Step 2: Run lifecycle tests and confirm RED** + +```bash +bats tests/rebuild-app-lifecycle.bats --filter 'pre-migration|volume|mysql|redis' +``` + +Expected: FAIL because mutation/recovery functions do not exist. + +- [ ] **Step 3: Implement atomic `.env` mutation** + +`atomic_set_env ERAMBA_IMAGE_TAG "$TARGET_IMAGE_TAG"` must reject more than one exact key, write to a same-directory `mktemp`, preserve the original numeric mode, replace or append one line with `awk`, and `mv` atomically. It must register the previous presence/value in memory without logging other `.env` content. A paired `restore_image_tag` restores only that key. + +- [ ] **Step 4: Implement exact volume identity checks** + +`volume_identity()` uses `docker inspect` Mounts to return `Name|CreatedAt` for one exact destination. Snapshot these destinations: + +- `eramba:/var/www/eramba` +- `eramba:/var/www/eramba/app/upgrade/data` +- `eramba:/var/www/eramba/app/upgrade/logs` +- `mysql:/var/lib/mysql` +- `triggers_caddy:/data/eramba_trigger_storage` + +Before deletion, re-read the application volume name and require it to match the snapshot. Invoke `docker volume rm -- "$APP_VOLUME_NAME"` with one argument only. + +- [ ] **Step 5: Implement pre-boundary recovery and confirm GREEN** + +Set `PHASE=mutating` immediately before `.env` mutation. The EXIT/ERR trap calls `recover_before_migration` only while this phase is active. Recovery restores the previous tag and runs Compose `up -d eramba cron triggers_caddy`; it reports recovery success/failure but preserves the original non-zero exit. + +Run: + +```bash +bats tests/rebuild-app-lifecycle.bats --filter 'pre-migration|volume|mysql|redis' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the reversible mutation phase** + +```bash +git add rebuild-app lib/rebuild-app/common.sh lib/rebuild-app/docker.sh \ + tests/rebuild-app-lifecycle.bats +git commit -m "feat: protect rebuild app volumes" +``` + +## Task 4: Add staged startup and post-boundary diagnostics + +**Files:** +- Modify: `rebuild-app` +- Modify: `lib/rebuild-app/common.sh` +- Modify: `lib/rebuild-app/docker.sh` +- Modify: `tests/rebuild-app-lifecycle.bats` + +**Interfaces:** +- Produces: `start_and_verify_eramba`, `start_and_verify_cron`, `start_and_verify_triggers`, `capture_diagnostics`. +- Consumes existing commands: `current_config validate`, `system_health check`, `migrations status`, and trigger health. + +- [ ] **Step 1: Add failing staged-start and boundary tests** + +Assert that target startup order is exactly: + +1. `compose up -d eramba`; +2. application-local HTTP wait with finite timeout; +3. target application `VERSION`, `current_config validate`, `system_health check`; +4. `compose up -d cron` then `migrations status` in cron; +5. `compose up -d triggers_caddy` then healthy status. + +Inject failure after the first target `up -d eramba`. Assert that the old tag is not restored, old services are not started, MySQL/Redis remain untouched, and diagnostics include the phase, old/target image refs, volume identities, compose state, and service logs without `.env` content. + +- [ ] **Step 2: Run staged lifecycle tests and confirm RED** + +```bash +bats tests/rebuild-app-lifecycle.bats --filter 'staged|migration boundary|diagnostics' +``` + +Expected: FAIL because staged startup and diagnostics are absent. + +- [ ] **Step 3: Implement the conservative boundary** + +Set `PHASE=migration-boundary` immediately before invoking `compose up -d eramba`. The error trap must never call `restore_image_tag` or `recover_before_migration` in this or later phases. + +Use bounded polling with configurable `REBUILD_APP_START_TIMEOUT_SECONDS`, default `300`, and two-second intervals. Run checks through `docker exec -w /var/www/eramba/app/upgrade -u www-data`. + +- [ ] **Step 4: Implement non-secret diagnostics** + +Create `.rebuild-app/runs//` at mode `0700`; files use `0600`. Store a generated state summary, `docker compose ps`, `docker inspect` for service/image/mount metadata, and bounded `docker logs --tail 500` output. Never call `docker compose config` after failure because it can render resolved secret environment values. + +- [ ] **Step 5: Confirm GREEN and full shell coverage** + +```bash +bats tests/rebuild-app-lifecycle.bats +bats tests/rebuild-app-options.bats tests/rebuild-app-plan.bats +bash -n rebuild-app lib/rebuild-app/common.sh lib/rebuild-app/plan.sh lib/rebuild-app/docker.sh +``` + +Expected: all tests PASS and Bash syntax checks exit `0`. + +- [ ] **Step 6: Commit staged startup and diagnostics** + +```bash +git add rebuild-app lib/rebuild-app/common.sh lib/rebuild-app/docker.sh \ + tests/rebuild-app-lifecycle.bats +git commit -m "feat: stage rebuild app startup" +``` + +## Task 5: CI, documentation, and integration readiness + +**Files:** +- Modify: `.github/workflows/Docker.yml` +- Modify: `README.md` +- Modify only if verification finds defects: executor/test files from Tasks 1-4. + +**Interfaces:** +- Produces CI gate for shell tests and compose interpolation. +- Documents the baseline requirement and the absence of an unsafe target override. + +- [ ] **Step 1: Add a CI shell-test job before the install job** + +Add `shell_tests` on Ubuntu 22.04 that checks out the repo, installs `bats`, runs all three Bats files, runs `bash -n`, and asserts Community/Enterprise images under `ERAMBA_IMAGE_TAG=3.30.1-6`. Make `simple_install` depend on `shell_tests`. + +- [ ] **Step 2: Document exact operator workflows** + +README sections must include: + +```bash +./rebuild-app --dry-run +./rebuild-app +./rebuild-app --edition enterprise --image-file eramba-enterprise--amd64.tar +./rebuild-app --update-repo +./rebuild-app --yes --backup-confirmed +``` + +State that pre-baseline images must use the documented manual procedure once, Enterprise download remains manual, MySQL/Redis stay running, only the app volume is replaced, and post-boundary recovery requires operator review. + +- [ ] **Step 3: Run all local non-destructive verification** + +```bash +bats tests/rebuild-app-options.bats tests/rebuild-app-plan.bats tests/rebuild-app-lifecycle.bats +bash -n rebuild-app lib/rebuild-app/common.sh lib/rebuild-app/plan.sh lib/rebuild-app/docker.sh +ERAMBA_IMAGE_TAG=3.30.1-6 docker compose -f docker-compose.simple-install.yml config >/dev/null +ERAMBA_IMAGE_TAG=3.30.1-6 docker compose -f docker-compose.simple-install.yml \ + -f docker-compose.simple-install.enterprise.yml config >/dev/null +git diff --check +``` + +Expected: all commands exit `0`. + +- [ ] **Step 4: Run the published-image integration gate when the baseline exists** + +After the support contract is deployed and a baseline image contains `image_switch_plan`, run the real switch fixture and assert unchanged identities for `db-data`, `data`, `logs`, and trigger storage; a changed app-volume identity; target tag/version; successful application/cron/trigger health; and a second-run no-op. + +Until the baseline exists, mark this release gate as blocked rather than adding a target override or bypass to production code. + +- [ ] **Step 5: Commit CI and operator documentation** + +```bash +git add .github/workflows/Docker.yml README.md +git commit -m "docs: add rebuild app operator workflow" +``` + +## Plan Completion Gate + +- Both compose variants pin `eramba` and `cron` to one persisted immutable tag. +- Every mutating behavior has a Bats test that was observed RED before implementation. +- Invalid/missing plans and artifacts fail before downtime. +- Only the verified `/var/www/eramba` volume is removed. +- MySQL and Redis are never intentionally stopped during a normal switch. +- Pre-boundary failures restore the previous tag/services; post-boundary failures never downgrade automatically. +- Diagnostics contain no `.env` data. +- All Bats, Bash syntax, compose config, and diff checks pass. +- Real integration remains gated on the separately released Eramba `image_switch_plan` baseline. diff --git a/docs/superpowers/plans/2026-07-10-rebuild-app-support-contract.md b/docs/superpowers/plans/2026-07-10-rebuild-app-support-contract.md new file mode 100644 index 0000000..0f4cdc2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-rebuild-app-support-contract.md @@ -0,0 +1,460 @@ +# Rebuild App Support Contract Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the existing support release tree with a backward-compatible, canonical Docker image-switch plan. + +**Architecture:** Store one approved immutable Docker tag on each legacy support release. When `check-update` receives the caller's current Docker tag, resolve either a same-version image rebuild or the first allowed pending application release and append an `image_switch` object without changing legacy responses for callers that omit the new field. + +**Tech Stack:** Laravel 12, PHP 8.3, Eloquent, Nova 5, PHPUnit 11, support-database connection. + +## Global Constraints + +- Work in `/Users/shrkz1/Sites/licenses-backend`. +- Run PHP, Artisan, PHPUnit, and Pint inside the local `web` Docker service. +- Keep `/api/check-update` Basic-auth behavior and the existing legacy response envelope unchanged. +- Requests without `dockerImageTag` must receive the current response shape and package-download URL behavior. +- Production customer plans may reference only `production` releases. +- Never infer or compare Docker targets using semver, lexical ordering, GitHub `latest`, or registry discovery. +- A missing tag on the required release is a safe API failure; never skip to a later release. +- Do not expose registry credentials, support credentials, or Enterprise download URLs. + +--- + +## File Map + +- `database/migrations/2026_07_10_120000_add_docker_image_tag_to_support_releases_table.php`: adds the nullable support-database column. +- `app/Models/LegacySupport/SupportRelease.php`: makes the column writable and exposes typed distribution helpers. +- `app/Nova/LegacySupportRelease.php`: lets release administrators set the approved tag. +- `app/Services/LegacySupport/LegacySupportReleaseService.php`: resolves the one allowed image-switch target. +- `app/Services/LegacySupport/LegacySupportUpdateService.php`: normalizes `dockerImageTag` and passes it into release resolution. +- `tests/Feature/LegacySupportApiContractTest.php`: owns the support SQLite fixture and full API contract coverage. + +## Task 1: Persist approved Docker image tags + +**Files:** +- Create: `database/migrations/2026_07_10_120000_add_docker_image_tag_to_support_releases_table.php` +- Modify: `app/Models/LegacySupport/SupportRelease.php` +- Modify: `app/Nova/LegacySupportRelease.php` +- Modify: `tests/Feature/LegacySupportApiContractTest.php` + +**Interfaces:** +- Produces: `SupportRelease::$docker_image_tag: ?string` +- Produces: `SupportRelease::dockerDistribution(): string`, returning `registry` for Community and `archive` for Enterprise. +- Consumes: existing `support.releases` rows and `type` values `community|enterprise`. + +- [ ] **Step 1: Add a failing metadata persistence test** + +Add the following test to `LegacySupportApiContractTest` and add `docker_image_tag` to its `createSupportSchema()` releases table and `insertSupportRelease()` defaults only after RED is confirmed: + +```php +public function test_support_release_persists_approved_docker_image_tag(): void +{ + $releaseId = $this->insertSupportRelease([ + 'type' => 'community', + 'docker_image_tag' => '3.30.1-6', + ]); + + $release = \App\Models\LegacySupport\SupportRelease::query()->findOrFail($releaseId); + + $this->assertSame('3.30.1-6', $release->docker_image_tag); + $this->assertSame('registry', $release->dockerDistribution()); + + $release->type = 'enterprise'; + $this->assertSame('archive', $release->dockerDistribution()); +} +``` + +- [ ] **Step 2: Run the narrow test and confirm valid RED** + +Run: + +```bash +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + php artisan test --compact tests/Feature/LegacySupportApiContractTest.php \ + --filter=test_support_release_persists_approved_docker_image_tag +``` + +Expected: FAIL because the test support schema/model does not yet define `docker_image_tag` or `dockerDistribution()`. + +- [ ] **Step 3: Add the support-database migration** + +Create the migration with this complete body: + +```php +table('releases', function (Blueprint $table): void { + $table->string('docker_image_tag')->nullable()->after('version_pre_release'); + }); + } + + public function down(): void + { + Schema::connection('support')->table('releases', function (Blueprint $table): void { + $table->dropColumn('docker_image_tag'); + }); + } +}; +``` + +- [ ] **Step 4: Extend the model, Nova field, and test fixture** + +Add `docker_image_tag` to `SupportRelease::$fillable` and add: + +```php +public function dockerDistribution(): string +{ + return $this->type === 'community' ? 'registry' : 'archive'; +} +``` + +Add this Nova field immediately after `Version`: + +```php +Text::make('Docker Image Tag', 'docker_image_tag') + ->rules('nullable', 'max:255') + ->help('Approved immutable Docker tag, for example 3.30.1-6.') + ->sortable(), +``` + +In `LegacySupportApiContractTest::createSupportSchema()`, add: + +```php +$table->string('docker_image_tag')->nullable(); +``` + +In `insertSupportRelease()` defaults, add: + +```php +'docker_image_tag' => null, +``` + +- [ ] **Step 5: Run the narrow test and confirm GREEN** + +Run the command from Step 2. + +Expected: PASS. + +- [ ] **Step 6: Format and commit the metadata boundary** + +Run: + +```bash +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + vendor/bin/pint --dirty --format agent +git add database/migrations/2026_07_10_120000_add_docker_image_tag_to_support_releases_table.php \ + app/Models/LegacySupport/SupportRelease.php \ + app/Nova/LegacySupportRelease.php \ + tests/Feature/LegacySupportApiContractTest.php +git commit -m "feat: store approved Docker release tags" +``` + +## Task 2: Resolve same-version image switches and no-op plans + +**Files:** +- Modify: `app/Services/LegacySupport/LegacySupportReleaseService.php` +- Modify: `app/Services/LegacySupport/LegacySupportUpdateService.php` +- Modify: `tests/Feature/LegacySupportApiContractTest.php` + +**Interfaces:** +- Consumes: `dockerImageTag: ?string` from the legacy request payload. +- Produces: `response.image_switch.required: bool` only when `dockerImageTag` was supplied. +- Produces when required: `source_app_version`, `target_app_version`, `current_image_tag`, `target_image_tag`, `edition`, and `distribution` strings. + +- [ ] **Step 1: Add failing API tests for same-version switch and no-op** + +Add two feature tests using a Community support stat and a `3.30.0` production release whose `docker_image_tag` is `3.30.0-23`: + +```php +public function test_check_update_returns_same_version_image_switch_for_old_docker_tag(): void +{ + $this->insertSupportStat(['client_id' => 'app-1', 'type' => 'community']); + $this->insertSupportRelease([ + 'version' => '3.30.0', + 'version_major' => 3, + 'version_minor' => 30, + 'version_patch' => 0, + 'type' => 'community', + 'status' => 'production', + 'docker_image_tag' => '3.30.0-23', + ]); + + $this->withBasicAuth('app-1', '') + ->postJson('/api/check-update', [ + 'appVersion' => '3.30.0', + 'dbVersion' => '20260710', + 'phpVersion' => '8.3.0', + 'dockerImageTag' => '3.30.0-22', + ]) + ->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('response.updates', false) + ->assertJsonPath('response.image_switch.required', true) + ->assertJsonPath('response.image_switch.source_app_version', '3.30.0') + ->assertJsonPath('response.image_switch.target_app_version', '3.30.0') + ->assertJsonPath('response.image_switch.current_image_tag', '3.30.0-22') + ->assertJsonPath('response.image_switch.target_image_tag', '3.30.0-23') + ->assertJsonPath('response.image_switch.edition', 'community') + ->assertJsonPath('response.image_switch.distribution', 'registry'); +} + +public function test_check_update_returns_noop_for_approved_docker_tag(): void +{ + $this->insertSupportStat(['client_id' => 'app-1', 'type' => 'community']); + $this->insertSupportRelease([ + 'version' => '3.30.0', + 'version_major' => 3, + 'version_minor' => 30, + 'version_patch' => 0, + 'type' => 'community', + 'status' => 'production', + 'docker_image_tag' => '3.30.0-23', + ]); + + $this->withBasicAuth('app-1', '') + ->postJson('/api/check-update', [ + 'appVersion' => '3.30.0', + 'dockerImageTag' => '3.30.0-23', + ]) + ->assertOk() + ->assertJsonPath('success', true) + ->assertExactJson([ + 'success' => true, + 'message' => null, + 'response' => [ + 'updates' => false, + 'image_switch' => ['required' => false], + ], + ]); +} +``` + +- [ ] **Step 2: Run both tests and confirm RED** + +Run: + +```bash +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + php artisan test --compact tests/Feature/LegacySupportApiContractTest.php \ + --filter='test_check_update_returns_(same_version_image_switch|noop)' +``` + +Expected: FAIL because `image_switch` is absent. + +- [ ] **Step 3: Pass the normalized request tag into release resolution** + +Change the `LegacySupportUpdateService` call to: + +```php +$resolved = $this->releaseService->checkForUpdates( + $identity, + $this->clean($payload['appVersion'] ?? null), + $this->clean($payload['dbVersion'] ?? null), + $this->clean($payload['phpVersion'] ?? null), + $this->clean($payload['dockerImageTag'] ?? null), +); +``` + +Extend the release-service signature with `?string $dockerImageTag = null`. + +- [ ] **Step 4: Implement the exact plan serializer** + +Add this private method to `LegacySupportReleaseService`: + +```php +private function imageSwitchPlan( + SupportRelease $source, + SupportRelease $target, + string $currentImageTag, +): array { + return [ + 'required' => true, + 'source_app_version' => $source->version, + 'target_app_version' => $target->version, + 'current_image_tag' => $currentImageTag, + 'target_image_tag' => $target->docker_image_tag, + 'edition' => $target->type, + 'distribution' => $target->dockerDistribution(), + ]; +} +``` + +Build a response array before each previous early return. When `$dockerImageTag === null`, do not add `image_switch`. When it is present and differs from the current release metadata, attach `imageSwitchPlan($currentRelease, $currentRelease, $dockerImageTag)`. When it matches and there are no pending releases, attach `['required' => false]`. + +- [ ] **Step 5: Run both tests and confirm GREEN** + +Run the command from Step 2. + +Expected: PASS. + +- [ ] **Step 6: Commit the same-version contract** + +```bash +git add app/Services/LegacySupport/LegacySupportReleaseService.php \ + app/Services/LegacySupport/LegacySupportUpdateService.php \ + tests/Feature/LegacySupportApiContractTest.php +git commit -m "feat: resolve Docker image switch plans" +``` + +## Task 3: Resolve next-release plans and fail closed + +**Files:** +- Modify: `app/Services/LegacySupport/LegacySupportReleaseService.php` +- Modify: `tests/Feature/LegacySupportApiContractTest.php` + +**Interfaces:** +- Consumes: the existing ordered pending release tree. +- Produces: one target only; the current release is preferred for a same-version rebuild, otherwise `pending[0]`. +- Produces: legacy failure envelope when required Docker metadata is missing. + +- [ ] **Step 1: Add failing next-release and metadata tests** + +Add coverage for: + +```php +public function test_check_update_returns_first_allowed_pending_release_image(): void +{ + $this->insertSupportStat(['client_id' => 'app-1', 'type' => 'community']); + $this->insertSupportRelease([ + 'version' => '3.30.0', + 'version_major' => 3, + 'version_minor' => 30, + 'version_patch' => 0, + 'type' => 'community', + 'docker_image_tag' => '3.30.0-23', + ]); + $this->insertSupportRelease([ + 'version' => '3.30.1', + 'version_major' => 3, + 'version_minor' => 30, + 'version_patch' => 1, + 'type' => 'community', + 'docker_image_tag' => '3.30.1-6', + ]); + + $this->withBasicAuth('app-1', '') + ->postJson('/api/check-update', [ + 'appVersion' => '3.30.0', + 'dockerImageTag' => '3.30.0-23', + ]) + ->assertOk() + ->assertJsonPath('response.image_switch.target_app_version', '3.30.1') + ->assertJsonPath('response.image_switch.target_image_tag', '3.30.1-6'); +} + +public function test_check_update_fails_when_required_release_has_no_docker_metadata(): void +{ + $this->insertSupportStat(['client_id' => 'app-1', 'type' => 'community']); + $this->insertSupportRelease([ + 'version' => '3.30.0', + 'version_major' => 3, + 'version_minor' => 30, + 'version_patch' => 0, + 'type' => 'community', + 'docker_image_tag' => null, + ]); + + $this->withBasicAuth('app-1', '') + ->postJson('/api/check-update', [ + 'appVersion' => '3.30.0', + 'dockerImageTag' => 'latest', + ]) + ->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('message', 'Docker image metadata is not configured for release 3.30.0.'); +} +``` + +Add `test_check_update_image_plan_honours_mandatory_next_release()`: create the mandatory target first, set its ID as `mandatory_next_update` on the current release, also create a normal patch candidate, and assert `target_image_tag` equals the mandatory target tag rather than the patch tag. + +Add `test_check_update_image_plan_uses_archive_distribution_for_enterprise()`: authenticate an Enterprise customer, create current and next Enterprise production releases with Docker tags, send the approved current tag, and assert `response.image_switch.edition` is `enterprise` and `response.image_switch.distribution` is `archive`. + +- [ ] **Step 2: Run the focused contract group and confirm RED** + +Run: + +```bash +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + php artisan test --compact tests/Feature/LegacySupportApiContractTest.php \ + --filter='test_check_update_.*docker|test_check_update_returns_first_allowed' +``` + +Expected: at least the next-release and missing-metadata assertions FAIL. + +- [ ] **Step 3: Implement fail-closed target selection** + +Import `LegacySupportException` and add: + +```php +private function requireDockerMetadata(SupportRelease $release): void +{ + if ($release->docker_image_tag !== null && trim($release->docker_image_tag) !== '') { + return; + } + + throw new LegacySupportException(sprintf( + 'Docker image metadata is not configured for release %s.', + $release->version, + )); +} +``` + +After same-version resolution, select only `$pendingReleases[0]`, call `requireDockerMetadata()` on it, and return `imageSwitchPlan($currentRelease, $pendingReleases[0], $dockerImageTag)`. Do not scan later pending releases. + +- [ ] **Step 4: Prove old clients are unchanged** + +Run the existing download contract test without `dockerImageTag`: + +```bash +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + php artisan test --compact tests/Feature/LegacySupportApiContractTest.php \ + --filter=test_check_update_returns_customer_safe_pending_release_with_download_url +``` + +Expected: PASS with no `response.image_switch` key and a valid first-release download URL. + +- [ ] **Step 5: Run the full legacy support API contract and Pint** + +```bash +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + php artisan test --compact tests/Feature/LegacySupportApiContractTest.php +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + vendor/bin/pint --dirty --format agent +``` + +Expected: all tests PASS; Pint exits `0`. + +- [ ] **Step 6: Validate the migration against the configured support connection** + +```bash +docker compose -f docker-compose.yml -f docker-compose.web.yml run --rm web \ + php artisan migrate:status --database=support --no-interaction +``` + +Expected: the new migration is listed as pending before deployment or completed in a migrated test environment; no connection or syntax error occurs. + +- [ ] **Step 7: Commit the completed support contract** + +```bash +git add app/Services/LegacySupport/LegacySupportReleaseService.php \ + tests/Feature/LegacySupportApiContractTest.php +git commit -m "test: cover Docker image switch release routing" +``` + +## Plan Completion Gate + +- The migration, model, Nova field, resolver, and request normalization are committed. +- The full `LegacySupportApiContractTest` passes in the Docker web service. +- A request without `dockerImageTag` is byte-for-byte compatible at the JSON-structure level. +- Same-version, next-release, mandatory-release, Community, Enterprise, no-op, and missing-metadata cases are proven. +- `git diff --check` and `vendor/bin/pint --dirty --format agent` pass. +- Deploy this support contract and populate baseline `docker_image_tag` values before executing the Eramba application plan. diff --git a/docs/superpowers/specs/2026-07-10-eramba-docker-rebuild-app-design.md b/docs/superpowers/specs/2026-07-10-eramba-docker-rebuild-app-design.md new file mode 100644 index 0000000..795d056 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-eramba-docker-rebuild-app-design.md @@ -0,0 +1,308 @@ +# Eramba Docker Rebuild App Design + +**Issue:** ERA-1706 + +**Date:** 2026-07-10 + +**Status:** Approved design + +## Summary + +Add a safe host-side `./rebuild-app` command to the Eramba Docker repository. The command obtains one canonical image-switch plan from the existing support update system, validates the target image before downtime, replaces only the application code volume, starts the application before its dependent workers, and verifies the resulting deployment. + +The workflow supports Community images pulled from GHCR and Enterprise images supplied as downloaded archives. It does not duplicate release-order rules in shell code, use mutable `latest` tags as deployment state, create database backups, or automatically roll back after a target container may have started database migrations. + +## Goals + +- Replace the documented manual image-switch sequence with one guided command. +- Prevent unsupported version jumps using the existing canonical release tree. +- Support application-version upgrades and Docker rebuilds that retain the same application version. +- Keep MySQL, Redis, user data, application data, logs, and trigger storage intact. +- Minimize downtime by acquiring and validating the target image before stopping application services. +- Produce deterministic diagnostics and recovery information on failure. +- Remain backward-compatible with existing application update clients. + +## Non-goals + +- Creating, retaining, or pruning database backups. +- Downloading authenticated Enterprise archives from the customer portal. +- Automatically rolling back after the target application container has started. +- Removing old Docker images. +- Updating Docker Engine, Docker Compose, or the host operating system. +- Supporting a fresh installation or reconstructing a deployment whose current `eramba` container is unavailable. + +## Current Lifecycle + +The current compose definition runs `eramba` and `cron` from mutable `latest` tags and mounts the same named `app` volume at `/var/www/eramba`. The documented image-switch procedure stops the stack, removes that volume, pulls a versioned image, retags it as `latest`, and starts the stack. + +On the next `eramba` start, the image entrypoint populates the new application volume and runs `database initialize`. That command can apply structural, data, and plugin migrations before Apache starts. The safe rollback boundary is therefore the first start attempt of the target `eramba` container, not the completion of the health checks. + +The support backend already resolves allowed application updates from its `releases` table. It follows mandatory-next-release links and production/development visibility rules and returns the ordered pending release tree from `/api/check-update`. + +## Architecture + +### 1. Canonical image-switch metadata + +Extend the existing support `releases` record with a nullable `docker_image_tag` value. The value identifies the approved Docker artifact for that application release, for example `3.30.1-6`. + +A release may receive a newer approved Docker tag without changing its application version. Updating `docker_image_tag` on the existing release therefore represents a runtime-only image switch without creating a fake application release. + +The existing release type determines the image repository and delivery mechanism: + +- `community`: `ghcr.io/eramba/eramba:`, delivered through the registry. +- `enterprise`: `ghcr.io/eramba/eramba-enterprise:`, delivered as a customer-supplied archive. + +Expose the field in the existing release administration UI. Production image-switch plans may reference only production releases with non-empty Docker metadata. + +### 2. Backward-compatible support API contract + +Add an optional `dockerImageTag` string to the existing `/api/check-update` request. Existing clients that omit it retain the current response contract. + +When `dockerImageTag` is present, add an `image_switch` object under `response`: + +```json +{ + "image_switch": { + "required": true, + "source_app_version": "3.30.0", + "target_app_version": "3.30.1", + "current_image_tag": "3.30.0-23", + "target_image_tag": "3.30.1-6", + "edition": "community", + "distribution": "registry" + } +} +``` + +When the deployment is already on the approved artifact, return: + +```json +{ + "image_switch": { + "required": false + } +} +``` + +The resolver selects exactly one target: + +1. Resolve the current release using the existing application version and installation type. +2. If the current release has an approved `docker_image_tag` different from the reported tag, return a same-version image switch. +3. Otherwise resolve the first release in the existing allowed pending tree. +4. If that first pending release has Docker metadata, return it as the target. +5. If the required current or next release lacks Docker metadata, return a safe metadata-missing error. Never skip to a later release and never infer a target from semver or `latest`. + +An incoming `latest` tag is considered different from the approved immutable tag. The first successful run pins the deployment; later runs become idempotent. + +### 3. Read-only application plan command + +Add a read-only Cake command: + +```bash +bin/cake image_switch_plan --current-image-tag --format json +``` + +The command uses the application's existing support credentials and update client. It sends the current app, database, PHP, MySQL, and Docker image versions to `/api/check-update`, then emits only the normalized `image_switch` contract. It never prints support credentials or unrelated update package URLs. + +The plan request must be fresh or cached by all request inputs, including `current-image-tag`. A response cached for another Docker tag must not be reused. + +Exit codes: + +- `0`: a valid plan was returned, including `required: false`. +- non-zero: authentication, connectivity, invalid response, missing metadata, or other plan resolution failure. + +The Docker executor treats any non-zero result or malformed JSON as a hard preflight failure. + +### 4. Docker compose image contract + +Replace the mutable application image tags with a persisted interpolation value: + +```yaml +image: ghcr.io/eramba/eramba:${ERAMBA_IMAGE_TAG:-latest} +``` + +The Enterprise overlay uses the same variable with the Enterprise repository. Both `eramba` and `cron` must resolve to the same repository and tag. + +`ERAMBA_IMAGE_TAG` is stored in the installation's existing `.env`. The executor updates only this key using an atomic same-directory replacement, preserves file permissions, refuses duplicate definitions, and never logs or copies the rest of `.env`. + +## User Interface + +The primary interactive command is: + +```bash +./rebuild-app +``` + +Supported options in the first release: + +- `--edition community|enterprise`: override edition detection when needed. +- `--image-file `: required for an Enterprise plan. +- `--update-repo`: update the Docker checkout before preflight. +- `--dry-run`: resolve and validate the complete plan without changing `.env`, containers, or volumes. +- `--yes`: accept the deployment plan in non-interactive execution. +- `--backup-confirmed`: explicitly confirm a current recoverable backup in non-interactive execution. + +`--yes` does not imply `--backup-confirmed`. A non-interactive mutation requires both flags. + +The executor auto-detects the edition from the current container image repository. If detection is impossible or conflicts with `--edition`, it exits before any mutation. Enterprise execution requires a readable archive path; Community execution rejects `--image-file`. + +`--update-repo` is opt-in. It requires a clean checkout and performs only `git pull --ff-only` before Docker preflight. After a successful pull, the launcher re-executes the updated script with the original public arguments and an internal loop-prevention marker. Dirty, detached, or diverged checkouts fail without touching the deployment. + +## Execution Flow + +### Phase A: no-downtime preflight + +1. Acquire a per-installation lock using an atomic lock directory. +2. Validate required commands, Docker daemon access, Docker Compose support, `.env`, and the resolved compose configuration. +3. Optionally perform the safe repository update and re-exec. +4. Require an existing running `eramba` container and identify: + - edition and compose file set; + - current image repository, tag, and image ID/digest; + - current application version; + - the exact volume mounted at `/var/www/eramba`; + - the persistent volumes that must not change. +5. Run the current deployment checks: + - application-local HTTP readiness; + - `current_config validate`; + - `system_health check`; + - `migrations status`. +6. Request and validate the canonical image-switch plan. +7. Exit successfully with `nothing to do` when `required` is false. +8. Acquire the target artifact: + - Community: pull the exact registry reference. + - Enterprise: load the supplied archive and require the expected repository/tag to appear. +9. Create a temporary container from the target image without running its normal entrypoint. Read its application `VERSION`, then remove the temporary container. +10. Require the target image application version, repository, architecture, and tag to match the plan. +11. Print the current and target versions, image references, affected containers, exact removable volume, and preserved volumes. +12. Require explicit plan and backup confirmation. `--dry-run` exits successfully here. + +No application container or volume changes occur during Phase A. + +### Phase B: reversible pre-migration mutation + +1. Record non-secret recovery metadata under `.rebuild-app/runs//` with mode `0700` for the directory and `0600` for files. +2. Atomically write the target `ERAMBA_IMAGE_TAG` to `.env`. +3. Stop and remove `triggers_caddy`, `cron`, and `eramba` in that order. Keep MySQL and Redis running. +4. Re-resolve the recorded application volume and require it to match the preflight value. +5. Remove only that application volume. + +If any failure occurs before the target `eramba` start is attempted, restore the previous image tag, recreate the original application services from the recorded image, and report whether recovery succeeded. The database has not been exposed to target migrations at this point. + +### Phase C: migration boundary and staged startup + +1. Start only `eramba` with the target tag. Compose may ensure its MySQL and Redis dependencies exist, but it must not start `cron` or `triggers_caddy` yet. +2. Once this start is attempted, mark the run as having crossed the migration boundary. +3. Wait with a finite timeout for the entrypoint and application-local HTTP endpoint. +4. Run `current_config validate`, `system_health check`, and the application version check. +5. Start `cron`, wait for it, and run `migrations status` from the cron container. +6. Start `triggers_caddy` and require its health check to pass. +7. Verify that the running `eramba` and `cron` containers use the planned repository and tag. +8. Verify that the preserved volume identities match the preflight snapshot and the application volume identity changed. +9. Write a successful run summary and release the lock. + +## Failure Handling + +Failures before Phase B do not cause downtime. Failures in Phase B attempt automatic pre-migration recovery. + +After the target `eramba` start is attempted, the executor must not automatically start the old application image or restore the old tag. Target migrations may be partially or fully applied, and an automatic code downgrade could compound the failure. + +For a post-boundary failure, the executor: + +- leaves MySQL and Redis available; +- keeps `cron` and `triggers_caddy` stopped unless they already passed their staged startup checks; +- captures compose state and logs for `eramba`, `cron`, MySQL, and triggers without printing `.env`; +- records the old and target image references, image IDs, application versions, volume identities, failed step, and exit status; +- prints the diagnostics directory and a recovery summary that requires operator review and, when necessary, database restore. + +Signals and unexpected exits release the lock. They use the same phase-aware recovery rule and never perform a post-boundary automatic rollback. + +## Security and Data Safety + +- Never invoke `docker compose down --volumes`, `docker system prune`, or wildcard volume deletion. +- Discover the removable application volume from the running container mount destination and verify it again immediately before deletion. +- Never print, copy, source into logs, or include `.env` values in diagnostics. +- Pass support credentials only through the existing application update client. +- Accept only the exact repository, immutable tag, application version, edition, and architecture returned by the plan. +- Do not accept a newer tag merely because it compares higher lexically or semantically. +- Require an explicit backup confirmation, while leaving backup implementation and retention to the existing documented process. + +## Testing Strategy + +### Support backend + +Feature tests cover: + +- an old Docker tag for the same application release; +- the first allowed next application release; +- mandatory-next-release routing; +- production versus development visibility; +- current approved tag producing `required: false`; +- `latest` producing an immutable target; +- missing current or next release Docker metadata; +- an old request without `dockerImageTag` retaining the existing response shape and download behavior; +- Community and Enterprise distribution values. + +### Eramba application + +Focused command and update-client tests cover: + +- exact normalized JSON for a required switch and no-op; +- propagation of the current Docker tag into the request; +- cache separation or forced refresh by current tag; +- authentication, network, malformed-response, and metadata errors; +- non-zero failure exit codes; +- absence of credentials and package URLs from stdout and stderr. + +### Docker executor + +Bats tests use controlled `git`, `docker`, and `docker compose` fakes to cover: + +- option validation and edition detection; +- safe repository update and re-exec; +- dirty, detached, and diverged repository failures; +- plan parsing, no-op, dry-run, and confirmation rules; +- Community pull and Enterprise load validation; +- target version, repository, tag, and architecture mismatch; +- exact volume discovery and deletion protection; +- every failure point before and after the migration boundary; +- atomic `.env` update and pre-boundary restoration; +- idempotent repeated execution. + +The executor requires no host JSON parser. It may use PHP inside the running application container to validate and extract the plan during Phase A. + +### Integrated release test + +After the support contract and plan command are available in a published baseline image, CI performs a real Community switch from that supported baseline to the next approved production image. The test proves that: + +- only the application volume identity changes; +- database, data, logs, and trigger-storage volume identities remain unchanged; +- MySQL and Redis remain available; +- `eramba` passes migrations and health checks before cron starts; +- `cron` passes `migrations status`; +- triggers become healthy; +- the final image tag and application version match the canonical plan; +- a repeated run returns `nothing to do`. + +Enterprise uses the same orchestration tests with an archive-loaded fixture. A production-like Enterprise smoke test validates the real archive path before release. + +## Rollout Order + +1. Add and populate `docker_image_tag` for the supported current and next releases in the support backend, expose it in release administration, and deploy the backward-compatible API contract. +2. Add the Eramba `image_switch_plan` command and release the first baseline image that supports it. +3. Add the compose tag interpolation, executor, tests, and documentation to the Docker repository. +4. Run the integrated Community and Enterprise release tests. +5. Document that installations older than the baseline must use the existing manual procedure once to reach the baseline. No unsafe target override is included solely to bridge old versions. + +## Acceptance Criteria + +- One interactive `./rebuild-app` command completes a supported Community or Enterprise image switch. +- The target is selected exclusively by the canonical support release tree and Docker metadata. +- Same-version Docker rebuilds and next-version upgrades are both represented. +- The executor performs all artifact and health validation possible before downtime. +- MySQL and Redis stay running during a normal switch. +- Only the volume mounted at `/var/www/eramba` is deleted. +- `eramba` completes its migration-aware startup before cron and triggers start. +- No automatic rollback occurs after the target container start is attempted. +- Failures produce non-secret phase-aware diagnostics and recovery information. +- Existing update clients that omit `dockerImageTag` continue to work unchanged. +- Re-running the command on the approved tag is a safe no-op. diff --git a/lib/rebuild-app/common.sh b/lib/rebuild-app/common.sh new file mode 100644 index 0000000..045b6e5 --- /dev/null +++ b/lib/rebuild-app/common.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash + +log() { + printf '%s\n' "$*" +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +acquire_lock() { + LOCK_DIR="${REBUILD_APP_LOCK_DIR:-${ROOT_DIR}/.rebuild-app/lock}" + mkdir -p "$(dirname "$LOCK_DIR")" + mkdir "$LOCK_DIR" 2>/dev/null || die "Another rebuild-app process is running." + LOCK_OWNER_FILE="${LOCK_DIR}/owner" + printf '%s\n' "$$" >"$LOCK_OWNER_FILE" + chmod 600 "$LOCK_OWNER_FILE" +} + +release_lock() { + if [[ -n "${LOCK_DIR:-}" && -f "${LOCK_DIR}/owner" ]] && \ + [[ "$(<"${LOCK_DIR}/owner")" == "$$" ]]; then + rm -f "${LOCK_DIR}/owner" + rmdir "$LOCK_DIR" 2>/dev/null || true + fi +} + +resume_lock_after_reexec() { + LOCK_DIR="${REBUILD_APP_LOCK_DIR:-${ROOT_DIR}/.rebuild-app/lock}" + [[ -f "${LOCK_DIR}/owner" && "$(<"${LOCK_DIR}/owner")" == "$$" ]] || \ + die "Unable to resume rebuild-app lock after repository update." + LOCK_OWNER_FILE="${LOCK_DIR}/owner" +} + +update_repo_and_reexec() { + local checkout_status + + command -v git >/dev/null 2>&1 || die "git is required for --update-repo." + checkout_status=$(git -C "$ROOT_DIR" status --porcelain) || \ + die "Unable to inspect the Docker checkout." + [[ -z "$checkout_status" ]] || die "--update-repo requires a clean checkout." + git -C "$ROOT_DIR" symbolic-ref -q HEAD >/dev/null || \ + die "--update-repo requires an attached branch." + git -C "$ROOT_DIR" pull --ff-only || die "git pull --ff-only failed." + + exec "${ROOT_DIR}/rebuild-app" "${ORIGINAL_ARGS[@]}" --rebuild-app-after-update +} + +validate_env_key_once() { + local key=$1 + local count + + count=$(awk -v key="$key" '$0 ~ "^" key "=" { count++ } END { print count + 0 }' "$ENV_FILE") || \ + die "Unable to inspect ${ENV_FILE}." + ((count <= 1)) || die "Duplicate ${key} definitions in ${ENV_FILE}." +} + +file_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + +rewrite_env_key() { + local key=$1 + local operation=$2 + local value=${3:-} + local mode + local temporary + + mode=$(file_mode "$ENV_FILE") || return 1 + temporary=$(mktemp "${ENV_FILE}.rebuild-app.XXXXXX") || return 1 + chmod "$mode" "$temporary" || { + rm -f "$temporary" + return 1 + } + + if [[ "$operation" == set ]]; then + awk -v key="$key" -v value="$value" ' + BEGIN { replaced = 0 } + $0 ~ "^" key "=" { + if (!replaced) print key "=" value + replaced = 1 + next + } + { print } + END { if (!replaced) print key "=" value } + ' "$ENV_FILE" >"$temporary" || { + rm -f "$temporary" + return 1 + } + else + awk -v key="$key" '$0 !~ "^" key "=" { print }' "$ENV_FILE" >"$temporary" || { + rm -f "$temporary" + return 1 + } + fi + + mv -f -- "$temporary" "$ENV_FILE" +} + +atomic_set_env() { + local key=$1 + local value=$2 + local count + + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "Invalid environment key: ${key}" + validate_env_key_once "$key" + count=$(awk -v key="$key" '$0 ~ "^" key "=" { count++ } END { print count + 0 }' "$ENV_FILE") + + PREVIOUS_ENV_KEY=$key + if ((count == 1)); then + PREVIOUS_ENV_PRESENT=1 + PREVIOUS_ENV_VALUE=$(awk -v key="$key" '$0 ~ "^" key "=" { print substr($0, index($0, "=") + 1) }' "$ENV_FILE") + else + PREVIOUS_ENV_PRESENT=0 + PREVIOUS_ENV_VALUE="" + fi + + rewrite_env_key "$key" set "$value" || die "Unable to atomically update ${ENV_FILE}." + ENV_MUTATED=1 +} + +restore_image_tag() { + [[ "${ENV_MUTATED:-0}" == 1 ]] || return 0 + if ((PREVIOUS_ENV_PRESENT)); then + rewrite_env_key "$PREVIOUS_ENV_KEY" set "$PREVIOUS_ENV_VALUE" || return 1 + else + rewrite_env_key "$PREVIOUS_ENV_KEY" remove || return 1 + fi + ENV_MUTATED=0 +} + +create_run_directory() { + local state_dir=${REBUILD_APP_STATE_DIR:-${ROOT_DIR}/.rebuild-app} + local timestamp + timestamp=$(date -u '+%Y%m%dT%H%M%SZ') + RUN_DIR="${state_dir}/runs/${timestamp}" + if [[ -e "$RUN_DIR" ]]; then + RUN_DIR="${RUN_DIR}.$$" + fi + mkdir -p "$(dirname "$RUN_DIR")" + mkdir -m 700 "$RUN_DIR" || die "Unable to create rebuild run directory." + chmod 700 "$RUN_DIR" +} + +write_run_state() { + local outcome=$1 + local exit_status=$2 + local failed_step=${3:-none} + local state_file + + [[ -n "${RUN_DIR:-}" ]] || return 0 + state_file="${RUN_DIR}/state.txt" + { + printf 'outcome=%s\n' "$outcome" + printf 'phase=%s\n' "${PHASE:-unknown}" + printf 'failed_step=%s\n' "$failed_step" + printf 'exit_status=%s\n' "$exit_status" + printf 'current_image=%s\n' "${CURRENT_IMAGE:-unknown}" + printf 'current_image_id=%s\n' "${CURRENT_IMAGE_ID:-unknown}" + printf 'current_app_version=%s\n' "${CURRENT_APP_VERSION:-unknown}" + printf 'target_image=%s\n' "${TARGET_IMAGE:-unknown}" + printf 'target_image_id=%s\n' "${TARGET_IMAGE_ID:-unknown}" + printf 'target_app_version=%s\n' "${TARGET_APP_VERSION:-unknown}" + printf 'app_volume_before=%s\n' "${APP_VOLUME_IDENTITY:-unknown}" + printf 'data_volume_before=%s\n' "${DATA_VOLUME_IDENTITY:-unknown}" + printf 'logs_volume_before=%s\n' "${LOGS_VOLUME_IDENTITY:-unknown}" + printf 'db_volume_before=%s\n' "${DB_VOLUME_IDENTITY:-unknown}" + printf 'trigger_storage_before=%s\n' "${TRIGGER_STORAGE_VOLUME_IDENTITY:-unknown}" + printf 'app_volume_after=%s\n' "${FINAL_APP_VOLUME_IDENTITY:-unknown}" + printf 'data_volume_after=%s\n' "${FINAL_DATA_VOLUME_IDENTITY:-unknown}" + printf 'logs_volume_after=%s\n' "${FINAL_LOGS_VOLUME_IDENTITY:-unknown}" + printf 'db_volume_after=%s\n' "${FINAL_DB_VOLUME_IDENTITY:-unknown}" + printf 'trigger_storage_after=%s\n' "${FINAL_TRIGGER_STORAGE_VOLUME_IDENTITY:-unknown}" + } >"$state_file" + chmod 600 "$state_file" +} diff --git a/lib/rebuild-app/docker.sh b/lib/rebuild-app/docker.sh new file mode 100644 index 0000000..71e39c4 --- /dev/null +++ b/lib/rebuild-app/docker.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash + +compose() { + docker compose "${COMPOSE_ARGS[@]}" "$@" +} + +configure_compose() { + COMPOSE_ARGS=(-f "${ROOT_DIR}/docker-compose.simple-install.yml") + if [[ "$EDITION" == enterprise ]]; then + COMPOSE_ARGS+=(-f "${ROOT_DIR}/docker-compose.simple-install.enterprise.yml") + fi +} + +detect_installation() { + local detected_edition + local running + + running=$(docker inspect --format '{{.State.Running}}' eramba 2>/dev/null) || \ + die "An existing running eramba container is required." + [[ "$running" == true ]] || die "An existing running eramba container is required." + + CURRENT_IMAGE=$(docker inspect --format '{{.Config.Image}}' eramba) || \ + die "Unable to inspect the running eramba image." + + if [[ "$CURRENT_IMAGE" =~ ^ghcr\.io/eramba/eramba:([^:@]+)$ ]]; then + detected_edition=community + CURRENT_IMAGE_REPOSITORY=ghcr.io/eramba/eramba + elif [[ "$CURRENT_IMAGE" =~ ^ghcr\.io/eramba/eramba-enterprise:([^:@]+)$ ]]; then + detected_edition=enterprise + CURRENT_IMAGE_REPOSITORY=ghcr.io/eramba/eramba-enterprise + else + die "Unable to detect edition from running image: ${CURRENT_IMAGE}" + fi + CURRENT_IMAGE_TAG=${BASH_REMATCH[1]} + + if [[ -n "$EDITION" && "$EDITION" != "$detected_edition" ]]; then + die "Requested edition conflicts with the running image." + fi + EDITION=$detected_edition + configure_compose + + CURRENT_APP_VERSION=$(docker exec -w /var/www/eramba/app/upgrade -u www-data \ + eramba cat /var/www/eramba/app/upgrade/VERSION) || \ + die "Unable to read the current application version." + CURRENT_APP_VERSION=${CURRENT_APP_VERSION//$'\r'/} + CURRENT_APP_VERSION=${CURRENT_APP_VERSION//$'\n'/} + [[ -n "$CURRENT_APP_VERSION" ]] || die "Current application version is empty." + + CURRENT_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$CURRENT_IMAGE") || \ + die "Unable to inspect the current application image ID." + [[ -n "$CURRENT_IMAGE_ID" ]] || die "Current application image ID is empty." +} + +validate_delivery_options() { + if [[ "$EDITION" == community ]]; then + [[ -z "$IMAGE_FILE" ]] || die "--image-file is only valid for enterprise" + else + [[ -n "$IMAGE_FILE" ]] || die "--image-file is required for enterprise" + [[ -r "$IMAGE_FILE" && -f "$IMAGE_FILE" ]] || die "Enterprise image archive is not readable: ${IMAGE_FILE}" + fi +} + +check_current_deployment() { + application_exec eramba curl -fsS -o /dev/null http://localhost:80 || \ + die "Current application HTTP readiness check failed." + application_exec eramba bin/cake current_config validate || \ + die "Current configuration validation failed." + application_exec eramba bin/cake system_health check || \ + die "Current system health check failed." + application_exec cron bin/cake migrations status || \ + die "Current migrations status check failed." +} + +acquire_target_image() { + if [[ "$EDITION" == community ]]; then + docker pull "$TARGET_IMAGE" || die "Unable to pull target image: ${TARGET_IMAGE}" + else + docker load --input "$IMAGE_FILE" || die "Unable to load Enterprise image archive." + docker image inspect "$TARGET_IMAGE" >/dev/null 2>&1 || \ + die "Enterprise archive did not provide the planned image: ${TARGET_IMAGE}" + fi +} + +normalize_architecture() { + case "$1" in + x86_64 | amd64) printf '%s\n' amd64 ;; + aarch64 | arm64) printf '%s\n' arm64 ;; + *) printf '%s\n' "$1" ;; + esac +} + +read_target_version() { + local container_id + local version_file + local version + + version_file=$(mktemp "${TMPDIR:-/tmp}/rebuild-app-version.XXXXXX") || \ + die "Unable to create target inspection file." + if ! container_id=$(docker create --entrypoint /bin/true "$TARGET_IMAGE"); then + rm -f "$version_file" + die "Unable to create target image inspection container." + fi + + if ! docker cp "${container_id}:/var/www/eramba/app/upgrade/VERSION" "$version_file"; then + docker rm -f "$container_id" >/dev/null 2>&1 || true + rm -f "$version_file" + die "Unable to read VERSION from target image." + fi + version=$(tr -d '\r\n' <"$version_file") + docker rm -f "$container_id" >/dev/null 2>&1 || true + rm -f "$version_file" + printf '%s\n' "$version" +} + +validate_target_image() { + local host_arch + local image_arch + local repo_tags + local target_version + + repo_tags=$(docker image inspect --format '{{json .RepoTags}}' "$TARGET_IMAGE") || \ + die "Unable to inspect target image tags." + [[ "$repo_tags" == *"\"${TARGET_IMAGE}\""* ]] || \ + die "Target image repository or tag does not match the plan." + + image_arch=$(docker image inspect --format '{{.Architecture}}' "$TARGET_IMAGE") || \ + die "Unable to inspect target image architecture." + host_arch=$(docker info --format '{{.Architecture}}') || \ + die "Unable to inspect Docker host architecture." + [[ "$(normalize_architecture "$image_arch")" == "$(normalize_architecture "$host_arch")" ]] || \ + die "Target image architecture does not match the Docker host." + + target_version=$(read_target_version) + [[ "$target_version" == "$TARGET_APP_VERSION" ]] || \ + die "Target application version does not match the plan." + + TARGET_IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$TARGET_IMAGE") || \ + die "Unable to inspect target image ID." + [[ -n "$TARGET_IMAGE_ID" ]] || die "Target image ID is empty." +} + +volume_name_at_destination() { + local container=$1 + local destination=$2 + local template + local name + + [[ "$destination" == /* && "$destination" != *'"'* ]] || \ + die "Invalid mount destination: ${destination}" + template="{{range .Mounts}}{{if eq .Destination \"${destination}\"}}{{println .Name}}{{end}}{{end}}" + name=$(docker inspect --format "$template" "$container") || \ + die "Unable to inspect ${container} mount at ${destination}." + name=${name%$'\n'} + [[ -n "$name" && "$name" != *$'\n'* ]] || \ + die "Expected exactly one named volume for ${container}:${destination}." + printf '%s\n' "$name" +} + +volume_identity() { + local container=$1 + local destination=$2 + local name + local created_at + + name=$(volume_name_at_destination "$container" "$destination") + created_at=$(docker volume inspect --format '{{.CreatedAt}}' "$name") || \ + die "Unable to inspect Docker volume: ${name}" + [[ -n "$created_at" ]] || die "Docker volume has no creation identity: ${name}" + printf '%s|%s\n' "$name" "$created_at" +} + +snapshot_volume_identities() { + APP_VOLUME_IDENTITY=$(volume_identity eramba /var/www/eramba) + DATA_VOLUME_IDENTITY=$(volume_identity eramba /var/www/eramba/app/upgrade/data) + LOGS_VOLUME_IDENTITY=$(volume_identity eramba /var/www/eramba/app/upgrade/logs) + DB_VOLUME_IDENTITY=$(volume_identity mysql /var/lib/mysql) + TRIGGER_STORAGE_VOLUME_IDENTITY=$(volume_identity triggers_caddy /data/eramba_trigger_storage) + APP_VOLUME_NAME=${APP_VOLUME_IDENTITY%%|*} +} + +stop_application_services() { + local service + for service in triggers_caddy cron eramba; do + compose stop "$service" + compose rm -f "$service" + done +} + +remove_verified_app_volume() { + local current_identity + local current_name + + compose create --no-deps eramba + if ! current_identity=$(volume_identity eramba /var/www/eramba); then + compose rm -f eramba >/dev/null 2>&1 || true + die "Unable to re-verify the application volume." + fi + compose rm -f eramba + current_name=${current_identity%%|*} + [[ "$current_name" == "$APP_VOLUME_NAME" ]] || \ + die "Application volume changed after preflight; refusing deletion." + docker volume rm -- "$APP_VOLUME_NAME" || die "Unable to remove application volume: ${APP_VOLUME_NAME}" +} + +recover_before_migration() { + local recovered=1 + + if restore_image_tag; then + compose up -d eramba cron triggers_caddy || recovered=0 + else + recovered=0 + fi + if ((recovered)); then + log "Pre-migration recovery succeeded; the previous image tag and application services were restored." + else + log "Pre-migration recovery failed; operator intervention is required." + fi + return 0 +} + +application_exec() { + docker exec -w /var/www/eramba/app/upgrade -u www-data "$@" +} + +wait_for_application_http() { + local timeout=${REBUILD_APP_START_TIMEOUT_SECONDS:-300} + local interval=${REBUILD_APP_POLL_INTERVAL_SECONDS:-2} + local deadline=$((SECONDS + timeout)) + + while true; do + if application_exec eramba curl -fsS -o /dev/null http://localhost:80; then + return 0 + fi + ((SECONDS >= deadline)) && die "Timed out waiting for the target eramba HTTP endpoint." + sleep "$interval" + done +} + +start_and_verify_eramba() { + local running_version + + PHASE=migration-boundary + FAILED_STEP=start-target-eramba + compose up -d eramba + + FAILED_STEP=wait-for-target-http + wait_for_application_http + + FAILED_STEP=verify-target-version + running_version=$(application_exec eramba cat /var/www/eramba/app/upgrade/VERSION) || \ + die "Unable to read the running target application version." + running_version=${running_version//$'\r'/} + running_version=${running_version//$'\n'/} + [[ "$running_version" == "$TARGET_APP_VERSION" ]] || \ + die "Running application version does not match the plan." + + FAILED_STEP=validate-target-config + application_exec eramba bin/cake current_config validate + FAILED_STEP=check-target-health + application_exec eramba bin/cake system_health check + ERAMBA_VERIFIED=1 +} + +start_and_verify_cron() { + FAILED_STEP=start-target-cron + compose up -d cron + FAILED_STEP=check-target-migrations + application_exec cron bin/cake migrations status + CRON_VERIFIED=1 +} + +wait_for_trigger_health() { + local timeout=${REBUILD_APP_START_TIMEOUT_SECONDS:-300} + local interval=${REBUILD_APP_POLL_INTERVAL_SECONDS:-2} + local deadline=$((SECONDS + timeout)) + local health + + while true; do + health=$(docker inspect --format '{{.State.Health.Status}}' triggers_caddy 2>/dev/null || true) + [[ "$health" == healthy ]] && return 0 + ((SECONDS >= deadline)) && die "Timed out waiting for triggers_caddy health." + sleep "$interval" + done +} + +start_and_verify_triggers() { + FAILED_STEP=start-target-triggers + compose up -d triggers_caddy + FAILED_STEP=check-target-triggers-health + wait_for_trigger_health + TRIGGERS_VERIFIED=1 +} + +verify_final_deployment() { + local eramba_image + local cron_image + + FAILED_STEP=verify-final-images + eramba_image=$(docker inspect --format '{{.Config.Image}}' eramba) || \ + die "Unable to inspect final eramba image." + cron_image=$(docker inspect --format '{{.Config.Image}}' cron) || \ + die "Unable to inspect final cron image." + [[ "$eramba_image" == "$TARGET_IMAGE" && "$cron_image" == "$TARGET_IMAGE" ]] || \ + die "Final application services do not use the planned image." + + FAILED_STEP=verify-final-volumes + FINAL_APP_VOLUME_IDENTITY=$(volume_identity eramba /var/www/eramba) + FINAL_DATA_VOLUME_IDENTITY=$(volume_identity eramba /var/www/eramba/app/upgrade/data) + FINAL_LOGS_VOLUME_IDENTITY=$(volume_identity eramba /var/www/eramba/app/upgrade/logs) + FINAL_DB_VOLUME_IDENTITY=$(volume_identity mysql /var/lib/mysql) + FINAL_TRIGGER_STORAGE_VOLUME_IDENTITY=$(volume_identity triggers_caddy /data/eramba_trigger_storage) + + [[ "$FINAL_APP_VOLUME_IDENTITY" != "$APP_VOLUME_IDENTITY" ]] || \ + die "Application volume identity did not change." + [[ "$FINAL_DATA_VOLUME_IDENTITY" == "$DATA_VOLUME_IDENTITY" ]] || \ + die "Preserved volume identity changed: application data." + [[ "$FINAL_LOGS_VOLUME_IDENTITY" == "$LOGS_VOLUME_IDENTITY" ]] || \ + die "Preserved volume identity changed: application logs." + [[ "$FINAL_DB_VOLUME_IDENTITY" == "$DB_VOLUME_IDENTITY" ]] || \ + die "Preserved volume identity changed: MySQL data." + [[ "$FINAL_TRIGGER_STORAGE_VOLUME_IDENTITY" == "$TRIGGER_STORAGE_VOLUME_IDENTITY" ]] || \ + die "Preserved volume identity changed: trigger storage." +} + +quiesce_unverified_dependents() { + if [[ "${TRIGGERS_VERIFIED:-0}" != 1 ]]; then + compose stop triggers_caddy >/dev/null 2>&1 || true + fi + if [[ "${CRON_VERIFIED:-0}" != 1 ]]; then + compose stop cron >/dev/null 2>&1 || true + fi +} + +capture_diagnostics() { + local exit_status=$1 + local service + local inspect_template + + [[ -n "${RUN_DIR:-}" ]] || return 0 + write_run_state failed "$exit_status" "${FAILED_STEP:-unknown}" || true + + compose ps >"${RUN_DIR}/compose-ps.txt" 2>&1 || true + chmod 600 "${RUN_DIR}/compose-ps.txt" 2>/dev/null || true + + inspect_template='{"name":{{json .Name}},"image":{{json .Config.Image}},"image_id":{{json .Image}},"state":{{json .State}},"mounts":{{json .Mounts}}}' + for service in eramba cron mysql redis triggers_caddy; do + docker inspect --format "$inspect_template" "$service" \ + >"${RUN_DIR}/${service}-inspect.json" 2>&1 || true + chmod 600 "${RUN_DIR}/${service}-inspect.json" 2>/dev/null || true + docker logs --tail 500 "$service" >"${RUN_DIR}/${service}.log" 2>&1 || true + chmod 600 "${RUN_DIR}/${service}.log" 2>/dev/null || true + done +} diff --git a/lib/rebuild-app/plan.sh b/lib/rebuild-app/plan.sh new file mode 100644 index 0000000..78b8572 --- /dev/null +++ b/lib/rebuild-app/plan.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +plan_field() { + local field=$1 + printf '%s' "$PLAN_JSON" | docker exec -i eramba php -r ' + $data = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR); + $field = $argv[1]; + if (!array_key_exists($field, $data) || (!is_string($data[$field]) && !is_bool($data[$field]))) { + exit(2); + } + echo is_bool($data[$field]) ? ($data[$field] ? "true" : "false") : $data[$field]; + ' "$field" +} + +require_plan_field() { + local field=$1 + local value + value=$(plan_field "$field") || die "Invalid image switch plan: missing or invalid ${field}." + [[ -n "$value" ]] || die "Invalid image switch plan: empty ${field}." + printf '%s\n' "$value" +} + +resolve_plan() { + PLAN_JSON=$(docker exec -w /var/www/eramba/app/upgrade -u www-data eramba \ + bin/cake image_switch_plan --current-image-tag "$CURRENT_IMAGE_TAG" --format json) || \ + die "Unable to resolve the canonical image switch plan." + + PLAN_REQUIRED=$(plan_field required) || die "Invalid image switch plan: missing or invalid required." + if [[ "$PLAN_REQUIRED" == false ]]; then + return 0 + fi + [[ "$PLAN_REQUIRED" == true ]] || die "Invalid image switch plan: required must be boolean." + + SOURCE_APP_VERSION=$(require_plan_field source_app_version) + TARGET_APP_VERSION=$(require_plan_field target_app_version) + PLAN_CURRENT_IMAGE_TAG=$(require_plan_field current_image_tag) + TARGET_IMAGE_TAG=$(require_plan_field target_image_tag) + PLAN_EDITION=$(require_plan_field edition) + DISTRIBUTION=$(require_plan_field distribution) + + [[ "$SOURCE_APP_VERSION" == "$CURRENT_APP_VERSION" ]] || \ + die "Plan source application version does not match the running deployment." + [[ "$PLAN_CURRENT_IMAGE_TAG" == "$CURRENT_IMAGE_TAG" ]] || \ + die "Plan current image tag does not match the running deployment." + [[ "$PLAN_EDITION" == "$EDITION" ]] || die "Plan edition does not match the running deployment." + [[ "$TARGET_IMAGE_TAG" != latest && "$TARGET_IMAGE_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || \ + die "Invalid image switch plan: target image tag must be immutable." + + if [[ "$EDITION" == community ]]; then + [[ "$DISTRIBUTION" == registry ]] || die "Community plans require registry distribution." + TARGET_IMAGE_REPOSITORY=ghcr.io/eramba/eramba + else + [[ "$DISTRIBUTION" == archive ]] || die "Enterprise plans require archive distribution." + TARGET_IMAGE_REPOSITORY=ghcr.io/eramba/eramba-enterprise + fi + TARGET_IMAGE="${TARGET_IMAGE_REPOSITORY}:${TARGET_IMAGE_TAG}" +} diff --git a/rebuild-app b/rebuild-app new file mode 100755 index 0000000..8e9495c --- /dev/null +++ b/rebuild-app @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/rebuild-app/common.sh +source "${ROOT_DIR}/lib/rebuild-app/common.sh" +# shellcheck source=lib/rebuild-app/plan.sh +source "${ROOT_DIR}/lib/rebuild-app/plan.sh" +# shellcheck source=lib/rebuild-app/docker.sh +source "${ROOT_DIR}/lib/rebuild-app/docker.sh" + +ORIGINAL_ARGS=("$@") +EDITION="" +IMAGE_FILE="" +UPDATE_REPO=0 +DRY_RUN=0 +ASSUME_YES=0 +BACKUP_CONFIRMED=0 +AFTER_REPO_UPDATE=0 + +while (($#)); do + case "$1" in + --edition) + (($# >= 2)) || die "--edition requires community or enterprise" + EDITION=$2 + shift 2 + ;; + --image-file) + (($# >= 2)) || die "--image-file requires a path" + IMAGE_FILE=$2 + shift 2 + ;; + --update-repo) + UPDATE_REPO=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --yes) + ASSUME_YES=1 + shift + ;; + --backup-confirmed) + BACKUP_CONFIRMED=1 + shift + ;; + --rebuild-app-after-update) + AFTER_REPO_UPDATE=1 + shift + ;; + *) + die "Unknown option: $1" + ;; + esac +done + +[[ -z "$EDITION" || "$EDITION" == community || "$EDITION" == enterprise ]] || \ + die "--edition must be community or enterprise" + +if [[ "$EDITION" == community && -n "$IMAGE_FILE" ]]; then + die "--image-file is only valid for enterprise" +fi + +if [[ "$EDITION" == enterprise && -z "$IMAGE_FILE" ]]; then + die "--image-file is required for enterprise" +fi + +if ((ASSUME_YES && !DRY_RUN && !BACKUP_CONFIRMED)); then + die "--backup-confirmed is required with --yes" +fi + +export EDITION IMAGE_FILE UPDATE_REPO DRY_RUN ASSUME_YES BACKUP_CONFIRMED + +ENV_FILE="${REBUILD_APP_ENV_FILE:-${ROOT_DIR}/.env}" +[[ -f "$ENV_FILE" ]] || die "Missing deployment environment file: ${ENV_FILE}" + +PHASE=preflight +ENV_MUTATED=0 +ERAMBA_VERIFIED=0 +CRON_VERIFIED=0 +TRIGGERS_VERIFIED=0 +FAILED_STEP=preflight + +handle_exit() { + local exit_status=$? + trap - EXIT INT TERM + if ((exit_status != 0)) && [[ "$PHASE" == mutating ]]; then + recover_before_migration + write_run_state recovered "$exit_status" "${FAILED_STEP:-unknown}" || true + elif ((exit_status != 0)) && [[ "$PHASE" == migration-boundary ]]; then + quiesce_unverified_dependents + capture_diagnostics "$exit_status" + log "Migration boundary crossed; automatic rollback was not attempted." + log "Operator review and, when required, database restore are necessary before recovery." + log "Diagnostics: ${RUN_DIR}" + fi + release_lock + exit "$exit_status" +} + +if ((AFTER_REPO_UPDATE)); then + resume_lock_after_reexec +else + acquire_lock +fi +trap handle_exit EXIT +trap 'exit 130' INT TERM + +if ((UPDATE_REPO && !AFTER_REPO_UPDATE)); then + update_repo_and_reexec +fi + +command -v docker >/dev/null 2>&1 || die "docker is required." +detect_installation +validate_delivery_options +compose config >/dev/null || die "Docker Compose configuration is invalid." +validate_env_key_once ERAMBA_IMAGE_TAG +check_current_deployment +resolve_plan + +if [[ "$PLAN_REQUIRED" == false ]]; then + log "nothing to do" + exit 0 +fi + +acquire_target_image +validate_target_image +snapshot_volume_identities + +log "Current: ${CURRENT_IMAGE} (application ${CURRENT_APP_VERSION})" +log "Target: ${TARGET_IMAGE} (application ${TARGET_APP_VERSION})" +log "Application volume to replace: ${APP_VOLUME_NAME}" +log "Preserved volumes: ${DATA_VOLUME_IDENTITY%%|*}, ${LOGS_VOLUME_IDENTITY%%|*}, ${DB_VOLUME_IDENTITY%%|*}, ${TRIGGER_STORAGE_VOLUME_IDENTITY%%|*}" + +if ((DRY_RUN)); then + log "Dry run complete; no deployment state changed." + exit 0 +fi + +if ((!ASSUME_YES)); then + read -r -p "Proceed with this deployment plan? Type yes: " plan_confirmation + [[ "$plan_confirmation" == yes ]] || die "Deployment plan was not confirmed." + read -r -p "Confirm a current recoverable backup exists. Type yes: " backup_confirmation + [[ "$backup_confirmation" == yes ]] || die "A current recoverable backup was not confirmed." +fi + +create_run_directory +write_run_state prepared 0 none +PHASE=mutating +FAILED_STEP=write-target-image-tag +atomic_set_env ERAMBA_IMAGE_TAG "$TARGET_IMAGE_TAG" +FAILED_STEP=stop-application-services +stop_application_services +FAILED_STEP=remove-application-volume +remove_verified_app_volume + +start_and_verify_eramba +start_and_verify_cron +start_and_verify_triggers +verify_final_deployment + +PHASE=complete +FAILED_STEP=none +write_run_state succeeded 0 none +log "Rebuild completed successfully." diff --git a/tests/rebuild-app-lifecycle.bats b/tests/rebuild-app-lifecycle.bats new file mode 100644 index 0000000..52cdf69 --- /dev/null +++ b/tests/rebuild-app-lifecycle.bats @@ -0,0 +1,241 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_rebuild_app_test + install_fake_docker + set_required_community_plan + write_current_env +} + +teardown() { + teardown_rebuild_app_test +} + +assert_post_boundary_failure() { + [ "$status" -ne 0 ] + assert_output_contains "Migration boundary crossed; automatic rollback was not attempted" + assert_output_contains "Diagnostics:" + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.1-6' "$REBUILD_APP_ENV_FILE" + assert_log_excludes " up -d eramba cron triggers_caddy" +} + +@test "pre-migration failure restores the previous tag and application services" { + export FAKE_FAIL_MATCH=" rm -f triggers_caddy " + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -ne 0 ] + assert_output_contains "Pre-migration recovery succeeded" + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.0-23' "$REBUILD_APP_ENV_FILE" + assert_log_contains "observed-tag-before-stop 3.30.1-6" + assert_log_contains " up -d eramba cron triggers_caddy" + assert_log_excludes " volume rm " +} + +@test "failed tag restoration never starts application services" { + install_fail_second_mv + export FAKE_FAIL_MATCH=" rm -f triggers_caddy " + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -ne 0 ] + assert_output_contains "Pre-migration recovery failed" + assert_log_excludes " up -d eramba cron triggers_caddy" +} + +@test "volume mutation removes only the twice-verified app volume and never mysql or redis" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -eq 0 ] + assert_output_contains "Rebuild completed successfully" + assert_log_contains "docker volume rm -- app-volume" + assert_log_excludes " stop mysql" + assert_log_excludes " rm -f mysql" + assert_log_excludes " stop redis" + assert_log_excludes " rm -f redis" + + triggers_stop=$(grep -n " stop triggers_caddy" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + cron_stop=$(grep -n " stop cron" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + eramba_stop=$(grep -n " stop eramba" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + verification_create=$(grep -n " create --no-deps eramba" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + volume_rm=$(grep -n " volume rm -- app-volume" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + [ "$triggers_stop" -lt "$cron_stop" ] + [ "$cron_stop" -lt "$eramba_stop" ] + [ "$eramba_stop" -lt "$verification_create" ] + [ "$verification_create" -lt "$volume_rm" ] + [ "$eramba_stop" -lt "$volume_rm" ] + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.1-6' "$REBUILD_APP_ENV_FILE" +} + +@test "volume identity change aborts without deleting any volume" { + export FAKE_CHANGED_APP_VOLUME=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -ne 0 ] + assert_output_contains "Application volume changed after preflight" + assert_log_excludes " volume rm " + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.0-23' "$REBUILD_APP_ENV_FILE" +} + +@test "atomic env update preserves permissions and unrelated values" { + run bash -c ' + set -Eeuo pipefail + ROOT_DIR=$1 + ENV_FILE=$2 + source "$ROOT_DIR/lib/rebuild-app/common.sh" + atomic_set_env ERAMBA_IMAGE_TAG 3.30.1-6 + ' bash "$REBUILD_APP_ROOT" "$REBUILD_APP_ENV_FILE" + [ "$status" -eq 0 ] + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.1-6' "$REBUILD_APP_ENV_FILE" + grep -Fx 'DB_PASSWORD=must-not-appear-in-output' "$REBUILD_APP_ENV_FILE" + [ "$(mode_of "$REBUILD_APP_ENV_FILE")" = 640 ] +} + +@test "atomic env update refuses duplicate exact keys" { + printf '%s\n' 'ERAMBA_IMAGE_TAG=old' 'ERAMBA_IMAGE_TAG=duplicate' >"$REBUILD_APP_ENV_FILE" + + run bash -c ' + set -Eeuo pipefail + ROOT_DIR=$1 + ENV_FILE=$2 + source "$ROOT_DIR/lib/rebuild-app/common.sh" + atomic_set_env ERAMBA_IMAGE_TAG target + ' bash "$REBUILD_APP_ROOT" "$REBUILD_APP_ENV_FILE" + [ "$status" -ne 0 ] + assert_output_contains "Duplicate ERAMBA_IMAGE_TAG definitions" + [ "$(grep -c '^ERAMBA_IMAGE_TAG=' "$REBUILD_APP_ENV_FILE")" -eq 2 ] +} + +@test "staged startup verifies eramba then cron then triggers" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -eq 0 ] + + eramba_up=$(grep -n " up -d eramba$" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + http_check=$(awk -v start="$eramba_up" 'NR > start && / exec .* eramba curl / { print NR; exit }' "$FAKE_COMMAND_LOG") + config_check=$(awk -v start="$eramba_up" 'NR > start && / current_config validate/ { print NR; exit }' "$FAKE_COMMAND_LOG") + health_check=$(awk -v start="$eramba_up" 'NR > start && / system_health check/ { print NR; exit }' "$FAKE_COMMAND_LOG") + cron_up=$(grep -n " up -d cron$" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + migrations_check=$(awk -v start="$cron_up" 'NR > start && / cron bin\/cake migrations status/ { print NR; exit }' "$FAKE_COMMAND_LOG") + triggers_up=$(grep -n " up -d triggers_caddy$" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + trigger_health=$(grep -n "State.Health.Status.*triggers_caddy" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + + [ "$eramba_up" -lt "$http_check" ] + [ "$http_check" -lt "$config_check" ] + [ "$config_check" -lt "$health_check" ] + [ "$health_check" -lt "$cron_up" ] + [ "$cron_up" -lt "$migrations_check" ] + [ "$migrations_check" -lt "$triggers_up" ] + [ "$triggers_up" -lt "$trigger_health" ] +} + +@test "migration boundary failure keeps target tag and captures diagnostics without rollback" { + export FAKE_TARGET_HTTP_STATUS=1 + export REBUILD_APP_START_TIMEOUT_SECONDS=0 + export REBUILD_APP_POLL_INTERVAL_SECONDS=0 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -ne 0 ] + assert_output_contains "Migration boundary crossed; automatic rollback was not attempted" + assert_output_contains "Diagnostics:" + [[ "$output" != *'must-not-appear-in-output'* ]] + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.1-6' "$REBUILD_APP_ENV_FILE" + assert_log_excludes " up -d eramba cron triggers_caddy" + assert_log_excludes " up -d cron" + assert_log_excludes " up -d triggers_caddy" + + run_dir=$(find "$REBUILD_APP_STATE_DIR/runs" -mindepth 1 -maxdepth 1 -type d | head -1) + [ -n "$run_dir" ] + grep -Fx 'phase=migration-boundary' "$run_dir/state.txt" + grep -Fx 'current_image=ghcr.io/eramba/eramba:3.30.0-23' "$run_dir/state.txt" + grep -Fx 'target_image=ghcr.io/eramba/eramba:3.30.1-6' "$run_dir/state.txt" + grep -Fx 'current_image_id=sha256:current' "$run_dir/state.txt" + grep -Fx 'target_image_id=sha256:target' "$run_dir/state.txt" + ! grep -R -F 'must-not-appear-in-output' "$run_dir" + [ "$(mode_of "$run_dir")" = 700 ] + while IFS= read -r file; do + [ "$(mode_of "$file")" = 600 ] + done < <(find "$run_dir" -type f) +} + +@test "post-boundary preserved volume mismatch fails without restoring old code" { + export FAKE_CHANGED_PRESERVED_VOLUME=logs + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -ne 0 ] + assert_output_contains "Preserved volume identity changed" + assert_output_contains "automatic rollback was not attempted" + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.1-6' "$REBUILD_APP_ENV_FILE" +} + +@test "pre-migration volume removal failure restores the previous deployment" { + export FAKE_VOLUME_RM_STATUS=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + [ "$status" -ne 0 ] + assert_output_contains "Pre-migration recovery succeeded" + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.0-23' "$REBUILD_APP_ENV_FILE" + assert_log_contains " up -d eramba cron triggers_caddy" +} + +@test "target eramba start failure is post-boundary and never rolls back" { + export FAKE_FAIL_MATCH=" up -d eramba" + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + assert_post_boundary_failure + assert_log_excludes " up -d cron" + assert_log_excludes " up -d triggers_caddy" +} + +@test "running target version mismatch is post-boundary and blocks cron" { + export FAKE_RUNNING_TARGET_APP_VERSION=3.30.9 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + assert_post_boundary_failure + assert_output_contains "Running application version does not match" + assert_log_excludes " up -d cron" +} + +@test "target configuration failure is post-boundary and blocks cron" { + export FAKE_TARGET_CONFIG_STATUS=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + assert_post_boundary_failure + assert_log_excludes " up -d cron" +} + +@test "target cron migrations failure stops unverified cron and blocks triggers" { + export FAKE_TARGET_MIGRATIONS_STATUS=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + assert_post_boundary_failure + assert_log_contains " up -d cron" + assert_log_contains " stop cron" + assert_log_excludes " up -d triggers_caddy" +} + +@test "unhealthy triggers are stopped post-boundary without stopping verified cron" { + export FAKE_TRIGGER_HEALTH=unhealthy + export REBUILD_APP_START_TIMEOUT_SECONDS=0 + export REBUILD_APP_POLL_INTERVAL_SECONDS=0 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + assert_post_boundary_failure + assert_log_contains " up -d triggers_caddy" + triggers_up=$(grep -n " up -d triggers_caddy$" "$FAKE_COMMAND_LOG" | head -1 | cut -d: -f1) + ! awk -v start="$triggers_up" 'NR > start && / stop cron/ { found = 1 } END { exit found ? 0 : 1 }' "$FAKE_COMMAND_LOG" +} + +@test "final image mismatch is diagnosed after all staged checks" { + export FAKE_TARGET_IMAGE=ghcr.io/eramba/eramba:wrong + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + assert_post_boundary_failure + assert_output_contains "do not use the planned image" +} + +@test "reused app volume is a post-boundary verification failure" { + export FAKE_APP_VOLUME_REUSED=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes --backup-confirmed + assert_post_boundary_failure + assert_output_contains "Application volume identity did not change" +} diff --git a/tests/rebuild-app-options.bats b/tests/rebuild-app-options.bats new file mode 100644 index 0000000..8a51bd8 --- /dev/null +++ b/tests/rebuild-app-options.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_rebuild_app_test +} + +teardown() { + teardown_rebuild_app_test +} + +@test "community rejects an image archive" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --image-file target.tar --dry-run + [ "$status" -ne 0 ] + assert_output_contains "--image-file is only valid for enterprise" +} + +@test "enterprise requires an image archive" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition enterprise --dry-run + [ "$status" -ne 0 ] + assert_output_contains "--image-file is required for enterprise" +} + +@test "non-interactive mutation requires backup confirmation" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --yes + [ "$status" -ne 0 ] + assert_output_contains "--backup-confirmed is required with --yes" +} + +@test "unknown options are rejected" { + run "$REBUILD_APP_ROOT/rebuild-app" --target-tag 3.30.1-6 + [ "$status" -ne 0 ] + assert_output_contains "Unknown option: --target-tag" +} + +@test "safe repository update pulls fast-forward once and re-executes" { + install_fake_git + install_fake_docker + export FAKE_PLAN_JSON='{"required":false}' + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --update-repo --dry-run + [ "$status" -eq 0 ] + [ "$(grep -c 'git -C .* pull --ff-only' "$FAKE_COMMAND_LOG")" -eq 1 ] + assert_log_contains "image_switch_plan" +} + +@test "repository update rejects a dirty checkout before Docker preflight" { + install_fake_git + export FAKE_GIT_STATUS=' M README.md' + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --update-repo --dry-run + [ "$status" -ne 0 ] + assert_output_contains "clean checkout" + assert_log_excludes "docker inspect" +} + +@test "repository update rejects a detached checkout before Docker preflight" { + install_fake_git + export FAKE_GIT_DETACHED=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --update-repo --dry-run + [ "$status" -ne 0 ] + assert_output_contains "attached branch" + assert_log_excludes "docker inspect" +} + +@test "repository update rejects a non-fast-forward pull before Docker preflight" { + install_fake_git + export FAKE_GIT_PULL_STATUS=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --update-repo --dry-run + [ "$status" -ne 0 ] + assert_output_contains "git pull --ff-only failed" + assert_log_excludes "docker inspect" +} + +@test "existing installation lock rejects concurrent execution" { + install_fake_docker + mkdir -p "$REBUILD_APP_LOCK_DIR" + + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Another rebuild-app process is running" + assert_log_excludes "docker inspect" +} + +@test "backup-confirmed does not bypass interactive plan confirmation" { + install_fake_docker + set_required_community_plan + write_current_env + + run bash -c 'printf "no\n" | "$1" --edition community --backup-confirmed' bash "$REBUILD_APP_ROOT/rebuild-app" + [ "$status" -ne 0 ] + assert_output_contains "Deployment plan was not confirmed" + assert_log_excludes " compose stop " + grep -Fx 'ERAMBA_IMAGE_TAG=3.30.0-23' "$REBUILD_APP_ENV_FILE" +} diff --git a/tests/rebuild-app-plan.bats b/tests/rebuild-app-plan.bats new file mode 100644 index 0000000..dad91e2 --- /dev/null +++ b/tests/rebuild-app-plan.bats @@ -0,0 +1,226 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_rebuild_app_test + install_fake_docker + set_required_community_plan +} + +teardown() { + teardown_rebuild_app_test +} + +@test "valid Community plan acquires and validates the exact target" { + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -eq 0 ] + assert_output_contains "ghcr.io/eramba/eramba:3.30.1-6" + assert_log_contains "docker pull ghcr.io/eramba/eramba:3.30.1-6" + assert_preflight_pure +} + +@test "required false is an idempotent no-op" { + export FAKE_PLAN_JSON='{"required":false}' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -eq 0 ] + assert_output_contains "nothing to do" + assert_log_excludes "docker pull" + assert_preflight_pure +} + +@test "malformed plan fails before mutation" { + export FAKE_PLAN_JSON='not json' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Invalid image switch plan" + assert_preflight_pure +} + +@test "required plan rejects a missing field" { + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0"}' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Invalid image switch plan" + assert_preflight_pure +} + +@test "plan rejects an edition mismatch" { + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"enterprise","distribution":"archive"}' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Plan edition does not match" + assert_preflight_pure +} + +@test "Community plan rejects a non-registry distribution" { + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"community","distribution":"archive"}' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Community plans require registry distribution" + assert_preflight_pure +} + +@test "target application VERSION must match the plan" { + export FAKE_TARGET_APP_VERSION=3.30.9 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Target application version does not match" + assert_preflight_pure +} + +@test "target local repository and tag must match the plan" { + export FAKE_TARGET_REPO_TAG=ghcr.io/eramba/eramba:wrong + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Target image repository or tag does not match" + assert_preflight_pure +} + +@test "target architecture must match the Docker host" { + export FAKE_TARGET_ARCH=arm64 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Target image architecture does not match" + assert_preflight_pure +} + +@test "Enterprise loads the supplied archive and validates its exact tag" { + archive="${TEST_TMPDIR}/enterprise.tar" + : >"$archive" + export FAKE_CURRENT_IMAGE=ghcr.io/eramba/eramba-enterprise:3.30.0-23 + export FAKE_TARGET_REPO_TAG=ghcr.io/eramba/eramba-enterprise:3.30.1-6 + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"enterprise","distribution":"archive"}' + + run "$REBUILD_APP_ROOT/rebuild-app" --edition enterprise --image-file "$archive" --dry-run + [ "$status" -eq 0 ] + assert_log_contains "docker load --input ${archive}" + assert_log_excludes "docker pull" + assert_preflight_pure +} + +@test "detected edition conflict fails before plan resolution" { + export FAKE_CURRENT_IMAGE=ghcr.io/eramba/eramba-enterprise:3.30.0-23 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Requested edition conflicts with the running image" + assert_log_excludes "image_switch_plan" + assert_preflight_pure +} + +@test "current HTTP failure blocks plan resolution" { + export FAKE_CURRENT_HTTP_STATUS=1 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Current application HTTP readiness check failed" + assert_log_excludes "image_switch_plan" + assert_preflight_pure +} + +@test "current config failure blocks plan resolution" { + export FAKE_CURRENT_CONFIG_STATUS=1 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Current configuration validation failed" + assert_log_excludes "image_switch_plan" + assert_preflight_pure +} + +@test "current system health failure blocks plan resolution" { + export FAKE_CURRENT_HEALTH_STATUS=1 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Current system health check failed" + assert_log_excludes "image_switch_plan" + assert_preflight_pure +} + +@test "current migrations failure blocks plan resolution" { + export FAKE_CURRENT_MIGRATIONS_STATUS=1 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Current migrations status check failed" + assert_log_excludes "image_switch_plan" + assert_preflight_pure +} + +@test "duplicate image tag keys fail before target acquisition" { + printf '%s\n' 'ERAMBA_IMAGE_TAG=first' 'ERAMBA_IMAGE_TAG=second' >"$REBUILD_APP_ENV_FILE" + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Duplicate ERAMBA_IMAGE_TAG definitions" + assert_log_excludes "image_switch_plan" + assert_log_excludes "docker pull" +} + +@test "plan current image tag must match the running deployment" { + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"wrong","target_image_tag":"3.30.1-6","edition":"community","distribution":"registry"}' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Plan current image tag does not match" + assert_preflight_pure +} + +@test "plan source application version must match the running deployment" { + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.29.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"community","distribution":"registry"}' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Plan source application version does not match" + assert_preflight_pure +} + +@test "plan target tag must be immutable" { + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"latest","edition":"community","distribution":"registry"}' + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "target image tag must be immutable" + assert_preflight_pure +} + +@test "missing running eramba container is a hard preflight failure" { + export FAKE_ERAMBA_RUNNING=false + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "existing running eramba container is required" + assert_log_excludes "image_switch_plan" +} + +@test "Community pull failure occurs before mutation" { + export FAKE_PULL_STATUS=1 + run "$REBUILD_APP_ROOT/rebuild-app" --edition community --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Unable to pull target image" + assert_preflight_pure +} + +@test "edition is detected when no override is supplied" { + export FAKE_PLAN_JSON='{"required":false}' + run "$REBUILD_APP_ROOT/rebuild-app" --dry-run + [ "$status" -eq 0 ] + assert_output_contains "nothing to do" +} + +@test "Enterprise plan rejects a non-archive distribution" { + archive="${TEST_TMPDIR}/enterprise.tar" + : >"$archive" + export FAKE_CURRENT_IMAGE=ghcr.io/eramba/eramba-enterprise:3.30.0-23 + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"enterprise","distribution":"registry"}' + + run "$REBUILD_APP_ROOT/rebuild-app" --edition enterprise --image-file "$archive" --dry-run + [ "$status" -ne 0 ] + assert_output_contains "Enterprise plans require archive distribution" + assert_preflight_pure +} + +@test "Enterprise archive must contain the exact planned image" { + archive="${TEST_TMPDIR}/enterprise.tar" + : >"$archive" + export FAKE_CURRENT_IMAGE=ghcr.io/eramba/eramba-enterprise:3.30.0-23 + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"enterprise","distribution":"archive"}' + export FAKE_IMAGE_INSPECT_STATUS=1 + + run "$REBUILD_APP_ROOT/rebuild-app" --edition enterprise --image-file "$archive" --dry-run + [ "$status" -ne 0 ] + assert_output_contains "archive did not provide the planned image" + assert_preflight_pure +} diff --git a/tests/rebuild-app-test-helper.bats b/tests/rebuild-app-test-helper.bats new file mode 100644 index 0000000..ed32b46 --- /dev/null +++ b/tests/rebuild-app-test-helper.bats @@ -0,0 +1,15 @@ +#!/usr/bin/env bats + +@test "test setup falls back when BATS_TEST_TMPDIR is unavailable" { + run env -u BATS_TEST_TMPDIR \ + TMPDIR="$BATS_TEST_TMPDIR" \ + BATS_TEST_DIRNAME="$BATS_TEST_DIRNAME" \ + bash -e -c ' + source "$1" + setup_rebuild_app_test + test -d "$TEST_TMPDIR" + teardown_rebuild_app_test + ' bash "$BATS_TEST_DIRNAME/test_helper.bash" + + [ "$status" -eq 0 ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..bac57b1 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,291 @@ +setup_rebuild_app_test() { + local test_tmp_root="${BATS_TEST_TMPDIR:-${TMPDIR:-/tmp}}" + export TEST_TMPDIR + TEST_TMPDIR="$(mktemp -d "${test_tmp_root}/rebuild-app.XXXXXX")" + export REBUILD_APP_ROOT + REBUILD_APP_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export REBUILD_APP_STATE_DIR="${TEST_TMPDIR}/state" + export REBUILD_APP_ENV_FILE="${TEST_TMPDIR}/.env" + export REBUILD_APP_LOCK_DIR="${TEST_TMPDIR}/lock" + export FAKE_COMMAND_LOG="${TEST_TMPDIR}/commands.log" + export FAKE_BIN_DIR="${TEST_TMPDIR}/bin" + mkdir -p "${FAKE_BIN_DIR}" + export PATH="${FAKE_BIN_DIR}:${PATH}" + : >"${REBUILD_APP_ENV_FILE}" + : >"${FAKE_COMMAND_LOG}" +} + +teardown_rebuild_app_test() { + rm -rf "${TEST_TMPDIR}" +} + +assert_output_contains() { + [[ "$output" == *"$1"* ]] +} + +assert_log_excludes() { + ! grep -F -- "$1" "${FAKE_COMMAND_LOG}" +} + +assert_log_contains() { + grep -F -- "$1" "${FAKE_COMMAND_LOG}" +} + +mode_of() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + +install_fake_docker() { + cat >"${FAKE_BIN_DIR}/docker" <<'FAKE_DOCKER' +#!/usr/bin/env bash +set -u + +{ + printf 'docker' + printf ' %q' "$@" + printf '\n' +} >>"${FAKE_COMMAND_LOG}" + +args=" $* " + +if [[ "$1" == inspect && "$args" == *".State.Running"* ]]; then + printf '%s\n' "${FAKE_ERAMBA_RUNNING:-true}" +elif [[ "$1" == inspect && "$args" == *".State.Health.Status"* ]]; then + printf '%s\n' "${FAKE_TRIGGER_HEALTH:-healthy}" +elif [[ "$1" == inspect && "$args" == *".Mounts"* ]]; then + container="${!#}" + if [[ "$container" == eramba && -f "${TEST_TMPDIR}/eramba-removed" ]]; then + printf 'No such container: eramba\n' >&2 + exit 1 + fi + if [[ "$args" == *"/var/www/eramba/app/upgrade/data"* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" && "${FAKE_CHANGED_PRESERVED_VOLUME:-}" == data ]]; then + printf '%s\n' changed-data-volume + else + printf '%s\n' data-volume + fi + elif [[ "$args" == *"/var/www/eramba/app/upgrade/logs"* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" && "${FAKE_CHANGED_PRESERVED_VOLUME:-}" == logs ]]; then + printf '%s\n' changed-logs-volume + else + printf '%s\n' logs-volume + fi + elif [[ "$args" == *"/var/lib/mysql"* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" && "${FAKE_CHANGED_PRESERVED_VOLUME:-}" == db ]]; then + printf '%s\n' changed-db-volume + else + printf '%s\n' db-volume + fi + elif [[ "$args" == *"/data/eramba_trigger_storage"* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" && "${FAKE_CHANGED_PRESERVED_VOLUME:-}" == trigger ]]; then + printf '%s\n' changed-trigger-storage-volume + else + printf '%s\n' trigger-storage-volume + fi + elif [[ "$args" == *"/var/www/eramba"* ]]; then + counter_file="${TEST_TMPDIR}/app-volume-reads" + count=0 + [[ ! -f "$counter_file" ]] || count=$(<"$counter_file") + count=$((count + 1)) + printf '%s\n' "$count" >"$counter_file" + if [[ -f "${TEST_TMPDIR}/target-started" ]]; then + if [[ "${FAKE_APP_VOLUME_REUSED:-0}" == 1 ]]; then + printf '%s\n' app-volume + else + printf '%s\n' new-app-volume + fi + elif [[ "${FAKE_CHANGED_APP_VOLUME:-0}" == 1 && "$count" -gt 1 ]]; then + printf '%s\n' unexpected-app-volume + else + printf '%s\n' app-volume + fi + else + printf 'Unknown mount query for %s: %s\n' "$container" "$*" >&2 + exit 97 + fi +elif [[ "$1" == inspect && "$args" == *".Config.Image"* ]]; then + container="${!#}" + if [[ -f "${TEST_TMPDIR}/target-started" && ( "$container" == eramba || "$container" == cron ) ]]; then + printf '%s\n' "${FAKE_TARGET_IMAGE:-ghcr.io/eramba/eramba:3.30.1-6}" + else + printf '%s\n' "${FAKE_CURRENT_IMAGE:-ghcr.io/eramba/eramba:3.30.0-23}" + fi +elif [[ "$1" == exec && "$args" == *" image_switch_plan "* ]]; then + if [[ -n "${FAKE_PLAN_JSON:-}" ]]; then + printf '%s\n' "$FAKE_PLAN_JSON" + else + printf '%s\n' '{"required":false}' + fi +elif [[ "$1" == exec && "$args" == *" php -r "* ]]; then + field="${!#}" + python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + value=data[sys.argv[1]] + if not isinstance(value, (str, bool)): + raise ValueError() + print(str(value).lower() if isinstance(value, bool) else value) +except Exception: + raise SystemExit(2)' "$field" +elif [[ "$1" == exec && "$args" == *" cat /var/www/eramba/app/upgrade/VERSION "* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" ]]; then + printf '%s\n' "${FAKE_RUNNING_TARGET_APP_VERSION:-3.30.1}" + else + printf '%s\n' "${FAKE_CURRENT_APP_VERSION:-3.30.0}" + fi +elif [[ "$1" == exec && "$args" == *" curl "* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" ]]; then + exit "${FAKE_TARGET_HTTP_STATUS:-0}" + fi + exit "${FAKE_CURRENT_HTTP_STATUS:-0}" +elif [[ "$1" == exec && "$args" == *" current_config validate "* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" ]]; then + exit "${FAKE_TARGET_CONFIG_STATUS:-0}" + fi + exit "${FAKE_CURRENT_CONFIG_STATUS:-0}" +elif [[ "$1" == exec && "$args" == *" system_health check "* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" ]]; then + exit "${FAKE_TARGET_HEALTH_STATUS:-0}" + fi + exit "${FAKE_CURRENT_HEALTH_STATUS:-0}" +elif [[ "$1" == exec && "$args" == *" migrations status "* ]]; then + if [[ -f "${TEST_TMPDIR}/target-started" ]]; then + exit "${FAKE_TARGET_MIGRATIONS_STATUS:-0}" + fi + exit "${FAKE_CURRENT_MIGRATIONS_STATUS:-0}" +elif [[ "$1" == pull ]]; then + exit "${FAKE_PULL_STATUS:-0}" +elif [[ "$1" == load ]]; then + exit "${FAKE_LOAD_STATUS:-0}" +elif [[ "$1" == image && "$2" == inspect && "$args" == *".Architecture"* ]]; then + printf '%s\n' "${FAKE_TARGET_ARCH:-amd64}" +elif [[ "$1" == image && "$2" == inspect && "$args" == *".RepoTags"* ]]; then + printf '["%s"]\n' "${FAKE_TARGET_REPO_TAG:-ghcr.io/eramba/eramba:3.30.1-6}" +elif [[ "$1" == image && "$2" == inspect && "$args" == *".Id"* ]]; then + image_ref="${!#}" + if [[ "$image_ref" == "${FAKE_CURRENT_IMAGE:-ghcr.io/eramba/eramba:3.30.0-23}" ]]; then + printf '%s\n' "${FAKE_CURRENT_IMAGE_ID:-sha256:current}" + else + printf '%s\n' "${FAKE_TARGET_IMAGE_ID:-sha256:target}" + fi +elif [[ "$1" == image && "$2" == inspect ]]; then + exit "${FAKE_IMAGE_INSPECT_STATUS:-0}" +elif [[ "$1" == info && "$args" == *".Architecture"* ]]; then + printf '%s\n' "${FAKE_DOCKER_ARCH:-x86_64}" +elif [[ "$1" == create ]]; then + printf '%s\n' fake-target-container +elif [[ "$1" == cp ]]; then + destination="${!#}" + printf '%s\n' "${FAKE_TARGET_APP_VERSION:-3.30.1}" >"$destination" +elif [[ "$1" == rm && "${2:-}" == -f ]]; then + exit 0 +elif [[ "$1" == logs ]]; then + printf 'bounded log for %s\n' "${!#}" + exit 0 +elif [[ "$1" == volume && "$2" == inspect ]]; then + volume_name="${!#}" + printf '%s-created\n' "$volume_name" +elif [[ "$1" == volume && "$2" == rm ]]; then + exit "${FAKE_VOLUME_RM_STATUS:-0}" +elif [[ "$1" == compose ]]; then + if [[ "$args" == *" stop "* ]]; then + current_tag=$(awk -F= '$1 == "ERAMBA_IMAGE_TAG" { print substr($0, index($0, "=") + 1) }' "$REBUILD_APP_ENV_FILE") + printf 'observed-tag-before-stop %s\n' "$current_tag" >>"${FAKE_COMMAND_LOG}" + fi + if [[ "$args" == *" up -d eramba "* || "$args" == *" up -d eramba" ]]; then + rm -f "${TEST_TMPDIR}/eramba-removed" + : >"${TEST_TMPDIR}/target-started" + fi + if [[ "$args" == *" create --no-deps eramba"* ]]; then + rm -f "${TEST_TMPDIR}/eramba-removed" + fi + if [[ "$args" == *" rm -f eramba"* ]]; then + : >"${TEST_TMPDIR}/eramba-removed" + fi + if [[ -n "${FAKE_FAIL_MATCH:-}" && "$args" == *"${FAKE_FAIL_MATCH}"* ]]; then + exit 42 + fi + if [[ "$args" == *" ps "* || "$args" == *" ps" ]]; then + printf 'NAME STATUS\neramba running\n' + fi + exit 0 +else + printf 'Unexpected fake docker call: %s\n' "$*" >&2 + exit 98 +fi +FAKE_DOCKER + chmod +x "${FAKE_BIN_DIR}/docker" +} + +install_fake_git() { + cat >"${FAKE_BIN_DIR}/git" <<'FAKE_GIT' +#!/usr/bin/env bash +set -u + +{ + printf 'git' + printf ' %q' "$@" + printf '\n' +} >>"${FAKE_COMMAND_LOG}" + +if [[ "${1:-}" == -C ]]; then + shift 2 +fi + +case "${1:-}" in + status) + printf '%s' "${FAKE_GIT_STATUS:-}" + ;; + symbolic-ref) + if [[ "${FAKE_GIT_DETACHED:-0}" == 1 ]]; then + exit 1 + fi + printf '%s\n' refs/heads/codex/era-1706-rebuild-app + ;; + pull) + exit "${FAKE_GIT_PULL_STATUS:-0}" + ;; + *) + printf 'Unexpected fake git call: %s\n' "$*" >&2 + exit 98 + ;; +esac +FAKE_GIT + chmod +x "${FAKE_BIN_DIR}/git" +} + +install_fail_second_mv() { + cat >"${FAKE_BIN_DIR}/mv" <<'FAKE_MV' +#!/usr/bin/env bash +set -u +counter_file="${TEST_TMPDIR}/mv-count" +count=0 +[[ ! -f "$counter_file" ]] || count=$(<"$counter_file") +count=$((count + 1)) +printf '%s\n' "$count" >"$counter_file" +if ((count >= 2)); then + exit 1 +fi +exec /bin/mv "$@" +FAKE_MV + chmod +x "${FAKE_BIN_DIR}/mv" +} + +set_required_community_plan() { + export FAKE_PLAN_JSON='{"required":true,"source_app_version":"3.30.0","target_app_version":"3.30.1","current_image_tag":"3.30.0-23","target_image_tag":"3.30.1-6","edition":"community","distribution":"registry"}' +} + +write_current_env() { + cat >"${REBUILD_APP_ENV_FILE}" <<'ENV' +DB_PASSWORD=must-not-appear-in-output +ERAMBA_IMAGE_TAG=3.30.0-23 +PUBLIC_ADDRESS=https://example.test +ENV + chmod 640 "${REBUILD_APP_ENV_FILE}" +} + +assert_preflight_pure() { + assert_log_excludes " compose stop " + assert_log_excludes " compose rm " + assert_log_excludes " volume rm " +}