ops: paivana and caddy config refresh

This commit is contained in:
Hernâni Marques 2026-09-09 00:52:54 +02:00
parent d8dbc6242b
commit 56f814a6de
No known key found for this signature in database
74 changed files with 4517 additions and 79 deletions

11
scripts/bbb/build.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Build BBB front stub on Ubuntu (run on koopa).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CTX="${ROOT}/configs/bbb"
TAG="${IMAGE_TAG:-localhost/koopa-bbb-front:ubuntu}"
cd "$CTX"
[[ -f Containerfile ]] || { echo "ERROR: missing $CTX/Containerfile" >&2; exit 1; }
echo "build $TAG ctx=$CTX"
podman build -t "$TAG" -f Containerfile .
echo "OK: $TAG"

View file

@ -15,12 +15,52 @@ sudo systemctl reload caddy
Caddy **ignores** Apache `.htaccess`. Use:
```bash
sudo bash /home/hernani/koopa-admin-log/scripts/caddy/apply-dossiers-ngi-basicauth.sh
# clone koopa-admin-log from Forgejo when needed (no permanent ~/ clone)
sudo bash /path/to/koopa-admin-log/scripts/caddy/apply-dossiers-ngi-basicauth.sh
```
Password file (not git): `/home/hernani/koopa-secrets/dossiers-ngi-basicauth.txt`
Host runtime: `/etc/caddy/secrets/dossiers-ngi-basicauth.txt` (root-only).
SoT: `koopa-admin-secrets-enc` (git-crypt) → `koopa/home-hernani/caddy/`.
Hosts: `dossiers.ngi` / `dossiers.2.ngi` / `dossiers.3.ngi`.hacktivism.ch
## New HTTPS vhost
## silverbullet.hacktivism.ch — merge + basic_auth
Add a site block to the Caddyfile (live + mirror), then validate and reload. Include the host in the shared HTTP site list (ACME + redirect) at the bottom of the file.
After podman cutover (`scripts/notes/cutover-to-silverbullet.sh --apply`) and DNS:
```bash
# laptop: SoT → /tmp seed (no secret echo)
bash /path/to/koopa-admin-log/scripts/notes/prepare-basicauth-seed.sh
scp /tmp/silverbullet-basicauth-seed.txt hernani@koopa:/tmp/
# koopa root:
sudo bash /path/to/koopa-admin-log/scripts/caddy/merge-notes-site.sh
sudo NOTES_AUTH_SEED=/tmp/silverbullet-basicauth-seed.txt \
bash /path/to/koopa-admin-log/scripts/caddy/apply-notes-basicauth.sh
```
`merge-notes-site.sh` renames leftover `freenotes`/`notes` site blocks → `silverbullet.hacktivism.ch`.
Host runtime: `/etc/caddy/secrets/silverbullet-basicauth.txt` (root-only).
SoT: `koopa-admin-secrets-enc` (git-crypt) → `koopa/home-hernani/caddy/silverbullet-basicauth.txt`.
## New HTTPS vhost — `caddy-apply.sh`
Generic merge (site snippet + ACME `http://` list + ports comment):
```bash
# laptop mirror (no reload):
bash scripts/caddy/caddy-apply.sh \
--config configs/caddy/Caddyfile \
--snippet configs/caddy/galene.hacktivism.ch.caddy \
--site galene.hacktivism.ch --port 9029 --label galene \
--no-reload
# koopa live (root):
sudo bash …/scripts/caddy/caddy-apply.sh \
--snippet …/configs/caddy/SITE.caddy \
--site SITE.hacktivism.ch --port NNNN --label NAME
```
Allocated collab vhosts (2026-09-08): `galene` **9029**, `cryptpad` **9030**, `meet` **9031**, `bbb` **9032**.
Snippets under `configs/caddy/*.hacktivism.ch.caddy`. Image bases: CachyOS except **bbb=Ubuntu**.
Still valid: hand-edit live + mirror, then validate/reload.

View file

