@le-space/rootfs 0.1.3 → 0.1.5

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.
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ ALEPH_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
6
+ PROJECT_DIR="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
7
+ OUT_DIR="${OUT_DIR:-${ALEPH_DIR}/dist-rootfs}"
8
+ ROOTFS_CONTRACT_FILE="${ROOTFS_CONTRACT_FILE:-${ALEPH_DIR}/root-profiles/uc-go-peer.json}"
9
+ ROOTFS_BUILD_DRIVER="${ROOTFS_BUILD_DRIVER:-auto}"
10
+ ROOTFS_SIZE_MIB="${ROOTFS_SIZE_MIB:-20480}"
11
+ ROOTFS_IMAGE_SIZE="${ROOTFS_IMAGE_SIZE:-20G}"
12
+ ROOTFS_VERSION="${ROOTFS_VERSION:-}"
13
+ CHANNEL="${CHANNEL:-ALEPH-CLOUDSOLUTIONS}"
14
+ SKIP_UPLOAD="${SKIP_UPLOAD:-0}"
15
+ SKIP_BUILD="${SKIP_BUILD:-0}"
16
+ IPFS_ADD_URL="${IPFS_ADD_URL:-https://ipfs.aleph.cloud/api/v0/add}"
17
+ IPFS_GATEWAY_URL="${IPFS_GATEWAY_URL:-https://ipfs.aleph.cloud/ipfs}"
18
+ ALEPH_API_HOST="${ALEPH_API_HOST:-https://api2.aleph.im}"
19
+ ALEPH_MESSAGE_WAIT_ATTEMPTS="${ALEPH_MESSAGE_WAIT_ATTEMPTS:-60}"
20
+ ALEPH_MESSAGE_WAIT_DELAY_SECONDS="${ALEPH_MESSAGE_WAIT_DELAY_SECONDS:-5}"
21
+ ALEPH_PIN_ATTEMPTS="${ALEPH_PIN_ATTEMPTS:-4}"
22
+ ALEPH_PIN_DELAY_SECONDS="${ALEPH_PIN_DELAY_SECONDS:-10}"
23
+ IPFS_GATEWAY_WAIT_ATTEMPTS="${IPFS_GATEWAY_WAIT_ATTEMPTS:-30}"
24
+ IPFS_GATEWAY_WAIT_DELAY_SECONDS="${IPFS_GATEWAY_WAIT_DELAY_SECONDS:-10}"
25
+ ROOTFS_CID=""
26
+ ROOTFS_ITEM_HASH=""
27
+
28
+ require() {
29
+ command -v "$1" >/dev/null 2>&1 || {
30
+ echo "Missing required command: $1" >&2
31
+ exit 1
32
+ }
33
+ }
34
+
35
+ die() {
36
+ echo "$*" >&2
37
+ exit 1
38
+ }
39
+
40
+ load_rootfs_contract() {
41
+ require python3
42
+ [ -f "${ROOTFS_CONTRACT_FILE}" ] || die "Rootfs contract does not exist: ${ROOTFS_CONTRACT_FILE}"
43
+
44
+ eval "$(python3 "${SCRIPT_DIR}/read-rootfs-contract.py" "${ROOTFS_CONTRACT_FILE}")"
45
+
46
+ if [ "${ROOTFS_CONTRACT_PROFILE}" != "uc-go-peer" ]; then
47
+ die "Only the uc-go-peer rootfs profile is supported, got: ${ROOTFS_CONTRACT_PROFILE}"
48
+ fi
49
+ if [ "${ROOTFS_CONTRACT_INSTALL_MODE}" != "prebaked" ]; then
50
+ die "Only prebaked install mode is supported, got: ${ROOTFS_CONTRACT_INSTALL_MODE}"
51
+ fi
52
+ }
53
+
54
+ resolve_rootfs_version() {
55
+ if [ -n "${ROOTFS_VERSION}" ]; then
56
+ printf '%s\n' "${ROOTFS_VERSION}"
57
+ return
58
+ fi
59
+
60
+ if [ -d "${PROJECT_DIR}/.git" ]; then
61
+ local short_sha
62
+ short_sha="$(git -C "${PROJECT_DIR}" rev-parse --short HEAD)"
63
+ local build_date
64
+ build_date="$(date -u +%Y%m%d)"
65
+ printf 'uc-go-peer-git-%s-%s\n' "${build_date}" "${short_sha}"
66
+ return
67
+ fi
68
+
69
+ printf 'uc-go-peer-v0.1.0\n'
70
+ }
71
+
72
+ resolve_aleph_bin() {
73
+ if [ -n "${ALEPH_BIN:-}" ]; then
74
+ printf '%s\n' "${ALEPH_BIN}"
75
+ return
76
+ fi
77
+
78
+ if command -v aleph >/dev/null 2>&1; then
79
+ command -v aleph
80
+ return
81
+ fi
82
+
83
+ die "Missing aleph CLI. Set ALEPH_BIN=/path/to/aleph or install aleph-client."
84
+ }
85
+
86
+ build_with_host_tools() {
87
+ echo "Using host virt-customize/qemu-img toolchain."
88
+ ROOTFS_CONTRACT_FILE="${ROOTFS_CONTRACT_FILE}" \
89
+ OUT_DIR="${OUT_DIR}" \
90
+ ROOTFS_IMAGE_SIZE="${ROOTFS_IMAGE_SIZE}" \
91
+ PROJECT_DIR="${PROJECT_DIR}" \
92
+ bash "${SCRIPT_DIR}/build-rootfs-image.sh"
93
+ }
94
+
95
+ build_with_docker() {
96
+ require docker
97
+
98
+ if ! docker info >/dev/null 2>&1; then
99
+ die "Docker is installed, but the Docker daemon is not running."
100
+ fi
101
+
102
+ echo "Using Dockerized Debian/libguestfs builder."
103
+ docker build --platform linux/amd64 \
104
+ -t uc-go-peer-rootfs-builder:local \
105
+ -f "${SCRIPT_DIR}/Dockerfile.rootfs" \
106
+ "${SCRIPT_DIR}"
107
+
108
+ docker run --rm --privileged --platform linux/amd64 \
109
+ -e LIBGUESTFS_BACKEND=direct \
110
+ -e ROOTFS_CONTRACT_FILE=/workspace/universal-connectivity/go-peer/aleph/root-profiles/uc-go-peer.json \
111
+ -e OUT_DIR=/workspace/universal-connectivity/go-peer/aleph/dist-rootfs \
112
+ -e ROOTFS_IMAGE_SIZE="${ROOTFS_IMAGE_SIZE}" \
113
+ -e PROJECT_DIR=/workspace/universal-connectivity \
114
+ -v "${PROJECT_DIR}:/workspace/universal-connectivity" \
115
+ -w /workspace/universal-connectivity/go-peer/aleph \
116
+ uc-go-peer-rootfs-builder:local \
117
+ bash rootfs/build-rootfs-image.sh
118
+ }
119
+
120
+ sync_manifest_copy_target() {
121
+ local manifest_path="${OUT_DIR}/rootfs-manifest.json"
122
+ local copy_target="${ROOTFS_CONTRACT_MANIFEST_COPY_TARGET:-}"
123
+ local resolved_target
124
+ local target_dir
125
+ local target_ext
126
+ local versioned_target
127
+
128
+ [ -n "${copy_target}" ] || return 0
129
+ [ -f "${manifest_path}" ] || die "Manifest does not exist: ${manifest_path}"
130
+
131
+ if [[ "${copy_target}" = /* ]]; then
132
+ resolved_target="${copy_target}"
133
+ else
134
+ resolved_target="${PROJECT_DIR}/${copy_target}"
135
+ fi
136
+
137
+ target_dir="$(dirname "${resolved_target}")"
138
+ mkdir -p "${target_dir}"
139
+ cp "${manifest_path}" "${resolved_target}"
140
+
141
+ target_ext=".json"
142
+ case "${resolved_target}" in
143
+ *.json)
144
+ target_ext=".json"
145
+ ;;
146
+ esac
147
+ versioned_target="${target_dir}/${ROOTFS_VERSION}${target_ext}"
148
+ cp "${manifest_path}" "${versioned_target}"
149
+
150
+ echo "Copied rootfs manifest to ${resolved_target}"
151
+ echo "Copied versioned rootfs manifest to ${versioned_target}"
152
+ }
153
+
154
+ write_manifest() {
155
+ local rootfs_cid="${1:-}"
156
+ local rootfs_item_hash="${2:-}"
157
+ local rootfs_source_size_bytes=""
158
+
159
+ if [ -f "${OUT_DIR}/ipfs-add-response.jsonl" ]; then
160
+ rootfs_source_size_bytes="$(python3 - "${OUT_DIR}/ipfs-add-response.jsonl" <<'PY'
161
+ import json
162
+ import sys
163
+ from pathlib import Path
164
+
165
+ lines = [line for line in Path(sys.argv[1]).read_text().splitlines() if line.strip()]
166
+ if not lines:
167
+ raise SystemExit(0)
168
+
169
+ payload = json.loads(lines[-1])
170
+ size = payload.get("Size")
171
+ if isinstance(size, str) and size.isdigit():
172
+ print(size)
173
+ elif isinstance(size, int) and size > 0:
174
+ print(size)
175
+ PY
176
+ )"
177
+ fi
178
+
179
+ {
180
+ echo '{'
181
+ echo ' "profile": "uc-go-peer",'
182
+ echo " \"version\": \"${ROOTFS_VERSION}\","
183
+ echo ' "rootfsInstallStrategy": "prebaked",'
184
+ echo ' "requiresBootstrapNetwork": false,'
185
+ echo ' "bootstrapSummary": "Dependencies are preinstalled in the image.",'
186
+ if [[ "${rootfs_source_size_bytes}" =~ ^[0-9]+$ ]]; then
187
+ echo " \"rootfsSourceSizeBytes\": ${rootfs_source_size_bytes},"
188
+ fi
189
+ printf ' "requiredPortForwards": %s,\n' "${ROOTFS_CONTRACT_PORT_FORWARDS_JSON}"
190
+ if [ -n "${rootfs_cid}" ]; then
191
+ echo " \"rootfsCid\": \"${rootfs_cid}\","
192
+ fi
193
+ if [ -n "${rootfs_item_hash}" ]; then
194
+ echo " \"rootfsItemHash\": \"${rootfs_item_hash}\","
195
+ fi
196
+ echo " \"rootfsSizeMiB\": ${ROOTFS_SIZE_MIB},"
197
+ echo " \"createdAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
198
+ printf ' "notes": "%s"\n' "${ROOTFS_CONTRACT_MANIFEST_NOTES}"
199
+ echo '}'
200
+ } > "${OUT_DIR}/rootfs-manifest.json"
201
+
202
+ echo "Rootfs manifest written to ${OUT_DIR}/rootfs-manifest.json"
203
+ sync_manifest_copy_target
204
+ }
205
+
206
+ wait_for_aleph_message_processed() {
207
+ require python3
208
+ require curl
209
+
210
+ local item_hash="${1:?missing item hash}"
211
+ local attempts="${2:-${ALEPH_MESSAGE_WAIT_ATTEMPTS}}"
212
+ local delay_seconds="${3:-${ALEPH_MESSAGE_WAIT_DELAY_SECONDS}}"
213
+ local api_host="${4:-${ALEPH_API_HOST}}"
214
+ local response_file
215
+ response_file="$(mktemp)"
216
+
217
+ local attempt
218
+ for attempt in $(seq 1 "${attempts}"); do
219
+ if ! curl --fail --silent --show-error \
220
+ "${api_host}/api/v0/messages/${item_hash}" \
221
+ > "${response_file}"; then
222
+ rm -f "${response_file}"
223
+ die "Failed to query Aleph message status for ${item_hash}"
224
+ fi
225
+
226
+ local status
227
+ status="$(python3 - "${response_file}" <<'PY'
228
+ import json
229
+ import sys
230
+ from pathlib import Path
231
+
232
+ payload = json.loads(Path(sys.argv[1]).read_text())
233
+ status = payload.get("status")
234
+ print(status or "")
235
+ PY
236
+ )"
237
+
238
+ case "${status}" in
239
+ processed)
240
+ rm -f "${response_file}"
241
+ return 0
242
+ ;;
243
+ rejected)
244
+ local rejection_summary
245
+ rejection_summary="$(python3 - "${response_file}" <<'PY'
246
+ import json
247
+ import sys
248
+ from pathlib import Path
249
+
250
+ payload = json.loads(Path(sys.argv[1]).read_text())
251
+ error_code = payload.get("error_code")
252
+ details = payload.get("details")
253
+ first_error = details.get("errors", [None])[0] if isinstance(details, dict) else None
254
+ if error_code == 5 and isinstance(first_error, dict):
255
+ account_balance = first_error.get("account_balance")
256
+ required_balance = first_error.get("required_balance")
257
+ if account_balance is not None and required_balance is not None:
258
+ print(f"insufficient Aleph balance: account has {account_balance}, required is {required_balance}")
259
+ raise SystemExit(0)
260
+ if error_code is None:
261
+ print(json.dumps(details or {}))
262
+ else:
263
+ print(f"error {error_code}: {json.dumps(details or {})}")
264
+ PY
265
+ )"
266
+ rm -f "${response_file}"
267
+ die "Aleph STORE message ${item_hash} was rejected: ${rejection_summary}"
268
+ ;;
269
+ "")
270
+ ;;
271
+ *)
272
+ ;;
273
+ esac
274
+
275
+ if [ "${attempt}" -lt "${attempts}" ]; then
276
+ sleep "${delay_seconds}"
277
+ fi
278
+ done
279
+
280
+ rm -f "${response_file}"
281
+ die "Aleph STORE message ${item_hash} did not become processed after ${attempts} attempts."
282
+ }
283
+
284
+ wait_for_ipfs_cid_available() {
285
+ require curl
286
+
287
+ local cid="${1:?missing cid}"
288
+ local attempts="${2:-${IPFS_GATEWAY_WAIT_ATTEMPTS}}"
289
+ local delay_seconds="${3:-${IPFS_GATEWAY_WAIT_DELAY_SECONDS}}"
290
+ local gateway_base="${4:-${IPFS_GATEWAY_URL}}"
291
+ local gateway_url="${gateway_base%/}/${cid}"
292
+ local headers_file
293
+ headers_file="$(mktemp)"
294
+
295
+ local attempt
296
+ for attempt in $(seq 1 "${attempts}"); do
297
+ : > "${headers_file}"
298
+ if curl --silent --show-error --location \
299
+ --range 0-0 \
300
+ --dump-header "${headers_file}" \
301
+ --output /dev/null \
302
+ "${gateway_url}"; then
303
+ local http_status
304
+ http_status="$(python3 - "${headers_file}" <<'PY'
305
+ import sys
306
+ from pathlib import Path
307
+
308
+ status_lines = []
309
+ for line in Path(sys.argv[1]).read_text(errors="replace").splitlines():
310
+ if line.startswith("HTTP/"):
311
+ status_lines.append(line)
312
+
313
+ if not status_lines:
314
+ print("")
315
+ else:
316
+ print(status_lines[-1].split()[1])
317
+ PY
318
+ )"
319
+
320
+ case "${http_status}" in
321
+ 200|206)
322
+ rm -f "${headers_file}"
323
+ return 0
324
+ ;;
325
+ esac
326
+ fi
327
+
328
+ if [ "${attempt}" -lt "${attempts}" ]; then
329
+ echo "CID ${cid} is not retrievable from ${gateway_base} yet (attempt ${attempt}/${attempts}); retrying in ${delay_seconds}s..." >&2
330
+ sleep "${delay_seconds}"
331
+ fi
332
+ done
333
+
334
+ rm -f "${headers_file}"
335
+ die "CID ${cid} did not become retrievable from ${gateway_base} after ${attempts} attempts."
336
+ }
337
+
338
+ upload_image() {
339
+ local aleph_bin
340
+ aleph_bin="$(resolve_aleph_bin)"
341
+
342
+ require python3
343
+ require curl
344
+
345
+ local image="${OUT_DIR}/aleph-uc-go-peer.qcow2"
346
+ [ -f "${image}" ] || die "Rootfs image does not exist: ${image}"
347
+
348
+ echo "Uploading ${image} to IPFS via ${IPFS_ADD_URL}..."
349
+ : > "${OUT_DIR}/ipfs-add-response.jsonl"
350
+ if ! curl --fail --silent --show-error \
351
+ -X POST \
352
+ -F "file=@${image}" \
353
+ "${IPFS_ADD_URL}" \
354
+ > "${OUT_DIR}/ipfs-add-response.jsonl"; then
355
+ die "IPFS upload failed for ${image}"
356
+ fi
357
+
358
+ ROOTFS_CID="$(python3 - "${OUT_DIR}/ipfs-add-response.jsonl" <<'PY'
359
+ import json
360
+ import sys
361
+ from pathlib import Path
362
+
363
+ lines = [line for line in Path(sys.argv[1]).read_text().splitlines() if line.strip()]
364
+ if not lines:
365
+ raise SystemExit("No response received from the IPFS add endpoint")
366
+
367
+ payload = json.loads(lines[-1])
368
+ cid = payload.get("Hash")
369
+ if not cid:
370
+ raise SystemExit(f"IPFS add response did not include a Hash: {payload}")
371
+
372
+ print(cid)
373
+ PY
374
+ )" || die "Failed to extract CID from ${OUT_DIR}/ipfs-add-response.jsonl"
375
+
376
+ echo "Waiting for CID ${ROOTFS_CID} to become retrievable via ${IPFS_GATEWAY_URL}..."
377
+ wait_for_ipfs_cid_available "${ROOTFS_CID}"
378
+
379
+ echo "Pinning CID ${ROOTFS_CID} on Aleph Cloud..."
380
+ local attempt
381
+ local stderr_log="${OUT_DIR}/store-message.stderr.log"
382
+ local stdout_log="${OUT_DIR}/store-message.json"
383
+ local last_error_summary=""
384
+
385
+ for attempt in $(seq 1 "${ALEPH_PIN_ATTEMPTS}"); do
386
+ : > "${stdout_log}"
387
+ : > "${stderr_log}"
388
+
389
+ echo "Aleph pin attempt ${attempt}/${ALEPH_PIN_ATTEMPTS} for CID ${ROOTFS_CID}..."
390
+ if "${aleph_bin}" file pin "${ROOTFS_CID}" \
391
+ --channel "${CHANNEL}" \
392
+ > "${stdout_log}" 2> "${stderr_log}"; then
393
+ break
394
+ fi
395
+
396
+ last_error_summary="$(python3 - "${stderr_log}" <<'PY'
397
+ import sys
398
+ from pathlib import Path
399
+
400
+ text = Path(sys.argv[1]).read_text(errors="replace").strip()
401
+ print(text or "Aleph pin failed without stderr output")
402
+ PY
403
+ )"
404
+
405
+ echo "Aleph pin attempt ${attempt}/${ALEPH_PIN_ATTEMPTS} failed for CID ${ROOTFS_CID}." >&2
406
+ if [[ -n "${last_error_summary}" ]]; then
407
+ echo "${last_error_summary}" >&2
408
+ fi
409
+
410
+ if [ "${attempt}" -lt "${ALEPH_PIN_ATTEMPTS}" ]; then
411
+ echo "Retrying Aleph pin in ${ALEPH_PIN_DELAY_SECONDS}s..." >&2
412
+ sleep "${ALEPH_PIN_DELAY_SECONDS}"
413
+ continue
414
+ fi
415
+
416
+ die "Aleph pin failed for CID ${ROOTFS_CID} after ${ALEPH_PIN_ATTEMPTS} attempts"
417
+ done
418
+
419
+ if [ ! -s "${stdout_log}" ]; then
420
+ if [ -n "${last_error_summary}" ]; then
421
+ echo "${last_error_summary}" >&2
422
+ fi
423
+ die "Aleph pin returned an empty response for CID ${ROOTFS_CID}"
424
+ fi
425
+
426
+ ROOTFS_ITEM_HASH="$(python3 - "${stdout_log}" <<'PY'
427
+ import json
428
+ import sys
429
+ from pathlib import Path
430
+
431
+ content = Path(sys.argv[1]).read_text().strip()
432
+ if not content:
433
+ raise SystemExit("Aleph pin returned an empty response")
434
+
435
+ payload = json.loads(content)
436
+ print(payload["item_hash"])
437
+ PY
438
+ )" || die "Failed to extract Aleph item hash from ${OUT_DIR}/store-message.json"
439
+
440
+ wait_for_aleph_message_processed "${ROOTFS_ITEM_HASH}"
441
+
442
+ echo "Published rootfs CID: ${ROOTFS_CID}"
443
+ echo "Published Aleph item hash: ${ROOTFS_ITEM_HASH}"
444
+ }
445
+
446
+ mkdir -p "${OUT_DIR}"
447
+ load_rootfs_contract
448
+ ROOTFS_VERSION="$(resolve_rootfs_version)"
449
+
450
+ echo "Building rootfs profile: uc-go-peer"
451
+ echo "Using install mode: prebaked"
452
+
453
+ if [ "${SKIP_BUILD}" != "1" ]; then
454
+ case "${ROOTFS_BUILD_DRIVER}" in
455
+ host)
456
+ if command -v virt-customize >/dev/null 2>&1; then
457
+ build_with_host_tools
458
+ else
459
+ die "ROOTFS_BUILD_DRIVER=host requested, but virt-customize is not available."
460
+ fi
461
+ ;;
462
+ docker)
463
+ build_with_docker
464
+ ;;
465
+ auto)
466
+ if [ "${GITHUB_ACTIONS:-}" = "true" ] && command -v docker >/dev/null 2>&1; then
467
+ build_with_docker
468
+ elif command -v virt-customize >/dev/null 2>&1; then
469
+ build_with_host_tools
470
+ else
471
+ build_with_docker
472
+ fi
473
+ ;;
474
+ *)
475
+ die "Unsupported ROOTFS_BUILD_DRIVER: ${ROOTFS_BUILD_DRIVER}"
476
+ ;;
477
+ esac
478
+ else
479
+ [ -f "${OUT_DIR}/aleph-uc-go-peer.qcow2" ] || die "SKIP_BUILD=1 requested, but image is missing."
480
+ fi
481
+
482
+ if [ "${SKIP_UPLOAD}" = "1" ]; then
483
+ write_manifest
484
+ echo "SKIP_UPLOAD=1 set; image ready at ${OUT_DIR}/aleph-uc-go-peer.qcow2"
485
+ exit 0
486
+ fi
487
+
488
+ upload_image
489
+ write_manifest "${ROOTFS_CID}" "${ROOTFS_ITEM_HASH}"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shlex
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def shell_assign(name: str, value: str) -> str:
13
+ return f"{name}={shlex.quote(value)}"
14
+
15
+
16
+ def main() -> int:
17
+ parser = argparse.ArgumentParser(description="Read a relay rootfs contract and emit shell exports.")
18
+ parser.add_argument("contract", type=Path, help="Path to the rootfs contract JSON file")
19
+ args = parser.parse_args()
20
+
21
+ try:
22
+ payload = json.loads(args.contract.read_text())
23
+ except FileNotFoundError:
24
+ print(f"Missing rootfs contract: {args.contract}", file=sys.stderr)
25
+ return 1
26
+ except json.JSONDecodeError as exc:
27
+ print(f"Invalid rootfs contract JSON in {args.contract}: {exc}", file=sys.stderr)
28
+ return 1
29
+
30
+ rootfs = payload.get("rootfs") or {}
31
+ services = payload.get("services") or {}
32
+ source = payload.get("source") or {}
33
+ manifest = payload.get("manifest") or {}
34
+ ports = payload.get("ports") or []
35
+
36
+ profile = rootfs.get("profile")
37
+ install_mode = rootfs.get("installMode")
38
+
39
+ if not isinstance(profile, str) or not profile.strip():
40
+ print("Rootfs contract is missing rootfs.profile", file=sys.stderr)
41
+ return 1
42
+ if not isinstance(install_mode, str) or not install_mode.strip():
43
+ print("Rootfs contract is missing rootfs.installMode", file=sys.stderr)
44
+ return 1
45
+ if not isinstance(ports, list):
46
+ print("Rootfs contract field ports must be a list", file=sys.stderr)
47
+ return 1
48
+
49
+ lines = [
50
+ shell_assign("ROOTFS_CONTRACT_PATH", str(args.contract.resolve())),
51
+ shell_assign("ROOTFS_CONTRACT_ID", str(payload.get("id", ""))),
52
+ shell_assign("ROOTFS_CONTRACT_PROFILE", profile.strip()),
53
+ shell_assign("ROOTFS_CONTRACT_INSTALL_MODE", install_mode.strip()),
54
+ shell_assign("ROOTFS_CONTRACT_SOURCE_SUBDIRECTORY", str(source.get("subdirectory", ""))),
55
+ shell_assign("ROOTFS_CONTRACT_INSTALL_DIR", str(rootfs.get("installDir", ""))),
56
+ shell_assign("ROOTFS_CONTRACT_BINARY_PATH", str(rootfs.get("binaryPath", "/usr/local/bin/universal-chat-go"))),
57
+ shell_assign("ROOTFS_CONTRACT_DATA_DIR", str(rootfs.get("dataDir", ""))),
58
+ shell_assign("ROOTFS_CONTRACT_ENV_FILE", str(rootfs.get("envFile", ""))),
59
+ shell_assign("ROOTFS_CONTRACT_MAIN_SERVICE", str(services.get("main", ""))),
60
+ shell_assign("ROOTFS_CONTRACT_BOOTSTRAP_SERVICE", str(services.get("bootstrap", ""))),
61
+ shell_assign("ROOTFS_CONTRACT_AUTOTLS_SERVICE", str(services.get("autotlsRefresh", ""))),
62
+ shell_assign("ROOTFS_CONTRACT_MANIFEST_COPY_TARGET", str(manifest.get("copyTarget", ""))),
63
+ shell_assign("ROOTFS_CONTRACT_MANIFEST_NOTES", str(manifest.get("notes", ""))),
64
+ shell_assign("ROOTFS_CONTRACT_PORT_FORWARDS_JSON", json.dumps(ports, separators=(",", ":"))),
65
+ ]
66
+
67
+ print("\n".join(lines))
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import time
6
+ from typing import Iterable
7
+
8
+
9
+ ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/uc-go-peer")
10
+ READY_FILE = os.environ.get("READY_FILE", "/etc/default/uc-go-peer.ready")
11
+ AUTOTLS_READY_FILE = os.environ.get("AUTOTLS_READY_FILE", "/etc/default/uc-go-peer.autotls-ready")
12
+ AUTOTLS_ZONE_FILE = os.environ.get("AUTOTLS_ZONE_FILE", "/etc/default/uc-go-peer.autotls-zone")
13
+ AUTOTLS_HOSTS_FILE = os.environ.get("AUTOTLS_HOSTS_FILE", "/etc/default/uc-go-peer.autotls-hosts")
14
+ SERVICE_NAME = os.environ.get("SERVICE_NAME", "uc-go-peer.service")
15
+ WAIT_TIMEOUT_SECONDS = int(os.environ.get("AUTOTLS_WAIT_TIMEOUT_SECONDS", "900"))
16
+ WAIT_INTERVAL_SECONDS = float(os.environ.get("AUTOTLS_WAIT_INTERVAL_SECONDS", "5"))
17
+ WS_BACKEND_PORT = os.environ.get("WS_BACKEND_PORT", "9097").strip()
18
+
19
+
20
+ def parse_env_file(path: str) -> dict[str, str]:
21
+ values: dict[str, str] = {}
22
+ if not os.path.exists(path):
23
+ return values
24
+
25
+ with open(path, encoding="utf-8") as handle:
26
+ for line in handle:
27
+ stripped = line.strip()
28
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
29
+ continue
30
+ key, value = stripped.split("=", 1)
31
+ values[key.strip()] = value.strip()
32
+ return values
33
+
34
+
35
+ def write_env_var(path: str, key: str, value: str) -> None:
36
+ lines: list[str] = []
37
+ replaced = False
38
+
39
+ if os.path.exists(path):
40
+ with open(path, encoding="utf-8") as handle:
41
+ lines = handle.readlines()
42
+
43
+ with open(path, "w", encoding="utf-8") as handle:
44
+ for line in lines:
45
+ stripped = line.lstrip()
46
+ if stripped.startswith(f"{key}=") or stripped.startswith(f"#{key}="):
47
+ handle.write(f"{key}={value}\n")
48
+ replaced = True
49
+ else:
50
+ handle.write(line)
51
+
52
+ if not replaced:
53
+ handle.write(f"{key}={value}\n")
54
+
55
+
56
+ def dedupe(sequence: Iterable[str]) -> list[str]:
57
+ seen: set[str] = set()
58
+ values: list[str] = []
59
+ for item in sequence:
60
+ if item and item not in seen:
61
+ seen.add(item)
62
+ values.append(item)
63
+ return values
64
+
65
+
66
+ def wait_for_exact_hosts(ws_port: str) -> tuple[str, list[str], list[str]]:
67
+ deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS
68
+ regex = re.compile(rf"(/(?:ip4|ip6)/[^ ]+/tcp/{re.escape(ws_port)}/tls/sni/([^/]+)/ws)")
69
+ zone_regex = re.compile(r'identifier": "\\*\.([^"]+)"')
70
+ last_error = "AutoTLS websocket hostnames not advertised yet"
71
+
72
+ while time.monotonic() < deadline:
73
+ result = subprocess.run(
74
+ ["journalctl", "-u", SERVICE_NAME, "-n", "400", "--no-pager"],
75
+ capture_output=True,
76
+ text=True,
77
+ check=False,
78
+ )
79
+ output = result.stdout
80
+ pairs = regex.findall(output)
81
+ addrs = dedupe([pair[0] for pair in pairs])
82
+ hosts = dedupe([pair[1] for pair in pairs])
83
+ zone_match = zone_regex.search(output)
84
+ zone = zone_match.group(1) if zone_match else ""
85
+ if zone and hosts:
86
+ return zone, hosts, addrs
87
+ if hosts:
88
+ return "", hosts, addrs
89
+ if result.stderr.strip():
90
+ last_error = result.stderr.strip()
91
+ time.sleep(WAIT_INTERVAL_SECONDS)
92
+
93
+ raise RuntimeError(last_error)
94
+
95
+
96
+ def main() -> None:
97
+ if not os.path.exists(READY_FILE):
98
+ raise SystemExit(f"missing ready file: {READY_FILE}")
99
+
100
+ env_values = parse_env_file(ENV_FILE)
101
+ ws_port = env_values.get("GO_PEER_WSS_PORT", "").strip() or WS_BACKEND_PORT
102
+ if not ws_port:
103
+ raise RuntimeError("missing GO_PEER_WSS_PORT in environment file")
104
+
105
+ proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip()
106
+
107
+ zone, exact_hosts, exact_logged_addrs = wait_for_exact_hosts(ws_port)
108
+ if not exact_hosts:
109
+ raise RuntimeError("no AutoTLS websocket hostnames found in logs")
110
+
111
+ existing = [entry.strip() for entry in env_values.get("LIBP2P_ANNOUNCE_ADDRS", "").split(",") if entry.strip()]
112
+ wildcard_filtered = [entry for entry in existing if "/tls/sni/*." not in entry]
113
+ exact_announces: list[str] = list(exact_logged_addrs)
114
+
115
+ if proxy_hostname:
116
+ exact_announces.append(f"/dns4/{proxy_hostname}/tcp/443/tls/ws")
117
+ exact_announces.append(f"/dns6/{proxy_hostname}/tcp/443/tls/ws")
118
+
119
+ merged = dedupe(wildcard_filtered + exact_announces)
120
+ announce_value = ",".join(merged)
121
+ write_env_var(ENV_FILE, "LIBP2P_ANNOUNCE_ADDRS", announce_value)
122
+ if zone:
123
+ write_env_var(ENV_FILE, "AUTOTLS_SERVING_ZONE", zone)
124
+
125
+ with open(AUTOTLS_HOSTS_FILE, "w", encoding="utf-8") as handle:
126
+ for host in exact_hosts:
127
+ handle.write(f"{host}\n")
128
+ if zone:
129
+ with open(AUTOTLS_ZONE_FILE, "w", encoding="utf-8") as handle:
130
+ handle.write(f"{zone}\n")
131
+
132
+ service_restarted = False
133
+ if env_values.get("LIBP2P_ANNOUNCE_ADDRS", "") != announce_value:
134
+ subprocess.run(["systemctl", "restart", SERVICE_NAME], check=True)
135
+ service_restarted = True
136
+
137
+ open(AUTOTLS_READY_FILE, "a", encoding="utf-8").close()
138
+ print(f"Updated LIBP2P_ANNOUNCE_ADDRS={announce_value}")
139
+ if service_restarted:
140
+ print(f"Restarted {SERVICE_NAME} after AutoTLS hostname refresh")
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()