Seed Uptime Kuma monitors

This commit is contained in:
juvdiaz 2026-06-27 16:43:51 -06:00
parent 7beb0778c9
commit 6070493b1b
8 changed files with 326 additions and 0 deletions

View File

@ -282,6 +282,9 @@ continue resolving if the Unbound container stops. Managed adlists live in
`infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without
deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided,
the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`.
Uptime Kuma monitor seeds live in
`infra/rpi-services/uptime-kuma-monitors.json` and are applied after the first
Kuma user exists.
`./jeannie status` and `./jeannie doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`.

View File

@ -282,6 +282,9 @@ continue resolving if the Unbound container stops. Managed adlists live in
`infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without
deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided,
the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`.
Uptime Kuma monitor seeds live in
`infra/rpi-services/uptime-kuma-monitors.json` and are applied after the first
Kuma user exists.
`./{{ main_script }} status` and `./{{ main_script }} doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`.

View File

@ -90,6 +90,17 @@ services:
pihole_web_port: 8081
uptime_kuma_port: 3001
unbound_container_port: 53
uptime_kuma_seed_file: infra/rpi-services/uptime-kuma-monitors.json
arr:
host: debian
prowlarr_port: 9696
radarr_port: 7878
sonarr_port: 8989
qbittorrent_port: 8080
kapowarr_port: 5656
suwayomi_port: 4567
maintainerr_port: 6246
flaresolverr_port: 8191
traefik:
host: kubernetes
load_balancer_ip: 192.168.100.240

View File

@ -6,6 +6,8 @@ Kubernetes cluster:
- Pi-hole on DNS port `53` and web port `8081`
- Unbound as Pi-hole's first upstream resolver
- Uptime Kuma on port `3001`
- Uptime Kuma monitor seeds for public edge, Gitea, Traefik, RPi DNS, Pi-hole,
and Debian-host ARR services
Pi-hole also keeps public fallback upstreams in its config, so DNS can continue
to resolve through `1.1.1.1` and `9.9.9.9` if the Unbound container stops.
@ -39,3 +41,8 @@ it in `/opt/homelab-rpi-services/.env` for future runs.
Managed Pi-hole adlists are stored in `adlists.txt`. The bootstrap inserts them
without deleting existing manual Pi-hole lists.
Managed Uptime Kuma monitors are stored in `uptime-kuma-monitors.json`. The
bootstrap seeds them after the Uptime Kuma database exists. If Uptime Kuma has
not completed initial admin setup yet, the seed step skips cleanly; rerun
`./jeannie rpi-services` after creating the first user.

View File

@ -207,6 +207,35 @@ PY
sudo docker exec "${pihole_container}" pihole -g
}
seed_uptime_kuma_monitors() {
local uptime_kuma_container="${UPTIME_KUMA_CONTAINER_NAME:-homelab-uptime-kuma}"
local monitor_file="${INSTALL_DIR}/uptime-kuma-monitors.json"
local seed_script="${INSTALL_DIR}/seed-uptime-kuma.js"
local elapsed=0
if [[ ! -s "${monitor_file}" || ! -s "${seed_script}" ]]; then
echo "Uptime Kuma monitor seed files are missing; skipping monitor seed."
return 0
fi
until sudo docker exec "${uptime_kuma_container}" test -s /app/data/kuma.db >/dev/null 2>&1; do
if ((elapsed >= 180)); then
echo "Timed out waiting for Uptime Kuma database; skipping monitor seed." >&2
return 0
fi
sleep 5
elapsed=$((elapsed + 5))
done
sudo docker cp "${monitor_file}" "${uptime_kuma_container}:/tmp/homelab-kuma-monitors.json"
sudo docker cp "${seed_script}" "${uptime_kuma_container}:/tmp/homelab-seed-kuma.js"
if sudo docker exec -w /app "${uptime_kuma_container}" node /tmp/homelab-seed-kuma.js /tmp/homelab-kuma-monitors.json; then
sudo docker restart "${uptime_kuma_container}" >/dev/null
else
echo "Uptime Kuma monitor seed failed; leaving the running service unchanged." >&2
fi
}
install_missing_packages ca-certificates curl iptables python3 rsync
if ! command -v docker >/dev/null 2>&1; then
@ -223,6 +252,8 @@ fi
sudo mkdir -p "${INSTALL_DIR}" "${DATA_DIR}/pihole/etc-pihole" "${DATA_DIR}/pihole/etc-dnsmasq.d" "${DATA_DIR}/uptime-kuma"
sudo cp "${SCRIPT_DIR}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
sudo cp "${SCRIPT_DIR}/adlists.txt" "${INSTALL_DIR}/adlists.txt"
sudo cp "${SCRIPT_DIR}/uptime-kuma-monitors.json" "${INSTALL_DIR}/uptime-kuma-monitors.json"
sudo cp "${SCRIPT_DIR}/seed-uptime-kuma.js" "${INSTALL_DIR}/seed-uptime-kuma.js"
write_env_file
stop_legacy_pihole
@ -231,4 +262,5 @@ cd "${INSTALL_DIR}"
sudo docker compose pull
sudo docker compose up -d --remove-orphans
sync_pihole_adlists
seed_uptime_kuma_monitors
sudo docker compose ps

