diff --git a/dag/extdeps/bmc/openbmc_fan_control.dag b/dag/extdeps/bmc/openbmc_fan_control.dag index 7c784c2947e..fc707076d23 100644 --- a/dag/extdeps/bmc/openbmc_fan_control.dag +++ b/dag/extdeps/bmc/openbmc_fan_control.dag @@ -27,8 +27,10 @@ import extdeps.tools.jq { JqProgram, JqProgramExit, JqRawOutput, + JqBinding, JqJsonBinding, JqBindingName, + JqJsonOutput, JqLastResultExit, jq_authority, JqOutcome, @@ -73,9 +75,6 @@ import extdeps.bmc.openbmc_operation { OpenBmcDiskUsageKib, OpenBmcFanConfigDesired, OpenBmcFanConfigQuery, - OpenBmcFanConfigScalar, - OpenBmcFanConfigValidate, - OpenBmcFanConfigVerify, OpenBmcFanControllerCount, OpenBmcFanControllerNameAt, OpenBmcFanInputAt, @@ -201,8 +200,8 @@ type OpenBmcStepwiseController { outputs: List } -fn openbmc_fan_config_desired_json(desired: OpenBmcFanConfigDesired) -> String { - serialize_json(v: json_object(members: [ +fn openbmc_fan_config_desired_value(desired: OpenBmcFanConfigDesired) -> JsonValue { + json_object(members: [ json_kv(key: "minimum", value: json_int(n: percent_count(desired.minimum_duty))), json_kv(key: "zone_failsafe_duty", value: json_int(n: percent_count(desired.zone_failsafe_duty))), json_kv(key: "sensor_failsafe", value: json_int(n: percent_count(desired.sensor_failsafe_duty))), @@ -226,7 +225,11 @@ fn openbmc_fan_config_desired_json(desired: OpenBmcFanConfigDesired) -> String { json_string(s: value as String) )), ), - ])) + ]) +} + +fn openbmc_fan_config_desired_json(desired: OpenBmcFanConfigDesired) -> String { + serialize_json(v: openbmc_fan_config_desired_value(desired: desired)) } type OpenBmcFanController { @@ -538,136 +541,6 @@ fn openbmc_execute( sensor_path: sensor_path, ) } - OpenBmcFanConfigScalar { config_path, query } => - match query { - OpenBmcMinimumDuty => - openbmc.PasswordSshTransport.FanMinimumDuty( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcZoneFailsafeDuty => - openbmc.PasswordSshTransport.FanZoneFailsafeDuty( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcSensorFailsafeDuty => - openbmc.PasswordSshTransport.FanSensorFailsafeDuty( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcPositiveHysteresis => - openbmc.PasswordSshTransport.FanPositiveHysteresis( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcNegativeHysteresis => - openbmc.PasswordSshTransport.FanNegativeHysteresis( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcReadingCount => - openbmc.PasswordSshTransport.FanReadingCount( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcOutputCount => - openbmc.PasswordSshTransport.FanOutputCount( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcReadingAt { index } => - openbmc.PasswordSshTransport.FanReadingAt( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - index: index, - ) - OpenBmcOutputAt { index } => - openbmc.PasswordSshTransport.FanOutputAt( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - index: index, - ) - OpenBmcFanControllerCount => - openbmc.PasswordSshTransport.FanControllerCount( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcFanControllerNameAt { index } => - openbmc.PasswordSshTransport.FanControllerNameAt( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - index: index, - ) - OpenBmcFanInputCountAt { fan_index } => - openbmc.PasswordSshTransport.FanInputCountAt( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - fan_index: fan_index, - ) - OpenBmcFanInputAt { fan_index, input_index } => - openbmc.PasswordSshTransport.FanInputAt( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - fan_index: fan_index, - input_index: input_index, - ) - } - OpenBmcFanConfigValidate { config_path } => - openbmc.PasswordSshTransport.FanConfigValidate( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - ) - OpenBmcFanConfigVerify { config_path, desired } => - openbmc.PasswordSshTransport.FanConfigVerify( - host: session.host, - username: session.username, - password: session.password, - known_hosts_path: session.known_hosts_path, - config_path: config_path, - desired_json: openbmc_fan_config_desired_json(desired: desired), - ) OpenBmcPathIsWritable { path } => openbmc.PasswordSshTransport.PathIsWritable( host: session.host, @@ -1574,18 +1447,134 @@ fn openbmc_fan_config_stepwise_count_result(outcome: JqOutcome) -> OpenBmcIntege } } -fn openbmc_fan_config_raw( - session: PasswordSshSession, - config_path: NonEmptyStr, +// THE FAN-CONFIG SCALAR POPULATION, DISSOLVED AT THE ROOT. The raw sshpass/ssh argv rows in +// openbmc_password_ssh_transport (FanMinimumDuty through FanInputAt) are gone; each query now +// lowers to the SAME JqInvocation the StepwiseCount migration proved (DESIGN 18): a semantic +// program from the data row below -- byte-identical to the old argv program word -- the binding +// value as canonical JSON (json_int lexeme = the decimal word the old {index} substitution +// spelled), the file operand after the program, and a JqOutcome decode that reads no success: +// Bool. The transport sees one grammar-owned command string via RunCommand, never argv. +data openbmc_fan_config_minimum_duty_program: String = "[.zones[] | select(any(.pids[]; .name == \"TEMP_SOC\" and .type == \"stepwise\"))][0].minThermalOutput | floor" +data openbmc_fan_config_zone_failsafe_duty_program: String = "[.zones[] | select(any(.pids[]; .name == \"TEMP_SOC\" and .type == \"stepwise\"))][0].failsafePercent | floor" +data openbmc_fan_config_sensor_failsafe_duty_program: String = "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].failsafePercent | floor" +data openbmc_fan_config_positive_hysteresis_program: String = "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.positiveHysteresis | floor" +data openbmc_fan_config_negative_hysteresis_program: String = "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.negativeHysteresis | floor" +data openbmc_fan_config_reading_count_program: String = "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.reading | length" +data openbmc_fan_config_output_count_program: String = "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.output | length" +data openbmc_fan_config_reading_at_program: String = "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.reading | to_entries | sort_by(.key | tonumber) | .[$index].value | floor" +data openbmc_fan_config_output_at_program: String = "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.output | to_entries | sort_by(.key | tonumber) | .[$index].value | floor" +data openbmc_fan_config_controller_count_program: String = "[.zones[].pids[] | select(.type == \"fan\")] | length" +data openbmc_fan_config_controller_name_at_program: String = "[.zones[].pids[] | select(.type == \"fan\")][$index].name" +data openbmc_fan_config_input_count_at_program: String = "[.zones[].pids[] | select(.type == \"fan\")][$fan_index].inputs | length" +data openbmc_fan_config_input_at_program: String = "[.zones[].pids[] | select(.type == \"fan\")][$fan_index].inputs[$input_index]" +data openbmc_fan_config_validate_program: String = "empty" +data openbmc_fan_config_verify_program: String = "([.zones[] | select(any(.pids[]; .name == \"TEMP_SOC\" and .type == \"stepwise\"))]) as $zones | ([.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")]) as $pids | ([.zones[].pids[] | select(.name == $desired.fan_pid_entry and .type == \"fan\")]) as $fans | (($zones | length) == 1) and (($pids | length) == 1) and (($fans | length) == 1) and ($zones[0].minThermalOutput == $desired.minimum) and ($zones[0].failsafePercent == $desired.zone_failsafe_duty) and ($pids[0].failsafePercent == $desired.sensor_failsafe) and ($pids[0].pid.positiveHysteresis == $desired.positive_hysteresis) and ($pids[0].pid.negativeHysteresis == $desired.negative_hysteresis) and ($pids[0].pid.reading == ($desired.readings | to_entries | map(\{key: (.key | tostring), value: .value}) | from_entries)) and ($pids[0].pid.output == ($desired.outputs | to_entries | map(\{key: (.key | tostring), value: .value}) | from_entries)) and ($fans[0].inputs == $desired.fan_inputs)" + +fn openbmc_fan_config_scalar_program(query: OpenBmcFanConfigQuery) -> String { + match query { + OpenBmcMinimumDuty => openbmc_fan_config_minimum_duty_program + OpenBmcZoneFailsafeDuty => openbmc_fan_config_zone_failsafe_duty_program + OpenBmcSensorFailsafeDuty => openbmc_fan_config_sensor_failsafe_duty_program + OpenBmcPositiveHysteresis => openbmc_fan_config_positive_hysteresis_program + OpenBmcNegativeHysteresis => openbmc_fan_config_negative_hysteresis_program + OpenBmcReadingCount => openbmc_fan_config_reading_count_program + OpenBmcOutputCount => openbmc_fan_config_output_count_program + OpenBmcReadingAt { index: index } => openbmc_fan_config_reading_at_program + OpenBmcOutputAt { index: index } => openbmc_fan_config_output_at_program + OpenBmcFanControllerCount => openbmc_fan_config_controller_count_program + OpenBmcFanControllerNameAt { index: index } => openbmc_fan_config_controller_name_at_program + OpenBmcFanInputCountAt { fan_index: fan_index } => openbmc_fan_config_input_count_at_program + OpenBmcFanInputAt { fan_index: fan_index, input_index: input_index } => openbmc_fan_config_input_at_program + } +} + +fn openbmc_fan_config_scalar_bindings(query: OpenBmcFanConfigQuery) -> List { + match query { + OpenBmcMinimumDuty => + Empty + OpenBmcZoneFailsafeDuty => + Empty + OpenBmcSensorFailsafeDuty => + Empty + OpenBmcPositiveHysteresis => + Empty + OpenBmcNegativeHysteresis => + Empty + OpenBmcReadingCount => + Empty + OpenBmcOutputCount => + Empty + OpenBmcReadingAt { index: index } => + [JqJsonBinding { name: JqBindingName { name: "index" }, value: json_int(n: index) }] + OpenBmcOutputAt { index: index } => + [JqJsonBinding { name: JqBindingName { name: "index" }, value: json_int(n: index) }] + OpenBmcFanControllerCount => + Empty + OpenBmcFanControllerNameAt { index: index } => + [JqJsonBinding { name: JqBindingName { name: "index" }, value: json_int(n: index) }] + OpenBmcFanInputCountAt { fan_index: fan_index } => + [JqJsonBinding { name: JqBindingName { name: "fan_index" }, value: json_int(n: fan_index) }] + OpenBmcFanInputAt { fan_index: fan_index, input_index: input_index } => + [ + JqJsonBinding { name: JqBindingName { name: "fan_index" }, value: json_int(n: fan_index) }, + JqJsonBinding { name: JqBindingName { name: "input_index" }, value: json_int(n: input_index) }, + ] + } +} + +fn openbmc_fan_config_scalar_invocation( query: OpenBmcFanConfigQuery, -) -> PasswordSshExecResult { - openbmc_execute( - session: session, - operation: OpenBmcFanConfigScalar { - config_path: config_path, - query: query, - }, - ) + config_path: NonEmptyStr, +) -> JqInvocation { + JqInvocation { + program: JqProgram { source: openbmc_fan_config_scalar_program(query: query) }, + bindings: openbmc_fan_config_scalar_bindings(query: query), + input: JqInputFile { path: config_path }, + output_encoding: JqRawOutput, + exit_policy: JqLastResultExit, + } +} + +// The decode labels the refusal after the queried field, so a consumer can say WHICH reading is +// absent or malformed without re-deriving the query. The StepwiseCount decode labels itself the +// same way ("OpenBMC fan stepwise count is absent"). +fn openbmc_fan_config_scalar_label(query: OpenBmcFanConfigQuery) -> String { + match query { + OpenBmcMinimumDuty => "minimum duty" + OpenBmcZoneFailsafeDuty => "zone failsafe duty" + OpenBmcSensorFailsafeDuty => "sensor failsafe duty" + OpenBmcPositiveHysteresis => "positive hysteresis" + OpenBmcNegativeHysteresis => "negative hysteresis" + OpenBmcReadingCount => "reading count" + OpenBmcOutputCount => "output count" + OpenBmcReadingAt { index: index } => "reading" + OpenBmcOutputAt { index: index } => "output" + OpenBmcFanControllerCount => "fan controller count" + OpenBmcFanControllerNameAt { index: index } => "fan controller name" + OpenBmcFanInputCountAt { fan_index: fan_index } => "fan input count" + OpenBmcFanInputAt { fan_index: fan_index, input_index: input_index } => "fan input" + } +} + +// THE JqOutcome DECODES, PER SHAPE. JqExecutionRefused carries jq's stderr (the transport and +// the execution refused); JqOutputAbsent is the exit-4 / no-output absence; JqOutputPresent +// carries the -r raw text. No success: Bool anywhere on this path. +fn openbmc_fan_config_integer_result( + outcome: JqOutcome, + label: String, +) -> OpenBmcIntegerResult { + match outcome { + JqExecutionRefused { exit_code: _, stderr: stderr } => + OpenBmcIntegerRefused { reason: stderr } + JqOutputAbsent => + OpenBmcIntegerRefused { reason: concat("OpenBMC fan config ", label, " is absent") } + JqOutputPresent { text: text } => + match parse_int(s: trim(text)) { + Absent => + OpenBmcIntegerRefused { reason: concat("OpenBMC fan config ", label, " is not an integer") } + Present { value } => OpenBmcIntegerObserved { value: value } + } + } } fn openbmc_fan_config_integer( @@ -1593,18 +1582,44 @@ fn openbmc_fan_config_integer( config_path: NonEmptyStr, query: OpenBmcFanConfigQuery, ) -> OpenBmcIntegerResult { - let result = openbmc_fan_config_raw( - session: session, - config_path: config_path, - query: query, - ) - if !result.success { - OpenBmcIntegerRefused { reason: result.stderr } - } else { - match parse_int(s: trim(result.stdout)) { - Absent => OpenBmcIntegerRefused { reason: "OpenBMC integer response is invalid" } - Present { value } => OpenBmcIntegerObserved { value: value } - } + match jq_invocation_process_plan( + invocation: openbmc_fan_config_scalar_invocation(query: query, config_path: config_path), + ) { + Rejected { diagnostics: _ } => + OpenBmcIntegerRefused { reason: "OpenBMC fan config scalar query could not be lowered to a jq invocation" } + Accepted { value: plan, diagnostics: _ } => + match openbmc_remote_jq_execute(session: session, plan: plan) { + OpenBmcRemoteJqRefused { cause: cause } => + OpenBmcIntegerRefused { reason: openbmc_remote_jq_refusal_text(cause: cause) } + OpenBmcRemoteJqExecuted { observation: observation } => + openbmc_fan_config_integer_result( + outcome: jq_classify_observation(plan: plan, observation: observation), + label: openbmc_fan_config_scalar_label(query: query), + ) + } + } +} + +// Under -r a null last result prints the LITERAL "null" (exit 1 is a legitimate value, not an +// error). A name query that produced null means the controller is missing, not a controller +// NAMED "null" -- the same null-is-absence stance the ObjectMapper service-at pin takes (PR +// #10148 review), expressed here in the decode because the program text stays byte-identical to +// the dissolved raw argv. +fn openbmc_fan_config_string_result( + outcome: JqOutcome, + label: String, +) -> OpenBmcStringResult { + match outcome { + JqExecutionRefused { exit_code: _, stderr: stderr } => + OpenBmcStringRefused { reason: stderr } + JqOutputAbsent => + OpenBmcStringRefused { reason: concat("OpenBMC fan config ", label, " is absent") } + JqOutputPresent { text: text } => + match trim(text) { + "" => OpenBmcStringRefused { reason: concat("OpenBMC fan config ", label, " is blank") } + "null" => OpenBmcStringRefused { reason: concat("OpenBMC fan config ", label, " is absent") } + other => OpenBmcStringObserved { value: other as NonEmptyStr } + } } } @@ -1613,15 +1628,78 @@ fn openbmc_fan_config_string( config_path: NonEmptyStr, query: OpenBmcFanConfigQuery, ) -> OpenBmcStringResult { - let result = openbmc_fan_config_raw( - session: session, - config_path: config_path, - query: query, - ) - if !result.success || trim(result.stdout) == "" { - OpenBmcStringRefused { reason: result.stderr } - } else { - OpenBmcStringObserved { value: trim(result.stdout) as NonEmptyStr } + match jq_invocation_process_plan( + invocation: openbmc_fan_config_scalar_invocation(query: query, config_path: config_path), + ) { + Rejected { diagnostics: _ } => + OpenBmcStringRefused { reason: "OpenBMC fan config scalar query could not be lowered to a jq invocation" } + Accepted { value: plan, diagnostics: _ } => + match openbmc_remote_jq_execute(session: session, plan: plan) { + OpenBmcRemoteJqRefused { cause: cause } => + OpenBmcStringRefused { reason: openbmc_remote_jq_refusal_text(cause: cause) } + OpenBmcRemoteJqExecuted { observation: observation } => + openbmc_fan_config_string_result( + outcome: jq_classify_observation(plan: plan, observation: observation), + label: openbmc_fan_config_scalar_label(query: query), + ) + } + } +} + +// VALIDATE AND VERIFY READ THE EXIT CONTRACT, NOT success: Bool. Validation runs `jq empty`: +// JqProgramExit exits 0 with no output on a parseable config, so the absence IS the success +// (present-or-absent => valid; a jq error is the refusal). Verification runs `jq -e` on the +// boolean and-chain: JqLastResultExit reports 0 for true and 1 for false, both as Present text; +// only a jq error refuses. +fn openbmc_fan_config_validate_invocation(config_path: NonEmptyStr) -> JqInvocation { + JqInvocation { + program: JqProgram { source: openbmc_fan_config_validate_program }, + bindings: Empty, + input: JqInputFile { path: config_path }, + output_encoding: JqJsonOutput, + exit_policy: JqProgramExit, + } +} + +fn openbmc_fan_config_validate_result(outcome: JqOutcome) -> OpenBmcBoolResult { + match outcome { + JqExecutionRefused { exit_code: _, stderr: stderr } => + OpenBmcBoolRefused { reason: stderr } + JqOutputAbsent => OpenBmcBoolObserved { value: true } + JqOutputPresent { text: _ } => OpenBmcBoolObserved { value: true } + } +} + +fn openbmc_fan_config_verify_invocation( + config_path: NonEmptyStr, + desired: OpenBmcFanConfigDesired, +) -> JqInvocation { + JqInvocation { + program: JqProgram { source: openbmc_fan_config_verify_program }, + bindings: [ + JqJsonBinding { + name: JqBindingName { name: "desired" }, + value: openbmc_fan_config_desired_value(desired: desired), + } + ], + input: JqInputFile { path: config_path }, + output_encoding: JqJsonOutput, + exit_policy: JqLastResultExit, + } +} + +fn openbmc_fan_config_verify_result(outcome: JqOutcome) -> OpenBmcBoolResult { + match outcome { + JqExecutionRefused { exit_code: _, stderr: stderr } => + OpenBmcBoolRefused { reason: stderr } + JqOutputAbsent => + OpenBmcBoolRefused { reason: "OpenBMC fan config verification produced no result" } + JqOutputPresent { text: text } => + match trim(text) { + "true" => OpenBmcBoolObserved { value: true } + "false" => OpenBmcBoolObserved { value: false } + other => OpenBmcBoolRefused { reason: other } + } } } @@ -2031,25 +2109,44 @@ fn openbmc_fan_config_observe( fn openbmc_fan_config_validate( session: PasswordSshSession, config_path: NonEmptyStr, -) -> PasswordSshExecResult { - openbmc_execute( - session: session, - operation: OpenBmcFanConfigValidate { config_path: config_path }, - ) +) -> OpenBmcBoolResult { + match jq_invocation_process_plan( + invocation: openbmc_fan_config_validate_invocation(config_path: config_path), + ) { + Rejected { diagnostics: _ } => + OpenBmcBoolRefused { reason: "OpenBMC fan config validation could not be lowered to a jq invocation" } + Accepted { value: plan, diagnostics: _ } => + match openbmc_remote_jq_execute(session: session, plan: plan) { + OpenBmcRemoteJqRefused { cause: cause } => + OpenBmcBoolRefused { reason: openbmc_remote_jq_refusal_text(cause: cause) } + OpenBmcRemoteJqExecuted { observation: observation } => + openbmc_fan_config_validate_result( + outcome: jq_classify_observation(plan: plan, observation: observation), + ) + } + } } fn openbmc_fan_config_verify( session: PasswordSshSession, config_path: NonEmptyStr, desired: OpenBmcFanConfigDesired, -) -> PasswordSshExecResult { - openbmc_execute( - session: session, - operation: OpenBmcFanConfigVerify { - config_path: config_path, - desired: desired, - }, - ) +) -> OpenBmcBoolResult { + match jq_invocation_process_plan( + invocation: openbmc_fan_config_verify_invocation(config_path: config_path, desired: desired), + ) { + Rejected { diagnostics: _ } => + OpenBmcBoolRefused { reason: "OpenBMC fan config verification could not be lowered to a jq invocation" } + Accepted { value: plan, diagnostics: _ } => + match openbmc_remote_jq_execute(session: session, plan: plan) { + OpenBmcRemoteJqRefused { cause: cause } => + OpenBmcBoolRefused { reason: openbmc_remote_jq_refusal_text(cause: cause) } + OpenBmcRemoteJqExecuted { observation: observation } => + openbmc_fan_config_verify_result( + outcome: jq_classify_observation(plan: plan, observation: observation), + ) + } + } } fn openbmc_fan_config_project_local( diff --git a/dag/extdeps/bmc/openbmc_operation.dag b/dag/extdeps/bmc/openbmc_operation.dag index b3242ba1810..8a3bf33cd51 100644 --- a/dag/extdeps/bmc/openbmc_operation.dag +++ b/dag/extdeps/bmc/openbmc_operation.dag @@ -39,15 +39,6 @@ type OpenBmcOperation sensor_path: NonEmptyStr, threshold: OpenBmcThresholdProperty, } - | OpenBmcFanConfigScalar { - config_path: NonEmptyStr, - query: OpenBmcFanConfigQuery, - } - | OpenBmcFanConfigValidate { config_path: NonEmptyStr } - | OpenBmcFanConfigVerify { - config_path: NonEmptyStr, - desired: OpenBmcFanConfigDesired, - } | OpenBmcPathIsWritable { path: NonEmptyStr } | OpenBmcPathExists { path: NonEmptyStr } | OpenBmcPathIsNonemptyFile { path: NonEmptyStr } diff --git a/dag/extdeps/bmc/openbmc_password_ssh_transport.dag b/dag/extdeps/bmc/openbmc_password_ssh_transport.dag index 21fd9ce279d..bd1ee231dbe 100644 --- a/dag/extdeps/bmc/openbmc_password_ssh_transport.dag +++ b/dag/extdeps/bmc/openbmc_password_ssh_transport.dag @@ -33,7 +33,7 @@ data extdeps_model_scope: ExternalModelScope = ExternalModelScope { // v2.lens.mandatory_tag.corpus_scan when native witness realization runs the parse-grain corpus // witness. -data openbmc_password_ssh_transport_note: String = "Every transport operation has fixed argv selected by the closed OpenBmcOperation interpreter. The ONE command-string row, RunCommand, carries the RFC 4254 exec payload for the Wave 2 remote-jq migration: a typed NonEmptyStr produced one layer up by gunbc.remote_shell_command remote_exec_command_string -- never caller-authored argv -- and the operation's own sshpass/ssh argv stays fixed. The remote jq population was seventeen re-inlined sshpass/ssh argv prefixes; the first migrated site routes through the semantic jq layer (extdeps.tools.jq) and reaches RunCommand as ONE command string -- the SINGLE final word after the \"--\" separator, with sshpass feeding the password on fd 0 (-d 0). A stdin-fed remote jq refuses one layer up (openbmc_fan_control openbmc_remote_jq_execute): fd 0 is the password's channel here, so a remote stdin payload would collide with it. SSH verifies the target-specific known-hosts file and password material stays on inherited fd 0." +data openbmc_password_ssh_transport_note: String = "Every transport operation has fixed argv selected by the closed OpenBmcOperation interpreter. The ONE command-string row, RunCommand, carries the RFC 4254 exec payload for the Wave 2 remote-jq migration: a typed NonEmptyStr produced one layer up by gunbc.remote_shell_command remote_exec_command_string -- never caller-authored argv -- and the operation's own sshpass/ssh argv stays fixed. The remote jq population was seventeen re-inlined sshpass/ssh argv prefixes; every one of them now routes through the semantic jq layer (extdeps.tools.jq) and reaches RunCommand as ONE command string -- the SINGLE final word after the \"--\" separator, with sshpass feeding the password on fd 0 (-d 0). A stdin-fed remote jq refuses one layer up (openbmc_fan_control openbmc_remote_jq_execute): fd 0 is the password's channel here, so a remote stdin payload would collide with it. SSH verifies the target-specific known-hosts file and password material stays on inherited fd 0." service openbmc.PasswordSshTransport { operation RunCommand { @@ -255,306 +255,6 @@ service openbmc.PasswordSshTransport { } } - operation FanMinimumDuty { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[] | select(any(.pids[]; .name == \"TEMP_SOC\" and .type == \"stepwise\"))][0].minThermalOutput | floor", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanZoneFailsafeDuty { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[] | select(any(.pids[]; .name == \"TEMP_SOC\" and .type == \"stepwise\"))][0].failsafePercent | floor", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanSensorFailsafeDuty { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].failsafePercent | floor", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanPositiveHysteresis { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.positiveHysteresis | floor", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanNegativeHysteresis { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.negativeHysteresis | floor", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanReadingCount { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.reading | length", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanOutputCount { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.output | length", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanReadingAt { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr, index: Int } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "--argjson", "index", "{index}", "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.reading | to_entries | sort_by(.key | tonumber) | .[$index].value | floor", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanOutputAt { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr, index: Int } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "--argjson", "index", "{index}", "[.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")][0].pid.output | to_entries | sort_by(.key | tonumber) | .[$index].value | floor", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanControllerCount { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "[.zones[].pids[] | select(.type == \"fan\")] | length", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanControllerNameAt { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr, index: Int } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "--argjson", "index", "{index}", "[.zones[].pids[] | select(.type == \"fan\")][$index].name", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanInputCountAt { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr, fan_index: Int } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "--argjson", "fan_index", "{fan_index}", "[.zones[].pids[] | select(.type == \"fan\")][$fan_index].inputs | length", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanInputAt { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr, fan_index: Int, input_index: Int } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-er", "--argjson", "fan_index", "{fan_index}", "--argjson", "input_index", "{input_index}", "[.zones[].pids[] | select(.type == \"fan\")][$fan_index].inputs[$input_index]", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanConfigValidate { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "empty", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - - operation FanConfigVerify { - requires Network - input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, config_path: NonEmptyStr, desired_json: String } - output { - exit_code: Int from "exit_code" - success: Bool from "exit_success" - stdout: String from "stdout" - stderr: String from "stderr" - } - readonly - transport shell { - argv: [sshpass_program().invocation, "-d", "0", "ssh", "-T", "-o", "PreferredAuthentications=password,keyboard-interactive", "-o", "PubkeyAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile={known_hosts_path}", "-o", "LogLevel=ERROR", "-o", "ConnectTimeout=10", "{username}@{host}", "--", "jq", "-e", "--argjson", "desired", "{desired_json}", "([.zones[] | select(any(.pids[]; .name == \"TEMP_SOC\" and .type == \"stepwise\"))]) as $zones | ([.zones[].pids[] | select(.name == \"TEMP_SOC\" and .type == \"stepwise\")]) as $pids | ([.zones[].pids[] | select(.name == $desired.fan_pid_entry and .type == \"fan\")]) as $fans | (($zones | length) == 1) and (($pids | length) == 1) and (($fans | length) == 1) and ($zones[0].minThermalOutput == $desired.minimum) and ($zones[0].failsafePercent == $desired.zone_failsafe_duty) and ($pids[0].failsafePercent == $desired.sensor_failsafe) and ($pids[0].pid.positiveHysteresis == $desired.positive_hysteresis) and ($pids[0].pid.negativeHysteresis == $desired.negative_hysteresis) and ($pids[0].pid.reading == ($desired.readings | to_entries | map(\{key: (.key | tostring), value: .value}) | from_entries)) and ($pids[0].pid.output == ($desired.outputs | to_entries | map(\{key: (.key | tostring), value: .value}) | from_entries)) and ($fans[0].inputs == $desired.fan_inputs)", "{config_path}"] - stdin: password - } - exit { - 0 => Unit - nonzero => String "closed OpenBMC password-SSH operation failed" - } - } - operation PathIsWritable { requires Network input { host: NonEmptyStr, username: NonEmptyStr, password: Secret, known_hosts_path: NonEmptyStr, path: NonEmptyStr } diff --git a/dag/extdeps/tools/hostname.dag b/dag/extdeps/tools/hostname.dag index 922621bbc0e..b4dbb0fb777 100644 --- a/dag/extdeps/tools/hostname.dag +++ b/dag/extdeps/tools/hostname.dag @@ -38,6 +38,17 @@ data extdeps_hostnamectl_external_authority_anchor: ExternalAuthority = External } } +// TWO READ ROWS BECAUSE THEY ANSWER DIFFERENT QUESTIONS. Bare `hostname` prints whatever the host is +// configured to call itself, which may be fully qualified; `hostname -s` prints the first label only. +// The reach probe compares its read for EXACT equality against the enrolled fleet slot label (the +// short name a host is known by on the LAN), so it needs the configured name the host itself reports +// — not a re-derived first label — and the probe therefore owns this row, the bare spelling, while a +// converge that matches by short label owns `hostname_short_read_argv`. Collapsing the two would +// silently change which name a caller compared. +fn hostname_read_argv() -> List { + [hostname_program().invocation as String] +} + fn hostname_short_read_argv() -> List { ["hostname", "-s"] } diff --git a/dag/extdeps/tools/id.dag b/dag/extdeps/tools/id.dag index 5230ceffcdc..16b79d1ecc8 100644 --- a/dag/extdeps/tools/id.dag +++ b/dag/extdeps/tools/id.dag @@ -28,6 +28,19 @@ fn id_uid_argv() -> List { [id_program().invocation as String, "-u"] } +// NO OPERAND, DELIBERATELY: id(1) with no operand answers from the CALLING PROCESS's own +// credentials, established at its exec and never changing afterwards. The reach probe asks "which +// account did the transport actually reach", and the only account that can say is the one the session +// IS — so the probe supplies no user and reads the login name the session itself carries. A user +// operand would ask the account database about a different account and answer the wrong question. +// +// -un, NOT -u: -n selects NAMES over numeric uids (the probe records the login name it was granted, +// not a per-host uid), and -u then prints that name alone rather than the full `id` record. One flag +// for the name, one for the field; the probe trims the newline the remote shell leaves on stdout. +fn id_user_name_argv() -> List { + [id_program().invocation as String, "-un"] +} + fn id_gid_argv() -> List { [id_program().invocation as String, "-g"] } diff --git a/dag/gunbc/host/host_reach_identity_probe.dag b/dag/gunbc/host/host_reach_identity_probe.dag index 0e5ed3f69ec..12e752be796 100644 --- a/dag/gunbc/host/host_reach_identity_probe.dag +++ b/dag/gunbc/host/host_reach_identity_probe.dag @@ -1,6 +1,8 @@ module gunbc.host_reach_identity_probe import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.tools.id { id_user_name_argv } +import extdeps.tools.hostname { hostname_read_argv } import std.algebra { trim } import std.process { ProcessExit, ExitSuccess, exit_failure } import gunbc.typed_argv_exec { @@ -326,11 +328,13 @@ type FleetReachProbeRun { // receipt stays keyed by fleet IDENTITY — addressing is transport, never identity. // Repairs #1 + #2: consult fleet_probe_target_for FIRST. A refused target (unobserved // principal) yields ProbeTargetUnobserved and NEVER touches SSH. A ready target runs two -// typed argv ops against the SAME explicit principal@endpoint — `hostname` for host -// identity and `id -un` for the account actually reached — so ProbeReachable carries both -// dimensions and the receipt carries the requested principal for the identity verdict. +// typed argv ops against the SAME explicit principal@endpoint — `hostname_read_argv` for +// host identity and `id_user_name_argv` for the account actually reached — so ProbeReachable +// carries both dimensions and the receipt carries the requested principal for the identity +// verdict. The argv is spelled by the tool module that owns the tool, not hand-typed here: +// the probe no longer decides how `hostname` or `id` get their flags. fn probe_reachable_with_principal(target: String, hostname_stdout: String) -> ReachProbeOutcome { - match typed_argv_probe_over_ssh(host: target as NonEmptyStr, argv: ["id", "-un"]) { + match typed_argv_probe_over_ssh(host: target as NonEmptyStr, argv: id_user_name_argv()) { TypedArgvExecConverged { result: idr } => if idr.success { ProbeReachable { observed_short_hostname: trim(hostname_stdout), observed_principal: trim(idr.stdout) } @@ -343,7 +347,7 @@ fn probe_reachable_with_principal(target: String, hostname_stdout: String) -> Re fn probe_ready_target_once(host: String, endpoint: String, remote_principal: String) -> ReachProbeOutcome { let target = fleet_probe_ready_ssh_target(t_host: host, endpoint: endpoint, remote_principal: remote_principal) - match typed_argv_probe_over_ssh(host: target as NonEmptyStr, argv: ["hostname"]) { + match typed_argv_probe_over_ssh(host: target as NonEmptyStr, argv: hostname_read_argv()) { TypedArgvExecConverged { result: r } => if r.success { probe_reachable_with_principal(target: target, hostname_stdout: r.stdout) diff --git a/dag/test/claim/host/host_reach_identity_probe_witness_test.dag b/dag/test/claim/host/host_reach_identity_probe_witness_test.dag index 0fb2db896e5..3cd8435042e 100644 --- a/dag/test/claim/host/host_reach_identity_probe_witness_test.dag +++ b/dag/test/claim/host/host_reach_identity_probe_witness_test.dag @@ -2,6 +2,8 @@ module test.claim.host_reach_identity_probe_witness import gunbc.network_identity_subsumption { srv3_post_install_lease_table_fixture } import gunbc.srv3_os_install_diagnostic { srv3_install_hang_no_router_lease_ms } +import extdeps.tools.id { id_user_name_argv } +import extdeps.tools.hostname { hostname_read_argv } import std.types { Bool, String, NonEmptyStr, List } import std.upsert_decision { ObservationVerdict, Converged, Drifted, Conflict, Inaccessible, UnknownRefused } import product.host_identity { HostIdentity } @@ -251,3 +253,17 @@ test fn probe_endpoint_agrees_with_the_projection_it_consumes() -> Bool { fleet_probe_endpoint_for(host: "srv5") == spark_endpoint_or_empty(binding: srv5_router_binding) && fleet_probe_endpoint_for(host: "srv6") == spark_endpoint_or_empty(binding: srv6_router_binding) } + +// THE HANDBACK CONTROL FOR THE HAND-ARGV DISSOLUTION (roadmap shell-dag-host-reach-identity-probe). +// probe_ready_target_once and probe_reachable_with_principal no longer spell their argv by hand: +// they call hostname_read_argv() and id_user_name_argv() respectively, so the probe's argv IS the +// builder's argv. This test pins each builder's MATERIALIZED argv to the exact words the probe has +// always executed, so the flags cannot drift out from under the builder: a mutant that swaps +// id_user_name_argv to ["id","-u"] (the numeric uid), drops -n, or hands hostname_read_argv a -s +// (first label) instead of the full configured name typechecks and reds here. The -s decision the +// row's first_slice names resolves to the BARE read: the probe compares its output for exact +// equality against the enrolled short slot label, and -s would re-derive a label the probe must +// instead read as the host reports it. +test fn the_probe_argv_is_spelled_by_its_tool_module_builders() -> Bool { + id_user_name_argv() == ["id", "-un"] && hostname_read_argv() == ["hostname"] +}