Skip to main content

fakecloud_sagemaker/
service.rs

1//! Amazon SageMaker (`sagemaker`) awsJson1.1 dispatch.
2//!
3//! Requests are routed to one of the ~403 modelled operations by the
4//! `X-Amz-Target: SageMaker.<Operation>` header (surfaced as
5//! [`AwsRequest::action`]). All inputs arrive in the JSON body. Input is
6//! validated against the model-derived constraints ([`crate::validate`]), then
7//! handled by the generic resource engine ([`engine`], for the create /
8//! describe / list / update / delete verb of every named resource family) or by
9//! a resource-specific handler ([`special`], for tagging). State is
10//! account-partitioned and persisted.
11
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use http::StatusCode;
16use serde_json::{Map, Value};
17use tokio::sync::Mutex as AsyncMutex;
18
19use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
20use fakecloud_persistence::SnapshotStore;
21
22use crate::generated::{OpMeta, Verb, OPS};
23use crate::persistence::save_snapshot;
24use crate::state::SharedSageMakerState;
25
26mod engine;
27mod special;
28#[cfg(test)]
29mod tests;
30
31/// Every operation name in the Amazon SageMaker Smithy model.
32pub use crate::generated::ACTIONS as SAGEMAKER_ACTIONS;
33
34/// SageMaker's service-wide "shared error responses": codes the live API can
35/// return from essentially any operation for a missing / duplicate / in-use /
36/// malformed resource, even where the per-operation Smithy `errors:` list omits
37/// them (the SageMaker model declares only four error shapes total). These are
38/// accepted alongside each operation's declared errors.
39pub const COMMON_ERRORS: &[&str] = &[
40    "ResourceNotFound",
41    "ResourceInUse",
42    "ResourceLimitExceeded",
43    "ConflictException",
44    "ValidationException",
45];
46
47pub struct SageMakerService {
48    state: SharedSageMakerState,
49    snapshot_store: Option<Arc<dyn SnapshotStore>>,
50    snapshot_lock: Arc<AsyncMutex<()>>,
51}
52
53impl SageMakerService {
54    pub fn new(state: SharedSageMakerState) -> Self {
55        Self {
56            state,
57            snapshot_store: None,
58            snapshot_lock: Arc::new(AsyncMutex::new(())),
59        }
60    }
61
62    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
63        self.snapshot_store = Some(store);
64        self
65    }
66
67    async fn save(&self) {
68        save_snapshot(
69            &self.state,
70            self.snapshot_store.clone(),
71            &self.snapshot_lock,
72        )
73        .await;
74    }
75
76    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
77    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
78        let store = self.snapshot_store.clone()?;
79        let state = self.state.clone();
80        let lock = self.snapshot_lock.clone();
81        Some(Arc::new(move || {
82            let state = state.clone();
83            let store = store.clone();
84            let lock = lock.clone();
85            Box::pin(async move {
86                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
87            })
88        }))
89    }
90}
91
92#[async_trait]
93impl AwsService for SageMakerService {
94    fn service_name(&self) -> &str {
95        "sagemaker"
96    }
97
98    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
99        let Some(meta) = OPS.iter().find(|m| m.op == req.action) else {
100            return Err(AwsServiceError::action_not_implemented(
101                "sagemaker",
102                &req.action,
103            ));
104        };
105        let (resp, mutated) = self.dispatch(meta, &req)?;
106        if mutated && resp.status.is_success() {
107            self.save().await;
108        }
109        Ok(resp)
110    }
111
112    fn supported_actions(&self) -> &[&str] {
113        SAGEMAKER_ACTIONS
114    }
115}
116
117/// Per-request context.
118pub(crate) struct Ctx {
119    pub account: String,
120    pub region: String,
121}
122
123impl SageMakerService {
124    fn dispatch(
125        &self,
126        meta: &'static OpMeta,
127        req: &AwsRequest,
128    ) -> Result<(AwsResponse, bool), AwsServiceError> {
129        let ctx = Ctx {
130            account: req.account_id.clone(),
131            region: if req.region.is_empty() {
132                "us-east-1".to_string()
133            } else {
134                req.region.clone()
135            },
136        };
137        let body = parse_body(&req.body);
138        crate::validate::validate(meta, &body)?;
139
140        // Resource-specific handlers (tagging) take precedence over the engine.
141        if let Some(res) = special::dispatch(self, meta, &ctx, &body)? {
142            return Ok(res);
143        }
144
145        match meta.verb {
146            Verb::Create => {
147                let mut g = self.state.write();
148                let data = g.get_or_create(&ctx.account);
149                Ok((engine::create(data, &ctx, meta, &body)?, true))
150            }
151            Verb::Update => {
152                let mut g = self.state.write();
153                let data = g.get_or_create(&ctx.account);
154                Ok((engine::update(data, &ctx, meta, &body)?, true))
155            }
156            Verb::Delete => {
157                let mut g = self.state.write();
158                let data = g.get_or_create(&ctx.account);
159                Ok((engine::delete(data, &ctx, meta, &body), true))
160            }
161            Verb::Get => {
162                let g = self.state.read();
163                let data = g.get(&ctx.account);
164                Ok((engine::get(data, &ctx, meta, &body)?, false))
165            }
166            Verb::List => {
167                let g = self.state.read();
168                let data = g.get(&ctx.account);
169                Ok((engine::list(data, &ctx, meta, &body), false))
170            }
171            Verb::Action => {
172                // Any action not claimed by `special` is accepted as a
173                // control-plane no-op whose response carries every required
174                // output member (synthesised from the request identifier).
175                Ok((engine::action(&ctx, meta, &body), false))
176            }
177        }
178    }
179}
180
181// ===================== shared helpers =====================
182
183pub(crate) fn ok_json(v: Value) -> AwsResponse {
184    AwsResponse::json_value(StatusCode::OK, v)
185}
186
187pub(crate) fn parse_body(body: &[u8]) -> Map<String, Value> {
188    if body.is_empty() {
189        return Map::new();
190    }
191    match serde_json::from_slice::<Value>(body) {
192        Ok(Value::Object(m)) => m,
193        _ => Map::new(),
194    }
195}
196
197/// Current time as an awsJson1.1 timestamp: Unix epoch **seconds** encoded as a
198/// JSON number, with fractional milliseconds preserved (e.g. `1752324947.041`).
199/// This is the default `timestamp` wire format for the awsJson1.1 protocol, and
200/// is what the aws-sdk / aws-smithy timestamp deserializer expects — an RFC3339
201/// string would be rejected.
202pub(crate) fn now_epoch() -> Value {
203    let millis = chrono::Utc::now().timestamp_millis();
204    Value::from(millis as f64 / 1000.0)
205}
206
207pub(crate) fn mint_arn(ctx: &Ctx, arn_path: &str, name: &str) -> String {
208    format!(
209        "arn:aws:sagemaker:{}:{}:{}/{}",
210        ctx.region, ctx.account, arn_path, name
211    )
212}
213
214/// Deterministic UUID-shaped id derived from account + family + seed.
215pub(crate) fn mint_id(account: &str, family: &str, seed: &str) -> String {
216    let base = format!("{account}:{family}:{seed}");
217    let h = fnv(&base);
218    let h2 = fnv(&format!("{base}:2"));
219    format!(
220        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
221        (h >> 32) as u32,
222        (h >> 16) as u16,
223        h as u16,
224        (h2 >> 48) as u16,
225        h2 & 0xffff_ffff_ffff
226    )
227}
228
229fn fnv(s: &str) -> u64 {
230    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
231    for b in s.bytes() {
232        h ^= b as u64;
233        h = h.wrapping_mul(0x0100_0000_01b3);
234    }
235    h
236}
237
238pub(crate) fn not_found(msg: impl Into<String>) -> AwsServiceError {
239    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFound", msg)
240}
241
242pub(crate) fn in_use(msg: impl Into<String>) -> AwsServiceError {
243    AwsServiceError::aws_error(StatusCode::CONFLICT, "ResourceInUse", msg)
244}