View File

@ -0,0 +1,174 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
function loadDatabaseModule() {
for (const moduleName of ["better-sqlite3", "/app/node_modules/better-sqlite3"]) {
try {
return require(moduleName);
} catch (_error) {
// Try the next location used by the upstream Uptime Kuma image.
}
}
throw new Error("Could not load better-sqlite3 from the Uptime Kuma container");
}
function nowSql() {
return new Date().toISOString().replace("T", " ").slice(0, 19);
}
function defaultForColumn(column, monitor, userId) {
const accepted = monitor.accepted_statuscodes || ["200-399"];
const defaults = {
name: monitor.name,
type: monitor.type,
active: 1,
user_id: userId,
interval: monitor.interval || 60,
retry_interval: monitor.retry_interval || 60,
resend_interval: monitor.resend_interval || 0,
maxretries: monitor.maxretries || 3,
accepted_statuscodes: JSON.stringify(accepted),
url: monitor.url || "",
method: monitor.method || "GET",
hostname: monitor.hostname || "",
port: monitor.port || null,
dns_resolve_type: monitor.dns_resolve_type || "A",
dns_resolve_server: monitor.dns_resolve_server || "1.1.1.1",
upside_down: 0,
weight: monitor.weight || 2000,
maxredirects: monitor.maxredirects || 10,
timeout: monitor.timeout || 48,
expiryNotification: 0,
ignoreTls: monitor.ignoreTls ? 1 : 0,
created_date: nowSql(),
modified_date: nowSql(),
body: "",
headers: "",
basic_auth_user: "",
basic_auth_pass: "",
authMethod: "",
mqtt_success_message: "",
database_connection_string: "",
database_query: "",
packet_size: 56
};
return Object.prototype.hasOwnProperty.call(defaults, column.name)
? defaults[column.name]
: "";
}
function monitorValues(monitor, columns, userId, includeCreatedDate) {
const values = {};
const supported = new Set(columns.map((column) => column.name));
const requested = {
name: monitor.name,
type: monitor.type,
active: 1,
user_id: userId,
interval: monitor.interval || 60,
retry_interval: monitor.retry_interval || 60,
resend_interval: monitor.resend_interval || 0,
maxretries: monitor.maxretries || 3,
accepted_statuscodes: JSON.stringify(monitor.accepted_statuscodes || ["200-399"]),
method: monitor.method || "GET",
url: monitor.url || "",
hostname: monitor.hostname || "",
port: monitor.port || null,
dns_resolve_type: monitor.dns_resolve_type || "A",
dns_resolve_server: monitor.dns_resolve_server || "1.1.1.1",
maxredirects: monitor.maxredirects || 10,
timeout: monitor.timeout || 48,
ignoreTls: monitor.ignoreTls ? 1 : 0,
modified_date: nowSql()
};
if (includeCreatedDate) {
requested.created_date = nowSql();
}
for (const [key, value] of Object.entries(requested)) {
if (supported.has(key)) {
values[key] = value;
}
}
for (const column of columns) {
if (column.pk || Object.prototype.hasOwnProperty.call(values, column.name)) {
continue;
}
if (column.notnull && column.dflt_value === null) {
values[column.name] = defaultForColumn(column, monitor, userId);
}
}
return values;
}
function upsertMonitor(database, monitor, columns, userId) {
const existing = database
.prepare("select id from monitor where user_id = ? and name = ? limit 1")
.get(userId, monitor.name);
if (existing) {
const values = monitorValues(monitor, columns, userId, false);
delete values.name;
delete values.user_id;
const assignments = Object.keys(values).map((key) => `"${key}" = @${key}`);
database
.prepare(`update monitor set ${assignments.join(", ")} where id = @id`)
.run({ ...values, id: existing.id });
console.log(`updated ${monitor.name}`);
return;
}
const values = monitorValues(monitor, columns, userId, true);
const keys = Object.keys(values);
database
.prepare(
`insert into monitor (${keys.map((key) => `"${key}"`).join(", ")}) values (${keys
.map((key) => `@${key}`)
.join(", ")})`
)
.run(values);
console.log(`created ${monitor.name}`);
}
function main() {
const monitorFile = process.argv[2] || "/tmp/homelab-kuma-monitors.json";
const databasePath = process.argv[3] || "/app/data/kuma.db";
const monitors = JSON.parse(fs.readFileSync(monitorFile, "utf8"));
const Database = loadDatabaseModule();
const database = new Database(databasePath);
const userTable = database
.prepare("select name from sqlite_master where type = 'table' and name = 'user'")
.get();
if (!userTable) {
console.log("Uptime Kuma user table is missing; skipping monitor seed");
return;
}
const user = database.prepare("select id from user order by id limit 1").get();
if (!user) {
console.log("Uptime Kuma initial setup has not been completed; skipping monitor seed");
return;
}
const columns = database.prepare("pragma table_info(monitor)").all();
if (!columns.length) {
throw new Error("Uptime Kuma monitor table is missing");
}
const transaction = database.transaction(() => {
for (const monitor of monitors) {
upsertMonitor(database, monitor, columns, user.id);
}
});
transaction();
}
main();

