Skip to content

Commit 34ea773

Browse files
committed
fix(guest-agent): answer 501, not 400, when the image cannot attest a GPU
`AttestGpu` reported "GPU attestation is not available in this image" as an uncoded error, which `dispatch_prpc` turns into the generic 400. That tells a client its request was malformed. It was not: the request is well-formed, and no other request would succeed either, because the image ships no nvattest and will not grow one at runtime. A client branching on the status retries with different arguments forever instead of falling back. `ra_rpc::ErrorExt::with_code` already carries a chosen status through the transport, and `code_of` walks the whole error chain, so the code survives the handler's `.context("GPU attestation failed")`. 501 rather than 503 because the capability is absent for the lifetime of the CVM, not temporarily unavailable. A malformed nonce keeps the default 400 -- that one really is the caller's fault, and the two failures must not be indistinguishable. Safe to change now: `AttestGpu` is v1-only and never shipped in a release, so no deployed client is reading the old 400. `rpc_service_v1.rs` already asserts it is absent from both frozen surfaces. The availability probe moves from `nvattest::available()` to a `GpuAttestor` field holding the binary's path. Without that, a test for the unavailable answer would pass only on a host with no nvattest installed and would spawn a real collection against the host's GPUs anywhere else. The test fixture pins it to a path that cannot exist, so every guest-agent test sees the same answer. Documented in the proto comment, the spec's Errors and Status codes sections, and the curl API reference, which listed only 400 and 500.
1 parent f9f9c50 commit 34ea773

6 files changed

Lines changed: 103 additions & 14 deletions

File tree

docs/guest-api-v1.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,12 @@ returns JSON.
103103
| 404 | the server's own 404 page | No such mount: this agent has no surface at that path |
104104
| 404 | `{"error": "Service not found: <Method>"}` | The surface is mounted; it has no such method |
105105
| 400 | `{"error": "<message>"}` | The method ran and failed |
106+
| 501 | `{"error": "<message>"}` | `AttestGpu` on an image that ships no GPU attestation |
106107
| other | `{"error": "<message>"}` | A handler chose the status; the message says why |
107108

108-
A handler failure is a 400 with the error text in the body. v1 does not default,
109-
coerce, or truncate a malformed request into a well-formed one.
109+
A handler failure is a 400 with the error text in the body unless the handler
110+
chose otherwise, and `AttestGpu` is the only v1 method that does. v1 does not
111+
default, coerce, or truncate a malformed request into a well-formed one.
110112

111113
### Detecting an agent without v1
112114

@@ -640,19 +642,20 @@ which is the authoritative description.
640642
| Derived secp256k1 scalar out of range | Error; the caller picks another domain |
641643
| `report_data` longer than 64 bytes | Error |
642644
| `nonce` not exactly 32 bytes | Error naming the required length |
643-
| GPU attestation unavailable in this image | Error; `AttestGpu` has nothing to collect |
645+
| GPU attestation unavailable in this image | HTTP 501; `AttestGpu` has nothing to collect |
644646
| `not_before` not earlier than `not_after` | Error |
645647
| Unknown method on a mounted surface | HTTP 404, `Service not found: <Method>` |
646648
| `/v1/...` on an agent that predates v1 | HTTP 404, no such mount |
647649

