Skip to content

Commit b5c416e

Browse files
bchodhia-gharsallaouiCopilotCopilot
authored
Fix NPD kubeconfig and node-name issues (#198)
Co-authored-by: Dhia Gharsallaoui <gharsallaoui.dhia@gmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a874d4e commit b5c416e

8 files changed

Lines changed: 214 additions & 14 deletions

File tree

hack/e2e/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The default `all` command runs:
2929
1. Build the local `aks-flex-node` binary unless `--binary` or `--skip-build` is used.
3030
2. Deploy AKS and three VMs with Bicep.
3131
3. Join all three VMs.
32-
4. Validate node readiness and run smoke workloads.
32+
4. Validate node readiness, node-problem-detector status, and run smoke workloads.
3333
5. Unjoin all Flex Nodes and verify they are absent.
3434
6. Rejoin all Flex Nodes and validate again.
3535
7. Run local-machine-driven repave validation.
@@ -51,7 +51,7 @@ The default `all` command runs:
5151
| `unjoin-msi` | Unjoin only the managed-identity node. |
5252
| `unjoin-token` | Unjoin only the bootstrap-token node. |
5353
| `unjoin-kubeadm` | Unjoin only the kubeadm-style node. |
54-
| `validate` | Verify joined nodes and run smoke tests. |
54+
| `validate` | Verify joined nodes, node-problem-detector status, and run smoke tests. |
5555
| `validate-absent` | Verify Flex Node objects are absent after unjoin. |
5656
| `smoke` | Run smoke workloads only. |
5757
| `upgrade-drift` | Validate local-machine-driven repave to the alternate nspawn side. |
@@ -197,6 +197,6 @@ Logs are collected under `$E2E_WORK_DIR/logs/`.
197197
- **Missing prerequisites:** run `./hack/e2e/run.sh --help` and confirm `az`, `jq`, `kubectl`, `ssh`, `scp`, and `openssl` are available.
198198
- **Azure auth failures:** run `az account show` and `az login` if needed.
199199
- **SSH failures:** inspect `state.json` for VM public IPs and confirm the SSH key configured by `E2E_SSH_KEY_FILE` is available.
200-
- **Node join failures:** run `./hack/e2e/run.sh logs` and inspect agent, bootstrap unit, kubelet, and containerd logs.
200+
- **Node join failures:** run `./hack/e2e/run.sh logs` and inspect agent, bootstrap unit, kubelet, containerd, and node-problem-detector logs.
201201
- **Repave failures:** check `aks-flex-node-agent` logs, `machinectl list`, and kubelet versions inside `kube1` and `kube2`.
202202
- **Leftover resources:** run `E2E_RESOURCE_GROUP=<rg> ./hack/e2e/run.sh cleanup`.

hack/e2e/lib/cleanup.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,39 @@ _collect_vm_logs() {
5252
fi" \
5353
> "${E2E_LOG_DIR}/${prefix}-containerd.log" 2>/dev/null || true
5454

55+
remote_exec "${vm_ip}" "bash -s" <<'REMOTE' > "${E2E_LOG_DIR}/${prefix}-npd.log" 2>&1 || true
56+
npd_service="node-problem-detector.service"
57+
active_machine="$(sudo python3 - <<'PY'
58+
import json
59+
import sys
60+
61+
try:
62+
with open("/etc/aks-flex-node/daemon-state.json", encoding="utf-8") as state:
63+
active_machine = json.load(state).get("activeMachine", "")
64+
if active_machine:
65+
print(active_machine)
66+
else:
67+
print("daemon state does not include activeMachine", file=sys.stderr)
68+
except FileNotFoundError as exc:
69+
print(f"daemon state not found: {exc}", file=sys.stderr)
70+
except json.JSONDecodeError as exc:
71+
print(f"daemon state is not valid JSON: {exc}", file=sys.stderr)
72+
except PermissionError as exc:
73+
print(f"daemon state permission denied: {exc}", file=sys.stderr)
74+
PY
75+
)"
76+
if [ -n "${active_machine}" ]; then
77+
echo "=== ${npd_service} logs (${active_machine}) ==="
78+
# Match the agent and kubelet log depth; NPD entries are sparse but useful across node lifecycle phases.
79+
sudo systemd-run --machine="${active_machine}" --quiet --pipe journalctl -u "${npd_service}" -n 500 --no-pager || \
80+
echo "warning: failed to collect ${npd_service} logs from ${active_machine}"
81+
else
82+
echo "warning: active machine unknown; falling back to host journal"
83+
sudo journalctl -u "${npd_service}" -n 500 --no-pager || \
84+
echo "warning: failed to collect ${npd_service} logs from host"
85+
fi
86+
REMOTE
87+
5588
# Collect CNI config and nspawn machine state for networking diagnostics.
5689
# Read directly from the nspawn rootfs at /var/lib/machines/kube1/.
5790
local kube1_root="/var/lib/machines/kube1"

hack/e2e/lib/validate.sh

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Functions:
66
# validate_node_joined <vm_name> - Wait for a specific node to appear in kubectl
77
# validate_all_nodes - Verify MSI, token, and kubeadm nodes joined
8+
# validate_npd_status <vm_name> <vm_ip> - Verify node-problem-detector is active
89
# validate_node_absent <vm_name> - Wait for a node to disappear from kubectl
910
# validate_all_nodes_absent - Verify all flex nodes are gone after unjoin
1011
# smoke_test <vm_name> <label> - Schedule an nginx pod on a node
@@ -73,6 +74,93 @@ validate_node_ip() {
7374
return 1
7475
}
7576

77+
# ---------------------------------------------------------------------------
78+
# validate_npd_status - Ensure node-problem-detector is active and reporting
79+
# ---------------------------------------------------------------------------
80+
validate_npd_status() {
81+
local vm_name="$1"
82+
local vm_ip="$2"
83+
local timeout="${E2E_NODE_JOIN_TIMEOUT}"
84+
local elapsed=0
85+
local npd_condition_jsonpath='{.status.conditions[?(@.type=="KernelDeadlock")].status}'
86+
local condition_error="${E2E_WORK_DIR}/npd-condition-${vm_name}.err"
87+
local quoted_timeout
88+
89+
log_info "Validating node-problem-detector on '${vm_name}'..."
90+
91+
if ! [[ "${timeout}" =~ ^[0-9]+$ ]]; then
92+
log_error "E2E_NODE_JOIN_TIMEOUT must be numeric, got '${timeout}'"
93+
return 1
94+
fi
95+
printf -v quoted_timeout "%q" "${timeout}"
96+
97+
remote_exec "${vm_ip}" "E2E_NODE_JOIN_TIMEOUT=${quoted_timeout} bash -s" <<'REMOTE'
98+
set -euo pipefail
99+
100+
deadline=$((SECONDS + E2E_NODE_JOIN_TIMEOUT))
101+
active_machine_error="/tmp/aks-flex-node-e2e-active-machine-$$.err"
102+
status_error="/tmp/aks-flex-node-e2e-npd-status-$$.err"
103+
while true; do
104+
if [[ ! -f /etc/aks-flex-node/daemon-state.json ]]; then
105+
active_machine=""
106+
echo "/etc/aks-flex-node/daemon-state.json is missing" > "${active_machine_error}"
107+
else
108+
active_machine="$(sudo python3 - <<'PY' 2>"${active_machine_error}" || true
109+
import json
110+
with open("/etc/aks-flex-node/daemon-state.json", encoding="utf-8") as state:
111+
print(json.load(state).get("activeMachine", ""))
112+
PY
113+
)"
114+
fi
115+
if [[ -n "${active_machine}" ]] && sudo machinectl show "${active_machine}" &>/dev/null; then
116+
status="$(sudo systemd-run --machine="${active_machine}" --quiet --pipe systemctl is-active node-problem-detector.service 2>"${status_error}" || true)"
117+
if [[ "${status}" == "active" ]]; then
118+
echo "node-problem-detector.service is active in ${active_machine}"
119+
exit 0
120+
fi
121+
fi
122+
123+
if (( SECONDS >= deadline )); then
124+
echo "node-problem-detector.service did not become active"
125+
if [[ -s "${active_machine_error}" ]]; then
126+
cat "${active_machine_error}"
127+
fi
128+
if [[ -s "${status_error}" ]]; then
129+
cat "${status_error}"
130+
fi
131+
sudo machinectl list --no-pager || true
132+
if [[ -n "${active_machine:-}" ]]; then
133+
sudo systemd-run --machine="${active_machine}" --quiet --pipe systemctl status node-problem-detector.service --no-pager -l || true
134+
sudo systemd-run --machine="${active_machine}" --quiet --pipe journalctl -u node-problem-detector.service -n 50 --no-pager || true
135+
fi
136+
exit 1
137+
fi
138+
139+
sleep 5
140+
done
141+
REMOTE
142+
143+
local kernel_deadlock
144+
while [[ "${elapsed}" -lt "${timeout}" ]]; do
145+
kernel_deadlock="$(kubectl get node "${vm_name}" -o jsonpath="${npd_condition_jsonpath}" 2>"${condition_error}" || true)"
146+
if [[ "${kernel_deadlock}" == "False" ]]; then
147+
log_success "node-problem-detector is active and reporting on '${vm_name}'"
148+
return 0
149+
fi
150+
151+
sleep 10
152+
elapsed=$((elapsed + 10))
153+
log_debug "Waiting for node-problem-detector condition on ${vm_name}... (${elapsed}/${timeout}s)"
154+
done
155+
156+
log_error "node-problem-detector did not report KernelDeadlock=False on '${vm_name}' within ${timeout}s"
157+
if [[ -s "${condition_error}" ]]; then
158+
cat "${condition_error}" >&2
159+
fi
160+
kubectl describe node "${vm_name}" 2>&1 || true
161+
return 1
162+
}
163+
76164
# ---------------------------------------------------------------------------
77165
# validate_all_nodes - Check all MSI, token, and kubeadm VMs joined
78166
# ---------------------------------------------------------------------------
@@ -91,17 +179,24 @@ validate_all_nodes() {
91179
--admin
92180

93181
local msi_vm_name token_vm_name kubeadm_vm_name
182+
local msi_vm_ip token_vm_ip kubeadm_vm_ip
94183
local token_vm_private_ip
95184
msi_vm_name="$(state_get msi_vm_name)"
96185
token_vm_name="$(state_get token_vm_name)"
97186
kubeadm_vm_name="$(state_get kubeadm_vm_name)"
187+
msi_vm_ip="$(state_get msi_vm_ip)"
188+
token_vm_ip="$(state_get token_vm_ip)"
189+
kubeadm_vm_ip="$(state_get kubeadm_vm_ip)"
98190
token_vm_private_ip="$(state_get token_vm_private_ip)"
99191

100192
local failed=0
101193
validate_node_joined "${msi_vm_name}" || failed=1
102194
validate_node_joined "${token_vm_name}" || failed=1
103195
validate_node_joined "${kubeadm_vm_name}" || failed=1
104196
validate_node_ip "${token_vm_name}" "${token_vm_private_ip}" || failed=1
197+
validate_npd_status "${msi_vm_name}" "${msi_vm_ip}" || failed=1
198+
validate_npd_status "${token_vm_name}" "${token_vm_ip}" || failed=1
199+
validate_npd_status "${kubeadm_vm_name}" "${kubeadm_vm_ip}" || failed=1
105200

106201
if [[ "${failed}" -eq 1 ]]; then
107202
log_error "One or more nodes failed to join"

pkg/daemon/nodeoperator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (o *nspawnNodeOperator) RestartNode(ctx context.Context, log *slog.Logger)
5252
nodestop.StopNode(log, active.Name),
5353
nodestart.StartNode(log, gs.NodeStart),
5454
nodestart.WaitForKubelet(log, active.Name),
55-
npd.Start(cfg, log, gs.RootFS.MachineDir, active.Name),
55+
npd.Start(log, gs.NodeStart),
5656
).Do(ctx)
5757
}
5858

