vibed-infra 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ git add dist && git commit && git push
19
19
 
20
20
  ## Operator flow (VPS)
21
21
 
22
- Non-root agent prep (SSH, Docker group, one-time uid-1000 operator or ~/services ACL): [`skills/agent-vps-prep/SKILL.md`](skills/agent-vps-prep/SKILL.md).
22
+ Non-root agent prep (SSH, Docker group): [`skills/agent-vps-prep/SKILL.md`](skills/agent-vps-prep/SKILL.md).
23
23
 
24
24
  ```bash
25
25
  # 1) DNS — paste dist/DNS-SKILL.md into AU browser agent
@@ -44,7 +44,10 @@ Multi-app: further products’ `install-gateway.sh` only add `apps/{other}/sites
44
44
  |------|---------|
45
45
  | `~/services/gateway` | Host nginx (`GATEWAY_HOME`) |
46
46
  | `~/services/vibed-infra/update-agent` | Serial pull queue; GHCR notify via GitHub Actions OIDC |
47
- | `~/services/vibed-infra/persist-logs` | Per-app WALs + optional R2/S3 ship |
47
+ | `~/services/vibed-infra/persist-logs` | Ship credentials + host cron leftover; per-app WALs live in named volumes |
48
+ | `~/services/vibed-infra/monitor-vibed.sh` | TUI: list deployments, summary, tail docker/volume logs |
49
+
50
+ App durable data (SQLite, persist WALs, worker logs) uses **Docker named volumes** by default — not bind mounts under `~/services`. Digests recreate containers; volumes keep the DB.
48
51
 
49
52
  ## Image updates (docker pull)
50
53
 
@@ -62,7 +65,8 @@ See [`skills/infra-update-agent/SKILL.md`](skills/infra-update-agent/SKILL.md).
62
65
  | `package.sh` / `lib/package.py` | Build `dist/` |
63
66
  | `templates/host-gateway/` | Shared gateway skeleton |
64
67
  | `templates/update-agent/` | Queue agent + webhook |
65
- | `templates/persist-logs/` | Shipper install |
68
+ | `templates/persist-logs/` | Shipper install + persist sidecar |
69
+ | `templates/monitor/` | Machine-wide `monitor-vibed.sh` |
66
70
  | `lib/persistlog/` | Python append / seal / replay |
67
71
  | `skills/` | system-gateway, agent-vps-prep, infra-update-agent, infra-cicd, infra-packager, persist-logs, dns-configure |
68
72
 
@@ -75,7 +79,9 @@ See [`skills/infra-update-agent/SKILL.md`](skills/infra-update-agent/SKILL.md).
75
79
  | `VIBED_UPDATE_AGENT` | Override update-agent dir (default `$VIBED_HOME/update-agent`) |
76
80
  | `GATEWAY_PUBLIC_IP` | VPS IPv4 (from `gateway.publicIp`) |
77
81
  | `TLS_EMAIL` / `TLS_MODE` | Let’s Encrypt email; `lab` or `letsencrypt` (docker certbot → `./certs` PEMs; optional `CERTBOT_IMAGE` / `LETSENCRYPT_HOME`) |
78
- | `PERSIST_LOG_DIR` | Per-service event log dir |
82
+ | `DATA_VOLUME` / `PERSIST_LOG_VOLUME` / `WORKER_LOG_VOLUME` | Named volume overrides (default `{container}-{data,persist,logs}`) |
83
+ | `PERSIST_LOGS` | Set `0` to disable persist volume + sidecar |
84
+ | `DATA_BIND` / `PERSIST_LOG_BIND` / `LOGS_BIND` | Force host bind mounts (debug) |
79
85
  | `DOCKER_AUTO_PRUNE` | Prune dangling images after update (default on) |
80
86
 
81
87
  ## Tests
package/install.sh CHANGED
@@ -397,9 +397,58 @@ else:
397
397
  echo "webhook token registered for ${PRODUCT_NAME}"
398
398
  fi
399
399
  fi
