diff --git a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/MANIFEST.sha256 b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/MANIFEST.sha256 index 26b8d77..6f61133 100644 --- a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/MANIFEST.sha256 +++ b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/MANIFEST.sha256 @@ -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 diff --git a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/README.md b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/README.md index 5c2dc9e..e448604 100644 --- a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/README.md +++ b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/README.md @@ -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. diff --git a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/53-stage3-static-tests.sh b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/53-stage3-static-tests.sh index 9b0cbfa..f641a38 100644 --- a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/53-stage3-static-tests.sh +++ b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/53-stage3-static-tests.sh @@ -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' diff --git a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/54-stage3-failure-path-tests.sh b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/54-stage3-failure-path-tests.sh index 75b3ff1..2396f80 100644 --- a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/54-stage3-failure-path-tests.sh +++ b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/54-stage3-failure-path-tests.sh @@ -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 diff --git a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/stage3-common.sh b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/stage3-common.sh index f7ca02d..abbde2c 100644 --- a/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/stage3-common.sh +++ b/docs/le-app-database-migration/phase2b-stage3-namespace-activation/tests/stage3-common.sh @@ -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() {