pkg/daemon/start.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func StartNode(
4747
),
4848
nodestart.StartNode(log, gs.NodeStart),
4949
nodestart.WaitForKubelet(log, machineName),
50-
npd.Start(cfg, log, gs.RootFS.MachineDir, machineName),
50+
npd.Start(log, gs.NodeStart),
5151
saveState(store, state),
5252
)
5353
}

pkg/npd/assets/node-problem-detector.service

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Description=Node Problem Detector
33
After=network.target
44

55
[Service]
6-
ExecStart={{.NPDBinaryPath}} --apiserver-override="{{.APIServerURL}}?inClusterConfig=false&auth={{.KubeconfigPath}}" --config.system-log-monitor={{.NPDConfigPath}}
6+
ExecStart={{.NPDBinaryPath}} --hostname-override={{.NodeName}} --apiserver-override="{{.APIServerURL}}?inClusterConfig=false&auth={{.KubeconfigPath}}" --config.system-log-monitor={{.NPDConfigPath}}
77
Restart=on-failure
88
RestartSec=5s
99

pkg/npd/start.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,17 @@ import (
1111
"path/filepath"
1212
"text/template"
1313

14-
"github.com/Azure/AKSFlexNode/pkg/config"
1514
"github.com/Azure/AKSFlexNode/pkg/utils/utilexec"
1615
"github.com/Azure/AKSFlexNode/pkg/utils/utilio"
16+
"github.com/Azure/unbounded/pkg/agent/goalstates"
1717
"github.com/Azure/unbounded/pkg/agent/phases"
1818
)
1919

