Skip to main content

fakecloud_amplify/
state.rs

1//! Account-partitioned, serializable state for AWS Amplify's (`amplify`)
2//! control plane.
3//!
4//! Each resource is stored as its already-output-valid wire JSON object
5//! (`serde_json::Value`, camelCase members matching the restJson1 member
6//! names) so `Get*` echoes exactly what `Create*` / `Update*` persisted.
7//! Storing the wire shape directly keeps nested configuration
8//! (`customRules`, `cacheConfig`, `subDomains`, ...) round-tripping verbatim.
9//!
10//! Apps own their branches, domain associations, backend environments, and
11//! per-branch jobs; webhooks live in an account-global map keyed by webhook id
12//! (they are addressed directly by `GET /webhooks/{webhookId}`). Every map key
13//! is a plain `String` so the snapshot never depends on the tuple-key serde
14//! adapter that has silently broken snapshot serialization on other services.
15//!
16//! The async domain-verification and job-build lifecycles are modelled by
17//! advancing the stored `domainStatus` / job `status` on the next read; the
18//! same reconciliation runs on restart so an interrupted lifecycle never
19//! wedges.
20
21use std::collections::BTreeMap;
22use std::sync::Arc;
23
24use parking_lot::RwLock;
25use serde::{Deserialize, Serialize};
26use serde_json::{json, Value};
27
28use fakecloud_core::multi_account::{AccountState, MultiAccountState};
29
30pub const AMPLIFY_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
31
32/// One Amplify app plus the child resources it owns.
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct AppRecord {
35    /// The `App` wire object (camelCase members matching `GetApp`).
36    pub app: Value,
37    /// Branches keyed by `branchName`; each value is a `Branch` wire object.
38    #[serde(default)]
39    pub branches: BTreeMap<String, Value>,
40    /// Domain associations keyed by `domainName`; `DomainAssociation` objects.
41    #[serde(default)]
42    pub domains: BTreeMap<String, Value>,
43    /// Backend environments keyed by `environmentName`.
44    #[serde(default)]
45    pub backend_environments: BTreeMap<String, Value>,
46    /// Jobs keyed by `branchName` then `jobId`; each value is a `Job` object
47    /// (`{ "summary": {...}, "steps": [...] }`).
48    #[serde(default)]
49    pub jobs: BTreeMap<String, BTreeMap<String, Value>>,
50    /// Next job id to allocate, per branch (Amplify job ids are numeric and
51    /// monotonically increasing within a branch).
52    #[serde(default)]
53    pub next_job_id: BTreeMap<String, u64>,
54}
55
56/// Per-account AWS Amplify state.
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct AmplifyData {
59    /// Apps keyed by `appId`.
60    #[serde(default)]
61    pub apps: BTreeMap<String, AppRecord>,
62    /// Webhooks keyed by `webhookId`; each value is a `Webhook` wire object
63    /// (carrying its owning `appId`).
64    #[serde(default)]
65    pub webhooks: BTreeMap<String, Value>,
66}
67
68/// The ordered, non-terminal job build stages. `StartJob` enters at `PENDING`;
69/// each read advances one stage until `SUCCEED`.
70const JOB_STAGES: &[&str] = &["PENDING", "PROVISIONING", "RUNNING", "SUCCEED"];
71
72fn next_job_stage(current: &str) -> Option<&'static str> {
73    let idx = JOB_STAGES.iter().position(|s| *s == current)?;
74    JOB_STAGES.get(idx + 1).copied()
75}
76
77impl AmplifyData {
78    /// Advance any in-flight domain / job lifecycle one step so an interrupted
79    /// transition never wedges. Used both on the next read and on restart.
80    /// Returns `true` if anything changed.
81    pub fn reconcile(&mut self) -> bool {
82        let mut changed = false;
83        for app in self.apps.values_mut() {
84            // Domains: settle a freshly-created / updating domain to AVAILABLE
85            // and mark its subdomains verified.
86            for domain in app.domains.values_mut() {
87                if advance_domain(domain) {
88                    changed = true;
89                }
90            }
91            // Jobs: advance each non-terminal build one stage.
92            let app_id = app
93                .app
94                .get("appId")
95                .and_then(Value::as_str)
96                .unwrap_or_default()
97                .to_string();
98            for (branch, jobs) in app.jobs.iter_mut() {
99                for (job_id, job) in jobs.iter_mut() {
100                    if advance_job(job, &app_id, branch, job_id) {
101                        changed = true;
102                    }
103                }
104            }
105        }
106        changed
107    }
108}
109
110/// Advance a domain association toward `AVAILABLE`. A `CREATING` /
111/// `IN_PROGRESS` / `UPDATING` / `PENDING_VERIFICATION` domain becomes
112/// `AVAILABLE` and its subdomains are marked verified.
113fn advance_domain(domain: &mut Value) -> bool {
114    let Some(obj) = domain.as_object_mut() else {
115        return false;
116    };
117    let status = obj
118        .get("domainStatus")
119        .and_then(Value::as_str)
120        .unwrap_or("");
121    let in_flight = matches!(
122        status,
123        "CREATING"
124            | "IN_PROGRESS"
125            | "UPDATING"
126            | "PENDING_VERIFICATION"
127            | "PENDING_DEPLOYMENT"
128            | "REQUESTING_CERTIFICATE"
129    );
130    if !in_flight {
131        return false;
132    }
133    obj.insert("domainStatus".into(), json!("AVAILABLE"));
134    obj.insert("updateStatus".into(), json!("UPDATE_COMPLETE"));
135    if let Some(subs) = obj.get_mut("subDomains").and_then(Value::as_array_mut) {
136        for sub in subs.iter_mut() {
137            if let Some(s) = sub.as_object_mut() {
138                s.insert("verified".into(), json!(true));
139            }
140        }
141    }
142    true
143}
144
145/// Advance a job's build one stage. On reaching `SUCCEED` it stamps `endTime`
146/// and records a single completed `BUILD` step. Terminal jobs (`SUCCEED`,
147/// `FAILED`, `CANCELLED`) are left untouched.
148fn advance_job(job: &mut Value, app_id: &str, branch: &str, job_id: &str) -> bool {
149    let Some(obj) = job.as_object_mut() else {
150        return false;
151    };
152    let status = obj
153        .get("summary")
154        .and_then(|s| s.get("status"))
155        .and_then(Value::as_str)
156        .unwrap_or("")
157        .to_string();
158    let Some(next) = next_job_stage(&status) else {
159        return false;
160    };
161    let now = crate::shared::now_epoch();
162    if let Some(summary) = obj.get_mut("summary").and_then(Value::as_object_mut) {
163        summary.insert("status".into(), json!(next));
164        if next == "SUCCEED" {
165            summary.insert("endTime".into(), json!(now));
166        }
167    }
168    if next == "SUCCEED" {
169        let _ = (app_id, branch, job_id);
170        let start = obj
171            .get("summary")
172            .and_then(|s| s.get("startTime"))
173            .cloned()
174            .unwrap_or_else(|| json!(now));
175        obj.insert(
176            "steps".into(),
177            json!([{
178                "stepName": "BUILD",
179                "startTime": start,
180                "status": "SUCCEED",
181                "endTime": now,
182            }]),
183        );
184    }
185    true
186}
187
188impl AccountState for AmplifyData {
189    fn new_for_account(_account_id: &str, _region: &str, _endpoint: &str) -> Self {
190        Self::default()
191    }
192}
193
194pub type SharedAmplifyState = Arc<RwLock<MultiAccountState<AmplifyData>>>;
195
196#[derive(Debug, Serialize, Deserialize)]
197pub struct AmplifySnapshot {
198    pub schema_version: u32,
199    pub accounts: MultiAccountState<AmplifyData>,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn app_with_domain(status: &str) -> AmplifyData {
207        let mut d = AmplifyData::default();
208        let mut rec = AppRecord {
209            app: json!({ "appId": "d1" }),
210            ..Default::default()
211        };
212        rec.domains.insert(
213            "example.com".to_string(),
214            json!({
215                "domainStatus": status,
216                "subDomains": [{ "verified": false }]
217            }),
218        );
219        d.apps.insert("d1".to_string(), rec);
220        d
221    }
222
223    #[test]
224    fn reconcile_settles_domain_to_available() {
225        let mut d = app_with_domain("CREATING");
226        assert!(d.reconcile());
227        let dom = &d.apps["d1"].domains["example.com"];
228        assert_eq!(dom["domainStatus"], json!("AVAILABLE"));
229        assert_eq!(dom["subDomains"][0]["verified"], json!(true));
230    }
231
232    #[test]
233    fn reconcile_available_domain_is_noop() {
234        let mut d = app_with_domain("AVAILABLE");
235        assert!(!d.reconcile());
236    }
237
238    #[test]
239    fn reconcile_advances_job_one_stage() {
240        let mut d = AmplifyData::default();
241        let mut rec = AppRecord {
242            app: json!({ "appId": "d1" }),
243            ..Default::default()
244        };
245        let mut branch_jobs = BTreeMap::new();
246        branch_jobs.insert(
247            "1".to_string(),
248            json!({ "summary": { "status": "PENDING", "startTime": 1.0 }, "steps": [] }),
249        );
250        rec.jobs.insert("main".to_string(), branch_jobs);
251        d.apps.insert("d1".to_string(), rec);
252
253        assert!(d.reconcile());
254        assert_eq!(
255            d.apps["d1"].jobs["main"]["1"]["summary"]["status"],
256            json!("PROVISIONING")
257        );
258        // Walk to terminal.
259        d.reconcile(); // RUNNING
260        d.reconcile(); // SUCCEED
261        let job = &d.apps["d1"].jobs["main"]["1"];
262        assert_eq!(job["summary"]["status"], json!("SUCCEED"));
263        assert!(job["summary"].get("endTime").is_some());
264        assert_eq!(job["steps"][0]["stepName"], json!("BUILD"));
265        // Terminal is a no-op.
266        assert!(!d.reconcile());
267    }
268}