@mriekki/project-forge 4.20.0 → 4.22.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.
@@ -8,6 +8,7 @@ EXPECTED_SPEC_SHA="${2:-}"
8
8
  DEPLOY_USER="${3:-}"
9
9
  HELD_LOCK_FD="${4:-}"
10
10
  APP_ROOT="__APP_ROOT__"
11
+ MATERIAL_ROOT="__MATERIAL_ROOT__"
11
12
  APP_SCHEMA_VERSION="__APP_SCHEMA_VERSION__"
12
13
  NETWORK_NAME="__NETWORK_NAME__"
13
14
  CADDY_CONTAINER="__CADDY_CONTAINER__"
@@ -26,6 +27,9 @@ D_DATA_TARGET_B64="__D_DATA_TARGET_B64__"
26
27
  D_SPEC_SHA="__D_SPEC_SHA__"
27
28
  D_ENV_KEYS=(__D_ENV_KEYS__)
28
29
  D_ENV_VALUES=(__D_ENV_VALUES__)
30
+ D_MATERIAL_NAMES=(__D_MATERIAL_NAMES__)
31
+ D_MATERIAL_TARGETS=(__D_MATERIAL_TARGETS__)
32
+ D_MATERIAL_EXPECTED=(__D_MATERIAL_EXPECTED__)
29
33
 
30
34
  b64d() { printf '%s' "$1" | base64 --decode; }