View File

@ -0,0 +1,94 @@
[
{
"name": "Public Website",
"type": "http",
"url": "https://lab2025.duckdns.org/",
"accepted_statuscodes": ["200-399"]
},
{
"name": "Public Gitea",
"type": "http",
"url": "https://lab2025.duckdns.org/git/",
"accepted_statuscodes": ["200-399", "404"]
},
{
"name": "Traefik LAN",
"type": "http",
"url": "http://192.168.100.240/",
"accepted_statuscodes": ["200-399", "404"]
},
{
"name": "Gitea LAN",
"type": "http",
"url": "http://192.168.100.73:3000/",
"accepted_statuscodes": ["200-399", "404"]
},
{
"name": "Pi-hole Web",
"type": "http",
"url": "http://192.168.100.89:8081/admin/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "RPi DNS TCP 53",
"type": "port",
"hostname": "192.168.100.89",
"port": 53
},
{
"name": "RPi DNS UDP 53",
"type": "dns",
"hostname": "cloudflare.com",
"port": 53,
"dns_resolve_type": "A",
"dns_resolve_server": "192.168.100.89"
},
{
"name": "ARR Prowlarr",
"type": "http",
"url": "http://192.168.100.73:9696/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Radarr",
"type": "http",
"url": "http://192.168.100.73:7878/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Sonarr",
"type": "http",
"url": "http://192.168.100.73:8989/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR qBittorrent",
"type": "http",
"url": "http://192.168.100.73:8080/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Kapowarr",
"type": "http",
"url": "http://192.168.100.73:5656/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Suwayomi",
"type": "http",
"url": "http://192.168.100.73:4567/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR Maintainerr",
"type": "http",
"url": "http://192.168.100.73:6246/",
"accepted_statuscodes": ["200-399", "401", "403"]
},
{
"name": "ARR FlareSolverr",
"type": "http",
"url": "http://192.168.100.73:8191/",
"accepted_statuscodes": ["200-399", "401", "403"]
}
]

View File

@ -2265,6 +2265,8 @@ deploy_rpi_services() {
scp -i "${rpi_key}" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
"${source_dir}/docker-compose.yml" \
"${source_dir}/adlists.txt" \
"${source_dir}/uptime-kuma-monitors.json" \
"${source_dir}/seed-uptime-kuma.js" \
"${source_dir}/bootstrap.sh" \
"${rpi_user}@${rpi_host}:/tmp/homelab-rpi-services/"