@ -4,12 +4,12 @@
#
# Caddy file_server does NOT honor Apache .htaccess — this edits the Caddyfile.
#
# On koopa (password for sudo):
# sudo -n true 2>/dev/null || sudo -v
# sudo bash /home/hernani/koopa-admin-log/scripts/caddy/apply-dossiers-ngi-basicauth.sh
# On koopa (clone admin-log from Forgejo when needed; no home git dump):
# sudo bash /path/to/koopa-admin-log/scripts/caddy/apply-dossiers-ngi-basicauth.sh
#
# Idempotent. Password file is NOT in git:
# /home/hernani/koopa-secrets/dossiers-ngi-basicauth.txt
# Host runtime file (root-only, not under ~):
# /etc/caddy/secrets/dossiers-ngi-basicauth.txt
# SoT (git-crypt): koopa-admin-secrets-enc → koopa/home-hernani/caddy/
set -euo pipefail
if [[ "$(id -u)" -ne 0 ]]; then
@ -19,8 +19,9 @@ fi
CADDY=/etc/caddy/Caddyfile
USER_NAME="${DOSSIERS_AUTH_USER:-hernani}"
SECRETS_DIR=/home/hernani/koopa-secrets
SECRETS_DIR=/etc/caddy/secrets
PASS_FILE="${SECRETS_DIR}/dossiers-ngi-basicauth.txt"
OLD_PASS_FILE=/home/hernani/koopa-secrets/dossiers-ngi-basicauth.txt
SITES=(
dossiers.ngi.hacktivism.ch
dossiers.2.ngi.hacktivism.ch
@ -34,9 +35,14 @@ fi
command -v caddy >/dev/null || { echo "ERROR: caddy not in PATH" >&2; exit 1; }
mkdir -p "$SECRETS_DIR"
chown hernani:hernani "$SECRETS_DIR"
chmod 700 "$SECRETS_DIR"
if [[ ! -f "$PASS_FILE" && -f "$OLD_PASS_FILE" ]]; then
cp -a "$OLD_PASS_FILE" "$PASS_FILE"
chmod 600 "$PASS_FILE"
echo "OK: migrated $OLD_PASS_FILE$PASS_FILE"
fi
if [[ -f "$PASS_FILE" ]]; then
# shellcheck disable=SC1090
# file format: USER=... PASS=... HASH=...

View file

@ -0,0 +1,179 @@
#!/usr/bin/env bash
# Apply HTTP basic_auth to silverbullet.hacktivism.ch (dossiers pattern).
#
# On koopa (clone admin-log from Forgejo when needed; no home git dump):
# sudo bash /path/to/koopa-admin-log/scripts/caddy/apply-notes-basicauth.sh
#
# Host runtime file (root-only, not under ~):
# /etc/caddy/secrets/silverbullet-basicauth.txt
# SoT (git-crypt): koopa-admin-secrets-enc → koopa/home-hernani/caddy/
set -euo pipefail
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: run as root: sudo bash $0" >&2
exit 1
fi
CADDY=/etc/caddy/Caddyfile
USER_NAME="${NOTES_AUTH_USER:-hernani}"
SECRETS_DIR=/etc/caddy/secrets
PASS_FILE="${SECRETS_DIR}/silverbullet-basicauth.txt"
# legacy home dumps (migrate once, then delete)
OLD_PASS_FILE=/home/hernani/koopa-secrets/silverbullet-basicauth.txt
OLD_NOTES_FILE=/home/hernani/koopa-secrets/notes-basicauth.txt
OLD_FREENOTES_FILE=/home/hernani/koopa-secrets/freenotes-basicauth.txt
SITE=silverbullet.hacktivism.ch
if [[ ! -f "$CADDY" ]]; then
echo "ERROR: missing $CADDY" >&2
exit 1
fi
command -v caddy >/dev/null || { echo "ERROR: caddy not in PATH" >&2; exit 1; }
mkdir -p "$SECRETS_DIR"
chmod 700 "$SECRETS_DIR"
# Prefer explicit seed (SoT from secrets-enc), then legacy home dumps once.
SEED_FILE="${NOTES_AUTH_SEED:-}"
if [[ ! -f "$PASS_FILE" && -n "$SEED_FILE" && -f "$SEED_FILE" ]]; then
cp -a "$SEED_FILE" "$PASS_FILE"
chmod 600 "$PASS_FILE"
echo "OK: seeded $SEED_FILE$PASS_FILE"
fi
if [[ ! -f "$PASS_FILE" && -f "$OLD_PASS_FILE" ]]; then
cp -a "$OLD_PASS_FILE" "$PASS_FILE"
chmod 600 "$PASS_FILE"
echo "OK: migrated $OLD_PASS_FILE$PASS_FILE"
fi
if [[ ! -f "$PASS_FILE" && -f "$OLD_NOTES_FILE" ]]; then
cp -a "$OLD_NOTES_FILE" "$PASS_FILE"
chmod 600 "$PASS_FILE"
echo "OK: migrated $OLD_NOTES_FILE$PASS_FILE"
fi
if [[ ! -f "$PASS_FILE" && -f "$OLD_FREENOTES_FILE" ]]; then
cp -a "$OLD_FREENOTES_FILE" "$PASS_FILE"
chmod 600 "$PASS_FILE"
echo "OK: migrated $OLD_FREENOTES_FILE$PASS_FILE"
fi
if [[ -f "$PASS_FILE" ]]; then
USER_NAME="$(awk -F= '/^USER=/{print substr($0,6); exit}' "$PASS_FILE")"
PASS="$(awk -F= '/^PASS=/{print substr($0,6); exit}' "$PASS_FILE")"
HASH="$(awk -F= '/^HASH=/{print substr($0,6); exit}' "$PASS_FILE")"
if [[ -z "$USER_NAME" || -z "$HASH" ]]; then
echo "ERROR: $PASS_FILE incomplete — move it aside and re-run" >&2
exit 1
fi
echo "OK: reusing credentials in $PASS_FILE"
else
PASS="$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)"
HASH="$(caddy hash-password --plaintext "$PASS")"
umask 077
cat >"$PASS_FILE" <<EOF
USER=${USER_NAME}
PASS=${PASS}
HASH=${HASH}
EOF
chmod 600 "$PASS_FILE"
echo "OK: wrote new password to $PASS_FILE — mirror into koopa-admin-secrets-enc (git-crypt)"
fi
# Write bcrypt hash with single "$". Doubling to "$$" left literal "$$" in
# `caddy adapt` JSON here and made every password 401.
HASH_CADDY="$HASH"
ts=$(date +%Y%m%d-%H%M%S)
cp -a "$CADDY" "${CADDY}.bak-silverbullet-auth-${ts}"
echo "backup ${CADDY}.bak-silverbullet-auth-${ts}"
python3 - "$CADDY" "$USER_NAME" "$HASH_CADDY" "$SITE" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
user = sys.argv[2]
hash_caddy = sys.argv[3]
site = sys.argv[4]
text = path.read_text()
orig = text
def site_span(src: str, site_name: str) -> tuple[int, int]:
marker = f"{site_name} {{"
i = src.find(marker)
if i < 0:
raise SystemExit(
f"ERROR: site block not found: {site_name} — merge silverbullet.hacktivism.ch.caddy first"
)
depth = 0
for k in range(i + len(marker) - 1, len(src)):
ch = src[k]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return i, k + 1
raise SystemExit(f"ERROR: unclosed block: {site_name}")
def has_basic_auth_directive(block: str) -> bool:
# Ignore comments; only real directives count.
stripped = "\n".join(
ln for ln in block.splitlines() if not ln.lstrip().startswith("#")
)
return bool(re.search(r"(?m)^\s*basic_?auth\b", stripped))
i, j = site_span(text, site)
block = text[i:j]
auth_lines = (
"\tbasic_auth {\n"
f"\t\t{user} {hash_caddy}\n"
"\t}\n"
)
# Replace existing basic_auth block (also fixes legacy "$$" hashes that never matched).
block_new, n = re.subn(
r"(?m)^[ \t]*basic_auth\s*\{.*?\n[ \t]*\}\n?",
auth_lines,
block,
count=1,
flags=re.S,
)
if n:
text = text[:i] + block_new + text[j:]
print(f"OK: replaced basic_auth in {site}")
elif has_basic_auth_directive(block):
print(f"OK: {site} already has basic_auth")
else:
m = re.search(r'(?m)^[ \t]*header Alt-Svc "clear"[ \t]*\n', block)
if m:
insert_at = m.end()
new_block = block[:insert_at] + auth_lines + block[insert_at:]
else:
nl = block.find("\n")
new_block = block[: nl + 1] + auth_lines + block[nl + 1 :]
text = text[:i] + new_block + text[j:]
print(f"OK: injected basic_auth in {site}")
if text != orig:
path.write_text(text)
print(f"wrote {path}")
else:
print("no Caddyfile change")
PY
echo "== validate =="
caddy validate --config "$CADDY"
echo "== reload =="
if systemctl is-active --quiet caddy; then
systemctl reload caddy
else
echo "WARN: caddy unit not active — start it yourself" >&2
fi
echo
echo "Done. Browser: https://${SITE}/"
echo "User/pass: $PASS_FILE"

217
scripts/caddy/caddy-apply.sh Executable file
View file

@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Generic: merge a site snippet into a Caddyfile + ACME http:// list + ports comment.
#
# Laptop (mirror only, no reload):
# bash scripts/caddy/caddy-apply.sh \
# --config configs/caddy/Caddyfile \
# --snippet configs/caddy/galene.hacktivism.ch.caddy \
# --site galene.hacktivism.ch --port 9029 --label galene \
# --no-reload
#
# Koopa live (root, Freigabe):
# sudo bash …/caddy-apply.sh \
# --snippet /home/hernani/koopa-admin-log/configs/caddy/galene.hacktivism.ch.caddy \
# --site galene.hacktivism.ch --port 9029 --label galene
set -euo pipefail
CADDY=/etc/caddy/Caddyfile
SNIPPET=""
SITE=""
PORT=""
LABEL=""
INSERT_AFTER="decidim.hacktivism.ch"
ACME_ANCHOR="http://decidim.hacktivism.ch"
NO_RELOAD=0
DRY=0
OLD_SITES=()
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
echo "Options: --config PATH --snippet PATH --site HOST --port N --label NAME"
echo " --insert-after HOST --acme-anchor http://HOST --old-site HOST"
echo " --no-reload --dry-run"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--config) CADDY=$2; shift 2 ;;
--snippet) SNIPPET=$2; shift 2 ;;
--site) SITE=$2; shift 2 ;;
--port) PORT=$2; shift 2 ;;
--label) LABEL=$2; shift 2 ;;
--insert-after) INSERT_AFTER=$2; shift 2 ;;
--acme-anchor) ACME_ANCHOR=$2; shift 2 ;;
--old-site) OLD_SITES+=("$2"); shift 2 ;;
--no-reload) NO_RELOAD=1; shift ;;
--dry-run) DRY=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$SNIPPET" && -n "$SITE" ]] || { echo "ERROR: --snippet and --site required" >&2; exit 2; }
[[ -f "$CADDY" ]] || { echo "ERROR: missing $CADDY" >&2; exit 1; }
[[ -f "$SNIPPET" ]] || { echo "ERROR: missing $SNIPPET" >&2; exit 1; }
LABEL="${LABEL:-$SITE}"
if [[ "$CADDY" == /etc/caddy/Caddyfile && "$(id -u)" -ne 0 && "$DRY" -eq 0 ]]; then
echo "ERROR: live Caddyfile needs root (or --dry-run / --config mirror)" >&2
exit 1
fi
ts=$(date +%Y%m%d-%H%M%S)
if [[ "$DRY" -eq 0 ]]; then
cp -a "$CADDY" "${CADDY}.bak-caddy-apply-${ts}"
echo "backup ${CADDY}.bak-caddy-apply-${ts}"
fi
export CADDY SNIPPET SITE PORT LABEL INSERT_AFTER ACME_ANCHOR DRY
export OLD_SITES_CSV
OLD_SITES_CSV=$(IFS=,; echo "${OLD_SITES[*]-}")
python3 - <<'PY'
import os, re, sys
from pathlib import Path
caddy = Path(os.environ["CADDY"])
snip = Path(os.environ["SNIPPET"]).read_text().rstrip() + "\n\n"
site = os.environ["SITE"]
port = os.environ.get("PORT") or ""
label = os.environ.get("LABEL") or site
insert_after = os.environ["INSERT_AFTER"]
acme_anchor = os.environ["ACME_ANCHOR"]
dry = os.environ.get("DRY") == "1"
old_sites = [s for s in os.environ.get("OLD_SITES_CSV", "").split(",") if s]
text = caddy.read_text()
orig = text
def drop_site_block(text: str, old_site: str) -> str:
marker_old = f"{old_site} {{"
i = text.find(marker_old)
if i < 0:
return text
j = text.find("\n}", i)
if j < 0:
return text
end = j + 2
while end < len(text) and text[end] == "\n":
end += 1
text = text[:i] + text[end:]
text = text.replace(f", http://{old_site}", "").replace(f"http://{old_site}, ", "")
text = text.replace(f"http://{old_site}", "")
print(f"OK: removed duplicate old block {old_site}")
return text
for old_site in old_sites:
if old_site in text and site not in text:
text = text.replace(old_site, site)
print(f"OK: renamed {old_site} → {site}")
elif old_site in text and site in text:
text = drop_site_block(text, old_site)
marker = f"{site} {{"
if marker in text:
print(f"OK: site block already present: {site}")
else:
insert_at = None
after = text.find(f"{insert_after} {{")
if after >= 0:
j = text.find("\n}", after)
if j >= 0:
insert_at = j + 2
while insert_at < len(text) and text[insert_at] == "\n":
insert_at += 1
if insert_at is None:
acme = text.find("http://taler.hacktivism.ch")
insert_at = acme if acme >= 0 else len(text)
text = text[:insert_at] + snip + text[insert_at:]
print(f"OK: inserted site block {site}")
http_tok = f"http://{site}"
if http_tok in text:
print(f"OK: ACME http list already has {http_tok}")
else:
# Prefer exact "anchor {" form; else append before " {" of the long http list
old = f"{acme_anchor} {{"
new = f"{acme_anchor}, {http_tok} {{"
if old in text:
text = text.replace(old, new, 1)
print(f"OK: added {http_tok} to ACME http list (anchor)")
else:
# Find the shared ACME line (starts with http://taler…)
m = re.search(r"(http://taler\.hacktivism\.ch[^\n]*?)(\s*\{)", text)
if not m:
raise SystemExit("ERROR: ACME http list not found")
line = m.group(1)
if http_tok in line:
print(f"OK: ACME http list already has {http_tok}")
else:
text = text[: m.start(1)] + line.rstrip() + f", {http_tok}" + text[m.end(1) :]
print(f"OK: added {http_tok} to ACME http list (append)")
# Ports comment header (first matching line starting with "# 9020 castopod")
if port:
token = f"{port} {label}"
def upd_hdr(line: str) -> str:
if token in line or f"| {port} " in line or f"| {port}|" in line:
return line
# insert before " | 9200 forgejo-ssh" if present, else before end
if "9200 forgejo-ssh" in line:
return line.replace(" | 9200 forgejo-ssh", f" | {token} | 9200 forgejo-ssh", 1)
if line.rstrip().endswith("forgejo-ssh"):
return line.rstrip() + f" | {token}\n"
return line.rstrip() + f" | {token}\n"
lines = text.splitlines(keepends=True)
changed = False
for i, line in enumerate(lines):
if line.startswith("# 9020 castopod"):
new_line = upd_hdr(line)
if new_line != line:
lines[i] = new_line if new_line.endswith("\n") else new_line + "\n"
print(f"OK: updated ports comment (+{token})")
changed = True
else:
print(f"OK: ports comment already has {token}")
break
if changed:
text = "".join(lines)
if text != orig:
if dry:
print(f"DRY: would write {caddy} ({len(text) - len(orig):+d} bytes)")
else:
caddy.write_text(text)
print(f"wrote {caddy}")
else:
print("no Caddyfile change")
PY
if [[ "$DRY" -eq 1 ]]; then
echo "dry-run done (no validate/reload)"
exit 0
fi
if command -v caddy >/dev/null 2>&1; then
echo "== validate =="
caddy validate --config "$CADDY"
else
echo "WARN: caddy binary missing — skip validate" >&2
fi
if [[ "$NO_RELOAD" -eq 1 ]]; then
echo "skip reload (--no-reload)"
exit 0
fi
if [[ "$CADDY" == /etc/caddy/Caddyfile ]] && command -v systemctl >/dev/null 2>&1; then
echo "== reload =="
if systemctl is-active --quiet caddy; then
systemctl reload caddy
else
echo "WARN: caddy unit not active — start it yourself" >&2
fi
else
echo "skip reload (not live path or no systemctl)"
fi

