vibed-infra 0.5.0 → 0.7.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.
Files changed (44) hide show
  1. package/README.md +42 -61
  2. package/github/__pycache__/notify-vps-pull.cpython-312.pyc +0 -0
  3. package/github/notify-vps-pull.py +194 -0
  4. package/github/workflows/docker-build-reusable.yml +32 -1
  5. package/install-auto-update.sh +17 -1
  6. package/install.sh +257 -27
  7. package/lib/__pycache__/generate.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/github_oidc.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/load_config.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/product_config.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/webhook.cpython-312.pyc +0 -0
  12. package/lib/generate.py +42 -3
  13. package/lib/github_oidc.py +224 -0
  14. package/lib/host_gateway.sh +117 -0
  15. package/lib/package.py +134 -12
  16. package/lib/persistlog/__init__.py +123 -0
  17. package/lib/persistlog/__pycache__/__init__.cpython-312.pyc +0 -0
  18. package/lib/persistlog/ship.py +69 -0
  19. package/lib/product_config.py +33 -16
  20. package/lib/update_queue.sh +57 -0
  21. package/lib/webhook.py +90 -0
  22. package/package.json +4 -2
  23. package/schema/packageconfig.md +30 -48
  24. package/skills/dns-configure/SKILL.md +18 -0
  25. package/skills/infra-cicd/SKILL.md +14 -7
  26. package/skills/infra-packager/SKILL.md +9 -7
  27. package/skills/infra-update-agent/SKILL.md +64 -0
  28. package/skills/persist-logs/SKILL.md +64 -0
  29. package/skills/system-gateway/SKILL.md +43 -23
  30. package/templates/generic/DNS-SKILL.md.tmpl +35 -0
  31. package/templates/generic/start-api.sh +8 -0
  32. package/templates/host-gateway/.env.example +19 -0
  33. package/templates/host-gateway/gateway/conf.d/00-default.conf +16 -0
  34. package/templates/host-gateway/gateway/nginx.conf +25 -0
  35. package/templates/host-gateway/reload-gateway.sh +7 -0
  36. package/templates/host-gateway/setup-tls.sh +249 -0
  37. package/templates/host-gateway/start-gateway.sh +108 -0
  38. package/templates/host-gateway/update-gateway.sh +41 -0
  39. package/templates/persist-logs/install-persist-logs.sh +38 -0
  40. package/templates/update-agent/__pycache__/webhook_server.cpython-312.pyc +0 -0
  41. package/templates/update-agent/agent.sh +87 -0
  42. package/templates/update-agent/enqueue.sh +54 -0
  43. package/templates/update-agent/install-agent.sh +70 -0
  44. package/templates/update-agent/webhook_server.py +208 -0
package/README.md CHANGED
@@ -1,100 +1,81 @@
1
1
  # vibed-infra
2
2
 
3
- Product-agnostic VPS deployment packager: **`package.sh`** turns four YAML configs into a committed **`dist/`** tree operators wget on the box install scripts, generic start/update helpers, env examples, and gateway nginx.
3
+ Product-agnostic VPS packager: **`package.sh`** builds committed **`dist/`** for wget install. One **host gateway** serves many apps; a **serial update agent** pulls images without overload; **persist-logs** enable event-sourced recovery; **`dist/DNS-SKILL.md`** configures DNS via an AU browser agent.
4
4
 
5
5
  Published on npm as **`vibed-infra`**.
6
6
 
7
7
  ## Product maintainer flow
8
8
 
9
- Author **`templates/`** in your product repo:
10
-
11
9
  | File | Purpose |
12
10
  |------|---------|
13
- | `vibed-infra-config.yml` | Product name, template refs, edge network, gateway `sites[]`, auto-update |
14
- | `api-config.yaml` | API image, port, opaque `config:` |
15
- | `ui-config.yaml` | UI image, port |
16
- | `nodes-config.yaml` | Worker image, opaque `config:` |
17
-
18
- Then package and commit **`dist/`**:
11
+ | `vibed-infra-config.yml` | Name, templates, `network.edge`, `gateway.publicIp` / `tlsEmail` / `sites[]`, auto-update |
12
+ | `api-config.yaml` / `ui-config.yaml` / `nodes-config.yaml` | Images + opaque config |
19
13
 
