#!/usr/bin/env node
/**
 * Pull the api + web images onto the target host before the stack is applied.
 *
 * This exists because the deploy action's `pull: true` does NOT reliably
 * refresh an already-cached tag on a standalone endpoint. Observed on galactus
 * 2026-07-30: the registry held web:latest built from 3ff56e6, the host still
 * had a web:latest cached from an earlier commit, the deploy reported success,
 * and the running container served the OLD build. A moving tag like `latest`
 * makes this silent — the stack file names the same string either way, so
 * nothing downstream notices.
 *
 * Pulling explicitly, and failing the deploy if a pull fails, makes "the image
 * the host runs" a thing the workflow controls rather than hopes for.
 *
 * Required env:
 *   PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
 *   REGISTRY, REGISTRY_USERNAME, REGISTRY_PASSWORD
 *   IMAGES   comma-separated repositories, e.g. "owner/api,owner/web"
 *   TAG      the tag to pull
 *
 * 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 REGISTRY = required("REGISTRY");
const USERNAME = required("REGISTRY_USERNAME");
const PASSWORD = required("REGISTRY_PASSWORD");
const IMAGES = required("IMAGES").split(",").map((s) => s.trim()).filter(Boolean);
const TAG = required("TAG");

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

// Docker wants the credentials as base64url'd JSON in a header. Node's
// "base64url" encoding omits the `=` padding, which Portainer's Go decoder
// rejects outright ("Illegal base64 data at input byte N"), so build the
// URL-safe alphabet by hand and KEEP the padding.
const REGISTRY_AUTH = Buffer.from(
  JSON.stringify({
    username: USERNAME,
    password: PASSWORD,
    serveraddress: REGISTRY,
  }),
)
  .toString("base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_");

async function pull(repository) {
  const image = `${REGISTRY}/${repository}`;
  const url =
    `${DOCKER}/images/create` +
    `?fromImage=${encodeURIComponent(image)}&tag=${encodeURIComponent(TAG)}`;

  const res = await fetch(url, {
    method: "POST",
    headers: { "X-API-Key": API_KEY, "X-Registry-Auth": REGISTRY_AUTH },
  });
  const body = await res.text();
  if (!res.ok) {
    throw new Error(`pull ${image}:${TAG} -> HTTP ${res.status} ${body.slice(0, 300)}`);
  }
  // The endpoint streams newline-delimited JSON and answers 200 even when the
  // pull itself failed — the failure only shows up as an {"error": ...} object
  // in the stream, so the status code alone proves nothing.
  const lines = body.split("\n").filter((l) => l.trim());
  for (const line of lines) {
    let obj;
    try {
      obj = JSON.parse(line);
    } catch {
      continue;
    }
    if (obj.error) {
      throw new Error(`pull ${image}:${TAG} failed: ${obj.error}`);
    }
  }
  const last = lines.length ? JSON.parse(lines[lines.length - 1]) : {};
  console.log(`${image}:${TAG} — ${last.status ?? "pulled"}`);
}

async function main() {
  for (const repository of IMAGES) {
    await pull(repository);
  }
  console.log("all images pulled");
}

main().catch((err) => {
  console.error(`image pull FAILED: ${err.message}`);
  process.exit(1);
});
