#!/usr/bin/env node
/**
 * Delete unused images from the target host after a successful deploy.
 *
 * This exists because nothing else reclaims them. Every build.yml run pushes a
 * new api + web image, every deploy pulls both onto the host, and the previous
 * pair is left behind untagged-but-present forever. On galactus that reached
 * 63 images / 83.85GB (79.26GB of it unused) and filled the 98GB root
 * filesystem to 100% on 2026-08-20 — which surfaced as "re-import is broken",
 * because the Operaciones REIMPORT job leads with a mysqldump that could no
 * longer write its safety backup.
 *
 * Two things keep this from eating a live deployment:
 *
 *  - Docker never prunes an image that a container references, running or
 *    stopped. The five images the prod stacks use are therefore untouchable
 *    for as long as their containers exist.
 *  - `until` gives a grace window on top of that, so a rollback target stays
 *    on disk instead of forcing a re-pull from the registry.
 *
 * TRAP: `until` filters on the image's CREATION time, not when the host pulled
 * it. Rolling back to an old tag pulls an image that is already older than the
 * window, so the grace period does NOT protect it — the running-container rule
 * is what does. That is why this step must run AFTER the app stack is deployed
 * and verified, never before.
 *
 * Required env:
 *   PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
 * Optional:
 *   KEEP_HOURS   grace window in hours (default 168 = 7 days)
 *
 * TLS: Portainer here is self-signed; the caller sets
 * NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
 */

function required(name) {
  const v = process.env[name];
  if (!v) {
    console.error(`missing required env: ${name}`);
    process.exit(1);
  }
  return v;
}

const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
const API_KEY = required("PORTAINER_API_KEY");
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
const KEEP_HOURS = process.env.KEEP_HOURS || "168";

const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;

// `dangling: ["false"]` is what makes this `docker image prune -a` rather than
// the default, which only collects untagged layers. The tagged-but-superseded
// api/web images are the whole problem, and the default filter walks straight
// past them.
const FILTERS = JSON.stringify({
  dangling: ["false"],
  until: [`${KEEP_HOURS}h`],
});

function human(bytes) {
  if (!bytes) return "0B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  let i = 0;
  let n = bytes;
  while (n >= 1024 && i < units.length - 1) {
    n /= 1024;
    i += 1;
  }
  return `${n.toFixed(i === 0 ? 0 : 2)}${units[i]}`;
}

async function main() {
  const url = `${DOCKER}/images/prune?filters=${encodeURIComponent(FILTERS)}`;
  const res = await fetch(url, {
    method: "POST",
    headers: { "X-API-Key": API_KEY },
  });
  const body = await res.text();
  if (!res.ok) {
    throw new Error(`prune -> HTTP ${res.status} ${body.slice(0, 300)}`);
  }

  let report;
  try {
    report = JSON.parse(body);
  } catch {
    throw new Error(`prune returned non-JSON: ${body.slice(0, 300)}`);
  }

  const deleted = report.ImagesDeleted ?? [];
  const reclaimed = report.SpaceReclaimed ?? 0;
  console.log(
    `pruned images older than ${KEEP_HOURS}h and unused by any container`,
  );
  console.log(`  entries removed : ${deleted.length}`);
  console.log(`  space reclaimed : ${human(reclaimed)}`);
}

main().catch((err) => {
  // Non-fatal by contract: the step that calls this sets continue-on-error, so
  // housekeeping never turns a good deploy red. Exit non-zero anyway so the
  // failure is visible in the run rather than swallowed.
  console.error(`::warning::image prune FAILED: ${err.message}`);
  process.exit(1);
});