20
14
  ```bash
21
- ./package.sh # exec vibed-infra packager → writes dist/
15
+ ./package.sh
22
16
  git add dist && git commit && git push
17
+ # Copy dist/DNS-SKILL.md into AU agent (domains + publicIp already filled when configured)
23
18
  ```
24
19
 
25
- Resolve the packager from npm:
20
+ ## Operator flow (VPS)
26
21
 
27
22
  ```bash
28
- node -e "console.log(require('path').dirname(require.resolve('vibed-infra/package.json')))"
29
- ```
30
-
31
- Example product: [`examples/vps-hello`](examples/vps-hello) (`app/`, `build-images.sh`, four YAMLs, committed `dist/`).
32
-
33
- ## Operator flow (wget on VPS)
34
-
35
- ```bash
36
- wget -qO- https://raw.githubusercontent.com/ORG/REPO/main/dist/install-api.sh | bash
37
- wget -qO- .../dist/install-nodes.sh | bash
23
+ # 1) DNS — paste dist/DNS-SKILL.md into AU browser agent
24
+ # 2) App roles
25
+ wget -qO- .../dist/install-api.sh | bash
38
26
  wget -qO- .../dist/install-ui.sh | bash
27
+ wget -qO- .../dist/install-nodes.sh | bash
28
+ # 3) Host gateway + TLS (setup-tls.sh; re-run with --force if host/IP changes)
39
29
  wget -qO- .../dist/install-gateway.sh | bash
40
- # edit each install dir .env, then ./start-*.sh
41
30
  ```
42
31
 
43
32
  | Profile | Role |
44
33
  |---------|------|
45
- | `api` | Backend on shared edge network |
46
- | `ui` | UI container (separate install) |
47
- | `nodes` | Worker compose on edge network |
48
- | `gateway` | HTTPS nginx only (API + UI must already run) |
34
+ | `api` / `ui` / `nodes` | Join shared `vps-edge` |
35
+ | `gateway` | Host nginx + `apps/{name}/sites.conf` + HTTPS via `~/services/gateway/setup-tls.sh` |
36
+
37
+ Multi-app: further products’ `install-gateway.sh` only add `apps/{other}/sites.conf`, refresh TLS SANs, and reload no second 80/443 bind.
38
+
39
+ ## Machine services (installed once)
40
+
41
+ | Path | Purpose |
42
+ |------|---------|
43
+ | `~/services/gateway` | Host nginx (`GATEWAY_HOME`) |
44
+ | `~/services/vibed-infra/update-agent` | Serial pull queue + optional GHCR webhook |
45
+ | `~/services/vibed-infra/persist-logs` | Per-app WALs + optional R2/S3 ship |
46
+
47
+ Auto-update cron **enqueues** work; the agent processes one job at a time. After a GHCR push, the reusable image workflow mints a GitHub Actions OIDC JWT and notifies `https://{domain}/_vibed/hooks/ghcr` (fallback `http://{publicIp}/…`) so the pull does not wait for cron. Updates prune dangling images (`DOCKER_AUTO_PRUNE=1`).
49
48
 
50
49
  ## Layout
51
50
 
52
51
  | Path | Purpose |
53
52
  |------|---------|
54
- | `package.sh` / `lib/package.py` | Build product `dist/` from four YAMLs + generic templates |
55
- | `install.sh` | Single entrypoint (`INFRA_PROFILE` or `--profile`) |
56
- | `install-auto-update.sh` | Cron from profile flags |
57
- | `lib/` | fetch, env, tls, prompt, generate, product_config |
58
- | `templates/generic/` | Generic start/update/env/certs/gateway |
59
- | `schema/packageconfig.md` | Four-file product schema + compiled packageconfig |
60
- | `examples/vps-hello/` | Full example with dist e2e tests |
61
- | `skills/` | Cursor skills |
62
- | `github/workflows/` | Reusable GHCR build workflow |
53
+ | `package.sh` / `lib/package.py` | Build `dist/` |
54
+ | `templates/host-gateway/` | Shared gateway skeleton |
55
+ | `templates/update-agent/` | Queue agent + webhook |
56
+ | `templates/persist-logs/` | Shipper install |
57
+ | `lib/persistlog/` | Python append / seal / replay |
58
+ | `skills/` | system-gateway, infra-update-agent, persist-logs, dns-configure |
63
59
 
