diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index d33f725dfd1..d8ae69d758e 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,7 @@ on: mode: description: plan shows membership hunks; apply executes a prior plan artifact; org_actions_observe validates the org credential and refuses on runner-group drift; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and refuses the kvm grant by name; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' vLLM units over the password session, workers before heads; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -227,7 +227,7 @@ jobs: echo "fleet-key: agent loaded (identity fleet-automation@gunbc; secret versions/1 pinned; fingerprint verified against modeled authority; key file wiped)" env: WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} - if: github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'launch_environment_plan' || github.event.inputs.mode == 'allocation_store_plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'app_control_plane_observe' || github.event.inputs.mode == 'microvm_host_converge' || github.event.inputs.mode == 'guest_image_observe' || github.event.inputs.mode == 'guest_image_converge' || github.event.inputs.mode == 'microvm_boot_probe' || github.event.inputs.mode == 'spark_grants' || github.event.inputs.mode == 'spark_bootstrap' || github.event.inputs.mode == 'spark_serving_apply' || github.event.inputs.mode == 'spark_runtime_image_probe' || github.event.inputs.mode == 'dashboard_deploy' || github.event.inputs.mode == 'rlm_launch_deployment_receipt' || github.event.inputs.mode == 'host_reset_return' || github.event.inputs.mode == 'runner_host_file_observe' || github.event.inputs.mode == 'runner_host_file_converge' || github.event.inputs.mode == 'runner_password_session_tool_converge' || github.event.inputs.mode == 'r2_mint_preflight' || github.event.inputs.mode == 'r2_object_write_mint' || github.event.inputs.mode == 'approval_keyring_converge' + if: github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'launch_environment_plan' || github.event.inputs.mode == 'allocation_store_plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'app_control_plane_observe' || github.event.inputs.mode == 'microvm_host_converge' || github.event.inputs.mode == 'guest_image_observe' || github.event.inputs.mode == 'guest_image_converge' || github.event.inputs.mode == 'microvm_boot_probe' || github.event.inputs.mode == 'spark_grants' || github.event.inputs.mode == 'spark_bootstrap' || github.event.inputs.mode == 'spark_serving_apply' || github.event.inputs.mode == 'spark_runtime_image_probe' || github.event.inputs.mode == 'dashboard_deploy' || github.event.inputs.mode == 'rlm_launch_deployment_receipt' || github.event.inputs.mode == 'host_reset_return' || github.event.inputs.mode == 'runner_host_file_observe' || github.event.inputs.mode == 'runner_host_file_converge' || github.event.inputs.mode == 'site_pxe_edge_observe' || github.event.inputs.mode == 'site_pxe_edge_converge' || github.event.inputs.mode == 'runner_password_session_tool_converge' || github.event.inputs.mode == 'r2_mint_preflight' || github.event.inputs.mode == 'r2_object_write_mint' || github.event.inputs.mode == 'approval_keyring_converge' timeout-minutes: 5 - name: Fleet converge plan (membership_reconcile → artifact) id: plan @@ -790,6 +790,36 @@ jobs: retention-days: 30 if: always() && (github.event.inputs.mode == 'runner_host_file_observe' || github.event.inputs.mode == 'runner_host_file_converge') timeout-minutes: 10 + - name: "Site PXE edge: observe the chainloader digest, dnsmasq, the edge config and unit (no writes)" + id: site_pxe_edge_observe + run: | + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/fleet/site_pxe_edge_converge.dag --function site_pxe_edge_observe_ci_wet + cat "$ROOT/target/site-pxe-edge-converge-receipt.txt" + env: + FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} + if: github.event.inputs.mode == 'site_pxe_edge_observe' + timeout-minutes: 15 + - name: "Site PXE edge: if the gate is open, write what differs, restart the unit, read its active state back" + id: site_pxe_edge_converge + run: | + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/fleet/site_pxe_edge_converge.dag --function site_pxe_edge_converge_ci_wet + cat "$ROOT/target/site-pxe-edge-converge-receipt.txt" + env: + FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} + if: github.event.inputs.mode == 'site_pxe_edge_converge' + timeout-minutes: 15 + - name: Upload site PXE edge converge receipt + id: site_pxe_edge_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: site-pxe-edge-converge-receipt + path: target/site-pxe-edge-converge-receipt.txt + if-no-files-found: error + retention-days: 30 + if: always() && (github.event.inputs.mode == 'site_pxe_edge_observe' || github.event.inputs.mode == 'site_pxe_edge_converge') + timeout-minutes: 10 - name: Kill the in-run ssh-agent (key never outlives the job) run: | if [ -n "${SSH_AGENT_PID:-}" ]; then ssh-agent -k || true; fi diff --git a/dag/extdeps/formats/dnsmasq.dag b/dag/extdeps/formats/dnsmasq.dag index 63cd36e470b..bda4feb4568 100644 --- a/dag/extdeps/formats/dnsmasq.dag +++ b/dag/extdeps/formats/dnsmasq.dag @@ -28,6 +28,9 @@ type DnsmasqDirective = | DnsmasqDhcpOptionForceTag { tag: String, option: String, value: String } | DnsmasqDhcpHostSetTag { mac: String, tag: String } | DnsmasqDhcpBootTags { first_tag: String, more_tags: List, target: String, server_name: String, server_address: String } + | DnsmasqLogDhcp + | DnsmasqEnableTftp + | DnsmasqTftpRoot { path: String } data dnsmasq_protocol: LayoutProtocol = LayoutProtocol { indent_unit: "" } @@ -48,6 +51,9 @@ fn dnsmasq_directive_line(d: DnsmasqDirective) -> String { fold(more, init: "", f: (acc, t) => concat(acc, concat(concat("tag:", t), ","))), target, ",", name, ",", address, ], "") + DnsmasqLogDhcp => "log-dhcp" + DnsmasqEnableTftp => "enable-tftp" + DnsmasqTftpRoot { path: path } => concat("tftp-root=", path) } } diff --git a/dag/extdeps/systemd/unit_file.dag b/dag/extdeps/systemd/unit_file.dag index e514425fd6c..eadc3867be1 100644 --- a/dag/extdeps/systemd/unit_file.dag +++ b/dag/extdeps/systemd/unit_file.dag @@ -738,6 +738,10 @@ type SystemdUnitFile // close for directive names. data systemd_instance_name_specifier: String = "%i" +// The administrator's system unit directory, the first entry of systemd's system unit search path +// (systemd.unit(5), "System Unit Search Path"); units placed here take precedence over packaged ones. +data systemd_system_unit_directory: NonEmptyStr = "/etc/systemd/system" + data systemd_unit_section_header: String = "[Unit]" data systemd_service_section_header: String = "[Service]" diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 4cb24331aa0..838faa767c2 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -832,6 +832,20 @@ data gunbc_ci_runner_host_file_converge_target: GunbcRunStepTarget = GunbcRunSte function: "runner_host_file_converge_ci_wet", } +// Where gunbc.site_pxe_edge_converge writes its run receipt: declared here, beside the step that cats it, +// and imported by the writer and the upload step (the import graph runs converge -> ci_spec, not back). +data site_pxe_edge_converge_receipt_path: String = "target/site-pxe-edge-converge-receipt.txt" + +data gunbc_ci_site_pxe_edge_observe_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/fleet/site_pxe_edge_converge.dag", + function: "site_pxe_edge_observe_ci_wet", +} + +data gunbc_ci_site_pxe_edge_converge_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/fleet/site_pxe_edge_converge.dag", + function: "site_pxe_edge_converge_ci_wet", +} + data gunbc_ci_runner_password_session_tool_converge_target: GunbcRunStepTarget = GunbcRunStepTarget { entry: "dag/gunbc/runner/runner_password_session_tool_converge.dag", function: "runner_password_session_tool_converge_ci_wet", @@ -1048,6 +1062,8 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_runner_microvm_host_converge_target, gunbc_ci_runner_host_file_observe_target, gunbc_ci_runner_host_file_converge_target, + gunbc_ci_site_pxe_edge_observe_target, + gunbc_ci_site_pxe_edge_converge_target, gunbc_ci_runner_password_session_tool_converge_target, gunbc_ci_runner_microvm_boot_probe_target, gunbc_ci_host_reset_return_target, @@ -1981,6 +1997,26 @@ fn gunbc_ci_runner_host_file_converge_invoke() -> String { ) } +fn gunbc_ci_site_pxe_edge_observe_invoke() -> String { + gunbc_run_step_script( + source_roots: witness_layer_roots, + entry: gunbc_ci_site_pxe_edge_observe_target.entry, + function: gunbc_ci_site_pxe_edge_observe_target.function, + claim_run: false, + receipt_rel: site_pxe_edge_converge_receipt_path + ) +} + +fn gunbc_ci_site_pxe_edge_converge_invoke() -> String { + gunbc_run_step_script( + source_roots: witness_layer_roots, + entry: gunbc_ci_site_pxe_edge_converge_target.entry, + function: gunbc_ci_site_pxe_edge_converge_target.function, + claim_run: false, + receipt_rel: site_pxe_edge_converge_receipt_path + ) +} + fn gunbc_ci_runner_password_session_tool_converge_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index 9b25df48112..0d0f91b4706 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -69,6 +69,7 @@ import gunbc.runner_spec_from_offer { gunbc_ci_runner_spec } import gunbc.host_reset_return_run { host_reset_dispatch_mode_env } import gunbc.machine_intake_mtcollins1_boot_run { mtcollins1_boot_artifact_glob, mtcollins1_boot_step_timeout_minutes } import gunbc.ci_spec { + site_pxe_edge_converge_receipt_path, gunbc_ci_fleet_converge_plan_invoke, gunbc_ci_fleet_converge_launch_environment_plan_invoke, gunbc_ci_fleet_converge_allocation_store_plan_invoke, @@ -85,6 +86,8 @@ import gunbc.ci_spec { gunbc_ci_runner_microvm_host_converge_invoke, gunbc_ci_runner_host_file_observe_invoke, gunbc_ci_runner_host_file_converge_invoke, + gunbc_ci_site_pxe_edge_observe_invoke, + gunbc_ci_site_pxe_edge_converge_invoke, gunbc_ci_runner_microvm_boot_probe_invoke, gunbc_ci_live_deploy_apply_srv1_invoke, gunbc_ci_spark_grant_install_invoke, @@ -190,6 +193,8 @@ type FleetConvergeWorkflowMode | HostResetReturn | RunnerHostFileObserve | RunnerHostFileConverge + | SitePxeEdgeObserve + | SitePxeEdgeConverge | RunnerPasswordSessionToolConverge | R2MintPreflight | R2ObjectWriteMint @@ -218,6 +223,8 @@ fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String HostResetReturn => "host_reset_return" RunnerHostFileObserve => "runner_host_file_observe" RunnerHostFileConverge => "runner_host_file_converge" + SitePxeEdgeObserve => "site_pxe_edge_observe" + SitePxeEdgeConverge => "site_pxe_edge_converge" RunnerPasswordSessionToolConverge => "runner_password_session_tool_converge" R2MintPreflight => "r2_mint_preflight" R2ObjectWriteMint => "r2_object_write_mint" @@ -248,7 +255,7 @@ fn fleet_converge_spark_target_description() -> String { ], "") } -data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, MtCollins1Boot] +data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, SitePxeEdgeObserve, SitePxeEdgeConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, MtCollins1Boot] // WHICH SCOPE A MODE SELECTS, WHERE IT SELECTS ONE AT ALL. // @@ -291,6 +298,8 @@ fn fleet_converge_mode_scope(mode: FleetConvergeWorkflowMode) -> FleetConvergeSc HostResetReturn => none RunnerHostFileObserve => none RunnerHostFileConverge => none + SitePxeEdgeObserve => none + SitePxeEdgeConverge => none RunnerPasswordSessionToolConverge => none R2MintPreflight => none R2ObjectWriteMint => none @@ -342,6 +351,8 @@ fn fleet_converge_mode_fleet_ssh_key_demand(mode: FleetConvergeWorkflowMode) -> HostResetReturn => FleetSshKeyConsumed RunnerHostFileObserve => FleetSshKeyConsumed RunnerHostFileConverge => FleetSshKeyConsumed + SitePxeEdgeObserve => FleetSshKeyConsumed + SitePxeEdgeConverge => FleetSshKeyConsumed RunnerPasswordSessionToolConverge => FleetSshKeyConsumed R2MintPreflight => FleetSshKeyConsumed R2ObjectWriteMint => FleetSshKeyConsumed @@ -742,6 +753,12 @@ data fleet_converge_runner_host_file_converge_step_if: String = fleet_converge_m data fleet_converge_runner_host_file_any_step_if: String = fleet_converge_any_mode_step_if(modes: [RunnerHostFileObserve, RunnerHostFileConverge]) +data fleet_converge_site_pxe_edge_observe_step_if: String = fleet_converge_mode_step_if(mode: SitePxeEdgeObserve) + +data fleet_converge_site_pxe_edge_converge_step_if: String = fleet_converge_mode_step_if(mode: SitePxeEdgeConverge) + +data fleet_converge_site_pxe_edge_any_step_if: String = fleet_converge_any_mode_step_if(modes: [SitePxeEdgeObserve, SitePxeEdgeConverge]) + // THE MODE THAT CLOSES #11061'S REFUSAL. That change made a password-session leg refuse with a // typed cause naming sshpass; this mode is the route that makes the refusal stop being true. It is // a STEP in the existing host-pinned fleet-converge job rather than a job of its own, because the @@ -1063,6 +1080,47 @@ fn fleet_converge_runner_password_session_tool_converge_step() -> Step { ) } +// THE SITE PXE EDGE (gunbc.site_pxe_edge_converge): the first onboarding segment's ProxyDHCP + TFTP +// daemon on srv4, UEFI ARM64 chainloader only. It reuses the runner-host step shaper for the same +// reason the password-session step does -- same host input binding, same timeout authority, same +// fleet-key reach as the administrator -- and differs only in id, sentence and mode gate. Observe and +// converge are two modes so the gate's causes can be read before anything on the /24 changes. +fn fleet_converge_site_pxe_edge_observe_step() -> Step { + fleet_converge_runner_host_file_step( + id: "site_pxe_edge_observe", + name: "Site PXE edge: observe the chainloader digest, dnsmasq, the edge config and unit (no writes)", + invoke: gunbc_ci_site_pxe_edge_observe_invoke(), + if_condition: fleet_converge_site_pxe_edge_observe_step_if, + ) +} + +fn fleet_converge_site_pxe_edge_converge_step() -> Step { + fleet_converge_runner_host_file_step( + id: "site_pxe_edge_converge", + name: "Site PXE edge: if the gate is open, write what differs, restart the unit, read its active state back", + invoke: gunbc_ci_site_pxe_edge_converge_invoke(), + if_condition: fleet_converge_site_pxe_edge_converge_step_if, + ) +} + +fn fleet_converge_site_pxe_edge_receipt_upload_step() -> Step { + UsesStep { + name: Present { value: "Upload site PXE edge converge receipt" }, + id: Present { value: "site_pxe_edge_receipt_upload" }, + uses: upload_artifact_action, + with: Present { value: [ + kv(key: "name", value: yaml_string(s: "site-pxe-edge-converge-receipt")), + kv(key: "path", value: yaml_string(s: site_pxe_edge_converge_receipt_path)), + kv(key: "if-no-files-found", value: yaml_string(s: "error")), + kv(key: "retention-days", value: yaml_int(n: 30)), + ] }, + env: none, + if_condition: Present { value: concat("always() && (", concat(fleet_converge_site_pxe_edge_any_step_if, ")")) }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_artifact_transfer_step_timeout_minutes } + } +} + fn fleet_converge_runner_host_file_receipt_upload_step() -> Step { UsesStep { name: Present { value: "Upload runner host file converge receipt" }, @@ -1724,6 +1782,9 @@ fn fleet_converge_job() -> Job { fleet_converge_runner_password_session_tool_converge_step(), fleet_converge_runner_password_session_tool_receipt_upload_step(), fleet_converge_runner_host_file_receipt_upload_step(), + fleet_converge_site_pxe_edge_observe_step(), + fleet_converge_site_pxe_edge_converge_step(), + fleet_converge_site_pxe_edge_receipt_upload_step(), ci_fleet_key_agent_cleanup_step(), ] ), diff --git a/dag/gunbc/fleet/fleet_workflow_steps.dag b/dag/gunbc/fleet/fleet_workflow_steps.dag index 87e748b5ff7..ebaf7b73ecc 100644 --- a/dag/gunbc/fleet/fleet_workflow_steps.dag +++ b/dag/gunbc/fleet/fleet_workflow_steps.dag @@ -645,7 +645,9 @@ data gunbc_ci_fleet_job_worst_case_mode_spark_step_count: Int = 1 // figure either. The runner_host_file_observe and runner_host_file_converge modes DO add steps to // this job -- one gated run step each plus one shared receipt upload -- and still do not move the // bound: a dispatch of either runs one step at gunbc_ci_runner_host_file_step_timeout_minutes and one -// transfer-tier upload, which is below the spark_grants branch that sets the worst case. +// transfer-tier upload, which is below the spark_grants branch that sets the worst case. The +// site_pxe_edge_observe and site_pxe_edge_converge modes (gunbc.site_pxe_edge_converge) have the same +// shape -- one gated run step each at that timeout plus one shared upload -- and do not move it either. type JobTimeoutBudget = JobTimeoutWithinCeiling { minutes: Int } | JobTimeoutExceedsCeiling { minutes: Int, ceiling: Int } diff --git a/dag/gunbc/fleet/site_pxe_edge_converge.dag b/dag/gunbc/fleet/site_pxe_edge_converge.dag new file mode 100644 index 00000000000..fedd5948b66 --- /dev/null +++ b/dag/gunbc/fleet/site_pxe_edge_converge.dag @@ -0,0 +1,817 @@ +module gunbc.site_pxe_edge_converge + +import std.types { String, NonEmptyStr, List, Bool, Int, FilePath } +import std.algebra { trim } +import std.resources { Network } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.content_hash { ContentHash, serialize_content_hash } +import extdeps.uri { Uri } +import extdeps.network.ipv4 { Ipv4Address, ipv4_address, Ipv4ParseResult, Ipv4Parsed, Ipv4ParseFailed, Ipv4ParseError, parse_ipv4_address } +import gunbc.fleet_intent_network { srv4_host_lan_endpoint, operator_lan_segment } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.crypto.hash { sha256_digest_content_hash } +import extdeps.tools.sha256sum { Sha256FileDigest, Sha256FileDigestUnavailable, sha256sum_digest_from_line } +import extdeps.systemd { ActiveState } +import extdeps.systemd.systemctl { + systemctl_daemon_reload_argv, + systemctl_enable_argv, + systemctl_restart_argv, + systemctl_show_property_argv, +} +import extdeps.systemd.unit_file { + SystemdUnitFile, + ServiceUnitFile, + Description, + After, + Wants, + ServiceType, + ExecStart, + Restart, + Installable, + WantedBy, + serialize_systemd_unit_file, + systemd_system_unit_directory, +} +import product.placement_supply { HostIdentity } +import gunbc.clock_read { clock_now_probed_at, probed_at_word } +import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name } +import gunbc.fleet_ssh_locus { prepare_fleet_ssh_agent_context, FleetSshContextReady, FleetSshContextRefused } +import gunbc.fleet_known_hosts_anchor { FleetSshExecutionContext, SshTarget } +import gunbc.fleet_reach_endpoint { fleet_probe_endpoint_for } +import gunbc.file_access { FileTreeScope } +import gunbc.typed_argv_exec { typed_argv_exec_over_fleet_ssh, TypedArgvExecConverged, TypedArgvExecRefused } +import gunbc.typed_remote_file_write { + RemoteFileModeOwnerReadWriteWorldRead, + RemoteFileViaNonInteractiveSudo, + TypedRemoteFileWriteAdmission, + TypedRemoteFileWriteAdmitted, + TypedRemoteFileWriteAdmissionRefused, + TypedRemoteFileConverged, + TypedRemoteFileConvergenceRefused, + ComparisonIdentical, + ComparisonDiffers, + ComparisonUnavailable, + typed_remote_file_write_seal, + typed_remote_file_step, + TypedRemoteFileStepRan, + TypedRemoteFileStepArgvRefused, + TypedRemoteFileStepLegRefused, + typed_remote_file_compare_argv, + classify_typed_remote_file_comparison, + converge_typed_remote_file, +} +import gunbc.runner_host_grants { executor_bootstrap_principal } +import gunbc.ci_spec { site_pxe_edge_converge_receipt_path } +import gunbc.runner_host_file_converge { elevated_argv } +import gunbc.machine_intake_subject { content_hash_equal } +import extdeps.toolchain.types { Architecture, Aarch64 } +import gunbc.network_boot_delivery { + first_slice_admitted_architectures, +} +import extdeps.firmware.ipxe { IpxeBuildTarget, ipxe_arm64_efi_snponly, ipxe_build_target_path } +import extdeps.network.mac { Eui48Address, MacParseResult, MacParsed, MacParseFailed, MacParseError } +import gunbc.site_uefi_arm64_pxe_edge { SiteEdgeRoster, site_edge_proxy_dhcp_config, first_onboarding_known_client_mac } +import gunbc.fleet_physical_inventory { operator_site } + +// THE FIRST ONBOARDING SEGMENT'S EDGE, CONVERGED ON srv4 THROUGH THE FLEET-CONVERGE WORKFLOW. +// Operator ruling (PXE-FABRIC 0C, via sleek-carp-159): the serving host is a converged executor on +// Mt. Collins's /24, srv4 unless srv3 is measurably freer, and no fact says it is. A peer of +// gunbc.runner_host_file_converge rather than an axis of gunbc.fleet_converge_plan: that plan's +// request coproduct carries runner and fabric populations and has no file or daemon axis, and the +// parent ruled out stretching it to fake one. +// +// WHAT CONVERGE ESTABLISHES AND WHAT IT DOES NOT. A converged run says srv4 holds the declared +// config and unit bytes, the unit is active, and the chainloader at the TFTP root hashes to the +// pinned digest. It does NOT establish the edge: gunbc.site_uefi_arm64_pxe_edge mints standing only +// from an observed boot offer reaching a client. A daemon that started is not an offer that arrived. +data site_pxe_edge_host: HostIdentity = "srv4" as HostIdentity + +// THE SEGMENT IS gunbc.fleet_intent_network operator_lan_segment, parsed from its cidr's network part +// rather than re-typed; a failure to parse is a gate cause, never a guessed network. +data site_pxe_edge_segment_network: Ipv4ParseResult = match split(s: operator_lan_segment.cidr as String, delimiter: "/").first() { + Present { value: network } => parse_ipv4_address(text: network) + Absent => parse_ipv4_address(text: "") +} + +data site_pxe_edge_unit_name: NonEmptyStr = "gunbc-site-pxe-edge.service" + +data site_pxe_edge_unit_path: FilePath = join([systemd_system_unit_directory as String, "/", site_pxe_edge_unit_name as String], "") as FilePath + +data site_pxe_edge_config_path: FilePath = "/etc/gunbc-site-pxe-edge.dnsmasq.conf" + +// The TFTP root holds exactly one file, the chainloader. Converge never writes into it: the binary's +// bytes have no producer in this repository yet, so it is observed at its pinned digest or refused. +data site_pxe_edge_tftp_root: NonEmptyStr = "/srv/gunbc-site-pxe-edge/tftp" + +data site_pxe_edge_dnsmasq_binary: NonEmptyStr = "/usr/sbin/dnsmasq" + +// Both desired files are directly under /etc or /etc/systemd/system, directories that exist on every +// host, because typed_remote_file_write stages beside its destination and creates no parents. +data site_pxe_edge_file_scope: FileTreeScope = FileTreeScope { root_path: "/etc" as NonEmptyStr } + +// THE CHAINLOADER PIN IS A BUILD RECEIPT, and the gate checks every field of it. A filename and a digest +// alone admit any binary that happens to share a basename, including an x86 build; the receipt names +// the upstream revision it was built from, the exact extdeps.firmware.ipxe build target, the +// architecture, the digest of the built file and the name it is installed under in the TFTP root. +// Unpinned is today's state and names what would ground it. The chainloader is THIS edge's artifact +// (the global boot origin carries kernel, initrd and images, not the chainloader); a guessed URL or +// filename with no digest is exactly what may not be served. +type SitePxeEdgeChainloaderBuildReceipt { + upstream_revision: NonEmptyStr + build_target: IpxeBuildTarget + architecture: Architecture + digest: ContentHash + installed_filename: NonEmptyStr +} + +type SitePxeEdgeChainloaderPin + = SitePxeEdgeChainloaderPinned { build: SitePxeEdgeChainloaderBuildReceipt } + | SitePxeEdgeChainloaderUnpinned { obligation: NonEmptyStr } + +data site_pxe_edge_chainloader_pin: SitePxeEdgeChainloaderPin = SitePxeEdgeChainloaderUnpinned { + obligation: "build iPXE extdeps.firmware.ipxe ipxe_arm64_efi_snponly for Aarch64 at a recorded upstream revision, pin a SitePxeEdgeChainloaderBuildReceipt with its sha256 and installed filename here, and place the file in site_pxe_edge_tftp_root", +} + +fn site_pxe_edge_chainloader_target(pin: SitePxeEdgeChainloaderPin) -> SitePxeEdgeChainloaderBuildReceipt? { + match pin { + SitePxeEdgeChainloaderUnpinned { obligation: _ } => Absent + SitePxeEdgeChainloaderPinned { build: b } => Present { value: b } + } +} + +fn site_pxe_edge_chainloader_path(target: SitePxeEdgeChainloaderBuildReceipt) -> NonEmptyStr { + join([site_pxe_edge_tftp_root as String, "/", target.installed_filename as String], "") as NonEmptyStr +} + +// ── Desired files (pure) ───────────────────────────────────────────────────────────────────────── + +type SitePxeEdgeFile { + subject: NonEmptyStr + path: FilePath + content: String +} + +// THE TFTP SERVER ADDRESS IS srv4's MODELED LAN ENDPOINT, parsed rather than re-typed; a failure to +// parse is a gate cause, never a guessed address. +data site_pxe_edge_tftp_server_address: Ipv4ParseResult = parse_ipv4_address(text: srv4_host_lan_endpoint.address as String) + +// THE EDGE ROSTER THIS HOST SERVES, from the pinned transport's digest and TFTP route plus the parsed +// known clients: the same SiteEdgeRoster type admission gates on, so the written config and a later +// admission read one authored value. +fn site_pxe_edge_roster(target: SitePxeEdgeChainloaderBuildReceipt, known_client_macs: List, tftp_server_address: Ipv4Address) -> SiteEdgeRoster { + SiteEdgeRoster { + site: operator_site, + admitted_architectures: first_slice_admitted_architectures, + chainloader_digest: target.digest, + tftp_server_name: site_pxe_edge_host as NonEmptyStr, + tftp_server_address: tftp_server_address, + known_client_macs: known_client_macs, + } +} + +fn site_pxe_edge_config_file(roster: SiteEdgeRoster, network: Ipv4Address) -> SitePxeEdgeFile { + SitePxeEdgeFile { + subject: "dnsmasq-proxy-dhcp-config", + path: site_pxe_edge_config_path, + content: site_edge_proxy_dhcp_config( + network_address: network, + roster: roster, + tftp_directory: site_pxe_edge_tftp_root, + ), + } +} + +data site_pxe_edge_unit: SystemdUnitFile = ServiceUnitFile { + unit: [ + Description { text: "gunbc site PXE edge: ProxyDHCP + TFTP, UEFI ARM64 chainloader only" }, + After { target: "network-online.target" }, + Wants { target: "network-online.target" }, + ], + service: [ + ServiceType { mode: "simple" }, + ExecStart { command: join([site_pxe_edge_dnsmasq_binary as String, " --keep-in-foreground --conf-file=", site_pxe_edge_config_path as String], "") as NonEmptyStr }, + Restart { policy: "on-failure" }, + ], + install: Installable { directives: [WantedBy { target: "multi-user.target" }] }, +} + +fn site_pxe_edge_unit_file() -> SitePxeEdgeFile { + SitePxeEdgeFile { + subject: "systemd-unit", + path: site_pxe_edge_unit_path, + content: serialize_systemd_unit_file(file: site_pxe_edge_unit), + } +} + +// ONE DERIVATION OF THE DESIRED CONFIG'S INPUTS, read by the file list and answerable beside the gate: +// the roster and the segment the config renders exist exactly when the pin, the known-client MAC, the +// TFTP server address and the segment all parsed, which is the same set site_pxe_edge_gate_causes types. +// Deriving it twice is how an apply could have no causes and still narrow to the unit file alone. +type SitePxeEdgeDesiredInputs { + roster: SiteEdgeRoster + network: Ipv4Address +} + +fn site_pxe_edge_desired_inputs( + pin: SitePxeEdgeChainloaderPin, + roster: MacParseResult, + tftp_server: Ipv4ParseResult, + segment: Ipv4ParseResult, +) -> SitePxeEdgeDesiredInputs? { + match site_pxe_edge_chainloader_target(pin: pin) { + Absent => Absent + Present { value: c } => + match roster { + MacParseFailed { error: _ } => Absent + MacParsed { address: a } => + match tftp_server { + Ipv4ParseFailed { error: _ } => Absent + Ipv4Parsed { address: server } => + match segment { + Ipv4ParseFailed { error: _ } => Absent + Ipv4Parsed { address: network } => Present { value: SitePxeEdgeDesiredInputs { + roster: site_pxe_edge_roster(target: c, known_client_macs: [a], tftp_server_address: server), + network: network, + } } + } + } + } + } +} + +fn site_pxe_edge_desired_files(roster: SiteEdgeRoster, network: Ipv4Address) -> List { + [site_pxe_edge_config_file(roster: roster, network: network), site_pxe_edge_unit_file()] +} + +// ── The gate (pure) ────────────────────────────────────────────────────────────────────────────── + +// A PROBE THAT RAN AND FOUND NO IS A FINDING; A PROBE THAT COULD NOT RUN IS A FAILURE TO OBSERVE. The +// two are different arms, so an observe run can report the first and must refuse on the second. +type SitePxeEdgeProbe + = SitePxeEdgeProbeHeld + | SitePxeEdgeProbeObservedNegative { reason: String } + | SitePxeEdgeProbeUnobservable { reason: String } + | SitePxeEdgeProbeNotAttempted { reason: String } + +type SitePxeEdgeChainloaderObservation + = ChainloaderDigestObserved { digest: ContentHash } + | ChainloaderDigestUnobservable { reason: String } + +// EVERY REASON TO WITHHOLD THE DAEMON, COUNTED, NOT THE FIRST ONE. A daemon offering a filename the +// TFTP root cannot serve, or a digest nobody pinned, is an edge that answers wrongly; so any cause +// here closes the gate and nothing is written or started. +// EVERY REASON THE GATE STAYS CLOSED, TYPED. A closed set of causes with their payloads (the parse +// errors, both digests, both filenames) kept as values; site_pxe_edge_run_text renders them, and no +// consumer has to recover a cause from prose. Probe refusals stay a String: that is open-ended stderr. +type SitePxeEdgeGateCause + = TftpServerAddressUnparseable { error: Ipv4ParseError } + | SegmentNetworkUnparseable { error: Ipv4ParseError } + | KnownClientMacUnparseable { error: MacParseError } + | ChainloaderUnpinned { obligation: NonEmptyStr } + | ChainloaderUnobservable { reason: String } + | ChainloaderDigestMismatch { observed: ContentHash, pinned: ContentHash } + | ChainloaderNotTheIpxeArm64Build { pinned: NonEmptyStr, expected: NonEmptyStr } + | ChainloaderBuildTargetNotIpxeArm64 { pinned: IpxeBuildTarget, expected: IpxeBuildTarget } + | ChainloaderArchitectureNotAarch64 { architecture: Architecture } + | DnsmasqBinaryAbsent { reason: String } + | DnsmasqBinaryUnobservable { reason: String } + | AdministratorPrivilegeRefused { reason: String } + | AdministratorPrivilegeUnobservable { reason: String } + +fn site_pxe_edge_gate_causes( + tftp_server: Ipv4ParseResult, + segment: Ipv4ParseResult, + roster: MacParseResult, + pin: SitePxeEdgeChainloaderPin, + chainloader: SitePxeEdgeChainloaderObservation, + dnsmasq_binary: SitePxeEdgeProbe, + privilege: SitePxeEdgeProbe, +) -> List { + (match tftp_server { + Ipv4Parsed { address: _ } => [] + Ipv4ParseFailed { error: e } => [TftpServerAddressUnparseable { error: e }] + }) + (match segment { + Ipv4Parsed { address: _ } => [] + Ipv4ParseFailed { error: e } => [SegmentNetworkUnparseable { error: e }] + }) + (match roster { + MacParsed { address: _ } => [] + MacParseFailed { error: e } => [KnownClientMacUnparseable { error: e }] + }) + (match pin { + SitePxeEdgeChainloaderUnpinned { obligation: o } => [ChainloaderUnpinned { obligation: o }] + SitePxeEdgeChainloaderPinned { build: b } => + (match chainloader { + ChainloaderDigestUnobservable { reason: r } => [ChainloaderUnobservable { reason: r }] + ChainloaderDigestObserved { digest: d } => + if content_hash_equal(left: d, right: b.digest) { [] } else { [ChainloaderDigestMismatch { observed: d, pinned: b.digest }] } + }) + (if b.build_target == ipxe_arm64_efi_snponly { + [] + } else { + [ChainloaderBuildTargetNotIpxeArm64 { pinned: b.build_target, expected: ipxe_arm64_efi_snponly }] + }) + (if b.architecture == Aarch64 { + [] + } else { + [ChainloaderArchitectureNotAarch64 { architecture: b.architecture }] + }) + (if (b.installed_filename as String) == (ipxe_arm64_efi_snponly.image as String) { + [] + } else { + [ChainloaderNotTheIpxeArm64Build { pinned: b.installed_filename, expected: ipxe_arm64_efi_snponly.image }] + }) + }) + (match dnsmasq_binary { + SitePxeEdgeProbeHeld => [] + SitePxeEdgeProbeObservedNegative { reason: r } => [DnsmasqBinaryAbsent { reason: r }] + SitePxeEdgeProbeUnobservable { reason: r } => [DnsmasqBinaryUnobservable { reason: r }] + SitePxeEdgeProbeNotAttempted { reason: r } => [DnsmasqBinaryUnobservable { reason: concat("not attempted: ", r) }] + }) + (match privilege { + SitePxeEdgeProbeHeld => [] + SitePxeEdgeProbeObservedNegative { reason: r } => [AdministratorPrivilegeRefused { reason: r }] + SitePxeEdgeProbeUnobservable { reason: r } => [AdministratorPrivilegeUnobservable { reason: r }] + SitePxeEdgeProbeNotAttempted { reason: r } => [AdministratorPrivilegeUnobservable { reason: concat("not attempted: ", r) }] + }) +} + +// A NOT-ATTEMPTED INPUT PROBE CLOSES THE GATE AS UNOBSERVABLE, and carries "not attempted: " on the +// reason so the finer fact survives in the payload. SitePxeEdgeProbe is one type across the input +// legs and the activation leg, but only the activation leg is ever minted NotAttempted; the dnsmasq +// and privilege legs are observed before the gate and are always attempted. The arm exists because +// the type is shared and the match must be total, not because the producer can reach it. What it may +// never do is mint an ObservedNegative -- a leg that never ran is not a leg that ran and said no, +// which is the distinction the gate is here to keep. +// WHICH GATE CAUSES MEAN THE RUN COULD NOT LOOK, as opposed to what it saw when it did. Only the three +// Unobservable arms are true: a leg that never executed. A sudo -n that RAN and answered no privilege, +// like an absent dnsmasq or an unpinned chainloader, is a finding an observe dispatch reports and +// still succeeds on. +fn site_pxe_edge_gate_cause_is_unobserved(cause: SitePxeEdgeGateCause) -> Bool { + match cause { + ChainloaderUnobservable { reason: _ } => true + DnsmasqBinaryUnobservable { reason: _ } => true + AdministratorPrivilegeUnobservable { reason: _ } => true + AdministratorPrivilegeRefused { reason: _ } => false + TftpServerAddressUnparseable { error: _ } => false + SegmentNetworkUnparseable { error: _ } => false + KnownClientMacUnparseable { error: _ } => false + ChainloaderUnpinned { obligation: _ } => false + ChainloaderDigestMismatch { observed: _, pinned: _ } => false + ChainloaderNotTheIpxeArm64Build { pinned: _, expected: _ } => false + ChainloaderBuildTargetNotIpxeArm64 { pinned: _, expected: _ } => false + ChainloaderArchitectureNotAarch64 { architecture: _ } => false + DnsmasqBinaryAbsent { reason: _ } => false + } +} + +fn site_pxe_edge_gate_cause_text(cause: SitePxeEdgeGateCause) -> String { + match cause { + TftpServerAddressUnparseable { error: _ } => "tftp-server-address-unparseable: srv4_host_lan_endpoint.address is not an IPv4 address" + SegmentNetworkUnparseable { error: _ } => "segment-network-unparseable: operator_lan_segment.cidr has no IPv4 network part" + KnownClientMacUnparseable { error: _ } => "known-client-mac-unparseable: the measured host NIC observation does not parse as an EUI-48 address" + ChainloaderUnpinned { obligation: o } => concat("chainloader-unpinned: ", o as String) + ChainloaderUnobservable { reason: r } => concat("chainloader-unobservable: ", r) + ChainloaderDigestMismatch { observed: o, pinned: p } => join(["chainloader-digest-mismatch: observed ", serialize_content_hash(hash: o) as String, ", pinned ", serialize_content_hash(hash: p) as String], "") + ChainloaderNotTheIpxeArm64Build { pinned: p, expected: e } => join(["chainloader-not-the-ipxe-arm64-build: pinned ", p as String, ", expected ", e as String], "") + ChainloaderBuildTargetNotIpxeArm64 { pinned: p, expected: e } => join(["chainloader-build-target-not-ipxe-arm64: pinned ", ipxe_build_target_path(target: p) as String, ", expected ", ipxe_build_target_path(target: e) as String], "") + ChainloaderArchitectureNotAarch64 { architecture: _ } => "chainloader-architecture-not-aarch64: the pinned build is not an Aarch64 build" + DnsmasqBinaryAbsent { reason: r } => concat("dnsmasq-binary-absent: ", r) + DnsmasqBinaryUnobservable { reason: r } => concat("dnsmasq-binary-unobservable: ", r) + AdministratorPrivilegeRefused { reason: r } => concat("administrator-privilege-refused: ", r) + AdministratorPrivilegeUnobservable { reason: r } => concat("administrator-privilege-unobservable: ", r) + } +} + +fn site_pxe_edge_digest_observation(path: NonEmptyStr, success: Bool, line: String) -> SitePxeEdgeChainloaderObservation { + match sha256sum_digest_from_line(path: path, success: success, line: line) { + Sha256FileDigestUnavailable { path: _, reason: r } => ChainloaderDigestUnobservable { reason: r } + Sha256FileDigest { digest: d } => + match sha256_digest_content_hash(digest: d) { + Absent => ChainloaderDigestUnobservable { reason: "sha256sum printed a field that is not a sha256 digest" } + Present { value: h } => ChainloaderDigestObserved { digest: h } + } + } +} + +// ── Subject ────────────────────────────────────────────────────────────────────────────────────── + +type SitePxeEdgeSubjectAdmission + = SitePxeEdgeSubjectAdmitted { target: SshTarget } + | SitePxeEdgeSubjectRefused { cause: String } + +fn site_pxe_edge_subject(host: HostIdentity) -> SitePxeEdgeSubjectAdmission { + if (host as String) != (site_pxe_edge_host as String) { + SitePxeEdgeSubjectRefused { + cause: join(["site_pxe_edge_converge: ", host as String, " is not the site edge host (", site_pxe_edge_host as String, "); the edge lives on one executor per segment"], ""), + } + } else { + SitePxeEdgeSubjectAdmitted { + target: SshTarget { + endpoint: fleet_probe_endpoint_for(host: host as String) as NonEmptyStr, + principal: executor_bootstrap_principal.login, + }, + } + } +} + +// ── Observation (effectful) ────────────────────────────────────────────────────────────────────── + +fn site_pxe_edge_argv_probe(target: SshTarget, context: FleetSshExecutionContext, argv: List, what: String) -> SitePxeEdgeProbe + uses net: Network +{ + match typed_argv_exec_over_fleet_ssh(target: target, context: context, argv: argv) { + TypedArgvExecRefused { reason: why } => SitePxeEdgeProbeUnobservable { reason: why } + TypedArgvExecConverged { result: r } => + if r.success { + SitePxeEdgeProbeHeld + } else { + SitePxeEdgeProbeObservedNegative { reason: join([what, " exit=", to_string(r.exit_code), " ", trim(s: r.stderr)], "") } + } + } +} + +fn observe_site_pxe_edge_chainloader(target: SshTarget, context: FleetSshExecutionContext, pin: SitePxeEdgeChainloaderPin) -> SitePxeEdgeChainloaderObservation + uses net: Network +{ + match site_pxe_edge_chainloader_target(pin: pin) { + Absent => ChainloaderDigestUnobservable { reason: "no pinned chainloader to digest" } + Present { value: c } => { + let path = site_pxe_edge_chainloader_path(target: c) + match typed_argv_exec_over_fleet_ssh(target: target, context: context, argv: ["sha256sum", path as String]) { + TypedArgvExecRefused { reason: why } => ChainloaderDigestUnobservable { reason: why } + TypedArgvExecConverged { result: r } => site_pxe_edge_digest_observation(path: path, success: r.success, line: r.stdout) + } + } + } +} + +type SitePxeEdgeFileStanding + = EdgeFileIdentical + | EdgeFileDiffers + | EdgeFileAbsent + | EdgeFileUnobservable { reason: String } + +fn site_pxe_edge_file_admission(file: SitePxeEdgeFile) -> TypedRemoteFileWriteAdmission { + typed_remote_file_write_seal(path: file.path, content: file.content, allowed_scope: site_pxe_edge_file_scope) +} + +// The comparison is typed_remote_file_write's own far-side `cmp -s -`, so the plan's "identical" and +// the write's read-back are one judgment. +fn observe_site_pxe_edge_file(target: SshTarget, context: FleetSshExecutionContext, file: SitePxeEdgeFile) -> SitePxeEdgeFileStanding + uses net: Network +{ + match typed_argv_exec_over_fleet_ssh(target: target, context: context, argv: ["test", "-e", file.path as String]) { + TypedArgvExecRefused { reason: why } => EdgeFileUnobservable { reason: why } + TypedArgvExecConverged { result: presence } => + if presence.exit_code == 1 { + EdgeFileAbsent + } else if presence.exit_code != 0 { + EdgeFileUnobservable { reason: join(["presence test exit=", to_string(presence.exit_code), " ", trim(s: presence.stderr)], "") } + } else { + match site_pxe_edge_file_admission(file: file) { + TypedRemoteFileWriteAdmissionRefused { cause: c } => EdgeFileUnobservable { reason: c as String } + TypedRemoteFileWriteAdmitted { write: w } => + match typed_remote_file_step(target: target, context: context, raws: typed_remote_file_compare_argv(write: w), stdin_payload: w.content) { + TypedRemoteFileStepArgvRefused => EdgeFileUnobservable { reason: "comparison argv refused as non-portable" } + TypedRemoteFileStepLegRefused { cause: why } => EdgeFileUnobservable { reason: why as String } + TypedRemoteFileStepRan { result: back } => + match classify_typed_remote_file_comparison(write: w, compare_exit_code: back.exit_code, compare_stderr: back.stderr) { + ComparisonIdentical => EdgeFileIdentical + ComparisonDiffers { reason: _ } => EdgeFileDiffers + ComparisonUnavailable { reason: r } => EdgeFileUnobservable { reason: r } + } + } + } + } + } +} + +fn observe_site_pxe_edge_unit_active(target: SshTarget, context: FleetSshExecutionContext) -> SitePxeEdgeProbe + uses net: Network +{ + match typed_argv_exec_over_fleet_ssh(target: target, context: context, argv: systemctl_show_property_argv(unit: site_pxe_edge_unit_name, property: ActiveState)) { + TypedArgvExecRefused { reason: why } => SitePxeEdgeProbeUnobservable { reason: why } + TypedArgvExecConverged { result: r } => + if (r.success == false) { + SitePxeEdgeProbeUnobservable { reason: join(["systemctl show exit=", to_string(r.exit_code), " ", trim(s: r.stderr)], "") } + } else if trim(s: r.stdout) == "ActiveState=active" { + SitePxeEdgeProbeHeld + } else { + SitePxeEdgeProbeObservedNegative { reason: join(["unit ", site_pxe_edge_unit_name as String, " ", trim(s: r.stdout)], "") } + } + } +} + +// ── Plan and apply ─────────────────────────────────────────────────────────────────────────────── + +type SitePxeEdgeMode + = SitePxeEdgeObserveOnly + | SitePxeEdgeApply + +type SitePxeEdgeFileOutcome + = EdgeFileLeftInPlace + | EdgeFileWrittenAndReadBack + | EdgeFileWriteRefused { step: String, reason: String } + | EdgeFileNotApplied { reason: String } + +type SitePxeEdgeFileResult { + file: SitePxeEdgeFile + before: SitePxeEdgeFileStanding + outcome: SitePxeEdgeFileOutcome +} + +// THE CHAINLOADER IS READ AGAIN AFTER ACTIVATION. The gate's digest was taken before files were written +// and the daemon restarted, so the bytes the daemon now serves are digested once more and must still be +// the pinned build before an apply may call itself converged. +type SitePxeEdgePostActivation + = PostActivationNotRead { reason: String } + | PostActivationDigestHeld + | PostActivationDigestDiffers { observed: ContentHash, pinned: ContentHash } + | PostActivationUnobservable { reason: String } + +fn site_pxe_edge_post_activation(pin: SitePxeEdgeChainloaderPin, observation: SitePxeEdgeChainloaderObservation) -> SitePxeEdgePostActivation { + match pin { + SitePxeEdgeChainloaderUnpinned { obligation: _ } => PostActivationNotRead { reason: "no pinned chainloader" } + SitePxeEdgeChainloaderPinned { build: b } => + match observation { + ChainloaderDigestUnobservable { reason: r } => PostActivationUnobservable { reason: r } + ChainloaderDigestObserved { digest: d } => + if content_hash_equal(left: d, right: b.digest) { PostActivationDigestHeld } else { PostActivationDigestDiffers { observed: d, pinned: b.digest } } + } + } +} + +type SitePxeEdgeRun { + mode: SitePxeEdgeMode + gate_causes: List + files: List + activation: SitePxeEdgeProbe + unit_active: SitePxeEdgeProbe + post_activation: SitePxeEdgePostActivation +} + +fn site_pxe_edge_file_outcome_settled(outcome: SitePxeEdgeFileOutcome) -> Bool { + match outcome { + EdgeFileLeftInPlace => true + EdgeFileWrittenAndReadBack => true + EdgeFileWriteRefused { step: _, reason: _ } => false + EdgeFileNotApplied { reason: _ } => false + } +} + +fn apply_site_pxe_edge_file(target: SshTarget, context: FleetSshExecutionContext, file: SitePxeEdgeFile, before: SitePxeEdgeFileStanding) -> SitePxeEdgeFileOutcome + uses net: Network +{ + match before { + EdgeFileIdentical => EdgeFileLeftInPlace + EdgeFileUnobservable { reason: r } => EdgeFileNotApplied { reason: concat("unobservable before write: ", r) } + EdgeFileDiffers => write_site_pxe_edge_file(target: target, context: context, file: file) + EdgeFileAbsent => write_site_pxe_edge_file(target: target, context: context, file: file) + } +} + +fn write_site_pxe_edge_file(target: SshTarget, context: FleetSshExecutionContext, file: SitePxeEdgeFile) -> SitePxeEdgeFileOutcome + uses net: Network +{ + match converge_typed_remote_file( + target: target, + context: context, + admission: site_pxe_edge_file_admission(file: file), + mode: RemoteFileModeOwnerReadWriteWorldRead, + privilege: RemoteFileViaNonInteractiveSudo, + ) { + TypedRemoteFileConvergenceRefused { step: st, reason: why } => EdgeFileWriteRefused { step: st, reason: why } + TypedRemoteFileConverged { converged: _ } => EdgeFileWrittenAndReadBack + } +} + +// daemon-reload, enable, restart -- restart and not start, so a rewritten config is actually the one +// running. Refused at the first failing step, by name. +fn activate_site_pxe_edge_unit(target: SshTarget, context: FleetSshExecutionContext) -> SitePxeEdgeProbe + uses net: Network +{ + match site_pxe_edge_argv_probe(target: target, context: context, argv: elevated_argv(command: systemctl_daemon_reload_argv()), what: "daemon-reload") { + SitePxeEdgeProbeObservedNegative { reason: r } => SitePxeEdgeProbeObservedNegative { reason: r } + SitePxeEdgeProbeUnobservable { reason: r } => SitePxeEdgeProbeUnobservable { reason: r } + SitePxeEdgeProbeNotAttempted { reason: r } => SitePxeEdgeProbeNotAttempted { reason: r } + SitePxeEdgeProbeHeld => + match site_pxe_edge_argv_probe(target: target, context: context, argv: elevated_argv(command: systemctl_enable_argv(unit: site_pxe_edge_unit_name)), what: "enable") { + SitePxeEdgeProbeObservedNegative { reason: r } => SitePxeEdgeProbeObservedNegative { reason: r } + SitePxeEdgeProbeUnobservable { reason: r } => SitePxeEdgeProbeUnobservable { reason: r } + SitePxeEdgeProbeNotAttempted { reason: r } => SitePxeEdgeProbeNotAttempted { reason: r } + SitePxeEdgeProbeHeld => + site_pxe_edge_argv_probe(target: target, context: context, argv: elevated_argv(command: systemctl_restart_argv(unit: site_pxe_edge_unit_name)), what: "restart") + } + } +} + +fn site_pxe_edge_file_results( + target: SshTarget, + context: FleetSshExecutionContext, + mode: SitePxeEdgeMode, + gate_open: Bool, + files: List, +) -> List + uses net: Network +{ + map(files, f => { + let before = observe_site_pxe_edge_file(target: target, context: context, file: f) + SitePxeEdgeFileResult { + file: f, + before: before, + outcome: match mode { + SitePxeEdgeObserveOnly => EdgeFileNotApplied { reason: "observe-only" } + SitePxeEdgeApply => + if gate_open { + apply_site_pxe_edge_file(target: target, context: context, file: f, before: before) + } else { + EdgeFileNotApplied { reason: "gate closed" } + } + }, + } + }) +} + +// ORDER: privilege, dnsmasq binary and chainloader digest (the gate), then every desired file, then +// activation only when the gate is open and every file settled, then the unit's active state read +// back. A closed gate still observes the files, so an observe dispatch says what WOULD be written. +// Every effect is sequenced by a let; the unit read-back is bound after activation, never ordered by +// its position in the result record. +fn run_site_pxe_edge(target: SshTarget, context: FleetSshExecutionContext, mode: SitePxeEdgeMode) -> SitePxeEdgeRun + uses net: Network +{ + let pin = site_pxe_edge_chainloader_pin + let privilege = site_pxe_edge_argv_probe(target: target, context: context, argv: elevated_argv(command: ["true"]), what: "non-interactive sudo") + let binary = site_pxe_edge_argv_probe(target: target, context: context, argv: ["test", "-x", site_pxe_edge_dnsmasq_binary as String], what: "test -x dnsmasq") + let chainloader = observe_site_pxe_edge_chainloader(target: target, context: context, pin: pin) + let causes = site_pxe_edge_gate_causes(tftp_server: site_pxe_edge_tftp_server_address, segment: site_pxe_edge_segment_network, roster: first_onboarding_known_client_mac, pin: pin, chainloader: chainloader, dnsmasq_binary: binary, privilege: privilege) + let files = match site_pxe_edge_desired_inputs(pin: pin, roster: first_onboarding_known_client_mac, tftp_server: site_pxe_edge_tftp_server_address, segment: site_pxe_edge_segment_network) { + Absent => [site_pxe_edge_unit_file()] + Present { value: inputs } => site_pxe_edge_desired_files(roster: inputs.roster, network: inputs.network) + } + let results = site_pxe_edge_file_results(target: target, context: context, mode: mode, gate_open: length(causes) == 0, files: files) + let activation = match mode { + SitePxeEdgeObserveOnly => SitePxeEdgeProbeNotAttempted { reason: "observe-only" } + SitePxeEdgeApply => + if length(causes) == 0 && all(results, r => site_pxe_edge_file_outcome_settled(outcome: r.outcome)) { + activate_site_pxe_edge_unit(target: target, context: context) + } else { + SitePxeEdgeProbeNotAttempted { reason: "gate closed or a file did not settle" } + } + } + let unit_active = observe_site_pxe_edge_unit_active(target: target, context: context) + let post_activation = match mode { + SitePxeEdgeObserveOnly => PostActivationNotRead { reason: "observe-only" } + SitePxeEdgeApply => + if site_pxe_edge_probe_held(probe: activation) { + site_pxe_edge_post_activation(pin: pin, observation: observe_site_pxe_edge_chainloader(target: target, context: context, pin: pin)) + } else { + PostActivationNotRead { reason: "activation did not hold" } + } + } + SitePxeEdgeRun { + mode: mode, + gate_causes: causes, + files: results, + activation: activation, + unit_active: unit_active, + post_activation: post_activation, + } +} + +// Observe succeeds when it could SEE everything; drift and a closed gate are its findings. Apply +// succeeds only when the gate was open, every file settled, activation held and the unit reads active. +fn site_pxe_edge_run_succeeded(run: SitePxeEdgeRun) -> Bool { + match run.mode { + SitePxeEdgeObserveOnly => + !any(run.gate_causes, c => site_pxe_edge_gate_cause_is_unobserved(cause: c)) + && site_pxe_edge_probe_observable(probe: run.unit_active) + && !any(run.files, r => match r.before { + EdgeFileUnobservable { reason: _ } => true + EdgeFileIdentical => false + EdgeFileDiffers => false + EdgeFileAbsent => false + }) + SitePxeEdgeApply => + length(run.gate_causes) == 0 + && all(run.files, r => site_pxe_edge_file_outcome_settled(outcome: r.outcome)) + && site_pxe_edge_probe_held(probe: run.activation) + && site_pxe_edge_probe_held(probe: run.unit_active) + && (match run.post_activation { + PostActivationDigestHeld => true + PostActivationNotRead { reason: _ } => false + PostActivationDigestDiffers { observed: _, pinned: _ } => false + PostActivationUnobservable { reason: _ } => false + }) + } +} + +fn site_pxe_edge_probe_held(probe: SitePxeEdgeProbe) -> Bool { + match probe { + SitePxeEdgeProbeHeld => true + SitePxeEdgeProbeObservedNegative { reason: _ } => false + SitePxeEdgeProbeUnobservable { reason: _ } => false + SitePxeEdgeProbeNotAttempted { reason: _ } => false + } +} + +fn site_pxe_edge_probe_observable(probe: SitePxeEdgeProbe) -> Bool { + match probe { + SitePxeEdgeProbeHeld => true + SitePxeEdgeProbeObservedNegative { reason: _ } => true + SitePxeEdgeProbeUnobservable { reason: _ } => false + SitePxeEdgeProbeNotAttempted { reason: _ } => false + } +} + +fn site_pxe_edge_probe_wire(probe: SitePxeEdgeProbe) -> String { + match probe { + SitePxeEdgeProbeHeld => "held" + SitePxeEdgeProbeObservedNegative { reason: r } => concat("no ", r) + SitePxeEdgeProbeUnobservable { reason: r } => concat("UNOBSERVABLE ", r) + SitePxeEdgeProbeNotAttempted { reason: r } => concat("not-attempted ", r) + } +} + +fn site_pxe_edge_file_standing_wire(standing: SitePxeEdgeFileStanding) -> String { + match standing { + EdgeFileIdentical => "identical" + EdgeFileDiffers => "differs" + EdgeFileAbsent => "absent" + EdgeFileUnobservable { reason: r } => concat("UNOBSERVABLE ", r) + } +} + +fn site_pxe_edge_file_outcome_wire(outcome: SitePxeEdgeFileOutcome) -> String { + match outcome { + EdgeFileLeftInPlace => "left-in-place" + EdgeFileWrittenAndReadBack => "written-and-read-back" + EdgeFileWriteRefused { step: st, reason: why } => join(["REFUSED step=", st, " ", why], "") + EdgeFileNotApplied { reason: why } => concat("not-applied ", why) + } +} + +fn site_pxe_edge_post_activation_wire(post: SitePxeEdgePostActivation) -> String { + match post { + PostActivationNotRead { reason: r } => concat("not-read ", r) + PostActivationDigestHeld => "held" + PostActivationDigestDiffers { observed: o, pinned: p } => join(["DIFFERS observed ", serialize_content_hash(hash: o) as String, ", pinned ", serialize_content_hash(hash: p) as String], "") + PostActivationUnobservable { reason: r } => concat("UNOBSERVABLE ", r) + } +} + +fn site_pxe_edge_run_text(run: SitePxeEdgeRun) -> String { + join([ + "mode=", match run.mode { SitePxeEdgeObserveOnly => "observe" SitePxeEdgeApply => "apply" }, "\n", + join(map(run.gate_causes, c => join(["gate-closed ", site_pxe_edge_gate_cause_text(cause: c), "\n"], "")), ""), + join(map(run.files, r => join(["file ", r.file.subject as String, " path=", r.file.path as String, " before=", site_pxe_edge_file_standing_wire(standing: r.before), " outcome=", site_pxe_edge_file_outcome_wire(outcome: r.outcome), "\n"], "")), ""), + "activation=", site_pxe_edge_probe_wire(probe: run.activation), "\n", + "unit-active=", site_pxe_edge_probe_wire(probe: run.unit_active), "\n", + "post-activation-chainloader=", site_pxe_edge_post_activation_wire(post: run.post_activation), "\n", + "terminal=", if site_pxe_edge_run_succeeded(run: run) { "succeeded" } else { "REFUSED" }, "\n", + ], "") +} + +// ── The CI entries ─────────────────────────────────────────────────────────────────────────────── + +data site_pxe_edge_converge_attempt_raw: String = "site-pxe-edge-converge-ci" + + +fn site_pxe_edge_ci_wet(mode: SitePxeEdgeMode) -> ProcessExit + uses net: Network +{ + match actions_variable_read(name: fleet_converge_expected_host_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => + exit_failure(reason: join(["site_pxe_edge_converge: ", v, " ", d, "; one host per run, named by the dispatch"], "")) + ActionsVariablePresent { value: raw_host } => + match site_pxe_edge_subject(host: raw_host as HostIdentity) { + SitePxeEdgeSubjectRefused { cause: c } => exit_failure(reason: c) + SitePxeEdgeSubjectAdmitted { target: target } => + match prepare_fleet_ssh_agent_context(attempt_raw: site_pxe_edge_converge_attempt_raw) { + FleetSshContextRefused { cause: c } => exit_failure(reason: c) + FleetSshContextReady { context: context, receipt: context_receipt } => { + let run = run_site_pxe_edge(target: target, context: context, mode: mode) + let body = join([ + "site_pxe_edge_converge host=", raw_host, " probed_at=", probed_at_word(reading: clock_now_probed_at()) as String, "\n", + context_receipt, + site_pxe_edge_run_text(run: run), + ], "") + let w = Filesystem.Write(path: site_pxe_edge_converge_receipt_path, content: body) + if w.success == false { + exit_failure(reason: join(["site_pxe_edge_converge: receipt write failed: ", w.error, "\n", body], "")) + } else if site_pxe_edge_run_succeeded(run: run) == false { + exit_failure(reason: body) + } else { + ExitSuccess + } + } + } + } + } +} + +fn site_pxe_edge_observe_ci_wet() -> ProcessExit + uses net: Network +{ + site_pxe_edge_ci_wet(mode: SitePxeEdgeObserveOnly) +} + +fn site_pxe_edge_converge_ci_wet() -> ProcessExit + uses net: Network +{ + site_pxe_edge_ci_wet(mode: SitePxeEdgeApply) +} diff --git a/dag/gunbc/site_uefi_arm64_pxe_edge.dag b/dag/gunbc/site_uefi_arm64_pxe_edge.dag index 3239eb67e7a..f2df500e3ad 100644 --- a/dag/gunbc/site_uefi_arm64_pxe_edge.dag +++ b/dag/gunbc/site_uefi_arm64_pxe_edge.dag @@ -8,8 +8,8 @@ import std.roster_frontier { FrontierRow, frontier_row_decl } import std.dissolution { unbound_dissolution } import extdeps.network.mac { Eui48Address, MacParseResult, parse_mac_address, render_mac_address } import extdeps.toolchain.types { Architecture, Aarch64 } -import product.placement_supply { SiteIdentity } import extdeps.network.ipv4 { Ipv4Address, render_ipv4_address } +import product.placement_supply { SiteIdentity } import extdeps.firmware.ipxe { ipxe_chainloading_authority, ipxe_arm64_efi_snponly, ipxe_build_target_path } import extdeps.formats.dnsmasq { DnsmasqDirective, @@ -19,6 +19,9 @@ import extdeps.formats.dnsmasq { DnsmasqDhcpMatchSet, DnsmasqDhcpHostSetTag, DnsmasqDhcpBootTags, + DnsmasqLogDhcp, + DnsmasqEnableTftp, + DnsmasqTftpRoot, serialize_dnsmasq } import gunbc.network_boot_delivery { @@ -270,31 +273,39 @@ fn site_edge_admission_of_standing(standing: SitePxeEdgeStanding, unreachable: L // edge on a segment with an existing DHCP server must say, and it is never evidence -- standing // comes only from the observed offer above. Only the aarch64 tag gets a boot line, and that line // also requires the known-machine tag, so the rendered edge offers nothing to an unknown machine -// or architecture. +// or architecture. The TFTP root it serves holds only that chainloader; kernels, initrds and images +// are fetched from the global HTTPS boot origin, never from the edge. data site_edge_arm64_tag: String = "efi-arm64" data site_edge_known_tag: String = "onboarding-known" -fn site_edge_proxy_dhcp_directives(network_address: Ipv4Address, roster: SiteEdgeRoster) -> List { +fn site_edge_proxy_dhcp_directives(network_address: Ipv4Address, roster: SiteEdgeRoster, tftp_directory: NonEmptyStr) -> List { concat( [ DnsmasqComment { text: join(["gunbc.site_uefi_arm64_pxe_edge: ProxyDHCP, UEFI ARM64 chainloader only: iPXE ", ipxe_build_target_path(target: ipxe_arm64_efi_snponly) as String, " per ", ipxe_chainloading_authority.uri.locator], "") }, DnsmasqPort { value: 0 }, + DnsmasqLogDhcp, DnsmasqDhcpRangeProxy { network_address: render_ipv4_address(addr: network_address) as String }, DnsmasqDhcpMatchSet { tag: site_edge_arm64_tag, option: "option:client-arch", value: to_string(uefi_arm64_client_arch_code) }, ], concat( map(roster.known_client_macs, m => DnsmasqDhcpHostSetTag { mac: render_mac_address(addr: m) as String, tag: site_edge_known_tag }), - [DnsmasqDhcpBootTags { first_tag: site_edge_arm64_tag, more_tags: [site_edge_known_tag], target: site_edge_offered_filename(roster: roster) as String, server_name: roster.tftp_server_name as String, server_address: render_ipv4_address(addr: roster.tftp_server_address) as String }], + [ + DnsmasqDhcpBootTags { first_tag: site_edge_arm64_tag, more_tags: [site_edge_known_tag], target: site_edge_offered_filename(roster: roster) as String, server_name: roster.tftp_server_name as String, server_address: render_ipv4_address(addr: roster.tftp_server_address) as String }, + DnsmasqEnableTftp, + DnsmasqTftpRoot { path: tftp_directory as String }, + ], ), ) } -// The config renders the SAME roster admission gates on: its known-machine tags are the roster's MACs -// and its boot line is the roster's derived transport, so the offered file and the checked file are -// one value. -fn site_edge_proxy_dhcp_config(network_address: Ipv4Address, roster: SiteEdgeRoster) -> String { - serialize_dnsmasq(directives: site_edge_proxy_dhcp_directives(network_address: network_address, roster: roster)) +// CONSUMER (§3c): gunbc.site_pxe_edge_converge site_pxe_edge_config_file renders this as the srv4 edge +// daemon's config once its gate opens (see the frontier row below for what that waits on). It renders the SAME roster admission gates on: its known-machine +// tags are the roster's MACs and its boot line is the roster's derived transport. tftp_directory is +// the serving host's filesystem path for dnsmasq's tftp-root, a host fact beside the roster's TFTP +// route identity. +fn site_edge_proxy_dhcp_config(network_address: Ipv4Address, roster: SiteEdgeRoster, tftp_directory: NonEmptyStr) -> String { + serialize_dnsmasq(directives: site_edge_proxy_dhcp_directives(network_address: network_address, roster: roster, tftp_directory: tftp_directory)) } fn site_edge_offered_filename(roster: SiteEdgeRoster) -> NonEmptyStr { @@ -308,21 +319,21 @@ fn site_edge_offered_filename(roster: SiteEdgeRoster) -> NonEmptyStr { // failure is a MacParseFailed every consumer must refuse on, never an empty roster. data first_onboarding_known_client_mac: MacParseResult = parse_mac_address(text: mtcollins1_host_nic_mac as String) -// The two production routes this module provides and nothing in this change executes, as counted +// The production routes this module provides and nothing yet executes, as counted // rows (enrolled in gunbc.census_closure_frontier) rather than prose. data site_uefi_arm64_pxe_edge_frontier_rows: List = [ frontier_row_decl( - ref: decl_ref(module_path: "gunbc.site_uefi_arm64_pxe_edge", decl_name: "site_edge_client_admission"), - reason: "no route binds an observed srv4 boot offer into gunbc.network_boot_delivery NetworkBootJoinInputs.site; the admission verdict is constructed only by witnesses over supplied rosters", + ref: decl_ref(module_path: "gunbc.site_uefi_arm64_pxe_edge", decl_name: "site_edge_proxy_dhcp_config"), + reason: "gunbc.site_pxe_edge_converge site_pxe_edge_config_file renders this config, but its converge gate is closed by construction (site_pxe_edge_chainloader_pin is SitePxeEdgeChainloaderUnpinned), so no dispatch has written it to srv4", dissolution: unbound_dissolution( - description: "dissolves when the first boot offer observed from the converged srv4 edge is minted as a SiteDhcpOfferObservation and bound through site_edge_client_admission into NetworkBootJoinInputs.site on a real boot attempt; a witness over supplied offers does not fire this" + description: "dissolves when the iPXE arm64 chainloader pin is grounded (a digested build pinned as SitePxeEdgeChainloaderPinned and present in the TFTP root) and a site_pxe_edge_converge dispatch writes this rendering to srv4 and reads it back; the converge code existing does not fire this" ) ), frontier_row_decl( - ref: decl_ref(module_path: "gunbc.site_uefi_arm64_pxe_edge", decl_name: "site_edge_proxy_dhcp_config"), - reason: "no production route writes the rendered ProxyDHCP config to the edge host in this change", + ref: decl_ref(module_path: "gunbc.site_uefi_arm64_pxe_edge", decl_name: "site_edge_client_admission"), + reason: "no route binds an observed srv4 boot offer into gunbc.network_boot_delivery NetworkBootJoinInputs.site; the admission verdict is constructed only by witnesses over supplied rosters", dissolution: unbound_dissolution( - description: "dissolves when gunbc.site_pxe_edge_converge site_pxe_edge_config_file writes this rendering to srv4 through the fleet-converge site_pxe_edge_converge mode (PXE-FABRIC 0C follow-up)" + description: "dissolves when the first boot offer observed from the converged srv4 edge is minted as a SiteDhcpOfferObservation and bound through site_edge_client_admission into NetworkBootJoinInputs.site on a real boot attempt; a witness over supplied offers does not fire this" ) ), ] diff --git a/dag/test/claim/fleet/site_pxe_edge_converge_witness_test.dag b/dag/test/claim/fleet/site_pxe_edge_converge_witness_test.dag new file mode 100644 index 00000000000..86c86f4ef5e --- /dev/null +++ b/dag/test/claim/fleet/site_pxe_edge_converge_witness_test.dag @@ -0,0 +1,439 @@ +module test.claim.fleet.site_pxe_edge_converge_witness + +import std.types { Bool, List, NonEmptyStr, String } +import std.content_hash { ContentHash, Sha256Digest, Sha256Hash } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import extdeps.toolchain.types { Architecture, Aarch64, X86_64 } +import extdeps.firmware.ipxe { IpxeBuildTarget, ipxe_arm64_efi_snponly } +import product.placement_supply { HostIdentity } +import gunbc.machine_intake_mtcollins1_netboot_client_observation { mtcollins1_host_nic_mac } +import gunbc.site_uefi_arm64_pxe_edge { first_onboarding_known_client_mac } +import extdeps.network.ipv4 { ipv4_address, Ipv4Parsed, Ipv4ParseFailed, Ipv4WrongOctetCount } +import extdeps.network.mac { Eui48Address, MacParsed, MacParseFailed, MacWrongOctetCount } +import gunbc.site_pxe_edge_converge { + SitePxeEdgeChainloaderPin, + SitePxeEdgeChainloaderPinned, + SitePxeEdgeChainloaderUnpinned, + SitePxeEdgeChainloaderBuildReceipt, + ChainloaderDigestObserved, + ChainloaderDigestUnobservable, + SitePxeEdgeProbeHeld, + SitePxeEdgeProbeObservedNegative, + SitePxeEdgeProbeUnobservable, + SitePxeEdgeProbeNotAttempted, + site_pxe_edge_desired_inputs, + SitePxeEdgeApply, + SitePxeEdgePostActivation, + PostActivationNotRead, + PostActivationDigestHeld, + PostActivationDigestDiffers, + PostActivationUnobservable, + site_pxe_edge_post_activation, + SitePxeEdgeSubjectAdmitted, + SitePxeEdgeSubjectRefused, + site_pxe_edge_chainloader_pin, + site_pxe_edge_chainloader_target, + site_pxe_edge_chainloader_path, + site_pxe_edge_gate_causes, + SitePxeEdgeGateCause, + SitePxeEdgeRun, + SitePxeEdgeObserveOnly, + site_pxe_edge_run_succeeded, + TftpServerAddressUnparseable, + SegmentNetworkUnparseable, + site_pxe_edge_segment_network, + site_pxe_edge_tftp_server_address, + KnownClientMacUnparseable, + ChainloaderUnpinned, + ChainloaderUnobservable, + ChainloaderDigestMismatch, + ChainloaderNotTheIpxeArm64Build, + ChainloaderBuildTargetNotIpxeArm64, + ChainloaderArchitectureNotAarch64, + DnsmasqBinaryAbsent, + DnsmasqBinaryUnobservable, + AdministratorPrivilegeUnobservable, + AdministratorPrivilegeRefused, + site_pxe_edge_digest_observation, + site_pxe_edge_desired_files, + site_pxe_edge_roster, + site_pxe_edge_unit_file, + site_pxe_edge_subject, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data hex_a: String = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +data digest_a: ContentHash = Sha256Hash(Sha256Digest { hex: hex_a }) + +data digest_b: ContentHash = Sha256Hash(Sha256Digest { hex: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }) + +fn build(filename: NonEmptyStr, target: IpxeBuildTarget, architecture: Architecture, digest: ContentHash) -> SitePxeEdgeChainloaderBuildReceipt { + SitePxeEdgeChainloaderBuildReceipt { + upstream_revision: "ipxe-fixture-revision", + build_target: target, + architecture: architecture, + digest: digest, + installed_filename: filename, + } +} + +fn pinned(digest: ContentHash) -> SitePxeEdgeChainloaderPin { + pinned_as(filename: "snponly.efi", digest: digest) +} + +fn pinned_as(filename: NonEmptyStr, digest: ContentHash) -> SitePxeEdgeChainloaderPin { + SitePxeEdgeChainloaderPinned { build: build(filename: filename, target: ipxe_arm64_efi_snponly, architecture: Aarch64, digest: digest) } +} + +fn known_macs() -> List { + match first_onboarding_known_client_mac { + MacParsed { address: a } => [a] + MacParseFailed { error: _ } => [] + } +} + +fn target_of(pin: SitePxeEdgeChainloaderPin) -> SitePxeEdgeChainloaderBuildReceipt { + match site_pxe_edge_chainloader_target(pin: pin) { + Present { value: t } => t + Absent => build(filename: "unreachable", target: ipxe_arm64_efi_snponly, architecture: Aarch64, digest: digest_b) + } +} + +test fn matching_digest_with_binary_and_privilege_opens_the_gate() -> Bool { + length(site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned(digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + )) == 0 +} + +// TODAY'S STATE, on the real row: nothing is pinned, so the gate is closed on the pin even with a +// file present and every other probe held. +test fn the_landed_pin_is_unpinned_and_closes_the_gate() -> Bool { + let causes = site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: site_pxe_edge_chainloader_pin, + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) + length(causes) == 1 && (match causes.first() { + Present { value: c } => match c { + ChainloaderUnpinned { obligation: _ } => true + TftpServerAddressUnparseable { error: _ } => false + SegmentNetworkUnparseable { error: _ } => false + KnownClientMacUnparseable { error: _ } => false + ChainloaderUnobservable { reason: _ } => false + ChainloaderDigestMismatch { observed: _, pinned: _ } => false + ChainloaderNotTheIpxeArm64Build { pinned: _, expected: _ } => false + ChainloaderBuildTargetNotIpxeArm64 { pinned: _, expected: _ } => false + ChainloaderArchitectureNotAarch64 { architecture: _ } => false + DnsmasqBinaryAbsent { reason: _ } => false + DnsmasqBinaryUnobservable { reason: _ } => false + AdministratorPrivilegeUnobservable { reason: _ } => false + AdministratorPrivilegeRefused { reason: _ } => false + } + Absent => false + }) +} + +test fn mismatched_digest_closes_the_gate() -> Bool { + site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned(digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_b }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) == [ChainloaderDigestMismatch { observed: digest_b, pinned: digest_a }] +} + +test fn a_pinned_file_that_is_not_the_ipxe_arm64_build_closes_the_gate() -> Bool { + site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned_as(filename: "grubnetaa64.efi", digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) == [ChainloaderNotTheIpxeArm64Build { pinned: "grubnetaa64.efi", expected: "snponly.efi" }] +} + +test fn missing_file_and_missing_dnsmasq_are_both_counted() -> Bool { + site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned(digest: digest_a), + chainloader: ChainloaderDigestUnobservable { reason: "no such file" }, + dnsmasq_binary: SitePxeEdgeProbeObservedNegative { reason: "absent" }, + privilege: SitePxeEdgeProbeHeld, + ) == [ChainloaderUnobservable { reason: "no such file" }, DnsmasqBinaryAbsent { reason: "absent" }] +} + +test fn sha256sum_line_parses_to_the_pinned_hash() -> Bool { + match site_pxe_edge_digest_observation(path: "/srv/x/snponly.efi", success: true, line: concat(hex_a, " /srv/x/snponly.efi\n")) { + ChainloaderDigestObserved { digest: d } => d == digest_a + ChainloaderDigestUnobservable { reason: _ } => false + } +} + +test fn failed_sha256sum_is_unobservable_not_a_digest() -> Bool { + match site_pxe_edge_digest_observation(path: "/srv/x/snponly.efi", success: false, line: "") { + ChainloaderDigestObserved { digest: _ } => false + ChainloaderDigestUnobservable { reason: _ } => true + } +} + +test fn desired_config_boots_only_known_arm64_from_the_edge_tftp_root() -> Bool { + let t = target_of(pin: pinned(digest: digest_a)) + match site_pxe_edge_desired_files(roster: site_pxe_edge_roster(target: t, known_client_macs: known_macs(), tftp_server_address: ipv4_address(octet1: 192, octet2: 168, octet3: 1, octet4: 196)), network: ipv4_address(octet1: 192, octet2: 168, octet3: 1, octet4: 0)).first() { + Absent => false + Present { value: cfg } => + cfg.path == "/etc/gunbc-site-pxe-edge.dnsmasq.conf" + && contains(cfg.content, "dhcp-range=192.168.1.0,proxy") + && contains(cfg.content, concat(concat("dhcp-host=", mtcollins1_host_nic_mac as String), ",set:onboarding-known")) + && contains(cfg.content, "dhcp-boot=tag:efi-arm64,tag:onboarding-known,snponly.efi,srv4,192.168.1.196") + && contains(cfg.content, "tftp-root=/srv/gunbc-site-pxe-edge/tftp") + && length(split(cfg.content, "dhcp-boot=")) == 2 + && site_pxe_edge_chainloader_path(target: t) as String == "/srv/gunbc-site-pxe-edge/tftp/snponly.efi" + } +} + +test fn unit_runs_dnsmasq_on_the_edge_config() -> Bool { + let u = site_pxe_edge_unit_file() + u.path == "/etc/systemd/system/gunbc-site-pxe-edge.service" + && contains(u.content, "ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/etc/gunbc-site-pxe-edge.dnsmasq.conf") + && contains(u.content, "WantedBy=multi-user.target") +} + +test fn only_srv4_is_the_edge_host() -> Bool { + (match site_pxe_edge_subject(host: "srv3" as HostIdentity) { + SitePxeEdgeSubjectRefused { cause: _ } => true + SitePxeEdgeSubjectAdmitted { target: _ } => false + }) && (match site_pxe_edge_subject(host: "srv4" as HostIdentity) { + SitePxeEdgeSubjectAdmitted { target: t } => t.principal as String == "ubuntu" + SitePxeEdgeSubjectRefused { cause: _ } => false + }) +} + +test fn an_unparseable_roster_closes_the_gate() -> Bool { + site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: MacParseFailed { error: MacWrongOctetCount { observed_count: 5 } }, + pin: pinned(digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) == [KnownClientMacUnparseable { error: MacWrongOctetCount { observed_count: 5 } }] +} + +// THE REFUSAL'S ADVICE IS FOLLOWABLE: pin exactly the `expected` the not-the-build refusal reports and +// that cause is gone. Paired with a_pinned_file_that_is_not_the_ipxe_arm64_build_closes_the_gate. +test fn pinning_the_reported_expected_filename_clears_the_cause() -> Bool { + let refused = site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned_as(filename: "grubnetaa64.efi", digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) + match refused.first() { + Absent => false + Present { value: c } => + match c { + ChainloaderNotTheIpxeArm64Build { pinned: _, expected: e } => + length(site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned_as(filename: e, digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + )) == 0 + TftpServerAddressUnparseable { error: _ } => false + SegmentNetworkUnparseable { error: _ } => false + KnownClientMacUnparseable { error: _ } => false + ChainloaderUnpinned { obligation: _ } => false + ChainloaderUnobservable { reason: _ } => false + ChainloaderDigestMismatch { observed: _, pinned: _ } => false + ChainloaderBuildTargetNotIpxeArm64 { pinned: _, expected: _ } => false + ChainloaderArchitectureNotAarch64 { architecture: _ } => false + DnsmasqBinaryAbsent { reason: _ } => false + DnsmasqBinaryUnobservable { reason: _ } => false + AdministratorPrivilegeUnobservable { reason: _ } => false + AdministratorPrivilegeRefused { reason: _ } => false + } + } +} + +fn observe_run(causes: List) -> SitePxeEdgeRun { + observe_run_with_unit(causes: causes, unit_active: SitePxeEdgeProbeObservedNegative { reason: "ActiveState=inactive" }) +} + +fn observe_run_with_unit(causes: List, unit_active: SitePxeEdgeProbe) -> SitePxeEdgeRun { + SitePxeEdgeRun { + mode: SitePxeEdgeObserveOnly, + gate_causes: causes, + files: [], + activation: SitePxeEdgeProbeNotAttempted { reason: "observe-only" }, + unit_active: unit_active, + post_activation: PostActivationNotRead { reason: "observe-only" }, + } +} + +fn apply_run(post: SitePxeEdgePostActivation) -> SitePxeEdgeRun { + SitePxeEdgeRun { + mode: SitePxeEdgeApply, + gate_causes: [], + files: [], + activation: SitePxeEdgeProbeHeld, + unit_active: SitePxeEdgeProbeHeld, + post_activation: post, + } +} + +// OBSERVE REPORTS FINDINGS BUT REFUSES WHAT IT COULD NOT SEE: an unpinned chainloader is a finding +// and observe succeeds; an undigestible chainloader or refused privilege is a failure to observe. +test fn observe_succeeds_on_findings_and_refuses_what_it_could_not_see() -> Bool { + site_pxe_edge_run_succeeded(run: observe_run(causes: [ChainloaderUnpinned { obligation: "pin it" }])) + && !site_pxe_edge_run_succeeded(run: observe_run(causes: [ChainloaderUnobservable { reason: "sha256sum exit=1" }])) + && !site_pxe_edge_run_succeeded(run: observe_run(causes: [AdministratorPrivilegeUnobservable { reason: "ssh leg refused" }])) + && site_pxe_edge_run_succeeded(run: observe_run(causes: [AdministratorPrivilegeRefused { reason: "non-interactive sudo exit=1" }])) +} + +// THE RENDERED SEGMENT IS THE FLEET MODEL'S: site_pxe_edge_segment_network parses from +// gunbc.fleet_intent_network operator_lan_segment, and the config built from it renders that network +// in its dhcp-range line. +test fn the_rendered_dhcp_range_is_the_parsed_fleet_segment() -> Bool { + match site_pxe_edge_segment_network { + Ipv4ParseFailed { error: _ } => false + Ipv4Parsed { address: network } => { + let t = target_of(pin: pinned(digest: digest_a)) + match site_pxe_edge_desired_files(roster: site_pxe_edge_roster(target: t, known_client_macs: known_macs(), tftp_server_address: ipv4_address(octet1: 192, octet2: 168, octet3: 1, octet4: 196)), network: network).first() { + Absent => false + Present { value: cfg } => + network == ipv4_address(octet1: 192, octet2: 168, octet3: 1, octet4: 0) + && contains(cfg.content, "dhcp-range=192.168.1.0,proxy") + } + } + } +} + +// A FAILED TFTP-SERVER PARSE SUPPLIED AT THE GATE CLOSES IT WITH EXACTLY THAT TYPED CAUSE. +test fn an_unparseable_tftp_server_address_closes_the_gate() -> Bool { + site_pxe_edge_gate_causes( + tftp_server: Ipv4ParseFailed { error: Ipv4WrongOctetCount { observed_count: 3 } }, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned(digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) == [TftpServerAddressUnparseable { error: Ipv4WrongOctetCount { observed_count: 3 } }] +} + +// A FAILED SEGMENT PARSE SUPPLIED AT THE GATE CLOSES IT WITH EXACTLY THAT TYPED CAUSE. +test fn an_unparseable_segment_network_closes_the_gate() -> Bool { + site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: Ipv4ParseFailed { error: Ipv4WrongOctetCount { observed_count: 1 } }, + roster: first_onboarding_known_client_mac, + pin: pinned(digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) == [SegmentNetworkUnparseable { error: Ipv4WrongOctetCount { observed_count: 1 } }] +} + +// PROVENANCE: an x86 build installed under the expected basename with a digest that agrees is still +// refused -- the receipt's architecture is checked, not only the filename and digest. +test fn a_non_arm64_build_with_the_expected_name_and_digest_is_refused() -> Bool { + site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: SitePxeEdgeChainloaderPinned { build: build(filename: "snponly.efi", target: ipxe_arm64_efi_snponly, architecture: X86_64, digest: digest_a) }, + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) == [ChainloaderArchitectureNotAarch64 { architecture: X86_64 }] +} + +// PROVENANCE: a receipt naming a different iPXE build target is refused even when its installed name and +// digest agree. +test fn a_different_ipxe_build_target_is_refused() -> Bool { + let x86_target = IpxeBuildTarget { platform: "bin-x86_64-efi", image: "snponly.efi" } + site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: SitePxeEdgeChainloaderPinned { build: build(filename: "snponly.efi", target: x86_target, architecture: Aarch64, digest: digest_a) }, + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) == [ChainloaderBuildTargetNotIpxeArm64 { pinned: x86_target, expected: ipxe_arm64_efi_snponly }] +} + +// POST-ACTIVATION BYTES: the gate's digest agreed, but the file digested after restart differs, so the +// apply is refused; the same run with the post-activation digest holding succeeds. +test fn a_post_activation_digest_that_differs_refuses_the_apply() -> Bool { + site_pxe_edge_post_activation(pin: pinned(digest: digest_a), observation: ChainloaderDigestObserved { digest: digest_b }) + == PostActivationDigestDiffers { observed: digest_b, pinned: digest_a } + && !site_pxe_edge_run_succeeded(run: apply_run(post: site_pxe_edge_post_activation(pin: pinned(digest: digest_a), observation: ChainloaderDigestObserved { digest: digest_b }))) + && site_pxe_edge_run_succeeded(run: apply_run(post: site_pxe_edge_post_activation(pin: pinned(digest: digest_a), observation: ChainloaderDigestObserved { digest: digest_a }))) + && !site_pxe_edge_run_succeeded(run: apply_run(post: PostActivationUnobservable { reason: "sha256sum exit=1" })) +} + +// OBSERVE SEMANTICS: a probe that ran and found no (dnsmasq absent, unit inactive) is a finding and +// observe succeeds; a probe that could not run (dnsmasq or the unit read unobservable) refuses observe. +test fn observe_keeps_observed_negatives_and_refuses_unobservable_probes() -> Bool { + site_pxe_edge_run_succeeded(run: observe_run(causes: [DnsmasqBinaryAbsent { reason: "test -x dnsmasq exit=1" }])) + && !site_pxe_edge_run_succeeded(run: observe_run(causes: [DnsmasqBinaryUnobservable { reason: "ssh leg refused" }])) + && !site_pxe_edge_run_succeeded(run: observe_run(causes: [AdministratorPrivilegeUnobservable { reason: "ssh leg refused" }])) + && site_pxe_edge_run_succeeded(run: observe_run_with_unit(causes: [], unit_active: SitePxeEdgeProbeObservedNegative { reason: "ActiveState=inactive" })) + && !site_pxe_edge_run_succeeded(run: observe_run_with_unit(causes: [], unit_active: SitePxeEdgeProbeUnobservable { reason: "ssh leg refused" })) +} + +// A sudo -n THAT RAN AND ANSWERED NO IS A FINDING, NOT A FAILURE TO OBSERVE: observe reports it and +// still succeeds, while a privilege leg that never executed refuses the observe run. +test fn an_executed_privilege_refusal_is_a_finding_observe_still_succeeds() -> Bool { + site_pxe_edge_run_succeeded(run: observe_run(causes: [AdministratorPrivilegeRefused { reason: "non-interactive sudo exit=1 sudo: a password is required" }])) + && !site_pxe_edge_run_succeeded(run: observe_run(causes: [AdministratorPrivilegeUnobservable { reason: "remote words refused" }])) +} + +// GATE-OPEN IMPLIES THE CONFIG IS DERIVABLE: the desired inputs are derived once and read by the file +// list, so an apply cannot pass a clean gate and still narrow to the unit file alone. The same four +// facts that leave the gate empty leave the inputs Present, and a failure in any one leaves both closed. +test fn an_open_gate_implies_the_desired_config_inputs_exist() -> Bool { + let open_causes = site_pxe_edge_gate_causes( + tftp_server: site_pxe_edge_tftp_server_address, + segment: site_pxe_edge_segment_network, + roster: first_onboarding_known_client_mac, + pin: pinned(digest: digest_a), + chainloader: ChainloaderDigestObserved { digest: digest_a }, + dnsmasq_binary: SitePxeEdgeProbeHeld, + privilege: SitePxeEdgeProbeHeld, + ) + let open_inputs = site_pxe_edge_desired_inputs(pin: pinned(digest: digest_a), roster: first_onboarding_known_client_mac, tftp_server: site_pxe_edge_tftp_server_address, segment: site_pxe_edge_segment_network) + let unpinned_inputs = site_pxe_edge_desired_inputs(pin: site_pxe_edge_chainloader_pin, roster: first_onboarding_known_client_mac, tftp_server: site_pxe_edge_tftp_server_address, segment: site_pxe_edge_segment_network) + let unparsed_inputs = site_pxe_edge_desired_inputs(pin: pinned(digest: digest_a), roster: MacParseFailed { error: MacWrongOctetCount { observed_count: 5 } }, tftp_server: site_pxe_edge_tftp_server_address, segment: site_pxe_edge_segment_network) + length(open_causes) == 0 + && (match open_inputs { Present { value: _ } => true Absent => false }) + && (match unpinned_inputs { Present { value: _ } => false Absent => true }) + && (match unparsed_inputs { Present { value: _ } => false Absent => true }) +} diff --git a/dag/test/claim/site_uefi_arm64_pxe_edge_witness_test.dag b/dag/test/claim/site_uefi_arm64_pxe_edge_witness_test.dag index d44a5b0da5e..7ec75d079c3 100644 --- a/dag/test/claim/site_uefi_arm64_pxe_edge_witness_test.dag +++ b/dag/test/claim/site_uefi_arm64_pxe_edge_witness_test.dag @@ -1,5 +1,6 @@ module test.claim.site_uefi_arm64_pxe_edge_witness +import extdeps.network.ipv4 { ipv4_address } import std.types { Bool, List, NonEmptyStr, String } import extdeps.network.mac { Eui48Address, MacParsed, MacParseFailed, mac_address } import std.content_hash { content_hash_of_value } @@ -8,7 +9,6 @@ import std.decl_ref { DeclarationRef, WholeDeclaration } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import extdeps.toolchain.types { Aarch64, X86_64 } import product.placement_supply { SiteIdentity } -import extdeps.network.ipv4 { ipv4_address } import extdeps.firmware.ipxe { ipxe_arm64_efi_snponly } import extdeps.provisioning.network_boot { ProxyDhcp } import gunbc.network_boot_delivery { @@ -248,13 +248,15 @@ test fn the_landed_roster_parses_to_the_measured_host_nic() -> Bool { } test fn proxy_dhcp_config_boots_only_known_arm64() -> Bool { - let cfg = site_edge_proxy_dhcp_config(network_address: ipv4_address(octet1: 192, octet2: 168, octet3: 1, octet4: 0), roster: roster_of(macs: [known_mac])) + let cfg = site_edge_proxy_dhcp_config(network_address: ipv4_address(octet1: 192, octet2: 168, octet3: 1, octet4: 0), roster: roster_of(macs: [known_mac]), tftp_directory: "/srv/edge-tftp") contains(cfg, "port=0") && contains(cfg, "dhcp-range=192.168.1.0,proxy") && contains(cfg, "dhcp-match=set:efi-arm64,option:client-arch,11") && contains(cfg, "dhcp-host=02:00:00:00:0c:01,set:onboarding-known") && contains(cfg, "dhcp-boot=tag:efi-arm64,tag:onboarding-known,snponly.efi,srv4,192.168.1.196") && contains(cfg, "iPXE bin-arm64-efi/snponly.efi per ipxe.org/howto/chainloading") + && contains(cfg, "enable-tftp") + && contains(cfg, "tftp-root=/srv/edge-tftp") && length(split(cfg, "dhcp-boot=")) == 2 }