648-
Everything above the last two rows is an HTTP 400 with the message in the body.
649-
See [Status codes](#status-codes) for the full mapping. v1 does not default,
650-
coerce, or truncate a malformed request into a well-formed one.
650+
Every row that does not name a status is an HTTP 400 with the message in the
651+
body. See [Status codes](#status-codes) for the full mapping. v1 does not
652+
default, coerce, or truncate a malformed request into a well-formed one.
651653

652-
A 400 here means "the method ran and failed", not "your request was malformed".
653-
The GPU-unavailable row is a property of the image, not of the call: a client
654-
that gets it should stop rather than retry with different arguments, and the
655-
message in the body is the only thing that separates the two cases.
654+
The GPU row is a 501 rather than a 400 because it is a property of the image,
655+
not of the call: the request is well-formed, and no other request would succeed
656+
either. A client that reads 400 there retries with different arguments forever;
657+
one that reads 501 stops and falls back to whatever it does without a GPU. A
658+
malformed nonce is still the caller's fault and still a 400.
656659

657660
## Migration from the unversioned API
658661

dstack/guest-agent/rpc/proto/agent_rpc_v1.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ service DstackGuest {
108108
// Returns vendor-native evidence, not a local verdict, so a relying party
109109
// can appraise it with its own verifier. Evidence still does not bind the
110110
// GPU to this TD; see `AttestGpuResponse.bundles`.
111+
//
112+
// An image that ships no GPU attestation answers HTTP 501, not the 400 every
113+
// other v1 failure uses. The request is well-formed there and no other
114+
// request would succeed either, so a caller can tell "fall back" from "fix
115+
// your arguments" on the status alone. A malformed nonce stays a 400.
111116
rpc AttestGpu(AttestGpuRequest) returns (AttestGpuResponse) {}
112117

113118
// Return this application's identity and measurements.

dstack/guest-agent/src/gpu_attest.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
//! process and talks to the GPU through the driver. The gate below serialises
99
//! collection so concurrent callers do not compete for the same devices.
1010
11+
use std::path::{Path, PathBuf};
1112
use std::time::Duration;
1213

13-
use anyhow::{bail, Result};
14+
use anyhow::{anyhow, bail, Result};
15+
use ra_rpc::ErrorExt;
1416
use tokio::sync::Mutex;
1517

1618
/// Serialises GPU evidence collection.
@@ -20,13 +22,28 @@ use tokio::sync::Mutex;
2022
/// challenge is exactly the confusion this API exists to avoid.
2123
pub struct GpuAttestor {
2224
timeout: Duration,
25+
/// The binary whose presence says this image can attest a GPU.
26+
///
27+
/// A field rather than a call to `nvattest::available()` so a test can pin
28+
/// the unavailable answer on a host that happens to have nvattest
29+
/// installed -- otherwise that test would spawn a real collection against
30+
/// whatever GPUs the host has.
31+
nvattest: PathBuf,
2332
run_lock: Mutex<()>,
2433
}
2534

2635
impl GpuAttestor {
2736
pub fn new() -> Self {
37+
Self::with_nvattest_path(nvattest::NVATTEST)
38+
}
39+
40+
/// `pub(crate)` so the handler tests can build a state that is
41+
/// deterministically without GPU support, whatever the host running
42+
/// them has installed.
43+
pub(crate) fn with_nvattest_path(path: impl AsRef<Path>) -> Self {
2844
Self {
2945
timeout: nvattest::DEFAULT_TIMEOUT,
46+
nvattest: path.as_ref().to_path_buf(),
3047
run_lock: Mutex::new(()),
3148
}
3249
}
@@ -40,8 +57,16 @@ impl GpuAttestor {
4057
nonce.len()
4158
);
4259
}
43-
if !nvattest::available() {
44-
bail!("GPU attestation is not available in this image");
60+
if !self.nvattest.exists() {
61+
// 501, not the 400 an uncoded error would report. The request is
62+
// well-formed and no other request would succeed: this image ships
63+
// no nvattest, so the capability is absent for the lifetime of the
64+
// CVM. A caller that reads 400 retries with different arguments
65+
// forever; one that reads 501 stops and falls back.
66+
//
67+
// Safe to say now because `AttestGpu` is v1-only and has never
68+
// shipped in a release -- no client is reading 400 here today.
69+
return Err(anyhow!("GPU attestation is not available in this image").with_code(501));
4570
}
4671
// Held across the whole run, so a second caller waits rather than
4772
// starting a competing nvattest against the same devices.
@@ -61,4 +86,23 @@ mod tests {
6186
let err = attestor.attest(&[0u8; 16]).await.unwrap_err().to_string();
6287
assert!(err.contains("exactly 32 bytes"), "{err}");
6388
}
89+
90+
/// A malformed nonce is the caller's fault; an image without nvattest is
91+
/// not. The two must not answer with the same status.
92+
///
93+
/// The nonce here is well-formed, so this reaches the availability check
94+
/// rather than stopping at the length check above.
95+
#[tokio::test]
96+
async fn an_image_without_nvattest_answers_not_implemented() {
97+
let attestor = GpuAttestor::with_nvattest_path("/nonexistent/nvattest");
98+
let err = attestor.attest(&[0u8; 32]).await.unwrap_err();
99+
assert!(
100+
err.to_string().contains("not available in this image"),
101+
"{err}"
102+
);
103+
assert_eq!(ra_rpc::code_of(&err), Some(501));
104+
105+
let malformed = attestor.attest(&[0u8; 16]).await.unwrap_err();
106+
assert_eq!(ra_rpc::code_of(&malformed), None, "a bad nonce stays a 400");
107+
}
64108
}

dstack/guest-agent/src/rpc_service.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1325,7 +1325,12 @@ pNs85uhOZE8z2jr8Pg==
13251325
probe,
13261326
}),
13271327
health: None,
1328-
gpu_attestor: crate::gpu_attest::GpuAttestor::new(),
1328+
// Pinned to a path that cannot exist, so no test ever spawns a
1329+
// real collection against a host GPU and every test sees the
1330+
// same "this image cannot attest a GPU" answer.
1331+
gpu_attestor: crate::gpu_attest::GpuAttestor::with_nvattest_path(
1332+
"/nonexistent/nvattest",
1333+
),
13291334
app_root_signing_key: SigningKey::from_slice(&DUMMY_K256_KEY).ok(),
13301335
identity: RwLock::new(None),
13311336
identity_last_failure: Mutex::new(None),

dstack/guest-agent/src/rpc_service_v1.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,10 +826,35 @@ mod tests {
826826
})
827827
.await
828828
.unwrap_err();
829+
assert_eq!(
830+
ra_rpc::code_of(&err),
831+
None,
832+
"a malformed nonce is the caller's fault, so it keeps the default 400"
833+
);
829834
let err = format!("{err:#}");
830835
assert!(err.contains("exactly 32 bytes"), "{err}");
831836
}
832837