64
60
  ## Environment
65
61
 
66
62
  | Variable | Meaning |
67
63
  |----------|---------|
68
- | `PACKAGER_RAW` | Base URL or local path to this package root |
69
- | `PACKAGECONFIG_URL` | Product `packageconfig.yaml` or `dist/` URL (URL or path) |
70
- | `PRODUCT_RAW` | Product `dist/` base (templates + install scripts) |
71
- | `INFRA_PROFILE` | `api`, `ui`, `nodes`, or `gateway` |
72
- | `INSTALL_DIR` | Target directory (default `.`) |
73
-
74
- Auto-update cron runs profile `update-*.sh`. Each update **prunes dangling Docker images** by default (`DOCKER_AUTO_PRUNE=1`). Set `DOCKER_PRUNE_UNUSED=1` to also drop unused tagged images older than `DOCKER_PRUNE_UNTIL` (default `72h`).
64
+ | `GATEWAY_HOME` | Host gateway dir (default `~/services/gateway`) |
65
+ | `VIBED_HOME` | Machine vibed root (default `~/services/vibed-infra`) |
66
+ | `GATEWAY_PUBLIC_IP` | VPS IPv4 (from `gateway.publicIp`) |
67
+ | `TLS_EMAIL` / `TLS_MODE` | Let’s Encrypt email; `lab` or `letsencrypt` |
68
+ | `PERSIST_LOG_DIR` | Per-service event log dir |
69
+ | `DOCKER_AUTO_PRUNE` | Prune dangling images after update (default on) |
75
70
 
76
71
  ## Tests
77
72
 
78
73
  ```bash
79
- npm test # package + dry-run all dist/install-*.sh
80
- npm run test:dist # build images + e2e api, ui, nodes (sequential)
81
- ./examples/vps-hello/test-dist.sh --profile api # single profile
74
+ npm test
75
+ npm run test:dist
76
+ npm run test:e2e-multi # two apps, localhost wget|bash, shared host gateway
82
77
  ```
83
78
 
84
- CI: `validate` job then parallel `dist-e2e` matrix (`api`, `ui`, `nodes`).
85
-
86
- ## Publishing (npm)
87
-
88
- Every **merge to `main`** runs [Publish npm](.github/workflows/publish.yml):
89
-
90
- 1. Bumps **minor** (`0.1.0` → `0.2.0`)
91
- 2. Commits `chore: release vibed-infra v…`, tags `v…`, pushes
92
- 3. Runs `npm publish` (needs repo secret `NPM_TOKEN`)
93
-
94
- **Major versions** — set `version` in `package.json` yourself (e.g. `1.0.0`) and include **`[major]`** in the commit message. That publishes the pinned version with **no** automatic minor bump.
95
-
96
- Set repository secret **`NPM_TOKEN`** before the first merge to `main`.
97
-
98
- Ordinary PRs only run CI (`npm test` + dist e2e); they do **not** publish.
79
+ ## Publishing
99
80
 
