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 `infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without
deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided, 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`. 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, `./jeannie status` and `./jeannie doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`. 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 `infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without
deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided, 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`. 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, `./{{ main_script }} status` and `./{{ main_script }} doctor-rpi` test Pi-hole DNS,
direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and
whether Docker is currently using the NVMe path or `/var/lib/docker`. whether Docker is currently using the NVMe path or `/var/lib/docker`.

View File

@ -90,6 +90,17 @@ services:
pihole_web_port: 8081 pihole_web_port: 8081
uptime_kuma_port: 3001 uptime_kuma_port: 3001
unbound_container_port: 53 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: traefik:
host: kubernetes host: kubernetes
load_balancer_ip: 192.168.100.240 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` - Pi-hole on DNS port `53` and web port `8081`
- Unbound as Pi-hole's first upstream resolver - Unbound as Pi-hole's first upstream resolver
- Uptime Kuma on port `3001` - 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 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. 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 Managed Pi-hole adlists are stored in `adlists.txt`. The bootstrap inserts them
without deleting existing manual Pi-hole lists. 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 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 install_missing_packages ca-certificates curl iptables python3 rsync
if ! command -v docker >/dev/null 2>&1; then 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 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}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
sudo cp "${SCRIPT_DIR}/adlists.txt" "${INSTALL_DIR}/adlists.txt" 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 write_env_file
stop_legacy_pihole stop_legacy_pihole
@ -231,4 +262,5 @@ cd "${INSTALL_DIR}"
sudo docker compose pull sudo docker compose pull
sudo docker compose up -d --remove-orphans sudo docker compose up -d --remove-orphans
sync_pihole_adlists sync_pihole_adlists
seed_uptime_kuma_monitors
sudo docker compose ps 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 \ scp -i "${rpi_key}" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
"${source_dir}/docker-compose.yml" \ "${source_dir}/docker-compose.yml" \
"${source_dir}/adlists.txt" \ "${source_dir}/adlists.txt" \
"${source_dir}/uptime-kuma-monitors.json" \
"${source_dir}/seed-uptime-kuma.js" \
"${source_dir}/bootstrap.sh" \ "${source_dir}/bootstrap.sh" \
"${rpi_user}@${rpi_host}:/tmp/homelab-rpi-services/" "${rpi_user}@${rpi_host}:/tmp/homelab-rpi-services/"