Runner smoke: surface relay-enroll failure cause + curl egress probe

The runner image smoke test only reported an opaque "challenge request"
when nucleicd failed to enroll with the relay on boot. RelayAccess.postJSON
collapsed transport errors, non-200 statuses, and malformed bodies into one
string, hiding whether the fully-static musl binary's URLSession/libcurl leg
is failing vs the relay rejecting vs no egress. Surface the specific cause,
and add an independent curl probe to the smoke step so one build tells us
which layer is at fault.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-11 01:13:51 +00:00
co-authored by Claude Opus 4.8
parent 2dfee24e75
commit 0c2fb86de9
2 changed files with 35 additions and 7 deletions
+10
View File
@@ -68,6 +68,16 @@ jobs:
run: |
chmod +x containers/nucleic-runner/nucleicd
file containers/nucleic-runner/nucleicd
# Independent egress probe: can THIS runner reach the relay's enroll endpoint, and what
# does it answer? Isolates a runner-network/relay problem from a Swift-binary (static-musl
# URLSession/libcurl) problem — the daemon's own enroll POST goes through URLSession.
echo "----- relay egress probe (curl) -----"
curl -sS -m 15 -o /tmp/relay.out -w 'HTTP %{http_code} in %{time_total}s\n' \
-X POST https://relay.nucleic.blakeslee.xyz/v1/relay/enroll/challenge \
-H 'content-type: application/json' -d '{"staticKey":"probe"}' \
|| echo "curl failed: exit $?"
head -c 400 /tmp/relay.out 2>/dev/null; echo
echo "-------------------------------------"
# Don't let `set -e` abort before we've printed the log: capture the exit code, then
# decide. `timeout` returns 124 when it had to kill a still-running daemon (the healthy
# case — nucleicd runs forever); a clean SIGTERM shutdown returns 0. Any other code is a
+25 -7
View File
@@ -99,7 +99,7 @@ public actor RelayAccess {
let staticKeyB64 = RelayEnrollment.staticKeyEncoding(identity.staticPublicKey)
let challenge = try await postJSON(
"/v1/relay/enroll/challenge", body: ["staticKey": staticKeyB64],
or: Error.enrollFailed("challenge request"))
label: "challenge request")
guard let challengeID = challenge["challengeId"] as? String,
let ephB64 = challenge["ephemeralPublicKey"] as? String,
let ephemeralPublicKey = Data(base64URLNoPad: ephB64)
@@ -111,7 +111,7 @@ public actor RelayAccess {
let enrolled = try await postJSON(
"/v1/relay/enroll",
body: ["challengeId": challengeID, "proof": proof.base64URLNoPadEncoded],
or: Error.enrollFailed("proof rejected"))
label: "proof rejected")
guard let hostID = enrolled["hostId"] as? String,
let secret = enrolled["hostSecret"] as? String,
let roomID = enrolled["roomId"] as? String, roomID == identity.hostID
@@ -144,18 +144,36 @@ public actor RelayAccess {
roomID: roomID, token: token, url: urlOverride, exp: json["exp"] as? Double)
}
/// POST `body` as JSON and decode the JSON reply. On failure the thrown `enrollFailed`
/// carries the *specific* cause transport error (the URLSession/libcurl leg, which is the
/// weak point in a fully-static musl build), a non-200 status (relay reachable but rejecting),
/// or a malformed body prefixed by `label` so a Settings status row / the runner's boot log
/// says which step and why, not just an opaque "challenge request".
private func postJSON(
_ path: String, body: [String: String], or error: Error
_ path: String, body: [String: String], label: String
) async throws -> [String: Any] {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = "POST"
request.timeoutInterval = 10
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
guard let (data, response) = try? await session.data(for: request),
(response as? HTTPURLResponse)?.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { throw error }
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw Error.enrollFailed("\(label): transport error: \(error)")
}
guard let http = response as? HTTPURLResponse else {
throw Error.enrollFailed("\(label): non-HTTP response")
}
guard http.statusCode == 200 else {
let snippet = String(data: data, encoding: .utf8)?.prefix(200) ?? ""
throw Error.enrollFailed("\(label): HTTP \(http.statusCode) \(snippet)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw Error.enrollFailed("\(label): malformed response body")
}
return json
}
}