mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys
The self-context adopt runs read-modify-write inside one transaction, the admin shell renders nothing until the role is confirmed, and the json-body helper merges Headers instances correctly. The compose image reference accepts a pinned digest through an env var, and a promote script resolves the published digest, verifies its signature and hands the operator the exact pinning call — the documented path away from a moving latest tag.
This commit is contained in:
1 parent
599b7e8c99
commit
f13aa684f9
12 files changed
+876
-79
No files matched your search
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* promote-digest.ts — pin a released image to its immutable digest.
|
||||
*
|
||||
* After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this
|
||||
* script turns the mutable tag into a deterministic deploy reference:
|
||||
*
|
||||
* 1. Resolves the multi-arch index digest for the tag straight from
|
||||
* the GHCR registry API (anonymous pull token — the image is
|
||||
* public; no docker daemon needed).
|
||||
* 2. Verifies the keyless cosign signature the publish workflow
|
||||
* attached to that digest (GitHub Actions OIDC identity). Fails
|
||||
* closed: no cosign binary or a failed verification aborts the
|
||||
* promotion.
|
||||
* 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose
|
||||
* file interpolates into `image:` — and, when a Coolify API token
|
||||
* is present in the environment, writes it to the application's
|
||||
* env vars via the documented `/envs` endpoint. Without a token
|
||||
* it prints the exact curl commands instead of guessing.
|
||||
*
|
||||
* Why an env var and not the compose file itself: for a git-based
|
||||
* "Docker Compose" application Coolify re-reads the compose file from
|
||||
* the repository checkout on every deploy (`docker_compose_raw` is its
|
||||
* stored copy of that file and is not part of the application-update
|
||||
* API surface), so editing the stored compose is not durable. The
|
||||
* application env vars ARE durable and are interpolated into the
|
||||
* compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`
|
||||
* resolves to the pinned digest when the var is set and to `:latest`
|
||||
* when it is not.
|
||||
*
|
||||
* Usage (one-shot, not a recurring job):
|
||||
* pnpm dlx tsx scripts/promote-digest.ts v1.16.5
|
||||
*
|
||||
* Env (all optional — without them the script prints commands only):
|
||||
* COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens)
|
||||
* COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com
|
||||
* COOLIFY_APP_UUID UUID of the HealthLog application resource
|
||||
* PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update
|
||||
*
|
||||
* See docs/ops/deploy.md ("Digest pinning") for the full runbook,
|
||||
* including rollback (re-point the var at the previous digest).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const IMAGE_REPO = "mbombeck/healthlog";
|
||||
const IMAGE = `ghcr.io/${IMAGE_REPO}`;
|
||||
const ENV_KEY = "HEALTHLOG_IMAGE_REF";
|
||||
const COSIGN_ISSUER = "https://token.actions.githubusercontent.com";
|
||||
const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/";
|
||||
|
||||
// The OCI index media types a multi-arch publish can produce. GHCR
|
||||
// answers the manifest request with the digest of whichever it stores;
|
||||
// accepting both keeps the script working across buildx versions.
|
||||
const INDEX_ACCEPT = [
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
].join(", ");
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`\nERROR: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function resolveDigest(tag: string): Promise<string> {
|
||||
// Anonymous pull token — the package is public, so no credentials.
|
||||
const tokenRes = await fetch(
|
||||
`https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`,
|
||||
);
|
||||
if (!tokenRes.ok) {
|
||||
fail(`GHCR token request failed: HTTP ${tokenRes.status}`);
|
||||
}
|
||||
const { token } = (await tokenRes.json()) as { token?: string };
|
||||
if (!token) fail("GHCR token response carried no token");
|
||||
|
||||
// HEAD is enough — the digest rides the Docker-Content-Digest header.
|
||||
const manifestRes = await fetch(
|
||||
`https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`,
|
||||
{
|
||||
method: "HEAD",
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT },
|
||||
},
|
||||
);
|
||||
if (!manifestRes.ok) {
|
||||
fail(
|
||||
`manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`,
|
||||
);
|
||||
}
|
||||
const digest = manifestRes.headers.get("docker-content-digest");
|
||||
if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
||||
fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function verifyCosign(digest: string): void {
|
||||
const ref = `${IMAGE}@${digest}`;
|
||||
try {
|
||||
execFileSync(
|
||||
"cosign",
|
||||
[
|
||||
"verify",
|
||||
"--certificate-oidc-issuer",
|
||||
COSIGN_ISSUER,
|
||||
"--certificate-identity-regexp",
|
||||
COSIGN_IDENTITY_RE,
|
||||
ref,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
||||
if (e.code === "ENOENT") {
|
||||
fail(
|
||||
"cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.",
|
||||
);
|
||||
}
|
||||
fail(
|
||||
`cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`,
|
||||
);
|
||||
}
|
||||
console.log(`cosign signature verified for ${ref}`);
|
||||
}
|
||||
|
||||
interface CoolifyEnvRow {
|
||||
uuid: string;
|
||||
key: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
async function coolifyRequest(
|
||||
base: string,
|
||||
token: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCoolifyEnv(
|
||||
base: string,
|
||||
token: string,
|
||||
appUuid: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
|
||||
if (!list.ok) {
|
||||
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
|
||||
}
|
||||
const rows = (await list.json()) as CoolifyEnvRow[];
|
||||
const existing = Array.isArray(rows)
|
||||
? rows.find((r) => r.key === ENV_KEY)
|
||||
: undefined;
|
||||
|
||||
const method = existing ? "PATCH" : "POST";
|
||||
const res = await coolifyRequest(
|
||||
base,
|
||||
token,
|
||||
`/applications/${appUuid}/envs`,
|
||||
{ method, body: JSON.stringify({ key: ENV_KEY, value }) },
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(`Coolify env ${method} failed: HTTP ${res.status}`);
|
||||
}
|
||||
console.log(
|
||||
`Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function triggerDeploy(
|
||||
base: string,
|
||||
token: string,
|
||||
appUuid: string,
|
||||
): Promise<void> {
|
||||
const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`);
|
||||
if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`);
|
||||
console.log("Deploy triggered. Verify /api/version after it settles.");
|
||||
}
|
||||
|
||||
function printManualCommands(value: string): void {
|
||||
console.log(`
|
||||
No COOLIFY_API_TOKEN in the environment — manual path (fill in host,
|
||||
token and application UUID; the UUID is in the Coolify URL of the app):
|
||||
|
||||
# set / update the pin (POST creates, PATCH updates an existing key)
|
||||
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \\
|
||||
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \\
|
||||
-d '{"key":"${ENV_KEY}","value":"${value}"}'
|
||||
|
||||
# redeploy with the pinned digest
|
||||
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \\
|
||||
-H "Authorization: Bearer <token>"
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tag = process.argv[2];
|
||||
if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) {
|
||||
fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z");
|
||||
}
|
||||
|
||||
console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`);
|
||||
const digest = await resolveDigest(tag);
|
||||
console.log(`digest: ${digest}`);
|
||||
|
||||
verifyCosign(digest);
|
||||
|
||||
const value = `@${digest}`;
|
||||
console.log(`\n${ENV_KEY}=${value}`);
|
||||
|
||||
const token = process.env.COOLIFY_API_TOKEN;
|
||||
const base = process.env.COOLIFY_API_URL;
|
||||
const appUuid = process.env.COOLIFY_APP_UUID;
|
||||
|
||||
if (token && base && appUuid) {
|
||||
await updateCoolifyEnv(base, token, appUuid, value);
|
||||
if (process.env.PROMOTE_DEPLOY === "1") {
|
||||
await triggerDeploy(base, token, appUuid);
|
||||
} else {
|
||||
console.log(
|
||||
"Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (token || base || appUuid) {
|
||||
console.log(
|
||||
"Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.",
|
||||
);
|
||||
}
|
||||
printManualCommands(value);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
||||
Reference in new issue
Block a user