2020
//go:embed assets/node-problem-detector.service
2121
var serviceTemplate string
2222

2323
const (
24-
KubeletKubeconfigPath = "/var/lib/kubelet/kubelet/kubeconfig"
25-
systemdUnitNPD = "node-problem-detector.service"
24+
systemdUnitNPD = "node-problem-detector.service"
2625
)
2726

2827
var tmpl = template.Must(template.New("npd-service").Parse(serviceTemplate))
@@ -33,18 +32,20 @@ type startTask struct {
3332
kubeconfigPath string
3433
machineDir string
3534
machineName string
35+
nodeName string
3636
}
3737

3838
// Start returns a task that renders the NPD systemd unit file into the
3939
// nspawn machine rootfs and ensures the service is running inside the
4040
// container via systemd-run --machine.
41-
func Start(cfg *config.Config, log *slog.Logger, machineDir, machineName string) phases.Task {
41+
func Start(log *slog.Logger, nodeStart *goalstates.NodeStart) phases.Task {
4242
return &startTask{
4343
log: log,
44-
apiServer: cfg.Node.Kubelet.ServerURL,
45-
kubeconfigPath: KubeletKubeconfigPath,
46-
machineDir: machineDir,
47-
machineName: machineName,
44+
apiServer: nodeStart.Kubelet.APIServer,
45+
kubeconfigPath: goalstates.KubeletKubeconfigPath,
46+
machineDir: nodeStart.MachineDir,
47+
machineName: nodeStart.MachineName,
48+
nodeName: nodeStart.NodeName,
4849
}
4950
}
5051

@@ -66,6 +67,7 @@ func (t *startTask) ensureServiceFile() (updated bool, err error) {
6667
"APIServerURL": t.apiServer,
6768
"KubeconfigPath": t.kubeconfigPath,
6869
"NPDConfigPath": npdConfigPath,
70+
"NodeName": t.nodeName,
6971
}); err != nil {
7072
return false, fmt.Errorf("render npd service template: %w", err)
7173
}

