Compare commits

..

2 Commits

Author SHA1 Message Date
Manus AI
40ac705398 chore(ops): versionner les scripts exécutables 2026-08-18 16:29:38 +00:00
Manus AI
255f54cf27 feat(ci): conditionner les déploiements et exposer les métriques 2026-08-18 16:28:39 +00:00
18 changed files with 3338 additions and 27 deletions

View File

@@ -0,0 +1,27 @@
name: Validation Dashboard
on:
push:
branches: [main]
paths-ignore:
- "**.md"
workflow_dispatch:
jobs:
verify:
name: Tests backend et build frontend
runs-on: ci-node22
steps:
- uses: actions/checkout@v4
- name: Installer et tester le backend
working-directory: src/backend
run: |
npm ci
npm test
- name: Installer et construire le frontend
working-directory: src/frontend
run: |
npm ci
npm run build

View File

@@ -6,5 +6,8 @@
"containerName": "manus-dashboard",
"image": "images/dashboard.png",
"giteaRepo": "manus-dashboard",
"giteaOwner": "manus-admin"
"giteaOwner": "manus-admin",
"ci": {
"required": true
}
}

View File

@@ -0,0 +1,9 @@
# Image de jobs locale : Node 22, pnpm, Bash, Git et GNU tar sont prêts avant le workflow.
FROM node:22-alpine
# actions/cache sappuie sur GNU tar pour archiver le store pnpm.
RUN apk add --no-cache bash git tar \
&& tar --version | grep -q 'GNU tar' \
&& corepack enable \
&& corepack prepare pnpm@10.4.1 --activate \
&& pnpm --version

View File

@@ -11,3 +11,7 @@ Après mise à jour Git, installer ou actualiser le service avec :
```bash
sudo ./ops/install-healthcheck.sh
```
## Runner CI de production
`install-gitea-act-runner.sh` installe le runner Gitea de production depuis ces fichiers versionnés. Il utilise limage locale `gitea-runner-node:22`, le label `ci-node22`, le réseau Docker `web` et un cache persistant dans `/opt/manus-deploy/gitea-runner/cache`.

0
ops/deploy-dashboard-from-host.sh Normal file → Executable file
View File

View File

@@ -0,0 +1,17 @@
log:
level: info
runner:
capacity: 1
envs:
DOCKER_HOST: unix:///var/run/docker.sock
# Cache Gitea Actions persistant, accessible depuis les jobs du réseau Docker web.
cache:
enabled: true
dir: /opt/manus-deploy/gitea-runner/cache
host: 172.18.0.1
port: 18088
container:
network: web

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Runner Gitea Actions de production
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/manus-deploy/gitea-runner
Environment=DOCKER_HOST=unix:///var/run/docker.sock
ExecStart=/usr/local/bin/act_runner daemon --config /opt/manus-deploy/gitea-runner/config.yaml
Restart=always
RestartSec=10s
[Install]
WantedBy=multi-user.target

0
ops/healthcheck-apps.sh Normal file → Executable file
View File

38
ops/install-gitea-act-runner.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Installe et enregistre le runner CI production à partir des fichiers versionnés.
set -Eeuo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_DIR="$(dirname "${SCRIPT_DIR}")"
readonly RUNNER_DIR="/opt/manus-deploy/gitea-runner"
readonly GITEA_CONTAINER="gitea"
readonly GITEA_URL="https://git.santinova-soft.org"
readonly RUNNER_NAME="production-docker-runner"
readonly RUNNER_IMAGE="gitea-runner-node:22"
readonly RUNNER_LABELS="ci-node22:docker://${RUNNER_IMAGE}"
readonly LABEL_FILE="${RUNNER_DIR}/labels"
install -d -m 0750 "${RUNNER_DIR}" "${RUNNER_DIR}/cache"
install -m 0640 "${SCRIPT_DIR}/gitea-act-runner-config.yaml" "${RUNNER_DIR}/config.yaml"
install -D -m 0644 "${SCRIPT_DIR}/gitea-act-runner.service" /etc/systemd/system/gitea-act-runner.service
docker build --tag "${RUNNER_IMAGE}" --file "${SCRIPT_DIR}/Dockerfile.gitea-runner" "${APP_DIR}"
if [[ ! -f "${RUNNER_DIR}/.runner" || ! -f "${LABEL_FILE}" || "$(<"${LABEL_FILE}")" != "${RUNNER_LABELS}" ]]; then
systemctl stop gitea-act-runner.service 2>/dev/null || true
rm -f "${RUNNER_DIR}/.runner"
token="$(docker exec -u git "${GITEA_CONTAINER}" gitea actions generate-runner-token)"
(
cd "${RUNNER_DIR}"
/usr/local/bin/act_runner register --no-interactive \
--instance "${GITEA_URL}" \
--token "${token}" \
--name "${RUNNER_NAME}" \
--labels "${RUNNER_LABELS}" \
--config "${RUNNER_DIR}/config.yaml"
)
printf '%s\n' "${RUNNER_LABELS}" > "${LABEL_FILE}"
fi
systemctl daemon-reload
systemctl enable --now gitea-act-runner.service
systemctl is-active gitea-act-runner.service

