@le-space/rootfs 0.6.1 → 0.6.2

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
@@ -9,6 +9,8 @@ Shared rootfs contract parsing, reference profile assets, and build helpers.
9
9
  - copied `uc-go-peer` reference contract and guest asset set
10
10
  - copied `orbitdb-relay` reference contract and guest asset set,
11
11
  including the delayed first-start, setup endpoint, Caddy, and AutoTLS flow
12
+ - added a `ucan-store` reference contract scaffold with documented service
13
+ ports and consumer env expectations
12
14
 
13
15
  ## Reference assets
14
16
 
@@ -18,3 +20,5 @@ Current shared reference profiles live under:
18
20
  - `reference/uc-go-peer/rootfs/*`
19
21
  - `reference/orbitdb-relay/contract.json`
20
22
  - `reference/orbitdb-relay/rootfs/*`
23
+ - `reference/ucan-store/contract.json`
24
+ - `reference/ucan-store/rootfs/*`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/rootfs",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,89 @@
1
+ # ucan-store Rootfs Profile
2
+
3
+ This profile is the shared `relay-button` scaffold for deploying the
4
+ `ucan-store` upload service VM that backs the rebranded upload wall.
5
+
6
+ Current scope:
7
+
8
+ - shared rootfs contract for the `ucan-store` profile
9
+ - required public VM ports for Aleph guest setup plus the proxied upload API
10
+ - documented browser/runtime configuration inputs used by the existing PWA
11
+ - a prebaked qcow2 scaffold for the current local upload service runtime
12
+ - guest-side setup/configure scripts that publish the service URLs and DID back to deployment tooling
13
+ - stable guest-side service signer persistence with an Ed25519 default and hostname-aware DID selection
14
+ - a guest-side request guard that narrows public UCAN invocations to the configured bootstrap envelope
15
+ - a guest-side admin delegation issuance API that can mint importable child delegations from the service DID
16
+ - a public service manifest endpoint for domain-first runtime discovery by a generic PWA
17
+
18
+ Known inputs from the current `ucan-upload-wall` sources:
19
+
20
+ - Internal upload API process port: `STORACHA_LOCAL_PORT` with default `8787`
21
+ - Internal bootstrap-policy request guard port: `UCAN_STORE_PROXY_PORT` with default `8788`
22
+ - Local WebAuthn origin inputs: `WEBAUTHN_ORIGIN`, `WEBAUTHN_ORIGIN_FALLBACKS`
23
+ - Browser upload-service wiring:
24
+ - `VITE_UPLOAD_SERVICE_URL`
25
+ - `VITE_UPLOAD_SERVICE_DID`
26
+ - `VITE_REVOCATION_URL`
27
+ - `VITE_REVOCATION_DID`
28
+ - `VITE_RECEIPTS_URL`
29
+ - Optional direct Helia test wiring:
30
+ - `VITE_HELIA_PEER_ID`
31
+ - `VITE_HELIA_ADDRS`
32
+
33
+ What is not implemented yet in this shared profile:
34
+
35
+ - a fixed public Helia/libp2p listener contract for direct IPFS fetches
36
+ - service-specific Aleph VM workflow glue for full bootstrap-package collection in `relay-button`
37
+
38
+ Current bootstrap-package support in this shared profile:
39
+
40
+ - canonical JSON bootstrap package shape accepted by the shared node deploy runner
41
+ - guest-side storage of the bootstrap package on the VM
42
+ - guest-side structural validation for:
43
+ - `operatorAddress`
44
+ - `adminDid`
45
+ - optional `serviceDid`
46
+ - `spaceDid`
47
+ - `rootDelegationProof`
48
+ - `allowedCapabilities`
49
+ - delegation expiration policy
50
+ - `pwaOrigin`
51
+ - `serviceOrigin`
52
+ - runtime consistency checks that compare:
53
+ - bootstrap `serviceDid` against the running service DID when provided
54
+ - bootstrap `serviceOrigin` against the configured public upload-service origin
55
+ - `ucan-store.service` startup gating that:
56
+ - requires a bootstrap package by default
57
+ - re-validates the package before the service stays up
58
+ - probes the live `did:web` document and rejects startup on DID/origin mismatches
59
+ - stable service identity handling that:
60
+ - persists one Ed25519 signer on the VM under `/var/lib/ucan-store`
61
+ - reuses that signer across restarts instead of generating a fresh test key
62
+ - prefers an explicit `UCAN_STORE_SERVICE_DID` override when configured
63
+ - otherwise derives `did:web:<proxy-hostname>` once a public hostname is assigned
64
+ - otherwise falls back to the persisted signer `did:key`
65
+ - cryptographic bootstrap proof verification that checks:
66
+ - root delegation signature
67
+ - root delegation issuer against `adminDid`
68
+ - root delegation audience against the configured service DID
69
+ - delegation lifetime (`nbf` / `exp`)
70
+ - coverage of the configured `allowedCapabilities` for the configured `spaceDid`
71
+ - request-time public invocation narrowing that rejects:
72
+ - capabilities outside the configured `allowedCapabilities`
73
+ - requests for a different `spaceDid`
74
+ - invocation proof trees that do not include the configured bootstrap root delegation
75
+ - service-side user delegation issuance that:
76
+ - uses the persisted service signer as issuer
77
+ - chains back to the stored root bootstrap delegation
78
+ - exports a CAR-backed `m...` proof string the current UI can import directly
79
+ - enforces configured capability and expiration policy
80
+ - can be protected with `UCAN_STORE_ADMIN_API_TOKEN`
81
+ - public service discovery metadata that:
82
+ - is exposed at `/.well-known/ucan-store.json`
83
+ - has a `/service-manifest.json` alias
84
+ - surfaces service DID/origin, PWA origin, allowed capabilities, and
85
+ delegation issuance metadata for runtime binding
86
+
87
+ This still does not emit protocol-native UCAN error receipts from the guard
88
+ layer yet; rejected requests currently fail at HTTP level before they reach the
89
+ underlying upload API.
@@ -0,0 +1,46 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "ucan-store",
4
+ "displayName": "UCAN Store Upload Service",
5
+ "source": {
6
+ "repository": "external",
7
+ "subdirectory": "."
8
+ },
9
+ "rootfs": {
10
+ "profile": "ucan-store",
11
+ "installMode": "prebaked",
12
+ "installDir": "/opt/ucan-store",
13
+ "binaryPath": "/usr/local/bin/ucan-store",
14
+ "dataDir": "/var/lib/ucan-store",
15
+ "envFile": "/etc/default/ucan-store"
16
+ },
17
+ "services": {
18
+ "bootstrap": "ucan-store-bootstrap.service",
19
+ "main": "ucan-store.service",
20
+ "autotlsRefresh": "ucan-store-autotls-refresh.service"
21
+ },
22
+ "ports": [
23
+ {
24
+ "port": 22,
25
+ "tcp": true,
26
+ "udp": false,
27
+ "purpose": "SSH"
28
+ },
29
+ {
30
+ "port": 80,
31
+ "tcp": true,
32
+ "udp": false,
33
+ "purpose": "Temporary setup endpoint"
34
+ },
35
+ {
36
+ "port": 443,
37
+ "tcp": true,
38
+ "udp": false,
39
+ "purpose": "HTTPS upload API, did:web discovery, revocation, and receipt proxy"
40
+ }
41
+ ],
42
+ "manifest": {
43
+ "copyTarget": "web/public/rootfs/ucan-store/latest.json",
44
+ "notes": "The ucan-store image prebakes the current upload-wall local upload service into the Aleph guest, keeps the upstream HTTP worker on internal port 8787, exposes a temporary guest setup endpoint on port 80, runs a bootstrap-policy request guard on internal port 8788, persists one Ed25519 service signer under /var/lib/ucan-store, and proxies the public upload API, did:web document, revocation, receipt, service manifest, and optional admin delegation issuance routes through Caddy on port 443 once a proxy hostname is assigned. The admin issuance API can export CAR-backed `m...` delegation proofs for manual UI import when UCAN_STORE_ADMIN_API_TOKEN is configured, and the public `/.well-known/ucan-store.json` manifest exposes runtime binding metadata for a generic PWA. The browser consumer currently wires the service with VITE_UPLOAD_SERVICE_URL, VITE_UPLOAD_SERVICE_DID, VITE_REVOCATION_URL, VITE_REVOCATION_DID, and VITE_RECEIPTS_URL. Helia/IPFS peer exposure remains a later milestone and is not part of this base contract."
45
+ }
46
+ }
@@ -0,0 +1,24 @@
1
+ FROM --platform=linux/amd64 debian:12
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive
4
+ ENV LIBGUESTFS_BACKEND=direct
5
+
6
+ RUN apt-get update \
7
+ && apt-get install -y --no-install-recommends \
8
+ ca-certificates \
9
+ curl \
10
+ libguestfs-tools \
11
+ linux-image-amd64 \
12
+ python3 \
13
+ qemu-system-x86 \
14
+ qemu-utils \
15
+ tar \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ WORKDIR /workspace/shared-rootfs
19
+
20
+ COPY . .
21
+
22
+ RUN chmod 0755 ./build-rootfs.sh ./build-rootfs-image.sh ./read-rootfs-contract.py \
23
+ ./ucan-store-bootstrap.sh ./ucan-store-configure.sh ./ucan-store-bootstrap-verify.mjs ./ucan-store-request-guard.mjs ./ucan-store-describe.py ./ucan-store-service-start.sh ./ucan-store-setup-server.py \
24
+ ./ucan_store_bootstrap_validate.py ./ucan-store-upload-api-context.js ./ucan-store-upload-api-service-identity.js
@@ -0,0 +1,84 @@
1
+ # ucan-store Rootfs Runtime
2
+
3
+ This shared rootfs profile packages the current `ucan-upload-wall` local upload
4
+ service into an Aleph guest image.
5
+
6
+ Runtime shape:
7
+
8
+ - `80/tcp`: temporary setup endpoint used by deployment tooling before first start
9
+ - `443/tcp`: public HTTPS proxy for upload API, `did:web`, revocations, and receipts
10
+ - internal `127.0.0.1:8787`: upstream upload API worker from `local-storacha-api`
11
+ - internal `127.0.0.1:8788`: bootstrap-policy request guard in front of the upload API
12
+
13
+ Service identity shape:
14
+
15
+ - default signer algorithm: `Ed25519`
16
+ - signer persistence: `/var/lib/ucan-store/service-ed25519.key`
17
+ - explicit DID override: `UCAN_STORE_SERVICE_DID`
18
+ - hostname-derived DID fallback: `did:web:<proxy-hostname>`
19
+ - no-host fallback: persisted `did:key`
20
+
21
+ Delegation issuance shape:
22
+
23
+ - admin endpoint: `POST /admin/delegations`
24
+ - policy endpoint: `GET /admin/delegations/policy`
25
+ - auth: `Authorization: Bearer $UCAN_STORE_ADMIN_API_TOKEN`
26
+ - export format: `m...` multibase base64 UCAN CAR proof string
27
+ - chaining: child delegations include the persisted bootstrap root delegation as proof
28
+
29
+ Service discovery shape:
30
+
31
+ - public manifest: `GET /.well-known/ucan-store.json`
32
+ - alias: `GET /service-manifest.json`
33
+ - purpose: expose service DID/origin, PWA origin, allowed capabilities, and
34
+ delegation issuance metadata for a generic domain-bound PWA
35
+
36
+ The guest publishes metadata for the browser PWA after configuration, including:
37
+
38
+ - `VITE_UPLOAD_SERVICE_URL`
39
+ - `VITE_UPLOAD_SERVICE_DID`
40
+ - `VITE_REVOCATION_URL`
41
+ - `VITE_REVOCATION_DID`
42
+ - `VITE_RECEIPTS_URL`
43
+
44
+ Not included in this base milestone:
45
+
46
+ - public Helia/libp2p exposure
47
+ - Filecoin/archive publishing glue
48
+
49
+ Bootstrap package handling in the current guest scaffold:
50
+
51
+ - the setup endpoint now accepts a canonical `bootstrap_package` JSON object
52
+ - the package is persisted on the VM for the running upload service
53
+ - guest metadata includes a `bootstrap_validation` summary
54
+ - guest metadata now also includes `bootstrap_proof_validation`
55
+ - guest metadata now also includes `service_identity`
56
+ - guest metadata now also includes `delegation_issuance`
57
+ - guest metadata now also includes `service_manifest`
58
+ - invalid package shape or runtime mismatches fail metadata publication
59
+ - `ucan-store.service` now verifies the persisted package again at startup and
60
+ refuses to keep the upload service running when the package is missing
61
+ (by default), malformed, or inconsistent with the runtime DID/origin
62
+ - the upload service now reuses one persisted Ed25519 signer on the VM instead
63
+ of generating a fresh test identity on every boot
64
+ - the request guard can now mint long-lived service-to-user delegations when an
65
+ admin bearer token is configured, enforcing:
66
+ - `allowedCapabilities`
67
+ - `spaceDid`
68
+ - `defaultUserDelegationExpiration`
69
+ - `maxUserDelegationExpiration`
70
+ - the request guard now also publishes a public runtime manifest that a generic
71
+ PWA can fetch via the service domain without relying on build-time-only
72
+ `VITE_UPLOAD_SERVICE_*` values
73
+ - the guest now performs cryptographic verification of the bootstrap root
74
+ delegation with the installed `ucanto` / Storacha packages before the
75
+ service is allowed to stay up
76
+ - public requests now pass through a local request guard that only forwards
77
+ UCAN invocations when:
78
+ - the invocation capability `with` matches the configured `spaceDid`
79
+ - the invocation `can` stays inside the configured `allowedCapabilities`
80
+ - the invocation proof tree includes the configured bootstrap root delegation
81
+
82
+ The current guest still does not expose this issuance flow through
83
+ `relay-button` yet, and it still does not provide signed multi-tenant manifest
84
+ publication; those remain follow-up steps.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ PROJECT_DIR="${PROJECT_DIR:-$(cd "${SCRIPT_DIR}/../../../../.." && pwd)}"
6
+ ROOTFS_CONTRACT_FILE="${ROOTFS_CONTRACT_FILE:-${PROJECT_DIR}/root-profiles/ucan-store.json}"
7
+ OUT_DIR="${OUT_DIR:-${PROJECT_DIR}/dist-rootfs}"
8
+ BASE_URL="${BASE_URL:-https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2}"
9
+ BASE_IMAGE="${OUT_DIR}/debian-12-genericcloud-amd64.qcow2"
10
+ IMAGE="${OUT_DIR}/aleph-ucan-store.qcow2"
11
+ APP_TAR="${OUT_DIR}/ucan-store-runtime.tar"
12
+ ROOTFS_IMAGE_SIZE="${ROOTFS_IMAGE_SIZE:-20G}"
13
+ ROOTFS_SPARSIFY="${ROOTFS_SPARSIFY:-1}"
14
+ HOST_UID="${HOST_UID:-}"
15
+ HOST_GID="${HOST_GID:-}"
16
+
17
+ require() {
18
+ command -v "$1" >/dev/null 2>&1 || {
19
+ echo "Missing required command: $1" >&2
20
+ exit 1
21
+ }
22
+ }
23
+
24
+ require curl
25
+ require qemu-img
26
+ require virt-customize
27
+ require python3
28
+ require tar
29
+
30
+ eval "$(python3 "${SCRIPT_DIR}/read-rootfs-contract.py" "${ROOTFS_CONTRACT_FILE}")"
31
+
32
+ if [ "${ROOTFS_CONTRACT_PROFILE}" != "ucan-store" ]; then
33
+ echo "Only the ucan-store rootfs profile is supported, got: ${ROOTFS_CONTRACT_PROFILE}" >&2
34
+ exit 1
35
+ fi
36
+ if [ "${ROOTFS_CONTRACT_INSTALL_MODE}" != "prebaked" ]; then
37
+ echo "Only prebaked install mode is supported, got: ${ROOTFS_CONTRACT_INSTALL_MODE}" >&2
38
+ exit 1
39
+ fi
40
+ if [ ! -d "${PROJECT_DIR}/local-storacha-api" ]; then
41
+ echo "Missing local-storacha-api directory: ${PROJECT_DIR}/local-storacha-api" >&2
42
+ exit 1
43
+ fi
44
+ if [ ! -f "${PROJECT_DIR}/web/package.json" ]; then
45
+ echo "Missing web/package.json: ${PROJECT_DIR}/web/package.json" >&2
46
+ exit 1
47
+ fi
48
+ if [ ! -f "${PROJECT_DIR}/web/package-lock.json" ]; then
49
+ echo "Missing web/package-lock.json: ${PROJECT_DIR}/web/package-lock.json" >&2
50
+ echo "The current ucan-store rootfs scaffold expects the upload wall web app to use npm lockfiles." >&2
51
+ exit 1
52
+ fi
53
+
54
+ mkdir -p "${OUT_DIR}"
55
+
56
+ echo "Building ucan-store image in prebaked mode"
57
+
58
+ if [ ! -f "${BASE_IMAGE}" ]; then
59
+ echo "Downloading base image from ${BASE_URL}"
60
+ curl --fail --show-error --location \
61
+ --retry 5 \
62
+ --retry-all-errors \
63
+ --retry-delay 5 \
64
+ --connect-timeout 20 \
65
+ --max-time 300 \
66
+ "${BASE_URL}" -o "${BASE_IMAGE}"
67
+ fi
68
+
69
+ cp "${BASE_IMAGE}" "${IMAGE}"
70
+ qemu-img resize "${IMAGE}" "${ROOTFS_IMAGE_SIZE}"
71
+
72
+ tar \
73
+ --exclude ".git" \
74
+ -C "${PROJECT_DIR}" \
75
+ -cf "${APP_TAR}" \
76
+ local-storacha-api \
77
+ web/package.json \
78
+ web/package-lock.json \
79
+ README.md
80
+
81
+ virt-customize \
82
+ -a "${IMAGE}" \
83
+ --mkdir "${ROOTFS_CONTRACT_INSTALL_DIR}" \
84
+ --mkdir "${ROOTFS_CONTRACT_DATA_DIR}" \
85
+ --mkdir /usr/local/share/ucan-store \
86
+ --copy-in "${APP_TAR}:/opt" \
87
+ --copy-in "${SCRIPT_DIR}/ucan-store-bootstrap.sh:/usr/local/sbin" \
88
+ --copy-in "${SCRIPT_DIR}/ucan-store-configure.sh:/usr/local/sbin" \
89
+ --copy-in "${SCRIPT_DIR}/ucan-store-bootstrap-verify.mjs:/usr/local/sbin" \
90
+ --copy-in "${SCRIPT_DIR}/ucan-store-request-guard.mjs:/usr/local/sbin" \
91
+ --copy-in "${SCRIPT_DIR}/ucan-store-upload-api-context.js:/usr/local/share/ucan-store" \
92
+ --copy-in "${SCRIPT_DIR}/ucan-store-upload-api-service-identity.js:/usr/local/share/ucan-store" \
93
+ --copy-in "${SCRIPT_DIR}/ucan-store-describe.py:/usr/local/sbin" \
94
+ --copy-in "${SCRIPT_DIR}/ucan-store-setup-server.py:/usr/local/sbin" \
95
+ --copy-in "${SCRIPT_DIR}/ucan-store-service-start.sh:/usr/local/sbin" \
96
+ --copy-in "${SCRIPT_DIR}/ucan_store_bootstrap_validate.py:/usr/local/sbin" \
97
+ --copy-in "${SCRIPT_DIR}/ucan-store-bootstrap.service:/etc/systemd/system" \
98
+ --copy-in "${SCRIPT_DIR}/ucan-store-autotls-refresh.service:/etc/systemd/system" \
99
+ --copy-in "${SCRIPT_DIR}/ucan-store.service:/etc/systemd/system" \
100
+ --run-command "tar -xf /opt/$(basename "${APP_TAR}") -C ${ROOTFS_CONTRACT_INSTALL_DIR}" \
101
+ --run-command "chmod 0755 /usr/local/sbin/ucan-store-bootstrap.sh" \
102
+ --run-command "chmod 0755 /usr/local/sbin/ucan-store-configure.sh" \
103
+ --run-command "chmod 0755 /usr/local/sbin/ucan-store-bootstrap-verify.mjs" \
104
+ --run-command "chmod 0755 /usr/local/sbin/ucan-store-request-guard.mjs" \
105
+ --run-command "chmod 0755 /usr/local/sbin/ucan-store-describe.py" \
106
+ --run-command "chmod 0755 /usr/local/sbin/ucan-store-setup-server.py" \
107
+ --run-command "chmod 0755 /usr/local/sbin/ucan-store-service-start.sh" \
108
+ --run-command "chmod 0755 /usr/local/sbin/ucan_store_bootstrap_validate.py" \
109
+ --run-command "INSTALL_DIR=${ROOTFS_CONTRACT_INSTALL_DIR} DATA_DIR=${ROOTFS_CONTRACT_DATA_DIR} ENV_FILE=${ROOTFS_CONTRACT_ENV_FILE} SERVICE_USER=ucan-store APP_BINARY=${ROOTFS_CONTRACT_BINARY_PATH} /usr/local/sbin/ucan-store-bootstrap.sh all" \
110
+ --run-command "systemctl enable ${ROOTFS_CONTRACT_BOOTSTRAP_SERVICE}" \
111
+ --run-command "systemctl enable ${ROOTFS_CONTRACT_AUTOTLS_SERVICE}" \
112
+ --run-command "systemctl enable ${ROOTFS_CONTRACT_MAIN_SERVICE}" \
113
+ --run-command "rm -f /opt/$(basename "${APP_TAR}")"
114
+
115
+ if [ "${ROOTFS_SPARSIFY}" = "1" ] && command -v virt-sparsify >/dev/null 2>&1; then
116
+ SPARSE_IMAGE="${IMAGE%.qcow2}.sparse.qcow2"
117
+ rm -f "${SPARSE_IMAGE}"
118
+ echo "Sparsifying and compressing ${IMAGE}..."
119
+ virt-sparsify --compress "${IMAGE}" "${SPARSE_IMAGE}"
120
+ mv "${SPARSE_IMAGE}" "${IMAGE}"
121
+ fi
122
+
123
+ echo "Rootfs image ready at ${IMAGE}"
124
+
125
+ if [ -n "${HOST_UID}" ] && [ -n "${HOST_GID}" ]; then
126
+ chown -R "${HOST_UID}:${HOST_GID}" "${OUT_DIR}"
127
+ fi
@@ -0,0 +1,242 @@
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
+ PROJECT_DIR="${PROJECT_DIR:-$(cd "${SCRIPT_DIR}/../../../../.." && pwd)}"
7
+ OUT_DIR="${OUT_DIR:-${APP_DIR}/dist-rootfs}"
8
+ ROOTFS_CONTRACT_FILE="${ROOTFS_CONTRACT_FILE:-${PROJECT_DIR}/root-profiles/ucan-store.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
+ ROOTFS_CID=""
18
+ ROOTFS_ITEM_HASH=""
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
+ require python3
34
+ [ -f "${ROOTFS_CONTRACT_FILE}" ] || die "Rootfs contract does not exist: ${ROOTFS_CONTRACT_FILE}"
35
+ eval "$(python3 "${SCRIPT_DIR}/read-rootfs-contract.py" "${ROOTFS_CONTRACT_FILE}")"
36
+
37
+ if [ "${ROOTFS_CONTRACT_PROFILE}" != "ucan-store" ]; then
38
+ die "Only the ucan-store rootfs profile is supported, got: ${ROOTFS_CONTRACT_PROFILE}"
39
+ fi
40
+ if [ "${ROOTFS_CONTRACT_INSTALL_MODE}" != "prebaked" ]; then
41
+ die "Only prebaked install mode is supported, got: ${ROOTFS_CONTRACT_INSTALL_MODE}"
42
+ fi
43
+ }
44
+
45
+ resolve_rootfs_version() {
46
+ if [ -n "${ROOTFS_VERSION}" ]; then
47
+ printf '%s\n' "${ROOTFS_VERSION}"
48
+ return
49
+ fi
50
+
51
+ if [ -d "${PROJECT_DIR}/.git" ]; then
52
+ local short_sha
53
+ short_sha="$(git -C "${PROJECT_DIR}" rev-parse --short HEAD)"
54
+ local build_date
55
+ build_date="$(date -u +%Y%m%d)"
56
+ printf 'ucan-store-git-%s-%s\n' "${build_date}" "${short_sha}"
57
+ return
58
+ fi
59
+
60
+ printf 'ucan-store-v0.1.0\n'
61
+ }
62
+
63
+ build_with_host_tools() {
64
+ echo "Using host virt-customize/qemu-img toolchain."
65
+ ROOTFS_CONTRACT_FILE="${ROOTFS_CONTRACT_FILE}" \
66
+ OUT_DIR="${OUT_DIR}" \
67
+ ROOTFS_IMAGE_SIZE="${ROOTFS_IMAGE_SIZE}" \
68
+ PROJECT_DIR="${PROJECT_DIR}" \
69
+ bash "${SCRIPT_DIR}/build-rootfs-image.sh"
70
+ }
71
+
72
+ build_with_docker() {
73
+ require docker
74
+
75
+ if ! docker info >/dev/null 2>&1; then
76
+ die "Docker is installed, but the Docker daemon is not running."
77
+ fi
78
+
79
+ echo "Using Dockerized Debian/libguestfs builder."
80
+ docker build --platform linux/amd64 \
81
+ -t ucan-store-rootfs-builder:local \
82
+ -f "${SCRIPT_DIR}/Dockerfile.rootfs" \
83
+ "${SCRIPT_DIR}"
84
+
85
+ docker run --rm --privileged --platform linux/amd64 \
86
+ -e LIBGUESTFS_BACKEND=direct \
87
+ -e ROOTFS_CONTRACT_FILE=/workspace/shared-rootfs/input-rootfs-contract.json \
88
+ -e OUT_DIR=/workspace/ucan-store/dist-rootfs \
89
+ -e ROOTFS_IMAGE_SIZE="${ROOTFS_IMAGE_SIZE}" \
90
+ -e PROJECT_DIR=/workspace/ucan-store \
91
+ -v "${PROJECT_DIR}:/workspace/ucan-store" \
92
+ -v "${SCRIPT_DIR}:/workspace/shared-rootfs" \
93
+ -v "${ROOTFS_CONTRACT_FILE}:/workspace/shared-rootfs/input-rootfs-contract.json:ro" \
94
+ -w /workspace/shared-rootfs \
95
+ ucan-store-rootfs-builder:local \
96
+ /bin/bash /workspace/shared-rootfs/build-rootfs-image.sh
97
+ }
98
+
99
+ sync_manifest_copy_target() {
100
+ local manifest_path="${OUT_DIR}/rootfs-manifest.json"
101
+ local copy_target="${ROOTFS_CONTRACT_MANIFEST_COPY_TARGET:-}"
102
+ local resolved_target
103
+ local target_dir
104
+ local versioned_target
105
+
106
+ [ -n "${copy_target}" ] || return 0
107
+ [ -f "${manifest_path}" ] || die "Manifest does not exist: ${manifest_path}"
108
+
109
+ if [[ "${copy_target}" = /* ]]; then
110
+ resolved_target="${copy_target}"
111
+ else
112
+ resolved_target="${PROJECT_DIR}/${copy_target}"
113
+ fi
114
+
115
+ target_dir="$(dirname "${resolved_target}")"
116
+ mkdir -p "${target_dir}"
117
+ cp "${manifest_path}" "${resolved_target}"
118
+
119
+ versioned_target="${target_dir}/${ROOTFS_VERSION}.json"
120
+ cp "${manifest_path}" "${versioned_target}"
121
+
122
+ echo "Copied rootfs manifest to ${resolved_target}"
123
+ echo "Copied versioned rootfs manifest to ${versioned_target}"
124
+ }
125
+
126
+ write_manifest() {
127
+ local rootfs_cid="${1:-}"
128
+ local rootfs_item_hash="${2:-}"
129
+ local rootfs_source_size_bytes=""
130
+
131
+ if [ -f "${OUT_DIR}/ipfs-add-response.jsonl" ]; then
132
+ rootfs_source_size_bytes="$(python3 - "${OUT_DIR}/ipfs-add-response.jsonl" <<'PY'
133
+ import json
134
+ import sys
135
+ from pathlib import Path
136
+
137
+ lines = [line for line in Path(sys.argv[1]).read_text().splitlines() if line.strip()]
138
+ if not lines:
139
+ raise SystemExit(0)
140
+
141
+ payload = json.loads(lines[-1])
142
+ size = payload.get("Size")
143
+ if isinstance(size, str) and size.isdigit():
144
+ print(size)
145
+ elif isinstance(size, int) and size > 0:
146
+ print(size)
147
+ PY
148
+ )"
149
+ fi
150
+
151
+ {
152
+ echo '{'
153
+ echo ' "profile": "ucan-store",'
154
+ echo " \"version\": \"${ROOTFS_VERSION}\","
155
+ echo ' "rootfsInstallStrategy": "prebaked",'
156
+ echo ' "requiresBootstrapNetwork": false,'
157
+ echo ' "bootstrapSummary": "Node.js, Caddy, and the current local upload service runtime are prebaked into the image.",'
158
+ if [[ "${rootfs_source_size_bytes}" =~ ^[0-9]+$ ]]; then
159
+ echo " \"rootfsSourceSizeBytes\": ${rootfs_source_size_bytes},"
160
+ fi
161
+ printf ' "requiredPortForwards": %s,\n' "${ROOTFS_CONTRACT_PORT_FORWARDS_JSON}"
162
+ if [ -n "${rootfs_cid}" ]; then
163
+ echo " \"rootfsCid\": \"${rootfs_cid}\","
164
+ fi
165
+ if [ -n "${rootfs_item_hash}" ]; then
166
+ echo " \"rootfsItemHash\": \"${rootfs_item_hash}\","
167
+ fi
168
+ echo " \"rootfsSizeMiB\": ${ROOTFS_SIZE_MIB},"
169
+ echo " \"createdAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
170
+ printf ' "notes": "%s"\n' "${ROOTFS_CONTRACT_MANIFEST_NOTES}"
171
+ echo '}'
172
+ } > "${OUT_DIR}/rootfs-manifest.json"
173
+
174
+ echo "Rootfs manifest written to ${OUT_DIR}/rootfs-manifest.json"
175
+ sync_manifest_copy_target
176
+ }
177
+
178
+ upload_rootfs_to_ipfs() {
179
+ require curl
180
+ [ -f "${OUT_DIR}/aleph-ucan-store.qcow2" ] || die "Missing rootfs image: ${OUT_DIR}/aleph-ucan-store.qcow2"
181
+
182
+ curl --fail --show-error --silent \
183
+ -X POST \
184
+ -F "file=@${OUT_DIR}/aleph-ucan-store.qcow2" \
185
+ "${IPFS_ADD_URL}" | tee "${OUT_DIR}/ipfs-add-response.jsonl" >/dev/null
186
+
187
+ ROOTFS_CID="$(python3 - "${OUT_DIR}/ipfs-add-response.jsonl" <<'PY'
188
+ import json
189
+ import sys
190
+ from pathlib import Path
191
+
192
+ lines = [line for line in Path(sys.argv[1]).read_text().splitlines() if line.strip()]
193
+ if not lines:
194
+ raise SystemExit("No IPFS add response lines found")
195
+ payload = json.loads(lines[-1])
196
+ cid = payload.get("Hash")
197
+ if not isinstance(cid, str) or not cid.strip():
198
+ raise SystemExit("IPFS add response is missing Hash")
199
+ print(cid.strip())
200
+ PY
201
+ )"
202
+
203
+ echo "Uploaded rootfs to IPFS with CID ${ROOTFS_CID}"
204
+ }
205
+
206
+ publish_store_message() {
207
+ [ -n "${ROOTFS_CID}" ] || die "Cannot publish rootfs STORE message without ROOTFS_CID"
208
+ die "Aleph STORE message publication is not wired for the shared ucan-store rootfs yet. Re-run with SKIP_UPLOAD=1 until the publication workflow is implemented."
209
+ }
210
+
211
+ load_rootfs_contract
212
+ ROOTFS_VERSION="$(resolve_rootfs_version)"
213
+
214
+ mkdir -p "${OUT_DIR}"
215
+
216
+ if [ "${SKIP_BUILD}" != "1" ]; then
217
+ case "${ROOTFS_BUILD_DRIVER}" in
218
+ host)
219
+ build_with_host_tools
220
+ ;;
221
+ docker)
222
+ build_with_docker
223
+ ;;
224
+ auto)
225
+ if command -v virt-customize >/dev/null 2>&1 && command -v qemu-img >/dev/null 2>&1; then
226
+ build_with_host_tools
227
+ else
228
+ build_with_docker
229
+ fi
230
+ ;;
231
+ *)
232
+ die "Unsupported ROOTFS_BUILD_DRIVER=${ROOTFS_BUILD_DRIVER}"
233
+ ;;
234
+ esac
235
+ fi
236
+
237
+ if [ "${SKIP_UPLOAD}" != "1" ]; then
238
+ upload_rootfs_to_ipfs
239
+ publish_store_message
240
+ fi
241
+
242
+ write_manifest "${ROOTFS_CID}" "${ROOTFS_ITEM_HASH}"