Files
Central/docs/superpowers/plans/2026-04-06-phase2a-deploy-versions.md
tristan 8f585b4be8
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
feat/ajout-de-fonctionnalites (#1)
Reviewed-on: #1
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-06 14:23:20 +00:00

22 KiB

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:

      - /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:

      - /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:

RUN apt-get update && apt-get install -y \
        libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
        nginx supervisor \

With:

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
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

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class GiteaRegistryService
{
    public function __construct(
        private HttpClientInterface $httpClient,
        #[Autowire('%env(GITEA_API_URL)%')]
        private string $giteaApiUrl,
        #[Autowire('%env(GITEA_API_TOKEN)%')]
        private string $giteaApiToken,
    ) {}

    /**
     * List available tags for a container image.
     *
     * @param string $registryImage e.g. "gitea.malio.fr/malio-dev/sirh"
     *
     * @return list<string>
     */
    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
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

declare(strict_types=1);

namespace App\Service;

use App\Entity\Environment;
use Symfony\Component\Process\Process;

final class DeployService
{
    /**
     * @return array{success: bool, output: string, exitCode: int}
     */
    public function deploy(Environment $environment, string $tag): array
    {
        $scriptPath = $environment->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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\TagListProvider;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/applications/{slug}/tags',
            security: "is_granted('ROLE_ADMIN')",
            provider: TagListProvider::class,
        ),
    ],
)]
final class TagList
{
    /** @var list<string> */
    public array $tags = [];
}
  • Step 2: Create DeployResult DTO
<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\DeployProcessor;

#[ApiResource(
    operations: [
        new Post(
            uriTemplate: '/environments/{id}/deploy',
            security: "is_granted('ROLE_ADMIN')",
            processor: DeployProcessor::class,
        ),
    ],
)]
final class DeployResult
{
    public bool $success = false;
    public string $output = '';
    public string $tag = '';
}
  • Step 3: Commit
git add src/ApiResource/TagList.php src/ApiResource/DeployResult.php
git commit -m "feat : add TagList and DeployResult API Platform DTOs"

Task 5: TagListProvider