133
scripts/caddy/merge-notes-site.sh Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Merge silverbullet.hacktivism.ch into live /etc/caddy/Caddyfile + ACME http list.
# Run on koopa as root AFTER podman is up on :9028:
# sudo bash /home/hernani/koopa-admin-log/scripts/caddy/merge-notes-site.sh
# Then:
# sudo bash /home/hernani/koopa-admin-log/scripts/caddy/apply-notes-basicauth.sh
set -euo pipefail
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: run as root: sudo bash $0" >&2
exit 1
fi
CADDY=/etc/caddy/Caddyfile
SNIPPET=/home/hernani/koopa-admin-log/configs/caddy/silverbullet.hacktivism.ch.caddy
SITE=silverbullet.hacktivism.ch
# Prior hostnames to rename into SITE (order: newest leftovers first)
OLD_SITES="freenotes.hacktivism.ch notes.hacktivism.ch"
[[ -f "$CADDY" ]] || { echo "ERROR: missing $CADDY" >&2; exit 1; }
[[ -f "$SNIPPET" ]] || { echo "ERROR: missing $SNIPPET" >&2; exit 1; }
command -v caddy >/dev/null || { echo "ERROR: caddy not in PATH" >&2; exit 1; }
ts=$(date +%Y%m%d-%H%M%S)
cp -a "$CADDY" "${CADDY}.bak-silverbullet-merge-${ts}"
echo "backup ${CADDY}.bak-silverbullet-merge-${ts}"
python3 - "$CADDY" "$SNIPPET" "$SITE" $OLD_SITES <<'PY'
import sys
from pathlib import Path
caddy = Path(sys.argv[1])
snippet_path = Path(sys.argv[2])
site = sys.argv[3]
old_sites = sys.argv[4:]
text = caddy.read_text()
orig = text
snip = snippet_path.read_text().rstrip() + "\n\n"
def drop_site_block(text: str, old_site: str) -> str:
marker_old = f"{old_site} {{"
i = text.find(marker_old)
if i < 0:
return text
j = text.find("\n}", i)
if j < 0:
return text
end = j + 2
while end < len(text) and text[end] == "\n":
end += 1
text = text[:i] + text[end:]
text = text.replace(f", http://{old_site}", "").replace(f"http://{old_site}, ", "")
text = text.replace(f"http://{old_site}", "")
print(f"OK: removed duplicate old block {old_site}")
return text
# Rename leftover freenotes/notes.hacktivism.ch → silverbullet.hacktivism.ch
for old_site in old_sites:
if old_site in text and site not in text:
text = text.replace(old_site, site)
text = text.replace("9028 freenotes", "9028 silverbullet")
text = text.replace("9028 notes", "9028 silverbullet")
print(f"OK: renamed {old_site} → {site}")
elif old_site in text and site in text:
text = drop_site_block(text, old_site)
marker = f"{site} {{"
if marker in text:
print(f"OK: site block already present: {site}")
else:
# Insert after decidim block if present, else before ACME http list
insert_at = None
dec = text.find("decidim.hacktivism.ch {")
if dec >= 0:
j = text.find("\n}", dec)
if j >= 0:
insert_at = j + 2 # after closing brace newline
while insert_at < len(text) and text[insert_at] == "\n":
insert_at += 1
if insert_at is None:
acme = text.find("http://taler.hacktivism.ch")
insert_at = acme if acme >= 0 else len(text)
text = text[:insert_at] + snip + text[insert_at:]
print(f"OK: inserted site block {site}")
http_tok = f"http://{site}"
if http_tok in text:
print(f"OK: ACME http list already has {http_tok}")
else:
old = "http://decidim.hacktivism.ch {"
new = f"http://decidim.hacktivism.ch, {http_tok} {{"
if old not in text:
# maybe already on a long list without trailing brace-only form
raise SystemExit("ERROR: ACME http list marker not found (decidim)")
text = text.replace(old, new, 1)
print(f"OK: added {http_tok} to ACME http list")
# Comment header line near top ports comment
for old_hdr, new_hdr in (
(
"# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9026 lemmy | 9027 decidim | 9200 forgejo-ssh",
"# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9026 lemmy | 9027 decidim | 9028 silverbullet | 9200 forgejo-ssh",
),
(
"# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9026 lemmy | 9027 decidim | 9028 notes | 9200 forgejo-ssh",
"# 9020 castopod | 9021 bonfire | 9022 prime | 9023 bt | 9024 forgejo | 9025 paivana | 9026 lemmy | 9027 decidim | 9028 silverbullet | 9200 forgejo-ssh",
),
):
if old_hdr in text:
text = text.replace(old_hdr, new_hdr, 1)
print("OK: updated ports comment header")
break
if text != orig:
caddy.write_text(text)
print(f"wrote {caddy}")
else:
print("no Caddyfile change")
PY
echo "== validate =="
caddy validate --config "$CADDY"
echo "== reload =="
if systemctl is-active --quiet caddy; then
systemctl reload caddy
else
echo "WARN: caddy unit not active — start it yourself" >&2
fi
echo
echo "Next: sudo bash /home/hernani/koopa-admin-log/scripts/caddy/apply-notes-basicauth.sh"
echo "DNS/VeciGate for ${SITE} still separate."

17
scripts/cryptpad/build.sh Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Build CryptPad image on CachyOS base (run on koopa).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CTX="${ROOT}/configs/cryptpad"
TAG="${IMAGE_TAG:-localhost/koopa-cryptpad:cachyos}"
CRYPTPAD_VERSION="${CRYPTPAD_VERSION:-2025.9.0}"
cd "$CTX"
[[ -f Containerfile ]] || { echo "ERROR: missing $CTX/Containerfile" >&2; exit 1; }
echo "build $TAG (CRYPTPAD_VERSION=$CRYPTPAD_VERSION) ctx=$CTX"
podman build \
--build-arg "CRYPTPAD_VERSION=${CRYPTPAD_VERSION}" \
-t "$TAG" \
-f Containerfile \
.
echo "OK: $TAG"
podman image inspect "$TAG" --format '{{.Id}} {{.Size}} {{.Architecture}}' 2>/dev/null || true

17
scripts/galene/build.sh Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Build Galene image on CachyOS base (run on koopa).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CTX="${ROOT}/configs/galene"
TAG="${IMAGE_TAG:-localhost/koopa-galene:cachyos}"
GALENE_VERSION="${GALENE_VERSION:-0.9.1}"
cd "$CTX"
[[ -f Containerfile ]] || { echo "ERROR: missing $CTX/Containerfile" >&2; exit 1; }
echo "build $TAG (GALENE_VERSION=$GALENE_VERSION) ctx=$CTX"
podman build \
--build-arg "GALENE_VERSION=${GALENE_VERSION}" \
-t "$TAG" \
-f Containerfile \
.
echo "OK: $TAG"
podman image inspect "$TAG" --format '{{.Id}} {{.Size}} {{.Architecture}}' 2>/dev/null || true