0
ops/install-healthcheck.sh Normal file → Executable file
View File

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js"
"test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js"
},
"dependencies": {
"express": "^4.21.0",

View File

@@ -44,6 +44,12 @@ function normalizeManifest(manifest, directory, environment) {
if (!isHttpsUrl(manifest.urls[environment])) {
throw new Error(`urls.${environment} absent ou invalide`);
}
if (manifest.ci !== undefined && (
!manifest.ci || typeof manifest.ci !== 'object' || Array.isArray(manifest.ci) ||
(manifest.ci.required !== undefined && typeof manifest.ci.required !== 'boolean')
)) {
throw new Error('ci doit être un objet avec une propriété required booléenne');
}
return {
...manifest,
@@ -51,6 +57,7 @@ function normalizeManifest(manifest, directory, environment) {
directory,
containerName: manifest.containerName || manifest.id,
healthCheckUrl: manifest.urls[environment],
ci: { required: manifest.ci?.required === true },
};
}
@@ -131,14 +138,23 @@ function writeAppManifest({ appsBasePath, environment, app }) {
}
function findAppDirectoryByRepo(config, repositoryName) {
const { apps } = refreshApps(config);
const app = apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName);
const app = findAppByRepo(config, repositoryName);
return app ? app.directory : null;
}
/**
* Retourne le manifeste complet afin que le webhook applique les règles de
* promotion déclarées par l'application (notamment ci.required).
*/
function findAppByRepo(config, repositoryName) {
const { apps } = refreshApps(config);
return apps.find((candidate) => candidate.giteaRepo === repositoryName || candidate.id === repositoryName) || null;
}
module.exports = {
discoverApps,
refreshApps,
writeAppManifest,
findAppDirectoryByRepo,
findAppByRepo,
};

View File

@@ -70,6 +70,48 @@ async function getBranches(owner, repo) {
}
}
function toDurationSeconds(startedAt, completedAt) {
if (!startedAt || !completedAt) return null;
const milliseconds = new Date(completedAt).getTime() - new Date(startedAt).getTime();
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds / 1000) : null;
}
/** Normalise les exécutions CI pour le webhook et le dashboard. */
function normalizeWorkflowRun(run) {
const startedAt = run.run_started_at || run.started_at || null;
const completedAt = run.updated_at || (run.status === 'completed' ? run.created_at : null);
return {
id: run.id,
name: run.name || run.workflow_id || 'Validation CI',
status: run.status || 'unknown',
conclusion: run.conclusion || null,
event: run.event || null,
commit: run.head_sha || null,
createdAt: run.created_at || null,
startedAt,
completedAt,
durationSeconds: toDurationSeconds(startedAt, completedAt),
url: run.html_url || null,
};
}
async function getWorkflowRuns(owner, repo, limit = 10) {
try {
const response = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, {
params: { limit, page: 1 },
});
return (response.data.workflow_runs || []).map(normalizeWorkflowRun);
} catch (err) {
console.error(`Erreur Gitea getWorkflowRuns ${owner}/${repo}:`, err.message);
return [];
}
}
async function getWorkflowRunForCommit(owner, repo, commitHash) {
const runs = await getWorkflowRuns(owner, repo, 30);
return runs.find((run) => run.commit && run.commit.startsWith(commitHash)) || null;
}
/**
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
*/
@@ -113,6 +155,9 @@ module.exports = {
getRepo,
getCommits,
getBranches,
getWorkflowRuns,
getWorkflowRunForCommit,
normalizeWorkflowRun,
listRepos,
createRepo,
giteaClient,

View File

@@ -4,7 +4,7 @@ const path = require('path');
const fs = require('fs');
const { authenticate, authMiddleware } = require('./auth');
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
const { getRepo, getCommits, getBranches, listRepos } = require('./gitea');
const { getRepo, getCommits, getBranches, getWorkflowRuns, listRepos } = require('./gitea');
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
const config = require('./config');
@@ -399,6 +399,16 @@ router.get('/gitea/repos/:owner/:repo/branches', authMiddleware, async (req, res
}
});
// Dernières validations CI avec statut et durée, utilisées par l'écran Gitea.
router.get('/gitea/repos/:owner/:repo/actions/runs', authMiddleware, async (req, res) => {
try {
const runs = await getWorkflowRuns(req.params.owner, req.params.repo, 10);
res.json(runs);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ PUBLIC STATUS ROUTE (sans authentification) ============
// Utilisé par le portail applicatif pour griser les tuiles des apps arrêtées
router.get('/public/status', async (req, res) => {

View File

@@ -9,7 +9,8 @@ const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
const config = require('./config');
const { findAppDirectoryByRepo } = require('./app-registry');
const { findAppByRepo } = require('./app-registry');
const { getWorkflowRunForCommit } = require('./gitea');
const router = express.Router();
@@ -19,6 +20,8 @@ const DEPLOY_SCRIPT = '/opt/manus-deploy/scripts/deploy-app.sh';
const LOG_DIR = '/var/log/manus-deploy';
const DASHBOARD_APP_ID = 'manus-dashboard';
const DASHBOARD_DEPLOY_REQUEST = path.join(APPS_BASE_PATH, DASHBOARD_APP_ID, '.deployment-request');
const CI_GATE_TIMEOUT_MS = Number.parseInt(process.env.CI_GATE_TIMEOUT_MS, 10) || 5 * 60 * 1000;
const CI_GATE_POLL_INTERVAL_MS = Number.parseInt(process.env.CI_GATE_POLL_INTERVAL_MS, 10) || 3000;
// Déploiements en cours (évite les doubles déclenchements)
const deployingApps = new Set();
@@ -44,6 +47,54 @@ function requestDashboardHostDeployment({ branch, commitHash, committer }) {
fs.renameSync(temporaryRequest, DASHBOARD_DEPLOY_REQUEST);
}
function shouldRequireCi(app) {
return app?.ci?.required === true;
}
function saveDeploymentStatus(appName, status) {
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify(status, null, 2));
} catch (error) {
console.error('[Webhook] Erreur sauvegarde statut:', error.message);
}
}
function wait(delay) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
/**
* Une application déclarant ci.required ne peut être déployée que si le commit
* reçu dispose dun run Gitea terminé avec succès.
*/
async function waitForSuccessfulCi(app, commitHash) {
const owner = app.giteaOwner || 'manus-admin';
const repo = app.giteaRepo || app.id;
const deadline = Date.now() + CI_GATE_TIMEOUT_MS;
let lastRun = null;
while (Date.now() < deadline) {
lastRun = await getWorkflowRunForCommit(owner, repo, commitHash);
if (lastRun?.status === 'completed') {
return {
allowed: lastRun.conclusion === 'success',
run: lastRun,
reason: lastRun.conclusion === 'success'
? null
: `La CI est terminée avec le statut ${lastRun.conclusion || 'inconnu'}`,
};
}
await wait(CI_GATE_POLL_INTERVAL_MS);
}
return {
allowed: false,
run: lastRun,
reason: lastRun ? 'La CI na pas terminé dans le délai autorisé' : 'Aucune validation CI trouvée pour ce commit',
};
}
function verifyGiteaSignature(req) {
if (!WEBHOOK_SECRET) {
console.warn('[Webhook] AVERTISSEMENT: WEBHOOK_SECRET non défini. Validation désactivée.');
@@ -93,16 +144,41 @@ function runDeploy(appName, branch, commitHash, committer, broadcast) {
if (broadcast) {
broadcast({ type: 'deploy_finished', data: { app: appName, status, exitCode: code } });
}
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.writeFileSync(path.join(LOG_DIR, `${appName}-last-deploy.json`), JSON.stringify({
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
timestamp: new Date().toISOString(), output: output.slice(-3000),
}, null, 2));
} catch (e) { console.error('[Webhook] Erreur sauvegarde statut:', e.message); }
saveDeploymentStatus(appName, {
app: appName, branch, commit: commitHash, committer, status, exitCode: code,
timestamp: new Date().toISOString(), output: output.slice(-3000),
});
});
}
async function gateAndDeploy(app, branch, commitHash, committer, broadcast) {
if (shouldRequireCi(app)) {
const ci = await waitForSuccessfulCi(app, commitHash);
if (!ci.allowed) {
const status = {
app: app.directory,
branch,
commit: commitHash,
committer,
status: 'blocked',
timestamp: new Date().toISOString(),
ci: ci.run,
reason: ci.reason,
};
console.warn(`[Webhook] Déploiement bloqué pour ${app.directory}: ${ci.reason}`);
saveDeploymentStatus(app.directory, status);
broadcast?.({ type: 'deploy_blocked', data: status });
return;
}
}
if (isHostManagedDeployment(app.directory)) {
requestDashboardHostDeployment({ branch, commitHash, committer });
return;
}
runDeploy(app.directory, branch, commitHash, committer, broadcast);
}
// POST /api/webhook/gitea
router.post('/gitea', (req, res) => {
if (!verifyGiteaSignature(req)) {
@@ -122,24 +198,20 @@ router.post('/gitea', (req, res) => {
console.log(`[Webhook] Push: repo=${repoName}, branch=${branch}, commit=${commitHash}, by=${committer}`);
// Le manifeste app.json associe le dépôt au dossier déployé : pas de mapping statique à maintenir.
const appName = findAppDirectoryByRepo(config, repoName);
if (!appName) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
const app = findAppByRepo(config, repoName);
if (!app) return res.status(200).json({ message: `Dépôt ${repoName} non déployé ou sans manifeste valide` });
if (branch !== 'main') return res.status(200).json({ message: `Branch ${branch} ignorée` });
if (isHostManagedDeployment(appName)) {
requestDashboardHostDeployment({ branch, commitHash, committer });
return res.status(202).json({
message: 'Redéploiement du dashboard confié au service hôte',
commit: commitHash,
branch,
});
}
res.status(202).json({ message: `Déploiement de ${appName} déclenché`, commit: commitHash, branch, committer });
res.status(202).json({
message: shouldRequireCi(app) ? `Validation CI requise avant déploiement de ${app.directory}` : `Déploiement de ${app.directory} déclenché`,
commit: commitHash,
branch,
ciRequired: shouldRequireCi(app),
});
// Récupérer la fonction broadcast si disponible globalement
const broadcastFn = global.wsBroadcast || null;
setImmediate(() => runDeploy(appName, branch, commitHash, committer, broadcastFn));
setImmediate(() => gateAndDeploy(app, branch, commitHash, committer, broadcastFn));
});
// GET /api/webhook/status/:appName
@@ -168,3 +240,5 @@ router.get('/status', (req, res) => {
module.exports = router;
module.exports.isHostManagedDeployment = isHostManagedDeployment;
module.exports.shouldRequireCi = shouldRequireCi;
module.exports.waitForSuccessfulCi = waitForSuccessfulCi;

View File

@@ -0,0 +1,18 @@
const assert = require('node:assert/strict');
const { normalizeWorkflowRun } = require('../src/gitea');
const run = normalizeWorkflowRun({
id: 42,
name: 'Validation',
status: 'completed',
conclusion: 'success',
head_sha: 'abcdef123456',
created_at: '2026-08-18T10:00:00Z',
run_started_at: '2026-08-18T10:00:10Z',
updated_at: '2026-08-18T10:02:15Z',
});
assert.equal(run.commit, 'abcdef123456');
assert.equal(run.durationSeconds, 125);
assert.equal(run.conclusion, 'success');
console.log('OK gitea');

View File

@@ -4,8 +4,11 @@
* donc déléguer ce cas précis à un service systemd hôte.
*/
const assert = require('node:assert/strict');
const { isHostManagedDeployment } = require('../src/webhook');
const { isHostManagedDeployment, shouldRequireCi } = require('../src/webhook');
assert.equal(isHostManagedDeployment('manus-dashboard'), true);
assert.equal(isHostManagedDeployment('itinova-contacts'), false);
assert.equal(shouldRequireCi({ ci: { required: true } }), true);
assert.equal(shouldRequireCi({ ci: { required: false } }), false);
assert.equal(shouldRequireCi({}), false);
console.log('OK webhook');

3030
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff