ignore ephemeral Docker network identity in Stage 3

This commit is contained in:
makearmy 2026-07-14 00:14:35 -04:00
parent 885dff127e
commit 1f5caf8761
5 changed files with 138 additions and 19 deletions

View file

@ -1,11 +1,11 @@
1e281c255a9a7ba72734c40a947cab4168f70b7e6b733eba7415109a40776a9d README.md
833809d6ce01da516fda0873d80cdbddcf6900c6aaf00be2a24201e8887c76f1 README.md
908ab54049c1e810ba5c47812fc51848e9eef8818a9ddc373f841672ab509a57 ROLLBACK.md
136707ac1391bd241efc834c549c0f54d4a849ce832c55457bdeb243cc03800a tests/50-activate-stage3.sh
5c8eceb9b80865ae09bd4ece2a248da4677237abcc7c55d26334020543e8edf8 tests/51-rollback-stage3.sh
9aa43f4b72057ea28ec00309889f75e756a9b36db77e5db758b752990768c0a0 tests/52-run-stage1-inert.sh
de70e85a4e4e6015726dfe3cbad2bdccb2990daae25eeeab272e107d2d68a115 tests/53-stage3-static-tests.sh
ec595556f2842329a8b1b6e88bc291ef8f4d0d98e675baeb4b317da6ae1c8e96 tests/54-stage3-failure-path-tests.sh
9e8ebfc21c026f7bfde07a9c4f7c190155060e52c2a792e654891a34855de84f tests/53-stage3-static-tests.sh
575b8ab40811daf66ba6602ff2b623acbcfc20e67b909c4f4d61f37aa3fc1790 tests/54-stage3-failure-path-tests.sh
ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356 tests/docker-config/config.json
1c64518e42afdf490047358c523a8213e989a84a61f51ff0126439db2b485fe3 tests/inert-bin/rm
034b23c09a023ce94d8b7888f0d6f45aed31ba14dbed2efd30c4b0a6322f657b tests/inert-bin/touch
3a3a84df1021e02cc258edbee621ab8abfb9f83bdc17dfa689fb9823b17c8cfd tests/stage3-common.sh
95a5241890b841f5838dc37b364f27d8c00eb0324e718a615946d14d946bbaba tests/stage3-common.sh

View file

@ -19,6 +19,8 @@ Before any mutation, activation requires:
- fresh immediate fingerprints of nonproject nftables, stable nonproject host network state, rootful Docker networks/runtime/ports, host listeners, resolver metadata, and public IPv4; and
- no marker, namespace, project veth/table, user manager, linger, UID-1200 process, or rootless socket.
The 41-network check uses one deterministic, name-sorted policy canonical for committed preflight, fresh-baseline capture, activation provenance, automatic recovery, and explicit rollback. It requires the exact unique name set and count; driver, scope, `Internal`, `Attachable`, `Ingress`, `EnableIPv6`, and `ConfigOnly`; normalized IPAM driver/options and every subnet, gateway, IP range, and auxiliary address; and network driver options that affect routing or isolation. Docker-generated network IDs, creation timestamps, container/endpoint attachments, peers, status, and other runtime bookkeeping are excluded. The separate denial gate still requires the exact committed set of 39 unique IPv4 Docker gateways.
Immediately before publication and start, the script revalidates the loaded manager state: `systemd-analyze verify`; exact `systemctl cat` fragment/drop-in surfaces for both units; exact namespace fragment path with no drop-ins; current manager cache; no pending job; exact single `ExecStart`/`ExecStop`; oneshot/`RemainAfterExit`; static inactive state; the approval-marker condition; `Before=user@1200.service`; and the reciprocal user-manager `Requires`/`After`, namespace path, fragment, and exact two-drop-in set.
The script renders and fsyncs a root-owned mode `0600`, single-link version-2 record before atomically renaming it into the approval-marker path and syncing the parent directory. No hard-link publication window exists, and an interrupted write cannot replace the prior authoritative record. Parsing requires the exact canonical one-delimiter, final-newline-complete byte representation, so trailing fields, truncated final writes, and embedded NUL bytes are rejected. The immutable fields include an activation UUID, machine-ID digest, approved Quad9 pair, Stage 1 source and artifact digest, Stage 2 verification commit, Stage 3 activation commit, UTC timestamp, hostname, boot ID, public IPv4, installed-policy digest, and baseline fingerprints. It starts `le-app-codex-netns.service`, then atomically replaces `PREPARED` with an `ACTIVE` record bound to the service invocation, namespace inode, host-veth ifindex/iflink/MAC, both nft table handles, independent counter-normalized table digests, and the combined project-policy digest.

View file

@ -76,6 +76,7 @@ if /usr/bin/grep -Eq '/usr/bin/nft[[:space:]]+--numeric|/usr/bin/nft[[:space:]]+
[ "$(/usr/bin/grep -Fc 'stage3_nft_json_inet_rules_match "$raw"' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_nft_json_nat_rules_match "$raw"' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_project_inet_snapshot_valid "$raw" "$text"' "$rollback")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_project_nat_snapshot_valid "$raw" "$text"' "$rollback")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_capture_exact_project_policy_snapshot' "$activate")" -eq 4 ] && ! /usr/bin/grep -q 'stage3_nft_text_chain' "$activate" "$rollback" "$common" && pass 'activation and rollback share exact canonical JSON policy validation' || fail 'nft JSON validation surface'
[ "$(/usr/bin/grep -Fc '/usr/bin/nft delete table inet le_app_codex' "$rollback")" -eq 1 ] && [ "$(/usr/bin/grep -Fc '/usr/bin/nft delete table ip le_app_codex_nat' "$rollback")" -eq 1 ] && [ "$(/usr/bin/grep -Fc '/usr/bin/ip link delete lecodex-host' "$rollback")" -eq 1 ] && [ "$(/usr/bin/grep -Fc '/usr/bin/ip netns delete le-app-codex' "$rollback")" -eq 1 ] && pass 'rollback direct cleanup limited to exact project names' || fail 'rollback direct cleanup literals'
if [ "$(/usr/bin/grep -Fc '/usr/bin/docker --config "$stage3_root/tests/docker-config" --host unix:///run/docker.sock "$@"' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc '/usr/bin/docker' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_rootful_docker ' "$common")" -eq 4 ] && [ "$(/usr/bin/grep -Fc 'stage3_rootful_docker network ls --no-trunc -q' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_rootful_docker network inspect --format' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_rootful_docker ps --no-trunc -aq' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_rootful_docker inspect --format' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc -- '-u DOCKER_API_VERSION -u DOCKER_CERT_PATH -u DOCKER_CONTEXT -u DOCKER_HOST -u DOCKER_TLS -u DOCKER_TLS_VERIFY' "$common")" -eq 1 ] && /usr/bin/jq -e 'type == "object" and length == 0' "$stage3_root/tests/docker-config/config.json" >/dev/null; then pass 'all rootful Docker reads use one context-scrubbed explicit-socket read allowlist and empty config'; else fail 'rootful Docker helper/socket/config surface'; fi
[ "$(/usr/bin/grep -Fc 'stage3_docker_network_policy_canonical' "$common")" -eq 3 ] && [ "$(/usr/bin/grep -Fc '}) | sort_by(.Name)' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_docker_network_policy_canonical < "$stage1_root/inventory/docker-networks.json"' "$common")" -eq 1 ] && [ "$(/usr/bin/grep -Fc "case_docker_network_policy_canonical" "$failure_suite")" -eq 2 ] && pass 'one name-sorted Docker policy canonical feeds live, committed, baseline, and rollback comparisons' || fail 'Docker network policy canonical surface'
[ "$(/usr/bin/grep -Fc '/usr/bin/systemd-run --service-type=exec --wait --pipe --collect --unit="$inert_unit"' "$runner")" -eq 1 ] && [ "$(/usr/bin/grep -Fc '/srv/le-app-codex/phase2b-tests/10-inert-containment.sh' "$runner")" -eq 1 ] && [ "$(/usr/bin/grep -Fc -- '--property=RuntimeMaxSec=120s' "$runner")" -eq 1 ] && pass 'finite collected wrapper executes existing installed inert test' || fail 'inert test wrapper command surface'
[ "$(/usr/bin/grep -Fc -- "--property='UnsetEnvironment=ALL_PROXY HTTPS_PROXY HTTP_PROXY NO_PROXY all_proxy https_proxy http_proxy no_proxy'" "$runner")" -eq 1 ] && ! /usr/bin/grep -Eq -- '--setenv=(ALL_PROXY|HTTPS_PROXY|HTTP_PROXY|NO_PROXY|all_proxy|https_proxy|http_proxy|no_proxy)' "$runner" && pass 'transient inert test removes every proxy variable' || fail 'transient inert proxy environment'
[ "$(/usr/bin/grep -Fc -- '--setenv="PHASE2B_STAGE3_INERT_TOKEN=$inert_token"' "$runner")" -eq 1 ] && [ "$(/usr/bin/grep -Fc '[ "$inert_owned" -eq 1 ] && [[ "$inert_invocation" =~ ^[0-9a-f]{32}$ ]]' "$runner")" -eq 1 ] && [ "$(/usr/bin/grep -Fc 'stage3_inert_unit_identity "$show" "$inert_unit" "${inert_invocation:-}" "$inert_activation" "$inert_token"' "$runner")" -eq 1 ] && pass 'transient success and cleanup require token-bound captured ownership' || fail 'transient ownership/token gate'
@ -112,7 +113,14 @@ if /usr/bin/awk '
failures_before=$failures
source "$common"
committed_networks=$(stage3_committed_docker_network_canonical)
printf '%s' "$committed_networks" | /usr/bin/jq -e 'length == 41' >/dev/null && pass 'committed Docker canonicalizer yields 41 networks' || fail 'committed Docker canonicalizer'
printf '%s' "$committed_networks" | /usr/bin/jq -e '
length == 41 and ([.[].Name] | unique | length) == 41 and
all(.[];
keys == ["Attachable","ConfigOnly","Driver","EnableIPv6","IPAM","Ingress","Internal","Name","Options","Scope"] and
(.Options | type) == "object" and
(.IPAM | keys) == ["Config","Driver","Options"] and
(.IPAM.Options | type) == "object" and (.IPAM.Config | type) == "array" and
all(.IPAM.Config[]; keys == ["AuxiliaryAddresses","Gateway","IPRange","Subnet"] and (.AuxiliaryAddresses | type) == "object"))' >/dev/null && pass 'committed Docker policy canonical yields exact 41-name inventory' || fail 'committed Docker policy canonical'
[ "$failures" -eq "$failures_before" ] || fail 'common library source has side effects'
/bin/bash "$stage1_root/tests/32-stage1-state-tests.sh" >/dev/null && pass 'existing non-mutating Stage 1 state tests' || fail 'Stage 1 state tests'