11
scripts/meet/build.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Build Meet web stub on CachyOS (run on koopa).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CTX="${ROOT}/configs/meet"
TAG="${IMAGE_TAG:-localhost/koopa-meet-web:cachyos}"
cd "$CTX"
[[ -f Containerfile ]] || { echo "ERROR: missing $CTX/Containerfile" >&2; exit 1; }
echo "build $TAG ctx=$CTX"
podman build -t "$TAG" -f Containerfile .
echo "OK: $TAG"

40
scripts/notes/README.md Normal file
View file

@ -0,0 +1,40 @@
# SilverBullet — scripts (koopa)
Product **FreeNotes** is separate (native enc). These scripts operate the parked **SilverBullet** web stack on port **9028**.
| Script | Role |
|--------|------|
| `status.sh` | Read-only dirs / containers / `:9028` |
| `build.sh` | `podman build``localhost/koopa-silverbullet:cachyos` |
| `up.sh` | `podman-compose up` in `~/koopa-silverbullet` + health |
| `cutover-to-silverbullet.sh` | Live freenotes → silverbullet (default **dry-run**; `--apply` mutates) |
| `prepare-basicauth-seed.sh` | SoT → `/tmp/silverbullet-basicauth-seed.txt` (no sudo) |
Caddy (sudo, separate): `scripts/caddy/merge-notes-site.sh`, `apply-notes-basicauth.sh`.
## Cutover order (needs Freigabe + DNS)
On koopa as `hernani` (admin-log clone from Forgejo when needed):
```bash
bash …/scripts/notes/status.sh
bash …/scripts/notes/cutover-to-silverbullet.sh # dry-run
bash …/scripts/notes/cutover-to-silverbullet.sh --apply # Freigabe
```
Then Caddy (root) + seed from laptop SoT:
```bash
# laptop (unlocked secrets-enc):
bash …/scripts/notes/prepare-basicauth-seed.sh
scp /tmp/silverbullet-basicauth-seed.txt hernani@koopa:/tmp/
# koopa root:
sudo bash …/scripts/caddy/merge-notes-site.sh
sudo NOTES_AUTH_SEED=/tmp/silverbullet-basicauth-seed.txt \
bash …/scripts/caddy/apply-notes-basicauth.sh
```
DNS: `silverbullet.hacktivism.ch` → koopa (human). Public: `https://silverbullet.hacktivism.ch/`.
Keeps on cutover: `space/`, `.env`. Refreshes compose/Containerfile/entrypoint/unit from `configs/notes/`.

23
scripts/notes/build.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Build SilverBullet image on CachyOS base (run on koopa).
# bash ~/koopa-admin-log/scripts/notes/build.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CTX="${ROOT}/configs/notes"
TAG="${IMAGE_TAG:-localhost/koopa-silverbullet:cachyos}"
SB_VERSION="${SB_VERSION:-2.10.0}"
cd "$CTX"
[[ -f Containerfile ]] || { echo "ERROR: missing $CTX/Containerfile" >&2; exit 1; }
[[ -f entrypoint.sh ]] || { echo "ERROR: missing $CTX/entrypoint.sh" >&2; exit 1; }
echo "build $TAG (SB_VERSION=$SB_VERSION) ctx=$CTX"
podman build \
--build-arg "SB_VERSION=${SB_VERSION}" \
-t "$TAG" \
-f Containerfile \
.
echo "OK: $TAG"
podman image inspect "$TAG" --format '{{.Id}} {{.Size}} {{.Architecture}}' 2>/dev/null || true

View file

@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Cut over live stack freenotes → silverbullet on koopa (hernani).
# Default: dry-run. Mutate only with --apply.
#
# bash /path/to/koopa-admin-log/scripts/notes/cutover-to-silverbullet.sh
# bash /path/to/koopa-admin-log/scripts/notes/cutover-to-silverbullet.sh --apply
#
# Keeps: space/, .env. Refreshes: compose.yml, Containerfile, entrypoint.sh, systemd unit.
# Image: retag localhost/koopa-freenotes:cachyos → localhost/koopa-silverbullet:cachyos (no rebuild).
# Does NOT touch Caddy (sudo) — print next steps only.
set -euo pipefail
APPLY=0
for a in "$@"; do
case "$a" in
--apply) APPLY=1 ;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*)
echo "ERROR: unknown arg: $a (use --apply)" >&2
exit 2
;;
esac
done
OLD_DIR="${HOME}/koopa-freenotes"
NEW_DIR="${HOME}/koopa-silverbullet"
OLD_CT=koopa-freenotes
NEW_CT=koopa-silverbullet
OLD_IMG=localhost/koopa-freenotes:cachyos
NEW_IMG=localhost/koopa-silverbullet:cachyos
PORT=9028
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CFG="${ROOT}/configs/notes"
[[ -d "$CFG" ]] || { echo "ERROR: missing $CFG" >&2; exit 1; }
[[ -f "$CFG/compose.yml" ]] || { echo "ERROR: missing $CFG/compose.yml" >&2; exit 1; }
mode=dry-run
[[ "$APPLY" -eq 1 ]] && mode=apply
echo "STATUS=start MODE=$mode HOST=$(hostname -s) OLD_DIR=$OLD_DIR NEW_DIR=$NEW_DIR"
have_old=0
have_new=0
[[ -d "$OLD_DIR" ]] && have_old=1
[[ -d "$NEW_DIR" ]] && have_new=1
echo "STATUS=dirs OLD=$have_old NEW=$have_new"
ct_old=$(podman ps -a --format '{{.Names}}' 2>/dev/null | grep -Fx "$OLD_CT" || true)
ct_new=$(podman ps -a --format '{{.Names}}' 2>/dev/null | grep -Fx "$NEW_CT" || true)
echo "STATUS=containers OLD=${ct_old:-none} NEW=${ct_new:-none}"
img_old=$(podman images -q "$OLD_IMG" 2>/dev/null || true)
img_new=$(podman images -q "$NEW_IMG" 2>/dev/null || true)
echo "STATUS=images OLD=${img_old:-none} NEW=${img_new:-none}"
dir_err=0
if [[ "$have_old" -eq 0 && "$have_new" -eq 1 ]]; then
echo "STATUS=already_cutover DIR=$NEW_DIR"
elif [[ "$have_old" -eq 0 && "$have_new" -eq 0 ]]; then
echo "STATUS=warn neither $OLD_DIR nor $NEW_DIR (ok for laptop dry-run; apply needs koopa)"
dir_err=1
elif [[ "$have_old" -eq 1 && "$have_new" -eq 1 ]]; then
echo "STATUS=warn both dirs exist — resolve manually before --apply"
dir_err=1
fi
plan() { echo "PLAN: $*"; }
plan "stop+rm container $OLD_CT (if present)"
plan "mv $OLD_DIR$NEW_DIR (if old present)"
plan "copy compose/Containerfile/entrypoint/unit from $CFG (keep space .env)"
plan "podman tag $OLD_IMG $NEW_IMG (if new image missing)"
plan "podman-compose up -d in $NEW_DIR"
plan "health curl :$PORT"
plan "optional: systemctl --user enable container-koopa-silverbullet.service"
if [[ "$APPLY" -ne 1 ]]; then
echo "STATUS=dry-run NEXT=re-run on koopa with --apply (Freigabe)"
echo "STATUS=caddy_next sudo bash $ROOT/scripts/caddy/merge-notes-site.sh"
echo "STATUS=caddy_next sudo NOTES_AUTH_SEED=/tmp/silverbullet-basicauth-seed.txt bash $ROOT/scripts/caddy/apply-notes-basicauth.sh"
echo "STATUS=dns_need silverbullet.hacktivism.ch → koopa"
exit 0
fi
if [[ "$dir_err" -eq 1 ]]; then
echo "ERROR: refuse --apply with ambiguous/missing live dirs" >&2
exit 1
fi
if [[ -n "$ct_old" ]]; then
echo "STATUS=stop $OLD_CT"
podman stop -t 20 "$OLD_CT" || true
podman rm "$OLD_CT" || true
fi
if [[ "$have_old" -eq 1 ]]; then
echo "STATUS=mv $OLD_DIR$NEW_DIR"
mv "$OLD_DIR" "$NEW_DIR"
fi
cd "$NEW_DIR"
# Refresh stack files from admin-log; never overwrite space/ or .env
cp -a "$CFG/compose.yml" "$NEW_DIR/compose.yml"
cp -a "$CFG/Containerfile" "$NEW_DIR/Containerfile"
cp -a "$CFG/entrypoint.sh" "$NEW_DIR/entrypoint.sh"
chmod +x "$NEW_DIR/entrypoint.sh"
cp -a "$CFG/container-koopa-silverbullet.service" "$NEW_DIR/container-koopa-silverbullet.service"
cp -a "$CFG/.env.example" "$NEW_DIR/.env.example"
# Drop obsolete unit name if present
rm -f "$NEW_DIR/container-koopa-freenotes.service"
echo "STATUS=refreshed_stack_files DIR=$NEW_DIR"
if [[ -z "$img_new" ]]; then
if [[ -n "$img_old" ]]; then
echo "STATUS=tag $OLD_IMG$NEW_IMG"
podman tag "$OLD_IMG" "$NEW_IMG"
else
echo "STATUS=build missing image via build.sh"
bash "$ROOT/scripts/notes/build.sh"
fi
fi
if [[ -n "$ct_new" ]]; then
echo "STATUS=rm stale $NEW_CT"
podman stop -t 10 "$NEW_CT" || true
podman rm "$NEW_CT" || true
fi
echo "STATUS=compose_up"
podman-compose up -d
ok=0
for i in $(seq 1 40); do
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "http://127.0.0.1:${PORT}/" || true)
echo "STATUS=health try=$i code=$code"
case "$code" in
2*|3*) ok=1; break ;;
esac
sleep 3
done
[[ "$ok" -eq 1 ]] || { echo "ERROR: no answer on :${PORT}" >&2; podman-compose ps; exit 1; }
# User systemd unit (optional; ignore failures)
UNIT_SRC="$NEW_DIR/container-koopa-silverbullet.service"
UNIT_DST="${HOME}/.config/systemd/user/container-koopa-silverbullet.service"
if [[ -f "$UNIT_SRC" ]] && command -v systemctl >/dev/null; then
mkdir -p "${HOME}/.config/systemd/user"
cp -a "$UNIT_SRC" "$UNIT_DST"
systemctl --user daemon-reload || true
systemctl --user disable --now container-koopa-freenotes.service 2>/dev/null || true
systemctl --user enable --now container-koopa-silverbullet.service 2>/dev/null || true
echo "STATUS=systemd_user unit=container-koopa-silverbullet.service"
fi
echo "STATUS=ok CONTAINER=$NEW_CT DIR=$NEW_DIR PORT=$PORT"
echo "STATUS=caddy_next sudo bash $ROOT/scripts/caddy/merge-notes-site.sh"
echo "STATUS=caddy_next sudo NOTES_AUTH_SEED=/tmp/silverbullet-basicauth-seed.txt bash $ROOT/scripts/caddy/apply-notes-basicauth.sh"
echo "STATUS=dns_need silverbullet.hacktivism.ch → koopa"

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Copy silverbullet basicauth SoT → /tmp seed for apply-notes-basicauth.sh.
# Run on magikoopa (or any host with unlocked koopa-admin-secrets-enc).
# Does NOT touch /etc/caddy (no sudo). Does NOT print secret contents.
#
# bash scripts/notes/prepare-basicauth-seed.sh
# # then on koopa (after scp seed or shared path):
# sudo NOTES_AUTH_SEED=/tmp/silverbullet-basicauth-seed.txt \
# bash …/scripts/caddy/apply-notes-basicauth.sh
set -euo pipefail
OUT="${NOTES_AUTH_SEED_OUT:-/tmp/silverbullet-basicauth-seed.txt}"
SOT_DEFAULT="${HOME}/git/koopa-admin-secrets-enc/koopa/home-hernani/caddy/silverbullet-basicauth.txt"
SOT="${NOTES_AUTH_SOT:-$SOT_DEFAULT}"
[[ -f "$SOT" ]] || {
echo "ERROR: missing SoT $SOT (unlock git-crypt / set NOTES_AUTH_SOT=)" >&2
exit 1
}
umask 077
cp -a "$SOT" "$OUT"
chmod 600 "$OUT"
echo "STATUS=ok OUT=$OUT BYTES=$(wc -c <"$OUT" | tr -d ' ') NEXT=scp_or_use_NOTES_AUTH_SEED"
echo "STATUS=hint scp $OUT hernani@koopa:/tmp/ && sudo NOTES_AUTH_SEED=$OUT bash …/apply-notes-basicauth.sh"