838+
/// An image that ships no nvattest must not answer like a malformed
839+
/// request: the call is well-formed and no other call would succeed, so
840+
/// the caller has to be able to tell "stop" from "try something else".
841+
///
842+
/// Asserted through the handler because that is where `.context(..)` wraps
843+
/// the attestor's error. `code_of` walks the whole chain for exactly this
844+
/// reason -- a context layer that buried the code would silently put the
845+
/// answer back to 400, and the message would still read correctly.
846+
#[tokio::test]
847+
async fn attest_gpu_reports_an_image_without_gpu_support_as_not_implemented() {
848+
let (state, _guard) = state().await;
849+
let err = V1RpcHandler::new(state)
850+
.attest_gpu(AttestGpuRequest {
851+
nonce: vec![0u8; 32],
852+
})
853+
.await
854+
.unwrap_err();
855+
assert_eq!(ra_rpc::code_of(&err), Some(501), "{err:#}");
856+
}
857+
833858
/// The on-demand format tag, pinned like its boot-time counterpart.
834859
///
835860
/// A consumer selects its verifier on `(vendor, format)`, so these two

sdk/curl/api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,11 @@ the evidence signature, certificate chain, measurements, and embedded nonce. The
331331
agent does not appraise the evidence. Evidence does not by itself bind the GPU to this
332332
CVM.
333333

334+
> An image that ships no GPU attestation answers `501 Not Implemented`, not
335+
> `400`. Retrying with a different nonce will not help; fall back to whatever
336+
> your application does without a GPU. A nonce that is not exactly 32 bytes is
337+
> still a `400`.
338+
334339
### 8. Boot-time GPU evidence *(v1)*
335340

336341
Returns the complete output NVIDIA `nvattest` produced during boot, as part of
@@ -415,6 +420,8 @@ All endpoints may return the following HTTP status codes:
415420

416421
- `200 OK`: Request successful
417422
- `400 Bad Request`: Invalid request parameters
423+
- `501 Not Implemented`: The agent cannot serve this method in this image; only
424+
`/v1/AttestGpu` answers this, and only when the image ships no GPU attestation
418425
- `500 Internal Server Error`: Server-side error
419426

420427
Error responses will include a JSON body with error details:

0 commit comments

Comments
 (0)