View file

@ -418,7 +418,7 @@ case_malformed_and_escaping_records() {
}
case_host_and_repository_provenance_mismatch() {
local root=$fixture/provenance good bad head parent original_stage3_root
local root=$fixture/provenance good bad head parent
root=$fixture/provenance; good=$root/good; bad=$root/bad
/usr/bin/mkdir "$root"
head=$(/usr/bin/git -C "$repo_root" rev-parse HEAD) || return 1
@ -442,13 +442,10 @@ case_host_and_repository_provenance_mismatch() {
! stage3_parse_record_file "$bad" 1 || return 1
/usr/bin/sed "s/^STAGE3_ACTIVATION_COMMIT|.*/STAGE3_ACTIVATION_COMMIT|$(printf '0%.0s' {1..40})/" "$good" > "$bad"
! stage3_parse_record_file "$bad" 1 || return 1
original_stage3_root=$stage3_root
stage3_root=$stage2_root
stage3_head=$parent
stage3_render_record PREPARED > "$bad"; /usr/bin/chmod 0600 "$bad"
stage3_head=$head
! stage3_parse_record_file "$bad" 1 || return 1
stage3_root=$original_stage3_root
}
case_lock_mismatch_and_concurrency() {
@ -563,6 +560,71 @@ case_docker_gateway_inventory() {
! stage3_materialize_docker_ipv4_gateways '[]' && [ "${#stage3_docker_gateways[@]}" -eq 0 ]
}
case_docker_network_policy_canonical() {
local raw baseline changed alternate canonical alternate_canonical filter
local old_id=4ecbee63f4b0f705479ec65b74b57c618b61d76da91bc5c042717460709dadb8
local new_id=575a3269b7cf00a0e79b3e3f1e2a7c5b8159b125792704b2f6044a38658493d4
local -a policy_mutations=(
'map(if .Name == "immich_s6_net" then .Name = "immich_s6_net_changed" else . end)'
'map(select(.Name != "immich_s6_net"))'
'. + [(.[] | select(.Name == "immich_s6_net") | .Name = "immich_s6_net_added")]'
'map(if .Name == "immich_s6_net" then .Driver = "overlay" else . end)'
'map(if .Name == "immich_s6_net" then .Scope = "swarm" else . end)'
'map(if .Name == "immich_s6_net" then .Internal = (.Internal | not) else . end)'
'map(if .Name == "immich_s6_net" then .Attachable = (.Attachable | not) else . end)'
'map(if .Name == "immich_s6_net" then .Ingress = (.Ingress | not) else . end)'
'map(if .Name == "immich_s6_net" then .EnableIPv6 = (.EnableIPv6 | not) else . end)'
'map(if .Name == "immich_s6_net" then .ConfigOnly = (.ConfigOnly | not) else . end)'
'map(if .Name == "immich_s6_net" then .IPAM.Driver = "changed" else . end)'
'map(if .Name == "immich_s6_net" then .IPAM.Options = {"fixture":"changed"} else . end)'
'map(if .Name == "immich_s6_net" then .IPAM.Config[0].Subnet = "10.254.0.0/24" else . end)'
'map(if .Name == "immich_s6_net" then .IPAM.Config[0].Gateway = "10.254.0.1" else . end)'
'map(if .Name == "immich_s6_net" then .IPAM.Config[0].IPRange = "10.18.128.0/17" else . end)'
'map(if .Name == "immich_s6_net" then .IPAM.Config[0].AuxiliaryAddresses = {"fixture":"10.18.0.254"} else . end)'
'map(if .Name == "immich_s6_net" then .Options = {"com.docker.network.bridge.enable_icc":"false"} else . end)'
)
raw=$(/usr/bin/cat "$stage1_root/inventory/docker-networks.json") || return 1
[ "$(printf '%s' "$raw" | /usr/bin/jq -r '.[] | select(.Name == "immich_s6_net") | .Id')" = "$old_id" ] || return 1
baseline=$(printf '%s' "$raw" | stage3_docker_network_policy_canonical) || return 1
printf '%s' "$baseline" | /usr/bin/jq -e 'length == 41 and ([.[].Name] | unique | length) == 41' >/dev/null || return 1
changed=$(printf '%s' "$raw" | /usr/bin/jq -c --arg id "$new_id" 'map(if .Name == "immich_s6_net" then .Id = $id else . end)') || return 1
canonical=$(printf '%s' "$changed" | stage3_docker_network_policy_canonical) || return 1
[ "$canonical" = "$baseline" ] || return 1
changed=$(printf '%s' "$raw" | /usr/bin/jq -c '
map(if .Name == "immich_s6_net" then
.Created = "2099-01-01T00:00:00Z" |
.Containers = {"runtime-container":{"Name":"runtime","EndpointID":"runtime-endpoint"}} |
.Peers = [{"Name":"runtime-peer"}] |
.Status = {"runtime":"changed"}
else . end)') || return 1
canonical=$(printf '%s' "$changed" | stage3_docker_network_policy_canonical) || return 1
[ "$canonical" = "$baseline" ] || return 1
changed=$(printf '%s' "$raw" | /usr/bin/jq -c 'reverse | map(if .Name == "immich_s6_net" then .IPAM.Config |= reverse else . end)') || return 1
canonical=$(printf '%s' "$changed" | stage3_docker_network_policy_canonical) || return 1
[ "$canonical" = "$baseline" ] || return 1
changed=$(printf '%s' "$raw" | /usr/bin/jq -c 'map(if .Name == "immich_s6_net" then .IPAM.Config[0].AuxiliaryAddresses = {"z":"10.18.0.253","a":"10.18.0.254"} else . end)') || return 1
alternate=$(printf '%s' "$raw" | /usr/bin/jq -c 'map(if .Name == "immich_s6_net" then .IPAM.Config[0].AuxiliaryAddresses = {"a":"10.18.0.254","z":"10.18.0.253"} else . end)') || return 1
canonical=$(printf '%s' "$changed" | stage3_docker_network_policy_canonical) || return 1
alternate_canonical=$(printf '%s' "$alternate" | stage3_docker_network_policy_canonical) || return 1
[ "$canonical" = "$alternate_canonical" ] || return 1
for filter in "${policy_mutations[@]}"; do
changed=$(printf '%s' "$raw" | /usr/bin/jq -c "$filter") || return 1
canonical=$(printf '%s' "$changed" | stage3_docker_network_policy_canonical) || return 1
[ "$canonical" != "$baseline" ] || return 1
done
changed=$(printf '%s' "$raw" | /usr/bin/jq -c '.[1].Name = .[0].Name') || return 1
! printf '%s' "$changed" | stage3_docker_network_policy_canonical >/dev/null 2>&1 || return 1
changed=$(printf '%s' "$raw" | /usr/bin/jq -c 'del(.[0].EnableIPv6)') || return 1
! printf '%s' "$changed" | stage3_docker_network_policy_canonical >/dev/null 2>&1
}
case_veth_drift() {
stage3_veth_fields_match 10 11 aa:bb:cc:dd:ee:ff 10 11 aa:bb:cc:dd:ee:ff || return 1
! stage3_veth_fields_match 10 11 aa:bb:cc:dd:ee:ff 12 11 aa:bb:cc:dd:ee:ff || return 1
@ -623,6 +685,7 @@ expect_success 'transient unit collision and wrong ownership token rejected' cas
expect_success 'inert signals are deferred through ownership capture and PID start-time identity is exact' case_inert_signal_deferral_and_pid_identity
expect_success 'inert write probe cleaned exactly and symlink preserved' case_inert_probe_cleanup
expect_success 'actual inert cleanup function removes mocked unit/probe and propagates execution or cleanup failure' case_inert_probe_failure_trap
expect_success 'Docker network policy canonical ignores runtime identity churn and rejects policy drift' case_docker_network_policy_canonical
expect_success 'Docker gateway inventory requires exact 39 unique addresses' case_docker_gateway_inventory
expect_success 'veth identity drift rejected before cleanup' case_veth_drift
expect_success 'table/veth drift aborts each direct cleanup path before deletion' case_direct_cleanup_drift_gates

View file

@ -202,8 +202,58 @@ stage3_rootful_docker() {
/usr/bin/docker --config "$stage3_root/tests/docker-config" --host unix:///run/docker.sock "$@"
}
stage3_docker_network_policy_canonical() {
/usr/bin/jq -eS -c '
def nullable_string: type == "null" or type == "string";
def string_map: type == "object" and all(to_entries[]; (.key | type) == "string" and (.value | type) == "string");
def nullable_string_map: type == "null" or string_map;
def normalized_string_map: if . == null then {} else to_entries | sort_by(.key) | from_entries end;
def ipam_config:
type == "object" and
((.Subnet // null) | nullable_string) and
((.Gateway // null) | nullable_string) and
((.IPRange // null) | nullable_string) and
((.AuxiliaryAddresses // null) | nullable_string_map);
length as $count |
if type == "array" and $count > 0 and
all(.[];
type == "object" and
(.Name | type) == "string" and (.Driver | type) == "string" and (.Scope | type) == "string" and
(.Internal | type) == "boolean" and (.Attachable | type) == "boolean" and
(.Ingress | type) == "boolean" and (.EnableIPv6 | type) == "boolean" and
(.ConfigOnly | type) == "boolean" and (.IPAM | type) == "object" and
(.IPAM.Driver | type) == "string" and (.IPAM.Options | nullable_string_map) and
((.IPAM.Config // []) | type == "array" and all(.[]; ipam_config)) and
((.Options // {}) | string_map)) and
([.[].Name] | unique | length) == $count
then map({
Name,
Driver,
Scope,
Internal,
Attachable,
Ingress,
EnableIPv6,
ConfigOnly,
IPAM: {
Driver: .IPAM.Driver,
Options: (.IPAM.Options | normalized_string_map),
Config: ((.IPAM.Config // []) |
map({
Subnet: (.Subnet // null),
Gateway: (.Gateway // null),
IPRange: (.IPRange // null),
AuxiliaryAddresses: (.AuxiliaryAddresses | normalized_string_map)
}) |
sort_by([.Subnet // "", .Gateway // "", .IPRange // "", (.AuxiliaryAddresses | tojson)]))
},
Options: (.Options | normalized_string_map)
}) | sort_by(.Name)
else error("invalid Docker network policy inventory") end'
}
stage3_docker_network_canonical() {
local ids_text raw id
local ids_text raw canonical id
local -a ids=()
ids_text=$(stage3_rootful_docker network ls --no-trunc -q 2>/dev/null) || return 1
[ -n "$ids_text" ] || return 1
@ -212,18 +262,14 @@ stage3_docker_network_canonical() {
ids+=("$id")
done <<< "$ids_text"
[ "${#ids[@]}" -gt 0 ] || return 1
raw=$(stage3_rootful_docker network inspect --format '{"Name":{{json .Name}},"Id":{{json .Id}},"Scope":{{json .Scope}},"Driver":{{json .Driver}},"EnableIPv4":{{json .EnableIPv4}},"EnableIPv6":{{json .EnableIPv6}},"IPAM":{{json .IPAM}},"Internal":{{json .Internal}},"Attachable":{{json .Attachable}},"Ingress":{{json .Ingress}}}' "${ids[@]}" 2>/dev/null) || return 1
printf '%s\n' "$raw" | /usr/bin/jq -esS -c '
if length > 0 and all(.[];
type == "object" and (.Name | type) == "string" and (.Id | type) == "string" and
(.Scope | type) == "string" and (.Driver | type) == "string" and (.IPAM | type) == "object" and
(.EnableIPv4 | type) == "boolean" and (.EnableIPv6 | type) == "boolean" and
(.Internal | type) == "boolean" and (.Attachable | type) == "boolean" and (.Ingress | type) == "boolean")
then sort_by(.Id) else error("invalid Docker network inventory") end'
raw=$(stage3_rootful_docker network inspect --format '{"Name":{{json .Name}},"Scope":{{json .Scope}},"Driver":{{json .Driver}},"EnableIPv6":{{json .EnableIPv6}},"IPAM":{{json .IPAM}},"Internal":{{json .Internal}},"Attachable":{{json .Attachable}},"Ingress":{{json .Ingress}},"ConfigOnly":{{json .ConfigOnly}},"Options":{{json .Options}}}' "${ids[@]}" 2>/dev/null) || return 1
canonical=$(printf '%s\n' "$raw" | /usr/bin/jq -s -c '.' | stage3_docker_network_policy_canonical) || return 1
[ "$(printf '%s' "$canonical" | /usr/bin/jq 'length')" -eq "${#ids[@]}" ] || return 1
printf '%s\n' "$canonical"
}
stage3_committed_docker_network_canonical() {
/usr/bin/jq -S -c 'map({Name,Id,Scope,Driver,EnableIPv4,EnableIPv6,IPAM,Internal,Attachable,Ingress}) | sort_by(.Id)' "$stage1_root/inventory/docker-networks.json"
stage3_docker_network_policy_canonical < "$stage1_root/inventory/docker-networks.json"
}
stage3_nonproject_nft_canonical() {