@le-space/rootfs 0.1.51 → 0.2.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.
@@ -0,0 +1,521 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
6
+ REPO_DIR="$(cd "${APP_DIR}/.." && pwd)"
7
+ OUT_DIR="${OUT_DIR:-${APP_DIR}/dist-rootfs}"
8
+ ROOTFS_CONTRACT_FILE="${ROOTFS_CONTRACT_FILE:-}"
9
+ ROOTFS_PROFILE="${ROOTFS_PROFILE:-}"
10
+ ROOTFS_INSTALL_MODE="${ROOTFS_INSTALL_MODE:-}"
11
+ ROOTFS_BUILD_DRIVER="${ROOTFS_BUILD_DRIVER:-auto}"
12
+ ROOTFS_SIZE_MIB="${ROOTFS_SIZE_MIB:-20480}"
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
+ ORBITDB_RELAY_PINNER_DIR="${ORBITDB_RELAY_PINNER_DIR:-}"
18
+ UNIVERSAL_CONNECTIVITY_DIR="${UNIVERSAL_CONNECTIVITY_DIR:-}"
19
+
20
+ require() {
21
+ command -v "$1" >/dev/null 2>&1 || {
22
+ echo "Missing required command: $1" >&2
23
+ exit 1
24
+ }
25
+ }
26
+
27
+ die() {
28
+ echo "$*" >&2
29
+ exit 1
30
+ }
31
+
32
+ load_rootfs_contract() {
33
+ [ -n "${ROOTFS_CONTRACT_FILE}" ] || return 0
34
+ require python3
35
+ [ -f "${ROOTFS_CONTRACT_FILE}" ] || die "Rootfs contract does not exist: ${ROOTFS_CONTRACT_FILE}"
36
+
37
+ # The contract is relay-owned metadata. We load it once here so users can
38
+ # select a standardized profile without repeating profile/install-mode flags.
39
+ eval "$(python3 "${SCRIPT_DIR}/read-rootfs-contract.py" "${ROOTFS_CONTRACT_FILE}")"
40
+
41
+ if [ -n "${ROOTFS_PROFILE}" ] && [ "${ROOTFS_PROFILE}" != "${ROOTFS_CONTRACT_PROFILE}" ]; then
42
+ die "ROOTFS_PROFILE=${ROOTFS_PROFILE} conflicts with contract profile ${ROOTFS_CONTRACT_PROFILE}"
43
+ fi
44
+ if [ -n "${ROOTFS_INSTALL_MODE}" ] && [ "${ROOTFS_INSTALL_MODE}" != "${ROOTFS_CONTRACT_INSTALL_MODE}" ]; then
45
+ die "ROOTFS_INSTALL_MODE=${ROOTFS_INSTALL_MODE} conflicts with contract install mode ${ROOTFS_CONTRACT_INSTALL_MODE}"
46
+ fi
47
+
48
+ ROOTFS_PROFILE="${ROOTFS_CONTRACT_PROFILE}"
49
+ ROOTFS_INSTALL_MODE="${ROOTFS_INSTALL_MODE:-${ROOTFS_CONTRACT_INSTALL_MODE}}"
50
+ echo "Loaded rootfs contract: ${ROOTFS_CONTRACT_PATH}"
51
+ }
52
+
53
+ load_rootfs_contract
54
+ ROOTFS_PROFILE="${ROOTFS_PROFILE:-py-libp2p}"
55
+
56
+ case "${ROOTFS_PROFILE}" in
57
+ py-libp2p)
58
+ IMAGE_BASENAME="aleph-py-libp2p-relay.qcow2"
59
+ DEFAULT_ROOTFS_VERSION="py-libp2p-relay-v0.1.0"
60
+ if [ -z "${ROOTFS_INSTALL_MODE}" ]; then
61
+ ROOTFS_INSTALL_MODE="thin"
62
+ fi
63
+ ;;
64
+ orbitdb-relay-pinner)
65
+ IMAGE_BASENAME="aleph-orbitdb-relay-pinner.qcow2"
66
+ DEFAULT_ROOTFS_VERSION="orbitdb-relay-pinner-v0.1.0"
67
+ if [ -z "${ROOTFS_INSTALL_MODE}" ]; then
68
+ ROOTFS_INSTALL_MODE="prebaked"
69
+ fi
70
+ ;;
71
+ uc-go-peer)
72
+ IMAGE_BASENAME="aleph-uc-go-peer.qcow2"
73
+ DEFAULT_ROOTFS_VERSION="uc-go-peer-v0.1.0"
74
+ if [ -z "${ROOTFS_INSTALL_MODE}" ]; then
75
+ ROOTFS_INSTALL_MODE="prebaked"
76
+ fi
77
+ ;;
78
+ uc-rust-peer)
79
+ IMAGE_BASENAME="aleph-uc-rust-peer.qcow2"
80
+ DEFAULT_ROOTFS_VERSION="uc-rust-peer-v0.1.0"
81
+ if [ -z "${ROOTFS_INSTALL_MODE}" ]; then
82
+ ROOTFS_INSTALL_MODE="prebaked"
83
+ fi
84
+ ;;
85
+ *)
86
+ echo "Unsupported ROOTFS_PROFILE: ${ROOTFS_PROFILE}" >&2
87
+ echo "Expected one of: py-libp2p, orbitdb-relay-pinner, uc-go-peer, uc-rust-peer" >&2
88
+ exit 1
89
+ ;;
90
+ esac
91
+
92
+ case "${ROOTFS_INSTALL_MODE}" in
93
+ thin|prebaked)
94
+ ;;
95
+ *)
96
+ echo "Unsupported ROOTFS_INSTALL_MODE: ${ROOTFS_INSTALL_MODE}" >&2
97
+ echo "Expected one of: thin, prebaked" >&2
98
+ exit 1
99
+ ;;
100
+ esac
101
+
102
+ case "${ROOTFS_BUILD_DRIVER}" in
103
+ auto|host|docker)
104
+ ;;
105
+ *)
106
+ echo "Unsupported ROOTFS_BUILD_DRIVER: ${ROOTFS_BUILD_DRIVER}" >&2
107
+ echo "Expected one of: auto, host, docker" >&2
108
+ exit 1
109
+ ;;
110
+ esac
111
+
112
+ IMAGE="${OUT_DIR}/${IMAGE_BASENAME}"
113
+
114
+ resolve_rootfs_version() {
115
+ if [ -n "${ROOTFS_VERSION:-}" ]; then
116
+ printf '%s\n' "${ROOTFS_VERSION}"
117
+ return
118
+ fi
119
+
120
+ if [ "${ROOTFS_PROFILE}" = "orbitdb-relay-pinner" ] && [ -n "${ORBITDB_RELAY_PINNER_DIR}" ]; then
121
+ local orbitdb_package_json="${ORBITDB_RELAY_PINNER_DIR}/package.json"
122
+ if [ -f "${orbitdb_package_json}" ] && command -v python3 >/dev/null 2>&1; then
123
+ local orbitdb_version
124
+ orbitdb_version="$(python3 - "${orbitdb_package_json}" <<'PY'
125
+ import json
126
+ import sys
127
+ from pathlib import Path
128
+
129
+ try:
130
+ payload = json.loads(Path(sys.argv[1]).read_text())
131
+ except Exception:
132
+ raise SystemExit(1)
133
+
134
+ version = payload.get("version")
135
+ if not isinstance(version, str) or not version.strip():
136
+ raise SystemExit(1)
137
+
138
+ print(f"orbitdb-relay-pinner-v{version.strip().lstrip('v')}")
139
+ PY
140
+ )" || orbitdb_version=""
141
+
142
+ if [ -n "${orbitdb_version}" ]; then
143
+ printf '%s\n' "${orbitdb_version}"
144
+ return
145
+ fi
146
+ fi
147
+ fi
148
+
149
+ if [ "${ROOTFS_PROFILE}" = "uc-rust-peer" ] && [ -n "${UNIVERSAL_CONNECTIVITY_DIR}" ]; then
150
+ local rust_peer_cargo_toml="${UNIVERSAL_CONNECTIVITY_DIR}/rust-peer/Cargo.toml"
151
+ if [ -f "${rust_peer_cargo_toml}" ]; then
152
+ local rust_peer_version
153
+ rust_peer_version="$(
154
+ sed -nE 's/^version = "([^"]+)"/uc-rust-peer-v\1/p' "${rust_peer_cargo_toml}" | head -n 1
155
+ )"
156
+ if [ -n "${rust_peer_version}" ]; then
157
+ printf '%s\n' "${rust_peer_version}"
158
+ return
159
+ fi
160
+ fi
161
+ fi
162
+
163
+ if [ "${ROOTFS_PROFILE}" = "uc-go-peer" ] && [ -n "${UNIVERSAL_CONNECTIVITY_DIR}" ]; then
164
+ if [ -f "${UNIVERSAL_CONNECTIVITY_DIR}/go-peer/go.mod" ]; then
165
+ printf '%s\n' "${DEFAULT_ROOTFS_VERSION}"
166
+ return
167
+ fi
168
+ fi
169
+
170
+ printf '%s\n' "${DEFAULT_ROOTFS_VERSION}"
171
+ }
172
+
173
+ ROOTFS_VERSION="$(resolve_rootfs_version)"
174
+
175
+ resolve_aleph_bin() {
176
+ if [ -n "${ALEPH_BIN:-}" ]; then
177
+ printf '%s\n' "${ALEPH_BIN}"
178
+ return
179
+ fi
180
+
181
+ local local_aleph="${REPO_DIR}/aleph-client/.venv/bin/aleph"
182
+ if [ -x "${local_aleph}" ]; then
183
+ printf '%s\n' "${local_aleph}"
184
+ return
185
+ fi
186
+
187
+ if command -v aleph >/dev/null 2>&1; then
188
+ command -v aleph
189
+ return
190
+ fi
191
+
192
+ echo "Missing aleph CLI. Set ALEPH_BIN=/path/to/aleph or install aleph-client." >&2
193
+ exit 1
194
+ }
195
+
196
+ build_with_host_tools() {
197
+ echo "Using host virt-customize/qemu-img toolchain."
198
+ "${SCRIPT_DIR}/build-rootfs-image.sh"
199
+ }
200
+
201
+ build_with_docker() {
202
+ require docker
203
+
204
+ if ! docker info >/dev/null 2>&1; then
205
+ echo "Docker is installed, but the Docker daemon is not running." >&2
206
+ echo "Start Docker Desktop, wait until it is ready, then rerun rootfs/build-rootfs.sh." >&2
207
+ exit 1
208
+ fi
209
+
210
+ echo "virt-customize is not available on this host."
211
+ echo "Using Dockerized Debian/libguestfs builder instead."
212
+ echo "This can be slow on macOS because qemu may run without hardware acceleration."
213
+
214
+ docker build --platform linux/amd64 \
215
+ -t aleph-relay-rootfs-builder:local \
216
+ -f "${SCRIPT_DIR}/Dockerfile.rootfs" \
217
+ "${SCRIPT_DIR}"
218
+
219
+ local orbitdb_mount=()
220
+ local orbitdb_env=()
221
+ local uc_mount=()
222
+ local uc_env=()
223
+ if [ "${ROOTFS_PROFILE}" = "orbitdb-relay-pinner" ]; then
224
+ if [ -z "${ORBITDB_RELAY_PINNER_DIR}" ]; then
225
+ echo "ROOTFS_PROFILE=orbitdb-relay-pinner requires ORBITDB_RELAY_PINNER_DIR=/path/to/orbitdb-relay-pinner" >&2
226
+ exit 1
227
+ fi
228
+ if [ ! -d "${ORBITDB_RELAY_PINNER_DIR}" ]; then
229
+ echo "Missing orbitdb-relay-pinner directory: ${ORBITDB_RELAY_PINNER_DIR}" >&2
230
+ exit 1
231
+ fi
232
+ orbitdb_mount=(-v "${ORBITDB_RELAY_PINNER_DIR}:/workspace-orbitdb-relay-pinner:ro")
233
+ orbitdb_env=(-e ORBITDB_RELAY_PINNER_DIR=/workspace-orbitdb-relay-pinner)
234
+ fi
235
+ if [ "${ROOTFS_PROFILE}" = "uc-rust-peer" ]; then
236
+ if [ -z "${UNIVERSAL_CONNECTIVITY_DIR}" ]; then
237
+ echo "ROOTFS_PROFILE=uc-rust-peer requires UNIVERSAL_CONNECTIVITY_DIR=/path/to/universal-connectivity" >&2
238
+ exit 1
239
+ fi
240
+ if [ ! -d "${UNIVERSAL_CONNECTIVITY_DIR}/rust-peer" ]; then
241
+ echo "Missing rust-peer directory: ${UNIVERSAL_CONNECTIVITY_DIR}/rust-peer" >&2
242
+ exit 1
243
+ fi
244
+ uc_mount=(-v "${UNIVERSAL_CONNECTIVITY_DIR}:/workspace-universal-connectivity:ro")
245
+ uc_env=(-e UNIVERSAL_CONNECTIVITY_DIR=/workspace-universal-connectivity)
246
+ fi
247
+ if [ "${ROOTFS_PROFILE}" = "uc-go-peer" ]; then
248
+ if [ -z "${UNIVERSAL_CONNECTIVITY_DIR}" ]; then
249
+ echo "ROOTFS_PROFILE=uc-go-peer requires UNIVERSAL_CONNECTIVITY_DIR=/path/to/universal-connectivity" >&2
250
+ exit 1
251
+ fi
252
+ if [ ! -d "${UNIVERSAL_CONNECTIVITY_DIR}/go-peer" ]; then
253
+ echo "Missing go-peer directory: ${UNIVERSAL_CONNECTIVITY_DIR}/go-peer" >&2
254
+ exit 1
255
+ fi
256
+ uc_mount=(-v "${UNIVERSAL_CONNECTIVITY_DIR}:/workspace-universal-connectivity:ro")
257
+ uc_env=(-e UNIVERSAL_CONNECTIVITY_DIR=/workspace-universal-connectivity)
258
+ fi
259
+
260
+ docker run --rm --privileged --platform linux/amd64 \
261
+ -e LIBGUESTFS_BACKEND=direct \
262
+ -e ROOTFS_PROFILE="${ROOTFS_PROFILE}" \
263
+ -e ROOTFS_INSTALL_MODE="${ROOTFS_INSTALL_MODE}" \
264
+ -e PY_LIBP2P_DIR=/workspace/py-libp2p \
265
+ "${orbitdb_env[@]}" \
266
+ "${uc_env[@]}" \
267
+ -e OUT_DIR=/workspace/relay-deployer-pwa/dist-rootfs \
268
+ -e BASE_URL="${BASE_URL:-}" \
269
+ -e IMAGE_SIZE="${IMAGE_SIZE:-20G}" \
270
+ -v "${REPO_DIR}:/workspace" \
271
+ "${orbitdb_mount[@]}" \
272
+ "${uc_mount[@]}" \
273
+ -w /workspace/relay-deployer-pwa \
274
+ aleph-relay-rootfs-builder:local \
275
+ bash rootfs/build-rootfs-image.sh
276
+ }
277
+
278
+ required_port_forwards_json() {
279
+ if [ -n "${ROOTFS_CONTRACT_PORT_FORWARDS_JSON:-}" ]; then
280
+ printf ' "requiredPortForwards": %s,\n' "${ROOTFS_CONTRACT_PORT_FORWARDS_JSON}"
281
+ return
282
+ fi
283
+
284
+ case "${ROOTFS_PROFILE}" in
285
+ orbitdb-relay-pinner)
286
+ cat <<'EOF'
287
+ "requiredPortForwards": [
288
+ { "port": 22, "tcp": true, "udp": false, "purpose": "SSH" },
289
+ { "port": 80, "tcp": true, "udp": false, "purpose": "Temporary setup endpoint" },
290
+ { "port": 9090, "tcp": true, "udp": false, "purpose": "Metrics and health API" },
291
+ { "port": 9091, "tcp": true, "udp": false, "purpose": "libp2p TCP" },
292
+ { "port": 443, "tcp": true, "udp": false, "purpose": "Caddy HTTPS and WSS proxy" },
293
+ { "port": 9093, "tcp": false, "udp": true, "purpose": "WebRTC direct" },
294
+ { "port": 9094, "tcp": false, "udp": true, "purpose": "QUIC" }
295
+ ],
296
+ EOF
297
+ ;;
298
+ uc-rust-peer)
299
+ cat <<'EOF'
300
+ "requiredPortForwards": [
301
+ { "port": 22, "tcp": true, "udp": false, "purpose": "SSH" },
302
+ { "port": 80, "tcp": true, "udp": false, "purpose": "Temporary setup endpoint" },
303
+ { "port": 9092, "tcp": true, "udp": false, "purpose": "libp2p TCP" },
304
+ { "port": 9093, "tcp": true, "udp": false, "purpose": "WebSocket bridge" },
305
+ { "port": 443, "tcp": true, "udp": false, "purpose": "Caddy HTTPS and WSS proxy" },
306
+ { "port": 9090, "tcp": false, "udp": true, "purpose": "WebRTC direct" },
307
+ { "port": 9091, "tcp": false, "udp": true, "purpose": "QUIC" }
308
+ ],
309
+ EOF
310
+ ;;
311
+ uc-go-peer)
312
+ cat <<'EOF'
313
+ "requiredPortForwards": [
314
+ { "port": 22, "tcp": true, "udp": false, "purpose": "SSH" },
315
+ { "port": 80, "tcp": true, "udp": false, "purpose": "Temporary setup endpoint" },
316
+ { "port": 443, "tcp": true, "udp": false, "purpose": "Caddy HTTPS and WSS proxy" },
317
+ { "port": 9095, "tcp": true, "udp": true, "purpose": "libp2p raw TCP and UDP transports" }
318
+ ],
319
+ EOF
320
+ ;;
321
+ esac
322
+ }
323
+
324
+ sync_manifest_copy_target() {
325
+ local manifest_path="${OUT_DIR}/rootfs-manifest.json"
326
+ local copy_target="${ROOTFS_CONTRACT_MANIFEST_COPY_TARGET:-}"
327
+ local resolved_target=""
328
+ local target_dir=""
329
+ local target_ext=".json"
330
+ local versioned_target=""
331
+
332
+ [ -n "${copy_target}" ] || return 0
333
+ [ -f "${manifest_path}" ] || die "Manifest does not exist: ${manifest_path}"
334
+
335
+ if [[ "${copy_target}" = /* ]]; then
336
+ resolved_target="${copy_target}"
337
+ else
338
+ resolved_target="${REPO_DIR}/${copy_target}"
339
+ fi
340
+
341
+ target_dir="$(dirname "${resolved_target}")"
342
+ mkdir -p "${target_dir}"
343
+ cp "${manifest_path}" "${resolved_target}"
344
+
345
+ case "${resolved_target}" in
346
+ *.json)
347
+ target_ext=".json"
348
+ ;;
349
+ esac
350
+
351
+ versioned_target="${target_dir}/${ROOTFS_VERSION}${target_ext}"
352
+ cp "${manifest_path}" "${versioned_target}"
353
+
354
+ echo "Copied rootfs manifest to ${resolved_target}"
355
+ echo "Copied versioned rootfs manifest to ${versioned_target}"
356
+ }
357
+
358
+ write_manifest() {
359
+ local rootfs_item_hash="$1"
360
+ local rootfs_source_size_bytes=""
361
+ local requires_bootstrap_network="false"
362
+ local bootstrap_summary="Dependencies are preinstalled in the image."
363
+ local notes="${ROOTFS_CONTRACT_MANIFEST_NOTES:-}"
364
+
365
+ if [ "${ROOTFS_INSTALL_MODE}" = "thin" ]; then
366
+ requires_bootstrap_network="true"
367
+ bootstrap_summary="First boot installs runtime packages and application dependencies. Outbound network access is required before the relay service becomes healthy."
368
+ fi
369
+
370
+ if [ -z "${notes}" ] && [ "${ROOTFS_PROFILE}" = "uc-rust-peer" ]; then
371
+ notes="The rust-peer image publishes a WSS bridge on 443 for browser clients, but the upstream peer does not yet self-advertise host-remapped websocket multiaddrs. Use the mapped proxy hostname or explicit multiaddrs from deployment metadata."
372
+ fi
373
+ if [ -z "${notes}" ] && [ "${ROOTFS_PROFILE}" = "uc-go-peer" ]; then
374
+ notes="The go-peer image keeps the relay on internal port 9095, then advertises raw TCP on external port 80, secure WebSocket on 443 via Caddy, and a single externally mapped UDP port for QUIC, WebTransport, and WebRTC-direct."
375
+ fi
376
+
377
+ if [ -f "${OUT_DIR}/ipfs-add-response.jsonl" ]; then
378
+ rootfs_source_size_bytes="$(python3 - "${OUT_DIR}/ipfs-add-response.jsonl" <<'PY'
379
+ import json
380
+ import sys
381
+ from pathlib import Path
382
+
383
+ lines = [line for line in Path(sys.argv[1]).read_text().splitlines() if line.strip()]
384
+ if not lines:
385
+ raise SystemExit(0)
386
+
387
+ payload = json.loads(lines[-1])
388
+ size = payload.get("Size")
389
+ if isinstance(size, str) and size.isdigit():
390
+ print(size)
391
+ elif isinstance(size, int) and size > 0:
392
+ print(size)
393
+ PY
394
+ )"
395
+ fi
396
+
397
+ cat > "${OUT_DIR}/rootfs-manifest.json" <<EOF
398
+ {
399
+ "profile": "${ROOTFS_PROFILE}",
400
+ "version": "${ROOTFS_VERSION}",
401
+ "rootfsInstallStrategy": "${ROOTFS_INSTALL_MODE}",
402
+ "requiresBootstrapNetwork": ${requires_bootstrap_network},
403
+ "bootstrapSummary": "${bootstrap_summary}",
404
+ $(if [[ "${rootfs_source_size_bytes}" =~ ^[0-9]+$ ]]; then printf ' "rootfsSourceSizeBytes": %s,\n' "${rootfs_source_size_bytes}"; fi)$(required_port_forwards_json) "rootfsItemHash": "${rootfs_item_hash}",
405
+ "rootfsSizeMiB": ${ROOTFS_SIZE_MIB},
406
+ "createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"$(if [ -n "${notes}" ]; then printf ',\n "notes": "%s"' "${notes}"; fi)
407
+ }
408
+ EOF
409
+
410
+ echo "Rootfs manifest written to ${OUT_DIR}/rootfs-manifest.json"
411
+ sync_manifest_copy_target
412
+ }
413
+
414
+ upload_image() {
415
+ local aleph_bin
416
+ aleph_bin="$(resolve_aleph_bin)"
417
+
418
+ require python3
419
+ require curl
420
+ [ -f "${IMAGE}" ] || die "Rootfs image does not exist: ${IMAGE}"
421
+
422
+ echo "Uploading ${IMAGE} to IPFS via ${IPFS_ADD_URL}..." >&2
423
+ : > "${OUT_DIR}/ipfs-add-response.jsonl"
424
+ if ! curl --fail --silent --show-error \
425
+ -X POST \
426
+ -F "file=@${IMAGE}" \
427
+ "${IPFS_ADD_URL}" \
428
+ > "${OUT_DIR}/ipfs-add-response.jsonl"; then
429
+ die "IPFS upload failed for ${IMAGE}"
430
+ fi
431
+
432
+ local cid
433
+ if ! cid="$(python3 - "${OUT_DIR}/ipfs-add-response.jsonl" <<'PY'
434
+ import json
435
+ import sys
436
+ from pathlib import Path
437
+
438
+ lines = [line for line in Path(sys.argv[1]).read_text().splitlines() if line.strip()]
439
+ if not lines:
440
+ raise SystemExit("No response received from the IPFS add endpoint")
441
+
442
+ payload = json.loads(lines[-1])
443
+ cid = payload.get("Hash")
444
+ if not cid:
445
+ raise SystemExit(f"IPFS add response did not include a Hash: {payload}")
446
+
447
+ print(cid)
448
+ PY
449
+ )"; then
450
+ die "Failed to extract CID from ${OUT_DIR}/ipfs-add-response.jsonl"
451
+ fi
452
+ [ -n "${cid}" ] || die "IPFS upload returned an empty CID"
453
+
454
+ echo "Pinning CID ${cid} on Aleph Cloud with ${aleph_bin}..." >&2
455
+ : > "${OUT_DIR}/store-message.json"
456
+ if ! "${aleph_bin}" file pin "${cid}" \
457
+ --channel "${CHANNEL}" \
458
+ > "${OUT_DIR}/store-message.json"; then
459
+ die "Aleph pin failed for CID ${cid}"
460
+ fi
461
+
462
+ if ! python3 - "${OUT_DIR}/store-message.json" <<'PY'
463
+ import json
464
+ import sys
465
+ from pathlib import Path
466
+
467
+ content = Path(sys.argv[1]).read_text().strip()
468
+ if not content:
469
+ raise SystemExit("Aleph pin returned an empty response")
470
+
471
+ payload = json.loads(content)
472
+ print(payload["item_hash"])
473
+ PY
474
+ then
475
+ die "Failed to extract item_hash from ${OUT_DIR}/store-message.json"
476
+ fi
477
+ }
478
+
479
+ mkdir -p "${OUT_DIR}"
480
+
481
+ echo "Building rootfs profile: ${ROOTFS_PROFILE}"
482
+ echo "Using install mode: ${ROOTFS_INSTALL_MODE}"
483
+
484
+ if [ "${SKIP_BUILD}" != "1" ]; then
485
+ case "${ROOTFS_BUILD_DRIVER}" in
486
+ host)
487
+ if command -v virt-customize >/dev/null 2>&1; then
488
+ build_with_host_tools
489
+ else
490
+ die "ROOTFS_BUILD_DRIVER=host requested, but virt-customize is not available."
491
+ fi
492
+ ;;
493
+ docker)
494
+ build_with_docker
495
+ ;;
496
+ auto)
497
+ # GitHub-hosted runners often have libguestfs tooling installed but fail
498
+ # later inside supermin. Prefer the Dockerized Debian/libguestfs toolchain
499
+ # in CI, while keeping host builds for local Linux machines.
500
+ if [ "${GITHUB_ACTIONS:-}" = "true" ] && command -v docker >/dev/null 2>&1; then
501
+ build_with_docker
502
+ elif command -v virt-customize >/dev/null 2>&1; then
503
+ build_with_host_tools
504
+ else
505
+ build_with_docker
506
+ fi
507
+ ;;
508
+ esac
509
+ else
510
+ echo "SKIP_BUILD=1 set; reusing ${IMAGE}"
511
+ [ -f "${IMAGE}" ] || die "SKIP_BUILD=1 requested, but rootfs image is missing: ${IMAGE}"
512
+ fi
513
+
514
+ if [ "${SKIP_UPLOAD}" = "1" ]; then
515
+ echo "SKIP_UPLOAD=1 set; image ready at ${IMAGE}"
516
+ echo "Upload later with: SKIP_BUILD=1 rootfs/build-rootfs.sh"
517
+ exit 0
518
+ fi
519
+
520
+ ROOTFS_ITEM_HASH="$(upload_image)"
521
+ write_manifest "${ROOTFS_ITEM_HASH}"