New namespaced CRD KumaStatusPage (uptime.renner.uno/v1alpha1) and a reconciler that owns the Kuma status page completely: config, domain names and the full group/monitor list. Sections select KumaMonitors in any namespace by label; monitors are ordered by displayName, then namespace/name, a monitor goes to the first matching section only, and monitors without a Kuma ID are left out and counted in status. The reconciler adopts an existing page with the slug, writes only when the managed content differs (no write per resync), reuses Kuma group IDs by position so saves do not churn them, reverts UI edits on resync, deletes the page on resource deletion, and reports Ready=False/Conflict for a younger resource claiming the same slug. Every KumaMonitor change to labels, displayName, monitor ID or deletion enqueues all pages. kumaclient gains a StatusPages interface (Breml + Fake). The socket API returns no group list, so groups are read from Kuma's public GET /api/status-page/<slug>, as the bootstrap did. Fields kuma-sync does not manage (logo, custom CSS, analytics, RSS title, auto refresh interval) are written back unchanged. autoRefreshInterval is not in the spec: go-uptime-kuma-client v0.6.2 (latest) never sends it on save. The Ansible bootstrap no longer creates or edits status pages; the existing page is left in place for kuma-sync to adopt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
5.9 KiB
JavaScript
180 lines
5.9 KiB
JavaScript
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification,
|
|
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
|
|
// All input comes from environment variables; secrets are never printed.
|
|
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
|
|
const fs = require("fs");
|
|
const { io } = require("socket.io-client");
|
|
|
|
const KUMA_URL = "http://127.0.0.1:3001";
|
|
const EMIT_TIMEOUT_MS = 30000;
|
|
const TOTAL_TIMEOUT_MS = 180000;
|
|
// Defaults the Kuma settings page applies to unset keys before saving (src/pages/Settings.vue).
|
|
const SETTINGS_DEFAULTS = {
|
|
checkUpdate: true,
|
|
searchEngineIndex: false,
|
|
entryPage: "dashboard",
|
|
nscd: true,
|
|
keepDataPeriodDays: 180,
|
|
tlsExpiryNotifyDays: [7, 14, 21],
|
|
domainExpiryNotifyDays: [7, 14, 21],
|
|
trustProxy: false,
|
|
};
|
|
|
|
const REQUIRED = [
|
|
"KUMA_SETUP_USERNAME",
|
|
"KUMA_SETUP_PASSWORD",
|
|
"KUMA_BASE_URL",
|
|
"KUMA_SMTP_HOST",
|
|
"KUMA_SMTP_PORT",
|
|
"KUMA_SMTP_USERNAME",
|
|
"KUMA_SMTP_PASSWORD",
|
|
"KUMA_SMTP_FROM",
|
|
"KUMA_SMTP_TO",
|
|
];
|
|
const missing = REQUIRED.filter((name) => !process.env[name]);
|
|
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
|
|
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
|
|
missing.push("KUMA_METRICS_KEY_FILE");
|
|
}
|
|
if (missing.length > 0) {
|
|
console.error(`missing environment variables: ${missing.join(", ")}`);
|
|
process.exit(2);
|
|
}
|
|
const env = process.env;
|
|
|
|
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
|
|
const lists = {};
|
|
const LIST_EVENTS = ["notificationList", "apiKeyList"];
|
|
for (const event of LIST_EVENTS) {
|
|
socket.on(event, (data) => {
|
|
lists[event] = data;
|
|
});
|
|
}
|
|
|
|
let finished = false;
|
|
function done(code, msg) {
|
|
if (finished) return;
|
|
finished = true;
|
|
clearTimeout(watchdog);
|
|
if (msg) (code === 0 ? console.log : console.error)(msg);
|
|
socket.close();
|
|
process.exit(code);
|
|
}
|
|
|
|
const watchdog = setTimeout(() => done(1, `timeout: bootstrap did not finish within ${TOTAL_TIMEOUT_MS / 1000}s`), TOTAL_TIMEOUT_MS);
|
|
socket.on("connect_error", (err) => done(1, `connect_error: ${err.message}`));
|
|
socket.on("disconnect", (reason) => done(1, `disconnected before bootstrap finished: ${reason}`));
|
|
|
|
function call(event, ...args) {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), EMIT_TIMEOUT_MS);
|
|
socket.emit(event, ...args, (res) => {
|
|
clearTimeout(timer);
|
|
resolve(res);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function callOk(event, ...args) {
|
|
const res = await call(event, ...args);
|
|
if (!res || !res.ok) {
|
|
throw new Error(`${event} failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
function report(step, changed) {
|
|
console.log(`${changed ? "changed" : "ok"}: ${step}`);
|
|
}
|
|
|
|
// Kuma registers socket handlers only after emitting info.
|
|
function waitForInfo() {
|
|
return new Promise((resolve) => socket.once("info", resolve));
|
|
}
|
|
|
|
async function setupAdmin() {
|
|
const needSetup = await call("needSetup");
|
|
if (!needSetup) {
|
|
console.log("already set up");
|
|
return;
|
|
}
|
|
await callOk("setup", env.KUMA_SETUP_USERNAME, env.KUMA_SETUP_PASSWORD);
|
|
console.log("admin user created");
|
|
}
|
|
|
|
async function login() {
|
|
await callOk("login", { username: env.KUMA_SETUP_USERNAME, password: env.KUMA_SETUP_PASSWORD, token: "" });
|
|
// afterLogin sends the lists before the login ack but swallows send failures.
|
|
for (const event of LIST_EVENTS) {
|
|
if (lists[event] === undefined) {
|
|
throw new Error(`${event} not received after login`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureSettings() {
|
|
const { data } = await callOk("getSettings");
|
|
if (data.trustProxy === true && data.primaryBaseURL === env.KUMA_BASE_URL) {
|
|
report("settings", false);
|
|
return;
|
|
}
|
|
const updated = { ...SETTINGS_DEFAULTS, ...data, trustProxy: true, primaryBaseURL: env.KUMA_BASE_URL };
|
|
await callOk("setSettings", updated, "");
|
|
report("settings", true);
|
|
}
|
|
|
|
async function ensureNotification() {
|
|
const port = Number(env.KUMA_SMTP_PORT);
|
|
const desired = {
|
|
name: "email",
|
|
type: "smtp",
|
|
isDefault: true,
|
|
smtpHost: env.KUMA_SMTP_HOST,
|
|
smtpPort: port,
|
|
// 465 is implicit TLS; any other port uses STARTTLS.
|
|
smtpSecure: port === 465,
|
|
smtpIgnoreSTARTTLS: false,
|
|
smtpUsername: env.KUMA_SMTP_USERNAME,
|
|
smtpPassword: env.KUMA_SMTP_PASSWORD,
|
|
smtpFrom: env.KUMA_SMTP_FROM,
|
|
smtpTo: env.KUMA_SMTP_TO,
|
|
};
|
|
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
|
|
const current = existing ? JSON.parse(existing.config || "{}") : {};
|
|
const upToDate =
|
|
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
|
|
if (upToDate) {
|
|
report("notification", false);
|
|
return;
|
|
}
|
|
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
|
|
report("notification", true);
|
|
}
|
|
|
|
async function rotateMetricsApiKey() {
|
|
if (!rotateMetricsKey) {
|
|
report("metrics api key", false);
|
|
return;
|
|
}
|
|
for (const key of (lists.apiKeyList || []).filter((k) => k.name === "prometheus")) {
|
|
await callOk("deleteAPIKey", key.id);
|
|
}
|
|
const res = await callOk("addAPIKey", { name: "prometheus", expires: null, active: 1 });
|
|
fs.writeFileSync(env.KUMA_METRICS_KEY_FILE, res.key, { mode: 0o600 });
|
|
report("metrics api key", true);
|
|
}
|
|
|
|
async function main() {
|
|
await waitForInfo();
|
|
await setupAdmin();
|
|
await login();
|
|
await ensureSettings();
|
|
await ensureNotification();
|
|
await rotateMetricsApiKey();
|
|
}
|
|
|
|
main().then(
|
|
() => done(0),
|
|
(err) => done(1, err.message)
|
|
);
|