Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 203 additions & 151 deletions dag/extdeps/bmc/http.dag

Large diffs are not rendered by default.

167 changes: 51 additions & 116 deletions dag/extdeps/http/client.dag
Original file line number Diff line number Diff line change
Expand Up @@ -64,67 +64,23 @@ fn http_client_get_unix_socket_argv(socket: NonEmptyStr, url: NonEmptyStr, max_t
]
}

// THE DEADLINE IS AN INPUT, NOT A LITERAL IN THIS ARGV, and the distinction is the one DESIGN
// section 3 draws between an interface fact and a policy fact. How long a caller is willing to wait
// for ITS backend is the caller's business; curl's ability to bound a request is ours. A number
// written here would be this module answering a question it cannot know, and every caller with a
// different backend would then need a second operation.
//
// WITHOUT IT THE WAIT WAS UNBOUNDED, which is not a slow path but a missing refusal: DESIGN section
// 5 rules that a bounded "forever" is not an "unknown" error. Measured on this deployment, a wedged
// backend held one harness POST open for over thirty minutes while the router answered an unrelated
// probe in 0.15ms -- so the turn was neither progressing nor failing, and no arm existed to say so.
// StatusWithin -- A STATUS IS AN ANSWER; ONLY NO STATUS IS SILENCE. Every GET carries -f, which
// turns a 4xx or 5xx into a failed operation -- right for a caller that wants a body, wrong for a
// caller asking whether anything is THERE: a front door returning 401 or 500 answered. That
// operation asks only for the status line, within the caller's deadline, and fails only when no
// HTTP response was received at all -- and WHY is carried as curl's exit code, because "no
// response" is not one fact: a failed connect (curl 7) and a deadline (curl 28) are different
// readings for a caller asking whether anything is THERE, and neither proves absence on its own.
// The consumer maps the code; the transport reports it. The codes are curl's own facts and live
// with the tool authority: extdeps.tools.curl curl_exit_outcome.
//
// PostStdinWithin, A BOUNDED POST WHOSE TRANSPORT OUTCOME IS KEPT, NOT FOLDED INTO SUCCESS. The request body goes
// on stdin (curl --data-binary @-), so no body byte passes through argv or a shared file. Without
// -f, an HTTP error status is an ANSWER (exit 0), and the status is appended as the final line by
// -w, so a caller can tell a server's refusal from curl never reaching it: curl's own exit code
// carries the transport outcome (6 could not resolve, 7 could not connect, 28 timed out, 35 TLS
// handshake failed -- curl(1) EXIT CODES), and a consumer classifies it rather than reading the
// stderr text.
// PostFormFromFile IS THE FORM SPELLING of the same bounded POST as PostJsonFromFile, declared
// at module grain because an operation's rationale cannot live inside the service body: it is
// for endpoints that read application/x-www-form-urlencoded (the OAuth token endpoint among
// them), one content-type line differs, and the body still rides --data @{file} so a client
// secret in the form body is never an argv word (gunbc.credential_argv_exposure).
// PostStdinWithinUnixSocket, THE SAME BOUNDED POST OVER A UNIX SOCKET (curl --unix-socket, curl(1)):
// the URL still names the scheme, authority and path the server routes on, but the connection is
// made to the socket file and the kernel attests the caller to the server. The exit codes are
// PostStdinWithin's; a socket that is absent or not traversable is exit 7, could not connect.
// PostFormFromFile IS THE FORM SPELLING of the same bounded POST as PostJsonFromFile, declared
// at module grain because an operation's rationale cannot live inside the service body: it is
// for endpoints that read application/x-www-form-urlencoded (the OAuth token endpoint among
// them), one content-type line differs, and the body still rides --data @{file} so a client
// secret in the form body is never an argv word (gunbc.credential_argv_exposure).
// Probe, A LIVENESS PROBE: one bounded connection attempt, answered as DATA on every arm. This is
// the observation half of a readiness poll (the retry loop lives with the caller, which owns the
// budget): StatusWithin REFUSES a nonzero curl, which is the wrong shape for polling a server that
// is still warming up -- "connection refused" is an expected answer mid-startup, not a refusal.
// Probe maps the curl exit code through untouched (7 refused, 28 timed out, 0 with an HTTP status
// in stdout; `000` on stdout is curl's no-response marker per man curl(1), `-w %{http_code}`), so
// the caller can distinguish "not up yet" from "up and wrong" without this operation inventing a
// retry policy.
service http.Client {
operation Get {
requires Network
input { url: NonEmptyStr }
output { body: String from "stdout", success: Bool from "exit_success" }
output { body: String, success: Bool }
readonly
transport shell {
argv: ["curl", "-fsS", "{url}"]
transport rest {
method: GET,
path: "{url}"
}
exit {
0 => Unit
nonzero => String "http client GET failed"
response {
2xx => String
4xx => String
5xx => String
}
mock_response {
0 => { body: "", success: false } "hermetic: no live HTTP endpoint; the GET refuses rather than fabricate a body"
}
}

Expand Down Expand Up @@ -232,14 +188,22 @@ service http.Client {
operation GetQueryStdinWithin {
requires Network
input { url: NonEmptyStr, query: String, connect_seconds: NonEmptyStr, max_seconds: NonEmptyStr }
output { success: Bool from "exit_success", body: String from "stdout" }
output {
success: Bool
body: String
}
readonly
transport shell {
argv: ["curl", "--disable", "-sS", "--connect-timeout", "{connect_seconds}", "--max-time", "{max_seconds}", "--get", "--data-binary", "@-", "-w", "\n%\{http_code\}", "{url}"]
stdin: query
argv: ["curl", "--disable", "-fsS", "--connect-timeout", "{connect_seconds}", "--max-time", "{max_seconds}", "--get", "--data-binary", "@-", "{url}"]
}
stdin: query
exit {
0 => Unit
nonzero => String "http client GET with query within deadlines failed"
}
mock_response {
0 => { success: false, body: "" } "hermetic: no live HTTP endpoint"
}
exit { 0 => Unit nonzero => String "bounded GET with stdin query did not complete" }
mock_response { 0 => { success: false, body: "" } "hermetic: no live HTTP endpoint" }
}

operation PostStdinWithin {
Expand All @@ -252,12 +216,12 @@ service http.Client {
stderr: String from "stderr"
}
transport shell {
argv: ["curl", "-sS", "--connect-timeout", "{connect_seconds}", "--max-time", "{max_seconds}", "-X", "POST", "--data-binary", "@-", "-w", "\n%\{http_code\}", "{url}"]
stdin: request_body
argv: ["curl", "--disable", "-sS", "--connect-timeout", "{connect_seconds}", "--max-time", "{max_seconds}", "-X", "POST", "--data-binary", "@-", "-w", "\n%{http_code}", "{url}"]
}
stdin: request_body
exit {
0 => Unit
nonzero => String "curl POST from stdin did not complete"
nonzero => Unit
}
mock_response {
0 => { exit_code: 7, success: false, body: "", stderr: "hermetic: no live HTTP endpoint" } "hermetic: a transport that never connects, so a caller answers unreachable rather than fabricating a reply"
Expand All @@ -274,12 +238,12 @@ service http.Client {
stderr: String from "stderr"
}
transport shell {
argv: ["curl", "-sS", "--unix-socket", "{socket}", "--connect-timeout", "{connect_seconds}", "--max-time", "{max_seconds}", "-X", "POST", "--data-binary", "@-", "-w", "\n%\{http_code\}", "{url}"]
stdin: request_body
argv: ["curl", "--disable", "-sS", "--unix-socket", "{socket}", "--connect-timeout", "{connect_seconds}", "--max-time", "{max_seconds}", "--data-binary", "@-", "-w", "\n%{http_code}", "{url}"]
}
stdin: request_body
exit {
0 => Unit
nonzero => String "curl POST over a unix socket did not complete"
nonzero => Unit
}
mock_response {
0 => { exit_code: 7, success: false, body: "", stderr: "hermetic: no live socket" } "hermetic: a transport that never connects, so a caller answers unreachable rather than fabricating a reply"
Expand All @@ -292,42 +256,26 @@ service http.Client {
output { body: String from "stdout", success: Bool from "exit_success" }
readonly
transport shell {
argv: [
"curl",
"--fail-with-body",
"-sS",
"--max-time", "{max_seconds}",
"-X", "POST",
"{url}",
"-H", "Content-Type: application/json",
"--data", "@{request_body_file}",
]
argv: ["curl", "-fsS", "--max-time", "{max_seconds}", "-X", "POST", "-H", "Content-Type: application/json", "--data", "@{request_body_file}", "{url}"]
}
exit {
0 => Unit
nonzero => String "curl POST JSON from file failed"
nonzero => String "http client POST JSON from file failed"
}
mock_response {
0 => { body: "", success: false } "hermetic: no live HTTP endpoint; the POST from file refuses rather than fabricate a body"
}
}

operation PostFormFromFile {
input { url: NonEmptyStr, request_body_file: NonEmptyStr, max_seconds: NonEmptyStr }
output { body: String from "stdout", success: Bool from "exit_success" }
readonly
transport shell {
argv: [
"curl",
"--fail-with-body",
"-sS",
"--max-time", "{max_seconds}",
"-X", "POST",
"{url}",
"-H", "Content-Type: application/x-www-form-urlencoded",
"--data", "@{request_body_file}",
]
argv: ["curl", "-fsS", "--max-time", "{max_seconds}", "-X", "POST", "-H", "Content-Type: application/x-www-form-urlencoded", "--data", "@{request_body_file}", "{url}"]
}
exit {
0 => Unit
nonzero => String "curl form POST from file failed"
nonzero => String "http client POST form from file failed"
}
mock_response {
0 => { body: "", success: false } "hermetic: no live token endpoint; the code exchange refuses rather than fabricate a response"
Expand All @@ -337,64 +285,51 @@ service http.Client {
operation DownloadToFile {
requires Network
input { url: NonEmptyStr, destination: NonEmptyStr }
output { success: Bool from "exit_success" }
transport shell { argv: ["curl", "-fL", "{url}", "-o", "{destination}"] }
output { success: Bool }
transport shell {
argv: ["curl", "-fsS", "-o", "{destination}", "{url}"]
}
exit {
0 => Unit
nonzero => String "curl download failed"
nonzero => String "http client download failed"
}
}

operation GetFollowRedirectsBoundedWithHeaderFile {
requires Network
input { url: NonEmptyStr, max_seconds: NonEmptyStr, accept: NonEmptyStr, header_file: NonEmptyStr }
output { body: String from "stdout", success: Bool from "exit_success" }
output { body: String }
readonly
transport shell {
argv: [
"curl",
"-fsSL",
"--max-time", "{max_seconds}",
"-H", "{accept}",
"-H", "@{header_file}",
"{url}",
]
argv: ["curl", "-fL", "--max-time", "{max_seconds}", "-H", "Accept: {accept}", "-H", "@{header_file}", "{url}"]
}
exit {
0 => Unit
nonzero => String "http client bounded GET with header file failed"
nonzero => String "http client GET with redirects and header file failed"
}
mock_response {
0 => { body: "", success: false } "hermetic: no live registry; observation refuses rather than fabricate a body"
0 => { body: "" } "hermetic: no live registry; observation refuses rather than fabricate a body"
}
}

operation Probe {
requires Network
input { url: NonEmptyStr, connect_time: Second, max_time: Second }
output {
status: String from "stdout"
answered: Bool from "exit_success"
status: String from "stdout",
answered: Bool from "exit_success",
exit_code: Int from "exit_code"
}
readonly
transport shell {
argv: [
"curl",
"-sS",
"-o", "/dev/null",
"-w", "%\{http_code\}",
"--connect-timeout", http_client_max_time_flag(max_time: connect_time),
"--max-time", http_client_max_time_flag(max_time: max_time),
"{url}",
]
argv: ["curl", "-sS", "--connect-timeout", http_client_max_time_flag(max_time: connect_time), "--max-time", http_client_max_time_flag(max_time: max_time), "-o", "/dev/null", "-w", "%\{http_code}", "{url}"]
}
exit {
0 => Unit
nonzero => Unit
}
mock_response {
0 => { status: "", answered: false, exit_code: 7 } "hermetic: no live endpoint; a probe replay records that nothing answered"
0 => { status: "000", answered: false, exit_code: 7 } "hermetic: no live endpoint; a probe replay records that nothing answered"
}
}
}
}
12 changes: 8 additions & 4 deletions dag/extdeps/namecheap/client.dag
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import std.types { String, NonEmptyStr, Secret, Bool }
import extdeps.uri { uri_percent_encode_component, UriPercentComponentEncoded, UriPercentComponentRefused }
import extdeps.external_authority { ExternalAuthority }
import extdeps.uri { Uri, Https }
import extdeps.transports.rest { RestResult }
import extdeps.http.client
import extdeps.namecheap.read_hosts { NamecheapHostsRead, NamecheapHostsObserved, NamecheapHostsRefused, namecheap_read_hosts }

Expand All @@ -30,10 +31,13 @@ fn namecheap_get_hosts(account: NonEmptyStr, credential: Secret, client_ipv4: No
let query = join(["ApiUser=", account_wire as String, "&UserName=", account_wire as String, "&ApiKey=", key_wire as String,
"&ClientIp=", ip_wire as String, "&SLD=", sld_wire as String, "&TLD=", tld_wire as String,
"&Command=namecheap.domains.dns.getHosts"], "")
let got = http.Client.GetQueryStdinWithin(url: "https://api.namecheap.com/xml.response" as NonEmptyStr, query: query,
connect_seconds: "5" as NonEmptyStr, max_seconds: "30" as NonEmptyStr)
let n = string_length(s: got.body)
if !got.success || n < 4 || substring(s: got.body, start: n - 4, end: n) != "\n200" {
let got = http.Client.GetQueryStdinWithin(url: "https://api.namecheap.com/xml.response" as NonEmptyStr, query: query)
let got_body = match got {
RestAnswered { answer: b } => b
RestRefused { refusal: r } => return NamecheapHostsRefused { reason: concat("Namecheap getHosts transport refused: ", match r { RestStatusRefused { status: _, body: b } => b | RestTransportRefused { cause: c } => c | RestBodyUndecodable { status: _, cause: c } => c }) }
}
let n = string_length(s: got_body)
if n < 4 || substring(s: got_body, start: n - 4, end: n) != "\n200" {
NamecheapHostsRefused { reason: "Namecheap getHosts transport did not return HTTP 200" }
} else {
namecheap_read_hosts_for_publication(body: substring(s: got.body, start: 0, end: n - 4),
Expand Down
3 changes: 2 additions & 1 deletion dag/extdeps/provisioning/ubuntu_install_media_fetch.dag
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import extdeps.external_authority { ExternalAuthority }
import extdeps.uri { Uri, Https }
import gunbc.output_policy { OutcomeIsData }
import extdeps.shell
import extdeps.transports.rest { RestResult }
import extdeps.http.client
import extdeps.tools.sha256sum { sha256sum_verify_via_shell, Sha256Matches, Sha256Mismatch, Sha256VerifyInfraFailure }
import extdeps.provisioning.ubuntu_install_media {
Expand Down Expand Up @@ -130,7 +131,7 @@ fn attempt_install_media_mirror(subject: AcquisitionSubject, origin: Acquisition

fn attempt_install_media_download(subject: AcquisitionSubject, origin: AcquisitionSource, url: NonEmptyStr, part_path: NonEmptyStr) -> AcquisitionAttempt {
let downloaded = http.Client.DownloadToFile(url: url, destination: part_path)
if downloaded.success == false {
if downloaded is RestRefused { refusal: _ } {
AttemptUnreachable
} else {
match sha256sum_verify_via_shell(path: part_path, expected_digest: acquisition_key_digest(key: subject.key)) {
Expand Down
Loading
Loading