39
scripts/notes/status.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Thin status for SilverBullet stack on koopa (hernani). Safe read-only.
# bash scripts/notes/status.sh
set -euo pipefail
OLD_DIR="${HOME}/koopa-freenotes"
NEW_DIR="${HOME}/koopa-silverbullet"
OLD_CT=koopa-freenotes
NEW_CT=koopa-silverbullet
OLD_IMG=localhost/koopa-freenotes:cachyos
NEW_IMG=localhost/koopa-silverbullet:cachyos
PORT=9028
echo "STATUS=host HOST=$(hostname -s)"
echo "STATUS=dirs OLD=$([ -d "$OLD_DIR" ] && echo 1 || echo 0) NEW=$([ -d "$NEW_DIR" ] && echo 1 || echo 0)"
if command -v podman >/dev/null 2>&1; then
ct_old=$(podman ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -E "^${OLD_CT} " || true)
ct_new=$(podman ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -E "^${NEW_CT} " || true)
echo "STATUS=ct_old ${ct_old:-none}"
echo "STATUS=ct_new ${ct_new:-none}"
img_old=$(podman images -q "$OLD_IMG" 2>/dev/null || true)
img_new=$(podman images -q "$NEW_IMG" 2>/dev/null || true)
echo "STATUS=img_old ${img_old:-none}"
echo "STATUS=img_new ${img_new:-none}"
else
echo "STATUS=podman missing"
fi
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 "http://127.0.0.1:${PORT}/" 2>/dev/null || echo fail)
echo "STATUS=local_http PORT=$PORT CODE=$code"
if [[ -d "$NEW_DIR" ]]; then
echo "STATUS=cutover done DIR=$NEW_DIR"
elif [[ -d "$OLD_DIR" ]]; then
echo "STATUS=cutover pending DIR=$OLD_DIR NEXT=cutover-to-silverbullet.sh"
else
echo "STATUS=cutover unknown (no live dir)"
fi

23
scripts/notes/up.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Bring up koopa-silverbullet / SilverBullet.
set -euo pipefail
DIR="${HOME}/koopa-silverbullet"
cd "${DIR}"
set -a
# shellcheck disable=SC1091
[[ -f .env ]] && source .env
set +a
PORT="${HOST_PORT:-9028}"
podman-compose up -d
echo "== wait for :${PORT} =="
ok=0
for i in $(seq 1 40); do
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "http://127.0.0.1:${PORT}/" || true)
echo "try $i: $code"
case "$code" in
2*|3*) ok=1; break ;;
esac
sleep 3
done
[[ "$ok" -eq 1 ]] || { echo "ERROR: no answer on ${PORT}"; podman-compose ps; exit 1; }
echo "local ok. public (after Caddy+DNS): https://silverbullet.hacktivism.ch/"