31
35
  meta_value() {
@@ -53,6 +57,92 @@ network_has_container() {
53
57
  grep -Fxq "$container"
54
58
  }
55
59
 
60
+
61
+ material_source() {
62
+ printf '%s/%s/%s/%s' "$MATERIAL_ROOT" "$D_APP" "$D_ENV" "$1"
63
+ }
64
+ material_identity() {
65
+ local source="$1" run_as="$2" uid gid dir owner mode hazards
66
+ uid="${run_as%%:*}"
67
+ gid="$uid"
68
+ [[ "$run_as" != *:* ]] || gid="${run_as#*:}"
69
+ command -v setpriv >/dev/null 2>&1 || {
70
+ echo "setpriv is required to verify read-only material access for runAs." >&2; return 1;
71
+ }
72
+ for dir in /srv "$MATERIAL_ROOT" "$MATERIAL_ROOT/$D_APP" "$MATERIAL_ROOT/$D_APP/$D_ENV" "$source"; do
73
+ [[ -d "$dir" && ! -L "$dir" ]] || {
74
+ echo "Read-only material directory missing or linked: $dir" >&2; return 1;
75
+ }
76
+ owner="$(stat -c '%u' "$dir")"
77
+ mode="$(stat -c '%a' "$dir")"
78
+ [[ "$owner" == 0 ]] && (( (8#$mode & 022) == 0 )) || {
79
+ echo "Read-only material directory ownership or mode is unsafe: $dir" >&2; return 1;
80
+ }
81
+ done
82
+ hazards="$(find -P "$source" -mindepth 1 \( -type l -o ! -uid 0 -o -perm /022 -o \( ! -type f -a ! -type d \) \) -print -quit)" || return 1
83
+ [[ -z "$hazards" ]] || {
84
+ echo "Read-only material tree has linked, writable, unowned or special entries." >&2; return 1;
85
+ }
86
+ setpriv --reuid "$uid" --regid "$gid" --clear-groups -- bash -c '
87
+ path="$1"
88
+ [[ -r "$path" && -x "$path" ]] || exit 1
89
+ invalid="$(find -P "$path" \( -type d \( ! -readable -o ! -executable \) -o -type f ! -readable \) -print -quit)" || exit 1
90
+ [[ -z "$invalid" ]]
91
+ ' -- "$source" || {
92
+ echo "Read-only material tree is not readable/traversable by runAs." >&2; return 1;
93
+ }
94
+ stat -c '%d:%i' "$source"
95
+ }
96
+ validate_material_entries() {
97
+ [[ "${#D_MATERIAL_NAMES[@]}" -eq "${#D_MATERIAL_TARGETS[@]}" &&
98
+ "${#D_MATERIAL_NAMES[@]}" -eq "${#D_MATERIAL_EXPECTED[@]}" ]] || {
99
+ echo "Read-only material argument count mismatch." >&2; exit 33;
100
+ }
101
+ local index name target part prior data
102
+ local -a segments targets=()
103
+ data="$D_DATA_TARGET"
104
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
105
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
106
+ target="$(b64d "${D_MATERIAL_TARGETS[$index]}")"
107
+ valid_slug "$name" || { echo "Invalid read-only material name." >&2; exit 33; }
108
+ [[ "$target" == /* && "$target" != / && "$target" =~ ^/[A-Za-z0-9._~+:/-]+$ ]] || {
109
+ echo "Invalid read-only material target." >&2; exit 33;
110
+ }
111
+ IFS='/' read -r -a segments <<< "${target#/}"
112
+ for part in "${segments[@]}"; do
113
+ [[ -n "$part" && "$part" != . && "$part" != .. ]] || {
114
+ echo "Read-only material target is not normalized." >&2; exit 33;
115
+ }
116
+ done
117
+ case "$target/" in
118
+ /proc/*|/sys/*|/dev/*|/run/*|/var/run/*)
119
+ echo "Read-only material target overlaps a system path." >&2; exit 33;;
120
+ esac
121
+ for prior in "${targets[@]}" "$data"; do
122
+ [[ -z "$prior" ]] && continue
123
+ if [[ "$target" == "$prior" || "$target" == "$prior/"* || "$prior" == "$target/"* ]]; then
124
+ echo "Read-only material target overlaps another mount." >&2; exit 33
125
+ fi
126
+ done
127
+ targets+=("$target")
128
+ done
129
+ }
130
+ material_probe() {
131
+ local check_expected="$1" index name source identity expected
132
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
133
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
134
+ source="$(material_source "$name")"
135
+ identity="$(material_identity "$source" "$D_RUN_AS")" || return 1
136
+ expected="${D_MATERIAL_EXPECTED[$index]}"
137
+ if [[ "$check_expected" == true && "$identity" != "$expected" ]]; then
138
+ echo "Read-only material directory changed after planning: $name" >&2; return 1
139
+ fi
140
+ if [[ "$check_expected" != true ]]; then
141
+ printf 'MATERIAL\t%s\t%s\n' "$name" "$identity"
142
+ fi
143
+ done
144
+ }
145
+
56
146
  probe_http() {
57
147
  local container="$1" port="$2" path="$3"
58
148
  docker exec "$CADDY_CONTAINER" \
@@ -75,6 +165,47 @@ wait_ready() {
75
165
  return 1
76
166
  }
77
167
 
168
+
169
+ material_inventory_state() {
170
+ local file="$1" app="$2" env="$3" container="$4" persistence="$5"
171
+ local number index name target source recorded current raw mounts run_as data_target line
172
+ number="$(meta_value "$file" MATERIAL_COUNT || true)"
173
+ MATERIAL_OK=true
174
+ MATERIAL_ENTRIES=""
175
+ [[ -n "$number" ]] || return 0
176
+ [[ "$number" =~ ^[1-8]$ ]] || { echo "Invalid saved material count." >&2; return 1; }
177
+ D_APP="$app"
178
+ D_ENV="$env"
179
+ run_as="$(meta_value "$file" RUN_AS || true)"
180
+ valid_run_as "$run_as" || { echo "Invalid saved material runAs." >&2; return 1; }
181
+ mounts="$(docker inspect --format '{{range .Mounts}}{{printf "%s@%s@%t@%s\n" .Source .Destination .RW .Type}}{{end}}' "$container" 2>/dev/null)" || {
182
+ MATERIAL_OK=false
183
+ mounts=""
184
+ }
185
+ local expected_count="$number"
186
+ if [[ "$persistence" == directory ]]; then
187
+ data_target="$(b64d "$(meta_value "$file" DATA_TARGET_B64 || true)")"
188
+ line="$APP_ROOT/$app/$env/data@$data_target@true@bind"
189
+ grep -Fxq -- "$line" <<< "$mounts" || MATERIAL_OK=false
190
+ expected_count="$((number + 1))"
191
+ fi
192
+ [[ "$(grep -c '.' <<< "$mounts" || true)" == "$expected_count" ]] || MATERIAL_OK=false
193
+ for ((index=0; index < number; index++)); do
194
+ name="$(b64d "$(meta_value "$file" MATERIAL_NAME_B64_"$index" || true)")"
195
+ target="$(b64d "$(meta_value "$file" MATERIAL_TARGET_B64_"$index" || true)")"
196
+ recorded="$(meta_value "$file" MATERIAL_ID_"$index" || true)"
197
+ valid_slug "$name" && [[ "$target" =~ ^/[A-Za-z0-9._~+:/-]+$ && "$recorded" =~ ^[0-9]+:[0-9]+$ ]] || {
198
+ echo "Invalid saved material mount metadata." >&2; return 1;
199
+ }
200
+ source="$(material_source "$name")"
201
+ current="$(material_identity "$source" "$run_as" 2>/dev/null)" || { MATERIAL_OK=false; current=""; }
202
+ [[ "$current" == "$recorded" ]] || MATERIAL_OK=false
203
+ grep -Fxq -- "$source@$target@false@bind" <<< "$mounts" || MATERIAL_OK=false
204
+ [[ -z "$MATERIAL_ENTRIES" ]] || MATERIAL_ENTRIES+=";"
205
+ MATERIAL_ENTRIES+="$name@$target@$recorded"
206
+ done
207
+ }
208
+
78
209
  inventory() {
79
210
  local count=0 state current previous release_file
80
211
  local owner env app container image spec_sha port health persistence
@@ -114,9 +245,16 @@ inventory() {
114
245
  if [[ "$running" == true && "$connected" == true ]]; then
115
246
  probe_http "$container" "$port" "$health" && healthy=true || true
116
247
  fi
117
- printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
118
- "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
119
- "$current" "$previous" "$running" "$connected" "$healthy"
248
+ material_inventory_state "$release_file" "$app" "$env" "$container" "$persistence" || exit 31
249
+ if [[ -n "$MATERIAL_ENTRIES" ]]; then
250
+ printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
251
+ "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
252
+ "$current" "$previous" "$running" "$connected" "$healthy" "$MATERIAL_OK" "$MATERIAL_ENTRIES"
253
+ else
254
+ printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
255
+ "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
256
+ "$current" "$previous" "$running" "$connected" "$healthy"
257
+ fi
120
258
  count=$((count + 1))
121
259
  done < <(find "$APP_ROOT" -mindepth 3 -maxdepth 3 -type f -name deployment.env -print 2>/dev/null | sort)
122
260
  fi
@@ -173,6 +311,7 @@ decode_desired() {
173
311
  exit 33
174
312
  }
175
313
  valid_run_as "$D_RUN_AS" || { echo "Invalid numeric runAs value." >&2; exit 33; }
314
+ validate_material_entries
176
315
  [[ "$D_PERSISTENCE" == "none" || "$D_PERSISTENCE" == "directory" ]] || {
177
316
  echo "Invalid persistence mode." >&2
178
317
  exit 33
@@ -216,6 +355,14 @@ write_release_metadata() {
216
355
  printf 'PERSISTENCE_MODE=%s\n' "$D_PERSISTENCE"
217
356
  printf 'DATA_TARGET_B64=%s\n' "$D_DATA_TARGET_B64"
218
357
  printf 'SPEC_SHA256=%s\n' "$D_SPEC_SHA"
358
+ if (( ${#D_MATERIAL_NAMES[@]} > 0 )); then
359
+ printf 'MATERIAL_COUNT=%s\n' "${#D_MATERIAL_NAMES[@]}"
360
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
361
+ printf 'MATERIAL_NAME_B64_%s=%s\n' "$index" "${D_MATERIAL_NAMES[$index]}"
362
+ printf 'MATERIAL_TARGET_B64_%s=%s\n' "$index" "${D_MATERIAL_TARGETS[$index]}"
363
+ printf 'MATERIAL_ID_%s=%s\n' "$index" "${D_MATERIAL_EXPECTED[$index]}"
364
+ done
365
+ fi
219
366
  printf 'ENV_COUNT=%s\n' "${#D_ENV_KEYS[@]}"
220
367
  for index in "${!D_ENV_KEYS[@]}"; do
221
368
  key_b64="${D_ENV_KEYS[$index]}"
@@ -271,6 +418,13 @@ start_desired_container() {
271
418
  value="$(b64d "${D_ENV_VALUES[$index]}")"
272
419
  docker_args+=(--env "$key=$value")
273
420
  done
421
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
422
+ local source target name
423
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
424
+ target="$(b64d "${D_MATERIAL_TARGETS[$index]}")"
425
+ source="$(material_source "$name")"
426
+ docker_args+=(--mount "type=bind,src=$source,dst=$target,readonly")
427
+ done
274
428
  docker_args+=("$D_IMAGE")
275
429
  "${docker_args[@]}" >/dev/null
276
430
  }
@@ -339,6 +493,7 @@ deploy() {
339
493
  release_file="$release_dir/release.env"
340
494
 
341
495
  acquire_deployment_lock "$lock_file"
496
+ material_probe true || { echo "Read-only material preflight failed." >&2; exit 40; }
342
497
 
343
498
  for path in "$root" "$releases" "$state" "$root/data" "$root/deployment-evidence.jsonl"; do
344
499
  [[ ! -L "$path" ]] || { echo "Managed application path must not be a symbolic link: $path" >&2; exit 35; }
@@ -397,11 +552,22 @@ deploy() {
397
552
  echo "Release directory collision with different runtime metadata." >&2
398
553
  exit 35
399
554
  }
555
+ if (( ${#D_MATERIAL_NAMES[@]} > 0 )); then
556
+ [[ "$(release_value "$release_file" MATERIAL_COUNT || true)" == "${#D_MATERIAL_NAMES[@]}" ]] || {
557
+ echo "Existing release material metadata mismatch." >&2; exit 35;
558
+ }
559
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
560
+ [[ "$(release_value "$release_file" MATERIAL_ID_"$index" || true)" == "${D_MATERIAL_EXPECTED[$index]}" ]] || {
561
+ echo "Existing release material directory identity changed." >&2; exit 35;
562
+ }
563
+ done
564
+ fi
400
565
  else
401
566
  write_release_metadata "$release_file"
402
567
  fi
403
568
 
404
569
  docker pull "$D_IMAGE" >/dev/null
570
+ material_probe true || { echo "Read-only material source changed before runtime replacement." >&2; exit 40; }
405
571
 
406
572
  if [[ -n "$current_container" ]]; then
407
573
  docker rm -f "$current_container" >/dev/null
@@ -442,6 +608,10 @@ case "$ACTION" in
442
608
  deploy)
443
609
  deploy
444
610
  ;;
611
+ material-inspect)
612
+ decode_desired
613
+ material_probe false || exit 40
614
+ ;;
445
615
  *)
446
616
  echo "Unsupported application-runtime action: $ACTION" >&2
447
617
  exit 2