100
- Runbook: [`examples/vps-hello/README.md`](examples/vps-hello/README.md).
81
+ Merge to `main` runs Publish npm (minor bump). Set `NPM_TOKEN`. Ordinary PRs run CI only.
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """Notify a vibed-infra VPS to pull a GHCR image.
3
+
4
+ Prefers GitHub Actions OIDC (no shared secret). Falls back to compiled
5
+ config token / VIBED_WEBHOOK_SECRET for local curl.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ # Keep in sync with lib/webhook.py
19
+ HOOK_PATH = "/_vibed/hooks/ghcr"
20
+ TOKEN_VERSION = "vibed-webhook|v1|"
21
+
22
+
23
+ def _derive_token(name: str, public_ip: str, host: str) -> str:
24
+ import hashlib
25
+
26
+ raw = f"{TOKEN_VERSION}{name}|{public_ip}|{host}"
27
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
28
+
29
+
30
+ def _strip(s: str) -> str:
31
+ s = s.strip()
32
+ if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
33
+ return s[1:-1]
34
+ return s
35
+
36
+
37
+ def _load_webhook(path: Path) -> dict[str, str]:
38
+ text = path.read_text(encoding="utf-8")
39
+ url = fallback = token = name = public_ip = host = ""
40
+ in_webhook = False
41
+ in_sites = False
42
+ for raw in text.splitlines():
43
+ line = raw.rstrip()
44
+ stripped = line.lstrip()
45
+ if stripped.startswith("webhook:"):
46
+ in_webhook = True
47
+ in_sites = False
48
+ continue
49
+ if in_webhook:
50
+ if stripped and not line.startswith(" ") and not line.startswith("\t"):
51
+ in_webhook = False
52
+ elif stripped.startswith("url:"):
53
+ url = _strip(stripped.split(":", 1)[1])
54
+ elif stripped.startswith("fallbackUrl:"):
55
+ fallback = _strip(stripped.split(":", 1)[1])
56
+ elif stripped.startswith("token:"):
57
+ token = _strip(stripped.split(":", 1)[1])
58
+ if stripped.startswith("name:") and not name:
59
+ name = _strip(stripped.split(":", 1)[1])
60
+ if stripped.startswith("publicIp:"):
61
+ public_ip = _strip(stripped.split(":", 1)[1])
62
+ if stripped.startswith("sites:"):
63
+ in_sites = True
64
+ continue
65
+ if in_sites and stripped.startswith("- host:"):
66
+ host = _strip(stripped.split(":", 1)[1])
67
+ in_sites = False
68
+ elif in_sites and stripped.startswith("host:"):
69
+ host = _strip(stripped.split(":", 1)[1])
70
+ in_sites = False
71
+ if not token and name and (public_ip or host):
72
+ token = _derive_token(name, public_ip, host)
73
+ if not url and host:
74
+ url = f"https://{host}{HOOK_PATH}"
75
+ if not fallback and public_ip:
76
+ fallback = f"http://{public_ip}{HOOK_PATH}"
77
+ if not url:
78
+ url = fallback
79
+ return {"url": url, "fallbackUrl": fallback, "token": token}
80
+
81
+
82
+ def _package_from_image(image: str) -> str:
83
+ img = image.split("@", 1)[0]
84
+ if "/" in img:
85
+ return img.rsplit("/", 1)[-1].split(":")[0]
86
+ return img.split(":")[0]
87
+
88
+
89
+ def fetch_oidc_token(audience: str) -> str:
90
+ url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL") or ""
91
+ tok = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN") or ""
92
+ if not url or not tok:
93
+ return ""
94
+ sep = "&" if "?" in url else "?"
95
+ req = urllib.request.Request(
96
+ f"{url}{sep}audience={urllib.parse.quote(audience, safe='')}",
97
+ headers={"Authorization": f"Bearer {tok}", "Accept": "application/json"},
98
+ )
99
+ try:
100
+ with urllib.request.urlopen(req, timeout=20) as resp:
101
+ data = json.loads(resp.read().decode())
102
+ return str(data.get("value") or "")
103
+ except Exception as e:
104
+ print(f"notify-vps: OIDC mint failed: {e}", file=sys.stderr)
105
+ return ""
106
+
107
+
108
+ def _post(url: str, body: dict, *, bearer: str = "", secret: str = "") -> tuple[int, str]:
109
+ headers = {"Content-Type": "application/json"}
110
+ if bearer:
111
+ headers["Authorization"] = f"Bearer {bearer}"
112
+ if secret:
113
+ headers["X-Vibed-Secret"] = secret
114
+ req = urllib.request.Request(
115
+ url,
116
+ data=json.dumps(body).encode(),
117
+ method="POST",
118
+ headers=headers,
119
+ )
120
+ try:
121
+ with urllib.request.urlopen(req, timeout=15) as resp:
122
+ return resp.status, resp.read().decode()
123
+ except urllib.error.HTTPError as e:
124
+ return e.code, e.read().decode(errors="replace")
125
+ except Exception as e:
126
+ return 0, str(e)
127
+
128
+
129
+ def main() -> int:
130
+ ap = argparse.ArgumentParser()
131
+ ap.add_argument("--image", required=True)
132
+ ap.add_argument("--tag", default="main")
133
+ ap.add_argument("--config", default="")
134
+ ap.add_argument("--url", action="append", default=[], help="Override webhook URL (repeatable)")
135
+ ap.add_argument("--strict", action="store_true", help="Exit 1 if notify fails")
136
+ args = ap.parse_args()
137
+
138
+ spec = {"url": "", "fallbackUrl": "", "token": ""}
139
+ if not args.url:
140
+ roots = []
141
+ if args.config:
142
+ roots.append(Path(args.config))
143
+ cwd = Path.cwd()
144
+ roots.extend(
145
+ [
146
+ cwd / "dist" / "packageconfig.yaml",
147
+ cwd / "packageconfig.yaml",
148
+ cwd / "templates" / "vibed-infra-config.yml",
149
+ ]
150
+ )
151
+ conf_path = next((p for p in roots if p.is_file()), None)
152
+ if conf_path:
153
+ spec = _load_webhook(conf_path)
154
+ elif not args.url:
155
+ print("notify-vps: no packageconfig (set gateway.publicIp + sites[].host)", file=sys.stderr)
156
+ return 1 if args.strict else 0
157
+
158
+ token = os.environ.get("VIBED_WEBHOOK_SECRET") or spec.get("token") or ""
159
+ urls = list(args.url) if args.url else [u for u in (spec.get("url"), spec.get("fallbackUrl")) if u]
160
+ seen: set[str] = set()
161
+ urls = [u for u in urls if not (u in seen or seen.add(u))]
162
+ has_oidc = bool(os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL"))
163
+ if not urls:
164
+ print("notify-vps: skipped (no webhook url)", file=sys.stderr)
165
+ return 1 if args.strict else 0
166
+ if not has_oidc and not token:
167
+ print("notify-vps: skipped (no OIDC env and no token)", file=sys.stderr)
168
+ return 1 if args.strict else 0
169
+
170
+ pkg = _package_from_image(args.image)
171
+ payload = {"package": pkg, "tag": args.tag, "image": args.image}
172
+ last_err = ""
173
+ for url in urls:
174
+ bearer = fetch_oidc_token(url) if has_oidc else ""
175
+ secret = "" if bearer else token
176
+ if not bearer and not secret:
177
+ last_err = f"{url}: no OIDC token"
178
+ print(f"notify-vps: {last_err}", file=sys.stderr)
179
+ continue
180
+ try:
181
+ code, text = _post(url, payload, bearer=bearer, secret=secret)
182
+ except Exception as e:
183
+ code, text = 0, str(e)
184
+ if code == 200:
185
+ print(f"notify-vps: {url} → {text.strip()}")
186
+ return 0
187
+ last_err = f"{url} HTTP {code}: {text[:200]}"
188
+ print(f"notify-vps: {last_err}", file=sys.stderr)
189
+ print(f"notify-vps: failed ({last_err})", file=sys.stderr)
190
+ return 1 if args.strict else 0
191
+
192
+
193
+ if __name__ == "__main__":
194
+ raise SystemExit(main())
@@ -4,12 +4,19 @@
4
4
  #
5
5
  # jobs:
6
6
  # build-api:
7
- # uses: ./infra/github/workflows/docker-build-reusable.yml
7
+ # uses: naiemk/vibed-infra/.github/workflows/docker-build-reusable.yml@main
8
8
  # with:
9
9
  # dockerfile: deploy/Dockerfile.api
10
10
  # image: ghcr.io/${{ github.repository_owner }}/my-api
11
11
  # context: .
12
+ # permissions:
13
+ # contents: read
14
+ # packages: write
15
+ # id-token: write
12
16
  # secrets: inherit
17
+ #
18
+ # After push to the default branch, notifies the VPS using GitHub Actions OIDC
19
+ # (audience = webhook URL). No GitHub Packages webhook and no extra secrets.
13
20
 
14
21
  name: Reusable docker build (single image)
15
22
 
@@ -26,14 +33,26 @@ on:
26
33
  required: false
27
34
  type: string
28
35
  default: .
36
+ notify:
37
+ required: false
38
+ type: boolean
39
+ default: true
40
+ secrets:
41
+ VIBED_WEBHOOK_SECRET:
42
+ required: false
29
43
 
30
44
  permissions:
31
45
  contents: read
32
46
  packages: write
47
+ id-token: write
33
48
 
34
49
  jobs:
35
50
  build-push:
36
51
  runs-on: ubuntu-latest
52
+ permissions:
53
+ contents: read
54
+ packages: write
55
+ id-token: write
37
56
  steps:
38
57
  - uses: actions/checkout@v4
39
58
  - uses: docker/setup-buildx-action@v3
@@ -59,3 +78,15 @@ jobs:
59
78
  labels: |
60
79
  org.opencontainers.image.revision=${{ github.sha }}
61
80
  org.opencontainers.image.source=https://github.com/${{ github.repository }}
81
+ - name: Notify VPS to pull
82
+ if: inputs.notify && github.event.repository.default_branch == github.ref_name
83
+ continue-on-error: true
84
+ env:
85
+ VIBED_WEBHOOK_SECRET: ${{ secrets.VIBED_WEBHOOK_SECRET }}
86
+ run: |
87
+ script=dist/notify-vps-pull.py
88
+ if [[ ! -f "$script" ]]; then
89
+ echo "notify-vps: dist/notify-vps-pull.py missing — re-package and commit dist/"
90
+ exit 0
91
+ fi
92
+ python3 "$script" --image "${{ inputs.image }}" --tag main
@@ -88,7 +88,23 @@ build_cron_minutes() {
88
88
  }
89
89
  CRON_MINUTES="$(build_cron_minutes "${CRON_OFFSET:-0}" "${INTERVAL_MIN:-30}")"
90
90
  CRON_SCHED="${CRON_MINUTES} * * * *"
91
- CRON_CMD="cd ${SCRIPT_DIR} && /bin/bash ${SCRIPT_DIR}/${UPDATE_SCRIPT} >/dev/null 2>&1"
91
+ # Prefer shared update-agent queue when available; else direct update script.
92
+ AGENT_ENQUEUE=""
93
+ for cand in \
94
+ "${VIBED_HOME:-}/update-agent/enqueue.sh" \
95
+ "${HOME:-}/services/vibed-infra/update-agent/enqueue.sh" \
96
+ "/var/lib/vibed/update-agent/enqueue.sh"
97
+ do
98
+ if [[ -n "$cand" && -x "$cand" ]]; then
99
+ AGENT_ENQUEUE="$cand"
100
+ break
101
+ fi
102
+ done
103
+ if [[ -n "$AGENT_ENQUEUE" ]]; then
104
+ CRON_CMD="cd ${SCRIPT_DIR} && UPDATE_REASON=cron /bin/bash ${AGENT_ENQUEUE} ${SCRIPT_DIR} >/dev/null 2>&1"
105
+ else
106
+ CRON_CMD="cd ${SCRIPT_DIR} && /bin/bash ${SCRIPT_DIR}/${UPDATE_SCRIPT} >/dev/null 2>&1"
107
+ fi
92
108
  dir_slug="$(basename "$SCRIPT_DIR" | tr -c 'A-Za-z0-9._-' '_')"
93
109
  CRON_D_FILE="/etc/cron.d/infra-${ROLE}-${dir_slug}"
94
110