Compare commits
26 Commits
38fdfa2faf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 747a0208e7 | |||
| 9ff28a8f78 | |||
| 81f704f248 | |||
| 681eb144ae | |||
| 43e9fd18f7 | |||
| 0ae2e0a622 | |||
| 76346f4e61 | |||
| 85643be2c8 | |||
| ef8856598b | |||
| 65a3b4f1f9 | |||
| 08abbe24ba | |||
| 37faf252dc | |||
| 233122d433 | |||
| cf50764fa2 | |||
| 24525b5cf3 | |||
| c08105f074 | |||
| ad4c607dfb | |||
| ee742976e2 | |||
| 3c909f190b | |||
| 3edbf1284b | |||
| bc991c84c6 | |||
| a2b90e4dfe | |||
| 439e718206 | |||
| 1d5dafe352 | |||
| d8839d6b2e | |||
| b88a2e1c6e |
@@ -1,3 +1,9 @@
|
|||||||
smb/
|
smb/
|
||||||
**/.env
|
**/.env
|
||||||
|
system/samba-private/
|
||||||
|
|
||||||
|
# vim swap/backup files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## What this repo is
|
## What this repo is
|
||||||
|
|
||||||
Git-based provisioning and recovery repo for the "argento" home server (Rocky Linux 9, ZFS, Docker). The repo lives at `/opt/argento/` on the server and contains the actual running configs. This dev copy at `~/dev/argento` is where edits happen, then files are manually copied to the server.
|
Git-based provisioning and recovery repo for the "argento" home server (Rocky Linux 9, ZFS, Docker). The repo lives at `/opt/argento/` on the server and contains the actual running configs. This dev copy at `~/dev/argento` is where edits happen, then synced to the server via git push/pull (Timothy runs the git commands).
|
||||||
|
|
||||||
Recovery steps are in `RUNBOOK.md`. Repo overview and workflows are in `README.md`.
|
Recovery steps are in `RUNBOOK.md`. Repo overview and workflows are in `README.md`.
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ Recovery steps are in `RUNBOOK.md`. Repo overview and workflows are in `README.m
|
|||||||
- **ZFS parent dataset mountpoints**: Pools have parent datasets (e.g., `nextcloud`, `threeteras`) that create mountpoints like `/mnt/nextcloud`. These are empty and expected — do not delete them.
|
- **ZFS parent dataset mountpoints**: Pools have parent datasets (e.g., `nextcloud`, `threeteras`) that create mountpoints like `/mnt/nextcloud`. These are empty and expected — do not delete them.
|
||||||
- **msmtp**: Replaces sendmail for cron MAILTO and smartd email alerts. Configured via `/etc/msmtprc` (Fastmail SMTP). The symlink `/usr/sbin/sendmail -> /usr/bin/msmtp` is set up in recovery step 2.
|
- **msmtp**: Replaces sendmail for cron MAILTO and smartd email alerts. Configured via `/etc/msmtprc` (Fastmail SMTP). The symlink `/usr/sbin/sendmail -> /usr/bin/msmtp` is set up in recovery step 2.
|
||||||
- **Beszel agent**: System monitoring agent that reports to the beszel hub on hantim (`beszel.hantim.net`). Uses host network, listens on port 45876. No nginx proxy — it's not a web app.
|
- **Beszel agent**: System monitoring agent that reports to the beszel hub on hantim (`beszel.hantim.net`). Uses host network, listens on port 45876. No nginx proxy — it's not a web app.
|
||||||
|
- **Diun**: Watches all running containers daily at 06:00 for new image digests, emails via Fastmail SMTP (credentials in `docker/diun/.env`). State DB in `docker/diun/data/` tracks seen digests; `firstCheckNotif: false` suppresses the flood on first run after a restore.
|
||||||
- **hantim-server**: Sister repo with similar nginx/certbot patterns. Located at `~/dev/hantim-server`. Useful as reference for deploy patterns.
|
- **hantim-server**: Sister repo with similar nginx/certbot patterns. Located at `~/dev/hantim-server`. Useful as reference for deploy patterns.
|
||||||
|
|
||||||
## Remotes
|
## Remotes
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ docker/ Docker app configs (compose files, nginx confs)
|
|||||||
minecraft/ MCSManager
|
minecraft/ MCSManager
|
||||||
immich/ Immich photo server + PostgreSQL
|
immich/ Immich photo server + PostgreSQL
|
||||||
beszel-agent/ System monitoring agent (reports to beszel.hantim.net)
|
beszel-agent/ System monitoring agent (reports to beszel.hantim.net)
|
||||||
|
diun/ Daily image-digest watcher (emails when updates available)
|
||||||
smb/ Samba share (ZFS mountpoint, not in git)
|
smb/ Samba share (ZFS mountpoint, not in git)
|
||||||
system/ System config snapshots (smb.conf, sanoid.conf, firewalld, etc.)
|
system/ System config snapshots (smb.conf, sanoid.conf, firewalld, etc.)
|
||||||
```
|
```
|
||||||
@@ -31,20 +32,19 @@ system/ System config snapshots (smb.conf, sanoid.conf, firewalld, et
|
|||||||
| `RUNBOOK.md` | Full recovery steps, top to bottom |
|
| `RUNBOOK.md` | Full recovery steps, top to bottom |
|
||||||
| `scripts/backup.sh` | Daily USB backup (DB dumps + rsync) |
|
| `scripts/backup.sh` | Daily USB backup (DB dumps + rsync) |
|
||||||
| `scripts/issue-cert.sh` | Issue SSL certs via certbot webroot |
|
| `scripts/issue-cert.sh` | Issue SSL certs via certbot webroot |
|
||||||
| `scripts/disk-health-check.sh` | SMART, ZFS, disk space monitoring (daily alerts + weekly reports via cron) |
|
| `scripts/disk-health-check.sh` | SMART, ZFS, quota, disk space monitoring (daily alerts + weekly reports via cron) |
|
||||||
| `scripts/sync-system.sh` | Auto-sync system configs to git |
|
| `scripts/sync-system.sh` | Auto-sync system configs to git |
|
||||||
| `system/tracked-configs` | Maps system config paths to repo paths |
|
| `system/tracked-configs` | Maps system config paths to repo paths |
|
||||||
| `docker/nginx/conf.d/_template.conf.example` | Template for new nginx app confs |
|
| `docker/nginx/conf.d/_template.conf.example` | Template for new nginx app confs |
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
**Compose files** -- edit in `/opt/argento/`, apply, commit:
|
**Compose files** -- edit in `/opt/argento/`, apply:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/argento/docker/<app>
|
cd /opt/argento/docker/<app>
|
||||||
vim compose.yaml
|
vim compose.yaml
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
git add . && git commit && git push
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**System configs** -- edit in place, auto-synced daily:
|
**System configs** -- edit in place, auto-synced daily:
|
||||||
@@ -61,9 +61,25 @@ systemctl restart smb
|
|||||||
vim /opt/argento/docker/nginx/conf.d/<domain>.conf
|
vim /opt/argento/docker/nginx/conf.d/<domain>.conf
|
||||||
docker exec nginx nginx -t
|
docker exec nginx nginx -t
|
||||||
docker exec nginx nginx -s reload
|
docker exec nginx nginx -s reload
|
||||||
git add . && git commit && git push
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Updating an app (Diun notification received):**
|
||||||
|
|
||||||
|
Diun emails name the image (e.g. `postgres:17-alpine`), not the container. Find it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --filter "ancestor=<image>:<tag>" --format '{{.Names}}'
|
||||||
|
```
|
||||||
|
Then update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/argento/docker/<app>
|
||||||
|
docker compose pull --ignore-buildable
|
||||||
|
docker compose build --pull # if repo contains dockerfile
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
Postgres major version bumps (e.g. 17 -> 18) need extra steps -- see [RUNBOOK.md](RUNBOOK.md#maintenance-postgres-major-version-upgrade).
|
||||||
|
|
||||||
**Adding a new app:**
|
**Adding a new app:**
|
||||||
|
|
||||||
1. Create `docker/<app>/compose.yaml` with `container_name` and `shared` network
|
1. Create `docker/<app>/compose.yaml` with `container_name` and `shared` network
|
||||||
@@ -71,7 +87,6 @@ git add . && git commit && git push
|
|||||||
3. `./scripts/issue-cert.sh <domain>`
|
3. `./scripts/issue-cert.sh <domain>`
|
||||||
4. `cp docker/nginx/conf.d/_template.conf.example docker/nginx/conf.d/<domain>.conf` and fill in placeholders
|
4. `cp docker/nginx/conf.d/_template.conf.example docker/nginx/conf.d/<domain>.conf` and fill in placeholders
|
||||||
5. `docker exec nginx nginx -t && docker exec nginx nginx -s reload`
|
5. `docker exec nginx nginx -t && docker exec nginx nginx -s reload`
|
||||||
6. Commit and push
|
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
@@ -79,15 +94,15 @@ See [RUNBOOK.md](RUNBOOK.md) for full disaster recovery steps.
|
|||||||
|
|
||||||
## What's protected where
|
## What's protected where
|
||||||
|
|
||||||
| Data | Protection | Recovery |
|
| Data | Protection |
|
||||||
|------|-----------|----------|
|
|------|-----------|
|
||||||
| Configs (compose, nginx, system) | Git (Gitea bare repo on USB backup) | `git clone` from USB |
|
| Configs (compose, nginx, system) | Git (Gitea bare repo on USB backup) |
|
||||||
| Nextcloud files | ZFS mirror + sanoid snapshots + USB | `rsync` from USB |
|
| Nextcloud files | ZFS mirror + sanoid snapshots + USB |
|
||||||
| Media library | ZFS raidz2 + sanoid snapshots + USB | `rsync` from USB |
|
| Media library | ZFS raidz2 + sanoid snapshots + USB |
|
||||||
| Nextcloud DB (PostgreSQL) | ZFS dataset + `pg_dumpall` + USB | `rsync` from USB |
|
| Nextcloud DB (PostgreSQL) | ZFS dataset + `pg_dumpall` + USB |
|
||||||
| Immich library (photos/videos) | ZFS raidz2 + sanoid snapshots + USB | `rsync` from USB |
|
| Immich library (photos/videos) | ZFS raidz2 + sanoid snapshots + USB |
|
||||||
| Immich DB (PostgreSQL) | `pg_dumpall` + USB | `rsync` from USB |
|
| Immich DB (PostgreSQL) | `pg_dumpall` + USB |
|
||||||
| Garage S3 data | 2-node replication + USB | Replication or USB |
|
| Garage S3 data | 2-node replication + USB |
|
||||||
| Gitea repos + DB | SQLite `.backup` + USB | `rsync` from USB |
|
| Gitea repos + DB | SQLite `.backup` + USB |
|
||||||
| Secrets (.env files) | USB backup + Bitwarden | `rsync` from USB, or recreate from Bitwarden |
|
| Secrets (.env files) | USB backup + Bitwarden |
|
||||||
| SSL certificates | Re-issued from Let's Encrypt | `./scripts/issue-cert.sh` |
|
| SSL certificates | Re-issued from Let's Encrypt |
|
||||||
|
|||||||
+117
-7
@@ -75,7 +75,7 @@ git clone /mnt/backup/argento/docker/gitea/data/git/repositories/timothykim/arge
|
|||||||
|
|
||||||
Clone first -- ZFS datasets will mount into this directory tree in the next step.
|
Clone first -- ZFS datasets will mount into this directory tree in the next step.
|
||||||
|
|
||||||
## 7. ZFS pools + data restore
|
## 7. ZFS pools
|
||||||
|
|
||||||
**If ZFS disks are intact (OS drive failure only)** -- the common case:
|
**If ZFS disks are intact (OS drive failure only)** -- the common case:
|
||||||
|
|
||||||
@@ -88,15 +88,12 @@ zpool import threeteras
|
|||||||
# Verify mountpoints (update if the old /opt/docker/ paths are still set):
|
# Verify mountpoints (update if the old /opt/docker/ paths are still set):
|
||||||
zfs set mountpoint=/opt/argento/docker/nextcloud/data nextcloud/data
|
zfs set mountpoint=/opt/argento/docker/nextcloud/data nextcloud/data
|
||||||
zfs set mountpoint=/opt/argento/smb/media threeteras/media
|
zfs set mountpoint=/opt/argento/smb/media threeteras/media
|
||||||
|
zfs set mountpoint=/opt/argento/smb/yireh threeteras/yireh
|
||||||
|
zfs set mountpoint=/opt/argento/smb/isaac threeteras/isaac
|
||||||
zfs set mountpoint=/opt/argento/docker/garage/data threeteras/garage
|
zfs set mountpoint=/opt/argento/docker/garage/data threeteras/garage
|
||||||
zfs set mountpoint=/opt/argento/docker/immich/library threeteras/immich
|
zfs set mountpoint=/opt/argento/docker/immich/library threeteras/immich
|
||||||
|
|
||||||
zpool status # verify pools are ONLINE
|
zpool status # verify pools are ONLINE
|
||||||
|
|
||||||
# Restore non-ZFS app data (Gitea, Minecraft, Jellyfin config) from USB:
|
|
||||||
rsync -a --exclude='docker/nextcloud/data/' --exclude='docker/garage/data/' \
|
|
||||||
--exclude='docker/immich/library/' --exclude='smb/' \
|
|
||||||
/mnt/backup/argento/ /opt/argento/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**If ZFS disks are lost (full rebuild):**
|
**If ZFS disks are lost (full rebuild):**
|
||||||
@@ -116,12 +113,75 @@ zfs create -o mountpoint=/opt/argento/docker/nextcloud/data nextcloud/data
|
|||||||
zpool create threeteras raidz2 /dev/disk/by-id/<disk3> ... /dev/disk/by-id/<disk8>
|
zpool create threeteras raidz2 /dev/disk/by-id/<disk3> ... /dev/disk/by-id/<disk8>
|
||||||
zfs set compression=lz4 threeteras
|
zfs set compression=lz4 threeteras
|
||||||
zfs create -o mountpoint=/opt/argento/smb/media threeteras/media
|
zfs create -o mountpoint=/opt/argento/smb/media threeteras/media
|
||||||
|
zfs create -o mountpoint=/opt/argento/smb/yireh -o quota=1T threeteras/yireh
|
||||||
|
zfs create -o mountpoint=/opt/argento/smb/isaac -o quota=1T threeteras/isaac
|
||||||
zfs create -o mountpoint=/opt/argento/docker/garage/data threeteras/garage
|
zfs create -o mountpoint=/opt/argento/docker/garage/data threeteras/garage
|
||||||
zfs create -o mountpoint=/opt/argento/docker/immich/library threeteras/immich
|
zfs create -o mountpoint=/opt/argento/docker/immich/library threeteras/immich
|
||||||
|
|
||||||
zpool status # verify pools are ONLINE
|
zpool status # verify pools are ONLINE
|
||||||
|
|
||||||
# Restore everything from USB (ZFS mountpoints are set, data lands in the right place):
|
# Per-user SMB share dirs need ownership + setgid (rsync in step 7c will
|
||||||
|
# preserve these if a USB backup exists, but freshly-created datasets are
|
||||||
|
# root-owned and need this fixup).
|
||||||
|
for u in yireh isaac; do
|
||||||
|
chown "$u":sambagroup "/opt/argento/smb/$u"
|
||||||
|
chmod 2770 "/opt/argento/smb/$u"
|
||||||
|
done
|
||||||
|
|
||||||
|
# SELinux contexts for samba — fresh ZFS datasets get unlabeled_t and samba
|
||||||
|
# refuses to serve them. Persistent rules (semanage) survive full relabels;
|
||||||
|
# restorecon applies them now.
|
||||||
|
semanage fcontext -a -t samba_share_t "/opt/argento/smb/media(/.*)?"
|
||||||
|
semanage fcontext -a -t samba_share_t "/opt/argento/smb/yireh(/.*)?"
|
||||||
|
semanage fcontext -a -t samba_share_t "/opt/argento/smb/isaac(/.*)?"
|
||||||
|
restorecon -Rv /opt/argento/smb/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7b. Restore users
|
||||||
|
|
||||||
|
System users and the samba passdb are not part of the OS install or the
|
||||||
|
`/opt/argento/` rsync. Recreate them now, BEFORE the data rsync in step 7c,
|
||||||
|
so file ownership lands on the correct UIDs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/argento/scripts/restore-users.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This recreates timothykim, yireh, isaac, and the sambagroup group with
|
||||||
|
their original UIDs/GIDs, then restores the samba password hashes from
|
||||||
|
`/mnt/backup/argento/system/samba-private/` if present (mirrored there
|
||||||
|
by sync-system.sh, gitignored so the hashes aren't in version control).
|
||||||
|
|
||||||
|
If the samba passdb backup is missing, set passwords manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for u in timothykim yireh isaac; do
|
||||||
|
smbpasswd -a "$u"
|
||||||
|
smbpasswd -e "$u"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Set timothykim's Linux login password (not in any backup):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
passwd timothykim
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7c. Restore data from USB
|
||||||
|
|
||||||
|
**If ZFS disks were intact:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Skip the big ZFS-backed dirs — that data is already on disk
|
||||||
|
rsync -a --exclude='docker/nextcloud/data/' --exclude='docker/garage/data/' \
|
||||||
|
--exclude='docker/immich/library/' --exclude='smb/' \
|
||||||
|
/mnt/backup/argento/ /opt/argento/
|
||||||
|
```
|
||||||
|
|
||||||
|
**If ZFS was rebuilt from scratch:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore everything (ZFS mountpoints are set, data lands in the right place)
|
||||||
rsync -a /mnt/backup/argento/ /opt/argento/
|
rsync -a /mnt/backup/argento/ /opt/argento/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -246,3 +306,53 @@ curl -I http://minecraft.thekims.family
|
|||||||
# Verify SMART, ZFS, and disk space are healthy
|
# Verify SMART, ZFS, and disk space are healthy
|
||||||
/opt/argento/scripts/disk-health-check.sh
|
/opt/argento/scripts/disk-health-check.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance: Postgres major version upgrade
|
||||||
|
|
||||||
|
A postgres major bump (e.g. 17 -> 18) cannot reuse the old data dir -- the
|
||||||
|
on-disk format changes. Re-init and reload from `pg_dumpall`. `backup.sh`
|
||||||
|
already produces a daily dump for both Nextcloud and Immich.
|
||||||
|
|
||||||
|
Procedure (Nextcloud; Immich notes at the end):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/argento/docker/nextcloud
|
||||||
|
|
||||||
|
# 1. Force a fresh dump so it reflects current state, not yesterday's.
|
||||||
|
/opt/argento/scripts/backup.sh
|
||||||
|
|
||||||
|
# 2. Stop the stack. Nothing will write to the DB after this.
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 3. Move the dump out of the data dir before nuking it.
|
||||||
|
cp db/pg_dumpall.sql /tmp/nc-pg_dumpall.sql
|
||||||
|
mv db db.old
|
||||||
|
|
||||||
|
# 4. Bump the postgres image tag in compose.yaml (e.g. 17-alpine -> 18-alpine).
|
||||||
|
|
||||||
|
# 5. Start only postgres so it initializes a fresh data dir, then wait for it.
|
||||||
|
docker compose up -d postgres
|
||||||
|
docker compose logs -f postgres # wait for "ready to accept connections", Ctrl-C
|
||||||
|
|
||||||
|
# 6. Restore. Connect to the default 'postgres' DB so the dump can recreate
|
||||||
|
# the 'nextcloud' DB itself.
|
||||||
|
docker exec -i nextcloud-db psql -U nextcloud -d postgres < /tmp/nc-pg_dumpall.sql
|
||||||
|
|
||||||
|
# 7. Bring up the rest of the stack and verify the app end-to-end.
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 8. Once verified, remove the old data dir and the temp dump.
|
||||||
|
rm -rf db.old /tmp/nc-pg_dumpall.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Immich differences:**
|
||||||
|
|
||||||
|
- Data dir is `${DB_DATA_LOCATION}` from `.env` (not under `docker/immich/`),
|
||||||
|
so step 3 is just `mv "$DB_DATA_LOCATION" "$DB_DATA_LOCATION.old"` -- the
|
||||||
|
dump at `docker/immich/db/pg_dumpall.sql` is in a separate dir and stays put.
|
||||||
|
- DB container is `immich_postgres`, user is `${DB_USERNAME}` from `.env`.
|
||||||
|
- Immich pins to a specific postgres image (`ghcr.io/immich-app/postgres`)
|
||||||
|
with a digest -- check Immich release notes before bumping; they coordinate
|
||||||
|
the upgrade, you usually just follow their version.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
data/
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: diun
|
||||||
|
|
||||||
|
services:
|
||||||
|
diun:
|
||||||
|
image: crazymax/diun:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: diun
|
||||||
|
command: serve
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- "./data:/data"
|
||||||
|
- "./diun.yml:/diun.yml:ro"
|
||||||
|
- "./diun.list.yml:/diun.list.yml:ro"
|
||||||
|
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||||
|
environment:
|
||||||
|
- "TZ=America/New_York"
|
||||||
|
labels:
|
||||||
|
- "diun.enable=true"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Base images for locally-built containers. The docker provider doesn't see
|
||||||
|
# these because the built image has a local-only tag. ctn_names metadata is set
|
||||||
|
# so the email subject still points at the compose dir that needs rebuilding.
|
||||||
|
- name: nextcloud:latest
|
||||||
|
metadata:
|
||||||
|
ctn_names: nextcloud-base
|
||||||
|
- name: githubyumao/mcsmanager-daemon:latest
|
||||||
|
metadata:
|
||||||
|
ctn_names: mcsmanager-daemon-base
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
watch:
|
||||||
|
workers: 20
|
||||||
|
schedule: "0 0 6 * * *" # daily at 06:00; six-field cron (sec min hr dom mon dow)
|
||||||
|
firstCheckNotif: false # don't email on first run for every image
|
||||||
|
jitter: 30s
|
||||||
|
|
||||||
|
providers:
|
||||||
|
docker:
|
||||||
|
watchByDefault: true # watch every running container without needing labels
|
||||||
|
watchStopped: false
|
||||||
|
file:
|
||||||
|
# Explicit watches for base images of locally-built containers (apps with a
|
||||||
|
# Dockerfile in their compose dir). The docker provider can't see these
|
||||||
|
# because the built image has a local-only tag with no registry digest.
|
||||||
|
filename: /diun.list.yml
|
||||||
|
|
||||||
|
notif:
|
||||||
|
mail:
|
||||||
|
host: smtp.fastmail.com
|
||||||
|
port: 465
|
||||||
|
ssl: true
|
||||||
|
insecureSkipVerify: false
|
||||||
|
username: timothykim@fastmail.fm
|
||||||
|
from: argento@fastmail.com
|
||||||
|
to:
|
||||||
|
- timothykim@fastmail.fm
|
||||||
|
# Subject leads with container name so the compose dir is obvious from the inbox
|
||||||
|
# (e.g. "nextcloud-redis" -> docker/nextcloud/). Default template uses the full
|
||||||
|
# canonical ref with @sha256 digest, which is unreadable.
|
||||||
|
templateTitle: '[{{ .Entry.Status }}] {{ .Entry.Metadata.ctn_names }} ({{ .Entry.Image.Path }}:{{ .Entry.Image.Tag }})'
|
||||||
|
templateBody: |
|
||||||
|
Container: {{ .Entry.Metadata.ctn_names }}
|
||||||
|
Image: {{ .Entry.Image.Path }}:{{ .Entry.Image.Tag }}
|
||||||
|
{{ if .Entry.Image.HubLink }}Link: {{ .Entry.Image.HubLink }}{{ end }}
|
||||||
|
Host: {{ .Meta.Hostname }}
|
||||||
|
|
||||||
@@ -17,3 +17,5 @@ bind_addr = "[::]:3902"
|
|||||||
root_domain = ".web.garage"
|
root_domain = ".web.garage"
|
||||||
index = "index.html"
|
index = "index.html"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
library/
|
library/
|
||||||
postgres/
|
postgres/
|
||||||
|
db/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
FROM githubyumao/mcsmanager-daemon:latest
|
FROM githubyumao/mcsmanager-daemon:latest
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y wget apt-transport-https && \
|
apt-get install -y wget apt-transport-https gpg && \
|
||||||
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | apt-key add - && \
|
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg && \
|
||||||
echo "deb https://packages.adoptium.net/artifactory/deb noble main" > /etc/apt/sources.list.d/adoptium.list && \
|
echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb noble main" > /etc/apt/sources.list.d/adoptium.list && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -y temurin-25-jre && \
|
apt-get install -y temurin-25-jre && \
|
||||||
apt-get clean
|
apt-get clean
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ server {
|
|||||||
client_max_body_size 20G;
|
client_max_body_size 20G;
|
||||||
# Stream uploads directly to Nextcloud instead of buffering to disk first
|
# Stream uploads directly to Nextcloud instead of buffering to disk first
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
set $upstream_nextcloud http://nextcloud:80;
|
set $upstream_nextcloud http://nextcloud:80;
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -40,7 +40,9 @@ for db in jellyfin library; do
|
|||||||
|| FAILURES+=("jellyfin ${db} sqlite backup")
|
|| FAILURES+=("jellyfin ${db} sqlite backup")
|
||||||
done
|
done
|
||||||
|
|
||||||
# Single rsync covers everything: configs, app data, media, DB dumps
|
# Single rsync covers everything: configs, app data, media, DB dumps,
|
||||||
|
# and the samba passdb (mirrored into /opt/argento/system/samba-private/
|
||||||
|
# by sync-system.sh, gitignored)
|
||||||
RSYNC_OPTS="-a --delete"
|
RSYNC_OPTS="-a --delete"
|
||||||
[ -t 1 ] && RSYNC_OPTS="$RSYNC_OPTS --info=progress2"
|
[ -t 1 ] && RSYNC_OPTS="$RSYNC_OPTS --info=progress2"
|
||||||
rsync $RSYNC_OPTS /opt/argento/ /mnt/backup/argento/ \
|
rsync $RSYNC_OPTS /opt/argento/ /mnt/backup/argento/ \
|
||||||
|
|||||||
+205
-27
@@ -1,20 +1,18 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# server-health-check.sh — Argento health monitoring
|
# disk-health-check.sh — argento health monitoring
|
||||||
# Checks: SMART disk health, ZFS pool status, disk space
|
# Checks SMART health (SATA and NVMe), ZFS pool state and error counters,
|
||||||
# Outputs to stdout by default, or sends email with --email.
|
# ZFS dataset quotas, and non-ZFS disk space.
|
||||||
# Use --error-only to suppress output when everything is healthy.
|
#
|
||||||
|
# Output is plain text to stdout (ANSI-colored when stdout is a TTY),
|
||||||
|
# or an HTML email to $MAILTO with --email.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./server-health-check.sh # full report to stdout
|
# disk-health-check.sh # full report to stdout
|
||||||
# ./server-health-check.sh --error-only # only print errors (silent if healthy)
|
# disk-health-check.sh --error-only # problems only; silent if healthy
|
||||||
# ./server-health-check.sh --email # full report to email
|
# disk-health-check.sh --email # full report by email
|
||||||
# ./server-health-check.sh --error-only --email # email errors (no email if healthy)
|
# disk-health-check.sh --error-only --email # email only if problems found
|
||||||
#
|
#
|
||||||
# Install:
|
# Cron on the server runs --error-only --email daily and --email weekly.
|
||||||
# chmod +x /opt/server-health-check.sh
|
|
||||||
# crontab -e:
|
|
||||||
# 0 6 * * * /opt/server-health-check.sh --error-only --email # daily alert
|
|
||||||
# 0 8 * * 1 /opt/server-health-check.sh --email # weekly report
|
|
||||||
#
|
#
|
||||||
# Dependencies: smartmontools, zfs, msmtp (as sendmail)
|
# Dependencies: smartmontools, zfs, msmtp (as sendmail)
|
||||||
|
|
||||||
@@ -23,6 +21,8 @@ set -uo pipefail
|
|||||||
MAILTO="timothykim@fastmail.fm"
|
MAILTO="timothykim@fastmail.fm"
|
||||||
HOSTNAME=$(hostname)
|
HOSTNAME=$(hostname)
|
||||||
SPACE_THRESHOLD=85 # percent used — alert above this
|
SPACE_THRESHOLD=85 # percent used — alert above this
|
||||||
|
ZFS_CAP_THRESHOLD=80 # ZFS write performance degrades on nearly-full pools
|
||||||
|
QUOTA_THRESHOLD=85 # percent of dataset quota — alert above this
|
||||||
|
|
||||||
ERROR_ONLY=false
|
ERROR_ONLY=false
|
||||||
USE_EMAIL=false
|
USE_EMAIL=false
|
||||||
@@ -37,8 +37,97 @@ done
|
|||||||
PROBLEMS=""
|
PROBLEMS=""
|
||||||
STATUS=""
|
STATUS=""
|
||||||
|
|
||||||
|
# HTML table rows for the email body, built alongside the plain-text STATUS.
|
||||||
|
# Styles are inlined on every element because most mail clients strip
|
||||||
|
# <style> blocks. nowrap keeps device paths and sizes from wrapping.
|
||||||
|
TH='style="padding:2px 10px;text-align:left;border-bottom:1px solid #ccc;white-space:nowrap"'
|
||||||
|
TD='style="padding:2px 10px;text-align:left;white-space:nowrap"'
|
||||||
|
TDR='style="padding:2px 10px;text-align:right;white-space:nowrap"'
|
||||||
|
TABLE='style="border-collapse:collapse;margin:4px 0 16px"'
|
||||||
|
HTML_SATA=""
|
||||||
|
HTML_NVME=""
|
||||||
|
HTML_ZFS=""
|
||||||
|
HTML_SPACE=""
|
||||||
|
|
||||||
|
# Map a usage percentage to a hex color: green at 0%, amber at 50%,
|
||||||
|
# red at 100%, linearly interpolated per RGB channel. Endpoints are the
|
||||||
|
# same dark shades used by state_html so they stay readable on white.
|
||||||
|
# Done in bash because email clients can't compute colors in CSS.
|
||||||
|
usage_color() {
|
||||||
|
local p=$1 r g b
|
||||||
|
[ "$p" -ge 0 ] 2>/dev/null || { printf '#222222'; return; }
|
||||||
|
[ "$p" -gt 100 ] && p=100
|
||||||
|
if [ "$p" -le 50 ]; then
|
||||||
|
# 1a7f37 (green) -> 9a6700 (amber)
|
||||||
|
r=$(( 26 + (154 - 26) * p / 50 ))
|
||||||
|
g=$(( 127 + (103 - 127) * p / 50 ))
|
||||||
|
b=$(( 55 + (0 - 55) * p / 50 ))
|
||||||
|
else
|
||||||
|
# 9a6700 (amber) -> cf222e (red)
|
||||||
|
r=$(( 154 + (207 - 154) * (p - 50) / 50 ))
|
||||||
|
g=$(( 103 + (34 - 103) * (p - 50) / 50 ))
|
||||||
|
b=$(( 0 + (46 - 0) * (p - 50) / 50 ))
|
||||||
|
fi
|
||||||
|
printf '#%02x%02x%02x' "$r" "$g" "$b"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Green for healthy, red for anything else. UNKNOWN is amber because it
|
||||||
|
# means smartctl couldn't read the device, not that the device failed.
|
||||||
|
state_html() {
|
||||||
|
case "$1" in
|
||||||
|
PASSED|ONLINE) printf '<span style="color:#1a7f37;font-weight:bold">%s</span>' "$1" ;;
|
||||||
|
UNKNOWN) printf '<span style="color:#9a6700;font-weight:bold">%s</span>' "$1" ;;
|
||||||
|
*) printf '<span style="color:#cf222e;font-weight:bold">%s</span>' "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Terminal colors (same palette as the HTML), only when stdout is a TTY
|
||||||
|
# so piped/redirected output stays clean.
|
||||||
|
COLOR=false
|
||||||
|
C_BOLD=""
|
||||||
|
C_RED=""
|
||||||
|
C_RESET=""
|
||||||
|
C_DIM=""
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
COLOR=true
|
||||||
|
C_BOLD=$'\e[1m'
|
||||||
|
C_DIM=$'\e[2m'
|
||||||
|
C_RED=$'\e[1;38;2;207;34;46m'
|
||||||
|
C_RESET=$'\e[0m'
|
||||||
|
fi
|
||||||
|
|
||||||
|
state_txt() {
|
||||||
|
if [ "$COLOR" = true ]; then
|
||||||
|
local c
|
||||||
|
case "$1" in
|
||||||
|
PASSED|ONLINE) c=$'\e[1;38;2;26;127;55m' ;;
|
||||||
|
UNKNOWN) c=$'\e[1;38;2;154;103;0m' ;;
|
||||||
|
*) c=$'\e[1;38;2;207;34;46m' ;;
|
||||||
|
esac
|
||||||
|
printf '%s%s%s' "$c" "$1" "$C_RESET"
|
||||||
|
else
|
||||||
|
printf '%s' "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# $1 = percentage (drives the gradient), $2 = text to print. Callers pad
|
||||||
|
# $2 to its column width BEFORE calling — ANSI codes are invisible but
|
||||||
|
# still count as characters, so padding after coloring breaks alignment.
|
||||||
|
usage_txt() {
|
||||||
|
if [ "$COLOR" = true ]; then
|
||||||
|
local hex
|
||||||
|
hex=$(usage_color "$1")
|
||||||
|
printf '\e[38;2;%d;%d;%dm%s%s' \
|
||||||
|
"$((16#${hex:1:2}))" "$((16#${hex:3:2}))" "$((16#${hex:5:2}))" \
|
||||||
|
"$2" "$C_RESET"
|
||||||
|
else
|
||||||
|
printf '%s' "$2"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ─── SMART Disk Health (SATA/SAS) ───────────────────────────────
|
# ─── SMART Disk Health (SATA/SAS) ───────────────────────────────
|
||||||
STATUS+="SMART (SATA):\n"
|
STATUS+="${C_BOLD}SMART (SATA):${C_RESET}\n"
|
||||||
|
STATUS+="${C_DIM}$(printf ' %-13s %-24s %5s %7s %s' Disk Model Temp Hours Health)${C_RESET}\n"
|
||||||
for disk in /dev/sd?; do
|
for disk in /dev/sd?; do
|
||||||
[ -b "$disk" ] || continue
|
[ -b "$disk" ] || continue
|
||||||
|
|
||||||
@@ -50,11 +139,15 @@ for disk in /dev/sd?; do
|
|||||||
temp=$(smartctl -A "$disk" 2>/dev/null | awk '$1 == 194 || $1 == 190 {print $10; exit}') || true
|
temp=$(smartctl -A "$disk" 2>/dev/null | awk '$1 == 194 || $1 == 190 {print $10; exit}') || true
|
||||||
temp="${temp:-?}"
|
temp="${temp:-?}"
|
||||||
hours=$(smartctl -A "$disk" 2>/dev/null | awk '$1 == 9 {print $10; exit}') || true
|
hours=$(smartctl -A "$disk" 2>/dev/null | awk '$1 == 9 {print $10; exit}') || true
|
||||||
|
# Some Seagates report attribute 9 raw as "11170h+42m+51.807s" —
|
||||||
|
# keep only the whole hours.
|
||||||
|
hours=${hours%%[^0-9]*}
|
||||||
hours="${hours:-?}"
|
hours="${hours:-?}"
|
||||||
model=$(smartctl -i "$disk" 2>/dev/null | awk -F': +' '/Device Model|Model Number/{print $2; exit}') || true
|
model=$(smartctl -i "$disk" 2>/dev/null | awk -F': +' '/Device Model|Model Number/{print $2; exit}') || true
|
||||||
model="${model:-unknown}"
|
model="${model:-unknown}"
|
||||||
|
|
||||||
STATUS+=" $disk ($model): $health, ${temp}C, ${hours}h\n"
|
STATUS+=$(printf ' %-13s %-24s %5s %7s ' "$disk" "$model" "${temp}C" "${hours}h")$(state_txt "$health")"\n"
|
||||||
|
HTML_SATA+="<tr><td $TD>$disk</td><td $TD>$model</td><td $TD>$(state_html "$health")</td><td $TDR>${temp}C</td><td $TDR>${hours}h</td></tr>"$'\n'
|
||||||
|
|
||||||
if [ "$health" != "PASSED" ] && [ "$health" != "UNKNOWN" ]; then
|
if [ "$health" != "PASSED" ] && [ "$health" != "UNKNOWN" ]; then
|
||||||
PROBLEMS+="[SMART] $disk: overall health check FAILED ($health)\n"
|
PROBLEMS+="[SMART] $disk: overall health check FAILED ($health)\n"
|
||||||
@@ -80,7 +173,8 @@ for disk in /dev/sd?; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# ─── SMART Disk Health (NVMe) ───────────────────────────────────
|
# ─── SMART Disk Health (NVMe) ───────────────────────────────────
|
||||||
STATUS+="\nSMART (NVMe):\n"
|
STATUS+="\n${C_BOLD}SMART (NVMe):${C_RESET}\n"
|
||||||
|
STATUS+="${C_DIM}$(printf ' %-13s %-24s %5s %7s %s' Disk Model Temp Hours Health)${C_RESET}\n"
|
||||||
for disk in /dev/nvme[0-9]*; do
|
for disk in /dev/nvme[0-9]*; do
|
||||||
# Match only controller devices (nvme0, nvme1), not namespaces (nvme0n1)
|
# Match only controller devices (nvme0, nvme1), not namespaces (nvme0n1)
|
||||||
[[ "$disk" =~ ^/dev/nvme[0-9]+$ ]] || continue
|
[[ "$disk" =~ ^/dev/nvme[0-9]+$ ]] || continue
|
||||||
@@ -93,14 +187,18 @@ for disk in /dev/nvme[0-9]*; do
|
|||||||
health="${health:-UNKNOWN}"
|
health="${health:-UNKNOWN}"
|
||||||
model=$(smartctl -i "$ns" 2>/dev/null | awk -F': +' '/Model Number/{print $2; exit}') || true
|
model=$(smartctl -i "$ns" 2>/dev/null | awk -F': +' '/Model Number/{print $2; exit}') || true
|
||||||
model="${model:-unknown}"
|
model="${model:-unknown}"
|
||||||
|
# smartctl prints "38 Celsius" — keep just the number to match the SATA column
|
||||||
temp=$(smartctl -A "$ns" 2>/dev/null | awk -F': +' '/Temperature:/{print $2; exit}') || true
|
temp=$(smartctl -A "$ns" 2>/dev/null | awk -F': +' '/Temperature:/{print $2; exit}') || true
|
||||||
|
temp=${temp%% *}
|
||||||
temp="${temp:-?}"
|
temp="${temp:-?}"
|
||||||
hours=$(smartctl -A "$ns" 2>/dev/null | awk -F': +' '/Power On Hours/{print $2; exit}') || true
|
# strip thousands separator ("9,852")
|
||||||
|
hours=$(smartctl -A "$ns" 2>/dev/null | awk -F': +' '/Power On Hours/{print $2; exit}' | tr -d ',') || true
|
||||||
hours="${hours:-?}"
|
hours="${hours:-?}"
|
||||||
# NVMe percentage used — 100% means full rated write endurance consumed
|
# NVMe percentage used — 100% means full rated write endurance consumed
|
||||||
pct_used=$(smartctl -A "$ns" 2>/dev/null | awk -F': +' '/Percentage Used/{print $2; exit}' | tr -d '%') || true
|
pct_used=$(smartctl -A "$ns" 2>/dev/null | awk -F': +' '/Percentage Used/{print $2; exit}' | tr -d '%') || true
|
||||||
|
|
||||||
STATUS+=" $ns ($model): $health, ${temp}, ${hours}h\n"
|
STATUS+=$(printf ' %-13s %-24s %5s %7s ' "$ns" "$model" "${temp}C" "${hours}h")$(state_txt "$health")"\n"
|
||||||
|
HTML_NVME+="<tr><td $TD>$ns</td><td $TD>$model</td><td $TD>$(state_html "$health")</td><td $TDR>${temp}C</td><td $TDR>${hours}h</td></tr>"$'\n'
|
||||||
|
|
||||||
if [ "$health" != "PASSED" ] && [ "$health" != "UNKNOWN" ]; then
|
if [ "$health" != "PASSED" ] && [ "$health" != "UNKNOWN" ]; then
|
||||||
PROBLEMS+="[SMART] $ns: overall health check FAILED ($health)\n"
|
PROBLEMS+="[SMART] $ns: overall health check FAILED ($health)\n"
|
||||||
@@ -112,17 +210,30 @@ for disk in /dev/nvme[0-9]*; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# ─── ZFS Pool Health ─────────────────────────────────────────────
|
# ─── ZFS Pool Health ─────────────────────────────────────────────
|
||||||
STATUS+="\nZFS Pools:\n"
|
STATUS+="\n${C_BOLD}ZFS Pools:${C_RESET}\n"
|
||||||
|
STATUS+="${C_DIM}$(printf ' %-12s %7s %7s %7s %5s %s' Pool Size Alloc Free Cap State)${C_RESET}\n"
|
||||||
while IFS= read -r pool; do
|
while IFS= read -r pool; do
|
||||||
[ -n "$pool" ] || continue
|
[ -n "$pool" ] || continue
|
||||||
state=$(zpool list -H -o health "$pool" 2>/dev/null) || true
|
state=$(zpool list -H -o health "$pool" 2>/dev/null) || true
|
||||||
|
state="${state:-UNKNOWN}"
|
||||||
pool_info=$(zpool list -H -o name,size,alloc,free,cap "$pool" 2>/dev/null) || true
|
pool_info=$(zpool list -H -o name,size,alloc,free,cap "$pool" 2>/dev/null) || true
|
||||||
STATUS+=" $pool_info $state\n"
|
|
||||||
|
# zpool -H output is tab-separated
|
||||||
|
IFS=$'\t' read -r p_name p_size p_alloc p_free p_cap <<< "$pool_info" || true
|
||||||
|
STATUS+=$(printf ' %-12s %7s %7s %7s ' "${p_name:-$pool}" "$p_size" "$p_alloc" "$p_free")$(usage_txt "${p_cap%\%}" "$(printf '%5s' "$p_cap")")" "$(state_txt "$state")"\n"
|
||||||
|
cap_cell="<span style=\"color:$(usage_color "${p_cap%\%}")\">$p_cap</span>"
|
||||||
|
HTML_ZFS+="<tr><td $TD>${p_name:-$pool}</td><td $TDR>$p_size</td><td $TDR>$p_alloc</td><td $TDR>$p_free</td><td $TDR>$cap_cell</td><td $TD>$(state_html "$state")</td></tr>"$'\n'
|
||||||
|
|
||||||
if [ "$state" != "ONLINE" ]; then
|
if [ "$state" != "ONLINE" ]; then
|
||||||
PROBLEMS+="[ZFS] Pool '$pool' state: $state\n"
|
PROBLEMS+="[ZFS] Pool '$pool' state: $state\n"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Datasets are excluded from the df check below, so pool cap is the
|
||||||
|
# only capacity alert for ZFS data.
|
||||||
|
if [ "${p_cap%\%}" -gt "$ZFS_CAP_THRESHOLD" ] 2>/dev/null; then
|
||||||
|
PROBLEMS+="[ZFS] Pool '$pool' is ${p_cap} full\n"
|
||||||
|
fi
|
||||||
|
|
||||||
# Check for errors via zpool status -p (parseable).
|
# Check for errors via zpool status -p (parseable).
|
||||||
# Parse the per-vdev lines: columns are NAME STATE READ WRITE CKSUM.
|
# Parse the per-vdev lines: columns are NAME STATE READ WRITE CKSUM.
|
||||||
# Flag any vdev that is not ONLINE, or has non-zero error counters.
|
# Flag any vdev that is not ONLINE, or has non-zero error counters.
|
||||||
@@ -145,8 +256,31 @@ while IFS= read -r pool; do
|
|||||||
done < <(zpool status -p "$pool" 2>/dev/null | awk '/NAME.*STATE.*READ/{found=1; next} found && /^[[:space:]]+[^ ]/{print} /^$/{found=0}' || true)
|
done < <(zpool status -p "$pool" 2>/dev/null | awk '/NAME.*STATE.*READ/{found=1; next} found && /^[[:space:]]+[^ ]/{print} /^$/{found=0}' || true)
|
||||||
done < <(zpool list -H -o name 2>/dev/null || true)
|
done < <(zpool list -H -o name 2>/dev/null || true)
|
||||||
|
|
||||||
|
# ─── ZFS Dataset Quotas ──────────────────────────────────────────
|
||||||
|
# Quotas are hard limits — writes fail at 100% — so warn before the kids
|
||||||
|
# hit the wall. Lists every dataset with a quota set (with -p, an unset
|
||||||
|
# quota reads as 0).
|
||||||
|
STATUS+="\n${C_BOLD}Quotas:${C_RESET}\n"
|
||||||
|
STATUS+="${C_DIM}$(printf ' %-18s %4s %7s %7s' Dataset 'Use%' Used Quota)${C_RESET}\n"
|
||||||
|
HTML_QUOTA=""
|
||||||
|
while IFS=$'\t' read -r ds used quota; do
|
||||||
|
[ -n "$ds" ] || continue
|
||||||
|
[ "$quota" -gt 0 ] 2>/dev/null || continue
|
||||||
|
pct=$(( used * 100 / quota ))
|
||||||
|
used_h=$(numfmt --to=iec "$used" 2>/dev/null || echo "$used")
|
||||||
|
quota_h=$(numfmt --to=iec "$quota" 2>/dev/null || echo "$quota")
|
||||||
|
STATUS+=$(printf ' %-18s ' "$ds")$(usage_txt "$pct" "$(printf '%4s' "${pct}%")")$(printf ' %7s %7s' "$used_h" "$quota_h")"\n"
|
||||||
|
weight=""
|
||||||
|
if [ "$pct" -gt "$QUOTA_THRESHOLD" ]; then
|
||||||
|
PROBLEMS+="[QUOTA] $ds is at ${pct}% of its ${quota_h} quota\n"
|
||||||
|
weight=";font-weight:bold"
|
||||||
|
fi
|
||||||
|
HTML_QUOTA+="<tr><td $TD>$ds</td><td $TDR>$used_h</td><td $TDR>$quota_h</td><td $TDR><span style=\"color:$(usage_color "$pct")$weight\">${pct}%</span></td></tr>"$'\n'
|
||||||
|
done < <(zfs list -Hp -o name,used,quota -t filesystem 2>/dev/null || true)
|
||||||
|
|
||||||
# ─── Disk Space ──────────────────────────────────────────────────
|
# ─── Disk Space ──────────────────────────────────────────────────
|
||||||
STATUS+="\nDisk Space:\n"
|
STATUS+="\n${C_BOLD}Disk Space:${C_RESET}\n"
|
||||||
|
STATUS+="${C_DIM}$(printf ' %-12s %4s %7s %7s %7s' Mount 'Use%' Used Size Avail)${C_RESET}\n"
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[ -n "$line" ] || continue
|
[ -n "$line" ] || continue
|
||||||
usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
|
usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
|
||||||
@@ -154,11 +288,17 @@ while IFS= read -r line; do
|
|||||||
size=$(echo "$line" | awk '{print $2}')
|
size=$(echo "$line" | awk '{print $2}')
|
||||||
used=$(echo "$line" | awk '{print $3}')
|
used=$(echo "$line" | awk '{print $3}')
|
||||||
avail=$(echo "$line" | awk '{print $4}')
|
avail=$(echo "$line" | awk '{print $4}')
|
||||||
STATUS+=" $mount: ${usage}% (${used}/${size}, ${avail} free)\n"
|
STATUS+=$(printf ' %-12s ' "$mount")$(usage_txt "$usage" "$(printf '%4s' "${usage}%")")$(printf ' %7s %7s %7s' "$used" "$size" "$avail")"\n"
|
||||||
|
weight=""
|
||||||
if [ "$usage" -gt "$SPACE_THRESHOLD" ] 2>/dev/null; then
|
if [ "$usage" -gt "$SPACE_THRESHOLD" ] 2>/dev/null; then
|
||||||
PROBLEMS+="[SPACE] $mount is ${usage}% full\n"
|
PROBLEMS+="[SPACE] $mount is ${usage}% full\n"
|
||||||
|
weight=";font-weight:bold"
|
||||||
fi
|
fi
|
||||||
done < <(df -h --output=source,size,used,avail,pcent,target -x tmpfs -x devtmpfs -x overlay -x efivarfs 2>/dev/null | tail -n +2 || true)
|
usage_cell="<span style=\"color:$(usage_color "$usage")$weight\">${usage}%</span>"
|
||||||
|
HTML_SPACE+="<tr><td $TD>$mount</td><td $TDR>$usage_cell</td><td $TDR>$used</td><td $TDR>$size</td><td $TDR>$avail</td></tr>"$'\n'
|
||||||
|
# ZFS datasets are excluded: their df percentages are computed against
|
||||||
|
# shared pool free space, so they're misleading — pool cap above covers them.
|
||||||
|
done < <(df -h --output=source,size,used,avail,pcent,target -x tmpfs -x devtmpfs -x overlay -x efivarfs -x zfs 2>/dev/null | tail -n +2 || true)
|
||||||
|
|
||||||
# ─── Output ──────────────────────────────────────────────────────
|
# ─── Output ──────────────────────────────────────────────────────
|
||||||
if [ "$ERROR_ONLY" = true ]; then
|
if [ "$ERROR_ONLY" = true ]; then
|
||||||
@@ -166,7 +306,7 @@ if [ "$ERROR_ONLY" = true ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
SUBJECT="[ALERT] $HOSTNAME health check failed"
|
SUBJECT="[ALERT] $HOSTNAME health check failed"
|
||||||
BODY="Health check found issues on $HOSTNAME at $(date):\n\n$PROBLEMS\n---\n"
|
BODY="Health check found issues on $HOSTNAME at $(date):\n\n${C_RED}$PROBLEMS${C_RESET}\n---\n"
|
||||||
else
|
else
|
||||||
if [ -n "$PROBLEMS" ]; then
|
if [ -n "$PROBLEMS" ]; then
|
||||||
SUBJECT="[REPORT] $HOSTNAME health — ISSUES FOUND"
|
SUBJECT="[REPORT] $HOSTNAME health — ISSUES FOUND"
|
||||||
@@ -175,18 +315,56 @@ else
|
|||||||
fi
|
fi
|
||||||
BODY="Health report for $HOSTNAME at $(date):\n\n"
|
BODY="Health report for $HOSTNAME at $(date):\n\n"
|
||||||
if [ -n "$PROBLEMS" ]; then
|
if [ -n "$PROBLEMS" ]; then
|
||||||
BODY+="*** ISSUES ***\n\n$PROBLEMS\n"
|
BODY+="${C_RED}*** ISSUES ***\n\n$PROBLEMS${C_RESET}\n"
|
||||||
fi
|
fi
|
||||||
BODY+="--- Status ---\n\n$STATUS\n---\n"
|
BODY+="--- Status ---\n\n$STATUS\n---\n"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$USE_EMAIL" = true ]; then
|
if [ "$USE_EMAIL" = true ]; then
|
||||||
|
HTML_PROBLEMS=""
|
||||||
|
while IFS= read -r p; do
|
||||||
|
[ -n "$p" ] || continue
|
||||||
|
HTML_PROBLEMS+="<li style=\"color:#cf222e;font-weight:bold;margin:2px 0\">$p</li>"$'\n'
|
||||||
|
done < <(printf '%b' "$PROBLEMS")
|
||||||
|
|
||||||
|
# Built with a newline after each row/section — SMTP caps lines at
|
||||||
|
# ~1000 chars and one giant line can get mangled in transit.
|
||||||
|
HTML_BODY="<div style=\"font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222\">"$'\n'
|
||||||
|
if [ "$ERROR_ONLY" = true ]; then
|
||||||
|
HTML_BODY+="<p>Health check found issues on <b>$HOSTNAME</b> at $(date):</p>"$'\n'
|
||||||
|
HTML_BODY+="<ul style=\"padding-left:20px\">"$'\n'"$HTML_PROBLEMS</ul>"$'\n'
|
||||||
|
else
|
||||||
|
HTML_BODY+="<p>Health report for <b>$HOSTNAME</b> at $(date):</p>"$'\n'
|
||||||
|
if [ -n "$HTML_PROBLEMS" ]; then
|
||||||
|
HTML_BODY+="<h3 style=\"color:#cf222e\">Issues</h3>"$'\n'
|
||||||
|
HTML_BODY+="<ul style=\"padding-left:20px\">"$'\n'"$HTML_PROBLEMS</ul>"$'\n'
|
||||||
|
fi
|
||||||
|
HTML_BODY+="<h3>SMART (SATA)</h3><table $TABLE>"$'\n'
|
||||||
|
HTML_BODY+="<tr><th $TH>Disk</th><th $TH>Model</th><th $TH>Health</th><th $TH>Temp</th><th $TH>Hours</th></tr>"$'\n'
|
||||||
|
HTML_BODY+="$HTML_SATA</table>"$'\n'
|
||||||
|
HTML_BODY+="<h3>SMART (NVMe)</h3><table $TABLE>"$'\n'
|
||||||
|
HTML_BODY+="<tr><th $TH>Disk</th><th $TH>Model</th><th $TH>Health</th><th $TH>Temp</th><th $TH>Hours</th></tr>"$'\n'
|
||||||
|
HTML_BODY+="$HTML_NVME</table>"$'\n'
|
||||||
|
HTML_BODY+="<h3>ZFS Pools</h3><table $TABLE>"$'\n'
|
||||||
|
HTML_BODY+="<tr><th $TH>Pool</th><th $TH>Size</th><th $TH>Alloc</th><th $TH>Free</th><th $TH>Cap</th><th $TH>State</th></tr>"$'\n'
|
||||||
|
HTML_BODY+="$HTML_ZFS</table>"$'\n'
|
||||||
|
HTML_BODY+="<h3>Quotas</h3><table $TABLE>"$'\n'
|
||||||
|
HTML_BODY+="<tr><th $TH>Dataset</th><th $TH>Used</th><th $TH>Quota</th><th $TH>Used %</th></tr>"$'\n'
|
||||||
|
HTML_BODY+="$HTML_QUOTA</table>"$'\n'
|
||||||
|
HTML_BODY+="<h3>Disk Space</h3><table $TABLE>"$'\n'
|
||||||
|
HTML_BODY+="<tr><th $TH>Mount</th><th $TH>Used %</th><th $TH>Used</th><th $TH>Size</th><th $TH>Free</th></tr>"$'\n'
|
||||||
|
HTML_BODY+="$HTML_SPACE</table>"$'\n'
|
||||||
|
fi
|
||||||
|
HTML_BODY+="</div>"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "Subject: $SUBJECT"
|
echo "Subject: $SUBJECT"
|
||||||
echo "From: $HOSTNAME <root@$HOSTNAME>"
|
echo "From: $HOSTNAME <argento@fastmail.com>"
|
||||||
echo "To: $MAILTO"
|
echo "To: $MAILTO"
|
||||||
|
echo "MIME-Version: 1.0"
|
||||||
|
echo "Content-Type: text/html; charset=utf-8"
|
||||||
echo ""
|
echo ""
|
||||||
printf '%b' "$BODY"
|
printf '%s\n' "$HTML_BODY"
|
||||||
} | sendmail "$MAILTO"
|
} | sendmail "$MAILTO"
|
||||||
else
|
else
|
||||||
printf '%b' "$BODY"
|
printf '%b' "$BODY"
|
||||||
|
|||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Recreate human users + groups with stable UIDs/GIDs, and restore the
|
||||||
|
# samba passdb from USB backup. Run during recovery BEFORE the data rsync
|
||||||
|
# so file ownership lands on the right UIDs.
|
||||||
|
#
|
||||||
|
# UIDs/GIDs MUST match what the original system used, or ZFS files end up
|
||||||
|
# owned by orphan numeric IDs. The script aborts loudly if it finds an
|
||||||
|
# existing user/group with a different UID/GID than expected — investigate
|
||||||
|
# and either rename the conflicting entry or update the constants below.
|
||||||
|
#
|
||||||
|
# Verify on a live system with:
|
||||||
|
# id timothykim # check uid + primary gid
|
||||||
|
# getent group sambagroup # check sambagroup gid
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "$EUID" -eq 0 ] || { echo "must run as root" >&2; exit 1; }
|
||||||
|
|
||||||
|
SAMBAGROUP_GID=1001
|
||||||
|
|
||||||
|
# Format: username:uid:gid:shell:home_flag:supp_groups
|
||||||
|
# - gid: numeric gid for the user's primary group (group name = username)
|
||||||
|
# - home_flag: "home" creates /home/<user>, "nohome" skips it (samba-only)
|
||||||
|
# - supp_groups: comma-separated supplementary groups, or "-" for none
|
||||||
|
USERS=(
|
||||||
|
"timothykim:1000:1000:/bin/bash:home:wheel,video,docker,sambagroup"
|
||||||
|
"yireh:1100:1100:/sbin/nologin:nohome:sambagroup"
|
||||||
|
"isaac:1101:1101:/sbin/nologin:nohome:sambagroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Abort if an existing entity's numeric ID doesn't match what we expect
|
||||||
|
assert_match() {
|
||||||
|
local label=$1 expected=$2 actual=$3
|
||||||
|
if [ "$expected" != "$actual" ]; then
|
||||||
|
echo "[FATAL] $label mismatch: expected $expected, got $actual" >&2
|
||||||
|
echo " ZFS file ownership will break if you proceed." >&2
|
||||||
|
echo " Either rename the conflicting entry or update this script." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== sambagroup ==="
|
||||||
|
if getent group sambagroup &>/dev/null; then
|
||||||
|
actual=$(getent group sambagroup | cut -d: -f3)
|
||||||
|
assert_match "sambagroup gid" "$SAMBAGROUP_GID" "$actual"
|
||||||
|
echo "[ok] sambagroup exists (gid $actual)"
|
||||||
|
else
|
||||||
|
groupadd -g "$SAMBAGROUP_GID" sambagroup
|
||||||
|
echo "[+] created sambagroup (gid $SAMBAGROUP_GID)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Preflight: every supplementary group referenced must already exist.
|
||||||
|
# wheel/video are OS-default; docker is created by docker-ce install (RUNBOOK
|
||||||
|
# step 2); sambagroup was just handled above. If anything's missing, abort
|
||||||
|
# before touching users — half-applied group memberships are annoying to undo.
|
||||||
|
echo ""
|
||||||
|
echo "=== preflight: supplementary groups ==="
|
||||||
|
missing=()
|
||||||
|
for entry in "${USERS[@]}"; do
|
||||||
|
supp=$(echo "$entry" | cut -d: -f6)
|
||||||
|
[ "$supp" = "-" ] && continue
|
||||||
|
IFS=, read -ra groups <<< "$supp"
|
||||||
|
for g in "${groups[@]}"; do
|
||||||
|
if ! getent group "$g" &>/dev/null; then
|
||||||
|
missing+=("$g")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
|
# de-dupe for a cleaner error message
|
||||||
|
uniq_missing=$(printf '%s\n' "${missing[@]}" | sort -u | tr '\n' ' ')
|
||||||
|
echo "[FATAL] required supplementary groups missing: $uniq_missing" >&2
|
||||||
|
echo " likely cause: a package that creates the group hasn't been" >&2
|
||||||
|
echo " installed yet (e.g., docker-ce creates the docker group)." >&2
|
||||||
|
echo " complete RUNBOOK step 2 before running this script." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[ok] all supplementary groups present"
|
||||||
|
|
||||||
|
for entry in "${USERS[@]}"; do
|
||||||
|
IFS=: read -r u uid gid shell home_flag supp <<< "$entry"
|
||||||
|
echo ""
|
||||||
|
echo "=== $u ==="
|
||||||
|
|
||||||
|
# Primary group (same name as user, pinned GID — pre-created so useradd
|
||||||
|
# doesn't auto-assign one via USERGROUPS_ENAB)
|
||||||
|
if getent group "$u" &>/dev/null; then
|
||||||
|
actual=$(getent group "$u" | cut -d: -f3)
|
||||||
|
assert_match "$u primary group gid" "$gid" "$actual"
|
||||||
|
echo "[ok] group $u exists (gid $actual)"
|
||||||
|
else
|
||||||
|
groupadd -g "$gid" "$u"
|
||||||
|
echo "[+] created group $u (gid $gid)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# User account
|
||||||
|
if id "$u" &>/dev/null; then
|
||||||
|
actual_uid=$(id -u "$u")
|
||||||
|
actual_gid=$(id -g "$u")
|
||||||
|
assert_match "$u uid" "$uid" "$actual_uid"
|
||||||
|
assert_match "$u primary gid" "$gid" "$actual_gid"
|
||||||
|
echo "[ok] $u exists (uid $actual_uid, gid $actual_gid)"
|
||||||
|
else
|
||||||
|
if [ "$home_flag" = "home" ]; then
|
||||||
|
useradd -u "$uid" -g "$gid" -s "$shell" -m "$u"
|
||||||
|
else
|
||||||
|
useradd -u "$uid" -g "$gid" -s "$shell" -M "$u"
|
||||||
|
fi
|
||||||
|
echo "[+] created $u (uid $uid, gid $gid)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Supplementary groups (idempotent — usermod -aG won't error on duplicates)
|
||||||
|
if [ "$supp" != "-" ]; then
|
||||||
|
usermod -aG "$supp" "$u"
|
||||||
|
echo "[+] $u in supplementary groups: $supp"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== samba passdb ==="
|
||||||
|
PASSDB_SRC=/mnt/backup/argento/system/samba-private
|
||||||
|
if ! mountpoint -q /mnt/backup; then
|
||||||
|
echo "[!] /mnt/backup is not mounted — skipping samba passdb restore"
|
||||||
|
echo " mount the USB backup drive (RUNBOOK step 5) and re-run if you want"
|
||||||
|
echo " to restore preserved SMB passwords; otherwise set them manually:"
|
||||||
|
echo " for u in timothykim yireh isaac; do smbpasswd -a \"\$u\"; done"
|
||||||
|
elif [ -f "$PASSDB_SRC/passdb.tdb" ]; then
|
||||||
|
install -d -m 700 /var/lib/samba/private
|
||||||
|
cp "$PASSDB_SRC"/*.tdb /var/lib/samba/private/
|
||||||
|
chmod 600 /var/lib/samba/private/*.tdb
|
||||||
|
echo "[+] restored samba passdb from $PASSDB_SRC/"
|
||||||
|
else
|
||||||
|
echo "[!] no samba passdb backup found at $PASSDB_SRC/"
|
||||||
|
echo " run: for u in timothykim yireh isaac; do smbpasswd -a \"\$u\"; done"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done ==="
|
||||||
|
echo "Next: set timothykim's Linux login password if not already (passwd timothykim)"
|
||||||
@@ -17,6 +17,12 @@ done < "$REPO/system/tracked-configs"
|
|||||||
|
|
||||||
sed -i 's/^password .*/password REDACTED/' "$REPO/system/msmtprc"
|
sed -i 's/^password .*/password REDACTED/' "$REPO/system/msmtprc"
|
||||||
|
|
||||||
|
# Mirror samba's binary passdb files into the repo so backup.sh's standard
|
||||||
|
# rsync of /opt/argento/ picks them up. Gitignored — credential hashes don't
|
||||||
|
# belong in version control, but they DO need to ride along on the USB backup.
|
||||||
|
install -d -m 700 "$REPO/system/samba-private"
|
||||||
|
rsync -a --delete --exclude='msg.sock/' /var/lib/samba/private/ "$REPO/system/samba-private/"
|
||||||
|
|
||||||
# Sync root crontab
|
# Sync root crontab
|
||||||
crontab -l > "$REPO/system/root-crontab" 2>/dev/null || true
|
crontab -l > "$REPO/system/root-crontab" 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
Executable
+241
@@ -0,0 +1,241 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# update-docker.sh — pull/build the latest images for every app under docker/,
|
||||||
|
# recreate only the containers whose image changed, validate they come back
|
||||||
|
# healthy, and run Nextcloud's post-upgrade occ steps. Prints a per-app summary
|
||||||
|
# (updated / no update / failed) and exits non-zero if anything failed.
|
||||||
|
#
|
||||||
|
# In-place by design: it never runs a global `docker compose down`, because the
|
||||||
|
# `shared` network is created by the nginx compose file and every other app
|
||||||
|
# joins it as external. Tearing it all down at once would break that network and
|
||||||
|
# cause needless downtime. `docker compose up -d` recreates only the changed
|
||||||
|
# containers and leaves the rest (and the network) alone.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./update-docker.sh # update every app under docker/
|
||||||
|
# ./update-docker.sh nextcloud nginx # update only the named apps
|
||||||
|
# ./update-docker.sh --prune # also reclaim dangling images afterward
|
||||||
|
#
|
||||||
|
# Env overrides:
|
||||||
|
# DOCKER_DIR=/path/to/docker # default: ../docker relative to this script
|
||||||
|
# HEALTH_TIMEOUT=180 # seconds to wait for containers to be healthy
|
||||||
|
#
|
||||||
|
# Runs on the server (Rocky Linux 9), where each app's .env is present so compose
|
||||||
|
# can resolve secrets. Run it from anywhere; it cd's into each app dir itself.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
DOCKER_DIR="${DOCKER_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)/docker}"
|
||||||
|
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-180}"
|
||||||
|
|
||||||
|
PRUNE=false
|
||||||
|
REQUESTED=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--prune) PRUNE=true ;;
|
||||||
|
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||||
|
*) REQUESTED+=("$arg") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# `docker compose pull --ignore-buildable` cleanly skips services with a build
|
||||||
|
# section (added in Compose v2.30). Without it, pulling minecraft would error on
|
||||||
|
# its local-only image tag — harmless here since we treat pull failures as
|
||||||
|
# non-fatal and rebuild buildable services anyway, but the flag keeps output clean.
|
||||||
|
PULL_FLAGS=""
|
||||||
|
if docker compose pull --help 2>/dev/null | grep -q -- '--ignore-buildable'; then
|
||||||
|
PULL_FLAGS="--ignore-buildable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
UPDATED=()
|
||||||
|
NOUPDATE=()
|
||||||
|
FAILED=()
|
||||||
|
|
||||||
|
# Stable "ref=imageID" listing of every image the project resolves to, so we can
|
||||||
|
# tell whether a pull/build actually changed anything.
|
||||||
|
image_signature() {
|
||||||
|
local img id
|
||||||
|
while IFS= read -r img; do
|
||||||
|
[ -z "$img" ] && continue
|
||||||
|
id=$(docker image inspect -f '{{.Id}}' "$img" 2>/dev/null || echo '<none>')
|
||||||
|
printf '%s=%s\n' "$img" "$id"
|
||||||
|
done < <(docker compose config --images 2>/dev/null) | sort
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_has_build() {
|
||||||
|
docker compose config 2>/dev/null | grep -qE '^[[:space:]]+build:'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait until every container in the project is running, and (if it has a
|
||||||
|
# healthcheck) healthy. Returns non-zero on timeout.
|
||||||
|
validate_app() {
|
||||||
|
local deadline cid cname state health all_ok pending
|
||||||
|
deadline=$(( SECONDS + HEALTH_TIMEOUT ))
|
||||||
|
while :; do
|
||||||
|
all_ok=true
|
||||||
|
pending=""
|
||||||
|
while IFS= read -r cid; do
|
||||||
|
[ -z "$cid" ] && continue
|
||||||
|
cname=$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | sed 's#^/##')
|
||||||
|
state=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null)
|
||||||
|
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null)
|
||||||
|
if [ "$state" != "running" ]; then
|
||||||
|
all_ok=false; pending+=" $cname=$state"
|
||||||
|
elif [ "$health" = "starting" ] || [ "$health" = "unhealthy" ]; then
|
||||||
|
all_ok=false; pending+=" $cname=health:$health"
|
||||||
|
fi
|
||||||
|
done < <(docker compose ps -a -q)
|
||||||
|
|
||||||
|
$all_ok && return 0
|
||||||
|
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||||
|
echo " validation timed out after ${HEALTH_TIMEOUT}s:$pending"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nextcloud post-upgrade steps.
|
||||||
|
#
|
||||||
|
# The official image's entrypoint already runs `occ upgrade` on start when it
|
||||||
|
# detects a version change, and holds the instance in maintenance mode while it
|
||||||
|
# does (https://github.com/nextcloud/docker README). The old upgrade.sh failed
|
||||||
|
# because it ran `occ upgrade` after a fixed `sleep 10` — i.e. while the
|
||||||
|
# entrypoint was still mid-upgrade. So here we first wait for maintenance mode to
|
||||||
|
# clear, then run occ ourselves.
|
||||||
|
#
|
||||||
|
# Exit codes from core/Command/Upgrade.php (current master): 0 = success AND
|
||||||
|
# "no upgrade required" (ERROR_UP_TO_DATE == 0), 2 = maintenance mode,
|
||||||
|
# 5 = failure. So a non-zero from `occ upgrade` is a genuine problem, not the
|
||||||
|
# benign "already up to date" it used to be in older versions (which returned 3).
|
||||||
|
nextcloud_occ() {
|
||||||
|
local rc=0 deadline
|
||||||
|
deadline=$(( SECONDS + HEALTH_TIMEOUT ))
|
||||||
|
echo " occ: waiting for the entrypoint's auto-upgrade / maintenance mode to clear"
|
||||||
|
while docker compose exec -T -u www-data nextcloud php occ maintenance:mode 2>/dev/null \
|
||||||
|
| grep -q 'currently enabled'; do
|
||||||
|
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||||
|
echo " still in maintenance mode after ${HEALTH_TIMEOUT}s — aborting occ steps"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
# Safety-net upgrade. Normally a no-op (the entrypoint already migrated), so
|
||||||
|
# this should print "No upgrade required" and return 0. A non-zero exit is real.
|
||||||
|
echo " occ: upgrade"
|
||||||
|
docker compose exec -T -u www-data nextcloud php occ upgrade 2>&1 | sed 's/^/ /'
|
||||||
|
local up=${PIPESTATUS[0]}
|
||||||
|
if [ "$up" -ne 0 ]; then
|
||||||
|
echo " occ upgrade exited $up (2=maintenance, 5=failure) — see output above"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Documented post-upgrade index optimization; idempotent and safe to repeat.
|
||||||
|
echo " occ: add missing DB indices"
|
||||||
|
docker compose exec -T -u www-data nextcloud php occ db:add-missing-indices 2>&1 | sed 's/^/ /'
|
||||||
|
[ "${PIPESTATUS[0]}" -ne 0 ] && rc=1
|
||||||
|
|
||||||
|
echo " occ: status"
|
||||||
|
docker compose exec -T -u www-data nextcloud php occ status 2>&1 | sed 's/^/ /'
|
||||||
|
[ "${PIPESTATUS[0]}" -ne 0 ] && rc=1
|
||||||
|
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
update_app() {
|
||||||
|
local name="$1"
|
||||||
|
local dir="$DOCKER_DIR/$name"
|
||||||
|
|
||||||
|
if ! cd "$dir" 2>/dev/null; then
|
||||||
|
FAILED+=("$name (cannot cd into $dir)"); return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> $name: checking for updates"
|
||||||
|
local before after
|
||||||
|
before=$(image_signature)
|
||||||
|
|
||||||
|
# Pull registry images. Lenient: a warning/error here (e.g. an image that is
|
||||||
|
# only built locally) shouldn't abort the run — validation catches real breakage.
|
||||||
|
docker compose pull $PULL_FLAGS 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
if compose_has_build; then
|
||||||
|
echo " rebuilding local images against latest base"
|
||||||
|
docker compose build --pull 2>&1 | sed 's/^/ /'
|
||||||
|
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
|
||||||
|
FAILED+=("$name (build failed)"); return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
after=$(image_signature)
|
||||||
|
if [ "$before" = "$after" ]; then
|
||||||
|
echo " no update needed"
|
||||||
|
NOUPDATE+=("$name"); return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " new image(s) detected — recreating containers"
|
||||||
|
docker compose up -d 2>&1 | sed 's/^/ /'
|
||||||
|
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
|
||||||
|
FAILED+=("$name (up -d failed)"); return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! validate_app; then
|
||||||
|
FAILED+=("$name (unhealthy after update)"); return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$name" = "nextcloud" ]; then
|
||||||
|
if ! nextcloud_occ; then
|
||||||
|
FAILED+=("$name (occ steps failed)"); return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " updated and validated"
|
||||||
|
UPDATED+=("$name")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the work list: every dir under docker/ that has a compose file, with
|
||||||
|
# nginx first so the shared network owner is touched before its dependents.
|
||||||
|
all_apps=()
|
||||||
|
while IFS= read -r d; do
|
||||||
|
name=$(basename "$d")
|
||||||
|
if compgen -G "$d/compose.y*ml" >/dev/null || compgen -G "$d/docker-compose.y*ml" >/dev/null; then
|
||||||
|
all_apps+=("$name")
|
||||||
|
fi
|
||||||
|
done < <(find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -type d | sort)
|
||||||
|
|
||||||
|
if [ "${#REQUESTED[@]}" -gt 0 ]; then
|
||||||
|
# Validate requested names against what actually exists.
|
||||||
|
apps=()
|
||||||
|
for req in "${REQUESTED[@]}"; do
|
||||||
|
found=false
|
||||||
|
for a in "${all_apps[@]}"; do [ "$a" = "$req" ] && found=true; done
|
||||||
|
if $found; then apps+=("$req"); else
|
||||||
|
echo "no such app under docker/: $req" >&2; exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
apps=("${all_apps[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# nginx first, the rest after.
|
||||||
|
ordered=()
|
||||||
|
for a in "${apps[@]}"; do [ "$a" = nginx ] && ordered+=("nginx"); done
|
||||||
|
for a in "${apps[@]}"; do [ "$a" != nginx ] && ordered+=("$a"); done
|
||||||
|
|
||||||
|
for name in "${ordered[@]}"; do
|
||||||
|
update_app "$name"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "================ summary ================"
|
||||||
|
echo "updated: ${UPDATED[*]:-none}"
|
||||||
|
echo "no update: ${NOUPDATE[*]:-none}"
|
||||||
|
echo "failed: ${FAILED[*]:-none}"
|
||||||
|
|
||||||
|
if $PRUNE && [ "${#UPDATED[@]}" -gt 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "pruning dangling images"
|
||||||
|
docker image prune -f 2>&1 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "${#FAILED[@]}" -eq 0 ]
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
MAILTO=timothykim@fastmail.com
|
MAILTO=timothykim@fastmail.fm
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
*/5 * * * * docker exec -u www-data nextcloud php /var/www/html/cron.php
|
*/5 * * * * docker exec -u www-data nextcloud php /var/www/html/cron.php
|
||||||
1 * * * * /opt/argento/docker/jellyfin/jellyfin-monitor.sh
|
1 * * * * /opt/argento/docker/jellyfin/jellyfin-monitor.sh
|
||||||
@@ -6,4 +6,4 @@ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|||||||
0 3 * * * /opt/argento/scripts/backup.sh
|
0 3 * * * /opt/argento/scripts/backup.sh
|
||||||
0 3 * * 0 docker system prune -f --volumes > /dev/null
|
0 3 * * 0 docker system prune -f --volumes > /dev/null
|
||||||
0 6 * * * /opt/argento/scripts/disk-health-check.sh --error-only --email
|
0 6 * * * /opt/argento/scripts/disk-health-check.sh --error-only --email
|
||||||
0 8 * * 1 /opt/argento/scripts/disk-health-check.sh --email
|
0 6 * * 1 /opt/argento/scripts/disk-health-check.sh --email
|
||||||
|
|||||||
+21
-2
@@ -12,6 +12,7 @@
|
|||||||
security = user
|
security = user
|
||||||
include = registry
|
include = registry
|
||||||
passdb backend = tdbsam
|
passdb backend = tdbsam
|
||||||
|
access based share enum = yes
|
||||||
# printing = cups
|
# printing = cups
|
||||||
# printcap name = cups
|
# printcap name = cups
|
||||||
# load printers = yes
|
# load printers = yes
|
||||||
@@ -20,10 +21,28 @@
|
|||||||
[media]
|
[media]
|
||||||
path = /opt/argento/smb/media
|
path = /opt/argento/smb/media
|
||||||
force group = sambagroup
|
force group = sambagroup
|
||||||
#valid users = timothykim
|
valid users = @sambagroup
|
||||||
|
write list = timothykim
|
||||||
|
read only = yes
|
||||||
browseable = yes
|
browseable = yes
|
||||||
writable = yes
|
guest ok = no
|
||||||
|
|
||||||
|
[yireh]
|
||||||
|
path = /opt/argento/smb/yireh
|
||||||
|
valid users = yireh timothykim
|
||||||
|
write list = yireh timothykim
|
||||||
|
force group = sambagroup
|
||||||
read only = no
|
read only = no
|
||||||
|
browseable = yes
|
||||||
|
guest ok = no
|
||||||
|
|
||||||
|
[isaac]
|
||||||
|
path = /opt/argento/smb/isaac
|
||||||
|
valid users = isaac timothykim
|
||||||
|
write list = isaac timothykim
|
||||||
|
force group = sambagroup
|
||||||
|
read only = no
|
||||||
|
browseable = yes
|
||||||
guest ok = no
|
guest ok = no
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user