@le-space/rootfs 0.1.4 → 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.
- package/index.js +17 -3
- package/package.json +1 -1
- package/reference/uc-go-peer/contract.json +58 -0
- package/reference/uc-go-peer/rootfs/Dockerfile.rootfs +24 -0
- package/reference/uc-go-peer/rootfs/build-rootfs-image.sh +94 -0
- package/reference/uc-go-peer/rootfs/build-rootfs.sh +489 -0
- package/reference/uc-go-peer/rootfs/read-rootfs-contract.py +72 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-autotls-refresh.py +144 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-autotls-refresh.service +23 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-bootstrap.service +17 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-bootstrap.sh +118 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-configure.sh +204 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-describe.py +195 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-setup-server.py +221 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer.service +19 -0
|
@@ -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()
|