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.
This commit is contained in:
@@ -27,8 +27,12 @@ usage() {
|
|||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: curl -fsSL ${BASE}/install.sh | bash -s -- [installer options]
|
Usage: curl -fsSL ${BASE}/install.sh | bash -s -- [installer options]
|
||||||
|
|
||||||
Downloads the latest fpagent release, verifies it, and runs its installer.
|
Downloads the fpagent release, verifies it, and runs its installer.
|
||||||
Options are forwarded to that installer:
|
|
||||||
|
Options:
|
||||||
|
--tag TAG release tag to install (default: latest)
|
||||||
|
|
||||||
|
Options below are forwarded to the bundled installer:
|
||||||
--check preflight and report only; change nothing
|
--check preflight and report only; change nothing
|
||||||
--offline never touch the network; fail if dependencies are missing
|
--offline never touch the network; fail if dependencies are missing
|
||||||
--enable-service install and enable a systemd service (starts at boot)
|
--enable-service install and enable a systemd service (starts at boot)
|
||||||
@@ -42,13 +46,26 @@ Environment:
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
for arg in "$@"; do
|
RELEASE_TAG="latest"
|
||||||
case "$arg" in
|
FORWARD=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
-h | --help)
|
-h | --help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
|
--tag)
|
||||||
|
RELEASE_TAG="${2:?--tag needs a release tag}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--tag=*)
|
||||||
|
RELEASE_TAG="${1#--tag=}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
FORWARD+=("$1")
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
for cmd in curl tar sha256sum python3 find; do
|
for cmd in curl tar sha256sum python3 find; do
|
||||||
@@ -68,11 +85,17 @@ find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \
|
|||||||
# online bundle (whose installer apt-get's the dependency).
|
# online bundle (whose installer apt-get's the dependency).
|
||||||
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
|
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
|
||||||
|
|
||||||
say "Fetching the latest Fingerprint Agent release..."
|
if [ "$RELEASE_TAG" = "latest" ]; then
|
||||||
meta="$(curl -fsSL "${BASE}/releases/latest")" || die "could not fetch the release list from ${BASE}"
|
meta_url="${BASE}/releases/latest"
|
||||||
|
else
|
||||||
|
meta_url="${BASE}/releases/tags/${RELEASE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Fetching the Fingerprint Agent release (${RELEASE_TAG})..."
|
||||||
|
meta="$(curl -fsSL "$meta_url")" || die "could not fetch the release list from ${BASE}"
|
||||||
|
|
||||||
readout="$(python3 -c '
|
readout="$(python3 -c '
|
||||||
import json, sys
|
import json, re, sys
|
||||||
codename = sys.argv[1]
|
codename = sys.argv[1]
|
||||||
release = json.loads(sys.argv[2])
|
release = json.loads(sys.argv[2])
|
||||||
assets = [a["name"] for a in release.get("assets", [])]
|
assets = [a["name"] for a in release.get("assets", [])]
|
||||||
@@ -83,15 +106,19 @@ def pick(suffix):
|
|||||||
name = pick("-ubuntu-" + codename + "-x86_64.tar.gz") if codename else ""
|
name = pick("-ubuntu-" + codename + "-x86_64.tar.gz") if codename else ""
|
||||||
if not name:
|
if not name:
|
||||||
name = pick("-linux-x86_64.tar.gz")
|
name = pick("-linux-x86_64.tar.gz")
|
||||||
print(release.get("tag", "") + "\t" + name)
|
# The git hash is baked into the asset name (bundle.sh --version) and into
|
||||||
|
# the binary itself; it is the version we record and later compare against.
|
||||||
|
m = re.match(r"^fpagent-(.+)-(?:linux-x86_64|ubuntu-[a-z0-9]+-x86_64)\.tar\.gz$", name)
|
||||||
|
version = m.group(1) if m else ""
|
||||||
|
print(release.get("tag", "") + "\t" + name + "\t" + version)
|
||||||
' "$codename" "$meta")" || die "could not parse the release metadata"
|
' "$codename" "$meta")" || die "could not parse the release metadata"
|
||||||
|
|
||||||
tag="${readout%%$'\t'*}"
|
IFS=$'\t' read -r tag asset version <<<"$readout"
|
||||||
asset="${readout#*$'\t'}"
|
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (x86_64 required)"
|
||||||
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in the latest release (x86_64 required)"
|
|
||||||
|
|
||||||
say " release ${tag}"
|
say " release ${tag}"
|
||||||
say " bundle ${asset}"
|
say " bundle ${asset}"
|
||||||
|
[ -n "$version" ] && say " version ${version}"
|
||||||
|
|
||||||
tmp="$(mktemp -d "$tmpbase/fpagent-install.XXXXXXXX")"
|
tmp="$(mktemp -d "$tmpbase/fpagent-install.XXXXXXXX")"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
@@ -106,5 +133,10 @@ get "${BASE}/releases/download/${tag}/${asset}.sha256" "${tmp}/${asset}.sha256"
|
|||||||
tar xzf "${tmp}/${asset}" -C "$tmp" || die "could not extract ${asset}"
|
tar xzf "${tmp}/${asset}" -C "$tmp" || die "could not extract ${asset}"
|
||||||
[ -x "${tmp}/install.sh" ] || die "the bundle does not contain install.sh"
|
[ -x "${tmp}/install.sh" ] || die "the bundle does not contain install.sh"
|
||||||
|
|
||||||
|
# Hand the version to the bundle installer so it can record it and report an
|
||||||
|
# upgrade. Bundles built by bundle.sh already carry VERSION; this also covers
|
||||||
|
# older bundles and other packagers, using the hash from the asset name.
|
||||||
|
[ -n "$version" ] && printf '%s\n' "$version" >"${tmp}/VERSION"
|
||||||
|
|
||||||
say "Running the installer (pass --enable-service to start at boot)..."
|
say "Running the installer (pass --enable-service to start at boot)..."
|
||||||
"${tmp}/install.sh" "$@"
|
"${tmp}/install.sh" "${FORWARD[@]}"
|
||||||
|
|||||||
+18
-5
@@ -345,12 +345,9 @@ export function createApp(overrides = {}) {
|
|||||||
message: 'The Fingerprint Agent installer is not configured on this deployment.',
|
message: 'The Fingerprint Agent installer is not configured on this deployment.',
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/fpagent/releases/latest', async (req, res) => {
|
const releasePayload = async (res, giteaPath) => {
|
||||||
if (!cfg.giteaToken) return installerNotConfigured(res);
|
|
||||||
try {
|
try {
|
||||||
const r = await cfg.giteaFetch(`${giteaBase()}/api/v1/repos/${cfg.fpagentRepo}/releases/latest`, {
|
const r = await cfg.giteaFetch(`${giteaBase()}${giteaPath}`, { headers: giteaHeaders() });
|
||||||
headers: giteaHeaders(),
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
||||||
}
|
}
|
||||||
@@ -372,6 +369,22 @@ export function createApp(overrides = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
|
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) => {
|
app.get('/fpagent/releases/download/:tag/:name', async (req, res) => {
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
// laptop never needs credentials.
|
// laptop never needs credentials.
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { createApp, FPAGENT_REPO } from '../server.js';
|
import { createApp, FPAGENT_REPO } from '../server.js';
|
||||||
import { startServer, stopServer, urlOf } from './helpers.js';
|
import { startServer, stopServer, urlOf } from './helpers.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const SCRIPT = path.join(
|
const SCRIPT = path.join(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
'..',
|
'..',
|
||||||
@@ -49,6 +52,11 @@ async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}
|
|||||||
res.end(JSON.stringify(RELEASE));
|
res.end(JSON.stringify(RELEASE));
|
||||||
return;
|
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/`)) {
|
if (req.url.startsWith(`/${FPAGENT_REPO}/releases/download/`)) {
|
||||||
res.writeHead(downloadStatus, { 'content-type': 'application/gzip' });
|
res.writeHead(downloadStatus, { 'content-type': 'application/gzip' });
|
||||||
res.end(Buffer.from('bundle-bytes'));
|
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 () => {
|
test('bundles stream through this app; the client sends no credentials', async () => {
|
||||||
const gitea = await startFakeGitea();
|
const gitea = await startFakeGitea();
|
||||||
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
const { server, base } = await bootFpagentApp(urlOf(gitea.server));
|
||||||
|
|||||||
Reference in New Issue
Block a user