Files:

  • Create: src/State/TagListProvider.php

  • Step 1: Create the provider

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\TagList;
use App\Repository\ApplicationRepository;
use App\Service\GiteaRegistryService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final readonly class TagListProvider implements ProviderInterface
{
    public function __construct(
        private ApplicationRepository $applicationRepository,
        private GiteaRegistryService $giteaRegistryService,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TagList
    {
        $slug = $uriVariables['slug'] ?? '';
        $application = $this->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
# 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
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

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\DeployResult;
use App\Repository\EnvironmentRepository;
use App\Service\DeployService;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final readonly class DeployProcessor implements ProcessorInterface
{
    public function __construct(
        private EnvironmentRepository $environmentRepository,
        private DeployService $deployService,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): DeployResult
    {
        $id = $uriVariables['id'] ?? null;
        $environment = $id ? $this->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
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

type Tag = {
    name: string
}

type TagListResponse = {
    tags: string[]
}

type DeployResult = {
    success: boolean
    output: string
    tag: string
}
  • Step 2: Create deploy service
import type { TagListResponse, DeployResult } from './dto/deploy'

export function getAvailableTags(slug: string): Promise<TagListResponse> {
    return useApi().get<TagListResponse>(`/applications/${slug}/tags`, undefined, {
        toast: false,
    })
}

export function deploy(envId: number, tag: string): Promise<DeployResult> {
    return useApi().post<DeployResult>(`/environments/${envId}/deploy`, { tag }, {
        toast: false,
    })
}
  • Step 3: Commit
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:

{
    "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:

{
    "errors": {
        "...existing keys...",
        "deploy": {
            "tags": "Erreur lors du chargement des versions",
            "deploy": "Erreur lors du deploiement"
        }
    }
}
  • Step 2: Commit
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 <script setup>, add the imports after existing ones:

import type { DeployResult } from '~/services/dto/deploy'
import { getAvailableTags, deploy } from '~/services/deploy'

Add state variables after the existing ones (after pendingMaintenanceByEnvId):

// Deploy modal
const showDeployModal = ref(false)
const deployEnvId = ref<number | null>(null)
const deployTags = ref<string[]>([])
const selectedTag = ref('')
const loadingTags = ref(false)
const isDeploying = ref(false)
const deployResult = ref<DeployResult | null>(null)
  • Step 2: Add deploy functions

Add these functions after removeLogFile:

async function openDeployModal(env: Environment) {
    deployEnvId.value = env.id!
    selectedTag.value = ''
    deployResult.value = null
    deployTags.value = []
    showDeployModal.value = true
    loadingTags.value = true
    try {
        const response = await getAvailableTags(slug)
        deployTags.value = response.tags ?? []
        if (deployTags.value.length > 0) {
            selectedTag.value = deployTags.value[0]
        }
    } finally {
        loadingTags.value = false
    }
}

async function handleDeploy() {
    if (!deployEnvId.value || !selectedTag.value) return
    isDeploying.value = true
    deployResult.value = null
    try {
        deployResult.value = await deploy(deployEnvId.value, selectedTag.value)
        if (deployResult.value.success) {
            await loadApplication()
        }
    } finally {
        isDeploying.value = false
    }
}

function closeDeployModal() {
    showDeployModal.value = false
    deployResult.value = null
}
  • Step 3: Add deploy button to each environment

In the template, in the environment action buttons area (the <div class="flex gap-2"> that contains the maintenance toggle button), add a deploy button before the maintenance button:

                            <MalioButton
                                :label="t('environments.deploy.button')"
                                icon-name="mdi:rocket-launch-outline"
                                icon-position="left"
                                @click="openDeployModal(env)"
                            />
  • Step 4: Add deploy modal to template

Add after the existing environment modal (before the closing </div> of the root template):

        <!-- Deploy modal -->
        <AppModal
            v-model="showDeployModal"
            :submit-label="isDeploying ? t('environments.deploy.deploying') : t('environments.deploy.confirm')"
            :cancel-label="t('applications.form.cancel')"
            :loading="isDeploying"
            max-width="xl"
            @submit="handleDeploy"
            @update:model-value="!$event && closeDeployModal()"
        >
            <template #title>{{ t('environments.deploy.title') }}</template>

            <!-- Tag selection -->
            <div v-if="!deployResult">
                <div v-if="loadingTags" class="py-8 text-center text-neutral-400">
                    <Icon name="mdi:loading" size="24" class="animate-spin" />
                    <p class="mt-2 text-sm">{{ t('environments.deploy.loadingTags') }}</p>
                </div>

                <div v-else-if="deployTags.length === 0" class="py-8 text-center text-neutral-400">
                    <Icon name="mdi:package-variant" size="32" />
                    <p class="mt-2 text-sm">{{ t('environments.deploy.noTags') }}</p>
                </div>

                <div v-else>
                    <label class="mb-1 block text-sm font-medium text-neutral-700">
                        {{ t('environments.deploy.selectTag') }}
                    </label>
                    <select
                        v-model="selectedTag"
                        class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
                    >
                        <option v-for="tag in deployTags" :key="tag" :value="tag">
                            {{ tag }}
                        </option>
                    </select>
                </div>
            </div>

            <!-- Deploy result -->
            <div v-else>
                <div
                    class="mb-4 flex items-center gap-2 rounded-lg px-4 py-3"
                    :class="deployResult.success
                        ? 'bg-green-50 text-green-700'
                        : 'bg-red-50 text-red-700'"
                >
                    <Icon
                        :name="deployResult.success ? 'mdi:check-circle' : 'mdi:alert-circle'"
                        size="20"
                    />
                    <span class="text-sm font-semibold">
                        {{ deployResult.success
                            ? t('environments.deploy.success')
                            : t('environments.deploy.error')
                        }}
                    </span>
                    <span class="ml-auto text-xs font-mono">{{ deployResult.tag }}</span>
                </div>

                <div>
                    <p class="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">
                        {{ t('environments.deploy.output') }}
                    </p>
                    <pre class="max-h-80 overflow-auto rounded-lg bg-neutral-900 p-4 text-xs text-green-400 font-mono whitespace-pre-wrap">{{ deployResult.output }}</pre>
                </div>
            </div>

            <!-- Override footer when showing result -->
            <template v-if="deployResult" #footer>
                <MalioButton
                    :label="t('applications.form.cancel')"
                    variant="tertiary"
                    @click="closeDeployModal"
                />
            </template>
        </AppModal>
  • Step 5: Verify in browser

Run make dev-nuxt, login, go to /applications/sirh:

  • Each environment should show a "Deployer" button with rocket icon

  • Clicking it opens a modal

  • The tag list will load (may fail if GITEA_API_TOKEN not configured — that's expected)

  • The deploy will fail in dev (script not accessible) — that's expected

  • Step 6: Commit

git add frontend/pages/applications/[slug].vue
git commit -m "feat : add deploy modal with tag selection and result display"

Task 10: Build and verify

  • Step 1: Clear Symfony cache
docker exec -t -u www-data php-central-fpm php bin/console cache:clear
  • Step 2: Build frontend
make build-nuxtJS
  • Step 3: Verify API endpoints exist
# 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"}'

# Tags endpoint (will return error about token, that's OK)
docker exec -t php-central-fpm curl -s -b /tmp/cookies http://nginx/api/applications/sirh/tags

# Deploy endpoint (will return error about script, that's OK)
docker exec -t php-central-fpm curl -s -b /tmp/cookies -X POST http://nginx/api/environments/1/deploy \
  -H "Content-Type: application/json" \
  -d '{"tag":"latest"}'

Both endpoints should return structured JSON responses (even if errors), not 404s.

  • Step 4: Final commit if adjustments needed
git add -A
git commit -m "fix : adjustments from end-to-end testing"