From 2ad4aa63c63c06d825a78f15bb99600795aefdc8 Mon Sep 17 00:00:00 2001 From: "Foluso ADEBISI (DevBox Aux)" Date: Fri, 2 Oct 2026 11:26:35 +0100 Subject: [PATCH] feat(fpagent): pin releases, report upgrades, restart the service The installer gains --tag to install a specific release (gateway route /fpagent/releases/tags/:tag), derives the git hash from the asset name, and hands it to the bundle installer as VERSION so upgrades are reported and the installed version is recorded. Service restart on upgrade lives in the bundle installer, which owns the systemd unit. --- tools/public/fpagent/install.sh | 56 +++++++++++++++++----- tools/server.js | 23 ++++++++-- tools/test/fpagent-install.test.js | 74 +++++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 18 deletions(-) diff --git a/tools/public/fpagent/install.sh b/tools/public/fpagent/install.sh index e4d72a3..c94a47d 100755 --- a/tools/public/fpagent/install.sh +++ b/tools/public/fpagent/install.sh @@ -27,8 +27,12 @@ usage() { cat <"${tmp}/VERSION" + say "Running the installer (pass --enable-service to start at boot)..." -"${tmp}/install.sh" "$@" +"${tmp}/install.sh" "${FORWARD[@]}" diff --git a/tools/server.js b/tools/server.js index 900c122..294fa02 100644 --- a/tools/server.js +++ b/tools/server.js @@ -345,12 +345,9 @@ export function createApp(overrides = {}) { message: 'The Fingerprint Agent installer is not configured on this deployment.', }); - app.get('/fpagent/releases/latest', async (req, res) => { - if (!cfg.giteaToken) return installerNotConfigured(res); + const releasePayload = async (res, giteaPath) => { try { - const r = await cfg.giteaFetch(`${giteaBase()}/api/v1/repos/${cfg.fpagentRepo}/releases/latest`, { - headers: giteaHeaders(), - }); + const r = await cfg.giteaFetch(`${giteaBase()}${giteaPath}`, { headers: giteaHeaders() }); if (!r.ok) { return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); } @@ -372,6 +369,22 @@ export function createApp(overrides = {}) { } catch { res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); } + }; + + app.get('/fpagent/releases/latest', (req, res) => { + if (!cfg.giteaToken) return installerNotConfigured(res); + return releasePayload(res, `/api/v1/repos/${cfg.fpagentRepo}/releases/latest`); + }); + + app.get('/fpagent/releases/tags/:tag', (req, res) => { + if (!cfg.giteaToken) return installerNotConfigured(res); + if (!SAFE_ASSET.test(req.params.tag)) { + return res.status(400).json({ ok: false, message: 'Unknown release tag.' }); + } + return releasePayload( + res, + `/api/v1/repos/${cfg.fpagentRepo}/releases/tags/${encodeURIComponent(req.params.tag)}` + ); }); app.get('/fpagent/releases/download/:tag/:name', async (req, res) => { diff --git a/tools/test/fpagent-install.test.js b/tools/test/fpagent-install.test.js index f87d4cc..739af5c 100644 --- a/tools/test/fpagent-install.test.js +++ b/tools/test/fpagent-install.test.js @@ -3,14 +3,17 @@ // laptop never needs credentials. import test from 'node:test'; import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFile, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; import { createApp, FPAGENT_REPO } from '../server.js'; import { startServer, stopServer, urlOf } from './helpers.js'; +const execFileAsync = promisify(execFile); + const SCRIPT = path.join( path.dirname(fileURLToPath(import.meta.url)), '..', @@ -49,6 +52,11 @@ async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {} res.end(JSON.stringify(RELEASE)); return; } + if (req.url.startsWith(`/api/v1/repos/${FPAGENT_REPO}/releases/tags/`)) { + res.writeHead(releaseStatus, { 'content-type': 'application/json' }); + res.end(JSON.stringify(RELEASE)); + return; + } if (req.url.startsWith(`/${FPAGENT_REPO}/releases/download/`)) { res.writeHead(downloadStatus, { 'content-type': 'application/gzip' }); res.end(Buffer.from('bundle-bytes')); @@ -98,6 +106,70 @@ test('latest release is proxied as tools-relative URLs, without the token', asyn } }); +test('a pinned tag is proxied from the forge tag endpoint', async () => { + const gitea = await startFakeGitea(); + const { server, base } = await bootFpagentApp(urlOf(gitea.server)); + try { + const r = await fetch(`${base}/fpagent/releases/tags/v1.0.0`); + assert.equal(r.status, 200); + assert.equal((await r.json()).tag, TAG); + assert.ok(gitea.calls[0].url.endsWith('/releases/tags/v1.0.0')); + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('an unsafe release tag is rejected before reaching the forge', async () => { + const gitea = await startFakeGitea(); + const { server, base } = await bootFpagentApp(urlOf(gitea.server)); + try { + const r = await fetch(`${base}/fpagent/releases/tags/-bad`); + assert.equal(r.status, 400); + assert.equal(gitea.calls.length, 0); + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('install.sh --tag asks for that tag, not latest', async () => { + const calls = []; + const gateway = await startServer((req, res) => { + calls.push(req.url); + if (req.url === '/releases/tags/v7.7.7') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + tag: 'v7.7.7', + assets: [{ name: 'fpagent-cafebabe-linux-x86_64.tar.gz' }], + }) + ); + return; + } + res.writeHead(404); + res.end(); + }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-tag-test-')); + try { + let failed = false; + try { + // Async so the in-process gateway can answer while the child runs. + await execFileAsync('bash', [SCRIPT, '--tag', 'v7.7.7', '--check'], { + env: { ...process.env, TMPDIR: tmp, FPAGENT_BASE_URL: urlOf(gateway) }, + }); + } catch { + failed = true; // the asset download 404s; the metadata request is what matters + } + assert.equal(failed, true); + assert.ok(calls.includes('/releases/tags/v7.7.7'), 'should request the pinned tag'); + assert.ok(!calls.includes('/releases/latest'), 'should not fall back to latest'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + await stopServer(gateway); + } +}); + test('bundles stream through this app; the client sends no credentials', async () => { const gitea = await startFakeGitea(); const { server, base } = await bootFpagentApp(urlOf(gitea.server));