diff --git a/docs/superpowers/plans/2026-04-06-phase2a-deploy-versions.md b/docs/superpowers/plans/2026-04-06-phase2a-deploy-versions.md new file mode 100644 index 0000000..a18ab9b --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-phase2a-deploy-versions.md @@ -0,0 +1,800 @@ +# Phase 2a — Deploy & Available Versions 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:** Allow admins to list available Docker image tags from Gitea registry and deploy a chosen version to an environment, all from the Central UI. + +**Architecture:** Two new PHP services (GiteaRegistryService for tag listing via Docker Registry V2 API, DeployService for script execution via Symfony Process). Two new API Platform endpoints exposed via custom providers/processors. Frontend adds a deploy modal on the environment detail. Docker socket mounted for deploy script execution. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Symfony HttpClient, Symfony Process, Nuxt 4, Vue 3 + +--- + +## File Structure + +### Backend — Create + +| File | Responsibility | +|------|---------------| +| `src/Service/GiteaRegistryService.php` | HTTP client for Gitea Docker Registry V2 API | +| `src/Service/DeployService.php` | Execute deploy.sh via Symfony Process | +| `src/ApiResource/TagList.php` | API Platform DTO for tag list response | +| `src/ApiResource/DeployResult.php` | API Platform DTO for deploy result response | +| `src/State/TagListProvider.php` | Provider that calls GiteaRegistryService | +| `src/State/DeployProcessor.php` | Processor that calls DeployService | + +### Backend — Modify + +| File | Change | +|------|--------| +| `.env` | Add GITEA_API_URL, GITEA_API_TOKEN | +| `docker-compose.yml` | Mount Docker socket in dev | +| `infra/prod/docker-compose.yml` | Mount Docker socket + deploy dirs in prod | +| `infra/prod/Dockerfile` | Install docker-cli in prod image | + +### Frontend — Create + +| File | Responsibility | +|------|---------------| +| `frontend/services/deploy.ts` | API calls for tags + deploy | +| `frontend/services/dto/deploy.ts` | TypeScript types for Tag and DeployResult | + +### Frontend — Modify + +| File | Change | +|------|--------| +| `frontend/pages/applications/[slug].vue` | Add deploy button + deploy modal | +| `frontend/i18n/locales/fr.json` | Deploy translation keys | + +--- + +## Task 1: Environment variables and Docker config + +**Files:** +- Modify: `.env` +- Modify: `docker-compose.yml` +- Modify: `infra/prod/docker-compose.yml` +- Modify: `infra/prod/Dockerfile` + +- [ ] **Step 1: Add env vars to `.env`** + +Add at the end of the file: + +``` +###> gitea ### +GITEA_API_URL=https://gitea.malio.fr +GITEA_API_TOKEN=change_me_in_env_local +###< gitea ### +``` + +- [ ] **Step 2: Mount Docker socket in dev docker-compose.yml** + +In `docker-compose.yml`, add to the `php` service `volumes` list: + +```yaml + - /var/run/docker.sock:/var/run/docker.sock +``` + +- [ ] **Step 3: Mount Docker socket + deploy dirs in prod docker-compose** + +In `infra/prod/docker-compose.yml`, add to the `app` service `volumes` list: + +```yaml + - /var/run/docker.sock:/var/run/docker.sock + - /var/www/sirh/deploy:/var/www/sirh/deploy:ro + - /var/www/lesstime/deploy:/var/www/lesstime/deploy:ro + - /var/www/inventory/deploy:/var/www/inventory/deploy:ro +``` + +- [ ] **Step 4: Install docker-cli in prod Dockerfile** + +In `infra/prod/Dockerfile`, in the Stage 3 (production) `apt-get install` line, add `docker.io` to the list: + +Replace: +```dockerfile +RUN apt-get update && apt-get install -y \ + libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \ + nginx supervisor \ +``` + +With: +```dockerfile +RUN apt-get update && apt-get install -y \ + libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \ + nginx supervisor docker.io \ +``` + +- [ ] **Step 5: Commit** + +```bash +git add .env docker-compose.yml infra/prod/docker-compose.yml infra/prod/Dockerfile +git commit -m "feat : add Gitea env vars, mount Docker socket and deploy dirs" +``` + +--- + +## Task 2: GiteaRegistryService + +**Files:** +- Create: `src/Service/GiteaRegistryService.php` + +- [ ] **Step 1: Create the service** + +```php + + */ + public function listTags(string $registryImage): array + { + $parts = explode('/', $registryImage); + + if (\count($parts) < 3) { + throw new \InvalidArgumentException(sprintf('Invalid registry image format: "%s". Expected "registry/owner/package".', $registryImage)); + } + + $owner = $parts[1]; + $package = implode('/', \array_slice($parts, 2)); + + $url = sprintf('%s/v2/%s/%s/tags/list', $this->giteaApiUrl, $owner, $package); + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => sprintf('token %s', $this->giteaApiToken), + ], + 'timeout' => 10, + ]); + + $data = $response->toArray(); + + $tags = $data['tags'] ?? []; + + // Sort: versions (vX.Y.Z) first descending, then others + usort($tags, function (string $a, string $b): int { + $aIsVersion = str_starts_with($a, 'v'); + $bIsVersion = str_starts_with($b, 'v'); + + if ($aIsVersion && $bIsVersion) { + return version_compare(ltrim($b, 'v'), ltrim($a, 'v')); + } + + if ($aIsVersion) { + return -1; + } + + if ($bIsVersion) { + return 1; + } + + return strcmp($a, $b); + }); + + return $tags; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Service/GiteaRegistryService.php +git commit -m "feat : add GiteaRegistryService for listing container tags" +``` + +--- + +## Task 3: DeployService + +**Files:** +- Create: `src/Service/DeployService.php` + +- [ ] **Step 1: Create the service** + +```php +getDeployScriptPath(); + + if (null === $scriptPath || !file_exists($scriptPath)) { + return [ + 'success' => false, + 'output' => sprintf('Deploy script not found: %s', $scriptPath ?? 'null'), + 'exitCode' => 1, + ]; + } + + $process = new Process( + ['bash', $scriptPath, $tag], + dirname($scriptPath), + ); + $process->setTimeout(300); + + $process->run(); + + return [ + 'success' => $process->isSuccessful(), + 'output' => $process->getOutput() . $process->getErrorOutput(), + 'exitCode' => $process->getExitCode() ?? 1, + ]; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Service/DeployService.php +git commit -m "feat : add DeployService for executing deploy scripts" +``` + +--- + +## Task 4: API Platform DTOs + +**Files:** +- Create: `src/ApiResource/TagList.php` +- Create: `src/ApiResource/DeployResult.php` + +- [ ] **Step 1: Create TagList DTO** + +```php + */ + public array $tags = []; +} +``` + +- [ ] **Step 2: Create DeployResult DTO** + +```php +applicationRepository->findOneBy(['slug' => $slug]); + + if (null === $application) { + throw new NotFoundHttpException(sprintf('Application "%s" not found.', $slug)); + } + + $dto = new TagList(); + $dto->tags = $this->giteaRegistryService->listTags($application->getRegistryImage()); + + return $dto; + } +} +``` + +- [ ] **Step 2: Verify endpoint works** + +```bash +# Login +docker exec -t php-central-fpm curl -s -c /tmp/cookies -X POST http://nginx/api/login_check \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' + +# List tags (will fail if GITEA_API_TOKEN is not set in .env.local, that's expected in dev) +docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/applications/sirh/tags +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/State/TagListProvider.php +git commit -m "feat : add TagListProvider for listing registry tags" +``` + +--- + +## Task 6: DeployProcessor + +**Files:** +- Create: `src/State/DeployProcessor.php` + +- [ ] **Step 1: Create the processor** + +```php +environmentRepository->find($id) : null; + + if (null === $environment) { + throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id)); + } + + $requestData = $context['request']?->toArray() ?? []; + $tag = $requestData['tag'] ?? null; + + if (null === $tag || '' === $tag) { + throw new BadRequestHttpException('The "tag" field is required.'); + } + + $result = $this->deployService->deploy($environment, $tag); + + $dto = new DeployResult(); + $dto->success = $result['success']; + $dto->output = $result['output']; + $dto->tag = $tag; + + return $dto; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/State/DeployProcessor.php +git commit -m "feat : add DeployProcessor for triggering deployments" +``` + +--- + +## Task 7: Frontend types and service + +**Files:** +- Create: `frontend/services/dto/deploy.ts` +- Create: `frontend/services/deploy.ts` + +- [ ] **Step 1: Create deploy types** + +```typescript +type Tag = { + name: string +} + +type TagListResponse = { + tags: string[] +} + +type DeployResult = { + success: boolean + output: string + tag: string +} +``` + +- [ ] **Step 2: Create deploy service** + +```typescript +import type { TagListResponse, DeployResult } from './dto/deploy' + +export function getAvailableTags(slug: string): Promise { + return useApi().get(`/applications/${slug}/tags`, undefined, { + toast: false, + }) +} + +export function deploy(envId: number, tag: string): Promise { + return useApi().post(`/environments/${envId}/deploy`, { tag }, { + toast: false, + }) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/services/dto/deploy.ts frontend/services/deploy.ts +git commit -m "feat : add frontend deploy types and service" +``` + +--- + +## Task 8: i18n translations + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add deploy translations** + +Add the following keys to the existing `environments` section in `fr.json`: + +```json +{ + "environments": { + "...existing keys...", + "deploy": { + "button": "Deployer", + "title": "Deployer une version", + "selectTag": "Version a deployer", + "selectPlaceholder": "Selectionner une version", + "loadingTags": "Chargement des versions...", + "noTags": "Aucune version disponible", + "confirm": "Deployer", + "deploying": "Deploiement en cours...", + "success": "Deploiement reussi", + "error": "Echec du deploiement", + "output": "Sortie du deploiement" + } + } +} +``` + +Also add error keys: + +```json +{ + "errors": { + "...existing keys...", + "deploy": { + "tags": "Erreur lors du chargement des versions", + "deploy": "Erreur lors du deploiement" + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat : add i18n translations for deploy feature" +``` + +--- + +## Task 9: Deploy modal and button on detail page + +**Files:** +- Modify: `frontend/pages/applications/[slug].vue` + +- [ ] **Step 1: Add imports and deploy state** + +At the top of `