89
scripts/podman-rss.sh Normal file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Human-readable container RSS when rootless podman stats shows 0B / host RAM.
# Cause on openSUSE: user@.service Delegate= (20-defaults-SUSE.conf) clears
# memory/cpu controllers → no memory.current. CPU% from podman stats still works.
# Usage: podman-rss.sh [--json] [NAME_FILTER…]
set -euo pipefail
JSON=0
FILTERS=()
for a in "$@"; do
case "$a" in
--json|-j) JSON=1 ;;
-h|--help)
echo "usage: $0 [--json] [NAME_FILTER…]" >&2
exit 0
;;
*) FILTERS+=("$a") ;;
esac
done
human() {
# stdin: kibibytes integer
awk '
{
k=$1+0
if (k >= 1048576) printf "%.1f GiB", k/1048576
else if (k >= 1024) printf "%.0f MiB", k/1024
else printf "%d KiB", k
}'
}
# CPU map: name -> cpu%
declare -A CPU=()
while IFS=$'\t' read -r name cpu; do
[ -n "$name" ] || continue
CPU["$name"]="$cpu"
done < <(podman stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}' 2>/dev/null || true)
rows=()
total_kib=0
while IFS= read -r cid; do
[ -n "$cid" ] || continue
name=$(podman inspect -f '{{.Name}}' "$cid" 2>/dev/null || true)
[ -n "$name" ] || continue
if [ ${#FILTERS[@]} -gt 0 ]; then
ok=0
for f in "${FILTERS[@]}"; do
case "$name" in *"$f"*) ok=1; break ;; esac
done
[ "$ok" -eq 1 ] || continue
fi
# podman top rss is KiB per process; sum
kib=$(podman top "$cid" -o rss 2>/dev/null | awk 'NR>1{s+=$1} END{print s+0}')
cpu="${CPU[$name]:-?}"
rows+=("${kib}|${name}|${cpu}")
total_kib=$((total_kib + kib))
done < <(podman ps -q)
IFS=$'\n' sorted=($(printf '%s\n' "${rows[@]:-}" | sort -t'|' -k1,1nr))
unset IFS
if [ "$JSON" -eq 1 ]; then
python3 - "$total_kib" "${sorted[@]:-}" <<'PY'
import json, sys
total = int(sys.argv[1]) if len(sys.argv) > 1 else 0
items = []
for row in sys.argv[2:]:
if not row.strip():
continue
kib, name, cpu = row.split("|", 2)
items.append({"name": name, "rss_kib": int(kib), "cpu": cpu})
print(json.dumps({"ok": True, "total_rss_kib": total, "containers": items}, indent=2))
PY
exit 0
fi
printf '%-44s %10s %8s\n' "NAME" "RSS" "CPU%"
printf '%-44s %10s %8s\n' "----" "---" "----"
for row in "${sorted[@]:-}"; do
[ -n "$row" ] || continue
kib="${row%%|*}"
rest="${row#*|}"
name="${rest%%|*}"
cpu="${rest##*|}"
hum=$(printf '%s' "$kib" | human)
printf '%-44s %10s %8s\n' "$name" "$hum" "$cpu"
done
printf '%-44s %10s\n' "TOTAL" "$(printf '%s' "$total_kib" | human)"
echo "# note: RSS via podman top (no memory cgroup); CPU via podman stats" >&2

654
scripts/upgrade-containers.sh Executable file
View file

@ -0,0 +1,654 @@
#!/usr/bin/env bash
# Upgrade hernani podman containers on koopa: per-target dry-run (prod-safe),
# then full backup (compose + mounts/volumes + DB dumps), then apply.
#
# Run on koopa as hernani:
# ~/upgrade-containers.sh # dry-run only (default)
# ~/upgrade-containers.sh --apply # dry-run → backup → upgrade per target
# ~/upgrade-containers.sh --apply --images-only
# ~/upgrade-containers.sh --apply --os-only
# ~/upgrade-containers.sh --apply --recreate-only
# ~/upgrade-containers.sh --apply --only koopa-forgejo,koopa-notes
# ~/upgrade-containers.sh --apply --skip 'taler-hacktivism*,goa-regio-ng'
# ~/upgrade-containers.sh --apply --taler-debs
# ~/upgrade-containers.sh --apply --rebuild-local
# ~/upgrade-containers.sh --apply --include-stopped
# ~/upgrade-containers.sh --apply --backup-max-gb 80
# ~/upgrade-containers.sh --apply --no-backup # emergency only
#
# Per-target --apply flow:
# 1) dry-run checks that do not recreate/restart or mutate packages
# 2) if dry-run fails → skip that target (others continue)
# 3) full backup under ~/backups/container-upgrades/<ts>/<name>/
# 4) if backup fails → skip apply for that target
# 5) pull / recreate / OS upgrade as selected
set -euo pipefail
APPLY=0
DO_IMAGES=1
DO_RECREATE=1
DO_OS=1
DO_TALER_DEBS=0
REBUILD_LOCAL=0
INCLUDE_STOPPED=0
DO_BACKUP=1
BACKUP_MAX_GB=50
ONLY_RAW=""
SKIP_RAW=""
HOME_DIR="${HOME:-/home/hernani}"
ADMIN_LOG="${ADMIN_LOG:-$HOME_DIR/koopa-admin-log}"
BACKUP_ROOT="${BACKUP_ROOT:-$HOME_DIR/backups/container-upgrades}"
RUN_TS="$(date '+%Y%m%d-%H%M%S')"
BACKUP_RUN_DIR="$BACKUP_ROOT/$RUN_TS"
LOG_TS() { date '+%Y-%m-%dT%H:%M:%S%z'; }
log() { printf '[%s] %s\n' "$(LOG_TS)" "$*"; }
run() {
if [[ "$APPLY" -eq 1 ]]; then
log "RUN: $*"
"$@"
else
log "DRY: $*"
fi
}
usage() {
sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
match_glob_list() {
local name="$1" raw="$2" g
[[ -z "$raw" ]] && return 1
raw="${raw//,/ }"
for g in $raw; do
# shellcheck disable=SC2254
case "$name" in
$g) return 0 ;;
esac
done
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage 0 ;;
--apply) APPLY=1; shift ;;
--dry-run) APPLY=0; shift ;;
--images-only) DO_IMAGES=1; DO_RECREATE=0; DO_OS=0; shift ;;
--os-only) DO_IMAGES=0; DO_RECREATE=0; DO_OS=1; shift ;;
--recreate-only) DO_IMAGES=0; DO_RECREATE=1; DO_OS=0; shift ;;
--taler-debs) DO_TALER_DEBS=1; shift ;;
--rebuild-local) REBUILD_LOCAL=1; shift ;;
--include-stopped) INCLUDE_STOPPED=1; shift ;;
--no-backup) DO_BACKUP=0; shift ;;
--backup-max-gb) BACKUP_MAX_GB="${2:-50}"; shift 2 ;;
--backup-max-gb=*) BACKUP_MAX_GB="${1#*=}"; shift ;;
--only) ONLY_RAW="${2:-}"; shift 2 ;;
--skip) SKIP_RAW="${2:-}"; shift 2 ;;
--only=*) ONLY_RAW="${1#*=}"; shift ;;
--skip=*) SKIP_RAW="${1#*=}"; shift ;;
*) log "ERROR: unknown arg: $1"; usage 2 ;;
esac
done
if [[ "$(id -un)" != "hernani" ]] && [[ "${FORCE_USER:-}" != "1" ]]; then
log "WARN: expected user hernani (got $(id -un)); set FORCE_USER=1 to override"
fi
declare -a NAMES=()
declare -A IMG=()
declare -A CDIR=()
declare -A STATUS=() # ok|dry_fail|backup_fail|upgraded|skipped
declare -A NOTE=()
list_cmd=(podman ps --format '{{.Names}}|{{.Image}}')
if [[ "$INCLUDE_STOPPED" -eq 1 ]]; then
list_cmd=(podman ps -a --format '{{.Names}}|{{.Image}}')
fi
while IFS='|' read -r name image; do
[[ -z "$name" ]] && continue
if [[ ! "$name" =~ ^(koopa-|taler-|goa-|mytops-) ]]; then
continue
fi
if [[ -n "$ONLY_RAW" ]] && ! match_glob_list "$name" "$ONLY_RAW"; then
continue
fi
if match_glob_list "$name" "$SKIP_RAW"; then
log "SKIP name=$name (matched --skip)"
continue
fi
NAMES+=("$name")
IMG["$name"]="$image"
dir=$(podman inspect -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$name" 2>/dev/null || true)
CDIR["$name"]="$dir"
STATUS["$name"]="pending"
done < <("${list_cmd[@]}")
if [[ ${#NAMES[@]} -eq 0 ]]; then
log "No matching containers."
exit 0
fi
log "MODE=$([ "$APPLY" -eq 1 ] && echo apply || echo dry-run) images=$DO_IMAGES recreate=$DO_RECREATE os=$DO_OS backup=$DO_BACKUP backup_max_gb=$BACKUP_MAX_GB"
log "Targets (${#NAMES[@]}): ${NAMES[*]}"
if [[ "$APPLY" -eq 1 && "$DO_BACKUP" -eq 1 ]]; then
mkdir -p "$BACKUP_RUN_DIR"
log "BACKUP_DIR=$BACKUP_RUN_DIR"
fi
is_pinned_tag() {
local ref="$1" tag="${1##*:}"
[[ "$ref" != *:* ]] && return 1
case "$tag" in
latest|stable|stable-*|main|master|landing|live|setup|rootless|*-rootless|alpine|*-alpine|[0-9]|[0-9][0-9]|[0-9].*-alpine|[0-9][0-9]-alpine) return 1 ;;
esac
if [[ "$tag" =~ ^[0-9]+\.[0-9]+ ]]; then
return 0
fi
return 1
}
# --- prod-safe dry-run for one container (no recreate, no package mutate) ---
dry_run_one() {
local name="$1" ref="${IMG[$name]}" dir="${CDIR[$name]}"
local rc=0
local tmp
tmp=$(mktemp)
# running?
if ! podman inspect "$name" >/dev/null 2>&1; then
log "DRYFAIL $name: inspect failed"
NOTE["$name"]="inspect failed"
rm -f "$tmp"
return 1
fi
# image phase checks
if [[ "$DO_IMAGES" -eq 1 ]]; then
if [[ "$ref" == localhost/* ]]; then
if ! podman image exists "$ref" 2>/dev/null; then
log "DRYFAIL $name: local image missing $ref"
NOTE["$name"]="local image missing"
rm -f "$tmp"
return 1
fi
log "DRYOK $name: local image present $ref"
else
# pull --dry-run is not universal; probe registry without replacing running CT
if podman pull --help 2>&1 | grep -q -- '--quiet'; then
:
fi
if ! podman image exists "$ref" 2>/dev/null; then
log "DRYWARN $name: image not local yet ($ref) — apply will pull"
else
log "DRYOK $name: image present locally $ref"
fi
# non-mutating registry probe when possible
if command -v curl >/dev/null 2>&1; then
:
fi
# Try a no-restart pull into local store is mutating cache but not prod CT.
# For dry-run we only validate the ref string / inspect local digest.
if is_pinned_tag "$ref"; then
log "DRYOK $name: pinned tag $ref (compose bump needed to move)"
fi
fi
fi
# compose config validate (no up)
if [[ "$DO_RECREATE" -eq 1 ]]; then
if [[ -n "$dir" ]]; then
if [[ ! -f "$dir/compose.yml" && ! -f "$dir/compose.yaml" && ! -f "$dir/docker-compose.yml" ]]; then
log "DRYFAIL $name: compose dir without compose file: $dir"
NOTE["$name"]="no compose file"
rm -f "$tmp"
return 1
fi
if ! (cd "$dir" && podman-compose config >/dev/null 2>"$tmp"); then
log "DRYFAIL $name: podman-compose config failed"
sed -n '1,20p' "$tmp" | while IFS= read -r line; do log " $line"; done
NOTE["$name"]="compose config failed"
rm -f "$tmp"
return 1
fi
log "DRYOK $name: compose config ok ($dir)"
else
log "DRYOK $name: no compose label — recreate will be skipped"
fi
fi
# OS simulate (no real upgrade)
if [[ "$DO_OS" -eq 1 ]]; then
if podman ps --format '{{.Names}}' | grep -qx "$name"; then
local os_script
os_script=$(cat <<'INNER'
set -e
export DEBIAN_FRONTEND=noninteractive
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq
apt-get -s -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade >/tmp/os-dry.out
echo OS_DRY=apt ok
elif command -v apk >/dev/null 2>&1; then
apk update >/dev/null
apk upgrade --simulate >/tmp/os-dry.out 2>&1 || apk upgrade -s >/tmp/os-dry.out 2>&1 || true
echo OS_DRY=apk ok
elif command -v zypper >/dev/null 2>&1; then
zypper -n refresh >/dev/null
zypper -n update -D >/tmp/os-dry.out 2>&1 || true
echo OS_DRY=zypper ok
elif command -v microdnf >/dev/null 2>&1; then
echo OS_DRY=microdnf none-sim
elif command -v dnf >/dev/null 2>&1; then
dnf -y check-update >/tmp/os-dry.out 2>&1 || true
echo OS_DRY=dnf ok
else
echo OS_DRY=none
fi
INNER
)
if podman exec -u 0 "$name" bash -lc "$os_script" >"$tmp" 2>&1 \
|| podman exec -u 0 "$name" sh -lc "$os_script" >"$tmp" 2>&1; then
log "DRYOK $name: OS simulate ($(grep -E '^OS_DRY=' "$tmp" | tail -1 || echo ok))"
else
# many distroless/app images have no pkg manager — treat as ok/skip
if grep -q 'OS_DRY=none' "$tmp" 2>/dev/null; then
log "DRYOK $name: no package manager (OS skip)"
else
log "DRYWARN $name: OS simulate soft-fail (will try apply carefully)"
sed -n '1,8p' "$tmp" | while IFS= read -r line; do log " $line"; done
fi
fi
else
log "DRYOK $name: not running — OS phase will skip"
fi
fi
rm -f "$tmp"
return "$rc"
}
# --- full backup with data ---
backup_one() {
local name="$1"
local dest="$BACKUP_RUN_DIR/$name"
local meta="$dest/META.txt"
local max_bytes=$((BACKUP_MAX_GB * 1024 * 1024 * 1024))
mkdir -p "$dest/binds" "$dest/volumes" "$dest/db" "$dest/compose"
{
echo "name=$name"
echo "image=${IMG[$name]}"
echo "compose_dir=${CDIR[$name]:-}"
echo "ts=$RUN_TS"
} >"$meta"
podman inspect "$name" >"$dest/inspect.json" 2>/dev/null || true
# compose project files
local dir="${CDIR[$name]}"
if [[ -n "$dir" && -d "$dir" ]]; then
for f in compose.yml compose.yaml docker-compose.yml docker-compose.yaml .env; do
if [[ -f "$dir/$f" ]]; then
cp -a "$dir/$f" "$dest/compose/$f"
fi
done
# shallow copy of known config sidecars (not whole tree)
for f in Caddyfile Containerfile Dockerfile; do
[[ -f "$dir/$f" ]] && cp -a "$dir/$f" "$dest/compose/$f"
done
fi
# bind mounts + named volumes from inspect
local mounts
mounts=$(podman inspect -f '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Source}}|{{.Destination}}|{{.RW}}{{println}}{{end}}' "$name" 2>/dev/null || true)
while IFS='|' read -r mtype mname src dst rw; do
[[ -z "$mtype" ]] && continue
# skip ephemeral / host tz
case "$dst" in
/etc/localtime|/etc/timezone|/run|/run/*|/tmp|/tmp/*|/dev|/dev/*|/proc|/proc/*|/sys|/sys/*) continue ;;
esac
case "$src" in
/usr/share/zoneinfo/*|/etc/localtime) continue ;;
esac
if [[ "$mtype" == "bind" ]]; then
if [[ ! -e "$src" ]]; then
log "BACKUPWARN $name: bind missing $src"
continue
fi
local sz
sz=$(du -sb "$src" 2>/dev/null | awk '{print $1}')
if [[ -n "$sz" && "$sz" -gt "$max_bytes" ]]; then
log "BACKUPWARN $name: skip huge bind $src (${sz}B > ${BACKUP_MAX_GB}G); raise --backup-max-gb"
echo "SKIP_HUGE_BIND $src size=$sz" >>"$meta"
continue
fi
local base
base=$(echo "$dst" | sed 's#^/##; s#/#_#g')
[[ -z "$base" ]] && base="root"
log "BACKUP $name: bind $src → binds/${base}.tar.gz"
if ! tar -C "$(dirname "$src")" -czf "$dest/binds/${base}.tar.gz" "$(basename "$src")" 2>>"$dest/backup.err"; then
log "BACKUPFAIL $name: tar bind $src"
NOTE["$name"]="backup bind failed: $src"
return 1
fi
echo "BIND $src -> binds/${base}.tar.gz" >>"$meta"
elif [[ "$mtype" == "volume" ]]; then
local vname="${mname:-}"
if [[ -z "$vname" ]]; then
# fallback: export via source path
if [[ -d "$src" ]]; then
local vbase
vbase=$(basename "$(dirname "$src")")
log "BACKUP $name: volume-path $src → volumes/${vbase}.tar.gz"
if ! tar -C "$src" -czf "$dest/volumes/${vbase}.tar.gz" . 2>>"$dest/backup.err"; then
log "BACKUPFAIL $name: tar volume path $src"
NOTE["$name"]="backup volume failed"
return 1
fi
echo "VOLPATH $src -> volumes/${vbase}.tar.gz" >>"$meta"
fi
continue
fi
local sz
sz=$(du -sb "$src" 2>/dev/null | awk '{print $1}')
if [[ -n "$sz" && "$sz" -gt "$max_bytes" ]]; then
log "BACKUPWARN $name: skip huge volume $vname (${sz}B > ${BACKUP_MAX_GB}G)"
echo "SKIP_HUGE_VOL $vname size=$sz" >>"$meta"
continue
fi
log "BACKUP $name: volume $vname → volumes/${vname}.tar"
if podman volume export "$vname" -o "$dest/volumes/${vname}.tar" 2>>"$dest/backup.err"; then
gzip -f "$dest/volumes/${vname}.tar" 2>/dev/null || true
echo "VOLUME $vname -> volumes/${vname}.tar.gz" >>"$meta"
else
# fallback tar of mount source
if [[ -d "$src" ]] && tar -C "$src" -czf "$dest/volumes/${vname}.tar.gz" . 2>>"$dest/backup.err"; then
echo "VOLPATH $src -> volumes/${vname}.tar.gz" >>"$meta"
else
log "BACKUPFAIL $name: volume $vname"
NOTE["$name"]="backup volume failed: $vname"
return 1
fi
fi
fi
done <<<"$mounts"
# DB dumps when obvious
if podman ps --format '{{.Names}}' | grep -qx "$name"; then
local img_l
img_l=$(echo "${IMG[$name]}" | tr '[:upper:]' '[:lower:]')
case "$img_l" in
*postgres*|*postgis*)
local dbuser
dbuser=$(podman exec "$name" printenv POSTGRES_USER 2>/dev/null || echo postgres)
local dbname
dbname=$(podman exec "$name" printenv POSTGRES_DB 2>/dev/null || echo postgres)
log "BACKUP $name: pg_dumpall"
if podman exec -u 0 "$name" bash -lc "pg_dumpall -U '$dbuser'" >"$dest/db/pg_dumpall.sql" 2>>"$dest/backup.err"; then
gzip -f "$dest/db/pg_dumpall.sql"
echo "PG_DUMPALL ok db=$dbname user=$dbuser" >>"$meta"
else
# single-db fallback
if podman exec -u 0 "$name" bash -lc "pg_dump -U '$dbuser' '$dbname'" >"$dest/db/pg_dump.sql" 2>>"$dest/backup.err"; then
gzip -f "$dest/db/pg_dump.sql"
echo "PG_DUMP ok db=$dbname" >>"$meta"
else
log "BACKUPWARN $name: postgres dump failed (mounts still saved)"
fi
fi
;;
*mariadb*|*mysql*)
local muser mpass mdb
muser=$(podman exec "$name" printenv MYSQL_USER 2>/dev/null || podman exec "$name" printenv MARIADB_USER 2>/dev/null || echo root)
mpass=$(podman exec "$name" printenv MYSQL_ROOT_PASSWORD 2>/dev/null || podman exec "$name" printenv MARIADB_ROOT_PASSWORD 2>/dev/null || podman exec "$name" printenv MYSQL_PASSWORD 2>/dev/null || true)
mdb=$(podman exec "$name" printenv MYSQL_DATABASE 2>/dev/null || podman exec "$name" printenv MARIADB_DATABASE 2>/dev/null || echo "")
log "BACKUP $name: mariadb/mysql dump"
if [[ -n "$mpass" ]]; then
if podman exec -u 0 "$name" bash -lc "MYSQL_PWD='$mpass' mysqldump -u root --all-databases" >"$dest/db/mysql_all.sql" 2>>"$dest/backup.err" \
|| podman exec -u 0 "$name" bash -lc "MYSQL_PWD='$mpass' mariadb-dump -u root --all-databases" >"$dest/db/mysql_all.sql" 2>>"$dest/backup.err"; then
gzip -f "$dest/db/mysql_all.sql"
echo "MYSQL_DUMPALL ok" >>"$meta"
else
log "BACKUPWARN $name: mysql dump failed (mounts still saved)"
fi
else
log "BACKUPWARN $name: no mysql password in env — skip dump"
fi
;;
esac
fi
echo "BACKUP_OK" >>"$meta"
log "BACKUPOK $name$dest"
return 0
}
# --- apply phases for one container ---
apply_images_one() {
local name="$1" ref="${IMG[$name]}"
[[ "$DO_IMAGES" -eq 1 ]] || return 0
if [[ "$ref" == localhost/* ]]; then
log "LOCAL image=$ref (container=$name) — pull skipped"
return 0
fi
if is_pinned_tag "$ref"; then
log "PINNED image=$ref (container=$name) — pull same tag only"
fi
run podman pull "$ref"
}
rebuild_one_local() {
local name="$1" ref="${IMG[$name]}" dir="${CDIR[$name]}"
[[ "$ref" == localhost/* ]] || return 0
local cf=""
if [[ -n "$dir" && -f "$dir/Containerfile" ]]; then
cf="$dir/Containerfile"
elif [[ -n "$dir" && -f "$dir/Dockerfile" ]]; then
cf="$dir/Dockerfile"
fi
case "$name" in
koopa-nym)
[[ -x "$ADMIN_LOG/scripts/nym/build.sh" ]] && { run bash "$ADMIN_LOG/scripts/nym/build.sh"; return; }
;;
koopa-tor-relay)
if [[ -f "$HOME_DIR/koopa-tor-relay/build/Containerfile" ]]; then
run podman build -t localhost/koopa-tor-relay:latest -f "$HOME_DIR/koopa-tor-relay/build/Containerfile" "$HOME_DIR/koopa-tor-relay/build"
return
fi
;;
koopa-paivana)
if [[ -f "$dir/Containerfile" ]]; then
run podman build -t localhost/koopa-paivana:latest -f "$dir/Containerfile" "$dir"
return
fi
;;
koopa-bbb-front)
[[ -x "$ADMIN_LOG/scripts/bbb/build.sh" ]] && { run bash "$ADMIN_LOG/scripts/bbb/build.sh"; return; }
;;
koopa-meet-web)
[[ -x "$ADMIN_LOG/scripts/meet/build.sh" ]] && { run bash "$ADMIN_LOG/scripts/meet/build.sh"; return; }
;;
koopa-galene|koopa-paivana-galene)
[[ -x "$ADMIN_LOG/scripts/galene/build.sh" ]] && { run bash "$ADMIN_LOG/scripts/galene/build.sh"; return; }
;;
koopa-cryptpad|koopa-cryptpad-edge|koopa-paivana-cryptpad)
[[ -x "$ADMIN_LOG/scripts/cryptpad/build.sh" ]] && { run bash "$ADMIN_LOG/scripts/cryptpad/build.sh"; return; }
;;
koopa-notes|koopa-silverbullet)
[[ -x "$ADMIN_LOG/scripts/notes/build.sh" ]] && { run bash "$ADMIN_LOG/scripts/notes/build.sh"; return; }
;;
esac
if [[ -n "$cf" ]]; then
run podman build -t "$ref" -f "$cf" "$(dirname "$cf")"
else
log "NO-REBUILD path for $name ($ref)"
fi
}
apply_recreate_one() {
local name="$1" dir="${CDIR[$name]}"
[[ "$DO_RECREATE" -eq 1 ]] || return 0
if [[ -z "$dir" ]]; then
log "NO-COMPOSE container=$name — recreate skipped"
return 0
fi
if [[ ! -f "$dir/compose.yml" && ! -f "$dir/compose.yaml" && ! -f "$dir/docker-compose.yml" ]]; then
log "WARN: compose dir without compose file: $dir"
return 0
fi
if [[ "$APPLY" -eq 1 ]]; then
log "RUN: (cd $dir && podman-compose pull && podman-compose up -d)"
(cd "$dir" && podman-compose pull && podman-compose up -d)
else
log "DRY: (cd $dir && podman-compose pull && podman-compose up -d)"
fi
}
os_upgrade_one() {
local name="$1"
[[ "$DO_OS" -eq 1 ]] || return 0
if ! podman ps --format '{{.Names}}' | grep -qx "$name"; then
log "SKIP os name=$name (not running)"
return 0
fi
local script
script=$(cat <<'INNER'
set -e
export DEBIAN_FRONTEND=noninteractive
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade
echo OS=apt ok
elif command -v apk >/dev/null 2>&1; then
apk update
apk upgrade
echo OS=apk ok
elif command -v zypper >/dev/null 2>&1; then
zypper -n refresh
zypper -n update
echo OS=zypper ok
elif command -v microdnf >/dev/null 2>&1; then
microdnf -y upgrade
echo OS=microdnf ok
elif command -v dnf >/dev/null 2>&1; then
dnf -y upgrade
echo OS=dnf ok
else
echo OS=none
exit 0
fi
INNER
)
if [[ "$APPLY" -eq 1 ]]; then
log "RUN: podman exec -u 0 $name OS-upgrade"
if ! podman exec -u 0 "$name" bash -lc "$script"; then
podman exec -u 0 "$name" sh -lc "$script" || log "WARN: OS upgrade failed for $name"
fi
else
log "DRY: podman exec -u 0 $name OS-upgrade"
fi
}
# Deduplicate compose recreate by dir
declare -A COMPOSE_DONE=()
declare -A IMAGE_DONE=()
declare -A BACKUP_DONE=()
process_one() {
local name="$1"
log "==== target: $name (${IMG[$name]}) ===="
# 1) always dry-run first (prod-safe)
if ! dry_run_one "$name"; then
STATUS["$name"]="dry_fail"
log "SKIP apply $name (dry-run failed)"
return 0
fi
STATUS["$name"]="dry_ok"
# global dry-run mode: stop after checks
if [[ "$APPLY" -eq 0 ]]; then
log "DRY-RUN only for $name (no backup/apply)"
return 0
fi
# 2) full backup with data BEFORE any mutate (even after dry-run ok)
# every container — do not skip siblings in a compose stack (DB/data)
if [[ "$DO_BACKUP" -eq 1 ]]; then
if [[ -z "${BACKUP_DONE[$name]:-}" ]]; then
if ! backup_one "$name"; then
STATUS["$name"]="backup_fail"
log "SKIP apply $name (backup failed)"
return 0
fi
BACKUP_DONE["$name"]=1
else
log "BACKUP skip $name (already backed this container)"
fi
else
log "WARN: --no-backup set; proceeding without backup for $name"
fi
# 3) apply
if [[ -z "${IMAGE_DONE[${IMG[$name]}]:-}" ]]; then
apply_images_one "$name" || { STATUS["$name"]="apply_fail"; NOTE["$name"]="image pull failed"; return 0; }
IMAGE_DONE["${IMG[$name]}"]=1
fi
if [[ "$REBUILD_LOCAL" -eq 1 ]]; then
rebuild_one_local "$name" || true
fi
local dir="${CDIR[$name]}"
if [[ -n "$dir" ]]; then
if [[ -z "${COMPOSE_DONE[$dir]:-}" ]]; then
apply_recreate_one "$name" || { STATUS["$name"]="apply_fail"; NOTE["$name"]="recreate failed"; return 0; }
COMPOSE_DONE["$dir"]=1
else
log "recreate skip $name (compose dir already done)"
fi
else
apply_recreate_one "$name" || true
fi
os_upgrade_one "$name" || { STATUS["$name"]="apply_fail"; NOTE["$name"]="os upgrade failed"; return 0; }
STATUS["$name"]="upgraded"
log "DONE $name"
}
# --- main loop: per container ---
for name in "${NAMES[@]}"; do
process_one "$name"
done
# optional Taler GOA debs (only on --apply, after per-CT work)
if [[ "$DO_TALER_DEBS" -eq 1 ]]; then
log "==== phase: taler-debs ===="
tscript="$ADMIN_LOG/scripts/taler-shared/upgrade-goa-debs.sh"
if [[ "$APPLY" -eq 0 ]]; then
log "DRY: bash $tscript"
elif [[ -x "$tscript" ]]; then
run bash "$tscript"
else
log "ERROR: missing $tscript"
exit 1
fi
fi
log "==== summary ===="
fail=0
for name in "${NAMES[@]}"; do
st="${STATUS[$name]:-?}"
printf ' %-42s %-12s %s %s\n' "$name" "$st" "${IMG[$name]}" "${NOTE[$name]:-}"
case "$st" in
dry_fail|backup_fail|apply_fail) fail=1 ;;
esac
done
if [[ "$APPLY" -eq 0 ]]; then
log "Dry-run only (default). Re-run with --apply for backup+upgrade per target that passed dry-run."
fi
if [[ "$fail" -eq 1 ]]; then
log "STATUS=partial_fail"
exit 1
fi
log "STATUS=ok"