Compare commits
No commits in common. "d5508249f2e1ace1537c0ed6f3e885ae23f1fe7c" and "58733939e2c2bc94ab4b2ab6401050c08214e939" have entirely different histories.
d5508249f2
...
58733939e2
19
server.js
19
server.js
|
|
@ -636,8 +636,8 @@ ${detail}`);
|
||||||
if (verifyMode === 'off') return { ok: true, results: [], skipped: true };
|
if (verifyMode === 'off') return { ok: true, results: [], skipped: true };
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const svc of services) {
|
for (const svc of services) {
|
||||||
const exp = await runOnServer(server, `cd ${deployDir} && ${kvPrefix} docker compose images --quiet ${svc} 2>/dev/null | head -1`);
|
const exp = await runOnServer(server, `cd ${deployDir} && docker compose images --quiet ${svc} 2>/dev/null | head -1`);
|
||||||
const cid = await runOnServer(server, `cd ${deployDir} && ${kvPrefix} docker compose ps --quiet ${svc} 2>/dev/null | head -1`);
|
const cid = await runOnServer(server, `cd ${deployDir} && docker compose ps --quiet ${svc} 2>/dev/null | head -1`);
|
||||||
const expectedSha = (exp.stdout || '').trim();
|
const expectedSha = (exp.stdout || '').trim();
|
||||||
const containerId = (cid.stdout || '').trim();
|
const containerId = (cid.stdout || '').trim();
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
|
|
@ -647,8 +647,7 @@ ${detail}`);
|
||||||
const insp = await runOnServer(server, `docker inspect --format '{{.Image}}|{{.State.StartedAt}}' ${containerId}`);
|
const insp = await runOnServer(server, `docker inspect --format '{{.Image}}|{{.State.StartedAt}}' ${containerId}`);
|
||||||
const [actualSha, startedAtStr] = (insp.stdout || '').trim().split('|');
|
const [actualSha, startedAtStr] = (insp.stdout || '').trim().split('|');
|
||||||
const startedAt = new Date(startedAtStr || 0);
|
const startedAt = new Date(startedAtStr || 0);
|
||||||
const stripSha = (s) => (s || '').replace(/^sha256:/, '');
|
const imageMatch = !!expectedSha && actualSha === expectedSha;
|
||||||
const imageMatch = !!expectedSha && stripSha(actualSha) === stripSha(expectedSha);
|
|
||||||
const freshlyStarted = !isNaN(startedAt) && startedAt >= deployStartTs;
|
const freshlyStarted = !isNaN(startedAt) && startedAt >= deployStartTs;
|
||||||
results.push({
|
results.push({
|
||||||
service: svc, ok: imageMatch && freshlyStarted,
|
service: svc, ok: imageMatch && freshlyStarted,
|
||||||
|
|
@ -1163,12 +1162,6 @@ fastify.post('/webhook/forgejo', async (request, reply) => {
|
||||||
|
|
||||||
// --- Apps ---
|
// --- Apps ---
|
||||||
|
|
||||||
// Registry dump — used by kua-mcp-core to discover all apps at startup
|
|
||||||
// without relying on a filesystem path that may not resolve inside its container.
|
|
||||||
fastify.get('/api/v1/apps/registry', async () => {
|
|
||||||
return registry;
|
|
||||||
});
|
|
||||||
|
|
||||||
// List all apps
|
// List all apps
|
||||||
fastify.get('/api/v1/apps', async () => {
|
fastify.get('/api/v1/apps', async () => {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
@ -1509,11 +1502,9 @@ async function completeSelfRecreate() {
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
// WEBHOOK_SECRET is optional — the Forgejo webhook path is now retired in
|
// Fail fast if webhook secret is missing in production
|
||||||
// favour of the admin API (/api/v1/apps/:app/deploy). The handler remains
|
|
||||||
// but returns 503 when the secret is absent, which is safe.
|
|
||||||
if (!DEV_MODE && !WEBHOOK_SECRET) {
|
if (!DEV_MODE && !WEBHOOK_SECRET) {
|
||||||
fastify.log.warn('KUA_DEPLOY_WEBHOOK_SECRET not set — /webhook/forgejo will return 503. Set the secret to re-enable Forgejo push triggers.');
|
throw new Error('KUA_DEPLOY_WEBHOOK_SECRET must be set in production — refusing to start');
|
||||||
}
|
}
|
||||||
await loadRegistry();
|
await loadRegistry();
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// Unit tests for registry loading — validates the path resolution and
|
|
||||||
// structural invariants of deploy-registry.json.
|
|
||||||
// Run with: node --test test/registry-loader.test.js
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
// On Bruno the registry is bind-mounted into the container at /app/deploy-registry.json
|
|
||||||
// (process.cwd() = /app). Locally (dev on gal), it lives in coder-core.
|
|
||||||
// Accept an override via env var so CI / test runners can point at any copy.
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const REGISTRY_FILE = process.env.REGISTRY_FILE
|
|
||||||
?? path.join(ROOT, 'deploy-registry.json') // container bind-mount
|
|
||||||
?? path.join(ROOT, '../../coder-core/services/kua-deploy/deploy-registry.json'); // local dev fallback
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Bug #7 regression: DEPLOY_REGISTRY_PATH must resolve to a real file.
|
|
||||||
// In kua-mcp-core the old ROOT was computed via __dirname + "../../../"
|
|
||||||
// which overshoots to "/" inside the container. Here we validate the
|
|
||||||
// kua-deploy-native path (process.cwd() relative) and the mcp-core
|
|
||||||
// path (CODER_CORE_ROOT env-var-backed).
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
let registry;
|
|
||||||
test('registry file exists at expected path', () => {
|
|
||||||
assert.ok(fs.existsSync(REGISTRY_FILE), `registry not found at ${REGISTRY_FILE}`);
|
|
||||||
const raw = fs.readFileSync(REGISTRY_FILE, 'utf-8');
|
|
||||||
registry = JSON.parse(raw);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registry has apps object', () => {
|
|
||||||
assert.ok(registry && typeof registry.apps === 'object', 'registry.apps must be an object');
|
|
||||||
const count = Object.keys(registry.apps).length;
|
|
||||||
assert.ok(count >= 5, `expected at least 5 registered apps, got ${count}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every app has required fields', () => {
|
|
||||||
for (const [name, cfg] of Object.entries(registry.apps)) {
|
|
||||||
assert.ok(typeof cfg.repo_dir === 'string' && cfg.repo_dir.length > 0,
|
|
||||||
`${name}: repo_dir must be a non-empty string`);
|
|
||||||
assert.ok(typeof cfg.deploy_mode === 'string',
|
|
||||||
`${name}: deploy_mode must be present`);
|
|
||||||
assert.ok(cfg.production && typeof cfg.production === 'object',
|
|
||||||
`${name}: production config must be present`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no app uses webhook deploy_mode (webhook path is retired)', () => {
|
|
||||||
const webhookApps = Object.entries(registry.apps)
|
|
||||||
.filter(([, cfg]) => cfg.deploy_mode === 'webhook')
|
|
||||||
.map(([name]) => name);
|
|
||||||
assert.deepEqual(webhookApps, [],
|
|
||||||
`These apps still have deploy_mode=webhook (retire them to direct): ${webhookApps.join(', ')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('kua-deploy is registered in its own registry', () => {
|
|
||||||
assert.ok('kua-deploy' in registry.apps, 'kua-deploy must be in deploy-registry.json');
|
|
||||||
assert.equal(registry.apps['kua-deploy'].deploy_mode, 'direct');
|
|
||||||
});
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
// Unit tests for SHA normalization in verify paths.
|
|
||||||
// Run with: node --test test/sha-comparison.test.js
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { test } from 'node:test';
|
|
||||||
|
|
||||||
// The canonical normalization function used in verifyStatelessRecreated()
|
|
||||||
// and runtime-status — must stay in sync with server.js.
|
|
||||||
const stripSha = (s) => (s || '').replace(/^sha256:/, '');
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Bug #3 regression: docker compose images returns bare hex,
|
|
||||||
// docker inspect .Image returns sha256:<hex>. They must compare equal.
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
test('sha: bare hex == bare hex', () => {
|
|
||||||
const a = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
|
|
||||||
assert.equal(stripSha(a), stripSha(a));
|
|
||||||
assert.ok(stripSha(a) === stripSha(a));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sha: sha256-prefixed == bare hex (the failing case before the fix)', () => {
|
|
||||||
const bare = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
|
|
||||||
const prefixed = `sha256:${bare}`;
|
|
||||||
// Before fix: prefixed === bare => false
|
|
||||||
assert.notEqual(prefixed, bare, 'raw strings are indeed unequal — this is the bug');
|
|
||||||
// After fix: stripSha(prefixed) === stripSha(bare) => true
|
|
||||||
assert.equal(stripSha(prefixed), stripSha(bare), 'normalized strings must be equal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sha: both sha256-prefixed', () => {
|
|
||||||
const a = 'sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
|
|
||||||
const b = 'sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
|
|
||||||
assert.equal(stripSha(a), stripSha(b));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sha: different digests stay different after normalization', () => {
|
|
||||||
const a = 'sha256:aaaa0000000000000000000000000000000000000000000000000000000000001111';
|
|
||||||
const b = 'sha256:bbbb0000000000000000000000000000000000000000000000000000000000002222';
|
|
||||||
assert.notEqual(stripSha(a), stripSha(b));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sha: empty/null input returns empty string', () => {
|
|
||||||
assert.equal(stripSha(''), '');
|
|
||||||
assert.equal(stripSha(null), '');
|
|
||||||
assert.equal(stripSha(undefined), '');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sha: imageMatch logic mirrors server.js verifyStatelessRecreated', () => {
|
|
||||||
const expectedSha = 'a0845a6c5772e01234567890abcdef01234567890abcdef01234567890abcdef01';
|
|
||||||
const actualSha = `sha256:${expectedSha}`;
|
|
||||||
const imageMatch = !!expectedSha && stripSha(actualSha) === stripSha(expectedSha);
|
|
||||||
assert.ok(imageMatch, 'imageMatch must be true when digests are the same modulo sha256: prefix');
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue