diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-10-31 22:45:58 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-10-31 22:45:58 +0900 |
| commit | 05e1418efa56a773c295a2e072796e220d8d8f51 (patch) | |
| tree | 5669d97650919db74e6f5f0174880e6e9221d770 /hetzner-admin | |
| parent | f48002a02c7b75de8add369672c861f63e900df4 (diff) | |
hetzner-admin: stop/start individual services, shown as STOPPED on status page
- run-command.sh: services / stop <name> / start <name> for app and infra
stacks (not caddy, hetzner-admin, status). Stopped names go to
status/stopped.txt (gitignored).
- status: a stopped service that does not respond is STOPPED (gray),
excluded from uptime and DOWN alerts. If it responds it is UP as usual.
- Infra deploy.sh skips stopped stacks; app repo pushes clear the flag.
- claude-code@.service: Restart=on-failure, and restart-sessions.sh restarts
one unit at a time, to avoid the tmux "server exited unexpectedly" race.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5eS3U54gzjRXzm65Np1RS
Diffstat (limited to 'hetzner-admin')
| -rw-r--r-- | hetzner-admin/app.py | 57 | ||||
| -rwxr-xr-x | hetzner-admin/run-command.sh | 41 |
2 files changed, 97 insertions, 1 deletions
diff --git a/hetzner-admin/app.py b/hetzner-admin/app.py index 90a1cca..ac3178c 100644 --- a/hetzner-admin/app.py +++ b/hetzner-admin/app.py @@ -65,6 +65,9 @@ COMMANDS = [ COMMAND_MAP = {label: cmd for label, cmd in COMMANDS} +# 個別に停止・起動できるサービス(run-command.sh の SERVICES と status/targets.txt の名前に合わせる) +SERVICES = ["golive", "whoisband", "blog", "todo", "git", "vpn", "ntfy", "mail"] + # ブラウザ側: JSON ⇔ WebAuthn の変換はブラウザ組み込みの # parseCreationOptionsFromJSON / parseRequestOptionsFromJSON / toJSON に任せる PASSKEY_JS = """<script> @@ -107,6 +110,30 @@ HTML = """<!DOCTYPE html> <body> <h3>Server Admin</h3> <p><a href="https://status.yyamashita.com">Status</a></p> +<h4>Services</h4> +{% if services is string %} +<p style="color:red">{{ services }}</p> +{% else %} +<table> +{% for name, state in services %} +<tr> + <td>{{ name }}</td> + <td style="color:{{ 'green' if state == 'running' else 'gray' }}">{{ state }}</td> + <td> + <form method="post" action="/service" style="margin:0"> + <input type="hidden" name="name" value="{{ name }}"> + {% if state == "running" %} + <button name="action" value="stop" onclick="return confirm('Stop {{ name }}?')">Stop</button> + {% else %} + <button name="action" value="start">Start</button> + {% endif %} + </form> + </td> +</tr> +{% endfor %} +</table> +{% endif %} +<h4>Commands</h4> {% for label, _ in commands %} <form method="post" action="/run" style="display:block;margin-bottom:8px"> <input type="hidden" name="cmd" value="{{ label }}"> @@ -177,9 +204,23 @@ def run_ssh(command): client.close() +def load_services(): + """[(name, "running"|"stopped"), ...]。取得できなければエラーメッセージ(str)""" + try: + out = run_ssh("services") + except Exception as e: + return f"Error loading services: {e}" + states = dict(line.split(None, 1) for line in out.splitlines() if len(line.split()) == 2) + return [(name, states.get(name, "unknown")) for name in SERVICES] + + def render_index(output=None): return render_template_string( - HTML, commands=COMMANDS, output=output, passkey_count=len(load_passkeys()) + HTML, + commands=COMMANDS, + services=load_services(), + output=output, + passkey_count=len(load_passkeys()), ) @@ -305,3 +346,17 @@ def run(): except Exception as e: output = f"Error: {e}" return render_index(output) + + +@app.route("/service", methods=["POST"]) +@requires_auth +def service(): + name = request.form.get("name", "") + action = request.form.get("action", "") + if name not in SERVICES or action not in ("stop", "start"): + return render_index(f"Unknown service action: {action} {name}") + try: + output = run_ssh(f"{action} {name}") + except Exception as e: + output = f"Error: {e}" + return render_index(output) diff --git a/hetzner-admin/run-command.sh b/hetzner-admin/run-command.sh index 0ed9fe3..c5b8ea5 100755 --- a/hetzner-admin/run-command.sh +++ b/hetzner-admin/run-command.sh @@ -2,7 +2,48 @@ # SSH authorized_keys の command= から呼ばれる。$SSH_ORIGINAL_COMMAND をホワイトリストで検証する。 set -e +# 管理画面から停止・起動できるサービス(名前は status/targets.txt の名前に合わせる) +# Caddy / hetzner-admin / status は止めると管理画面に戻れなくなるので対象外 +SERVICES="golive whoisband blog todo git vpn ntfy mail" +service_compose() { + case "$1" in + golive) echo /app/tokyo-livehouse-events/docker-compose.yml ;; + whoisband) echo /app/whois-band/docker-compose.yml ;; + blog) echo /app/microblog/docker-compose.yml ;; + todo) echo /app/todo/docker-compose.yml ;; + git) echo /app/infra/cgit/docker-compose.yml ;; + vpn) echo /app/infra/vpn/docker-compose.yml ;; + ntfy) echo /app/infra/ntfy/docker-compose.yml ;; + mail) echo /app/infra/mail/docker-compose.yml ;; + *) return 1 ;; + esac +} + +# 管理画面で停止したサービスの一覧。status-app が読み、DOWN ではなく STOPPED と表示する +STOPPED_FILE=/app/infra/status/stopped.txt + case "$SSH_ORIGINAL_COMMAND" in + "services") + touch "$STOPPED_FILE" + for name in $SERVICES; do + running=$(docker compose -f "$(service_compose "$name")" ps -q --status running 2>/dev/null | wc -l) + if [ "$running" -gt 0 ]; then state=running; else state=stopped; fi + echo "$name $state" + done + ;; + "stop "*|"start "*) + action="${SSH_ORIGINAL_COMMAND%% *}" + name="${SSH_ORIGINAL_COMMAND#* }" + compose=$(service_compose "$name") || { echo "Unknown service: $name" >&2; exit 1; } + touch "$STOPPED_FILE" + if [ "$action" = stop ]; then + grep -qx "$name" "$STOPPED_FILE" || echo "$name" >> "$STOPPED_FILE" + docker compose -f "$compose" stop 2>&1 + else + sed -i "/^${name}\$/d" "$STOPPED_FILE" + docker compose -f "$compose" up -d 2>&1 + fi + ;; "docker ps") exec docker ps ;; |
