fakecloud_amplify/
state.rs1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct AppRecord {
35 pub app: Value,
37 #[serde(default)]
39 pub branches: BTreeMap<String, Value>,
40 #[serde(default)]
42 pub domains: BTreeMap<String, Value>,
43 #[serde(default)]
45 pub backend_environments: BTreeMap<String, Value>,
46 #[serde(default)]
49 pub jobs: BTreeMap<String, BTreeMap<String, Value>>,
50 #[serde(default)]
53 pub next_job_id: BTreeMap<String, u64>,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct AmplifyData {
59 #[serde(default)]
61 pub apps: BTreeMap<String, AppRecord>,
62 #[serde(default)]
65 pub webhooks: BTreeMap<String, Value>,
66}
67
68const 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 pub fn reconcile(&mut self) -> bool {
82 let mut changed = false;
83 for app in self.apps.values_mut() {
84 for domain in app.domains.values_mut() {
87 if advance_domain(domain) {
88 changed = true;
89 }
90 }
91 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
110fn 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
145fn 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 d.reconcile(); d.reconcile(); 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 assert!(!d.reconcile());
267 }
268}