pkg/npd/start_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package npd
2+
3+
import (
4+
"log/slog"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/Azure/unbounded/pkg/agent/goalstates"
11+
)
12+
13+
// wantKubeconfigPath is the path the kubelet actually writes its kubeconfig to.
14+
// The historical bug used a doubled segment ("/var/lib/kubelet/kubelet/kubeconfig"),
15+
// which made node-problem-detector panic on startup and crash-loop forever:
16+
//
17+
// panic: stat /var/lib/kubelet/kubelet/kubeconfig: no such file or directory
18+
const wantKubeconfigPath = "/var/lib/kubelet/kubeconfig"
19+
20+
// TestCanonicalKubeletKubeconfigPath guards the value of the shared library
21+
// constant so the doubled-segment typo cannot reappear from a dependency bump.
22+
func TestCanonicalKubeletKubeconfigPath(t *testing.T) {
23+
t.Parallel()
24+
if goalstates.KubeletKubeconfigPath != wantKubeconfigPath {
25+
t.Fatalf("goalstates.KubeletKubeconfigPath = %q, want %q",
26+
goalstates.KubeletKubeconfigPath, wantKubeconfigPath)
27+
}
28+
}
29+
30+
// TestRenderedNPDUnitUsesCanonicalKubeconfig drives Start() through the real
31+
// service-file rendering and asserts on the kubeconfig path that ends up in the
32+
// node-problem-detector systemd unit's ExecStart. Asserting on the rendered unit
33+
// (the externally observable artifact) rather than internal fields keeps the
34+
// test stable across refactors while still exercising the Start() wiring.
35+
func TestRenderedNPDUnitUsesCanonicalKubeconfig(t *testing.T) {
36+
t.Parallel()
37+
machineDir := t.TempDir()
38+
nodeStart := &goalstates.NodeStart{
39+
MachineDir: machineDir,
40+
MachineName: "kube1",
41+
NodeName: "vm-e2e-token-1781659839",
42+
Kubelet: goalstates.Kubelet{
43+
APIServer: "https://example.hcp.westus.azmk8s.io:443",
44+
},
45+
}
46+
task, ok := Start(slog.Default(), nodeStart).(*startTask)
47+
if !ok {
48+
t.Fatalf("Start did not return *startTask")
49+
}
50+
if _, err := task.ensureServiceFile(); err != nil {
51+
t.Fatalf("ensureServiceFile: %v", err)
52+
}
53+
54+
unitPath := filepath.Join(machineDir, "etc/systemd/system", systemdUnitNPD)
55+
data, err := os.ReadFile(unitPath) //nolint:gosec // path built from test TempDir
56+
if err != nil {
57+
t.Fatalf("read rendered unit: %v", err)
58+
}
59+
rendered := string(data)
60+
61+
if !strings.Contains(rendered, "auth="+wantKubeconfigPath) {
62+
t.Fatalf("rendered unit missing auth=%s:\n%s", wantKubeconfigPath, rendered)
63+
}
64+
if strings.Contains(rendered, "kubelet/kubelet") {
65+
t.Fatalf("rendered unit contains doubled 'kubelet' segment:\n%s", rendered)
66+
}
67+
if !strings.Contains(rendered, "--hostname-override=vm-e2e-token-1781659839") {
68+
t.Fatalf("rendered unit missing hostname override:\n%s", rendered)
69+
}
70+
}

0 commit comments

Comments
 (0)