vibed-infra 0.3.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/lib/env.sh ADDED
@@ -0,0 +1,224 @@
1
+ # shellcheck shell=bash
2
+ # Environment helpers for infra packager.
3
+
4
+ load_dotenv() {
5
+ local env_file="${1:-.env}"
6
+ [[ -f "$env_file" ]] || return 0
7
+ while IFS= read -r line || [[ -n "$line" ]]; do
8
+ line="${line%$'\r'}"
9
+ [[ -z "${line//[[:space:]]/}" || "$line" =~ ^[[:space:]]*# ]] && continue
10
+ local key="${line%%=*}"
11
+ local val="${line#*=}"
12
+ key="${key%%[[:space:]]*}"
13
+ key="${key##[[:space:]]*}"
14
+ key="${key%$'\r'}"
15
+ val="${val%$'\r'}"
16
+ if [[ "$val" =~ ^\"(.*)\"$ ]]; then val="${BASH_REMATCH[1]}"; fi
17
+ if [[ "$val" =~ ^\'(.*)\'$ ]]; then val="${BASH_REMATCH[1]}"; fi
18
+ [[ -z "$key" || "$key" == *[!A-Za-z0-9_]* ]] && continue
19
+ if [[ -z "${!key-}" ]]; then
20
+ export "$key=$val"
21
+ fi
22
+ done <"$env_file"
23
+ }
24
+
25
+ env_flag_on() {
26
+ local name="$1"
27
+ local val="${!name-}"
28
+ case "$val" in
29
+ 1|true|TRUE|yes|YES|on|ON) return 0 ;;
30
+ *) return 1 ;;
31
+ esac
32
+ }
33
+
34
+ role_auto_update_on() {
35
+ local primary="$1"
36
+ if [[ -n "${!primary+x}" ]]; then
37
+ env_flag_on "$primary"
38
+ return
39
+ fi
40
+ env_flag_on AUTO_UPDATE
41
+ }
42
+
43
+ any_auto_update_on() {
44
+ local flag
45
+ for flag in "$@"; do
46
+ if role_auto_update_on "$flag"; then
47
+ return 0
48
+ fi
49
+ done
50
+ return 1
51
+ }
52
+
53
+ append_missing_env_keys() {
54
+ local example="$1"
55
+ local envfile="$2"
56
+ shift 2
57
+ [[ -f "$example" && -f "$envfile" ]] || return 0
58
+ local key line added=0
59
+ local legacy_off=0
60
+ if grep -qiE "^[[:space:]]*AUTO_UPDATE=(0|false|off)\b" "$envfile"; then
61
+ legacy_off=1
62
+ fi
63
+ for key in "$@"; do
64
+ if grep -qE "^[[:space:]]*${key}=" "$envfile"; then
65
+ continue
66
+ fi
67
+ line="$(grep -E "^[[:space:]]*${key}=" "$example" | head -1 || true)"
68
+ [[ -n "$line" ]] || continue
69
+ if [[ "$legacy_off" -eq 1 && "$key" == *_AUTO_UPDATE ]]; then
70
+ line="${key}=0"
71
+ fi
72
+ if [[ "$added" -eq 0 ]]; then
73
+ {
74
+ echo ""
75
+ echo "# --- Auto-update (added by infra install; see .env.example) ---"
76
+ } >>"$envfile"
77
+ fi
78
+ echo "$line" >>"$envfile"
79
+ echo "appended to .env: $key"
80
+ added=1
81
+ done
82
+ }
83
+
84
+ log_update() {
85
+ local dir="${1:-.}"
86
+ local msg="$2"
87
+ mkdir -p "$dir/logs"
88
+ local line
89
+ line="$(date -u +"%Y-%m-%dT%H:%M:%SZ") $msg"
90
+ echo "$line" | tee -a "$dir/logs/auto-update.log"
91
+ }
92
+
93
+ resolve_update_lock_file() {
94
+ if [[ -n "${UPDATE_LOCK_FILE:-}" ]]; then
95
+ echo "$UPDATE_LOCK_FILE"
96
+ return 0
97
+ fi
98
+ if [[ -d /var/lock && -w /var/lock ]]; then
99
+ echo /var/lock/infra-auto-update.lock
100
+ return 0
101
+ fi
102
+ echo /tmp/infra-auto-update.lock
103
+ }
104
+
105
+ with_update_lock() {
106
+ local log_dir="$1"
107
+ local role_label="$2"
108
+ local fn="$3"
109
+ local lock
110
+ lock="$(resolve_update_lock_file)"
111
+ exec 9>"$lock" || {
112
+ log_update "$log_dir" "${role_label}: cannot open lock $lock — proceeding without flock"
113
+ "$fn"
114
+ return $?
115
+ }
116
+ if ! flock -n 9; then
117
+ log_update "$log_dir" "${role_label}: another auto-update holds $lock — skip"
118
+ exec 9>&-
119
+ return 0
120
+ fi
121
+ "$fn"
122
+ local rc=$?
123
+ flock -u 9 2>/dev/null || true
124
+ exec 9>&-
125
+ return "$rc"
126
+ }
127
+
128
+ local_image_id() {
129
+ local image="$1"
130
+ docker image inspect -f '{{.Id}}' "$image" 2>/dev/null || true
131
+ }
132
+
133
+ remote_image_digest() {
134
+ local image="$1"
135
+ local digest="" out
136
+ if out="$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}' 2>/dev/null)"; then
137
+ digest="$(printf '%s\n' "$out" | tr -d '[:space:]')"
138
+ fi
139
+ if [[ "$digest" != sha256:* ]]; then
140
+ if out="$(docker buildx imagetools inspect "$image" -f '{{.Manifest.Digest}}' 2>/dev/null)"; then
141
+ digest="$(printf '%s\n' "$out" | tr -d '[:space:]')"
142
+ fi
143
+ fi
144
+ if [[ "$digest" != sha256:* ]]; then
145
+ if out="$(docker buildx imagetools inspect "$image" 2>/dev/null)"; then
146
+ digest="$(printf '%s\n' "$out" | awk '/^Digest:/{print $2; exit}')"
147
+ fi
148
+ fi
149
+ if [[ "$digest" == sha256:* ]]; then
150
+ echo "$digest"
151
+ fi
152
+ }
153
+
154
+ local_repo_digest() {
155
+ local image="$1"
156
+ docker image inspect -f '{{range .RepoDigests}}{{println .}}{{end}}' "$image" 2>/dev/null \
157
+ | sed -n 's/.*@//p' \
158
+ | head -1
159
+ }
160
+
161
+ pull_image_if_needed() {
162
+ local image="$1"
163
+ local remote locald
164
+ if ! docker image inspect "$image" >/dev/null 2>&1; then
165
+ docker pull "$image" >/dev/null
166
+ echo "pulled (missing locally)"
167
+ return 0
168
+ fi
169
+ remote="$(remote_image_digest "$image")"
170
+ if [[ -z "$remote" ]]; then
171
+ docker pull "$image" >/dev/null
172
+ echo "pulled (digest probe unavailable)"
173
+ return 0
174
+ fi
175
+ locald="$(local_repo_digest "$image")"
176
+ if [[ -n "$locald" && "$locald" == "$remote" ]]; then
177
+ echo "skipped (already $remote)"
178
+ return 0
179
+ fi
180
+ docker pull "$image" >/dev/null
181
+ echo "pulled ($remote)"
182
+ }
183
+
184
+ container_needs_image() {
185
+ local name="$1"
186
+ local image="$2"
187
+ if ! docker inspect "$name" >/dev/null 2>&1; then
188
+ return 0
189
+ fi
190
+ local running new
191
+ running="$(docker inspect -f '{{.Image}}' "$name" 2>/dev/null || true)"
192
+ new="$(docker image inspect -f '{{.Id}}' "$image" 2>/dev/null || true)"
193
+ [[ -z "$running" || -z "$new" || "$running" != "$new" ]]
194
+ }
195
+
196
+ graceful_stop() {
197
+ local name="$1"
198
+ local timeout="${2:-180}"
199
+ if docker inspect "$name" >/dev/null 2>&1; then
200
+ docker stop -t "$timeout" "$name" >/dev/null 2>&1 || true
201
+ fi
202
+ }
203
+
204
+ memory_args() {
205
+ local limit="${1:-}"
206
+ if [[ -n "$limit" ]]; then
207
+ echo --memory="$limit"
208
+ fi
209
+ }
210
+
211
+ chown_data_dir() {
212
+ local dir="$1"
213
+ local uid="${2:-1000}"
214
+ mkdir -p "$dir"
215
+ if [[ "$(id -u)" -eq 0 ]]; then
216
+ chown -R "${uid}:${uid}" "$dir" || true
217
+ elif command -v sudo >/dev/null 2>&1; then
218
+ sudo chown -R "${uid}:${uid}" "$dir" 2>/dev/null || \
219
+ echo "warning: could not chown $dir to ${uid} — fix if container cannot write" >&2
220
+ else
221
+ echo "warning: ensure $dir is writable by uid ${uid}" >&2
222
+ fi
223
+ chmod 755 "$dir" 2>/dev/null || true
224
+ }
package/lib/fetch.sh ADDED
@@ -0,0 +1,49 @@
1
+ # shellcheck shell=bash
2
+ # Fetch helpers for infra packager.
3
+
4
+ infra_fetch() {
5
+ local url="$1"
6
+ local out="$2"
7
+ mkdir -p "$(dirname "$out")"
8
+ if [[ "$url" =~ ^/ ]]; then
9
+ cp -f "$url" "$out"
10
+ return 0
11
+ fi
12
+ if [[ "$url" =~ ^file:// ]]; then
13
+ cp -f "${url#file://}" "$out"
14
+ return 0
15
+ fi
16
+ if command -v curl >/dev/null 2>&1; then
17
+ curl -fsSL "$url" -o "$out"
18
+ else
19
+ wget -qO "$out" "$url"
20
+ fi
21
+ }
22
+
23
+ infra_write_if_missing() {
24
+ local url="$1"
25
+ local path="$2"
26
+ local executable="${3:-0}"
27
+ if [[ -f "$path" ]]; then
28
+ echo "exists (unchanged): $path"
29
+ return 0
30
+ fi
31
+ echo "downloading $(basename "$path") ..."
32
+ infra_fetch "$url" "$path"
33
+ if [[ "$executable" == "1" ]]; then
34
+ chmod +x "$path"
35
+ fi
36
+ echo "created: $path"
37
+ }
38
+
39
+ infra_write_template() {
40
+ local url="$1"
41
+ local path="$2"
42
+ local executable="${3:-0}"
43
+ echo "refreshing $(basename "$path") ..."
44
+ infra_fetch "$url" "$path"
45
+ if [[ "$executable" == "1" ]]; then
46
+ chmod +x "$path"
47
+ fi
48
+ echo "updated: $path"
49
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Generate nginx site config from packageconfig gateway profile."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
10
+ from load_config import get_profile, load_packageconfig # noqa: E402
11
+
12
+
13
+ def render_site(site: dict) -> str:
14
+ host = site["host"]
15
+ aliases = site.get("aliases") or []
16
+ server_names = " ".join([host, *aliases])
17
+ backend = site["backend"]
18
+ backend_port = site.get("backendPort", 8080)
19
+ ui = site["ui"]
20
+ ui_port = site.get("uiPort", 80)
21
+ health = site.get("healthPath", "/api/health")
22
+ create = site.get("createPath") or ""
23
+ be_var = backend.replace("-", "_")
24
+ ui_var = ui.replace("-", "_")
25
+ proxy_headers = f""" proxy_pass http://${be_var}_upstream;
26
+ proxy_set_header Host $host;
27
+ proxy_set_header X-Real-IP $remote_addr;
28
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
29
+ proxy_set_header X-Forwarded-Proto $scheme;"""
30
+ create_block = ""
31
+ if create:
32
+ create_block = f"""
33
+ location = {create} {{
34
+ limit_req zone=api_create burst=2 nodelay;
35
+ {proxy_headers}
36
+ }}
37
+ """
38
+
39
+ return f"""# --- {host} ---
40
+ server {{
41
+ listen 443 ssl;
42
+ http2 on;
43
+ server_name {server_names};
44
+
45
+ ssl_certificate /etc/nginx/certs/fullchain.pem;
46
+ ssl_certificate_key /etc/nginx/certs/privkey.pem;
47
+ ssl_protocols TLSv1.2 TLSv1.3;
48
+ ssl_prefer_server_ciphers on;
49
+
50
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
51
+ add_header X-Content-Type-Options nosniff always;
52
+ add_header X-Frame-Options DENY always;
53
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
54
+
55
+ limit_conn addr 40;
56
+
57
+ set ${be_var}_upstream {backend}:{backend_port};
58
+ set ${ui_var}_upstream {ui}:{ui_port};
59
+
60
+ location = {health} {{
61
+ {proxy_headers}
62
+ }}
63
+ {create_block}
64
+ location /api/ {{
65
+ limit_req zone=api_public burst=40 nodelay;
66
+ {proxy_headers}
67
+ }}
68
+
69
+ location / {{
70
+ proxy_pass http://${ui_var}_upstream;
71
+ proxy_http_version 1.1;
72
+ proxy_set_header Host $host;
73
+ proxy_set_header X-Real-IP $remote_addr;
74
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
75
+ proxy_set_header X-Forwarded-Proto $scheme;
76
+ }}
77
+ }}
78
+ """
79
+
80
+
81
+ def render_gateway(conf: dict, profile: str, prof: dict, output_name: str = "domains.conf") -> str:
82
+ sites = prof.get("sites") or []
83
+ parts = [
84
+ "# Generated by lib/generate.py — edit packageconfig sites[] and re-install",
85
+ "resolver 127.0.0.11 valid=10s ipv6=off;",
86
+ "",
87
+ "server {",
88
+ " listen 80 default_server;",
89
+ " server_name _;",
90
+ " location /.well-known/acme-challenge/ { root /var/www/certbot; }",
91
+ " location / { return 301 https://$host$request_uri; }",
92
+ "}",
93
+ "",
94
+ ]
95
+ for site in sites:
96
+ parts.append(render_site(site))
97
+ parts.append("")
98
+ include = prof.get("templates", {}).get("nginxInclude")
99
+ if include and Path(include).name != output_name:
100
+ parts.append(f"include /etc/nginx/conf.d/{Path(include).name};")
101
+ return "\n".join(parts)
102
+
103
+
104
+ def main() -> int:
105
+ ap = argparse.ArgumentParser()
106
+ ap.add_argument("packageconfig")
107
+ ap.add_argument("--profile", default="gateway")
108
+ ap.add_argument("-o", "--output", required=True)
109
+ args = ap.parse_args()
110
+ conf = load_packageconfig(Path(args.packageconfig))
111
+ prof = get_profile(conf, args.profile)
112
+ out = render_gateway(conf, args.profile, prof, Path(args.output).name)
113
+ Path(args.output).write_text(out, encoding="utf-8")
114
+ print(f"wrote {args.output}")
115
+ return 0
116
+
117
+
118
+ if __name__ == "__main__":
119
+ sys.exit(main())
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Load infra packageconfig.yaml without external deps (subset YAML)."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def _strip_quotes(s: str) -> str:
12
+ s = s.strip()
13
+ if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
14
+ return s[1:-1]
15
+ return s
16
+
17
+
18
+ def load_packageconfig(path: Path) -> dict[str, Any]:
19
+ try:
20
+ import yaml # type: ignore
21
+
22
+ with path.open(encoding="utf-8") as f:
23
+ return yaml.safe_load(f) or {}
24
+ except ImportError:
25
+ return _parse_minimal(path.read_text(encoding="utf-8"))
26
+
27
+
28
+ def _parse_minimal(text: str) -> dict[str, Any]:
29
+ """Parse flat/nested keys for our packageconfig shape only."""
30
+ root: dict[str, Any] = {}
31
+ stack: list[tuple[int, dict[str, Any]]] = [(-1, root)]
32
+ list_key: str | None = None
33
+ list_indent = -1
34
+
35
+ for raw in text.splitlines():
36
+ if not raw.strip() or raw.lstrip().startswith("#"):
37
+ continue
38
+ indent = len(raw) - len(raw.lstrip(" "))
39
+ line = raw.lstrip()
40
+ while stack and indent <= stack[-1][0]:
41
+ stack.pop()
42
+ if list_indent >= 0 and indent <= list_indent:
43
+ list_key = None
44
+ list_indent = -1
45
+ parent = stack[-1][1]
46
+
47
+ if line.startswith("- ") and list_key and isinstance(parent.get(list_key), list):
48
+ item: dict[str, Any] = {}
49
+ rest = line[2:].strip()
50
+ if ":" in rest:
51
+ k, _, v = rest.partition(":")
52
+ item[k.strip()] = _strip_quotes(v.strip())
53
+ parent[list_key].append(item)
54
+ stack.append((indent, item))
55
+ continue
56
+
57
+ if ":" not in line:
58
+ continue
59
+ key, _, rest = line.partition(":")
60
+ key = key.strip()
61
+ rest = rest.strip()
62
+ if rest == "":
63
+ # Only known sequences — do not treat every plural key as a list
64
+ # (`profiles`, `images`, `templates` are mappings).
65
+ if key in ("sites", "aliases", "extras", "services", "flags"):
66
+ parent[key] = []
67
+ list_key = key
68
+ list_indent = indent
69
+ else:
70
+ child: dict[str, Any] = {}
71
+ parent[key] = child
72
+ stack.append((indent, child))
73
+ elif rest.startswith("[") and rest.endswith("]"):
74
+ inner = rest[1:-1].strip()
75
+ parent[key] = [_strip_quotes(x.strip()) for x in inner.split(",") if x.strip()] if inner else []
76
+ else:
77
+ parent[key] = _strip_quotes(rest)
78
+ return root
79
+
80
+
81
+ def get_profile(conf: dict[str, Any], name: str) -> dict[str, Any]:
82
+ profiles = conf.get("profiles") or {}
83
+ if name not in profiles:
84
+ raise KeyError(f"profile not found: {name}")
85
+ return profiles[name]
86
+
87
+
88
+ def main() -> int:
89
+ if len(sys.argv) < 3:
90
+ print("usage: load_config.py <packageconfig> <profile> [key...]", file=sys.stderr)
91
+ return 2
92
+ conf = load_packageconfig(Path(sys.argv[1]))
93
+ prof = get_profile(conf, sys.argv[2])
94
+ node: Any = prof
95
+ for k in sys.argv[3:]:
96
+ if isinstance(node, dict):
97
+ node = node.get(k, "")
98
+ else:
99
+ node = ""
100
+ if isinstance(node, (dict, list)):
101
+ import json
102
+
103
+ print(json.dumps(node))
104
+ else:
105
+ print(node)
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())
package/lib/prompt.sh ADDED
@@ -0,0 +1,25 @@
1
+ # shellcheck shell=bash
2
+ # Interactive component picker (TTY only).
3
+
4
+ infra_pick_profile() {
5
+ if [[ -n "${INFRA_PROFILE:-}" ]]; then
6
+ echo "$INFRA_PROFILE"
7
+ return 0
8
+ fi
9
+ if [[ ! -t 0 ]]; then
10
+ echo "Set INFRA_PROFILE=api|nodes|gateway (or pass --profile) when piping install.sh" >&2
11
+ return 1
12
+ fi
13
+ echo "Select component to install:" >&2
14
+ echo " 1) Backend (API)" >&2
15
+ echo " 2) Workers (nodes)" >&2
16
+ echo " 3) Gateway (HTTPS + UI)" >&2
17
+ local choice
18
+ read -r -p "Choice [1-3]: " choice
19
+ case "$choice" in
20
+ 1|api|backend) echo "api" ;;
21
+ 2|nodes|workers) echo "nodes" ;;
22
+ 3|gateway) echo "gateway" ;;
23
+ *) echo "Invalid choice" >&2; return 1 ;;
24
+ esac
25
+ }
package/lib/tls.sh ADDED
@@ -0,0 +1,59 @@
1
+ # shellcheck shell=bash
2
+ # TLS / certbot helpers.
3
+
4
+ infra_tls_check() {
5
+ local fullchain="$1"
6
+ local privkey="$2"
7
+ [[ -f "$fullchain" && -f "$privkey" ]]
8
+ }
9
+
10
+ infra_tls_suggest_certbot() {
11
+ local domains=("$@")
12
+ local joined=""
13
+ local d
14
+ for d in "${domains[@]}"; do
15
+ joined+=" -d $d"
16
+ done
17
+ cat <<EOF
18
+
19
+ TLS certificates not found. Issue with certbot (port 80 must be free):
20
+
21
+ sudo certbot certonly --standalone${joined}
22
+
23
+ Or after gateway HTTP is up (webroot):
24
+
25
+ sudo certbot certonly --webroot -w /var/www/certbot${joined}
26
+
27
+ Then set TLS_FULLCHAIN and TLS_PRIVKEY in .env and run ./start.sh
28
+
29
+ EOF
30
+ }
31
+
32
+ infra_tls_offer_interactive() {
33
+ local fullchain="$1"
34
+ local privkey="$2"
35
+ shift 2
36
+ local domains=("$@")
37
+ if infra_tls_check "$fullchain" "$privkey"; then
38
+ return 0
39
+ fi
40
+ infra_tls_suggest_certbot "${domains[@]}"
41
+ if [[ ! -t 0 ]]; then
42
+ return 0
43
+ fi
44
+ read -r -p "Run certbot --standalone now? [y/N] " ans
45
+ case "$ans" in
46
+ y|Y|yes|YES)
47
+ if ! command -v certbot >/dev/null 2>&1; then
48
+ echo "certbot not found — install certbot first" >&2
49
+ return 1
50
+ fi
51
+ local args=()
52
+ local d
53
+ for d in "${domains[@]}"; do
54
+ args+=(-d "$d")
55
+ done
56
+ sudo certbot certonly --standalone "${args[@]}"
57
+ ;;
58
+ esac
59
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "vibed-infra",
3
+ "version": "0.3.0",
4
+ "description": "Product-agnostic VPS packager: wget install, Docker Compose, TLS/nginx, auto-update",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/naiemk/vibed-infra.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/naiemk/vibed-infra/issues"
12
+ },
13
+ "homepage": "https://github.com/naiemk/vibed-infra#readme",
14
+ "bin": {
15
+ "vibed-infra": "./install.sh"
16
+ },
17
+ "files": [
18
+ "install.sh",
19
+ "start.sh",
20
+ "update.sh",
21
+ "install-auto-update.sh",
22
+ "lib",
23
+ "templates",
24
+ "schema",
25
+ "github",
26
+ "skills"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "scripts": {
32
+ "test": "bash scripts/validate-package.sh"
33
+ }
34
+ }