Let --report-ip lead host identity and pass it through the installer

An explicit --report-ip is the user naming the primary address on a
multi-NIC host, but identityFromHost appended it after the auto-detected
interface addresses while every consumer of ResourceIdentity.IPAddresses
treats the first entry as primary, so the override never changed what
the Machines table displayed. Prepend it instead.

The install script also rejected --report-ip as an unknown argument even
though the agent supports the flag, forcing hand edits to the service
unit that a later --update run would drop. Accept the flag, render it
into the service ExecStart, persist it in connection state, and
recognise it during saved-state and arg-stream recovery so updates
preserve it.

Refs #829

Contract-Neutral: behavioral fix: user-specified report-ip leads host identity addresses and the installer passes --report-ip through; no public contract change (#829)
This commit is contained in:
rcourtman 2026-07-22 00:49:43 +01:00
parent b853638980
commit f8c5c55d11
5 changed files with 47 additions and 4 deletions

View file

@ -93,7 +93,10 @@ func resourceFromProxmoxNode(node models.Node, linkedHost *models.Host) (Resourc
func identityFromHost(host models.Host) ResourceIdentity {
ips, macs := collectInterfaceIDs(host.NetworkInterfaces)
if host.ReportIP != "" {
ips = append(ips, host.ReportIP)
// --report-ip is the user naming the primary address on a multi-NIC
// host, and consumers treat the first entry as primary, so it must
// lead the list rather than trail the auto-detected ones (#829).
ips = append([]string{host.ReportIP}, ips...)
}
return ResourceIdentity{

View file

@ -1752,6 +1752,22 @@ func TestCollectInterfaceIDsOrdersPhysicalInterfacesFirst(t *testing.T) {
}
}
func TestIdentityFromHostReportIPLeadsAddresses(t *testing.T) {
host := models.Host{
Hostname: "multi-nic",
ReportIP: "192.168.1.9",
NetworkInterfaces: []models.HostNetworkInterface{
{Name: "eth0", Addresses: []string{"2.2.2.10/24"}},
{Name: "eth1", Addresses: []string{"192.168.1.9/24"}},
},
}
identity := identityFromHost(host)
if len(identity.IPAddresses) == 0 || identity.IPAddresses[0] != "192.168.1.9" {
t.Fatalf("expected --report-ip to lead identity addresses, got %v", identity.IPAddresses)
}
}
func TestCollectInterfaceIDsAllVirtualStillCollects(t *testing.T) {
interfaces := []models.HostNetworkInterface{
{Name: "docker0", Addresses: []string{"172.17.0.1/16"}},

View file

@ -83,6 +83,7 @@ SERVER_FINGERPRINT="${PULSE_SERVER_FINGERPRINT:-}"
OBSERVERS_FILE="${PULSE_OBSERVERS_FILE:-}"
AGENT_ID=""
HOSTNAME_OVERRIDE=""
REPORT_IP=""
ENABLE_COMMANDS="false"
HEALTH_ADDR="${PULSE_HEALTH_ADDR:-}"
HEALTH_ADDR_SET="false"
@ -331,6 +332,7 @@ Options:
--enable-proxmox Force enable Proxmox integration
--agent-id <id> Custom agent identifier
--hostname <name> Override hostname reported to Pulse
--report-ip <ip> IP address to report to Pulse (for multi-NIC systems)
--state-dir <path> Override persistent state directory
--disk-exclude <path> Exclude mount point (repeatable)
--insecure Skip TLS verification (auto-enabled for http:// URLs)
@ -1530,6 +1532,7 @@ build_exec_arg_items() {
if [[ "$KUBE_INCLUDE_ALL_DEPLOYMENTS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-deployments); fi
if [[ -n "$AGENT_ID" ]]; then EXEC_ARG_ITEMS+=(--agent-id "$AGENT_ID"); fi
if [[ -n "$HOSTNAME_OVERRIDE" ]]; then EXEC_ARG_ITEMS+=(--hostname "$HOSTNAME_OVERRIDE"); fi
if [[ -n "$REPORT_IP" ]]; then EXEC_ARG_ITEMS+=(--report-ip "$REPORT_IP"); fi
if [[ -n "$STATE_DIR" ]]; then EXEC_ARG_ITEMS+=(--state-dir "$STATE_DIR"); fi
# Add disk exclude patterns (use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u)
for pattern in ${DISK_EXCLUDES[@]+"${DISK_EXCLUDES[@]}"}; do
@ -1749,6 +1752,9 @@ recover_connection_state() {
if [[ -z "$HOSTNAME_OVERRIDE" ]]; then
HOSTNAME_OVERRIDE=$(read_connection_state_value "$file" "PULSE_HOSTNAME")
fi
if [[ -z "$REPORT_IP" ]]; then
REPORT_IP=$(read_connection_state_value "$file" "PULSE_REPORT_IP")
fi
if [[ "$INSECURE" != "true" ]]; then
local saved_insecure=""
saved_insecure=$(read_connection_state_value "$file" "PULSE_INSECURE_SKIP_VERIFY")
@ -1817,6 +1823,10 @@ apply_recovered_agent_arg_value() {
if [[ -z "$HOSTNAME_OVERRIDE" ]]; then HOSTNAME_OVERRIDE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
report-ip)
if [[ -z "$REPORT_IP" ]]; then REPORT_IP="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
cacert)
if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
@ -1888,10 +1898,10 @@ recover_connection_state_from_arg_stream() {
fi
case "$arg" in
--url|--pulse-url|--token|--token-file|--interval|--agent-id|--hostname|--cacert|--server-fingerprint|--observers-file|--health-addr|--state-dir|--kubeconfig|--proxmox-type|--disk-exclude|-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-cacert|-server-fingerprint|-observers-file|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude)
--url|--pulse-url|--token|--token-file|--interval|--agent-id|--hostname|--report-ip|--cacert|--server-fingerprint|--observers-file|--health-addr|--state-dir|--kubeconfig|--proxmox-type|--disk-exclude|-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude)
pending_key=$(normalize_recovered_agent_arg_key "$arg")
;;
--url=*|--pulse-url=*|--token=*|--token-file=*|--interval=*|--agent-id=*|--hostname=*|--cacert=*|--server-fingerprint=*|--observers-file=*|--health-addr=*|--state-dir=*|--kubeconfig=*|--proxmox-type=*|--disk-exclude=*|-url=*|-pulse-url=*|-token=*|-token-file=*|-interval=*|-agent-id=*|-hostname=*|-cacert=*|-server-fingerprint=*|-observers-file=*|-health-addr=*|-state-dir=*|-kubeconfig=*|-proxmox-type=*|-disk-exclude=*)
--url=*|--pulse-url=*|--token=*|--token-file=*|--interval=*|--agent-id=*|--hostname=*|--report-ip=*|--cacert=*|--server-fingerprint=*|--observers-file=*|--health-addr=*|--state-dir=*|--kubeconfig=*|--proxmox-type=*|--disk-exclude=*|-url=*|-pulse-url=*|-token=*|-token-file=*|-interval=*|-agent-id=*|-hostname=*|-report-ip=*|-cacert=*|-server-fingerprint=*|-observers-file=*|-health-addr=*|-state-dir=*|-kubeconfig=*|-proxmox-type=*|-disk-exclude=*)
key="${arg%%=*}"
value="${arg#*=}"
apply_recovered_agent_arg_value "$key" "$value"
@ -2021,6 +2031,11 @@ recover_connection_state_from_env_stream() {
if [[ -z "$HOSTNAME_OVERRIDE" ]]; then HOSTNAME_OVERRIDE="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_REPORT_IP=*)
value="${env_line#*=}"
if [[ -z "$REPORT_IP" ]]; then REPORT_IP="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_INSECURE_SKIP_VERIFY=true)
INSECURE="true"
RECOVERED_AGENT_ENV_STATE="true"
@ -2409,6 +2424,7 @@ save_connection_info() {
write_connection_state_value "$conn_tmp" "PULSE_TOKEN_FILE" "$RUNTIME_TOKEN_FILE"
write_connection_state_value "$conn_tmp" "PULSE_AGENT_ID" "$AGENT_ID"
write_connection_state_value "$conn_tmp" "PULSE_HOSTNAME" "$HOSTNAME_OVERRIDE"
write_connection_state_value "$conn_tmp" "PULSE_REPORT_IP" "$REPORT_IP"
if [[ "$INSECURE" == "true" ]]; then
write_connection_state_value "$conn_tmp" "PULSE_INSECURE_SKIP_VERIFY" "true"
fi
@ -2477,6 +2493,7 @@ while [[ $# -gt 0 ]]; do
--uninstall) UNINSTALL="true"; shift ;;
--agent-id) AGENT_ID="$2"; shift 2 ;;
--hostname) HOSTNAME_OVERRIDE="$2"; shift 2 ;;
--report-ip) REPORT_IP="$2"; shift 2 ;;
--state-dir) STATE_DIR="$2"; STATE_DIR_SOURCE="explicit"; shift 2 ;;
--kube-include-all-pods) KUBE_INCLUDE_ALL_PODS="true"; shift ;;
--kube-include-all-deployments) KUBE_INCLUDE_ALL_DEPLOYMENTS="true"; shift ;;

View file

@ -178,6 +178,7 @@ func renderLifecycleService(t *testing.T, stateDir, stateSource, unitPath, pulse
PULSE_TOKEN=""
AGENT_ID=""
HOSTNAME_OVERRIDE=""
REPORT_IP=""
INSECURE="false"
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
@ -211,6 +212,7 @@ func renderLifecycleService(t *testing.T, stateDir, stateSource, unitPath, pulse
KUBE_INCLUDE_ALL_DEPLOYMENTS="false"
AGENT_ID=""
HOSTNAME_OVERRIDE="state-lifecycle-host"
REPORT_IP=""
DISK_EXCLUDES=()
CURL_CA_BUNDLE=""
RUNTIME_TOKEN_FILE=""

View file

@ -716,7 +716,7 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) {
`recover_connection_state_from_arg_stream`,
`recover_token_from_default_agent_token_file() {`,
`normalize_recovered_agent_arg_key() {`,
`-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-cacert|-server-fingerprint|-observers-file|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude)`,
`-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude)`,
`--enable-host|-enable-host|--enable-host=true|-enable-host=true)`,
`recover_connection_state_from_env_stream`,
`recovered_connection_state_ready() {`,
@ -2582,6 +2582,7 @@ func TestInstallSHConnectionEnvPersistsCanonicalStateDirWithoutTokenValue(t *tes
RUNTIME_TOKEN_FILE="$STATE_DIR/token"
AGENT_ID="agent-custom"
HOSTNAME_OVERRIDE="host-custom"
REPORT_IP="192.168.1.9"
INSECURE="false"
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
@ -2601,6 +2602,9 @@ func TestInstallSHConnectionEnvPersistsCanonicalStateDirWithoutTokenValue(t *tes
!strings.Contains(got, "PULSE_TOKEN_FILE='"+filepath.Join(stateDir, "token")+"'") {
t.Fatalf("connection.env did not persist canonical state paths:\n%s", got)
}
if !strings.Contains(got, "PULSE_REPORT_IP='192.168.1.9'") {
t.Fatalf("connection.env did not persist the report IP:\n%s", got)
}
if strings.Contains(got, "PULSE_TOKEN='") || strings.Contains(got, "deadbeef") {
t.Fatalf("connection.env leaked the token value:\n%s", got)
}
@ -2643,6 +2647,7 @@ func TestInstallSHStateWritesReplaceSymlinksAtomically(t *testing.T) {
ENROLL="false"
AGENT_ID="agent-one"
HOSTNAME_OVERRIDE="host-one"
REPORT_IP=""
INSECURE="false"
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""