400
- if [[ -f "${PACKAGER_RAW}/templates/persist-logs/install-persist-logs.sh" ]]; then
401
- PACKAGER_RAW="$PACKAGER_RAW" VIBED_HOME="${VIBED_HOME:-}" bash "${PACKAGER_RAW}/templates/persist-logs/install-persist-logs.sh" || true
402
- fi
400
+ # Machine-wide persist-logs + monitor (idempotent; works for path or http PACKAGER_RAW)
401
+ _install_machine_helpers() {
402
+ local helper_packager="$PACKAGER_RAW"
403
+ if [[ "$LOCAL_PACKAGER" != "1" ]]; then
404
+ helper_packager="${DEST}/.infra-packager"
405
+ mkdir -p "$helper_packager/templates/persist-logs/lib" \
406
+ "$helper_packager/templates/monitor" \
407
+ "$helper_packager/lib"
408
+ _fetch "${PACKAGER_RAW}/templates/persist-logs/install-persist-logs.sh" \
409
+ "$helper_packager/templates/persist-logs/install-persist-logs.sh" || true
410
+ _fetch "${PACKAGER_RAW}/lib/persistlog/ship.py" \
411
+ "$helper_packager/lib/persistlog/ship.py" || true
412
+ # Minimal persistlog package for host cron / sidecar copy
413
+ mkdir -p "$helper_packager/lib/persistlog"
414
+ for f in __init__.py ship.py; do
415
+ _fetch "${PACKAGER_RAW}/lib/persistlog/${f}" \
416
+ "$helper_packager/lib/persistlog/${f}" || true
417
+ done
418
+ _fetch "${PACKAGER_RAW}/templates/monitor/install-monitor.sh" \
419
+ "$helper_packager/templates/monitor/install-monitor.sh" || true
420
+ _fetch "${PACKAGER_RAW}/templates/monitor/monitor-vibed.sh" \
421
+ "$helper_packager/templates/monitor/monitor-vibed.sh" || true
422
+ _fetch "${PACKAGER_RAW}/templates/persist-logs/start-persist-sidecar.sh" \
423
+ "$helper_packager/templates/persist-logs/start-persist-sidecar.sh" || true
424
+ fi
425
+ if [[ -f "${helper_packager}/templates/persist-logs/install-persist-logs.sh" ]]; then
426
+ chmod +x "${helper_packager}/templates/persist-logs/"*.sh 2>/dev/null || true
427
+ PACKAGER_RAW="$helper_packager" VIBED_HOME="${VIBED_HOME:-}" \
428
+ bash "${helper_packager}/templates/persist-logs/install-persist-logs.sh" || true
429
+ fi
430
+ if [[ -f "${helper_packager}/templates/monitor/install-monitor.sh" ]]; then
431
+ chmod +x "${helper_packager}/templates/monitor/"*.sh 2>/dev/null || true
432
+ PACKAGER_RAW="$helper_packager" VIBED_HOME="${VIBED_HOME:-}" \
433
+ bash "${helper_packager}/templates/monitor/install-monitor.sh" || true
434
+ fi
435
+ if [[ "$ROLE" == "backend" || "$ROLE" == "workers" ]]; then
436
+ # Prefer product dist copy when present; else packager template.
437
+ if [[ -f "${PRODUCT_RAW}/start-persist-sidecar.sh" ]]; then
438
+ cp -f "${PRODUCT_RAW}/start-persist-sidecar.sh" "$DEST/start-persist-sidecar.sh"
439
+ elif [[ "${PRODUCT_RAW}" =~ ^https?:// ]]; then
440
+ _fetch "${PRODUCT_RAW}/start-persist-sidecar.sh" "$DEST/start-persist-sidecar.sh" || true
441
+ elif [[ -f "${helper_packager}/templates/persist-logs/start-persist-sidecar.sh" ]]; then
442
+ cp -f "${helper_packager}/templates/persist-logs/start-persist-sidecar.sh" \
443
+ "$DEST/start-persist-sidecar.sh"
444
+ elif [[ -f "${PACKAGER_RAW}/templates/persist-logs/start-persist-sidecar.sh" ]]; then
445
+ cp -f "${PACKAGER_RAW}/templates/persist-logs/start-persist-sidecar.sh" \
446
+ "$DEST/start-persist-sidecar.sh"
447
+ fi
448
+ chmod +x "$DEST/start-persist-sidecar.sh" 2>/dev/null || true
449
+ fi
450
+ }
451
+ _install_machine_helpers
403
452
 
404
453
  INFRA_PROFILE="$PROFILE" ./install-auto-update.sh || true
405
454
 
package/lib/env.sh CHANGED
@@ -209,6 +209,7 @@ memory_args() {
209
209
  }
210
210
 
211
211
  chown_data_dir() {
212
+ # Legacy bind-mount helper (DATA_BIND=1). Named volumes do not use this.
212
213
  local dir="$1"
213
214
  local uid="${2:-1000}"
214
215
  mkdir -p "$dir"
@@ -223,6 +224,146 @@ chown_data_dir() {
223
224
  chmod 755 "$dir" 2>/dev/null || true
224
225
  }
225
226
 
227
+ # Create a durable named volume (idempotent). Never removes existing volumes.
228
+ # Sets VIBED_VOLUME_CREATED=1 when a new volume was created, else 0.
229
+ # Extra label key=value pairs after role/container are optional.
230
+ vibed_ensure_volume() {
231
+ local name="$1"
232
+ local role="${2:-}"
233
+ local container="${3:-}"
234
+ shift 3 || true
235
+ VIBED_VOLUME_CREATED=0
236
+ local -a labels=(--label "vibed.managed=1")
237
+ [[ -n "$role" ]] && labels+=(--label "vibed.role=${role}")
238
+ [[ -n "$container" ]] && labels+=(--label "vibed.container=${container}")
239
+ local kv
240
+ for kv in "$@"; do
241
+ [[ -n "$kv" ]] && labels+=(--label "$kv")
242
+ done
243
+ if docker volume inspect "$name" >/dev/null 2>&1; then
244
+ return 0
245
+ fi
246
+ docker volume create "${labels[@]}" "$name" >/dev/null
247
+ VIBED_VOLUME_CREATED=1
248
+ }
249
+
250
+ # True if dir exists and has any entries (including hidden except . and ..).
251
+ _vibed_host_dir_has_files() {
252
+ local dir="$1"
253
+ [[ -d "$dir" ]] || return 1
254
+ local f
255
+ for f in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do
256
+ [[ -e "$f" ]] && return 0
257
+ done
258
+ return 1
259
+ }
260
+
261
+ # One-time copy from a legacy host bind dir into a newly created empty named volume.
262
+ # Do NOT mount the volume before the app unless migrating — any first mount can
263
+ # initialize ownership from that helper image instead of the app image.
264
+ vibed_migrate_host_dir() {
265
+ local vol="$1"
266
+ local host_dir="$2"
267
+ local newly_created="${3:-0}"
268
+ [[ -n "$vol" && -n "$host_dir" ]] || return 0
269
+ [[ "$newly_created" == "1" ]] || return 0
270
+ _vibed_host_dir_has_files "$host_dir" || return 0
271
+ local abs
272
+ abs="$(cd "$host_dir" && pwd)"
273
+ echo "Migrating $abs → volume $vol (keeping host copy) ..."
274
+ docker run --rm \
275
+ -v "${vol}:/dest" \
276
+ -v "${abs}:/src:ro" \
277
+ alpine sh -c 'cp -a /src/. /dest/' >/dev/null
278
+ }
279
+
280
+ # Resolve data storage: named volume (default) vs host bind (DATA_BIND=1 or non-default DATA_DIR).
281
+ # Sets: VIBED_DATA_MODE=volume|bind, VIBED_DATA_VOLUME, VIBED_DATA_BIND_PATH
282
+ vibed_resolve_data_storage() {
283
+ local container_name="$1"
284
+ local role="${2:-api}"
285
+ VIBED_DATA_MODE=volume
286
+ VIBED_DATA_VOLUME="${DATA_VOLUME:-${container_name}-data}"
287
+ VIBED_DATA_BIND_PATH=""
288
+ if env_flag_on DATA_BIND; then
289
+ VIBED_DATA_MODE=bind
290
+ VIBED_DATA_BIND_PATH="${DATA_DIR:-./data}"
291
+ return 0
292
+ fi
293
+ local dd="${DATA_DIR-}"
294
+ if [[ -n "$dd" && "$dd" != "./data" && "$dd" != "data" ]]; then
295
+ VIBED_DATA_MODE=bind
296
+ VIBED_DATA_BIND_PATH="$dd"
297
+ return 0
298
+ fi
299
+ vibed_ensure_volume "$VIBED_DATA_VOLUME" "$role" "$container_name" "vibed.kind=data"
300
+ if [[ -z "$dd" || "$dd" == "./data" || "$dd" == "data" ]]; then
301
+ vibed_migrate_host_dir "$VIBED_DATA_VOLUME" "./data" "${VIBED_VOLUME_CREATED:-0}"
302
+ fi
303
+ }
304
+
305
+ # Persist logs: default named volume + sidecar. PERSIST_LOGS=0 disables.
306
+ # Sets: VIBED_PERSIST_MODE=volume|bind|off, VIBED_PERSIST_VOLUME, VIBED_PERSIST_BIND_PATH
307
+ vibed_resolve_persist_storage() {
308
+ local container_name="$1"
309
+ local role="${2:-api}"
310
+ VIBED_PERSIST_MODE=volume
311
+ VIBED_PERSIST_VOLUME="${PERSIST_LOG_VOLUME:-${container_name}-persist}"
312
+ VIBED_PERSIST_BIND_PATH=""
313
+ if [[ -n "${PERSIST_LOGS+x}" ]] && ! env_flag_on PERSIST_LOGS; then
314
+ VIBED_PERSIST_MODE=off
315
+ return 0
316
+ fi
317
+ if env_flag_on PERSIST_LOG_BIND; then
318
+ VIBED_PERSIST_MODE=bind
319
+ VIBED_PERSIST_BIND_PATH="${PERSIST_LOG_DIR:-./persist-logs}"
320
+ return 0
321
+ fi
322
+ local pd="${PERSIST_LOG_DIR-}"
323
+ if [[ -n "$pd" && "$pd" != "./persist-logs" && "$pd" != "persist-logs" ]]; then
324
+ VIBED_PERSIST_MODE=bind
325
+ VIBED_PERSIST_BIND_PATH="$pd"
326
+ return 0
327
+ fi
328
+ vibed_ensure_volume "$VIBED_PERSIST_VOLUME" "$role" "$container_name" "vibed.kind=persist"
329
+ if [[ -z "$pd" || "$pd" == "./persist-logs" || "$pd" == "persist-logs" ]]; then
330
+ vibed_migrate_host_dir "$VIBED_PERSIST_VOLUME" "./persist-logs" "${VIBED_VOLUME_CREATED:-0}"
331
+ fi
332
+ }
333
+
334
+ # Worker logs volume (default) or bind.
335
+ # Sets: VIBED_LOGS_MODE=volume|bind, VIBED_LOGS_VOLUME, VIBED_LOGS_BIND_PATH
336
+ vibed_resolve_logs_storage() {
337
+ local container_name="$1"
338
+ local role="${2:-nodes}"
339
+ VIBED_LOGS_MODE=volume
340
+ VIBED_LOGS_VOLUME="${WORKER_LOG_VOLUME:-${container_name}-logs}"
341
+ VIBED_LOGS_BIND_PATH=""
342
+ if env_flag_on LOGS_BIND; then
343
+ VIBED_LOGS_MODE=bind
344
+ VIBED_LOGS_BIND_PATH="${LOGS_DIR:-./logs}"
345
+ return 0
346
+ fi
347
+ local ld="${LOGS_DIR-}"
348
+ if [[ -n "$ld" && "$ld" != "./logs" && "$ld" != "logs" ]]; then
349
+ VIBED_LOGS_MODE=bind
350
+ VIBED_LOGS_BIND_PATH="$ld"
351
+ return 0
352
+ fi
353
+ vibed_ensure_volume "$VIBED_LOGS_VOLUME" "$role" "$container_name" "vibed.kind=logs"
354
+ if [[ -z "$ld" || "$ld" == "./logs" || "$ld" == "logs" ]]; then
355
+ vibed_migrate_host_dir "$VIBED_LOGS_VOLUME" "./logs" "${VIBED_VOLUME_CREATED:-0}"
356
+ fi
357
+ }
358
+
359
+ vibed_home_dir() {
360
+ if [[ -n "${VIBED_HOME:-}" ]]; then
361
+ echo "${VIBED_HOME/#\~/$HOME}"
362
+ return 0
363
+ fi
364
+ echo "${HOME}/services/vibed-infra"
365
+ }
366
+
226
367
  # Default on during auto-update. Set DOCKER_AUTO_PRUNE=0 to disable.
227
368
  docker_auto_prune_on() {
228
369
  if [[ -n "${DOCKER_AUTO_PRUNE+x}" ]]; then
package/lib/package.py CHANGED
@@ -45,10 +45,17 @@ DOCKER_NETWORK={meta['network']}
45
45
  DOCKER_NAME={meta['apiContainer']}
46
46
  BACKEND_IMAGE={conf['images']['backend']}
47
47
  HOST_PORT={meta['apiPort']}
48
- DATA_DIR=./data
48
+ # Durable data uses Docker named volume ${{DOCKER_NAME}}-data by default.
49
+ # DATA_VOLUME=custom-data-vol
50
+ # DATA_BIND=1
51
+ # DATA_DIR=./data
49
52
  CONFIG_FILE=api-app.yaml
50
53
  API_MEMORY_LIMIT=256m
51
- PERSIST_LOG_DIR=./persist-logs
54
+ # Persist logs: named volume ${{DOCKER_NAME}}-persist + ship sidecar (default on).
55
+ # PERSIST_LOG_VOLUME=custom-persist-vol
56
+ # PERSIST_LOGS=0
57
+ # PERSIST_LOG_BIND=1
58
+ # PERSIST_LOG_DIR=./persist-logs
52
59
  {au['flag']}=0
53
60
  {au['intervalEnv']}=30
54
61
  {au['stopTimeoutEnv']}=60
@@ -65,7 +72,6 @@ DOCKER_NETWORK={meta['network']}
65
72
  UI_NAME={meta['uiContainer']}
66
73
  UI_IMAGE={conf['images']['ui']}
67
74
  UI_MEMORY_LIMIT=32m
68
- PERSIST_LOG_DIR=./persist-logs
69
75
  {au['flag']}=0
70
76
  {au['intervalEnv']}=20
71
77
  {au['stopTimeoutEnv']}=30
@@ -87,7 +93,12 @@ WORKER_CONTAINER_NAME={meta['workerContainer']}
87
93
  WORKER_ROLE=heartbeat
88
94
  INTERVAL_SEC=60
89
95
  WORKER_MEMORY_LIMIT=64m
90
- PERSIST_LOG_DIR=./persist-logs
96
+ # Logs + persist use named volumes by default (${{WORKER_CONTAINER_NAME}}-logs / -persist).
97
+ # WORKER_LOG_VOLUME=
98
+ # PERSIST_LOG_VOLUME=
99
+ # PERSIST_LOGS=0
100
+ # LOGS_BIND=1
101
+ # LOGS_DIR=./logs
91
102
  {au['flag']}=0
92
103
  {au['intervalEnv']}=30
93
104
  {au['stopTimeoutEnv']}=30
@@ -186,6 +197,14 @@ networks:
186
197
  external: true
187
198
  name: ${{DOCKER_NETWORK:-{meta['network']}}}
188
199
 
200
+ volumes:
201
+ worker_logs:
202
+ external: true
203
+ name: ${{WORKER_LOG_VOLUME:-{meta['workerContainer']}-logs}}
204
+ worker_persist:
205
+ external: true
206
+ name: ${{PERSIST_LOG_VOLUME:-{meta['workerContainer']}-persist}}
207
+
189
208
  services:
190
209
  worker:
191
210
  image: ${{WORKER_IMAGE:-{conf['images']['worker']}}}
@@ -193,13 +212,20 @@ services:
193
212
  restart: unless-stopped
194
213
  mem_limit: ${{WORKER_MEMORY_LIMIT:-64m}}
195
214
  env_file: .env
215
+ labels:
216
+ vibed.managed: "1"
217
+ vibed.role: nodes
218
+ vibed.logs-volume: ${{WORKER_LOG_VOLUME:-{meta['workerContainer']}-logs}}
219
+ vibed.persist-volume: ${{PERSIST_LOG_VOLUME:-{meta['workerContainer']}-persist}}
196
220
  environment:
197
221
  WORKER_ROLE: ${{WORKER_ROLE:-heartbeat}}
198
222
  WORKER_CONFIG: /config/workers.yaml
199
223
  API_URL: ${{API_URL:-http://{meta['apiContainer']}:{meta['apiPort']}}}
200
224
  INTERVAL_SEC: ${{INTERVAL_SEC:-60}}
225
+ PERSIST_LOG_DIR: /persist-logs
201
226
  volumes:
202
- - ./logs:/data/logs
227
+ - worker_logs:/data/logs
228
+ - worker_persist:/persist-logs
203
229
  - ./nodes-workers.yaml:/config/workers.yaml:ro
204
230
  extra_hosts:
205
231
  - "host.docker.internal:host-gateway"
@@ -308,6 +334,11 @@ def build_dist(
308
334
  ):
309
335
  _copy_generic(packager_root, out_dir, script)
310
336
 
337
+ sidecar = packager_root / "templates" / "persist-logs" / "start-persist-sidecar.sh"
338
+ if sidecar.is_file():
339
+ shutil.copy2(sidecar, out_dir / "start-persist-sidecar.sh")
340
+ _chmod_x(out_dir / "start-persist-sidecar.sh")
341
+
311
342
  # Bake HTTP URLs into install wrappers when packaging for wget serve; else GitHub default.
312
343
  if packager_raw_val.startswith("http://") or packager_raw_val.startswith("https://"):
313
344
  wrapper_packager = packager_raw_val
@@ -2,18 +2,105 @@
2
2
  """Ship sealed persist-log segments to R2/S3-compatible storage (batched PUTs)."""
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
6
+ import hmac
5
7
  import os
8
+ import shutil
9
+ import subprocess
6
10
  import sys
7
11
  import urllib.error
8
12
  import urllib.request
13
+ from datetime import datetime, timezone
9
14
  from pathlib import Path
15
+ from urllib.parse import quote, urlsplit
10
16
 
11
- # Optional: use aws CLI if present (works with R2 endpoint)
12
- def ship_file(local: Path, key: str, endpoint: str, bucket: str, access: str, secret: str) -> None:
13
- # Prefer aws CLI for SigV4
14
- import shutil
15
- import subprocess
16
17
 
18
+ def _sigv4_put(
19
+ local: Path,
20
+ key: str,
21
+ endpoint: str,
22
+ bucket: str,
23
+ access: str,
24
+ secret: str,
25
+ region: str = "auto",
26
+ ) -> None:
27
+ """Minimal SigV4 PUT for S3/R2-compatible endpoints (stdlib only)."""
28
+ body = local.read_bytes()
29
+ payload_hash = hashlib.sha256(body).hexdigest()
30
+ now = datetime.now(timezone.utc)
31
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
32
+ date_stamp = now.strftime("%Y%m%d")
33
+
34
+ parts = urlsplit(endpoint.rstrip("/"))
35
+ host = parts.netloc
36
+ # Virtual-hosted style preferred for R2: https://bucket.account.r2.cloudflarestorage.com/key
37
+ # Path style: https://endpoint/bucket/key
38
+ if host.startswith(f"{bucket}."):
39
+ url_path = "/" + quote(key, safe="/")
40
+ canonical_uri = url_path
41
+ host_header = host
42
+ else:
43
+ url_path = f"/{bucket}/{quote(key, safe='/')}"
44
+ canonical_uri = f"/{bucket}/{quote(key, safe='/')}"
45
+ host_header = host
46
+
47
+ service = "s3"
48
+ credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
49
+ canonical_headers = (
50
+ f"host:{host_header}\n"
51
+ f"x-amz-content-sha256:{payload_hash}\n"
52
+ f"x-amz-date:{amz_date}\n"
53
+ )
54
+ signed_headers = "host;x-amz-content-sha256;x-amz-date"
55
+ canonical_request = "\n".join(
56
+ [
57
+ "PUT",
58
+ canonical_uri,
59
+ "",
60
+ canonical_headers,
61
+ signed_headers,
62
+ payload_hash,
63
+ ]
64
+ )
65
+ string_to_sign = "\n".join(
66
+ [
67
+ "AWS4-HMAC-SHA256",
68
+ amz_date,
69
+ credential_scope,
70
+ hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
71
+ ]
72
+ )
73
+
74
+ def _sign(key: bytes, msg: str) -> bytes:
75
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
76
+
77
+ k_date = _sign(("AWS4" + secret).encode("utf-8"), date_stamp)
78
+ k_region = _sign(k_date, region)
79
+ k_service = _sign(k_region, service)
80
+ k_signing = _sign(k_service, "aws4_request")
81
+ signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
82
+ authorization = (
83
+ f"AWS4-HMAC-SHA256 Credential={access}/{credential_scope}, "
84
+ f"SignedHeaders={signed_headers}, Signature={signature}"
85
+ )
86
+
87
+ url = f"{parts.scheme}://{host_header}{url_path}"
88
+ req = urllib.request.Request(url, data=body, method="PUT")
89
+ req.add_header("Host", host_header)
90
+ req.add_header("x-amz-content-sha256", payload_hash)
91
+ req.add_header("x-amz-date", amz_date)
92
+ req.add_header("Authorization", authorization)
93
+ req.add_header("Content-Length", str(len(body)))
94
+ try:
95
+ with urllib.request.urlopen(req, timeout=120) as resp:
96
+ if resp.status not in (200, 201, 204):
97
+ raise RuntimeError(f"PUT {url} returned {resp.status}")
98
+ except urllib.error.HTTPError as exc:
99
+ detail = exc.read().decode("utf-8", errors="replace")[:500]
100
+ raise RuntimeError(f"PUT {url} failed: {exc.code} {detail}") from exc
101
+
102
+
103
+ def ship_file(local: Path, key: str, endpoint: str, bucket: str, access: str, secret: str) -> None:
17
104
  if shutil.which("aws"):
18
105
  env = os.environ.copy()
19
106
  env["AWS_ACCESS_KEY_ID"] = access
@@ -30,7 +117,8 @@ def ship_file(local: Path, key: str, endpoint: str, bucket: str, access: str, se
30
117
  ]
31
118
  subprocess.run(cmd, check=True, env=env)
32
119
  return
33
- raise RuntimeError("aws CLI required for persist-log ship (or set PERSIST_SHIP=0)")
120
+ region = os.environ.get("R2_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "auto"
121
+ _sigv4_put(local, key, endpoint, bucket, access, secret, region=region)
34
122
 
35
123
 
36
124
  def main() -> int:
@@ -43,6 +131,7 @@ def main() -> int:
43
131
  access = os.environ.get("R2_ACCESS_KEY_ID") or os.environ.get("AWS_ACCESS_KEY_ID") or ""
44
132
  secret = os.environ.get("R2_SECRET_ACCESS_KEY") or os.environ.get("AWS_SECRET_ACCESS_KEY") or ""
45
133
  machine = os.environ.get("PERSIST_MACHINE_ID") or os.uname().nodename
134
+ prefix = (os.environ.get("PERSIST_SHIP_PREFIX") or "").strip().strip("/")
46
135
  if not endpoint or not bucket or not access or not secret:
47
136
  print("missing R2/S3 credentials — skip ship", file=sys.stderr)
48
137
  return 0
@@ -53,7 +142,10 @@ def main() -> int:
53
142
  if marker.is_file():
54
143
  continue
55
144
  rel = seg.relative_to(base).as_posix()
56
- key = f"{machine}/{rel}"
145
+ if prefix:
146
+ key = f"{machine}/{prefix}/{rel}"
147
+ else:
148
+ key = f"{machine}/{rel}"
57
149
  try:
58
150
  ship_file(seg, key, endpoint, bucket, access, secret)
59
151
  marker.write_text("ok\n", encoding="utf-8")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibed-infra",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Product-agnostic VPS packager: wget install, Docker Compose, TLS/nginx, auto-update",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,7 +43,7 @@ Compiled `packageconfig.yaml` also includes `webhook.url` / `fallbackUrl` (from
43
43
  | `DNS-SKILL.md` | Paste into AU browser agent; includes domains + `publicIp` when set |
44
44
  | `packageconfig.yaml` | compiled config |
45
45
  | `start-*.sh` / `update-*.sh` | lifecycle |
46
- | `.env.*.example` | includes `PERSIST_LOG_DIR`, prune flags |
46
+ | `.env.*.example` | includes named-volume / persist / prune flags |
47
47
 
48
48
  Gateway install writes `$GATEWAY_HOME/apps/{name}/sites.conf` (host-extension mode).
49
49
 
@@ -57,9 +57,13 @@ Install registers each role with the machine **update-agent**. Pulls are serial
57
57
 
58
58
  `webhook.token` in compiled packageconfig is only for local curl; CI does not need `VIBED_WEBHOOK_SECRET`.
59
59
 
60
- ## Persist logs
60
+ ## Persist logs + named volumes
61
61
 
62
- `PERSIST_LOG_DIR` per role. See [`skills/persist-logs/SKILL.md`](../skills/persist-logs/SKILL.md).
62
+ By default API `/data`, persist WALs, and worker `/data/logs` use Docker **named volumes** (stable across digest-gated recreates). A vibed-prepared **persist sidecar** ships sealed segments using machine-wide R2/S3 config. See [`skills/persist-logs/SKILL.md`](../skills/persist-logs/SKILL.md).
63
+
64
+ `.env.*.example` documents `DATA_VOLUME`, `PERSIST_LOG_VOLUME`, `PERSIST_LOGS=0`, and bind escape hatches.
65
+
66
+ Inspect: `~/services/vibed-infra/monitor-vibed.sh`.
63
67
 
64
68
  ## Environment (install)
65
69
 
@@ -2,7 +2,7 @@
2
2
  name: agent-vps-prep
3
3
  description: >-
4
4
  Prepare a fresh VPS for vibed-infra installs run by an automation agent
5
- (cloud_agent): SSH key, sudoers, Docker group, one-time uid-1000/ACL ownership, firewall.
5
+ (cloud_agent): SSH key, sudoers, Docker group, firewall; app data uses named volumes.
6
6
  ---
7
7
 
8
8
  # Prepare a VPS for vibed-infra (agent operator)
@@ -67,42 +67,43 @@ sudo chown -R cloud_agent:cloud_agent /home/cloud_agent
67
67
 
68
68
  vibed-infra defaults: `~/services/gateway`, `~/services/vibed-infra`, product dirs under `~/services/<app>/`.
69
69
 
70
- ## 5. Bind-mount ownership (uid 1000) configure once
70
+ ## 5. App data ownership named volumes (default)
71
71
 
72
- Product images often run as `node` (uid/gid **1000**). If the operator creates `./data` / `./logs` as a *different* uid, containers hit `SQLITE_CANTOPEN` / `EACCES`. Fix this **once on the host**, not after every product install.
72
+ API data, persist logs, and worker logs use **Docker named volumes** by default (`{container}-data`, `{container}-persist`, `{container}-logs`). They are not under `~/services`, so the operator does **not** chown them to uid 1000 and does not need a `u:1000` ACL for SQLite/WALs.
73
73
 
74
- ### Preferred: operator is already uid 1000
74
+ In-volume ownership comes from the **app image** on first empty mount. Digest-gated recreates remount the same volume — the database is not wiped.
75
75
 
76
- If §1 created `cloud_agent` as uid/gid 1000, new trees under `~/services` are already writable by the container user. Nothing else to do.
76
+ ### Inspect data / logs over SSH
77
77
 
78
- ### Fallback (configure once on `~/services`): default ACL
78
+ ```bash
79
+ ~/services/vibed-infra/monitor-vibed.sh
80
+ # non-interactive:
81
+ ~/services/vibed-infra/monitor-vibed.sh --list
82
+ ~/services/vibed-infra/monitor-vibed.sh --summary myapp-api
83
+ ```
79
84
 
80
- When the operator is **not** uid 1000, grant uid 1000 rwx on every new file under services regardless of who creates it. Requires the `acl` package.
85
+ Or:
81
86
 
82
87
  ```bash
83
- # as root, once after creating cloud_agent (any uid)
84
- apt-get install -y acl
85
- install -d -o cloud_agent -g cloud_agent /home/cloud_agent/services
86
- setfacl -m u:1000:rwx /home/cloud_agent/services
87
- setfacl -d -m u:1000:rwx /home/cloud_agent/services
88
- # also grant the operator full access via ACL defaults
89
- setfacl -m u:cloud_agent:rwx /home/cloud_agent/services
90
- setfacl -d -m u:cloud_agent:rwx /home/cloud_agent/services
88
+ docker run --rm -v myapp-api-data:/data:ro alpine ls -la /data
89
+ docker run --rm -v myapp-api-persist:/persist-logs:ro alpine ls -laR /persist-logs
91
90
  ```
92
91
 
93
- ### Recovery only
92
+ ### Optional: bind-mount escape hatch
93
+
94
+ `DATA_BIND=1` / `PERSIST_LOG_BIND=1` / `LOGS_BIND=1` with a host path. Only then does host uid matter for those dirs. Prefer named volumes.
94
95
 
95
- If someone already created wrong-owned dirs **before** uid-1000 or ACL setup:
96
+ ### Install-dir scripts under `~/services`
97
+
98
+ Gateway certs, product `.env`, and yaml configs still live under `~/services`. Prefer operator uid 1000 **or** a one-time ACL on `~/services` so the agent can write install trees — not for app SQLite.
96
99
 
97
100
  ```bash
98
- sudo chown -R 1000:1000 \
99
- /home/cloud_agent/services/*/api/data \
100
- /home/cloud_agent/services/*/api/persist-logs \
101
- /home/cloud_agent/services/*/nodes/logs
101
+ # optional ACL for install scripts only (operator not uid 1000)
102
+ apt-get install -y acl
103
+ install -d -o cloud_agent -g cloud_agent /home/cloud_agent/services
104
+ setfacl -m u:cloud_agent:rwx -d -m u:cloud_agent:rwx /home/cloud_agent/services
102
105
  ```
103
106
 
104
- Do not rely on per-product `chown` as ongoing ops. Prefer fixing the host model above.
105
-
106
107
  ## 6. Ports + DNS
107
108
 
108
109
  - Free **80/443** for the host gateway (only vps-gateway binds them)
@@ -129,6 +130,6 @@ wget -qO- https://raw.githubusercontent.com/ORG/REPO/main/deploy/.../dist/instal
129
130
  - [ ] `ssh cloud_agent@VPS` works with key only
130
131
  - [ ] `docker ps` works without sudo
131
132
  - [ ] `$HOME` owned by `cloud_agent`
132
- - [ ] operator uid is 1000 **OR** `~/services` has default ACL for `u:1000` (and `u:cloud_agent` if needed)
133
133
  - [ ] DNS → VPS IP
134
134
  - [ ] install-gateway + setup-tls + start-gateway
135
+ - [ ] `~/services/vibed-infra/monitor-vibed.sh --list` shows deployments after start
@@ -14,18 +14,28 @@ Each service writes **domain events** to a durable local log. After DB loss (or
14
14
 
15
15
  ## Paths
16
16
 
17
- - Machine root: `~/services/vibed-infra/persist-logs/` (one-time `install-persist-logs.sh`)
18
- - Per app/role: `$PERSIST_LOG_DIR` (set in `.env`, mounted into API as `/persist-logs`)
17
+ - Machine config (credentials + host cron leftover): `~/services/vibed-infra/persist-logs/` (one-time `install-persist-logs.sh`)
18
+ - Per deployment: Docker **named volume** `${DOCKER_NAME}-persist` mounted at `/persist-logs` (default). Set `PERSIST_LOGS=0` to disable. Bind escape: `PERSIST_LOG_BIND=1` + `PERSIST_LOG_DIR=…`.
19
+ - **Ship sidecar** `${DOCKER_NAME}-persist-ship` shares only that persist volume (no Docker socket, no app `/data`). Uses machine `.env` for R2/S3; app container does not get those secrets.
20
+ - Object keys: `{machine}/{PERSIST_SHIP_PREFIX}/{rel}` (prefix defaults to the app container name).
19
21
 
20
22
  ## Algorithm
21
23
 
22
24
  1. **Append** NDJSON to `wal.ndjson` with fsync (fast, free).
23
25
  2. **Seal** → `seg-NNNN.ndjson.gz` at ~8 MiB or ~60s.
24
- 3. **Ship** sealed segments with one PUT each (`PERSIST_SHIP=1` + R2/S3 env). Default `PERSIST_SHIP=0` (local only).
26
+ 3. **Ship** sealed segments with one PUT each (`PERSIST_SHIP=1` + R2/S3 env). Default `PERSIST_SHIP=0` (local only). Sidecar prefers stdlib SigV4; host cron may use `aws` CLI.
25
27
  4. **Replay** sealed + wal in order.
26
28
 
27
29
  Python: `lib/persistlog` — `append`, `seal`, `iter_events`, `replay`.
28
30
 
31
+ ## Inspect on the VPS
32
+
33
+ ```bash
34
+ ~/services/vibed-infra/monitor-vibed.sh
35
+ # or:
36
+ docker run --rm -v "${DOCKER_NAME}-persist:/persist-logs:ro" alpine ls -laR /persist-logs
37
+ ```
38
+
29
39
  ## Event schema
30
40
 
31
41
  ```json