1use std::collections::{BTreeMap, BTreeSet, VecDeque};
8use std::io::Read;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::Context;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16
17use crate::discovery;
18use crate::doctor::{Diagnostic, DiagnosticSeverity, DoctorReport};
19
20pub const SETUP_MACHINE_EXTENSION: &str = "greentic.setup.machine.v1";
21pub const SETUP_MACHINE_SCHEMA_URL: &str = "https://schemas.greentic.ai/setup-machine/v1";
22pub const SETUP_BACKEND_CONTRACT_EXTENSION: &str = "greentic.setup.backend-contract.v1";
23pub const SETUP_WEB_COMPONENT_EXTENSION: &str = "greentic.setup.web-component.v1";
24pub const SETUP_I18N_EXTENSION: &str = "greentic.setup.i18n.v1";
25pub const HTTP_ROUTES_EXTENSION: &str = "greentic.http-routes.v1";
26const SETUP_MACHINE_SCHEMA: &str = include_str!("../schemas/setup-machine.v1.schema.json");
27const SETUP_BACKEND_CONTRACT_SCHEMA: &str =
28 include_str!("../schemas/setup-backend-contract.v1.schema.json");
29
30const TERMINAL_SUCCESS: &str = "complete";
31const TERMINAL_FAILED: &str = "failed";
32const SUPPORTED_SETUP_STATE_SCHEMA_VERSION: u32 = 1;
33
34const SUPPORTED_STEP_KINDS: &[&str] = &[
35 "qa_form",
36 "platform_public_url",
37 "cloudflare_tunnel",
38 "oauth_device_code",
39 "oauth_authorization_code",
40 "http_probe",
41 "http_json",
42 "select_from_json",
43 "generate_file",
44 "download_file",
45 "manual_action",
46 "provider_component_call",
47 "persist_runtime_config",
48];
49
50const SUPPORTED_BACKEND_EXECUTOR_KINDS: &[&str] = &[
51 "oauth_device_code",
52 "microsoft_graph_application",
53 "provider_http",
54 "microsoft_graph_teams_app_catalog_publish",
55 "microsoft_graph_teams_app_user_install",
56 "runtime_observation",
57];
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
60pub struct SetupMachine {
61 pub version: u32,
62 pub id: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub display_name: Option<String>,
65 pub entry_step: String,
66 #[serde(default)]
67 pub steps: Vec<SetupMachineStep>,
68 #[serde(flatten)]
69 pub extra: serde_json::Map<String, Value>,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
73pub struct SetupMachineStep {
74 pub id: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub title: Option<String>,
77 pub kind: String,
78 #[serde(default)]
79 pub requires: Vec<String>,
80 #[serde(default)]
81 pub outputs: Vec<Value>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub on_success: Option<String>,
84 #[serde(default)]
85 pub on_failure: Vec<Value>,
86 #[serde(default)]
87 pub recover: Vec<Value>,
88 #[serde(flatten)]
89 pub extra: serde_json::Map<String, Value>,
90}
91
92#[derive(Clone, Debug, Serialize, Deserialize)]
93pub struct SetupMachineState {
94 pub schema_version: u32,
95 pub provider_id: String,
96 pub tenant: String,
97 pub team: String,
98 pub machine_id: String,
99 pub machine_version: u32,
100 pub status: SetupMachineStatus,
101 pub current_step: Option<String>,
102 #[serde(default)]
103 pub completed_steps: Vec<String>,
104 #[serde(default)]
105 pub failed_step: Option<String>,
106 #[serde(default)]
107 pub answers_hash: Option<String>,
108 #[serde(default)]
109 pub pack_fingerprint: Option<String>,
110 #[serde(default)]
111 pub outputs: Value,
112 #[serde(default)]
113 pub created_resources: Vec<Value>,
114 #[serde(default)]
115 pub last_error: Option<Value>,
116 #[serde(default)]
117 pub updated_at: Option<String>,
118}
119
120#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "snake_case")]
122pub enum SetupMachineStatus {
123 NotStarted,
124 Running,
125 Paused,
126 Complete,
127 Failed,
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize)]
131pub struct SetupMachineEvent {
132 pub ts: String,
133 pub level: String,
134 pub event: String,
135 pub provider_id: String,
136 pub tenant: String,
137 pub team: String,
138 pub machine_id: String,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub step_id: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub correlation_id: Option<String>,
143 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
144 pub fields: serde_json::Map<String, Value>,
145}
146
147pub fn load_setup_machine_from_pack(pack_path: &Path) -> anyhow::Result<Option<SetupMachine>> {
148 let Some(extension) = discovery::read_pack_extension(pack_path, SETUP_MACHINE_EXTENSION)?
149 else {
150 return Ok(None);
151 };
152 let machine_value = resolve_machine_value(pack_path, extension)?;
153 Ok(Some(serde_json::from_value(machine_value)?))
154}
155
156pub fn validate_setup_machine(machine: &SetupMachine) -> Vec<Diagnostic> {
157 let mut diagnostics = Vec::new();
158
159 if machine.version == 0 {
160 diagnostics.push(error(
161 "setup.machine.version",
162 "setup machine version must be greater than zero",
163 ));
164 }
165 if machine.id.trim().is_empty() {
166 diagnostics.push(error("setup.machine.id", "setup machine id is required"));
167 }
168 if machine.entry_step.trim().is_empty() {
169 diagnostics.push(error(
170 "setup.machine.entry_step",
171 "setup machine entry_step is required",
172 ));
173 }
174 if machine.steps.is_empty() {
175 diagnostics.push(error(
176 "setup.machine.steps",
177 "setup machine must declare at least one step",
178 ));
179 return diagnostics;
180 }
181
182 let mut seen = BTreeSet::new();
183 let mut step_ids = BTreeSet::new();
184 for step in &machine.steps {
185 if step.id.trim().is_empty() {
186 diagnostics.push(error("setup.machine.step.id", "setup step id is required"));
187 continue;
188 }
189 if !seen.insert(step.id.clone()) {
190 diagnostics.push(error_with_actual(
191 "setup.machine.step.unique_id",
192 "setup step ids must be unique",
193 &step.id,
194 ));
195 }
196 step_ids.insert(step.id.clone());
197 if step.kind.trim().is_empty() {
198 diagnostics.push(error_with_actual(
199 "setup.machine.step.kind",
200 "setup step kind is required",
201 &step.id,
202 ));
203 } else if !SUPPORTED_STEP_KINDS.contains(&step.kind.as_str()) {
204 diagnostics.push(error_with_actual(
205 "setup.machine.step.supported_kind",
206 "setup step kind is not supported by this greentic-setup version",
207 &step.kind,
208 ));
209 }
210 for required in &step.requires {
211 if required.trim().is_empty() {
212 diagnostics.push(error_with_actual(
213 "setup.machine.step.requires",
214 "setup step requires entries must be non-empty",
215 &step.id,
216 ));
217 }
218 }
219 }
220
221 if !step_ids.contains(&machine.entry_step) {
222 diagnostics.push(error_with_actual(
223 "setup.machine.entry_step.exists",
224 "setup machine entry_step must reference an existing step",
225 &machine.entry_step,
226 ));
227 }
228
229 for step in &machine.steps {
230 validate_transition(
231 &mut diagnostics,
232 &step_ids,
233 &step.id,
234 "setup.machine.step.on_success",
235 step.on_success.as_deref(),
236 );
237 for rule in step.on_failure.iter().chain(step.recover.iter()) {
238 for target in rule_targets(rule) {
239 validate_transition(
240 &mut diagnostics,
241 &step_ids,
242 &step.id,
243 "setup.machine.step.rule_target",
244 Some(&target),
245 );
246 }
247 }
248 }
249
250 diagnostics.extend(validate_reachability(machine, &step_ids));
251 diagnostics.extend(validate_unbounded_cycles(machine, &step_ids));
252 diagnostics.extend(validate_artifact_paths(machine));
253 diagnostics.extend(validate_http_steps(machine));
254 diagnostics.extend(validate_tunnel_steps(machine));
255 diagnostics.extend(validate_oauth_steps(machine));
256 diagnostics.extend(validate_data_mapping_steps(machine));
257 diagnostics
258}
259
260pub fn run_provider_setup_machine_doctor(pack_path: &Path) -> DoctorReport {
261 let mut diagnostics = Vec::new();
262 if !pack_path.is_file() {
263 diagnostics.push(Diagnostic {
264 check_id: "setup.provider_pack.present".to_string(),
265 severity: DiagnosticSeverity::Error,
266 component: "provider_setup".to_string(),
267 message: "provider pack does not exist or is not a file".to_string(),
268 evidence: None,
269 expected: Some(".gtpack file".to_string()),
270 actual: Some(pack_path.display().to_string()),
271 fix_hint: Some("pass a built provider .gtpack file".to_string()),
272 related_file: Some(pack_path.display().to_string()),
273 related_pack: None,
274 related_component: None,
275 });
276 return report(pack_path, diagnostics);
277 }
278
279 let pack_id = discovery::read_pack_meta(pack_path)
280 .ok()
281 .flatten()
282 .map(|meta| meta.pack_id)
283 .or_else(|| {
284 pack_path
285 .file_stem()
286 .and_then(|stem| stem.to_str())
287 .map(str::to_string)
288 });
289
290 diagnostics.extend(validate_legacy_setup_actions_absent(
291 pack_path,
292 pack_id.as_deref(),
293 ));
294
295 let mut found_setup_contract = false;
296
297 match load_setup_backend_contract_from_pack(pack_path, pack_id.as_deref()) {
298 Ok(Some(contract)) => {
299 found_setup_contract = true;
300 diagnostics.push(info(
301 "setup.backend_contract.extension.present",
302 "provider pack declares a setup backend contract",
303 SETUP_BACKEND_CONTRACT_EXTENSION,
304 pack_path,
305 contract.get("provider_id").and_then(Value::as_str),
306 ));
307 diagnostics.extend(validate_json_schema_diagnostics(
308 "setup.backend_contract.schema",
309 &contract,
310 SETUP_BACKEND_CONTRACT_SCHEMA,
311 pack_path,
312 ));
313 diagnostics.extend(validate_setup_backend_contract(&contract, pack_path));
314 diagnostics.extend(validate_setup_web_component(pack_path));
315 diagnostics.extend(validate_provider_http_routes(pack_path, &contract));
316 }
317 Ok(None) => {}
318 Err(err) => diagnostics.push(load_error(
319 "setup.backend_contract.load",
320 "provider setup backend contract could not be loaded",
321 "valid greentic.setup.backend-contract.v1 JSON",
322 err,
323 pack_path,
324 )),
325 }
326
327 match load_setup_machine_from_pack(pack_path) {
328 Ok(Some(machine)) => {
329 found_setup_contract = true;
330 diagnostics.push(info(
331 "setup.machine.extension.present",
332 "provider pack declares a setup state machine",
333 SETUP_MACHINE_EXTENSION,
334 pack_path,
335 Some(&machine.id),
336 ));
337 if let Ok(machine_value) = serde_json::to_value(&machine) {
338 diagnostics.extend(validate_json_schema_diagnostics(
339 "setup.machine.schema",
340 &machine_value,
341 SETUP_MACHINE_SCHEMA,
342 pack_path,
343 ));
344 }
345 diagnostics.extend(validate_setup_machine(&machine));
346 diagnostics.extend(validate_setup_machine_template_inputs(
347 pack_path,
348 pack_id.as_deref(),
349 &machine,
350 ));
351 diagnostics.extend(validate_setup_i18n_message_keys(pack_path, &machine));
352 diagnostics.extend(validate_setup_machine_fixtures(pack_path, &machine));
353 }
354 Ok(None) => {}
355 Err(err) => diagnostics.push(load_error(
356 "setup.machine.load",
357 "provider setup state machine could not be loaded",
358 "valid setup-machine.v1 JSON",
359 err,
360 pack_path,
361 )),
362 }
363
364 if !found_setup_contract {
365 diagnostics.push(Diagnostic {
366 check_id: "setup.provider_contract.present".to_string(),
367 severity: DiagnosticSeverity::Error,
368 component: "provider_setup".to_string(),
369 message: "provider pack does not declare a supported generic setup contract"
370 .to_string(),
371 evidence: None,
372 expected: Some(format!(
373 "{SETUP_BACKEND_CONTRACT_EXTENSION} or {SETUP_MACHINE_EXTENSION}"
374 )),
375 actual: Some("missing".to_string()),
376 fix_hint: Some(format!(
377 "add {SETUP_BACKEND_CONTRACT_EXTENSION} for the current generic setup contract or {SETUP_MACHINE_EXTENSION} for the future setup-machine contract"
378 )),
379 related_file: Some(pack_path.display().to_string()),
380 related_pack: pack_id,
381 related_component: None,
382 });
383 }
384
385 report(pack_path, diagnostics)
386}
387
388fn validate_legacy_setup_actions_absent(
389 pack_path: &Path,
390 pack_id: Option<&str>,
391) -> Vec<Diagnostic> {
392 let Some(spec) = crate::setup_input::load_setup_spec(pack_path)
393 .ok()
394 .flatten()
395 else {
396 return Vec::new();
397 };
398 if spec.setup_actions.is_empty() {
399 return Vec::new();
400 }
401 vec![Diagnostic {
402 check_id: "setup.legacy_setup_actions.absent".to_string(),
403 severity: DiagnosticSeverity::Error,
404 component: "provider_setup".to_string(),
405 message: "provider setup.yaml still declares legacy setup_actions".to_string(),
406 evidence: Some(format!("{} setup action(s)", spec.setup_actions.len())),
407 expected: Some(format!(
408 "{SETUP_BACKEND_CONTRACT_EXTENSION} or {SETUP_MACHINE_EXTENSION} only"
409 )),
410 actual: Some("setup.yaml setup_actions".to_string()),
411 fix_hint: Some(
412 "move provider setup into the generic setup contract and remove setup_actions from setup.yaml"
413 .to_string(),
414 ),
415 related_file: Some(pack_path.display().to_string()),
416 related_pack: pack_id.map(str::to_string),
417 related_component: None,
418 }]
419}
420
421fn validate_json_schema_diagnostics(
422 check_id: &str,
423 value: &Value,
424 schema_text: &str,
425 pack_path: &Path,
426) -> Vec<Diagnostic> {
427 let schema = match serde_json::from_str::<Value>(schema_text) {
428 Ok(schema) => schema,
429 Err(err) => {
430 return vec![load_error(
431 check_id,
432 "setup schema could not be parsed",
433 "valid JSON Schema",
434 err.into(),
435 pack_path,
436 )];
437 }
438 };
439 crate::schema_validation::validate(value, &schema)
440 .into_iter()
441 .map(|error| Diagnostic {
442 check_id: check_id.to_string(),
443 severity: DiagnosticSeverity::Error,
444 component: "provider_setup".to_string(),
445 message: "setup contract does not match its JSON schema".to_string(),
446 evidence: Some(error),
447 expected: schema
448 .get("$id")
449 .and_then(Value::as_str)
450 .map(str::to_string),
451 actual: None,
452 fix_hint: Some(
453 "update provider setup metadata to match the published schema".to_string(),
454 ),
455 related_file: Some(pack_path.display().to_string()),
456 related_pack: value
457 .get("provider_id")
458 .or_else(|| value.get("id"))
459 .and_then(Value::as_str)
460 .map(str::to_string),
461 related_component: None,
462 })
463 .collect()
464}
465
466pub fn load_setup_backend_contract_from_pack(
467 pack_path: &Path,
468 expected_provider_id: Option<&str>,
469) -> anyhow::Result<Option<Value>> {
470 let Some(extension) =
471 discovery::read_pack_extension(pack_path, SETUP_BACKEND_CONTRACT_EXTENSION)?
472 else {
473 return Ok(None);
474 };
475 let descriptor = extension_inline(&extension).cloned().unwrap_or(extension);
476 validate_schema_id(
477 &descriptor,
478 SETUP_BACKEND_CONTRACT_EXTENSION,
479 "setup backend contract descriptor",
480 )?;
481
482 let Some(asset) = descriptor.get("asset").and_then(Value::as_str) else {
483 let provider_id = descriptor.get("provider_id").and_then(Value::as_str);
484 validate_provider_id(provider_id, expected_provider_id)?;
485 return Ok(Some(descriptor));
486 };
487 let mut contract = discovery::read_pack_json_asset(pack_path, asset)?;
488 validate_schema_id(
489 &contract,
490 SETUP_BACKEND_CONTRACT_EXTENSION,
491 "setup backend contract asset",
492 )?;
493 let provider_id = contract.get("provider_id").and_then(Value::as_str);
494 validate_provider_id(provider_id, expected_provider_id)?;
495 if let Some(map) = contract.as_object_mut() {
496 map.insert(
497 "descriptor".to_string(),
498 serde_json::json!({
499 "schema_id": descriptor.get("schema_id").cloned().unwrap_or(Value::Null),
500 "provider_id": descriptor.get("provider_id").cloned().unwrap_or(Value::Null),
501 "asset": descriptor.get("asset").cloned().unwrap_or(Value::Null),
502 }),
503 );
504 }
505 Ok(Some(contract))
506}
507
508fn validate_setup_backend_contract(contract: &Value, pack_path: &Path) -> Vec<Diagnostic> {
509 let mut diagnostics = Vec::new();
510 let provider_id = contract.get("provider_id").and_then(Value::as_str);
511
512 if provider_id.is_none_or(|value| value.trim().is_empty()) {
513 diagnostics.push(error(
514 "setup.backend_contract.provider_id",
515 "setup backend contract provider_id is required",
516 ));
517 }
518
519 let required_order = string_array(contract.get("required_order"));
520 if required_order.is_empty() {
521 diagnostics.push(error(
522 "setup.backend_contract.required_order",
523 "setup backend contract must declare required_order",
524 ));
525 }
526 diagnostics.extend(validate_unique_strings(
527 "setup.backend_contract.required_order.unique",
528 "required_order entries must be unique",
529 &required_order,
530 ));
531
532 let actions = contract
533 .get("actions")
534 .and_then(Value::as_array)
535 .cloned()
536 .unwrap_or_default();
537 if actions.is_empty() {
538 diagnostics.push(error(
539 "setup.backend_contract.actions",
540 "setup backend contract must declare actions",
541 ));
542 }
543
544 let server_owned = string_array(contract.get("server_owned_config_keys"));
545 let mut action_ids = BTreeSet::new();
546 for action in &actions {
547 let Some(id) = action.get("id").and_then(Value::as_str) else {
548 diagnostics.push(error(
549 "setup.backend_contract.action.id",
550 "setup backend action id is required",
551 ));
552 continue;
553 };
554 if id.trim().is_empty() {
555 diagnostics.push(error(
556 "setup.backend_contract.action.id",
557 "setup backend action id is required",
558 ));
559 continue;
560 }
561 if !action_ids.insert(id.to_string()) {
562 diagnostics.push(error_with_actual(
563 "setup.backend_contract.action.unique_id",
564 "setup backend action ids must be unique",
565 id,
566 ));
567 }
568
569 for required in string_array(action.get("requires")) {
570 if !required_order.contains(&required) && !action_ids.contains(&required) {
571 diagnostics.push(error_with_actual(
572 "setup.backend_contract.action.requires",
573 "setup backend action requires entry must reference another declared action",
574 &format!("{id} requires {required}"),
575 ));
576 }
577 }
578
579 validate_backend_executor(&mut diagnostics, id, action, pack_path, &server_owned);
580
581 if action.get("completion").is_none() {
582 diagnostics.push(error_with_actual(
583 "setup.backend_contract.action.completion",
584 "setup backend action should declare completion criteria",
585 id,
586 ));
587 }
588 }
589
590 for step in &required_order {
591 if !action_ids.contains(step) {
592 diagnostics.push(error_with_actual(
593 "setup.backend_contract.required_order.action",
594 "required_order entry must have a matching action",
595 step,
596 ));
597 }
598 }
599
600 diagnostics.extend(validate_unique_strings(
601 "setup.backend_contract.server_owned_config_keys.unique",
602 "server_owned_config_keys entries must be unique",
603 &server_owned,
604 ));
605
606 diagnostics
607}
608
609fn validate_backend_executor(
610 diagnostics: &mut Vec<Diagnostic>,
611 action_id: &str,
612 action: &Value,
613 _pack_path: &Path,
614 server_owned: &[String],
615) {
616 let Some(executor) = action.get("executor").and_then(Value::as_object) else {
617 diagnostics.push(error_with_actual(
618 "setup.backend_contract.action.executor",
619 "setup backend action must declare executor",
620 action_id,
621 ));
622 return;
623 };
624 let kind = executor
625 .get("kind")
626 .and_then(Value::as_str)
627 .unwrap_or_default();
628 if kind.is_empty() {
629 diagnostics.push(error_with_actual(
630 "setup.backend_contract.executor.kind",
631 "setup backend executor kind is required",
632 action_id,
633 ));
634 return;
635 }
636 if !SUPPORTED_BACKEND_EXECUTOR_KINDS.contains(&kind) {
637 diagnostics.push(error_with_actual(
638 "setup.backend_contract.executor.supported_kind",
639 "setup backend executor kind is not supported by this greentic-setup version",
640 kind,
641 ));
642 return;
643 }
644
645 match kind {
646 "oauth_device_code" => {
647 for key in [
648 "authority_url_template",
649 "client_id_config_key",
650 "oauth_kind",
651 "token_store_key",
652 ] {
653 require_executor_str(diagnostics, action_id, executor, key);
654 }
655 if string_array(executor.get("scopes")).is_empty() {
656 diagnostics.push(error_with_actual(
657 "setup.backend_contract.executor.scopes",
658 "oauth_device_code executor requires at least one scope",
659 action_id,
660 ));
661 }
662 validate_oauth_server_owned_keys(diagnostics, action_id, executor, server_owned);
663 }
664 "microsoft_graph_application" => {
665 for key in [
666 "graph_token_store_key",
667 "display_name_config_key",
668 "app_id_config_key",
669 "client_secret_config_key",
670 ] {
671 require_executor_str(diagnostics, action_id, executor, key);
672 }
673 }
674 "provider_http" => {
675 let path_template = executor
676 .get("path_template")
677 .or_else(|| executor.get("target_path_template"))
678 .and_then(Value::as_str);
679 let url_template = executor
680 .get("url_template")
681 .or_else(|| executor.get("target_url_template"))
682 .and_then(Value::as_str);
683 if path_template.is_none() && url_template.is_none() {
684 diagnostics.push(error_with_actual(
685 "setup.backend_contract.executor.provider_http.target",
686 "provider_http executor requires path_template or url_template",
687 action_id,
688 ));
689 }
690 if let Some(path) = path_template
691 && !path.starts_with('/')
692 {
693 diagnostics.push(error_with_actual(
694 "setup.backend_contract.executor.provider_http.path",
695 "provider_http path_template must be an absolute path",
696 path,
697 ));
698 }
699 }
700 "runtime_observation" => {
701 for key in ["source", "event", "state_store_key"] {
702 require_executor_str(diagnostics, action_id, executor, key);
703 }
704 }
705 _ => {}
706 }
707}
708
709fn validate_oauth_server_owned_keys(
710 diagnostics: &mut Vec<Diagnostic>,
711 action_id: &str,
712 executor: &serde_json::Map<String, Value>,
713 server_owned: &[String],
714) {
715 let oauth_kind_key = "oauth_kind";
716 validate_server_owned_key(diagnostics, action_id, server_owned, oauth_kind_key);
717
718 for key in [
719 "token_store_key",
720 "device_code_store_key",
721 "user_code_store_key",
722 ] {
723 if let Some(value) = executor.get(key).and_then(Value::as_str) {
724 validate_server_owned_key(diagnostics, action_id, server_owned, value);
725 }
726 }
727
728 if !executor.contains_key("device_code_store_key") {
729 validate_server_owned_key(diagnostics, action_id, server_owned, "oauth_device_code");
730 }
731 if !executor.contains_key("user_code_store_key") {
732 validate_server_owned_key(diagnostics, action_id, server_owned, "oauth_user_code");
733 }
734}
735
736fn validate_server_owned_key(
737 diagnostics: &mut Vec<Diagnostic>,
738 action_id: &str,
739 server_owned: &[String],
740 key: &str,
741) {
742 if server_owned.iter().any(|value| value == key) {
743 return;
744 }
745 diagnostics.push(error_with_actual(
746 "setup.backend_contract.server_owned_config_keys.oauth",
747 "oauth_device_code executor output keys must be listed in server_owned_config_keys",
748 &format!("{action_id}.{key}"),
749 ));
750}
751
752fn validate_setup_web_component(pack_path: &Path) -> Vec<Diagnostic> {
753 let mut diagnostics = Vec::new();
754 match discovery::read_pack_extension(pack_path, SETUP_WEB_COMPONENT_EXTENSION) {
755 Ok(Some(extension)) => {
756 let descriptor = extension_inline(&extension).unwrap_or(&extension);
757 if let Err(err) = validate_schema_id(
758 descriptor,
759 SETUP_WEB_COMPONENT_EXTENSION,
760 "setup web component descriptor",
761 ) {
762 diagnostics.push(load_error(
763 "setup.web_component.schema_id",
764 "setup web component descriptor has invalid schema_id",
765 SETUP_WEB_COMPONENT_EXTENSION,
766 err,
767 pack_path,
768 ));
769 }
770 for key in ["provider_id", "tag_name", "module_asset"] {
771 if descriptor
772 .get(key)
773 .and_then(Value::as_str)
774 .is_none_or(|value| value.trim().is_empty())
775 {
776 diagnostics.push(error_with_actual(
777 "setup.web_component.required_field",
778 "setup web component descriptor is missing a required field",
779 key,
780 ));
781 }
782 }
783 if let Some(module_asset) = descriptor.get("module_asset").and_then(Value::as_str) {
784 match discovery::pack_asset_exists(pack_path, module_asset) {
785 Ok(true) => {}
786 Ok(false) => diagnostics.push(error_with_actual(
787 "setup.web_component.module_asset.exists",
788 "setup web component module_asset must exist in the pack",
789 module_asset,
790 )),
791 Err(err) => diagnostics.push(load_error(
792 "setup.web_component.module_asset.read",
793 "setup web component module_asset could not be checked",
794 "readable pack asset",
795 err,
796 pack_path,
797 )),
798 }
799 }
800 }
801 Ok(None) => diagnostics.push(Diagnostic {
802 check_id: "setup.web_component.extension.present".to_string(),
803 severity: DiagnosticSeverity::Error,
804 component: "provider_setup".to_string(),
805 message: "provider pack does not declare a setup web component".to_string(),
806 evidence: None,
807 expected: Some(SETUP_WEB_COMPONENT_EXTENSION.to_string()),
808 actual: Some("missing".to_string()),
809 fix_hint: Some(format!(
810 "add {SETUP_WEB_COMPONENT_EXTENSION} to the pack manifest"
811 )),
812 related_file: Some(pack_path.display().to_string()),
813 related_pack: None,
814 related_component: None,
815 }),
816 Err(err) => diagnostics.push(load_error(
817 "setup.web_component.load",
818 "setup web component descriptor could not be loaded",
819 SETUP_WEB_COMPONENT_EXTENSION,
820 err,
821 pack_path,
822 )),
823 }
824 diagnostics
825}
826
827fn validate_provider_http_routes(pack_path: &Path, contract: &Value) -> Vec<Diagnostic> {
828 let mut diagnostics = Vec::new();
829 let provider_http_routes = provider_http_requirements(contract);
830 if provider_http_routes.is_empty() {
831 return diagnostics;
832 }
833
834 let routes_extension = match discovery::read_pack_extension(pack_path, HTTP_ROUTES_EXTENSION) {
835 Ok(Some(extension)) => extension,
836 Ok(None) => {
837 diagnostics.push(Diagnostic {
838 check_id: "setup.backend_contract.provider_http.routes_extension".to_string(),
839 severity: DiagnosticSeverity::Error,
840 component: "provider_setup".to_string(),
841 message: "provider_http setup actions require greentic.http-routes.v1".to_string(),
842 evidence: Some(
843 provider_http_routes
844 .iter()
845 .map(|route| route.path.as_str())
846 .collect::<Vec<_>>()
847 .join(", "),
848 ),
849 expected: Some(HTTP_ROUTES_EXTENSION.to_string()),
850 actual: Some("missing".to_string()),
851 fix_hint: Some(format!(
852 "declare {HTTP_ROUTES_EXTENSION} routes for provider_http setup actions"
853 )),
854 related_file: Some(pack_path.display().to_string()),
855 related_pack: contract
856 .get("provider_id")
857 .and_then(Value::as_str)
858 .map(str::to_string),
859 related_component: None,
860 });
861 return diagnostics;
862 }
863 Err(err) => {
864 diagnostics.push(load_error(
865 "setup.backend_contract.provider_http.routes_load",
866 "greentic.http-routes.v1 could not be loaded",
867 HTTP_ROUTES_EXTENSION,
868 err,
869 pack_path,
870 ));
871 return diagnostics;
872 }
873 };
874
875 let routes = extension_inline(&routes_extension).unwrap_or(&routes_extension);
876 let declared_routes: Vec<DeclaredHttpRoute> = routes
877 .get("routes")
878 .and_then(Value::as_array)
879 .into_iter()
880 .flatten()
881 .filter_map(declared_http_route)
882 .collect();
883 if declared_routes.is_empty() {
884 diagnostics.push(error(
885 "setup.backend_contract.provider_http.routes",
886 "greentic.http-routes.v1 must declare routes for provider_http setup actions",
887 ));
888 return diagnostics;
889 }
890
891 for required in provider_http_routes {
892 let Some(route) = declared_routes
893 .iter()
894 .find(|route| route_pattern_covers_path(&route.pattern, &required.path))
895 else {
896 diagnostics.push(error_with_actual(
897 "setup.backend_contract.provider_http.route_covered",
898 "provider_http path_template is not covered by greentic.http-routes.v1",
899 &required.path,
900 ));
901 continue;
902 };
903 if !route.methods.is_empty()
904 && !route
905 .methods
906 .iter()
907 .any(|method| method.eq_ignore_ascii_case(&required.method))
908 {
909 diagnostics.push(error_with_actual(
910 "setup.backend_contract.provider_http.route_method",
911 "provider_http method is not allowed by greentic.http-routes.v1",
912 &format!("{} {}", required.method, required.path),
913 ));
914 }
915 }
916
917 diagnostics
918}
919
920#[derive(Clone, Debug)]
921struct ProviderHttpRequirement {
922 method: String,
923 path: String,
924}
925
926#[derive(Clone, Debug)]
927struct DeclaredHttpRoute {
928 pattern: String,
929 methods: Vec<String>,
930}
931
932fn provider_http_requirements(contract: &Value) -> Vec<ProviderHttpRequirement> {
933 contract
934 .get("actions")
935 .and_then(Value::as_array)
936 .into_iter()
937 .flatten()
938 .filter_map(|action| action.get("executor"))
939 .filter(|executor| executor.get("kind").and_then(Value::as_str) == Some("provider_http"))
940 .filter_map(|executor| {
941 let path = executor
942 .get("path_template")
943 .or_else(|| executor.get("target_path_template"))
944 .and_then(Value::as_str)?;
945 let method = executor
946 .get("method")
947 .or_else(|| executor.get("http_method"))
948 .and_then(Value::as_str)
949 .unwrap_or("POST");
950 Some(ProviderHttpRequirement {
951 method: method.trim().to_ascii_uppercase(),
952 path: path.trim().to_string(),
953 })
954 })
955 .filter(|route| !route.path.is_empty())
956 .collect()
957}
958
959fn declared_http_route(record: &Value) -> Option<DeclaredHttpRoute> {
960 let pattern = record.get("pattern").and_then(Value::as_str)?.trim();
961 if pattern.is_empty() {
962 return None;
963 }
964 let methods = record
965 .get("methods")
966 .and_then(Value::as_array)
967 .into_iter()
968 .flatten()
969 .filter_map(Value::as_str)
970 .map(str::trim)
971 .filter(|value| !value.is_empty())
972 .map(|value| value.to_ascii_uppercase())
973 .collect();
974 Some(DeclaredHttpRoute {
975 pattern: pattern.to_string(),
976 methods,
977 })
978}
979
980fn route_pattern_covers_path(pattern: &str, path: &str) -> bool {
981 let pattern_segments: Vec<&str> = normalized_path_segments(pattern).collect();
982 let path_segments: Vec<&str> = normalized_path_segments(path).collect();
983 route_segments_cover(&pattern_segments, &path_segments)
984}
985
986fn normalized_path_segments(path: &str) -> impl Iterator<Item = &str> {
987 path.trim_start_matches('/')
988 .split('/')
989 .filter(|segment| !segment.is_empty())
990}
991
992fn route_segments_cover(pattern: &[&str], path: &[&str]) -> bool {
993 let mut p = 0;
994 let mut s = 0;
995 while p < pattern.len() {
996 let pattern_segment = pattern[p];
997 if is_route_splat(pattern_segment) {
998 return true;
999 }
1000 let Some(path_segment) = path.get(s) else {
1001 return false;
1002 };
1003 if !route_segment_covers(pattern_segment, path_segment) {
1004 return false;
1005 }
1006 p += 1;
1007 s += 1;
1008 }
1009 s == path.len()
1010}
1011
1012fn route_segment_covers(pattern_segment: &str, path_segment: &str) -> bool {
1013 pattern_segment == path_segment
1014 || is_route_variable(pattern_segment) && is_route_variable(path_segment)
1015}
1016
1017fn is_route_variable(segment: &str) -> bool {
1018 segment.starts_with('{') && segment.ends_with('}') && !segment.ends_with("*}")
1019}
1020
1021fn is_route_splat(segment: &str) -> bool {
1022 segment.starts_with('{') && segment.ends_with("*}")
1023}
1024
1025pub fn setup_machine_state_dir(
1026 bundle_root: &Path,
1027 tenant: &str,
1028 team: &str,
1029 provider_id: &str,
1030) -> PathBuf {
1031 bundle_root
1032 .join("state")
1033 .join("setup")
1034 .join(tenant)
1035 .join(team)
1036 .join(provider_id)
1037}
1038
1039pub fn setup_machine_state_path(
1040 bundle_root: &Path,
1041 tenant: &str,
1042 team: &str,
1043 provider_id: &str,
1044) -> PathBuf {
1045 setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("machine.json")
1046}
1047
1048pub fn setup_machine_events_path(
1049 bundle_root: &Path,
1050 tenant: &str,
1051 team: &str,
1052 provider_id: &str,
1053) -> PathBuf {
1054 setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("events.jsonl")
1055}
1056
1057pub fn write_setup_machine_state(
1058 bundle_root: &Path,
1059 state: &SetupMachineState,
1060) -> anyhow::Result<PathBuf> {
1061 let path =
1062 setup_machine_state_path(bundle_root, &state.tenant, &state.team, &state.provider_id);
1063 if let Some(parent) = path.parent() {
1064 std::fs::create_dir_all(parent)?;
1065 }
1066 std::fs::write(&path, serde_json::to_vec_pretty(state)?)?;
1067 Ok(path)
1068}
1069
1070pub fn load_setup_machine_state(path: &Path) -> anyhow::Result<SetupMachineState> {
1071 let raw = std::fs::read_to_string(path)?;
1072 Ok(serde_json::from_str(&raw)?)
1073}
1074
1075pub fn append_setup_machine_event(
1076 bundle_root: &Path,
1077 event: &SetupMachineEvent,
1078) -> anyhow::Result<PathBuf> {
1079 let path =
1080 setup_machine_events_path(bundle_root, &event.tenant, &event.team, &event.provider_id);
1081 if let Some(parent) = path.parent() {
1082 std::fs::create_dir_all(parent)?;
1083 }
1084 let mut line = serde_json::to_string(event)?;
1085 line.push('\n');
1086 use std::io::Write;
1087 let mut file = std::fs::OpenOptions::new()
1088 .create(true)
1089 .append(true)
1090 .open(&path)?;
1091 file.write_all(line.as_bytes())?;
1092 Ok(path)
1093}
1094
1095pub fn load_or_init_setup_machine_state(
1096 bundle_root: &Path,
1097 provider_id: &str,
1098 tenant: &str,
1099 team: &str,
1100 machine: &SetupMachine,
1101) -> anyhow::Result<SetupMachineState> {
1102 load_or_init_setup_machine_state_with_context(
1103 bundle_root,
1104 None,
1105 provider_id,
1106 tenant,
1107 team,
1108 machine,
1109 )
1110}
1111
1112pub fn load_or_init_setup_machine_state_with_context(
1113 bundle_root: &Path,
1114 pack_path: Option<&Path>,
1115 provider_id: &str,
1116 tenant: &str,
1117 team: &str,
1118 machine: &SetupMachine,
1119) -> anyhow::Result<SetupMachineState> {
1120 let current_pack_fingerprint = pack_path
1121 .map(setup_machine_hash_file)
1122 .transpose()
1123 .with_context(|| format!("hash setup provider pack for {provider_id}"))?;
1124 let current_answers_hash = setup_machine_answers_hash(bundle_root, provider_id)?;
1125 let path = setup_machine_state_path(bundle_root, tenant, team, provider_id);
1126 if path.is_file() {
1127 let mut state = load_setup_machine_state(&path)?;
1128 if state.schema_version != SUPPORTED_SETUP_STATE_SCHEMA_VERSION {
1129 state.status = SetupMachineStatus::Failed;
1130 state.last_error = Some(serde_json::json!({
1131 "error": "setup_state_schema_changed",
1132 "recoverable": false,
1133 "expected": SUPPORTED_SETUP_STATE_SCHEMA_VERSION,
1134 "actual": state.schema_version,
1135 }));
1136 }
1137 if state.provider_id != provider_id || state.tenant != tenant || state.team != team {
1138 state.status = SetupMachineStatus::Failed;
1139 state.last_error = Some(serde_json::json!({
1140 "error": "setup_state_scope_changed",
1141 "recoverable": false,
1142 "expected": {
1143 "provider_id": provider_id,
1144 "tenant": tenant,
1145 "team": team,
1146 },
1147 "actual": {
1148 "provider_id": state.provider_id,
1149 "tenant": state.tenant,
1150 "team": state.team,
1151 }
1152 }));
1153 }
1154 if state.machine_id != machine.id || state.machine_version != machine.version {
1155 state.status = SetupMachineStatus::Failed;
1156 state.last_error = Some(serde_json::json!({
1157 "error": "machine_identity_changed",
1158 "recoverable": false,
1159 "expected": {
1160 "machine_id": machine.id,
1161 "machine_version": machine.version,
1162 },
1163 "actual": {
1164 "machine_id": state.machine_id,
1165 "machine_version": state.machine_version,
1166 }
1167 }));
1168 }
1169 if let (Some(stored), Some(current)) = (
1170 state.pack_fingerprint.as_deref(),
1171 current_pack_fingerprint.as_deref(),
1172 ) && stored != current
1173 {
1174 state.status = SetupMachineStatus::Failed;
1175 state.last_error = Some(serde_json::json!({
1176 "error": "pack_fingerprint_changed",
1177 "recoverable": false,
1178 "expected": stored,
1179 "actual": current,
1180 }));
1181 } else if state.pack_fingerprint.is_none() {
1182 state.pack_fingerprint = current_pack_fingerprint;
1183 }
1184 if state.answers_hash.as_deref() != current_answers_hash.as_deref() {
1185 if let Some(stored) = state.answers_hash.as_deref() {
1186 state.status = SetupMachineStatus::Failed;
1187 state.last_error = Some(serde_json::json!({
1188 "error": "setup_answers_changed",
1189 "recoverable": false,
1190 "expected": stored,
1191 "actual": current_answers_hash.as_deref().unwrap_or("missing"),
1192 }));
1193 } else {
1194 state.answers_hash = current_answers_hash;
1195 }
1196 }
1197 return Ok(state);
1198 }
1199
1200 Ok(SetupMachineState {
1201 schema_version: 1,
1202 provider_id: provider_id.to_string(),
1203 tenant: tenant.to_string(),
1204 team: team.to_string(),
1205 machine_id: machine.id.clone(),
1206 machine_version: machine.version,
1207 status: SetupMachineStatus::NotStarted,
1208 current_step: Some(machine.entry_step.clone()),
1209 completed_steps: Vec::new(),
1210 failed_step: None,
1211 answers_hash: current_answers_hash,
1212 pack_fingerprint: current_pack_fingerprint,
1213 outputs: serde_json::json!({}),
1214 created_resources: Vec::new(),
1215 last_error: None,
1216 updated_at: Some(now_setup_ts()),
1217 })
1218}
1219
1220fn setup_machine_state_compatibility_error(state: &SetupMachineState) -> Option<String> {
1221 state
1222 .last_error
1223 .as_ref()
1224 .and_then(|error| error.get("error"))
1225 .and_then(Value::as_str)
1226 .filter(|error| {
1227 matches!(
1228 *error,
1229 "setup_state_schema_changed"
1230 | "setup_state_scope_changed"
1231 | "machine_identity_changed"
1232 | "pack_fingerprint_changed"
1233 | "setup_answers_changed"
1234 )
1235 })
1236 .map(str::to_string)
1237}
1238
1239fn setup_machine_answers_hash(
1240 bundle_root: &Path,
1241 provider_id: &str,
1242) -> anyhow::Result<Option<String>> {
1243 let path = bundle_root
1244 .join("state/config")
1245 .join(provider_id)
1246 .join("setup-answers.json");
1247 if !path.is_file() {
1248 return Ok(None);
1249 }
1250 Ok(Some(setup_machine_hash_file(&path)?))
1251}
1252
1253fn setup_machine_hash_file(path: &Path) -> anyhow::Result<String> {
1254 let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
1255 let mut hasher = Sha256::new();
1256 let mut buf = [0_u8; 8192];
1257 loop {
1258 let read = file
1259 .read(&mut buf)
1260 .with_context(|| format!("read {}", path.display()))?;
1261 if read == 0 {
1262 break;
1263 }
1264 hasher.update(&buf[..read]);
1265 }
1266 Ok(format!(
1267 "sha256:{}",
1268 setup_machine_hex_encode(&hasher.finalize())
1269 ))
1270}
1271
1272fn setup_machine_hex_encode(bytes: &[u8]) -> String {
1273 const HEX: &[u8; 16] = b"0123456789abcdef";
1274 let mut out = String::with_capacity(bytes.len() * 2);
1275 for byte in bytes {
1276 out.push(HEX[(byte >> 4) as usize] as char);
1277 out.push(HEX[(byte & 0x0f) as usize] as char);
1278 }
1279 out
1280}
1281
1282pub fn render_setup_machine_status(machine: &SetupMachine, state: &SetupMachineState) -> Value {
1283 let current = state
1284 .current_step
1285 .clone()
1286 .unwrap_or_else(|| machine.entry_step.clone());
1287 let completed: BTreeSet<&str> = state.completed_steps.iter().map(String::as_str).collect();
1288 let steps: Vec<Value> = machine
1289 .steps
1290 .iter()
1291 .map(|step| {
1292 let step_state = if completed.contains(step.id.as_str()) {
1293 "done"
1294 } else if Some(step.id.as_str()) == state.failed_step.as_deref() {
1295 "failed"
1296 } else if step.id == current {
1297 "current"
1298 } else {
1299 "pending"
1300 };
1301 serde_json::json!({
1302 "id": step.id,
1303 "kind": step.kind,
1304 "title": step.title,
1305 "state": step_state,
1306 })
1307 })
1308 .collect();
1309 serde_json::json!({
1310 "ok": state.status == SetupMachineStatus::Complete,
1311 "provider_id": state.provider_id,
1312 "tenant": state.tenant,
1313 "team": state.team,
1314 "machine_id": state.machine_id,
1315 "machine_version": state.machine_version,
1316 "status": state.status,
1317 "current_step": current,
1318 "completed_steps": state.completed_steps,
1319 "failed_step": state.failed_step,
1320 "last_error": state.last_error,
1321 "steps": steps,
1322 })
1323}
1324
1325pub async fn complete_setup_machine_oauth_authorization_code_with_token_response(
1326 bundle_root: &Path,
1327 env: &str,
1328 oauth_state: &crate::setup_actions::OAuthStatePayload,
1329 token_response: &Value,
1330) -> anyhow::Result<crate::oauth_callback::OAuthCallbackReport> {
1331 let discovered = discovery::discover(bundle_root)
1332 .context("failed to discover providers for setup-machine OAuth callback")?;
1333 let provider = discovered
1334 .find_setup_target(&oauth_state.provider_id)
1335 .ok_or_else(|| {
1336 anyhow::anyhow!(
1337 "provider not found for setup-machine OAuth callback: {}",
1338 oauth_state.provider_id
1339 )
1340 })?;
1341 let machine = load_setup_machine_from_pack(&provider.pack_path)?.ok_or_else(|| {
1342 anyhow::anyhow!(
1343 "provider does not declare {SETUP_MACHINE_EXTENSION}: {}",
1344 oauth_state.provider_id
1345 )
1346 })?;
1347 complete_setup_machine_oauth_authorization_code_for_machine(
1348 bundle_root,
1349 env,
1350 Some(&provider.pack_path),
1351 &machine,
1352 oauth_state,
1353 token_response,
1354 )
1355 .await
1356}
1357
1358pub async fn complete_setup_machine_oauth_authorization_code(
1359 bundle_root: &Path,
1360 env: &str,
1361 oauth_state: &crate::setup_actions::OAuthStatePayload,
1362 code: &str,
1363) -> anyhow::Result<crate::oauth_callback::OAuthCallbackReport> {
1364 if code.trim().is_empty() {
1365 anyhow::bail!("OAuth callback missing code");
1366 }
1367 let discovered = discovery::discover(bundle_root)
1368 .context("failed to discover providers for setup-machine OAuth callback")?;
1369 let provider = discovered
1370 .find_setup_target(&oauth_state.provider_id)
1371 .ok_or_else(|| {
1372 anyhow::anyhow!(
1373 "provider not found for setup-machine OAuth callback: {}",
1374 oauth_state.provider_id
1375 )
1376 })?;
1377 let machine = load_setup_machine_from_pack(&provider.pack_path)?.ok_or_else(|| {
1378 anyhow::anyhow!(
1379 "provider does not declare {SETUP_MACHINE_EXTENSION}: {}",
1380 oauth_state.provider_id
1381 )
1382 })?;
1383 let token_response = exchange_setup_machine_oauth_authorization_code(
1384 bundle_root,
1385 &machine,
1386 oauth_state,
1387 code.trim(),
1388 )?;
1389 complete_setup_machine_oauth_authorization_code_for_machine(
1390 bundle_root,
1391 env,
1392 Some(&provider.pack_path),
1393 &machine,
1394 oauth_state,
1395 &token_response,
1396 )
1397 .await
1398}
1399
1400pub async fn complete_setup_machine_oauth_authorization_code_for_machine(
1401 bundle_root: &Path,
1402 env: &str,
1403 pack_path: Option<&Path>,
1404 machine: &SetupMachine,
1405 oauth_state: &crate::setup_actions::OAuthStatePayload,
1406 token_response: &Value,
1407) -> anyhow::Result<crate::oauth_callback::OAuthCallbackReport> {
1408 let step = machine
1409 .steps
1410 .iter()
1411 .find(|candidate| candidate.id == oauth_state.action_id)
1412 .ok_or_else(|| anyhow::anyhow!("setup-machine step not found: {}", oauth_state.action_id))?
1413 .clone();
1414 if step.kind != "oauth_authorization_code" {
1415 anyhow::bail!(
1416 "setup-machine step is not an oauth_authorization_code step: {}",
1417 step.id
1418 );
1419 }
1420
1421 let mut state = load_or_init_setup_machine_state(
1422 bundle_root,
1423 &oauth_state.provider_id,
1424 &oauth_state.tenant,
1425 &oauth_state.team,
1426 machine,
1427 )?;
1428 if state.completed_steps.iter().any(|id| id == &step.id) {
1429 anyhow::bail!("setup-machine OAuth step is already complete");
1430 }
1431 if !matches!(
1432 state.status,
1433 SetupMachineStatus::Running | SetupMachineStatus::Paused | SetupMachineStatus::NotStarted
1434 ) {
1435 anyhow::bail!("setup-machine OAuth step is not pending");
1436 }
1437 if state.current_step.as_deref() != Some(step.id.as_str()) {
1438 anyhow::bail!(
1439 "setup-machine OAuth callback step mismatch: current={:?} callback={}",
1440 state.current_step,
1441 step.id
1442 );
1443 }
1444
1445 let config = map_oauth_authorization_token_response(&step, token_response)?;
1446 let persisted_keys = crate::qa::persist::persist_all_config_as_secrets(
1447 bundle_root,
1448 env,
1449 &oauth_state.tenant,
1450 Some(&oauth_state.team),
1451 &oauth_state.provider_id,
1452 &Value::Object(config.clone()),
1453 pack_path,
1454 )
1455 .await?;
1456
1457 ensure_outputs_object(&mut state).insert(
1458 step.extra
1459 .get("output_key")
1460 .and_then(Value::as_str)
1461 .unwrap_or("oauth_authorization")
1462 .to_string(),
1463 serde_json::json!({
1464 "ok": true,
1465 "persisted_keys": persisted_keys,
1466 }),
1467 );
1468 mark_machine_step_complete(machine, &step, &mut state);
1469 state.updated_at = Some(now_setup_ts());
1470 write_setup_machine_state(bundle_root, &state)?;
1471 append_machine_step_event(
1472 bundle_root,
1473 &state,
1474 "setup.step.oauth_callback_complete",
1475 Some(&step.id),
1476 Some(serde_json::json!({
1477 "persisted_keys": persisted_keys,
1478 })),
1479 )?;
1480
1481 Ok(crate::oauth_callback::OAuthCallbackReport {
1482 provider_id: oauth_state.provider_id.clone(),
1483 tenant: oauth_state.tenant.clone(),
1484 team: oauth_state.team.clone(),
1485 action_id: oauth_state.action_id.clone(),
1486 persisted_secret_keys: persisted_keys,
1487 })
1488}
1489
1490pub fn advance_setup_machine(
1491 bundle_root: &Path,
1492 provider_id: &str,
1493 tenant: &str,
1494 team: &str,
1495 machine: &SetupMachine,
1496 dry_run: bool,
1497) -> anyhow::Result<Value> {
1498 advance_setup_machine_with_pack(
1499 bundle_root,
1500 None,
1501 provider_id,
1502 tenant,
1503 team,
1504 machine,
1505 dry_run,
1506 )
1507}
1508
1509pub fn advance_setup_machine_with_pack(
1510 bundle_root: &Path,
1511 pack_path: Option<&Path>,
1512 provider_id: &str,
1513 tenant: &str,
1514 team: &str,
1515 machine: &SetupMachine,
1516 dry_run: bool,
1517) -> anyhow::Result<Value> {
1518 let mut state = load_or_init_setup_machine_state_with_context(
1519 bundle_root,
1520 pack_path,
1521 provider_id,
1522 tenant,
1523 team,
1524 machine,
1525 )?;
1526 let before = render_setup_machine_status(machine, &state);
1527 if state.status == SetupMachineStatus::Complete {
1528 return Ok(serde_json::json!({
1529 "provider_id": provider_id,
1530 "tenant": tenant,
1531 "team": team,
1532 "machine_id": machine.id,
1533 "ok": true,
1534 "step": "complete",
1535 "dry_run": dry_run,
1536 "result": {
1537 "ok": true,
1538 "complete": true,
1539 },
1540 "status": before,
1541 }));
1542 }
1543 if let Some(error) = setup_machine_state_compatibility_error(&state) {
1544 state.updated_at = Some(now_setup_ts());
1545 let event_path = if dry_run {
1546 None
1547 } else {
1548 write_setup_machine_state(bundle_root, &state)?;
1549 Some(append_machine_step_event(
1550 bundle_root,
1551 &state,
1552 "setup.machine.incompatible_state",
1553 state.current_step.as_deref(),
1554 state.last_error.clone(),
1555 )?)
1556 };
1557 let step_id = state
1558 .current_step
1559 .clone()
1560 .unwrap_or_else(|| machine.entry_step.clone());
1561 return Ok(machine_step_output(
1562 machine,
1563 &state,
1564 &step_id,
1565 dry_run,
1566 false,
1567 &format!("{error}; run setup-migrate or setup-reset before continuing"),
1568 event_path,
1569 ));
1570 }
1571
1572 let step_id = state
1573 .current_step
1574 .clone()
1575 .unwrap_or_else(|| machine.entry_step.clone());
1576 let Some(step) = machine
1577 .steps
1578 .iter()
1579 .find(|candidate| candidate.id == step_id)
1580 else {
1581 state.status = SetupMachineStatus::Failed;
1582 state.failed_step = Some(step_id.clone());
1583 state.last_error = Some(serde_json::json!({
1584 "error": "missing_current_step",
1585 "step": step_id,
1586 "recoverable": false,
1587 }));
1588 state.updated_at = Some(now_setup_ts());
1589 if !dry_run {
1590 write_setup_machine_state(bundle_root, &state)?;
1591 append_machine_step_event(
1592 bundle_root,
1593 &state,
1594 "setup.step.failed",
1595 Some(&step_id),
1596 None,
1597 )?;
1598 }
1599 return Ok(machine_step_output(
1600 machine,
1601 &state,
1602 &step_id,
1603 dry_run,
1604 false,
1605 "missing_current_step",
1606 None,
1607 ));
1608 };
1609
1610 let mut state_after = state.clone();
1611 state_after.status = SetupMachineStatus::Running;
1612 let result = execute_machine_step(bundle_root, pack_path, machine, step, &mut state_after);
1613 let recovery_step = apply_declared_failure_transition(machine, step, &mut state_after, &result);
1614 state_after.updated_at = Some(now_setup_ts());
1615 let event_name = if recovery_step.is_some() {
1616 "setup.step.recovery_scheduled"
1617 } else if result.ok {
1618 "setup.step.completed"
1619 } else if state_after.status == SetupMachineStatus::Paused {
1620 "setup.step.paused"
1621 } else {
1622 "setup.step.failed"
1623 };
1624 let mut result_detail = result.detail;
1625 if let Some(recovery_step) = recovery_step.as_deref()
1626 && let Some(obj) = result_detail.as_object_mut()
1627 {
1628 obj.insert(
1629 "recovery_step".to_string(),
1630 Value::String(recovery_step.to_string()),
1631 );
1632 }
1633 let event_path = if dry_run {
1634 None
1635 } else {
1636 write_setup_machine_state(bundle_root, &state_after)?;
1637 Some(append_machine_step_event(
1638 bundle_root,
1639 &state_after,
1640 event_name,
1641 Some(&step.id),
1642 Some(result_detail.clone()),
1643 )?)
1644 };
1645 let next_text = recovery_step
1646 .as_deref()
1647 .map(|step| format!("recovery scheduled: {step}"))
1648 .unwrap_or_else(|| result.next.clone());
1649 let mut output = machine_step_output(
1650 machine,
1651 &state_after,
1652 &step.id,
1653 dry_run,
1654 result.ok,
1655 &next_text,
1656 event_path,
1657 );
1658 if let Some(result_obj) = output.get_mut("result").and_then(Value::as_object_mut) {
1659 result_obj.insert("detail".to_string(), result_detail);
1660 }
1661 Ok(output)
1662}
1663
1664pub fn reset_setup_machine_state(
1665 bundle_root: &Path,
1666 tenant: &str,
1667 team: &str,
1668 provider_id: &str,
1669 reason: &str,
1670) -> anyhow::Result<Option<PathBuf>> {
1671 let state_path = setup_machine_state_path(bundle_root, tenant, team, provider_id);
1672 let archive_path = if state_path.is_file() {
1673 let archive_dir =
1674 setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("archive");
1675 std::fs::create_dir_all(&archive_dir)?;
1676 let archive_path = archive_dir.join(format!(
1677 "machine-{}-{}.json",
1678 safe_archive_segment(reason),
1679 now_setup_ms()
1680 ));
1681 std::fs::copy(&state_path, &archive_path)?;
1682 std::fs::remove_file(&state_path)?;
1683 Some(archive_path)
1684 } else {
1685 None
1686 };
1687 append_setup_machine_event(
1688 bundle_root,
1689 &SetupMachineEvent {
1690 ts: now_setup_ts(),
1691 level: "info".to_string(),
1692 event: "setup.machine.reset".to_string(),
1693 provider_id: provider_id.to_string(),
1694 tenant: tenant.to_string(),
1695 team: team.to_string(),
1696 machine_id: provider_id.to_string(),
1697 step_id: None,
1698 correlation_id: Some(format!("setup-{}", now_setup_ms())),
1699 fields: serde_json::json!({
1700 "reason": reason,
1701 "archive_path": archive_path,
1702 })
1703 .as_object()
1704 .cloned()
1705 .unwrap_or_default(),
1706 },
1707 )?;
1708 Ok(archive_path)
1709}
1710
1711#[derive(Clone, Debug)]
1712pub struct SetupMachineMigration {
1713 pub legacy_backend_path: PathBuf,
1714 pub legacy_setup_actions_path: PathBuf,
1715 pub state_path: PathBuf,
1716 pub backend_archive_path: Option<PathBuf>,
1717 pub setup_actions_archive_path: Option<PathBuf>,
1718 pub event_path: PathBuf,
1719 pub source: String,
1720 pub legacy_backend_removed: bool,
1721 pub setup_actions_removed: bool,
1722 pub initialized: bool,
1723 pub empty: bool,
1724}
1725
1726pub fn migrate_setup_machine_state(
1727 bundle_root: &Path,
1728 env: &str,
1729 tenant: &str,
1730 team: &str,
1731 provider_id: &str,
1732 machine: &SetupMachine,
1733) -> anyhow::Result<SetupMachineMigration> {
1734 let state_path = setup_machine_state_path(bundle_root, tenant, team, provider_id);
1735 let legacy_backend_path = crate::setup_backend_contract::legacy_backend_state_path(
1736 bundle_root,
1737 env,
1738 tenant,
1739 team,
1740 provider_id,
1741 )?;
1742 let legacy_setup_actions_path =
1743 crate::setup_actions::setup_actions_state_path(bundle_root, tenant, team, provider_id);
1744 let had_machine_state = state_path.is_file();
1745 let had_legacy_backend = legacy_backend_path.is_file();
1746 let had_setup_actions = legacy_setup_actions_path.is_file();
1747
1748 let mut state =
1749 load_or_init_setup_machine_state(bundle_root, provider_id, tenant, team, machine)?;
1750 if !had_machine_state {
1751 state.status = SetupMachineStatus::NotStarted;
1752 state.current_step = Some(machine.entry_step.clone());
1753 state.updated_at = Some(now_setup_ts());
1754 write_setup_machine_state(bundle_root, &state)?;
1755 }
1756
1757 let backend_archive_path = if legacy_backend_path.is_file() {
1758 archive_setup_machine_file(
1759 bundle_root,
1760 tenant,
1761 team,
1762 provider_id,
1763 &legacy_backend_path,
1764 "legacy-backend",
1765 )?
1766 } else {
1767 None
1768 };
1769 let legacy_backend_removed = if legacy_backend_path.is_file() {
1770 std::fs::remove_file(&legacy_backend_path).with_context(|| {
1771 format!(
1772 "remove legacy setup backend {}",
1773 legacy_backend_path.display()
1774 )
1775 })?;
1776 true
1777 } else {
1778 false
1779 };
1780 let setup_actions_archive_path = if legacy_setup_actions_path.is_file() {
1781 archive_setup_machine_file(
1782 bundle_root,
1783 tenant,
1784 team,
1785 provider_id,
1786 &legacy_setup_actions_path,
1787 "legacy-setup-actions",
1788 )?
1789 } else {
1790 None
1791 };
1792 let setup_actions_removed = if legacy_setup_actions_path.is_file() {
1793 std::fs::remove_file(&legacy_setup_actions_path).with_context(|| {
1794 format!(
1795 "remove legacy setup actions {}",
1796 legacy_setup_actions_path.display()
1797 )
1798 })?;
1799 true
1800 } else {
1801 false
1802 };
1803 let source = if had_machine_state {
1804 "machine"
1805 } else if had_legacy_backend || had_setup_actions {
1806 "legacy-archived"
1807 } else {
1808 "empty"
1809 };
1810 let event_path = append_setup_machine_event(
1811 bundle_root,
1812 &SetupMachineEvent {
1813 ts: now_setup_ts(),
1814 level: "info".to_string(),
1815 event: "setup.machine.legacy_migrated".to_string(),
1816 provider_id: provider_id.to_string(),
1817 tenant: tenant.to_string(),
1818 team: team.to_string(),
1819 machine_id: machine.id.clone(),
1820 step_id: None,
1821 correlation_id: Some(format!("setup-{}", now_setup_ms())),
1822 fields: serde_json::json!({
1823 "legacy_backend_path": legacy_backend_path,
1824 "legacy_setup_actions_path": legacy_setup_actions_path,
1825 "state_path": state_path,
1826 "backend_archive_path": backend_archive_path,
1827 "setup_actions_archive_path": setup_actions_archive_path,
1828 "source": source,
1829 "legacy_backend_removed": legacy_backend_removed,
1830 "setup_actions_removed": setup_actions_removed,
1831 "initialized": !had_machine_state,
1832 "empty": !had_machine_state && !had_legacy_backend && !had_setup_actions,
1833 })
1834 .as_object()
1835 .cloned()
1836 .unwrap_or_default(),
1837 },
1838 )?;
1839
1840 Ok(SetupMachineMigration {
1841 legacy_backend_path,
1842 legacy_setup_actions_path,
1843 state_path,
1844 backend_archive_path,
1845 setup_actions_archive_path,
1846 event_path,
1847 source: source.to_string(),
1848 legacy_backend_removed,
1849 setup_actions_removed,
1850 initialized: !had_machine_state,
1851 empty: !had_machine_state && !had_legacy_backend && !had_setup_actions,
1852 })
1853}
1854
1855fn archive_setup_machine_file(
1856 bundle_root: &Path,
1857 tenant: &str,
1858 team: &str,
1859 provider_id: &str,
1860 source_path: &Path,
1861 label: &str,
1862) -> anyhow::Result<Option<PathBuf>> {
1863 if !source_path.is_file() {
1864 return Ok(None);
1865 }
1866 let archive_dir =
1867 setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("archive");
1868 std::fs::create_dir_all(&archive_dir)?;
1869 let archive_path = archive_dir.join(format!(
1870 "{}-{}.json",
1871 safe_archive_segment(label),
1872 now_setup_ms()
1873 ));
1874 std::fs::copy(source_path, &archive_path)?;
1875 Ok(Some(archive_path))
1876}
1877
1878pub fn retry_setup_machine_step(
1879 bundle_root: &Path,
1880 provider_id: &str,
1881 tenant: &str,
1882 team: &str,
1883 machine: &SetupMachine,
1884 step: Option<&str>,
1885) -> anyhow::Result<Value> {
1886 let mut state =
1887 load_or_init_setup_machine_state(bundle_root, provider_id, tenant, team, machine)?;
1888 let selected_step = step
1889 .map(str::to_string)
1890 .or_else(|| state.failed_step.clone())
1891 .or_else(|| state.current_step.clone())
1892 .unwrap_or_else(|| machine.entry_step.clone());
1893 if !machine
1894 .steps
1895 .iter()
1896 .any(|candidate| candidate.id == selected_step)
1897 {
1898 anyhow::bail!("setup-machine step not found: {selected_step}");
1899 }
1900 let changed = state.last_error.is_some()
1901 || state.failed_step.as_deref() == Some(selected_step.as_str())
1902 || state.status == SetupMachineStatus::Paused
1903 || state.status == SetupMachineStatus::Failed;
1904 state.current_step = Some(selected_step.clone());
1905 state.failed_step = None;
1906 state.last_error = None;
1907 if state.status != SetupMachineStatus::Complete {
1908 state.status = SetupMachineStatus::Running;
1909 }
1910 state.updated_at = Some(now_setup_ts());
1911 write_setup_machine_state(bundle_root, &state)?;
1912 let event_path = append_machine_step_event(
1913 bundle_root,
1914 &state,
1915 "setup.step.retry_requested",
1916 Some(&selected_step),
1917 Some(serde_json::json!({
1918 "changed": changed,
1919 })),
1920 )?;
1921 Ok(serde_json::json!({
1922 "provider_id": provider_id,
1923 "tenant": tenant,
1924 "team": team,
1925 "machine_id": machine.id,
1926 "step": selected_step,
1927 "changed": changed,
1928 "event_path": event_path,
1929 "status": render_setup_machine_status(machine, &state),
1930 }))
1931}
1932
1933#[derive(Clone, Debug)]
1934struct MachineStepResult {
1935 ok: bool,
1936 next: String,
1937 detail: Value,
1938}
1939
1940fn execute_machine_step(
1941 bundle_root: &Path,
1942 pack_path: Option<&Path>,
1943 machine: &SetupMachine,
1944 step: &SetupMachineStep,
1945 state: &mut SetupMachineState,
1946) -> MachineStepResult {
1947 if step.extra.get("auto_complete").and_then(Value::as_bool) == Some(true) {
1948 mark_machine_step_complete(machine, step, state);
1949 return MachineStepResult {
1950 ok: true,
1951 next: "step completed".to_string(),
1952 detail: serde_json::json!({
1953 "kind": step.kind,
1954 "auto_complete": true,
1955 }),
1956 };
1957 }
1958
1959 match step.kind.as_str() {
1960 "platform_public_url" => {
1961 execute_platform_public_url_step(bundle_root, machine, step, state)
1962 }
1963 "cloudflare_tunnel" => execute_cloudflare_tunnel_step(bundle_root, machine, step, state),
1964 "oauth_device_code" => execute_oauth_device_code_step(bundle_root, machine, step, state),
1965 "oauth_authorization_code" => {
1966 execute_oauth_authorization_code_step(bundle_root, machine, step, state)
1967 }
1968 "http_probe" | "http_json" => execute_http_step(machine, step, state),
1969 "select_from_json" => execute_select_from_json_step(machine, step, state),
1970 "generate_file" => execute_generate_file_step(bundle_root, machine, step, state),
1971 "persist_runtime_config" => {
1972 execute_persist_runtime_config_step(bundle_root, pack_path, machine, step, state)
1973 }
1974 "provider_component_call" => {
1975 execute_provider_component_call_step(bundle_root, pack_path, machine, step, state)
1976 }
1977 "manual_action" | "download_file" | "qa_form" => {
1978 state.status = SetupMachineStatus::Paused;
1979 state.current_step = Some(step.id.clone());
1980 state.last_error = Some(serde_json::json!({
1981 "error": "manual_action_required",
1982 "step": step.id,
1983 "recoverable": true,
1984 }));
1985 MachineStepResult {
1986 ok: false,
1987 next: "operator action required".to_string(),
1988 detail: serde_json::json!({
1989 "blocked": true,
1990 "retryable": true,
1991 "error": "manual_action_required",
1992 "kind": step.kind,
1993 "title": step.title,
1994 }),
1995 }
1996 }
1997 _ => {
1998 state.status = SetupMachineStatus::Paused;
1999 state.current_step = Some(step.id.clone());
2000 state.last_error = Some(serde_json::json!({
2001 "error": "unsupported_step_kind",
2002 "step": step.id,
2003 "kind": step.kind,
2004 "recoverable": true,
2005 }));
2006 MachineStepResult {
2007 ok: false,
2008 next: "setup-machine step kind is not supported by this greentic-setup build"
2009 .to_string(),
2010 detail: serde_json::json!({
2011 "blocked": true,
2012 "retryable": true,
2013 "error": "unsupported_step_kind",
2014 "kind": step.kind,
2015 }),
2016 }
2017 }
2018 }
2019}
2020
2021fn apply_declared_failure_transition(
2022 machine: &SetupMachine,
2023 step: &SetupMachineStep,
2024 state: &mut SetupMachineState,
2025 result: &MachineStepResult,
2026) -> Option<String> {
2027 if result.ok {
2028 return None;
2029 }
2030 let error = result_error_code(state, result)?;
2031 let target = step
2032 .recover
2033 .iter()
2034 .chain(step.on_failure.iter())
2035 .find(|rule| failure_rule_matches(rule, &error))
2036 .and_then(|rule| rule_targets(rule).into_iter().next())?;
2037
2038 if target == TERMINAL_FAILED {
2039 state.status = SetupMachineStatus::Failed;
2040 state.current_step = Some(step.id.clone());
2041 state.failed_step = Some(step.id.clone());
2042 state.last_error = Some(merge_failure_recovery_detail(
2043 state.last_error.clone(),
2044 &error,
2045 &target,
2046 ));
2047 return Some(target);
2048 }
2049 if target == TERMINAL_SUCCESS {
2050 state.status = SetupMachineStatus::Complete;
2051 state.current_step = None;
2052 state.failed_step = None;
2053 state.last_error = None;
2054 return Some(target);
2055 }
2056 if machine.steps.iter().any(|candidate| candidate.id == target) {
2057 state.status = SetupMachineStatus::Running;
2058 state.current_step = Some(target.clone());
2059 state.failed_step = None;
2060 state.last_error = Some(merge_failure_recovery_detail(
2061 state.last_error.clone(),
2062 &error,
2063 &target,
2064 ));
2065 return Some(target);
2066 }
2067
2068 state.status = SetupMachineStatus::Failed;
2069 state.current_step = Some(step.id.clone());
2070 state.failed_step = Some(step.id.clone());
2071 state.last_error = Some(serde_json::json!({
2072 "error": "invalid_recovery_target",
2073 "step": step.id,
2074 "target": target,
2075 "recoverable": false,
2076 }));
2077 None
2078}
2079
2080fn result_error_code(state: &SetupMachineState, result: &MachineStepResult) -> Option<String> {
2081 result
2082 .detail
2083 .get("error")
2084 .and_then(Value::as_str)
2085 .or_else(|| {
2086 state
2087 .last_error
2088 .as_ref()
2089 .and_then(|error| error.get("error"))
2090 .and_then(Value::as_str)
2091 })
2092 .map(str::to_string)
2093}
2094
2095fn failure_rule_matches(rule: &Value, error: &str) -> bool {
2096 let Some(obj) = rule.as_object() else {
2097 return false;
2098 };
2099 for key in ["when", "error", "code"] {
2100 match obj.get(key) {
2101 Some(Value::String(value)) if value == error || value == "*" => return true,
2102 Some(Value::Array(values))
2103 if values
2104 .iter()
2105 .any(|value| value.as_str() == Some(error) || value.as_str() == Some("*")) =>
2106 {
2107 return true;
2108 }
2109 _ => {}
2110 }
2111 }
2112 !obj.contains_key("when") && !obj.contains_key("error") && !obj.contains_key("code")
2113}
2114
2115fn merge_failure_recovery_detail(base: Option<Value>, error: &str, recovery_step: &str) -> Value {
2116 let mut object = base
2117 .and_then(|value| value.as_object().cloned())
2118 .unwrap_or_default();
2119 object.insert("error".to_string(), Value::String(error.to_string()));
2120 object.insert(
2121 "recovery_step".to_string(),
2122 Value::String(recovery_step.to_string()),
2123 );
2124 object.insert("recoverable".to_string(), Value::Bool(true));
2125 Value::Object(object)
2126}
2127
2128fn execute_provider_component_call_step(
2129 bundle_root: &Path,
2130 pack_path: Option<&Path>,
2131 machine: &SetupMachine,
2132 step: &SetupMachineStep,
2133 state: &mut SetupMachineState,
2134) -> MachineStepResult {
2135 let Some(pack_path) = pack_path else {
2136 state.status = SetupMachineStatus::Paused;
2137 state.current_step = Some(step.id.clone());
2138 state.last_error = Some(serde_json::json!({
2139 "error": "missing_prerequisite",
2140 "step": step.id,
2141 "missing": "pack_path",
2142 "recoverable": true,
2143 }));
2144 return MachineStepResult {
2145 ok: false,
2146 next: "run setup-machine with provider pack provenance, then retry".to_string(),
2147 detail: serde_json::json!({
2148 "blocked": true,
2149 "retryable": true,
2150 "error": "missing_prerequisite",
2151 "missing_config_key": "pack_path",
2152 }),
2153 };
2154 };
2155 let Some(component_ref) = step
2156 .extra
2157 .get("component_ref")
2158 .or_else(|| step.extra.get("component"))
2159 .and_then(Value::as_str)
2160 .map(str::trim)
2161 .filter(|value| !value.is_empty())
2162 else {
2163 return pause_provider_component_contract_error(
2164 step,
2165 state,
2166 "provider_component_call requires component_ref",
2167 );
2168 };
2169 let Some(op) = step
2170 .extra
2171 .get("op")
2172 .or_else(|| step.extra.get("operation"))
2173 .and_then(Value::as_str)
2174 .map(str::trim)
2175 .filter(|value| !value.is_empty())
2176 else {
2177 return pause_provider_component_contract_error(
2178 step,
2179 state,
2180 "provider_component_call requires op",
2181 );
2182 };
2183 let request = step
2184 .extra
2185 .get("request")
2186 .or_else(|| step.extra.get("body"))
2187 .cloned()
2188 .unwrap_or_else(|| {
2189 serde_json::json!({
2190 "tenant": state.tenant,
2191 "team": state.team,
2192 "provider_id": state.provider_id,
2193 "outputs": state.outputs,
2194 })
2195 });
2196 let request = expand_machine_value_templates(request, state);
2197 let env = step
2198 .extra
2199 .get("env")
2200 .and_then(Value::as_str)
2201 .unwrap_or("dev");
2202 let config = crate::engine::SetupConfig {
2203 tenant: state.tenant.clone(),
2204 team: Some(state.team.clone()),
2205 env: env.to_string(),
2206 offline: false,
2207 verbose: false,
2208 };
2209 let result = match crate::engine::invoke_setup_component_operation(
2210 bundle_root,
2211 pack_path,
2212 component_ref,
2213 op,
2214 &request,
2215 &config,
2216 ) {
2217 Ok(result) => result,
2218 Err(err) => {
2219 return pause_provider_component_contract_error(step, state, &err.to_string());
2220 }
2221 };
2222 if result.get("ok").and_then(Value::as_bool) == Some(false) {
2223 state.status = SetupMachineStatus::Paused;
2224 state.current_step = Some(step.id.clone());
2225 state.last_error = Some(serde_json::json!({
2226 "error": "provider_contract_error",
2227 "step": step.id,
2228 "detail": result,
2229 "recoverable": true,
2230 }));
2231 return MachineStepResult {
2232 ok: false,
2233 next: "provider setup component returned ok:false".to_string(),
2234 detail: serde_json::json!({
2235 "blocked": true,
2236 "retryable": true,
2237 "error": "provider_contract_error",
2238 "result": result,
2239 }),
2240 };
2241 }
2242 let output_key = step
2243 .extra
2244 .get("output_key")
2245 .and_then(Value::as_str)
2246 .unwrap_or(step.id.as_str());
2247 ensure_outputs_object(state).insert(output_key.to_string(), result.clone());
2248 mark_machine_step_complete(machine, step, state);
2249 MachineStepResult {
2250 ok: true,
2251 next: "provider component setup operation completed".to_string(),
2252 detail: serde_json::json!({
2253 "kind": step.kind,
2254 "component_ref": component_ref,
2255 "op": op,
2256 "output_key": output_key,
2257 "result": redact_large_json_detail(&result),
2258 }),
2259 }
2260}
2261
2262fn pause_provider_component_contract_error(
2263 step: &SetupMachineStep,
2264 state: &mut SetupMachineState,
2265 detail: &str,
2266) -> MachineStepResult {
2267 state.status = SetupMachineStatus::Paused;
2268 state.current_step = Some(step.id.clone());
2269 state.last_error = Some(serde_json::json!({
2270 "error": "provider_contract_error",
2271 "step": step.id,
2272 "detail": detail,
2273 "recoverable": true,
2274 }));
2275 MachineStepResult {
2276 ok: false,
2277 next: "fix provider component setup declaration, then retry".to_string(),
2278 detail: serde_json::json!({
2279 "blocked": true,
2280 "retryable": true,
2281 "error": "provider_contract_error",
2282 "detail": detail,
2283 }),
2284 }
2285}
2286
2287#[derive(Clone, Debug, Serialize, Deserialize)]
2288struct SetupMachineOAuthDeviceSession {
2289 step_id: String,
2290 provider_id: String,
2291 tenant: String,
2292 team: String,
2293 device_code: String,
2294 client_id: String,
2295 token_url: String,
2296 token_store_key: String,
2297 #[serde(default, skip_serializing_if = "Option::is_none")]
2298 refresh_token_store_key: Option<String>,
2299 interval: u64,
2300 expires_at: u64,
2301 created_at: u64,
2302}
2303
2304fn execute_oauth_device_code_step(
2305 bundle_root: &Path,
2306 machine: &SetupMachine,
2307 step: &SetupMachineStep,
2308 state: &mut SetupMachineState,
2309) -> MachineStepResult {
2310 match load_setup_machine_oauth_device_session(bundle_root, state, &step.id) {
2311 Ok(Some(session)) => {
2312 execute_oauth_device_code_poll(bundle_root, machine, step, state, session)
2313 }
2314 Ok(None) => execute_oauth_device_code_start(bundle_root, step, state),
2315 Err(err) => pause_oauth_device_error(
2316 step,
2317 state,
2318 "provider_contract_error",
2319 "OAuth device-code session could not be loaded",
2320 serde_json::json!({"detail": err.to_string()}),
2321 ),
2322 }
2323}
2324
2325fn execute_oauth_device_code_start(
2326 bundle_root: &Path,
2327 step: &SetupMachineStep,
2328 state: &mut SetupMachineState,
2329) -> MachineStepResult {
2330 let client_id = match oauth_step_value(step, state, "client_id", "client_id_output") {
2331 Ok(Some(value)) if !value.trim().is_empty() => value,
2332 Ok(_) => {
2333 return pause_oauth_device_error(
2334 step,
2335 state,
2336 "missing_prerequisite",
2337 "set OAuth client_id, then retry",
2338 serde_json::json!({"missing_config_key": "client_id"}),
2339 );
2340 }
2341 Err(err) => {
2342 return pause_oauth_device_error(
2343 step,
2344 state,
2345 "invalid_answer",
2346 "fix OAuth client_id template, then retry",
2347 serde_json::json!({"detail": err.to_string()}),
2348 );
2349 }
2350 };
2351 let scopes = match oauth_scope(step) {
2352 Ok(Some(scopes)) if !scopes.trim().is_empty() => scopes,
2353 Ok(_) => {
2354 return pause_oauth_device_error(
2355 step,
2356 state,
2357 "missing_prerequisite",
2358 "declare OAuth device-code scopes, then retry",
2359 serde_json::json!({"missing_config_key": "scopes"}),
2360 );
2361 }
2362 Err(err) => {
2363 return pause_oauth_device_error(
2364 step,
2365 state,
2366 "invalid_answer",
2367 "fix OAuth scopes, then retry",
2368 serde_json::json!({"detail": err.to_string()}),
2369 );
2370 }
2371 };
2372 let device_code_url = match oauth_device_endpoint(step, "device_code_url", "devicecode") {
2373 Ok(url) => url,
2374 Err(err) => {
2375 return pause_oauth_device_error(
2376 step,
2377 state,
2378 "missing_prerequisite",
2379 "declare device_code_url or authority_url_template, then retry",
2380 serde_json::json!({"detail": err.to_string()}),
2381 );
2382 }
2383 };
2384 let token_url = match oauth_device_endpoint(step, "token_url", "token") {
2385 Ok(url) => url,
2386 Err(err) => {
2387 return pause_oauth_device_error(
2388 step,
2389 state,
2390 "missing_prerequisite",
2391 "declare token_url or authority_url_template, then retry",
2392 serde_json::json!({"detail": err.to_string()}),
2393 );
2394 }
2395 };
2396 if let Err(err) =
2397 validate_http_step_url(&device_code_url).and_then(|_| validate_http_step_url(&token_url))
2398 {
2399 return pause_oauth_device_error(
2400 step,
2401 state,
2402 "invalid_answer",
2403 "fix OAuth device-code URLs, then retry",
2404 serde_json::json!({"detail": err.to_string()}),
2405 );
2406 }
2407
2408 let mut response = match ureq::post(&device_code_url).send_form([
2409 ("client_id", client_id.as_str()),
2410 ("scope", scopes.as_str()),
2411 ]) {
2412 Ok(response) => response,
2413 Err(err) => {
2414 return pause_oauth_device_error(
2415 step,
2416 state,
2417 "external_timeout",
2418 "OAuth device-code endpoint is not reachable",
2419 serde_json::json!({"detail": err.to_string(), "target": device_code_url}),
2420 );
2421 }
2422 };
2423 let body = match response.body_mut().read_json::<Value>() {
2424 Ok(body) => body,
2425 Err(err) => {
2426 return pause_oauth_device_error(
2427 step,
2428 state,
2429 "provider_contract_error",
2430 "OAuth device-code response was not valid JSON",
2431 serde_json::json!({"detail": err.to_string()}),
2432 );
2433 }
2434 };
2435 let device_code = match body
2436 .get("device_code")
2437 .and_then(Value::as_str)
2438 .map(str::trim)
2439 {
2440 Some(value) if !value.is_empty() => value.to_string(),
2441 _ => {
2442 return pause_oauth_device_error(
2443 step,
2444 state,
2445 "provider_contract_error",
2446 "OAuth device-code response did not include device_code",
2447 serde_json::json!({"body": public_oauth_device_response(&body)}),
2448 );
2449 }
2450 };
2451 let user_code = body
2452 .get("user_code")
2453 .and_then(Value::as_str)
2454 .map(str::trim)
2455 .filter(|value| !value.is_empty())
2456 .unwrap_or_default()
2457 .to_string();
2458 let verification_uri = body
2459 .get("verification_uri")
2460 .or_else(|| body.get("verification_url"))
2461 .and_then(Value::as_str)
2462 .map(str::trim)
2463 .filter(|value| !value.is_empty())
2464 .or_else(|| step.extra.get("verification_uri").and_then(Value::as_str))
2465 .unwrap_or_default()
2466 .to_string();
2467 if user_code.is_empty() || verification_uri.is_empty() {
2468 return pause_oauth_device_error(
2469 step,
2470 state,
2471 "provider_contract_error",
2472 "OAuth device-code response did not include user_code or verification_uri",
2473 serde_json::json!({"body": public_oauth_device_response(&body)}),
2474 );
2475 }
2476 let now = crate::setup_actions::current_epoch_secs();
2477 let expires_in = body
2478 .get("expires_in")
2479 .and_then(Value::as_u64)
2480 .unwrap_or(900);
2481 let interval = body
2482 .get("interval")
2483 .and_then(Value::as_u64)
2484 .unwrap_or(5)
2485 .max(1);
2486 let token_store_key = step
2487 .extra
2488 .get("token_store_key")
2489 .and_then(Value::as_str)
2490 .map(str::trim)
2491 .filter(|value| !value.is_empty())
2492 .unwrap_or("oauth_access_token")
2493 .to_string();
2494 let refresh_token_store_key = step
2495 .extra
2496 .get("refresh_token_store_key")
2497 .and_then(Value::as_str)
2498 .map(str::trim)
2499 .filter(|value| !value.is_empty())
2500 .map(str::to_string);
2501 let session = SetupMachineOAuthDeviceSession {
2502 step_id: step.id.clone(),
2503 provider_id: state.provider_id.clone(),
2504 tenant: state.tenant.clone(),
2505 team: state.team.clone(),
2506 device_code,
2507 client_id,
2508 token_url,
2509 token_store_key,
2510 refresh_token_store_key,
2511 interval,
2512 expires_at: now + expires_in,
2513 created_at: now,
2514 };
2515 if let Err(err) = write_setup_machine_oauth_device_session(bundle_root, state, &session) {
2516 return pause_oauth_device_error(
2517 step,
2518 state,
2519 "provider_contract_error",
2520 "OAuth device-code session could not be saved",
2521 serde_json::json!({"detail": err.to_string()}),
2522 );
2523 }
2524
2525 let output_key = step
2526 .extra
2527 .get("output_key")
2528 .and_then(Value::as_str)
2529 .unwrap_or("oauth_device_login");
2530 ensure_outputs_object(state).insert(
2531 output_key.to_string(),
2532 serde_json::json!({
2533 "verification_uri": verification_uri,
2534 "verification_uri_complete": body.get("verification_uri_complete").cloned().unwrap_or(Value::Null),
2535 "user_code": user_code,
2536 "expires_at": session.expires_at,
2537 "interval": session.interval,
2538 }),
2539 );
2540 state.status = SetupMachineStatus::Paused;
2541 state.current_step = Some(step.id.clone());
2542 state.last_error = Some(serde_json::json!({
2543 "error": "manual_action_required",
2544 "reason": "oauth_device_code_pending",
2545 "step": step.id,
2546 "recoverable": true,
2547 }));
2548 MachineStepResult {
2549 ok: false,
2550 next: "open device-code URL, enter the code, then run setup-next again".to_string(),
2551 detail: serde_json::json!({
2552 "blocked": true,
2553 "retryable": true,
2554 "error": "manual_action_required",
2555 "reason": "oauth_device_code_pending",
2556 "login": state.outputs.get(output_key).cloned().unwrap_or(Value::Null),
2557 "output_key": output_key,
2558 }),
2559 }
2560}
2561
2562fn execute_oauth_device_code_poll(
2563 bundle_root: &Path,
2564 machine: &SetupMachine,
2565 step: &SetupMachineStep,
2566 state: &mut SetupMachineState,
2567 session: SetupMachineOAuthDeviceSession,
2568) -> MachineStepResult {
2569 if crate::setup_actions::current_epoch_secs() >= session.expires_at {
2570 let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2571 return pause_oauth_device_error(
2572 step,
2573 state,
2574 "oauth_token_expired",
2575 "OAuth device code expired; retry to start a new login",
2576 serde_json::json!({"expired": true}),
2577 );
2578 }
2579 let agent = ureq::Agent::config_builder()
2580 .http_status_as_error(false)
2581 .build()
2582 .new_agent();
2583 let mut response = match agent.post(&session.token_url).send_form([
2584 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
2585 ("client_id", session.client_id.as_str()),
2586 ("device_code", session.device_code.as_str()),
2587 ]) {
2588 Ok(response) => response,
2589 Err(err) => {
2590 return pause_oauth_device_error(
2591 step,
2592 state,
2593 "external_timeout",
2594 "OAuth token endpoint is not reachable",
2595 serde_json::json!({"detail": err.to_string(), "target": session.token_url}),
2596 );
2597 }
2598 };
2599 let status = response.status().as_u16();
2600 let body = match response.body_mut().read_json::<Value>() {
2601 Ok(body) => body,
2602 Err(err) => {
2603 return pause_oauth_device_error(
2604 step,
2605 state,
2606 "provider_contract_error",
2607 "OAuth token response was not valid JSON",
2608 serde_json::json!({"detail": err.to_string(), "status": status}),
2609 );
2610 }
2611 };
2612 if let Some(error) = body.get("error").and_then(Value::as_str)
2613 && matches!(error, "authorization_pending" | "slow_down")
2614 {
2615 state.status = SetupMachineStatus::Paused;
2616 state.current_step = Some(step.id.clone());
2617 state.last_error = Some(serde_json::json!({
2618 "error": "oauth_pending",
2619 "step": step.id,
2620 "recoverable": true,
2621 }));
2622 return MachineStepResult {
2623 ok: false,
2624 next: "OAuth authorization is still pending".to_string(),
2625 detail: serde_json::json!({
2626 "blocked": true,
2627 "retryable": true,
2628 "error": "oauth_pending",
2629 "interval": session.interval,
2630 }),
2631 };
2632 }
2633 if status >= 400 || body.get("access_token").and_then(Value::as_str).is_none() {
2634 return pause_oauth_device_error(
2635 step,
2636 state,
2637 "oauth_refresh_failed",
2638 "OAuth token polling failed",
2639 serde_json::json!({"status": status, "body": public_oauth_device_response(&body)}),
2640 );
2641 }
2642 let mut config = serde_json::Map::new();
2643 if let Some(access_token) = body.get("access_token").and_then(Value::as_str) {
2644 config.insert(
2645 session.token_store_key.clone(),
2646 Value::String(access_token.to_string()),
2647 );
2648 }
2649 if let Some(refresh_key) = session.refresh_token_store_key.as_deref()
2650 && let Some(refresh_token) = body.get("refresh_token").and_then(Value::as_str)
2651 {
2652 config.insert(
2653 refresh_key.to_string(),
2654 Value::String(refresh_token.to_string()),
2655 );
2656 }
2657 if config.is_empty() {
2658 return pause_oauth_device_error(
2659 step,
2660 state,
2661 "provider_contract_error",
2662 "OAuth token response had no persistable tokens",
2663 serde_json::json!({"status": status}),
2664 );
2665 }
2666 let config = Value::Object(config);
2667 let env = step
2668 .extra
2669 .get("env")
2670 .and_then(Value::as_str)
2671 .unwrap_or("dev");
2672 let persist_result = tokio::runtime::Runtime::new()
2673 .map_err(|err| err.to_string())
2674 .and_then(|runtime| {
2675 runtime
2676 .block_on(crate::qa::persist::persist_all_config_as_secrets(
2677 bundle_root,
2678 env,
2679 &state.tenant,
2680 Some(&state.team),
2681 &state.provider_id,
2682 &config,
2683 None,
2684 ))
2685 .map_err(|err| err.to_string())
2686 });
2687 let persisted_keys = match persist_result {
2688 Ok(keys) => keys,
2689 Err(err) => {
2690 return pause_oauth_device_error(
2691 step,
2692 state,
2693 "provider_contract_error",
2694 "OAuth token secrets could not be persisted",
2695 serde_json::json!({"detail": err}),
2696 );
2697 }
2698 };
2699 let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2700 ensure_outputs_object(state).insert(
2701 step.extra
2702 .get("output_key")
2703 .and_then(Value::as_str)
2704 .unwrap_or("oauth_device_login")
2705 .to_string(),
2706 serde_json::json!({
2707 "ok": true,
2708 "token_store_key": session.token_store_key,
2709 "persisted_keys": persisted_keys,
2710 }),
2711 );
2712 mark_machine_step_complete(machine, step, state);
2713 MachineStepResult {
2714 ok: true,
2715 next: "OAuth device-code step completed".to_string(),
2716 detail: serde_json::json!({
2717 "kind": step.kind,
2718 "persisted_keys": persisted_keys,
2719 }),
2720 }
2721}
2722
2723fn pause_oauth_device_error(
2724 step: &SetupMachineStep,
2725 state: &mut SetupMachineState,
2726 error: &str,
2727 next: &str,
2728 detail: Value,
2729) -> MachineStepResult {
2730 state.status = SetupMachineStatus::Paused;
2731 state.current_step = Some(step.id.clone());
2732 state.last_error = Some(serde_json::json!({
2733 "error": error,
2734 "step": step.id,
2735 "detail": detail,
2736 "recoverable": true,
2737 }));
2738 let mut detail_obj = serde_json::json!({
2739 "blocked": true,
2740 "retryable": true,
2741 "error": error,
2742 });
2743 if let (Some(obj), Some(extra)) = (detail_obj.as_object_mut(), detail.as_object()) {
2744 for (key, value) in extra {
2745 obj.insert(key.clone(), value.clone());
2746 }
2747 }
2748 MachineStepResult {
2749 ok: false,
2750 next: next.to_string(),
2751 detail: detail_obj,
2752 }
2753}
2754
2755fn oauth_device_endpoint(
2756 step: &SetupMachineStep,
2757 direct_key: &str,
2758 authority_suffix: &str,
2759) -> anyhow::Result<String> {
2760 if let Some(value) = step
2761 .extra
2762 .get(direct_key)
2763 .or_else(|| step.extra.get(&format!("{direct_key}_template")))
2764 .and_then(Value::as_str)
2765 .map(str::trim)
2766 .filter(|value| !value.is_empty())
2767 {
2768 return Ok(value.to_string());
2769 }
2770 let authority = step
2771 .extra
2772 .get("authority_url_template")
2773 .or_else(|| step.extra.get("authority_url"))
2774 .and_then(Value::as_str)
2775 .map(str::trim)
2776 .filter(|value| !value.is_empty())
2777 .ok_or_else(|| anyhow::anyhow!("missing {direct_key}"))?;
2778 Ok(format!(
2779 "{}/oauth2/v2.0/{}",
2780 authority.trim_end_matches('/'),
2781 authority_suffix
2782 ))
2783}
2784
2785fn public_oauth_device_response(body: &Value) -> Value {
2786 let mut public = serde_json::Map::new();
2787 for key in [
2788 "user_code",
2789 "verification_uri",
2790 "verification_url",
2791 "verification_uri_complete",
2792 "expires_in",
2793 "interval",
2794 "message",
2795 "error",
2796 "error_description",
2797 ] {
2798 if let Some(value) = body.get(key) {
2799 public.insert(key.to_string(), value.clone());
2800 }
2801 }
2802 Value::Object(public)
2803}
2804
2805fn setup_machine_oauth_device_session_path(
2806 bundle_root: &Path,
2807 state: &SetupMachineState,
2808 step_id: &str,
2809) -> PathBuf {
2810 setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
2811 .join("oauth-device-sessions")
2812 .join(format!("{step_id}.json"))
2813}
2814
2815fn load_setup_machine_oauth_device_session(
2816 bundle_root: &Path,
2817 state: &SetupMachineState,
2818 step_id: &str,
2819) -> anyhow::Result<Option<SetupMachineOAuthDeviceSession>> {
2820 let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2821 if !path.is_file() {
2822 return Ok(None);
2823 }
2824 let raw = std::fs::read_to_string(&path)?;
2825 Ok(Some(serde_json::from_str(&raw)?))
2826}
2827
2828fn write_setup_machine_oauth_device_session(
2829 bundle_root: &Path,
2830 state: &SetupMachineState,
2831 session: &SetupMachineOAuthDeviceSession,
2832) -> anyhow::Result<PathBuf> {
2833 let path = setup_machine_oauth_device_session_path(bundle_root, state, &session.step_id);
2834 if let Some(parent) = path.parent() {
2835 std::fs::create_dir_all(parent)?;
2836 }
2837 std::fs::write(&path, serde_json::to_vec_pretty(session)?)?;
2838 #[cfg(unix)]
2839 {
2840 use std::os::unix::fs::PermissionsExt;
2841 let mut permissions = std::fs::metadata(&path)?.permissions();
2842 permissions.set_mode(0o600);
2843 std::fs::set_permissions(&path, permissions)?;
2844 }
2845 Ok(path)
2846}
2847
2848fn remove_setup_machine_oauth_device_session(
2849 bundle_root: &Path,
2850 state: &SetupMachineState,
2851 step_id: &str,
2852) -> anyhow::Result<()> {
2853 let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2854 if path.exists() {
2855 std::fs::remove_file(path)?;
2856 }
2857 Ok(())
2858}
2859
2860fn execute_oauth_authorization_code_step(
2861 bundle_root: &Path,
2862 _machine: &SetupMachine,
2863 step: &SetupMachineStep,
2864 state: &mut SetupMachineState,
2865) -> MachineStepResult {
2866 let authorization_url = match build_oauth_authorization_url(bundle_root, step, state) {
2867 Ok(url) => url,
2868 Err(OauthAuthorizationError::Missing(key)) => {
2869 state.status = SetupMachineStatus::Paused;
2870 state.current_step = Some(step.id.clone());
2871 state.last_error = Some(serde_json::json!({
2872 "error": "missing_prerequisite",
2873 "step": step.id,
2874 "missing": key,
2875 "recoverable": true,
2876 }));
2877 return MachineStepResult {
2878 ok: false,
2879 next: "provide OAuth authorization prerequisites, then retry".to_string(),
2880 detail: serde_json::json!({
2881 "blocked": true,
2882 "retryable": true,
2883 "error": "missing_prerequisite",
2884 "missing_config_key": key,
2885 }),
2886 };
2887 }
2888 Err(OauthAuthorizationError::Invalid(detail)) => {
2889 state.status = SetupMachineStatus::Paused;
2890 state.current_step = Some(step.id.clone());
2891 state.last_error = Some(serde_json::json!({
2892 "error": "invalid_answer",
2893 "step": step.id,
2894 "detail": detail,
2895 "recoverable": true,
2896 }));
2897 return MachineStepResult {
2898 ok: false,
2899 next: "fix OAuth authorization URL inputs, then retry".to_string(),
2900 detail: serde_json::json!({
2901 "blocked": true,
2902 "retryable": true,
2903 "error": "invalid_answer",
2904 "detail": detail,
2905 }),
2906 };
2907 }
2908 };
2909
2910 let output_key = step
2911 .extra
2912 .get("output_key")
2913 .and_then(Value::as_str)
2914 .unwrap_or("authorization_url");
2915 ensure_outputs_object(state).insert(
2916 output_key.to_string(),
2917 Value::String(authorization_url.clone()),
2918 );
2919 state.status = SetupMachineStatus::Paused;
2920 state.current_step = Some(step.id.clone());
2921 state.last_error = Some(serde_json::json!({
2922 "error": "manual_action_required",
2923 "reason": "oauth_authorization_required",
2924 "step": step.id,
2925 "recoverable": true,
2926 }));
2927 MachineStepResult {
2928 ok: false,
2929 next: "open authorization URL and complete OAuth callback".to_string(),
2930 detail: serde_json::json!({
2931 "blocked": true,
2932 "retryable": true,
2933 "error": "manual_action_required",
2934 "reason": "oauth_authorization_required",
2935 "authorization_url": authorization_url,
2936 "output_key": output_key,
2937 "kind": step.kind,
2938 }),
2939 }
2940}
2941
2942#[derive(Debug)]
2943enum OauthAuthorizationError {
2944 Missing(&'static str),
2945 Invalid(String),
2946}
2947
2948impl std::fmt::Display for OauthAuthorizationError {
2949 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2950 match self {
2951 Self::Missing(key) => write!(f, "missing {key}"),
2952 Self::Invalid(detail) => f.write_str(detail),
2953 }
2954 }
2955}
2956
2957impl std::error::Error for OauthAuthorizationError {}
2958
2959fn build_oauth_authorization_url(
2960 bundle_root: &Path,
2961 step: &SetupMachineStep,
2962 state: &SetupMachineState,
2963) -> Result<String, OauthAuthorizationError> {
2964 let template = step
2965 .extra
2966 .get("authorize_url")
2967 .or_else(|| step.extra.get("authorization_url"))
2968 .or_else(|| step.extra.get("auth_url"))
2969 .and_then(Value::as_str)
2970 .map(str::trim)
2971 .filter(|value| !value.is_empty())
2972 .ok_or(OauthAuthorizationError::Missing("authorize_url"))?;
2973 let expanded = expand_machine_template(template, state)
2974 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2975 let mut parsed = url::Url::parse(&expanded)
2976 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2977 if parsed.scheme() != "https" || parsed.host_str().is_none() {
2978 return Err(OauthAuthorizationError::Invalid(
2979 "authorize_url must be an https URL with a host".to_string(),
2980 ));
2981 }
2982
2983 let key = crate::setup_actions::load_or_create_signing_key(bundle_root)
2984 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2985 let ttl = step
2986 .extra
2987 .get("state_ttl_secs")
2988 .and_then(Value::as_u64)
2989 .unwrap_or(15 * 60);
2990 let payload = crate::setup_actions::OAuthStatePayload {
2991 provider_id: state.provider_id.clone(),
2992 tenant: state.tenant.clone(),
2993 team: state.team.clone(),
2994 action_id: step.id.clone(),
2995 nonce: oauth_nonce(),
2996 expires_at: crate::setup_actions::current_epoch_secs() + ttl.max(1),
2997 };
2998 let signed_state = crate::setup_actions::sign_oauth_state(&payload, &key)
2999 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3000
3001 append_oauth_query_param(&mut parsed, "response_type", oauth_response_type(step));
3002 if let Some(client_id) = oauth_step_value(step, state, "client_id", "client_id_output")? {
3003 append_oauth_query_param(&mut parsed, "client_id", &client_id);
3004 }
3005 if let Some(redirect_uri) = oauth_redirect_uri(bundle_root, step, state)? {
3006 append_oauth_query_param(&mut parsed, "redirect_uri", &redirect_uri);
3007 }
3008 if let Some(scope) = oauth_scope(step)? {
3009 append_oauth_query_param(&mut parsed, "scope", &scope);
3010 }
3011 append_oauth_query_param(&mut parsed, "state", &signed_state);
3012 if let Some(extra_params) = step.extra.get("params").and_then(Value::as_object) {
3013 let expanded = expand_machine_value_templates(Value::Object(extra_params.clone()), state);
3014 if let Some(params) = expanded.as_object() {
3015 for (key, value) in params {
3016 if let Some(value) = value.as_str() {
3017 append_oauth_query_param(&mut parsed, key, value);
3018 }
3019 }
3020 }
3021 }
3022 Ok(parsed.to_string())
3023}
3024
3025fn exchange_setup_machine_oauth_authorization_code(
3026 bundle_root: &Path,
3027 machine: &SetupMachine,
3028 oauth_state: &crate::setup_actions::OAuthStatePayload,
3029 code: &str,
3030) -> anyhow::Result<Value> {
3031 let state = load_or_init_setup_machine_state(
3032 bundle_root,
3033 &oauth_state.provider_id,
3034 &oauth_state.tenant,
3035 &oauth_state.team,
3036 machine,
3037 )?;
3038 let step = machine
3039 .steps
3040 .iter()
3041 .find(|candidate| candidate.id == oauth_state.action_id)
3042 .ok_or_else(|| {
3043 anyhow::anyhow!("setup-machine step not found: {}", oauth_state.action_id)
3044 })?;
3045 if step.kind != "oauth_authorization_code" {
3046 anyhow::bail!(
3047 "setup-machine step is not an oauth_authorization_code step: {}",
3048 step.id
3049 );
3050 }
3051 let token_url = oauth_authorization_token_url(step, &state)?;
3052 let redirect_uri = oauth_redirect_uri(bundle_root, step, &state)?
3053 .ok_or(OauthAuthorizationError::Missing("redirect_uri"))?;
3054 let client_id = oauth_step_value(step, &state, "client_id", "client_id_output")?
3055 .ok_or(OauthAuthorizationError::Missing("client_id"))?;
3056 let client_secret = oauth_step_value(step, &state, "client_secret", "client_secret_output")?;
3057
3058 let mut form = vec![
3059 ("grant_type".to_string(), "authorization_code".to_string()),
3060 ("code".to_string(), code.to_string()),
3061 ("redirect_uri".to_string(), redirect_uri),
3062 ("client_id".to_string(), client_id),
3063 ];
3064 if let Some(client_secret) = client_secret {
3065 form.push(("client_secret".to_string(), client_secret));
3066 }
3067 let agent = ureq::Agent::config_builder()
3068 .http_status_as_error(false)
3069 .build()
3070 .new_agent();
3071 let mut response = agent
3072 .post(&token_url)
3073 .send_form(form)
3074 .context("OAuth token exchange failed")?;
3075 let status = response.status().as_u16();
3076 let body = response
3077 .body_mut()
3078 .read_json::<Value>()
3079 .context("failed to parse OAuth token response")?;
3080 if status >= 400 {
3081 anyhow::bail!("OAuth token exchange failed with status {status}: {body}");
3082 }
3083 Ok(body)
3084}
3085
3086fn oauth_nonce() -> String {
3087 use base64::Engine;
3088 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>())
3089}
3090
3091fn oauth_response_type(step: &SetupMachineStep) -> &str {
3092 step.extra
3093 .get("response_type")
3094 .and_then(Value::as_str)
3095 .map(str::trim)
3096 .filter(|value| !value.is_empty())
3097 .unwrap_or("code")
3098}
3099
3100fn oauth_step_value(
3101 step: &SetupMachineStep,
3102 state: &SetupMachineState,
3103 literal_key: &'static str,
3104 output_key_key: &'static str,
3105) -> Result<Option<String>, OauthAuthorizationError> {
3106 if let Some(value) = step
3107 .extra
3108 .get(literal_key)
3109 .and_then(Value::as_str)
3110 .map(str::trim)
3111 .filter(|value| !value.is_empty())
3112 {
3113 return expand_machine_template(value, state)
3114 .map(Some)
3115 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3116 }
3117 let output_key = step
3118 .extra
3119 .get(output_key_key)
3120 .and_then(Value::as_str)
3121 .unwrap_or(literal_key);
3122 Ok(output_string(&state.outputs, output_key))
3123}
3124
3125fn oauth_redirect_uri(
3126 bundle_root: &Path,
3127 step: &SetupMachineStep,
3128 state: &SetupMachineState,
3129) -> Result<Option<String>, OauthAuthorizationError> {
3130 if let Some(value) = step
3131 .extra
3132 .get("redirect_uri")
3133 .or_else(|| step.extra.get("redirect_uri_template"))
3134 .and_then(Value::as_str)
3135 .map(str::trim)
3136 .filter(|value| !value.is_empty())
3137 {
3138 return expand_machine_template(value, state)
3139 .map(Some)
3140 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3141 }
3142 let Some(callback_path) = step
3143 .extra
3144 .get("callback_path")
3145 .and_then(Value::as_str)
3146 .map(str::trim)
3147 .filter(|value| !value.is_empty())
3148 else {
3149 return Ok(None);
3150 };
3151 let public_base_url = output_string(&state.outputs, "public_base_url")
3152 .or_else(|| {
3153 crate::platform_setup::load_effective_static_routes_defaults(
3154 bundle_root,
3155 &state.tenant,
3156 Some(&state.team),
3157 )
3158 .ok()
3159 .flatten()
3160 .and_then(|policy| policy.public_base_url)
3161 })
3162 .ok_or(OauthAuthorizationError::Missing("public_base_url"))?;
3163 Ok(Some(format!(
3164 "{}{}",
3165 public_base_url.trim_end_matches('/'),
3166 ensure_leading_slash(callback_path)
3167 )))
3168}
3169
3170fn oauth_authorization_token_url(
3171 step: &SetupMachineStep,
3172 state: &SetupMachineState,
3173) -> Result<String, OauthAuthorizationError> {
3174 let template = step
3175 .extra
3176 .get("token_url")
3177 .or_else(|| step.extra.get("token_url_template"))
3178 .and_then(Value::as_str)
3179 .map(str::trim)
3180 .filter(|value| !value.is_empty())
3181 .ok_or(OauthAuthorizationError::Missing("token_url"))?;
3182 let token_url = expand_machine_template(template, state)
3183 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3184 let parsed = url::Url::parse(&token_url)
3185 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3186 if !(parsed.scheme() == "https" && parsed.host_str().is_some()
3187 || parsed.scheme() == "http"
3188 && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "::1")))
3189 {
3190 return Err(OauthAuthorizationError::Invalid(
3191 "token_url must be https or loopback http".to_string(),
3192 ));
3193 }
3194 Ok(token_url)
3195}
3196
3197fn oauth_scope(step: &SetupMachineStep) -> Result<Option<String>, OauthAuthorizationError> {
3198 if let Some(scope) = step.extra.get("scope").and_then(Value::as_str) {
3199 let scope = scope.trim();
3200 return Ok((!scope.is_empty()).then(|| scope.to_string()));
3201 }
3202 if let Some(scopes) = step.extra.get("scopes").and_then(Value::as_array) {
3203 let scope = scopes
3204 .iter()
3205 .filter_map(Value::as_str)
3206 .map(str::trim)
3207 .filter(|value| !value.is_empty())
3208 .collect::<Vec<_>>()
3209 .join(" ");
3210 return Ok((!scope.is_empty()).then_some(scope));
3211 }
3212 Ok(None)
3213}
3214
3215fn append_oauth_query_param(parsed: &mut url::Url, key: &str, value: &str) {
3216 if value.trim().is_empty() || parsed.query_pairs().any(|(existing, _)| existing == key) {
3217 return;
3218 }
3219 parsed.query_pairs_mut().append_pair(key, value);
3220}
3221
3222fn map_oauth_authorization_token_response(
3223 step: &SetupMachineStep,
3224 token_response: &Value,
3225) -> anyhow::Result<serde_json::Map<String, Value>> {
3226 let mut config = serde_json::Map::new();
3227 if let Some(map) = step
3228 .extra
3229 .get("response_secret_map")
3230 .and_then(Value::as_object)
3231 {
3232 for (response_key, secret_key) in map {
3233 let Some(secret_key) = secret_key
3234 .as_str()
3235 .map(str::trim)
3236 .filter(|value| !value.is_empty())
3237 else {
3238 anyhow::bail!(
3239 "setup-machine OAuth response_secret_map value must be a non-empty string: {response_key}"
3240 );
3241 };
3242 if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3243 && !value.trim().is_empty()
3244 {
3245 config.insert(secret_key.to_string(), Value::String(value.to_string()));
3246 }
3247 }
3248 } else {
3249 insert_oauth_token_field(
3250 &mut config,
3251 token_response,
3252 "access_token",
3253 step.extra
3254 .get("token_store_key")
3255 .and_then(Value::as_str)
3256 .unwrap_or("oauth_access_token"),
3257 );
3258 if let Some(secret_key) = step
3259 .extra
3260 .get("refresh_token_store_key")
3261 .and_then(Value::as_str)
3262 {
3263 insert_oauth_token_field(&mut config, token_response, "refresh_token", secret_key);
3264 }
3265 if let Some(secret_key) = step.extra.get("id_token_store_key").and_then(Value::as_str) {
3266 insert_oauth_token_field(&mut config, token_response, "id_token", secret_key);
3267 }
3268 }
3269
3270 if config.is_empty() {
3271 anyhow::bail!("OAuth token response had no persistable tokens");
3272 }
3273 Ok(config)
3274}
3275
3276fn insert_oauth_token_field(
3277 config: &mut serde_json::Map<String, Value>,
3278 token_response: &Value,
3279 response_key: &str,
3280 secret_key: &str,
3281) {
3282 let secret_key = secret_key.trim();
3283 if secret_key.is_empty() {
3284 return;
3285 }
3286 if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3287 && !value.trim().is_empty()
3288 {
3289 config.insert(secret_key.to_string(), Value::String(value.to_string()));
3290 }
3291}
3292
3293fn ensure_leading_slash(value: &str) -> String {
3294 if value.starts_with('/') {
3295 value.to_string()
3296 } else {
3297 format!("/{value}")
3298 }
3299}
3300
3301fn execute_select_from_json_step(
3302 machine: &SetupMachine,
3303 step: &SetupMachineStep,
3304 state: &mut SetupMachineState,
3305) -> MachineStepResult {
3306 let Some(source_output) = step
3307 .extra
3308 .get("source_output")
3309 .or_else(|| step.extra.get("source"))
3310 .and_then(Value::as_str)
3311 .map(str::trim)
3312 .filter(|value| !value.is_empty())
3313 else {
3314 return pause_select_from_json(
3315 step,
3316 state,
3317 "missing_prerequisite",
3318 "source_output",
3319 "declare source_output, then retry",
3320 );
3321 };
3322 let Some(source) = state.outputs.get(source_output) else {
3323 return pause_select_from_json(
3324 step,
3325 state,
3326 "missing_prerequisite",
3327 source_output,
3328 "run prerequisite JSON step, then retry",
3329 );
3330 };
3331 let selected_scope = match select_json_scope(source, step) {
3332 Some(value) => value,
3333 None => {
3334 return pause_select_from_json(
3335 step,
3336 state,
3337 "missing_prerequisite",
3338 "json_path",
3339 "fix source JSON path, then retry",
3340 );
3341 }
3342 };
3343
3344 let selected = match choose_json_selection(selected_scope, step, state) {
3345 Ok(selected) => selected,
3346 Err(SelectFromJsonError::SelectionRequired(count)) => {
3347 state.status = SetupMachineStatus::Paused;
3348 state.current_step = Some(step.id.clone());
3349 state.last_error = Some(serde_json::json!({
3350 "error": "manual_action_required",
3351 "reason": "selection_required",
3352 "step": step.id,
3353 "options": count,
3354 "recoverable": true,
3355 }));
3356 return MachineStepResult {
3357 ok: false,
3358 next: "operator selection required".to_string(),
3359 detail: serde_json::json!({
3360 "blocked": true,
3361 "retryable": true,
3362 "error": "manual_action_required",
3363 "reason": "selection_required",
3364 "options": count,
3365 "source_output": source_output,
3366 }),
3367 };
3368 }
3369 Err(SelectFromJsonError::InvalidSelection(detail)) => {
3370 state.status = SetupMachineStatus::Paused;
3371 state.current_step = Some(step.id.clone());
3372 state.last_error = Some(serde_json::json!({
3373 "error": "invalid_answer",
3374 "step": step.id,
3375 "detail": detail,
3376 "recoverable": true,
3377 }));
3378 return MachineStepResult {
3379 ok: false,
3380 next: "fix selected JSON value, then retry".to_string(),
3381 detail: serde_json::json!({
3382 "blocked": true,
3383 "retryable": true,
3384 "error": "invalid_answer",
3385 "detail": detail,
3386 }),
3387 };
3388 }
3389 };
3390
3391 let output_key = step
3392 .extra
3393 .get("output_key")
3394 .and_then(Value::as_str)
3395 .unwrap_or(step.id.as_str());
3396 let value_key = step.extra.get("value_key").and_then(Value::as_str);
3397 let stored = value_key
3398 .and_then(|key| selected.get(key))
3399 .cloned()
3400 .unwrap_or_else(|| selected.clone());
3401 let outputs = ensure_outputs_object(state);
3402 outputs.insert(output_key.to_string(), stored.clone());
3403 if selected.is_object() {
3404 outputs.insert(format!("{output_key}_item"), selected.clone());
3405 }
3406 mark_machine_step_complete(machine, step, state);
3407 MachineStepResult {
3408 ok: true,
3409 next: "JSON selection completed".to_string(),
3410 detail: serde_json::json!({
3411 "kind": step.kind,
3412 "source_output": source_output,
3413 "output_key": output_key,
3414 "selected": redact_large_json_detail(&stored),
3415 }),
3416 }
3417}
3418
3419#[derive(Debug)]
3420enum SelectFromJsonError {
3421 SelectionRequired(usize),
3422 InvalidSelection(String),
3423}
3424
3425fn pause_select_from_json(
3426 step: &SetupMachineStep,
3427 state: &mut SetupMachineState,
3428 error: &str,
3429 missing: &str,
3430 next: &str,
3431) -> MachineStepResult {
3432 state.status = SetupMachineStatus::Paused;
3433 state.current_step = Some(step.id.clone());
3434 state.last_error = Some(serde_json::json!({
3435 "error": error,
3436 "step": step.id,
3437 "missing": missing,
3438 "recoverable": true,
3439 }));
3440 MachineStepResult {
3441 ok: false,
3442 next: next.to_string(),
3443 detail: serde_json::json!({
3444 "blocked": true,
3445 "retryable": true,
3446 "error": error,
3447 "missing_config_key": missing,
3448 }),
3449 }
3450}
3451
3452fn select_json_scope<'a>(source: &'a Value, step: &SetupMachineStep) -> Option<&'a Value> {
3453 let Some(path) = step
3454 .extra
3455 .get("json_pointer")
3456 .or_else(|| step.extra.get("path"))
3457 .or_else(|| step.extra.get("json_path"))
3458 .and_then(Value::as_str)
3459 .map(str::trim)
3460 .filter(|value| !value.is_empty())
3461 else {
3462 return Some(source);
3463 };
3464 if path.starts_with('/') {
3465 source.pointer(path)
3466 } else {
3467 let mut current = source;
3468 for segment in path.split('.').filter(|segment| !segment.is_empty()) {
3469 current = current.get(segment)?;
3470 }
3471 Some(current)
3472 }
3473}
3474
3475fn choose_json_selection(
3476 value: &Value,
3477 step: &SetupMachineStep,
3478 state: &SetupMachineState,
3479) -> Result<Value, SelectFromJsonError> {
3480 let Some(items) = value.as_array() else {
3481 return Ok(value.clone());
3482 };
3483 if items.is_empty() {
3484 return Err(SelectFromJsonError::InvalidSelection(
3485 "source JSON selection array is empty".to_string(),
3486 ));
3487 }
3488 if let Some(index) = step.extra.get("selected_index").and_then(Value::as_u64) {
3489 return items.get(index as usize).cloned().ok_or_else(|| {
3490 SelectFromJsonError::InvalidSelection(format!("selected_index {index} is out of range"))
3491 });
3492 }
3493 if let Some(value) = step
3494 .extra
3495 .get("selected_value")
3496 .or_else(|| step.extra.get("default_value"))
3497 {
3498 return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3499 SelectFromJsonError::InvalidSelection(
3500 "selected_value did not match any option".to_string(),
3501 )
3502 });
3503 }
3504 let choice_key = step
3505 .extra
3506 .get("choice_output_key")
3507 .and_then(Value::as_str)
3508 .unwrap_or(step.id.as_str());
3509 if let Some(value) = state.outputs.get(choice_key) {
3510 return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3511 SelectFromJsonError::InvalidSelection(format!(
3512 "choice output `{choice_key}` did not match any option"
3513 ))
3514 });
3515 }
3516 if items.len() == 1 {
3517 return Ok(items[0].clone());
3518 }
3519 Err(SelectFromJsonError::SelectionRequired(items.len()))
3520}
3521
3522fn find_json_selection_by_value(
3523 items: &[Value],
3524 step: &SetupMachineStep,
3525 selected_value: &Value,
3526) -> Option<Value> {
3527 let value_key = step.extra.get("value_key").and_then(Value::as_str);
3528 items.iter().find_map(|item| {
3529 let candidate = value_key.and_then(|key| item.get(key)).unwrap_or(item);
3530 values_match_for_selection(candidate, selected_value).then(|| item.clone())
3531 })
3532}
3533
3534fn values_match_for_selection(candidate: &Value, selected_value: &Value) -> bool {
3535 if candidate == selected_value {
3536 return true;
3537 }
3538 match (candidate, selected_value) {
3539 (Value::String(left), Value::String(right)) => left == right,
3540 (Value::String(left), other) => other.as_str() == Some(left.as_str()),
3541 (other, Value::String(right)) => other.as_str() == Some(right.as_str()),
3542 _ => false,
3543 }
3544}
3545
3546fn redact_large_json_detail(value: &Value) -> Value {
3547 match value {
3548 Value::Array(items) if items.len() > 10 => serde_json::json!({
3549 "kind": "array",
3550 "len": items.len(),
3551 }),
3552 Value::Object(map) if map.len() > 20 => serde_json::json!({
3553 "kind": "object",
3554 "keys": map.len(),
3555 }),
3556 other => other.clone(),
3557 }
3558}
3559
3560fn execute_generate_file_step(
3561 bundle_root: &Path,
3562 machine: &SetupMachine,
3563 step: &SetupMachineStep,
3564 state: &mut SetupMachineState,
3565) -> MachineStepResult {
3566 let Some(artifact_path) = setup_artifact_relative_path(step) else {
3567 state.status = SetupMachineStatus::Paused;
3568 state.current_step = Some(step.id.clone());
3569 state.last_error = Some(serde_json::json!({
3570 "error": "missing_prerequisite",
3571 "step": step.id,
3572 "missing": "artifact_path",
3573 "recoverable": true,
3574 }));
3575 return MachineStepResult {
3576 ok: false,
3577 next: "declare artifact_path, then retry".to_string(),
3578 detail: serde_json::json!({
3579 "blocked": true,
3580 "retryable": true,
3581 "error": "missing_prerequisite",
3582 "missing_config_key": "artifact_path",
3583 }),
3584 };
3585 };
3586 if !is_safe_setup_artifact_path(artifact_path) {
3587 state.status = SetupMachineStatus::Paused;
3588 state.current_step = Some(step.id.clone());
3589 state.last_error = Some(serde_json::json!({
3590 "error": "provider_contract_error",
3591 "step": step.id,
3592 "artifact_path": artifact_path,
3593 "recoverable": true,
3594 }));
3595 return MachineStepResult {
3596 ok: false,
3597 next: "fix setup artifact path, then retry".to_string(),
3598 detail: serde_json::json!({
3599 "blocked": true,
3600 "retryable": true,
3601 "error": "provider_contract_error",
3602 "artifact_path": artifact_path,
3603 }),
3604 };
3605 }
3606
3607 let rendered = match render_generate_file_content(step, state) {
3608 Ok(rendered) => rendered,
3609 Err(err) => {
3610 state.status = SetupMachineStatus::Paused;
3611 state.current_step = Some(step.id.clone());
3612 state.last_error = Some(serde_json::json!({
3613 "error": "missing_prerequisite",
3614 "step": step.id,
3615 "detail": err.to_string(),
3616 "recoverable": true,
3617 }));
3618 return MachineStepResult {
3619 ok: false,
3620 next: "provide file content inputs, then retry".to_string(),
3621 detail: serde_json::json!({
3622 "blocked": true,
3623 "retryable": true,
3624 "error": "missing_prerequisite",
3625 "detail": err.to_string(),
3626 }),
3627 };
3628 }
3629 };
3630
3631 let artifact_file = resolve_setup_artifact_path(bundle_root, state, artifact_path);
3632 if let Some(parent) = artifact_file.parent()
3633 && let Err(err) = std::fs::create_dir_all(parent)
3634 {
3635 return pause_generate_file_write_error(step, state, artifact_path, err);
3636 }
3637 if let Err(err) = std::fs::write(&artifact_file, rendered.as_bytes()) {
3638 return pause_generate_file_write_error(step, state, artifact_path, err);
3639 }
3640
3641 let output_key = step
3642 .extra
3643 .get("output_key")
3644 .and_then(Value::as_str)
3645 .unwrap_or(step.id.as_str());
3646 ensure_outputs_object(state).insert(
3647 output_key.to_string(),
3648 Value::String(artifact_path.to_string()),
3649 );
3650 state.created_resources.push(serde_json::json!({
3651 "kind": "setup_artifact",
3652 "step": step.id,
3653 "path": artifact_path,
3654 }));
3655 mark_machine_step_complete(machine, step, state);
3656 MachineStepResult {
3657 ok: true,
3658 next: "setup artifact generated".to_string(),
3659 detail: serde_json::json!({
3660 "kind": step.kind,
3661 "artifact_path": artifact_path,
3662 "bytes": rendered.len(),
3663 "output_key": output_key,
3664 }),
3665 }
3666}
3667
3668fn execute_persist_runtime_config_step(
3669 bundle_root: &Path,
3670 pack_path: Option<&Path>,
3671 machine: &SetupMachine,
3672 step: &SetupMachineStep,
3673 state: &mut SetupMachineState,
3674) -> MachineStepResult {
3675 let config = match render_runtime_config(machine, step, state) {
3676 Ok(config) => config,
3677 Err(err) => {
3678 state.status = SetupMachineStatus::Paused;
3679 state.current_step = Some(step.id.clone());
3680 state.last_error = Some(serde_json::json!({
3681 "error": "missing_prerequisite",
3682 "step": step.id,
3683 "detail": err.to_string(),
3684 "recoverable": true,
3685 }));
3686 return MachineStepResult {
3687 ok: false,
3688 next: "provide runtime config inputs, then retry".to_string(),
3689 detail: serde_json::json!({
3690 "blocked": true,
3691 "retryable": true,
3692 "error": "missing_prerequisite",
3693 "detail": err.to_string(),
3694 }),
3695 };
3696 }
3697 };
3698
3699 let config_dir = bundle_root
3700 .join("state")
3701 .join("config")
3702 .join(&state.provider_id);
3703 let config_path = config_dir.join("setup-answers.json");
3704 if let Some(parent) = config_path.parent()
3705 && let Err(err) = std::fs::create_dir_all(parent)
3706 {
3707 return pause_runtime_config_write_error(step, state, "setup_answers", err);
3708 }
3709 let bytes = match serde_json::to_vec_pretty(&config) {
3710 Ok(bytes) => bytes,
3711 Err(err) => {
3712 state.status = SetupMachineStatus::Paused;
3713 state.current_step = Some(step.id.clone());
3714 state.last_error = Some(serde_json::json!({
3715 "error": "provider_contract_error",
3716 "step": step.id,
3717 "detail": err.to_string(),
3718 "recoverable": true,
3719 }));
3720 return MachineStepResult {
3721 ok: false,
3722 next: "runtime config could not be serialized".to_string(),
3723 detail: serde_json::json!({
3724 "blocked": true,
3725 "retryable": true,
3726 "error": "provider_contract_error",
3727 "detail": err.to_string(),
3728 }),
3729 };
3730 }
3731 };
3732 if let Err(err) = std::fs::write(&config_path, bytes) {
3733 return pause_runtime_config_write_error(step, state, "setup_answers", err);
3734 }
3735
3736 let mut envelope_path = Value::Null;
3737 let mut envelope_skipped = false;
3738 if let Some(pack_path) = pack_path {
3739 match crate::config_envelope::write_provider_config_envelope(
3740 &bundle_root.join(".providers"),
3741 &state.provider_id,
3742 &step.id,
3743 &config,
3744 pack_path,
3745 true,
3746 ) {
3747 Ok(path) => envelope_path = Value::String(path.display().to_string()),
3748 Err(err) => {
3749 state.status = SetupMachineStatus::Paused;
3750 state.current_step = Some(step.id.clone());
3751 state.last_error = Some(serde_json::json!({
3752 "error": "provider_contract_error",
3753 "step": step.id,
3754 "detail": err.to_string(),
3755 "recoverable": true,
3756 }));
3757 return MachineStepResult {
3758 ok: false,
3759 next: "provider config envelope could not be written".to_string(),
3760 detail: serde_json::json!({
3761 "blocked": true,
3762 "retryable": true,
3763 "error": "provider_contract_error",
3764 "detail": err.to_string(),
3765 }),
3766 };
3767 }
3768 }
3769 } else {
3770 envelope_skipped = true;
3771 }
3772
3773 state.created_resources.push(serde_json::json!({
3774 "kind": "runtime_config",
3775 "step": step.id,
3776 "path": config_path.display().to_string(),
3777 "envelope_path": envelope_path,
3778 }));
3779 let output_key = step
3780 .extra
3781 .get("output_key")
3782 .and_then(Value::as_str)
3783 .unwrap_or("runtime_config_path");
3784 ensure_outputs_object(state).insert(
3785 output_key.to_string(),
3786 Value::String(config_path.display().to_string()),
3787 );
3788 mark_machine_step_complete(machine, step, state);
3789 MachineStepResult {
3790 ok: true,
3791 next: "runtime config persisted".to_string(),
3792 detail: serde_json::json!({
3793 "kind": step.kind,
3794 "config_path": config_path,
3795 "envelope_path": envelope_path,
3796 "envelope_skipped": envelope_skipped,
3797 }),
3798 }
3799}
3800
3801fn render_runtime_config(
3802 machine: &SetupMachine,
3803 step: &SetupMachineStep,
3804 state: &SetupMachineState,
3805) -> anyhow::Result<Value> {
3806 let mut config = if let Some(config) = step.extra.get("config") {
3807 expand_machine_value_templates(config.clone(), state)
3808 } else if let Some(mappings) = step.extra.get("mappings") {
3809 expand_machine_value_templates(mappings.clone(), state)
3810 } else {
3811 anyhow::bail!("persist_runtime_config requires config or mappings")
3812 };
3813 let server_owned = setup_machine_server_owned_keys(machine, step);
3814 if let Some(config) = config.as_object_mut() {
3815 for key in server_owned {
3816 config.remove(&key);
3817 }
3818 } else {
3819 anyhow::bail!("persist_runtime_config config must render to an object");
3820 }
3821 Ok(config)
3822}
3823
3824fn setup_machine_server_owned_keys(
3825 machine: &SetupMachine,
3826 step: &SetupMachineStep,
3827) -> BTreeSet<String> {
3828 machine
3829 .extra
3830 .get("server_owned_config_keys")
3831 .into_iter()
3832 .chain(step.extra.get("server_owned_config_keys"))
3833 .flat_map(|value| value.as_array().into_iter().flatten())
3834 .filter_map(Value::as_str)
3835 .map(str::trim)
3836 .filter(|value| !value.is_empty())
3837 .map(str::to_string)
3838 .collect()
3839}
3840
3841fn pause_runtime_config_write_error(
3842 step: &SetupMachineStep,
3843 state: &mut SetupMachineState,
3844 target: &str,
3845 err: std::io::Error,
3846) -> MachineStepResult {
3847 state.status = SetupMachineStatus::Paused;
3848 state.current_step = Some(step.id.clone());
3849 state.last_error = Some(serde_json::json!({
3850 "error": "provider_contract_error",
3851 "step": step.id,
3852 "target": target,
3853 "detail": err.to_string(),
3854 "recoverable": true,
3855 }));
3856 MachineStepResult {
3857 ok: false,
3858 next: "runtime config could not be written".to_string(),
3859 detail: serde_json::json!({
3860 "blocked": true,
3861 "retryable": true,
3862 "error": "provider_contract_error",
3863 "target": target,
3864 "detail": err.to_string(),
3865 }),
3866 }
3867}
3868
3869fn pause_generate_file_write_error(
3870 step: &SetupMachineStep,
3871 state: &mut SetupMachineState,
3872 artifact_path: &str,
3873 err: std::io::Error,
3874) -> MachineStepResult {
3875 state.status = SetupMachineStatus::Paused;
3876 state.current_step = Some(step.id.clone());
3877 state.last_error = Some(serde_json::json!({
3878 "error": "artifact_write_failed",
3879 "step": step.id,
3880 "artifact_path": artifact_path,
3881 "detail": err.to_string(),
3882 "recoverable": true,
3883 }));
3884 MachineStepResult {
3885 ok: false,
3886 next: "setup artifact could not be written".to_string(),
3887 detail: serde_json::json!({
3888 "blocked": true,
3889 "retryable": true,
3890 "error": "artifact_write_failed",
3891 "artifact_path": artifact_path,
3892 "detail": err.to_string(),
3893 }),
3894 }
3895}
3896
3897fn setup_artifact_relative_path(step: &SetupMachineStep) -> Option<&str> {
3898 step.extra
3899 .get("artifact_path")
3900 .or_else(|| step.extra.get("path"))
3901 .or_else(|| step.extra.get("output_path"))
3902 .and_then(Value::as_str)
3903 .map(str::trim)
3904 .filter(|value| !value.is_empty())
3905}
3906
3907fn is_safe_setup_artifact_path(path: &str) -> bool {
3908 !path.starts_with('/')
3909 && !path.contains("..")
3910 && (path.starts_with("artifacts/") || path.starts_with("state/setup/"))
3911}
3912
3913fn resolve_setup_artifact_path(
3914 bundle_root: &Path,
3915 state: &SetupMachineState,
3916 artifact_path: &str,
3917) -> PathBuf {
3918 if artifact_path.starts_with("state/setup/") {
3919 bundle_root.join(artifact_path)
3920 } else {
3921 setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
3922 .join(artifact_path)
3923 }
3924}
3925
3926fn render_generate_file_content(
3927 step: &SetupMachineStep,
3928 state: &SetupMachineState,
3929) -> anyhow::Result<String> {
3930 if let Some(template) = step.extra.get("template").and_then(Value::as_str) {
3931 return expand_machine_template(template, state);
3932 }
3933 if let Some(content) = step.extra.get("content") {
3934 return render_generated_value(content.clone(), state);
3935 }
3936 if let Some(template_json) = step.extra.get("template_json") {
3937 return render_generated_value(template_json.clone(), state);
3938 }
3939 anyhow::bail!("generate_file requires template, content, or template_json")
3940}
3941
3942fn render_generated_value(value: Value, state: &SetupMachineState) -> anyhow::Result<String> {
3943 match value {
3944 Value::String(text) => expand_machine_template(&text, state),
3945 other => Ok(serde_json::to_string_pretty(
3946 &expand_machine_value_templates(other, state),
3947 )?),
3948 }
3949}
3950
3951fn execute_http_step(
3952 machine: &SetupMachine,
3953 step: &SetupMachineStep,
3954 state: &mut SetupMachineState,
3955) -> MachineStepResult {
3956 let Some(template) = step
3957 .extra
3958 .get("url_template")
3959 .or_else(|| step.extra.get("url"))
3960 .and_then(Value::as_str)
3961 else {
3962 state.status = SetupMachineStatus::Paused;
3963 state.current_step = Some(step.id.clone());
3964 state.last_error = Some(serde_json::json!({
3965 "error": "missing_prerequisite",
3966 "step": step.id,
3967 "missing": "url_template",
3968 "recoverable": true,
3969 }));
3970 return MachineStepResult {
3971 ok: false,
3972 next: "declare url_template, then retry".to_string(),
3973 detail: serde_json::json!({
3974 "blocked": true,
3975 "retryable": true,
3976 "error": "missing_prerequisite",
3977 "missing_config_key": "url_template",
3978 }),
3979 };
3980 };
3981 let target = match expand_machine_template(template, state) {
3982 Ok(target) => target,
3983 Err(err) => {
3984 state.status = SetupMachineStatus::Paused;
3985 state.current_step = Some(step.id.clone());
3986 state.last_error = Some(serde_json::json!({
3987 "error": "missing_prerequisite",
3988 "step": step.id,
3989 "detail": err.to_string(),
3990 "recoverable": true,
3991 }));
3992 return MachineStepResult {
3993 ok: false,
3994 next: "provide missing template values, then retry".to_string(),
3995 detail: serde_json::json!({
3996 "blocked": true,
3997 "retryable": true,
3998 "error": "missing_prerequisite",
3999 "detail": err.to_string(),
4000 }),
4001 };
4002 }
4003 };
4004 if let Err(err) = validate_http_step_url(&target) {
4005 state.status = SetupMachineStatus::Paused;
4006 state.current_step = Some(step.id.clone());
4007 state.last_error = Some(serde_json::json!({
4008 "error": "invalid_answer",
4009 "step": step.id,
4010 "detail": err.to_string(),
4011 "recoverable": true,
4012 }));
4013 return MachineStepResult {
4014 ok: false,
4015 next: "fix setup HTTP URL, then retry".to_string(),
4016 detail: serde_json::json!({
4017 "blocked": true,
4018 "retryable": true,
4019 "error": "invalid_answer",
4020 "detail": err.to_string(),
4021 }),
4022 };
4023 }
4024
4025 let method = step
4026 .extra
4027 .get("method")
4028 .and_then(Value::as_str)
4029 .unwrap_or("GET")
4030 .to_ascii_uppercase();
4031 let expected_status = step
4032 .extra
4033 .get("expected_status")
4034 .or_else(|| step.extra.get("status"))
4035 .and_then(Value::as_u64)
4036 .unwrap_or(200) as u16;
4037 let response = match method.as_str() {
4038 "GET" => ureq::get(&target).call(),
4039 "POST" => {
4040 let payload = step
4041 .extra
4042 .get("body")
4043 .cloned()
4044 .unwrap_or_else(|| serde_json::json!({}));
4045 let payload = expand_machine_value_templates(payload, state);
4046 ureq::post(&target).send_json(payload)
4047 }
4048 _ => {
4049 state.status = SetupMachineStatus::Paused;
4050 state.current_step = Some(step.id.clone());
4051 state.last_error = Some(serde_json::json!({
4052 "error": "unsupported_http_method",
4053 "step": step.id,
4054 "method": method,
4055 "recoverable": true,
4056 }));
4057 return MachineStepResult {
4058 ok: false,
4059 next: "fix setup HTTP method, then retry".to_string(),
4060 detail: serde_json::json!({
4061 "blocked": true,
4062 "retryable": true,
4063 "error": "unsupported_http_method",
4064 "method": method,
4065 }),
4066 };
4067 }
4068 };
4069 let mut response = match response {
4070 Ok(response) => response,
4071 Err(ureq::Error::StatusCode(status)) => {
4072 state.status = SetupMachineStatus::Paused;
4073 state.current_step = Some(step.id.clone());
4074 state.last_error = Some(serde_json::json!({
4075 "error": "provider_contract_error",
4076 "step": step.id,
4077 "status": status,
4078 "expected_status": expected_status,
4079 "recoverable": true,
4080 }));
4081 return MachineStepResult {
4082 ok: false,
4083 next: "setup HTTP endpoint returned an unexpected status".to_string(),
4084 detail: serde_json::json!({
4085 "blocked": true,
4086 "retryable": true,
4087 "error": "provider_contract_error",
4088 "status": status,
4089 "expected_status": expected_status,
4090 "target": target,
4091 }),
4092 };
4093 }
4094 Err(err) => {
4095 state.status = SetupMachineStatus::Paused;
4096 state.current_step = Some(step.id.clone());
4097 state.last_error = Some(serde_json::json!({
4098 "error": "external_timeout",
4099 "step": step.id,
4100 "detail": err.to_string(),
4101 "recoverable": true,
4102 }));
4103 return MachineStepResult {
4104 ok: false,
4105 next: "setup HTTP endpoint is not reachable".to_string(),
4106 detail: serde_json::json!({
4107 "blocked": true,
4108 "retryable": true,
4109 "error": "external_timeout",
4110 "detail": err.to_string(),
4111 "target": target,
4112 }),
4113 };
4114 }
4115 };
4116 let status = response.status().as_u16();
4117 if status != expected_status {
4118 state.status = SetupMachineStatus::Paused;
4119 state.current_step = Some(step.id.clone());
4120 state.last_error = Some(serde_json::json!({
4121 "error": "provider_contract_error",
4122 "step": step.id,
4123 "status": status,
4124 "expected_status": expected_status,
4125 "recoverable": true,
4126 }));
4127 return MachineStepResult {
4128 ok: false,
4129 next: "setup HTTP endpoint returned an unexpected status".to_string(),
4130 detail: serde_json::json!({
4131 "blocked": true,
4132 "retryable": true,
4133 "error": "provider_contract_error",
4134 "status": status,
4135 "expected_status": expected_status,
4136 "target": target,
4137 }),
4138 };
4139 }
4140
4141 let body = response
4142 .body_mut()
4143 .read_json::<Value>()
4144 .unwrap_or(Value::Null);
4145 if step.kind == "http_json" {
4146 let output_key = step
4147 .extra
4148 .get("output_key")
4149 .and_then(Value::as_str)
4150 .unwrap_or(step.id.as_str());
4151 ensure_outputs_object(state).insert(output_key.to_string(), body.clone());
4152 }
4153 mark_machine_step_complete(machine, step, state);
4154 MachineStepResult {
4155 ok: true,
4156 next: "HTTP setup step completed".to_string(),
4157 detail: serde_json::json!({
4158 "kind": step.kind,
4159 "method": method,
4160 "status": status,
4161 "target": target,
4162 "body": if step.kind == "http_json" { body } else { Value::Null },
4163 }),
4164 }
4165}
4166
4167fn execute_platform_public_url_step(
4168 bundle_root: &Path,
4169 machine: &SetupMachine,
4170 step: &SetupMachineStep,
4171 state: &mut SetupMachineState,
4172) -> MachineStepResult {
4173 let key = step
4174 .extra
4175 .get("output_key")
4176 .and_then(Value::as_str)
4177 .unwrap_or("public_base_url");
4178 let candidate = output_string(&state.outputs, key)
4179 .or_else(|| {
4180 step.extra
4181 .get(key)
4182 .and_then(Value::as_str)
4183 .map(str::to_string)
4184 })
4185 .or_else(|| {
4186 step.extra
4187 .get("default")
4188 .or_else(|| step.extra.get("default_url"))
4189 .and_then(Value::as_str)
4190 .map(str::to_string)
4191 })
4192 .or_else(|| {
4193 crate::platform_setup::load_effective_static_routes_defaults(
4194 bundle_root,
4195 &state.tenant,
4196 Some(&state.team),
4197 )
4198 .ok()
4199 .flatten()
4200 .and_then(|policy| policy.public_base_url)
4201 })
4202 .map(|value| value.trim_end_matches('/').to_string())
4203 .filter(|value| !value.trim().is_empty());
4204
4205 let Some(public_base_url) = candidate else {
4206 state.status = SetupMachineStatus::Paused;
4207 state.current_step = Some(step.id.clone());
4208 state.last_error = Some(serde_json::json!({
4209 "error": "missing_prerequisite",
4210 "step": step.id,
4211 "missing": key,
4212 "recoverable": true,
4213 }));
4214 return MachineStepResult {
4215 ok: false,
4216 next: "configure public_base_url, then retry".to_string(),
4217 detail: serde_json::json!({
4218 "blocked": true,
4219 "retryable": true,
4220 "error": "missing_prerequisite",
4221 "missing_config_key": key,
4222 }),
4223 };
4224 };
4225
4226 if url::Url::parse(&public_base_url).is_err() {
4227 state.status = SetupMachineStatus::Paused;
4228 state.current_step = Some(step.id.clone());
4229 state.last_error = Some(serde_json::json!({
4230 "error": "invalid_answer",
4231 "step": step.id,
4232 "field": key,
4233 "recoverable": true,
4234 }));
4235 return MachineStepResult {
4236 ok: false,
4237 next: "fix public_base_url, then retry".to_string(),
4238 detail: serde_json::json!({
4239 "blocked": true,
4240 "retryable": true,
4241 "error": "invalid_answer",
4242 "field": key,
4243 }),
4244 };
4245 }
4246
4247 ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4248 mark_machine_step_complete(machine, step, state);
4249 MachineStepResult {
4250 ok: true,
4251 next: "public URL resolved".to_string(),
4252 detail: serde_json::json!({
4253 "kind": step.kind,
4254 "output_key": key,
4255 "public_base_url": public_base_url,
4256 }),
4257 }
4258}
4259
4260fn execute_cloudflare_tunnel_step(
4261 bundle_root: &Path,
4262 machine: &SetupMachine,
4263 step: &SetupMachineStep,
4264 state: &mut SetupMachineState,
4265) -> MachineStepResult {
4266 let key = step
4267 .extra
4268 .get("output_key")
4269 .and_then(Value::as_str)
4270 .unwrap_or("public_base_url");
4271 let candidate = output_string(&state.outputs, key)
4272 .or_else(|| {
4273 step.extra
4274 .get(key)
4275 .and_then(Value::as_str)
4276 .map(str::to_string)
4277 })
4278 .or_else(|| {
4279 step.extra
4280 .get("public_base_url")
4281 .or_else(|| step.extra.get("default"))
4282 .or_else(|| step.extra.get("default_url"))
4283 .and_then(Value::as_str)
4284 .map(str::to_string)
4285 })
4286 .or_else(|| {
4287 crate::platform_setup::load_effective_static_routes_defaults(
4288 bundle_root,
4289 &state.tenant,
4290 Some(&state.team),
4291 )
4292 .ok()
4293 .flatten()
4294 .and_then(|policy| policy.public_base_url)
4295 })
4296 .map(|value| value.trim_end_matches('/').to_string())
4297 .filter(|value| !value.trim().is_empty());
4298
4299 let Some(public_base_url) = candidate else {
4300 state.status = SetupMachineStatus::Paused;
4301 state.current_step = Some(step.id.clone());
4302 state.last_error = Some(serde_json::json!({
4303 "error": "tunnel_unreachable",
4304 "step": step.id,
4305 "missing": key,
4306 "recoverable": true,
4307 }));
4308 return MachineStepResult {
4309 ok: false,
4310 next: "start or configure a setup tunnel, then retry".to_string(),
4311 detail: serde_json::json!({
4312 "blocked": true,
4313 "retryable": true,
4314 "error": "tunnel_unreachable",
4315 "missing_config_key": key,
4316 }),
4317 };
4318 };
4319
4320 let parsed = match url::Url::parse(&public_base_url) {
4321 Ok(parsed) => parsed,
4322 Err(err) => {
4323 state.status = SetupMachineStatus::Paused;
4324 state.current_step = Some(step.id.clone());
4325 state.last_error = Some(serde_json::json!({
4326 "error": "invalid_answer",
4327 "step": step.id,
4328 "field": key,
4329 "detail": err.to_string(),
4330 "recoverable": true,
4331 }));
4332 return MachineStepResult {
4333 ok: false,
4334 next: "fix setup tunnel URL, then retry".to_string(),
4335 detail: serde_json::json!({
4336 "blocked": true,
4337 "retryable": true,
4338 "error": "invalid_answer",
4339 "field": key,
4340 "detail": err.to_string(),
4341 }),
4342 };
4343 }
4344 };
4345 if parsed.scheme() != "https" || parsed.host_str().is_none() {
4346 state.status = SetupMachineStatus::Paused;
4347 state.current_step = Some(step.id.clone());
4348 state.last_error = Some(serde_json::json!({
4349 "error": "invalid_answer",
4350 "step": step.id,
4351 "field": key,
4352 "detail": "setup tunnel URL must be an https URL with a host",
4353 "recoverable": true,
4354 }));
4355 return MachineStepResult {
4356 ok: false,
4357 next: "fix setup tunnel URL, then retry".to_string(),
4358 detail: serde_json::json!({
4359 "blocked": true,
4360 "retryable": true,
4361 "error": "invalid_answer",
4362 "field": key,
4363 "detail": "setup tunnel URL must be an https URL with a host",
4364 }),
4365 };
4366 }
4367
4368 ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4369 mark_machine_step_complete(machine, step, state);
4370 MachineStepResult {
4371 ok: true,
4372 next: "setup tunnel URL resolved".to_string(),
4373 detail: serde_json::json!({
4374 "kind": step.kind,
4375 "output_key": key,
4376 "public_base_url": public_base_url,
4377 "ephemeral": crate::setup_tunnel::is_ephemeral_tunnel_url(&public_base_url),
4378 }),
4379 }
4380}
4381
4382fn ensure_outputs_object(state: &mut SetupMachineState) -> &mut serde_json::Map<String, Value> {
4383 if !state.outputs.is_object() {
4384 state.outputs = serde_json::json!({});
4385 }
4386 state.outputs.as_object_mut().expect("outputs object")
4387}
4388
4389fn output_string(outputs: &Value, key: &str) -> Option<String> {
4390 outputs
4391 .get(key)
4392 .and_then(Value::as_str)
4393 .map(str::trim)
4394 .filter(|value| !value.is_empty())
4395 .map(str::to_string)
4396}
4397
4398fn validate_http_step_url(value: &str) -> anyhow::Result<()> {
4399 let url = url::Url::parse(value)?;
4400 match url.scheme() {
4401 "http" if url.host_str().is_some_and(is_loopback_host) => Ok(()),
4402 "https" => Ok(()),
4403 _ => anyhow::bail!("setup HTTP URL must use https or loopback http"),
4404 }
4405}
4406
4407fn is_loopback_host(host: &str) -> bool {
4408 matches!(host, "localhost" | "127.0.0.1" | "::1")
4409}
4410
4411fn expand_machine_template(template: &str, state: &SetupMachineState) -> anyhow::Result<String> {
4412 let mut expanded = template
4413 .replace("{tenant}", &state.tenant)
4414 .replace("{team}", &state.team)
4415 .replace("{provider_id}", &state.provider_id);
4416 let mut start = 0;
4417 while let Some(open) = expanded[start..].find('{') {
4418 let open = start + open;
4419 let Some(close_rel) = expanded[open + 1..].find('}') else {
4420 break;
4421 };
4422 let close = open + 1 + close_rel;
4423 let key = &expanded[open + 1..close];
4424 let Some(value) = output_string(&state.outputs, key) else {
4425 anyhow::bail!("missing setup-machine output `{key}`");
4426 };
4427 expanded.replace_range(open..=close, &value);
4428 start = open + value.len();
4429 }
4430 Ok(expanded)
4431}
4432
4433fn expand_machine_value_templates(value: Value, state: &SetupMachineState) -> Value {
4434 match value {
4435 Value::String(text) => expand_machine_template(&text, state)
4436 .map(Value::String)
4437 .unwrap_or(Value::String(text)),
4438 Value::Array(values) => Value::Array(
4439 values
4440 .into_iter()
4441 .map(|value| expand_machine_value_templates(value, state))
4442 .collect(),
4443 ),
4444 Value::Object(map) => Value::Object(
4445 map.into_iter()
4446 .map(|(key, value)| (key, expand_machine_value_templates(value, state)))
4447 .collect(),
4448 ),
4449 other => other,
4450 }
4451}
4452
4453fn mark_machine_step_complete(
4454 machine: &SetupMachine,
4455 step: &SetupMachineStep,
4456 state: &mut SetupMachineState,
4457) {
4458 if !state.completed_steps.iter().any(|id| id == &step.id) {
4459 state.completed_steps.push(step.id.clone());
4460 }
4461 state.last_error = None;
4462 state.failed_step = None;
4463 match step.on_success.as_deref() {
4464 Some(TERMINAL_SUCCESS) | None => {
4465 state.status = SetupMachineStatus::Complete;
4466 state.current_step = None;
4467 }
4468 Some(TERMINAL_FAILED) => {
4469 state.status = SetupMachineStatus::Failed;
4470 state.current_step = None;
4471 state.failed_step = Some(step.id.clone());
4472 state.last_error = Some(serde_json::json!({
4473 "error": "transitioned_to_failed",
4474 "step": step.id,
4475 }));
4476 }
4477 Some(next) if machine.steps.iter().any(|candidate| candidate.id == next) => {
4478 state.status = SetupMachineStatus::Running;
4479 state.current_step = Some(next.to_string());
4480 }
4481 Some(next) => {
4482 state.status = SetupMachineStatus::Failed;
4483 state.current_step = Some(step.id.clone());
4484 state.failed_step = Some(step.id.clone());
4485 state.last_error = Some(serde_json::json!({
4486 "error": "invalid_transition_target",
4487 "step": step.id,
4488 "target": next,
4489 }));
4490 }
4491 }
4492}
4493
4494fn machine_step_output(
4495 machine: &SetupMachine,
4496 state: &SetupMachineState,
4497 step_id: &str,
4498 dry_run: bool,
4499 ok: bool,
4500 next: &str,
4501 event_path: Option<PathBuf>,
4502) -> Value {
4503 serde_json::json!({
4504 "provider_id": state.provider_id,
4505 "tenant": state.tenant,
4506 "team": state.team,
4507 "machine_id": machine.id,
4508 "dry_run": dry_run,
4509 "step": step_id,
4510 "ok": ok,
4511 "next": next,
4512 "result": {
4513 "ok": ok,
4514 "status": state.status,
4515 },
4516 "event_path": event_path,
4517 "status": render_setup_machine_status(machine, state),
4518 })
4519}
4520
4521fn append_machine_step_event(
4522 bundle_root: &Path,
4523 state: &SetupMachineState,
4524 event_name: &str,
4525 step_id: Option<&str>,
4526 detail: Option<Value>,
4527) -> anyhow::Result<PathBuf> {
4528 let mut fields = serde_json::Map::new();
4529 if let Some(detail) = detail {
4530 fields.insert("detail".to_string(), detail);
4531 }
4532 append_setup_machine_event(
4533 bundle_root,
4534 &SetupMachineEvent {
4535 ts: now_setup_ts(),
4536 level: "info".to_string(),
4537 event: event_name.to_string(),
4538 provider_id: state.provider_id.clone(),
4539 tenant: state.tenant.clone(),
4540 team: state.team.clone(),
4541 machine_id: state.machine_id.clone(),
4542 step_id: step_id.map(str::to_string),
4543 correlation_id: Some(format!("setup-{}", now_setup_ms())),
4544 fields,
4545 },
4546 )
4547}
4548
4549fn now_setup_ts() -> String {
4550 now_setup_ms().to_string()
4551}
4552
4553fn now_setup_ms() -> u128 {
4554 SystemTime::now()
4555 .duration_since(UNIX_EPOCH)
4556 .unwrap_or_default()
4557 .as_millis()
4558}
4559
4560fn safe_archive_segment(value: &str) -> String {
4561 let mut out = value
4562 .chars()
4563 .map(|ch| {
4564 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
4565 ch
4566 } else {
4567 '-'
4568 }
4569 })
4570 .collect::<String>();
4571 while out.contains("--") {
4572 out = out.replace("--", "-");
4573 }
4574 let out = out.trim_matches('-');
4575 if out.is_empty() {
4576 "archive".to_string()
4577 } else {
4578 out.to_string()
4579 }
4580}
4581
4582fn resolve_machine_value(pack_path: &Path, extension: Value) -> anyhow::Result<Value> {
4583 if let Some(inline) = extension.get("inline") {
4584 return Ok(inline.clone());
4585 }
4586 if let Some(path) = extension.get("path").and_then(Value::as_str) {
4587 return discovery::read_pack_json_asset(pack_path, path);
4588 }
4589 Ok(extension)
4590}
4591
4592fn extension_inline(extension: &Value) -> Option<&Value> {
4593 extension.get("inline").or(Some(extension))
4594}
4595
4596fn validate_schema_id(value: &Value, expected: &str, label: &str) -> anyhow::Result<()> {
4597 let schema_id = value
4598 .get("schema_id")
4599 .and_then(Value::as_str)
4600 .unwrap_or(expected);
4601 if schema_id != expected {
4602 anyhow::bail!("{label} has schema_id {schema_id}, expected {expected}");
4603 }
4604 Ok(())
4605}
4606
4607fn validate_provider_id(
4608 provider_id: Option<&str>,
4609 expected_provider_id: Option<&str>,
4610) -> anyhow::Result<()> {
4611 let Some(expected) = expected_provider_id else {
4612 return Ok(());
4613 };
4614 let Some(actual) = provider_id else {
4615 anyhow::bail!("setup backend contract provider_id is missing");
4616 };
4617 if actual != expected {
4618 anyhow::bail!(
4619 "setup backend contract provider_id {actual} does not match pack id {expected}"
4620 );
4621 }
4622 Ok(())
4623}
4624
4625fn string_array(value: Option<&Value>) -> Vec<String> {
4626 value
4627 .and_then(Value::as_array)
4628 .into_iter()
4629 .flatten()
4630 .filter_map(Value::as_str)
4631 .map(str::trim)
4632 .filter(|value| !value.is_empty())
4633 .map(str::to_string)
4634 .collect()
4635}
4636
4637fn validate_unique_strings(check_id: &str, message: &str, values: &[String]) -> Vec<Diagnostic> {
4638 let mut diagnostics = Vec::new();
4639 let mut seen = BTreeSet::new();
4640 for value in values {
4641 if !seen.insert(value) {
4642 diagnostics.push(error_with_actual(check_id, message, value));
4643 }
4644 }
4645 diagnostics
4646}
4647
4648fn require_executor_str(
4649 diagnostics: &mut Vec<Diagnostic>,
4650 action_id: &str,
4651 executor: &serde_json::Map<String, Value>,
4652 key: &str,
4653) {
4654 if executor
4655 .get(key)
4656 .and_then(Value::as_str)
4657 .is_none_or(|value| value.trim().is_empty())
4658 {
4659 diagnostics.push(error_with_actual(
4660 "setup.backend_contract.executor.required_field",
4661 "setup backend executor is missing a required field",
4662 &format!("{action_id}.{key}"),
4663 ));
4664 }
4665}
4666
4667fn validate_transition(
4668 diagnostics: &mut Vec<Diagnostic>,
4669 step_ids: &BTreeSet<String>,
4670 source_step: &str,
4671 check_id: &str,
4672 target: Option<&str>,
4673) {
4674 let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
4675 return;
4676 };
4677 if matches!(target, TERMINAL_SUCCESS | TERMINAL_FAILED) || step_ids.contains(target) {
4678 return;
4679 }
4680 diagnostics.push(Diagnostic {
4681 check_id: check_id.to_string(),
4682 severity: DiagnosticSeverity::Error,
4683 component: "provider_setup".to_string(),
4684 message: "setup transition target must reference an existing step or terminal state"
4685 .to_string(),
4686 evidence: Some(format!("{source_step} -> {target}")),
4687 expected: Some("existing step id, complete, or failed".to_string()),
4688 actual: Some(target.to_string()),
4689 fix_hint: Some("fix the transition target or add the missing setup step".to_string()),
4690 related_file: None,
4691 related_pack: None,
4692 related_component: None,
4693 });
4694}
4695
4696fn rule_targets(rule: &Value) -> Vec<String> {
4697 let Some(obj) = rule.as_object() else {
4698 return Vec::new();
4699 };
4700 ["step", "next", "target", "on_success"]
4701 .iter()
4702 .filter_map(|key| obj.get(*key).and_then(Value::as_str).map(str::to_string))
4703 .collect()
4704}
4705
4706fn validate_reachability(machine: &SetupMachine, step_ids: &BTreeSet<String>) -> Vec<Diagnostic> {
4707 if !step_ids.contains(&machine.entry_step) {
4708 return Vec::new();
4709 }
4710
4711 let mut seen = BTreeSet::new();
4712 let mut queue = VecDeque::from([machine.entry_step.clone()]);
4713 while let Some(step_id) = queue.pop_front() {
4714 if !seen.insert(step_id.clone()) {
4715 continue;
4716 }
4717 let Some(step) = machine.steps.iter().find(|step| step.id == step_id) else {
4718 continue;
4719 };
4720 if let Some(target) = step.on_success.as_deref()
4721 && step_ids.contains(target)
4722 {
4723 queue.push_back(target.to_string());
4724 }
4725 for rule in step.on_failure.iter().chain(step.recover.iter()) {
4726 for target in rule_targets(rule) {
4727 if step_ids.contains(&target) {
4728 queue.push_back(target);
4729 }
4730 }
4731 }
4732 }
4733
4734 step_ids
4735 .difference(&seen)
4736 .map(|step_id| {
4737 error_with_actual(
4738 "setup.machine.step.reachable",
4739 "setup step is not reachable from entry_step",
4740 step_id,
4741 )
4742 })
4743 .collect()
4744}
4745
4746fn validate_unbounded_cycles(
4747 machine: &SetupMachine,
4748 step_ids: &BTreeSet<String>,
4749) -> Vec<Diagnostic> {
4750 let steps: BTreeMap<&str, &SetupMachineStep> = machine
4751 .steps
4752 .iter()
4753 .map(|step| (step.id.as_str(), step))
4754 .collect();
4755 let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
4756 for step in &machine.steps {
4757 let mut targets = Vec::new();
4758 if let Some(target) = step.on_success.as_deref()
4759 && step_ids.contains(target)
4760 {
4761 targets.push(target.to_string());
4762 }
4763 for rule in step.on_failure.iter().chain(step.recover.iter()) {
4764 for target in rule_targets(rule) {
4765 if step_ids.contains(&target) {
4766 targets.push(target);
4767 }
4768 }
4769 }
4770 edges.insert(step.id.clone(), targets);
4771 }
4772
4773 let mut diagnostics = Vec::new();
4774 let mut visited = BTreeSet::new();
4775 let mut stack = Vec::<String>::new();
4776 let mut reported = BTreeSet::new();
4777 for step_id in step_ids {
4778 detect_cycle(
4779 step_id,
4780 &edges,
4781 &steps,
4782 &mut visited,
4783 &mut stack,
4784 &mut reported,
4785 &mut diagnostics,
4786 );
4787 }
4788 diagnostics
4789}
4790
4791fn validate_artifact_paths(machine: &SetupMachine) -> Vec<Diagnostic> {
4792 let mut diagnostics = Vec::new();
4793 for step in &machine.steps {
4794 if !matches!(step.kind.as_str(), "generate_file" | "download_file") {
4795 continue;
4796 }
4797 let path = step
4798 .extra
4799 .get("artifact_path")
4800 .or_else(|| step.extra.get("path"))
4801 .or_else(|| step.extra.get("output_path"))
4802 .and_then(Value::as_str)
4803 .map(str::trim)
4804 .filter(|value| !value.is_empty());
4805 let Some(path) = path else {
4806 diagnostics.push(error_with_actual(
4807 "setup.machine.artifact.path",
4808 "setup artifact steps must declare artifact_path",
4809 &step.id,
4810 ));
4811 continue;
4812 };
4813 if path.starts_with('/')
4814 || path.contains("..")
4815 || !(path.starts_with("artifacts/") || path.starts_with("state/setup/"))
4816 {
4817 diagnostics.push(error_with_actual(
4818 "setup.machine.artifact.path.safe",
4819 "setup artifact path must be relative and stay under setup artifacts",
4820 &format!("{}: {}", step.id, path),
4821 ));
4822 }
4823 }
4824 diagnostics
4825}
4826
4827fn validate_http_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4828 let mut diagnostics = Vec::new();
4829 for step in &machine.steps {
4830 if !matches!(step.kind.as_str(), "http_probe" | "http_json") {
4831 continue;
4832 }
4833 let url = step
4834 .extra
4835 .get("url_template")
4836 .or_else(|| step.extra.get("url"))
4837 .and_then(Value::as_str)
4838 .map(str::trim)
4839 .filter(|value| !value.is_empty());
4840 let Some(url) = url else {
4841 diagnostics.push(error_with_actual(
4842 "setup.machine.http.url",
4843 "setup HTTP steps must declare url_template",
4844 &step.id,
4845 ));
4846 continue;
4847 };
4848 if !url.contains('{')
4849 && let Err(err) = validate_http_step_url(url)
4850 {
4851 diagnostics.push(error_with_actual(
4852 "setup.machine.http.url.safe",
4853 "setup HTTP URL must use https or loopback http",
4854 &format!("{}: {url} ({err})", step.id),
4855 ));
4856 }
4857 let method = step
4858 .extra
4859 .get("method")
4860 .and_then(Value::as_str)
4861 .unwrap_or("GET")
4862 .to_ascii_uppercase();
4863 if !matches!(method.as_str(), "GET" | "POST") {
4864 diagnostics.push(error_with_actual(
4865 "setup.machine.http.method",
4866 "setup HTTP method must be GET or POST",
4867 &format!("{}: {method}", step.id),
4868 ));
4869 }
4870 if step.kind == "http_json" && method != "GET" && !has_idempotency_key(step) {
4871 diagnostics.push(error_with_actual(
4872 "setup.machine.step.idempotency_key",
4873 "side-effecting setup steps must declare idempotency_key",
4874 &step.id,
4875 ));
4876 }
4877 if let Some(status) = step
4878 .extra
4879 .get("expected_status")
4880 .or_else(|| step.extra.get("status"))
4881 .and_then(Value::as_u64)
4882 && !(100..=599).contains(&status)
4883 {
4884 diagnostics.push(error_with_actual(
4885 "setup.machine.http.expected_status",
4886 "setup HTTP expected_status must be a valid HTTP status code",
4887 &format!("{}: {status}", step.id),
4888 ));
4889 }
4890 }
4891 diagnostics
4892}
4893
4894fn validate_tunnel_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4895 let mut diagnostics = Vec::new();
4896 for step in &machine.steps {
4897 if step.kind != "cloudflare_tunnel" {
4898 continue;
4899 }
4900 if let Some(mode) = step.extra.get("mode").and_then(Value::as_str)
4901 && !matches!(mode, "cloudflared" | "cloudflare" | "external")
4902 {
4903 diagnostics.push(error_with_actual(
4904 "setup.machine.cloudflare_tunnel.mode",
4905 "cloudflare_tunnel mode must be cloudflared, cloudflare, or external",
4906 &format!("{}: {}", step.id, mode),
4907 ));
4908 }
4909 if let Some(url) = step
4910 .extra
4911 .get("public_base_url")
4912 .or_else(|| step.extra.get("default"))
4913 .or_else(|| step.extra.get("default_url"))
4914 .and_then(Value::as_str)
4915 .map(str::trim)
4916 .filter(|value| !value.is_empty())
4917 && !url.contains('{')
4918 {
4919 match url::Url::parse(url) {
4920 Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
4921 Ok(_) | Err(_) => diagnostics.push(error_with_actual(
4922 "setup.machine.cloudflare_tunnel.public_base_url",
4923 "cloudflare_tunnel declared public URL must be https with a host",
4924 &format!("{}: {}", step.id, url),
4925 )),
4926 }
4927 }
4928 }
4929 diagnostics
4930}
4931
4932fn validate_oauth_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4933 let mut diagnostics = Vec::new();
4934 for step in &machine.steps {
4935 match step.kind.as_str() {
4936 "oauth_device_code" => validate_oauth_device_step(&mut diagnostics, step),
4937 "oauth_authorization_code" => validate_oauth_authorization_step(&mut diagnostics, step),
4938 _ => {}
4939 }
4940 }
4941 diagnostics
4942}
4943
4944fn validate_oauth_device_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
4945 let has_device_url = step
4946 .extra
4947 .get("device_code_url")
4948 .or_else(|| step.extra.get("device_code_url_template"))
4949 .and_then(Value::as_str)
4950 .map(str::trim)
4951 .filter(|value| !value.is_empty())
4952 .is_some();
4953 let has_token_url = step
4954 .extra
4955 .get("token_url")
4956 .or_else(|| step.extra.get("token_url_template"))
4957 .and_then(Value::as_str)
4958 .map(str::trim)
4959 .filter(|value| !value.is_empty())
4960 .is_some();
4961 let has_authority = step
4962 .extra
4963 .get("authority_url_template")
4964 .or_else(|| step.extra.get("authority_url"))
4965 .and_then(Value::as_str)
4966 .map(str::trim)
4967 .filter(|value| !value.is_empty())
4968 .is_some();
4969 if !has_authority && (!has_device_url || !has_token_url) {
4970 diagnostics.push(error_with_actual(
4971 "setup.machine.oauth_device_code.endpoints",
4972 "oauth_device_code steps must declare authority_url_template or both device_code_url and token_url",
4973 &step.id,
4974 ));
4975 }
4976 for key in ["device_code_url", "token_url", "authority_url"] {
4977 if let Some(url) = step
4978 .extra
4979 .get(key)
4980 .or_else(|| step.extra.get(&format!("{key}_template")))
4981 .and_then(Value::as_str)
4982 .map(str::trim)
4983 .filter(|value| !value.is_empty())
4984 && !url.contains('{')
4985 && let Err(err) = validate_http_step_url(url)
4986 {
4987 diagnostics.push(error_with_actual(
4988 "setup.machine.oauth_device_code.url.safe",
4989 "oauth_device_code URLs must use https or loopback http",
4990 &format!("{}: {url} ({err})", step.id),
4991 ));
4992 }
4993 }
4994 let has_client_id = step
4995 .extra
4996 .get("client_id")
4997 .or_else(|| step.extra.get("client_id_output"))
4998 .and_then(Value::as_str)
4999 .map(str::trim)
5000 .filter(|value| !value.is_empty())
5001 .is_some();
5002 if !has_client_id {
5003 diagnostics.push(error_with_actual(
5004 "setup.machine.oauth_device_code.client_id",
5005 "oauth_device_code steps must declare client_id or client_id_output",
5006 &step.id,
5007 ));
5008 }
5009 if step.extra.get("scope").is_none() && step.extra.get("scopes").is_none() {
5010 diagnostics.push(error_with_actual(
5011 "setup.machine.oauth_device_code.scopes",
5012 "oauth_device_code steps must declare scope or scopes",
5013 &step.id,
5014 ));
5015 }
5016 if let Some(scopes) = step.extra.get("scopes")
5017 && !scopes.is_array()
5018 {
5019 diagnostics.push(error_with_actual(
5020 "setup.machine.oauth_device_code.scopes",
5021 "oauth_device_code scopes must be an array",
5022 &step.id,
5023 ));
5024 }
5025}
5026
5027fn validate_oauth_authorization_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
5028 let authorize_url = step
5029 .extra
5030 .get("authorize_url")
5031 .or_else(|| step.extra.get("authorization_url"))
5032 .or_else(|| step.extra.get("auth_url"))
5033 .and_then(Value::as_str)
5034 .map(str::trim)
5035 .filter(|value| !value.is_empty());
5036 let Some(authorize_url) = authorize_url else {
5037 diagnostics.push(error_with_actual(
5038 "setup.machine.oauth_authorization_code.authorize_url",
5039 "oauth_authorization_code steps must declare authorize_url",
5040 &step.id,
5041 ));
5042 return;
5043 };
5044 if !authorize_url.contains('{') {
5045 match url::Url::parse(authorize_url) {
5046 Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
5047 Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5048 "setup.machine.oauth_authorization_code.authorize_url.safe",
5049 "oauth_authorization_code authorize_url must be https with a host",
5050 &format!("{}: {}", step.id, authorize_url),
5051 )),
5052 }
5053 }
5054 let token_url = step
5055 .extra
5056 .get("token_url")
5057 .or_else(|| step.extra.get("token_url_template"))
5058 .and_then(Value::as_str)
5059 .map(str::trim)
5060 .filter(|value| !value.is_empty());
5061 match token_url {
5062 Some(token_url) if !token_url.contains('{') => match url::Url::parse(token_url) {
5063 Ok(parsed)
5064 if (parsed.host_str().is_some() && parsed.scheme() == "https")
5065 || (parsed.scheme() == "http"
5066 && matches!(
5067 parsed.host_str(),
5068 Some("127.0.0.1" | "localhost" | "::1")
5069 )) => {}
5070 Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5071 "setup.machine.oauth_authorization_code.token_url.safe",
5072 "oauth_authorization_code token_url must be https or loopback http",
5073 &format!("{}: {}", step.id, token_url),
5074 )),
5075 },
5076 Some(_) => {}
5077 None => diagnostics.push(error_with_actual(
5078 "setup.machine.oauth_authorization_code.token_url",
5079 "oauth_authorization_code steps must declare token_url",
5080 &step.id,
5081 )),
5082 }
5083 if let Some(scopes) = step.extra.get("scopes")
5084 && !scopes.is_array()
5085 {
5086 diagnostics.push(error_with_actual(
5087 "setup.machine.oauth_authorization_code.scopes",
5088 "oauth_authorization_code scopes must be an array",
5089 &step.id,
5090 ));
5091 }
5092 if let Some(params) = step.extra.get("params")
5093 && !params.is_object()
5094 {
5095 diagnostics.push(error_with_actual(
5096 "setup.machine.oauth_authorization_code.params",
5097 "oauth_authorization_code params must be a JSON object",
5098 &step.id,
5099 ));
5100 }
5101 if let Some(ttl) = step.extra.get("state_ttl_secs").and_then(Value::as_u64)
5102 && ttl == 0
5103 {
5104 diagnostics.push(error_with_actual(
5105 "setup.machine.oauth_authorization_code.state_ttl_secs",
5106 "oauth_authorization_code state_ttl_secs must be greater than zero",
5107 &step.id,
5108 ));
5109 }
5110}
5111
5112fn validate_data_mapping_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
5113 let mut diagnostics = Vec::new();
5114 for step in &machine.steps {
5115 match step.kind.as_str() {
5116 "select_from_json" => {
5117 if step
5118 .extra
5119 .get("source_output")
5120 .or_else(|| step.extra.get("source"))
5121 .and_then(Value::as_str)
5122 .map(str::trim)
5123 .filter(|value| !value.is_empty())
5124 .is_none()
5125 {
5126 diagnostics.push(error_with_actual(
5127 "setup.machine.select_from_json.source_output",
5128 "select_from_json steps must declare source_output",
5129 &step.id,
5130 ));
5131 }
5132 if let Some(pointer) = step.extra.get("json_pointer").and_then(Value::as_str)
5133 && !pointer.starts_with('/')
5134 {
5135 diagnostics.push(error_with_actual(
5136 "setup.machine.select_from_json.json_pointer",
5137 "select_from_json json_pointer must use JSON Pointer syntax",
5138 &format!("{}: {}", step.id, pointer),
5139 ));
5140 }
5141 }
5142 "persist_runtime_config" => {
5143 let config = step.extra.get("config");
5144 let mappings = step.extra.get("mappings");
5145 if config.is_none() && mappings.is_none() {
5146 diagnostics.push(error_with_actual(
5147 "setup.machine.persist_runtime_config.input",
5148 "persist_runtime_config steps must declare config or mappings",
5149 &step.id,
5150 ));
5151 }
5152 for (name, value) in [("config", config), ("mappings", mappings)] {
5153 if let Some(value) = value
5154 && !value.is_object()
5155 {
5156 diagnostics.push(error_with_actual(
5157 "setup.machine.persist_runtime_config.object",
5158 "persist_runtime_config config and mappings must be JSON objects",
5159 &format!("{}: {}", step.id, name),
5160 ));
5161 }
5162 }
5163 if let Some(server_owned) = step.extra.get("server_owned_config_keys")
5164 && !server_owned.is_array()
5165 {
5166 diagnostics.push(error_with_actual(
5167 "setup.machine.persist_runtime_config.server_owned_config_keys",
5168 "persist_runtime_config server_owned_config_keys must be an array",
5169 &step.id,
5170 ));
5171 }
5172 let server_owned = setup_machine_server_owned_keys(machine, step);
5173 for (name, value) in [("config", config), ("mappings", mappings)] {
5174 if let Some(object) = value.and_then(Value::as_object) {
5175 for key in object.keys() {
5176 if server_owned.contains(key) {
5177 diagnostics.push(error_with_actual(
5178 "setup.machine.persist_runtime_config.server_owned_output",
5179 "persist_runtime_config config and mappings must not write server-owned keys",
5180 &format!("{}: {}.{}", step.id, name, key),
5181 ));
5182 }
5183 }
5184 }
5185 }
5186 }
5187 "provider_component_call" => {
5188 if !has_idempotency_key(step) {
5189 diagnostics.push(error_with_actual(
5190 "setup.machine.step.idempotency_key",
5191 "side-effecting setup steps must declare idempotency_key",
5192 &step.id,
5193 ));
5194 }
5195 if step
5196 .extra
5197 .get("component_ref")
5198 .or_else(|| step.extra.get("component"))
5199 .and_then(Value::as_str)
5200 .map(str::trim)
5201 .filter(|value| !value.is_empty())
5202 .is_none()
5203 {
5204 diagnostics.push(error_with_actual(
5205 "setup.machine.provider_component_call.component_ref",
5206 "provider_component_call steps must declare component_ref",
5207 &step.id,
5208 ));
5209 }
5210 if step
5211 .extra
5212 .get("op")
5213 .or_else(|| step.extra.get("operation"))
5214 .and_then(Value::as_str)
5215 .map(str::trim)
5216 .filter(|value| !value.is_empty())
5217 .is_none()
5218 {
5219 diagnostics.push(error_with_actual(
5220 "setup.machine.provider_component_call.op",
5221 "provider_component_call steps must declare op",
5222 &step.id,
5223 ));
5224 }
5225 if let Some(request) = step.extra.get("request").or_else(|| step.extra.get("body"))
5226 && !request.is_object()
5227 {
5228 diagnostics.push(error_with_actual(
5229 "setup.machine.provider_component_call.request",
5230 "provider_component_call request/body must be a JSON object",
5231 &step.id,
5232 ));
5233 }
5234 }
5235 _ => {}
5236 }
5237 }
5238 diagnostics
5239}
5240
5241fn validate_setup_machine_template_inputs(
5242 pack_path: &Path,
5243 provider_id: Option<&str>,
5244 machine: &SetupMachine,
5245) -> Vec<Diagnostic> {
5246 let mut available = BTreeSet::from([
5247 "tenant".to_string(),
5248 "team".to_string(),
5249 "provider_id".to_string(),
5250 ]);
5251
5252 if let Some(provider_id) = provider_id
5253 && let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
5254 {
5255 for question in form_spec.questions {
5256 if !question.id.trim().is_empty() {
5257 available.insert(question.id);
5258 }
5259 }
5260 }
5261
5262 for step in &machine.steps {
5263 available.extend(setup_machine_step_output_keys(step));
5264 }
5265
5266 let mut diagnostics = Vec::new();
5267 for step in &machine.steps {
5268 for (path, key) in setup_machine_step_template_references(step) {
5269 if !available.contains(&key) {
5270 diagnostics.push(error_with_actual(
5271 "setup.machine.template.input",
5272 "setup-machine template placeholder must reference a built-in value, setup answer, or declared setup output",
5273 &format!("{}: {} -> {}", step.id, path, key),
5274 ));
5275 }
5276 }
5277 }
5278 diagnostics
5279}
5280
5281fn setup_machine_step_template_references(step: &SetupMachineStep) -> Vec<(String, String)> {
5282 let mut references = Vec::new();
5283 for (key, value) in &step.extra {
5284 collect_template_references(value, key, &mut references);
5285 }
5286 references
5287}
5288
5289fn collect_template_references(value: &Value, path: &str, references: &mut Vec<(String, String)>) {
5290 match value {
5291 Value::String(text) => {
5292 for key in template_placeholders(text) {
5293 references.push((path.to_string(), key));
5294 }
5295 }
5296 Value::Array(values) => {
5297 for (idx, value) in values.iter().enumerate() {
5298 collect_template_references(value, &format!("{path}[{idx}]"), references);
5299 }
5300 }
5301 Value::Object(map) => {
5302 for (key, value) in map {
5303 collect_template_references(value, &format!("{path}.{key}"), references);
5304 }
5305 }
5306 _ => {}
5307 }
5308}
5309
5310fn template_placeholders(text: &str) -> Vec<String> {
5311 let mut keys = Vec::new();
5312 let mut start = 0;
5313 while let Some(open_rel) = text[start..].find('{') {
5314 let open = start + open_rel;
5315 let Some(close_rel) = text[open + 1..].find('}') else {
5316 break;
5317 };
5318 let close = open + 1 + close_rel;
5319 let key = text[open + 1..close].trim();
5320 if !key.is_empty() {
5321 keys.push(key.to_string());
5322 }
5323 start = close + 1;
5324 }
5325 keys
5326}
5327
5328fn setup_machine_step_output_keys(step: &SetupMachineStep) -> BTreeSet<String> {
5329 let mut keys = BTreeSet::new();
5330 for output in &step.outputs {
5331 match output {
5332 Value::String(key) if !key.trim().is_empty() => {
5333 keys.insert(key.trim().to_string());
5334 }
5335 Value::Object(object) => {
5336 for field in ["key", "name", "id"] {
5337 if let Some(key) = object
5338 .get(field)
5339 .and_then(Value::as_str)
5340 .map(str::trim)
5341 .filter(|value| !value.is_empty())
5342 {
5343 keys.insert(key.to_string());
5344 break;
5345 }
5346 }
5347 }
5348 _ => {}
5349 }
5350 }
5351
5352 let output_key = step
5353 .extra
5354 .get("output_key")
5355 .and_then(Value::as_str)
5356 .map(str::trim)
5357 .filter(|value| !value.is_empty());
5358 match step.kind.as_str() {
5359 "platform_public_url" | "cloudflare_tunnel" => {
5360 keys.insert(output_key.unwrap_or("public_base_url").to_string());
5361 }
5362 "oauth_device_code" => {
5363 keys.insert(output_key.unwrap_or("oauth_device_login").to_string());
5364 }
5365 "oauth_authorization_code" => {
5366 keys.insert(output_key.unwrap_or("authorization_url").to_string());
5367 }
5368 "http_json" | "select_from_json" | "generate_file" | "provider_component_call" => {
5369 let key = output_key.unwrap_or(step.id.as_str()).to_string();
5370 if step.kind == "select_from_json" {
5371 keys.insert(format!("{key}_item"));
5372 }
5373 keys.insert(key);
5374 }
5375 "persist_runtime_config" => {
5376 keys.insert(output_key.unwrap_or("runtime_config_path").to_string());
5377 }
5378 _ => {}
5379 }
5380 keys
5381}
5382
5383fn has_idempotency_key(step: &SetupMachineStep) -> bool {
5384 step.extra
5385 .get("idempotency_key")
5386 .and_then(Value::as_str)
5387 .map(str::trim)
5388 .is_some_and(|value| !value.is_empty())
5389}
5390
5391fn validate_setup_i18n_message_keys(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5392 let extension = match discovery::read_pack_extension(pack_path, SETUP_I18N_EXTENSION) {
5393 Ok(Some(extension)) => extension,
5394 Ok(None) => return Vec::new(),
5395 Err(err) => {
5396 return vec![load_error(
5397 "setup.i18n.load",
5398 "setup i18n descriptor could not be loaded",
5399 SETUP_I18N_EXTENSION,
5400 err,
5401 pack_path,
5402 )];
5403 }
5404 };
5405 let mut diagnostics = Vec::new();
5406 let declared_keys = match collect_setup_i18n_keys(pack_path, &extension) {
5407 Ok(keys) => keys,
5408 Err(err) => {
5409 diagnostics.push(load_error(
5410 "setup.i18n.messages.load",
5411 "setup i18n messages could not be loaded",
5412 "valid setup i18n JSON",
5413 err,
5414 pack_path,
5415 ));
5416 return diagnostics;
5417 }
5418 };
5419 if declared_keys.is_empty() {
5420 diagnostics.push(Diagnostic {
5421 check_id: "setup.i18n.messages.present".to_string(),
5422 severity: DiagnosticSeverity::Error,
5423 component: "provider_setup".to_string(),
5424 message: "setup i18n extension does not declare any message keys".to_string(),
5425 evidence: Some(SETUP_I18N_EXTENSION.to_string()),
5426 expected: Some("inline messages or message asset with at least one key".to_string()),
5427 actual: Some("empty".to_string()),
5428 fix_hint: Some(
5429 "add localized setup messages or remove the setup i18n extension".to_string(),
5430 ),
5431 related_file: Some(pack_path.display().to_string()),
5432 related_pack: Some(machine.id.clone()),
5433 related_component: None,
5434 });
5435 return diagnostics;
5436 }
5437
5438 for key in collect_setup_message_key_references(machine) {
5439 if !declared_keys.contains(&key) {
5440 diagnostics.push(error_with_actual(
5441 "setup.i18n.message_key.exists",
5442 "setup message_key must exist in greentic.setup.i18n.v1 messages",
5443 &key,
5444 ));
5445 }
5446 }
5447 diagnostics
5448}
5449
5450fn validate_setup_machine_fixtures(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5451 let mut diagnostics = Vec::new();
5452 let fixture_paths = setup_machine_fixture_paths(machine);
5453 if fixture_paths.is_empty() {
5454 return diagnostics;
5455 }
5456
5457 let step_ids = machine
5458 .steps
5459 .iter()
5460 .map(|step| step.id.as_str())
5461 .collect::<BTreeSet<_>>();
5462 for path in fixture_paths {
5463 let value = match discovery::read_pack_json_asset(pack_path, &path) {
5464 Ok(value) => value,
5465 Err(err) => {
5466 diagnostics.push(load_error(
5467 "setup.machine.fixture.load",
5468 "setup-machine fixture could not be loaded",
5469 "JSON fixture asset inside the provider pack",
5470 err,
5471 pack_path,
5472 ));
5473 continue;
5474 }
5475 };
5476 let state_value = value.get("state").cloned().unwrap_or(value);
5477 let state: SetupMachineState = match serde_json::from_value(state_value) {
5478 Ok(state) => state,
5479 Err(err) => {
5480 diagnostics.push(error_with_actual(
5481 "setup.machine.fixture.state",
5482 "setup-machine fixture must contain a valid setup-machine state",
5483 &format!("{path}: {err}"),
5484 ));
5485 continue;
5486 }
5487 };
5488 if state.machine_id != machine.id {
5489 diagnostics.push(error_with_actual(
5490 "setup.machine.fixture.machine_id",
5491 "setup-machine fixture machine_id must match the current machine",
5492 &format!("{}: {}", path, state.machine_id),
5493 ));
5494 }
5495 if state.machine_version != machine.version {
5496 diagnostics.push(error_with_actual(
5497 "setup.machine.fixture.machine_version",
5498 "setup-machine fixture machine_version must match the current machine version",
5499 &format!("{}: {}", path, state.machine_version),
5500 ));
5501 }
5502 if let Some(current_step) = state.current_step.as_deref()
5503 && !step_ids.contains(current_step)
5504 {
5505 diagnostics.push(error_with_actual(
5506 "setup.machine.fixture.current_step",
5507 "setup-machine fixture current_step must reference a declared step",
5508 &format!("{path}: {current_step}"),
5509 ));
5510 }
5511 if let Some(failed_step) = state.failed_step.as_deref()
5512 && !step_ids.contains(failed_step)
5513 {
5514 diagnostics.push(error_with_actual(
5515 "setup.machine.fixture.failed_step",
5516 "setup-machine fixture failed_step must reference a declared step",
5517 &format!("{path}: {failed_step}"),
5518 ));
5519 }
5520 for completed_step in &state.completed_steps {
5521 if !step_ids.contains(completed_step.as_str()) {
5522 diagnostics.push(error_with_actual(
5523 "setup.machine.fixture.completed_step",
5524 "setup-machine fixture completed_steps must reference declared steps",
5525 &format!("{path}: {completed_step}"),
5526 ));
5527 }
5528 }
5529 }
5530
5531 diagnostics
5532}
5533
5534fn setup_machine_fixture_paths(machine: &SetupMachine) -> Vec<String> {
5535 let mut paths = Vec::new();
5536 if let Some(fixtures) = machine.extra.get("fixtures").and_then(Value::as_array) {
5537 for fixture in fixtures {
5538 if let Some(path) = fixture.as_str() {
5539 paths.push(path.to_string());
5540 } else if let Some(path) = fixture.get("path").and_then(Value::as_str) {
5541 paths.push(path.to_string());
5542 }
5543 }
5544 }
5545 if let Some(fixture_paths) = machine.extra.get("fixture_paths").and_then(Value::as_array) {
5546 for path in fixture_paths.iter().filter_map(Value::as_str) {
5547 paths.push(path.to_string());
5548 }
5549 }
5550 paths
5551 .into_iter()
5552 .map(|path| path.trim().to_string())
5553 .filter(|path| !path.is_empty())
5554 .collect()
5555}
5556
5557fn collect_setup_i18n_keys(
5558 pack_path: &Path,
5559 extension: &Value,
5560) -> anyhow::Result<BTreeSet<String>> {
5561 let descriptor = extension_inline(extension).unwrap_or(extension);
5562 let mut keys = BTreeSet::new();
5563 collect_message_keys_from_value(&mut keys, descriptor);
5564
5565 for key in ["asset", "path", "messages_asset", "messages_path"] {
5566 if let Some(path) = descriptor.get(key).and_then(Value::as_str) {
5567 let value = discovery::read_pack_json_asset(pack_path, path)?;
5568 collect_message_keys_from_value(&mut keys, &value);
5569 }
5570 }
5571
5572 for key in ["assets", "paths", "locales"] {
5573 if let Some(map) = descriptor.get(key).and_then(Value::as_object) {
5574 for value in map.values() {
5575 if let Some(path) = value.as_str() {
5576 let messages = discovery::read_pack_json_asset(pack_path, path)?;
5577 collect_message_keys_from_value(&mut keys, &messages);
5578 } else {
5579 collect_message_keys_from_value(&mut keys, value);
5580 }
5581 }
5582 }
5583 }
5584
5585 Ok(keys)
5586}
5587
5588fn collect_message_keys_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5589 if let Some(messages) = value.get("messages").and_then(Value::as_object) {
5590 collect_message_keys_from_object(keys, messages);
5591 }
5592 if let Some(translations) = value.get("translations").and_then(Value::as_object) {
5593 collect_message_keys_from_object(keys, translations);
5594 }
5595 if let Some(obj) = value.as_object() {
5596 collect_message_keys_from_object(keys, obj);
5597 }
5598}
5599
5600fn collect_message_keys_from_object(
5601 keys: &mut BTreeSet<String>,
5602 obj: &serde_json::Map<String, Value>,
5603) {
5604 for (key, value) in obj {
5605 if [
5606 "schema_id",
5607 "provider_id",
5608 "default_locale",
5609 "asset",
5610 "path",
5611 "messages",
5612 "translations",
5613 "assets",
5614 "paths",
5615 "locales",
5616 ]
5617 .contains(&key.as_str())
5618 {
5619 continue;
5620 }
5621 match value {
5622 Value::String(_) => {
5623 keys.insert(key.clone());
5624 }
5625 Value::Object(map) if map.values().all(|candidate| candidate.as_str().is_some()) => {
5626 keys.insert(key.clone());
5627 }
5628 Value::Object(_) => collect_message_keys_from_value(keys, value),
5629 _ => {}
5630 }
5631 }
5632}
5633
5634fn collect_setup_message_key_references(machine: &SetupMachine) -> BTreeSet<String> {
5635 let mut keys = BTreeSet::new();
5636 for step in &machine.steps {
5637 collect_message_key_from_value(&mut keys, &Value::Object(step.extra.clone()));
5638 for rule in step.on_failure.iter().chain(step.recover.iter()) {
5639 collect_message_key_from_value(&mut keys, rule);
5640 }
5641 }
5642 keys
5643}
5644
5645fn collect_message_key_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5646 match value {
5647 Value::Object(map) => {
5648 for (key, nested) in map {
5649 if (key == "message_key" || key == "title_key" || key == "description_key")
5650 && let Some(message_key) = nested.as_str()
5651 && !message_key.trim().is_empty()
5652 {
5653 keys.insert(message_key.to_string());
5654 }
5655 collect_message_key_from_value(keys, nested);
5656 }
5657 }
5658 Value::Array(values) => {
5659 for nested in values {
5660 collect_message_key_from_value(keys, nested);
5661 }
5662 }
5663 _ => {}
5664 }
5665}
5666
5667fn detect_cycle(
5668 step_id: &str,
5669 edges: &BTreeMap<String, Vec<String>>,
5670 steps: &BTreeMap<&str, &SetupMachineStep>,
5671 visited: &mut BTreeSet<String>,
5672 stack: &mut Vec<String>,
5673 reported: &mut BTreeSet<String>,
5674 diagnostics: &mut Vec<Diagnostic>,
5675) {
5676 if let Some(position) = stack.iter().position(|candidate| candidate == step_id) {
5677 let cycle = stack[position..].to_vec();
5678 let key = cycle.join(" -> ");
5679 if reported.insert(key.clone())
5680 && !cycle.iter().any(|id| {
5681 steps.get(id.as_str()).is_some_and(|step| {
5682 step.extra.get("retry").is_some() || step.extra.get("timeout").is_some()
5683 })
5684 })
5685 {
5686 diagnostics.push(error_with_actual(
5687 "setup.machine.step.cycle",
5688 "setup step cycle must declare bounded retry or timeout policy",
5689 &key,
5690 ));
5691 }
5692 return;
5693 }
5694 if !visited.insert(step_id.to_string()) {
5695 return;
5696 }
5697 stack.push(step_id.to_string());
5698 for target in edges.get(step_id).into_iter().flatten() {
5699 detect_cycle(target, edges, steps, visited, stack, reported, diagnostics);
5700 }
5701 stack.pop();
5702}
5703
5704fn error(check_id: &str, message: &str) -> Diagnostic {
5705 Diagnostic {
5706 check_id: check_id.to_string(),
5707 severity: DiagnosticSeverity::Error,
5708 component: "provider_setup".to_string(),
5709 message: message.to_string(),
5710 evidence: None,
5711 expected: None,
5712 actual: None,
5713 fix_hint: None,
5714 related_file: None,
5715 related_pack: None,
5716 related_component: None,
5717 }
5718}
5719
5720fn error_with_actual(check_id: &str, message: &str, actual: &str) -> Diagnostic {
5721 Diagnostic {
5722 check_id: check_id.to_string(),
5723 severity: DiagnosticSeverity::Error,
5724 component: "provider_setup".to_string(),
5725 message: message.to_string(),
5726 evidence: None,
5727 expected: None,
5728 actual: Some(actual.to_string()),
5729 fix_hint: None,
5730 related_file: None,
5731 related_pack: None,
5732 related_component: None,
5733 }
5734}
5735
5736fn info(
5737 check_id: &str,
5738 message: &str,
5739 evidence: &str,
5740 pack_path: &Path,
5741 related_pack: Option<&str>,
5742) -> Diagnostic {
5743 Diagnostic {
5744 check_id: check_id.to_string(),
5745 severity: DiagnosticSeverity::Info,
5746 component: "provider_setup".to_string(),
5747 message: message.to_string(),
5748 evidence: Some(evidence.to_string()),
5749 expected: None,
5750 actual: None,
5751 fix_hint: None,
5752 related_file: Some(pack_path.display().to_string()),
5753 related_pack: related_pack.map(str::to_string),
5754 related_component: None,
5755 }
5756}
5757
5758fn load_error(
5759 check_id: &str,
5760 message: &str,
5761 expected: &str,
5762 err: anyhow::Error,
5763 pack_path: &Path,
5764) -> Diagnostic {
5765 Diagnostic {
5766 check_id: check_id.to_string(),
5767 severity: DiagnosticSeverity::Error,
5768 component: "provider_setup".to_string(),
5769 message: message.to_string(),
5770 evidence: Some(err.to_string()),
5771 expected: Some(expected.to_string()),
5772 actual: None,
5773 fix_hint: None,
5774 related_file: Some(pack_path.display().to_string()),
5775 related_pack: None,
5776 related_component: None,
5777 }
5778}
5779
5780fn report(pack_path: &Path, diagnostics: Vec<Diagnostic>) -> DoctorReport {
5781 let error_count = diagnostics
5782 .iter()
5783 .filter(|d| d.severity == DiagnosticSeverity::Error)
5784 .count();
5785 let warn_count = diagnostics
5786 .iter()
5787 .filter(|d| d.severity == DiagnosticSeverity::Warn)
5788 .count();
5789 let info_count = diagnostics
5790 .iter()
5791 .filter(|d| d.severity == DiagnosticSeverity::Info)
5792 .count();
5793 DoctorReport {
5794 bundle: pack_path.display().to_string(),
5795 status: if error_count > 0 {
5796 "error".to_string()
5797 } else if warn_count > 0 {
5798 "warn".to_string()
5799 } else {
5800 "ok".to_string()
5801 },
5802 error_count,
5803 warn_count,
5804 info_count,
5805 diagnostics,
5806 }
5807}
5808
5809#[cfg(test)]
5810mod tests {
5811 use super::*;
5812 use greentic_secrets_lib::SecretsStore;
5813 use std::io::Read;
5814 use std::io::Write;
5815 use zip::write::{FileOptions, ZipWriter};
5816
5817 fn valid_machine() -> SetupMachine {
5818 serde_json::from_value(serde_json::json!({
5819 "version": 1,
5820 "id": "messaging-teams-default",
5821 "entry_step": "collect",
5822 "steps": [
5823 {
5824 "id": "collect",
5825 "kind": "qa_form",
5826 "on_success": "login"
5827 },
5828 {
5829 "id": "login",
5830 "kind": "oauth_device_code",
5831 "device_code_url": "https://login.example.com/device",
5832 "token_url": "https://login.example.com/token",
5833 "client_id": "client-123",
5834 "scopes": ["User.Read"],
5835 "recover": [
5836 {"when": "refresh_token_invalid", "action": "reauthorize"}
5837 ],
5838 "on_success": "complete"
5839 }
5840 ]
5841 }))
5842 .unwrap()
5843 }
5844
5845 fn serve_one_json(status: u16, body: &'static str) -> String {
5846 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5847 let addr = listener.local_addr().unwrap();
5848 std::thread::spawn(move || {
5849 let (mut stream, _) = listener.accept().unwrap();
5850 let mut buffer = [0_u8; 2048];
5851 let _ = stream.read(&mut buffer);
5852 let wire = format!(
5853 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5854 body.len(),
5855 body
5856 );
5857 stream.write_all(wire.as_bytes()).unwrap();
5858 });
5859 format!("http://{addr}")
5860 }
5861
5862 fn serve_json_sequence(responses: Vec<(u16, &'static str)>) -> String {
5863 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5864 let addr = listener.local_addr().unwrap();
5865 std::thread::spawn(move || {
5866 for (status, body) in responses {
5867 let (mut stream, _) = listener.accept().unwrap();
5868 let mut buffer = [0_u8; 4096];
5869 let _ = stream.read(&mut buffer);
5870 let wire = format!(
5871 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5872 body.len(),
5873 body
5874 );
5875 stream.write_all(wire.as_bytes()).unwrap();
5876 }
5877 });
5878 format!("http://{addr}")
5879 }
5880
5881 #[test]
5882 fn validates_valid_machine() {
5883 let diagnostics = validate_setup_machine(&valid_machine());
5884 assert!(diagnostics.is_empty(), "{diagnostics:?}");
5885 }
5886
5887 #[test]
5888 fn rejects_missing_transition_target_and_unreachable_step() {
5889 let mut machine = valid_machine();
5890 machine.steps[0].on_success = Some("missing".to_string());
5891 machine.steps.push(SetupMachineStep {
5892 id: "never".to_string(),
5893 title: None,
5894 kind: "qa_form".to_string(),
5895 requires: vec![],
5896 outputs: vec![],
5897 on_success: None,
5898 on_failure: vec![],
5899 recover: vec![],
5900 extra: serde_json::Map::new(),
5901 });
5902
5903 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5904 .into_iter()
5905 .map(|d| d.check_id)
5906 .collect();
5907 assert!(ids.contains("setup.machine.step.on_success"));
5908 assert!(ids.contains("setup.machine.step.reachable"));
5909 }
5910
5911 #[test]
5912 fn rejects_unbounded_setup_machine_cycles() {
5913 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5914 "version": 1,
5915 "id": "cycle-machine",
5916 "entry_step": "a",
5917 "steps": [
5918 {"id": "a", "kind": "qa_form", "on_success": "b"},
5919 {"id": "b", "kind": "manual_action", "on_success": "a"}
5920 ]
5921 }))
5922 .unwrap();
5923
5924 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5925 .into_iter()
5926 .map(|d| d.check_id)
5927 .collect();
5928 assert!(ids.contains("setup.machine.step.cycle"));
5929 }
5930
5931 #[test]
5932 fn allows_bounded_setup_machine_cycles() {
5933 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5934 "version": 1,
5935 "id": "cycle-machine",
5936 "entry_step": "a",
5937 "steps": [
5938 {"id": "a", "kind": "qa_form", "on_success": "b"},
5939 {
5940 "id": "b",
5941 "kind": "manual_action",
5942 "retry": {"max_attempts": 3},
5943 "on_success": "a"
5944 }
5945 ]
5946 }))
5947 .unwrap();
5948
5949 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5950 .into_iter()
5951 .map(|d| d.check_id)
5952 .collect();
5953 assert!(!ids.contains("setup.machine.step.cycle"));
5954 }
5955
5956 #[test]
5957 fn rejects_unsafe_setup_machine_artifact_paths() {
5958 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5959 "version": 1,
5960 "id": "artifact-machine",
5961 "entry_step": "generate",
5962 "steps": [
5963 {"id": "generate", "kind": "generate_file", "artifact_path": "../secret.json"},
5964 {"id": "download", "kind": "download_file", "on_success": "complete"}
5965 ]
5966 }))
5967 .unwrap();
5968
5969 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5970 .into_iter()
5971 .map(|d| d.check_id)
5972 .collect();
5973 assert!(ids.contains("setup.machine.artifact.path.safe"));
5974 assert!(ids.contains("setup.machine.artifact.path"));
5975 }
5976
5977 #[test]
5978 fn accepts_setup_machine_artifact_paths_under_artifacts() {
5979 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5980 "version": 1,
5981 "id": "artifact-machine",
5982 "entry_step": "generate",
5983 "steps": [
5984 {
5985 "id": "generate",
5986 "kind": "generate_file",
5987 "artifact_path": "artifacts/teams/app.zip",
5988 "on_success": "download"
5989 },
5990 {
5991 "id": "download",
5992 "kind": "download_file",
5993 "artifact_path": "artifacts/teams/app.zip",
5994 "on_success": "complete"
5995 }
5996 ]
5997 }))
5998 .unwrap();
5999
6000 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6001 .into_iter()
6002 .map(|d| d.check_id)
6003 .collect();
6004 assert!(!ids.contains("setup.machine.artifact.path"));
6005 assert!(!ids.contains("setup.machine.artifact.path.safe"));
6006 }
6007
6008 #[test]
6009 fn rejects_invalid_setup_machine_http_steps() {
6010 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6011 "version": 1,
6012 "id": "http-machine",
6013 "entry_step": "missing_url",
6014 "steps": [
6015 {"id": "missing_url", "kind": "http_probe", "on_success": "bad_url"},
6016 {
6017 "id": "bad_url",
6018 "kind": "http_json",
6019 "url_template": "http://example.com/setup",
6020 "method": "PUT",
6021 "expected_status": 999
6022 }
6023 ]
6024 }))
6025 .unwrap();
6026
6027 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6028 .into_iter()
6029 .map(|d| d.check_id)
6030 .collect();
6031 assert!(ids.contains("setup.machine.http.url"));
6032 assert!(ids.contains("setup.machine.http.url.safe"));
6033 assert!(ids.contains("setup.machine.http.method"));
6034 assert!(ids.contains("setup.machine.http.expected_status"));
6035 assert!(ids.contains("setup.machine.step.idempotency_key"));
6036 }
6037
6038 #[test]
6039 fn rejects_invalid_setup_machine_tunnel_steps() {
6040 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6041 "version": 1,
6042 "id": "tunnel-machine",
6043 "entry_step": "tunnel",
6044 "steps": [
6045 {
6046 "id": "tunnel",
6047 "kind": "cloudflare_tunnel",
6048 "mode": "unsupported",
6049 "public_base_url": "http://example.com"
6050 }
6051 ]
6052 }))
6053 .unwrap();
6054
6055 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6056 .into_iter()
6057 .map(|d| d.check_id)
6058 .collect();
6059 assert!(ids.contains("setup.machine.cloudflare_tunnel.mode"));
6060 assert!(ids.contains("setup.machine.cloudflare_tunnel.public_base_url"));
6061 }
6062
6063 #[test]
6064 fn rejects_invalid_setup_machine_oauth_authorization_steps() {
6065 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6066 "version": 1,
6067 "id": "oauth-machine",
6068 "entry_step": "oauth",
6069 "steps": [
6070 {
6071 "id": "oauth",
6072 "kind": "oauth_authorization_code",
6073 "authorize_url": "http://login.example.com/auth",
6074 "token_url": "http://login.example.com/token",
6075 "scopes": "User.Read",
6076 "params": ["bad"],
6077 "state_ttl_secs": 0
6078 }
6079 ]
6080 }))
6081 .unwrap();
6082
6083 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6084 .into_iter()
6085 .map(|d| d.check_id)
6086 .collect();
6087 assert!(ids.contains("setup.machine.oauth_authorization_code.authorize_url.safe"));
6088 assert!(ids.contains("setup.machine.oauth_authorization_code.token_url.safe"));
6089 assert!(ids.contains("setup.machine.oauth_authorization_code.scopes"));
6090 assert!(ids.contains("setup.machine.oauth_authorization_code.params"));
6091 assert!(ids.contains("setup.machine.oauth_authorization_code.state_ttl_secs"));
6092 }
6093
6094 #[test]
6095 fn rejects_invalid_setup_machine_oauth_device_steps() {
6096 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6097 "version": 1,
6098 "id": "oauth-machine",
6099 "entry_step": "device",
6100 "steps": [
6101 {
6102 "id": "device",
6103 "kind": "oauth_device_code",
6104 "device_code_url": "http://example.com/device",
6105 "scopes": "User.Read"
6106 }
6107 ]
6108 }))
6109 .unwrap();
6110
6111 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6112 .into_iter()
6113 .map(|d| d.check_id)
6114 .collect();
6115 assert!(ids.contains("setup.machine.oauth_device_code.endpoints"));
6116 assert!(ids.contains("setup.machine.oauth_device_code.url.safe"));
6117 assert!(ids.contains("setup.machine.oauth_device_code.client_id"));
6118 assert!(ids.contains("setup.machine.oauth_device_code.scopes"));
6119 }
6120
6121 #[test]
6122 fn rejects_invalid_setup_machine_data_mapping_steps() {
6123 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6124 "version": 1,
6125 "id": "mapping-machine",
6126 "entry_step": "select",
6127 "steps": [
6128 {
6129 "id": "select",
6130 "kind": "select_from_json",
6131 "json_pointer": "value",
6132 "on_success": "persist"
6133 },
6134 {
6135 "id": "persist",
6136 "kind": "persist_runtime_config",
6137 "config": ["not", "object"],
6138 "server_owned_config_keys": "graph_access_token"
6139 },
6140 {
6141 "id": "component",
6142 "kind": "provider_component_call",
6143 "request": ["not", "object"]
6144 }
6145 ]
6146 }))
6147 .unwrap();
6148
6149 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6150 .into_iter()
6151 .map(|d| d.check_id)
6152 .collect();
6153 assert!(ids.contains("setup.machine.select_from_json.source_output"));
6154 assert!(ids.contains("setup.machine.select_from_json.json_pointer"));
6155 assert!(ids.contains("setup.machine.persist_runtime_config.object"));
6156 assert!(ids.contains("setup.machine.persist_runtime_config.server_owned_config_keys"));
6157 assert!(ids.contains("setup.machine.provider_component_call.component_ref"));
6158 assert!(ids.contains("setup.machine.provider_component_call.op"));
6159 assert!(ids.contains("setup.machine.provider_component_call.request"));
6160 assert!(ids.contains("setup.machine.step.idempotency_key"));
6161 }
6162
6163 #[test]
6164 fn accepts_valid_setup_machine_data_mapping_steps() {
6165 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6166 "version": 1,
6167 "id": "mapping-machine",
6168 "entry_step": "select",
6169 "steps": [
6170 {
6171 "id": "select",
6172 "kind": "select_from_json",
6173 "source_output": "teams",
6174 "json_pointer": "/value",
6175 "value_key": "id",
6176 "on_success": "persist"
6177 },
6178 {
6179 "id": "persist",
6180 "kind": "persist_runtime_config",
6181 "config": {"team_id": "{select}"},
6182 "server_owned_config_keys": ["graph_access_token"],
6183 "on_success": "component"
6184 },
6185 {
6186 "id": "component",
6187 "kind": "provider_component_call",
6188 "component_ref": "messaging-example",
6189 "op": "register",
6190 "idempotency_key": "register:{select}",
6191 "request": {"team_id": "{select}"}
6192 }
6193 ]
6194 }))
6195 .unwrap();
6196
6197 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6198 .into_iter()
6199 .map(|d| d.check_id)
6200 .collect();
6201 assert!(!ids.contains("setup.machine.select_from_json.source_output"));
6202 assert!(!ids.contains("setup.machine.select_from_json.json_pointer"));
6203 assert!(!ids.contains("setup.machine.persist_runtime_config.input"));
6204 assert!(!ids.contains("setup.machine.persist_runtime_config.object"));
6205 assert!(!ids.contains("setup.machine.provider_component_call.component_ref"));
6206 assert!(!ids.contains("setup.machine.provider_component_call.op"));
6207 assert!(!ids.contains("setup.machine.provider_component_call.request"));
6208 assert!(!ids.contains("setup.machine.step.idempotency_key"));
6209 }
6210
6211 #[test]
6212 fn rejects_persist_runtime_config_writing_server_owned_keys() {
6213 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6214 "version": 1,
6215 "id": "mapping-machine",
6216 "entry_step": "persist",
6217 "server_owned_config_keys": ["tenant_secret"],
6218 "steps": [
6219 {
6220 "id": "persist",
6221 "kind": "persist_runtime_config",
6222 "config": {
6223 "graph_access_token": "{token}",
6224 "team_id": "{team}"
6225 },
6226 "mappings": {
6227 "tenant_secret": "{secret}"
6228 },
6229 "server_owned_config_keys": ["graph_access_token"]
6230 }
6231 ]
6232 }))
6233 .unwrap();
6234
6235 let diagnostics = validate_setup_machine(&machine);
6236 let actual: BTreeSet<_> = diagnostics
6237 .iter()
6238 .filter(|diagnostic| {
6239 diagnostic.check_id == "setup.machine.persist_runtime_config.server_owned_output"
6240 })
6241 .filter_map(|diagnostic| diagnostic.actual.as_deref())
6242 .collect();
6243 assert!(actual.contains("persist: config.graph_access_token"));
6244 assert!(actual.contains("persist: mappings.tenant_secret"));
6245 }
6246
6247 #[test]
6248 fn accepts_templated_setup_machine_http_steps() {
6249 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6250 "version": 1,
6251 "id": "http-machine",
6252 "entry_step": "probe",
6253 "steps": [
6254 {
6255 "id": "probe",
6256 "kind": "http_probe",
6257 "url_template": "{public_base_url}/health",
6258 "method": "GET",
6259 "expected_status": 204,
6260 "on_success": "register"
6261 },
6262 {
6263 "id": "register",
6264 "kind": "http_json",
6265 "url_template": "{public_base_url}/register",
6266 "method": "POST",
6267 "expected_status": 200,
6268 "idempotency_key": "register:{tenant}",
6269 "on_success": "complete"
6270 }
6271 ]
6272 }))
6273 .unwrap();
6274
6275 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6276 .into_iter()
6277 .map(|d| d.check_id)
6278 .collect();
6279 assert!(!ids.contains("setup.machine.http.url"));
6280 assert!(!ids.contains("setup.machine.http.url.safe"));
6281 assert!(!ids.contains("setup.machine.http.method"));
6282 assert!(!ids.contains("setup.machine.http.expected_status"));
6283 assert!(!ids.contains("setup.machine.step.idempotency_key"));
6284 }
6285
6286 fn write_setup_machine_pack(
6287 pack: &Path,
6288 provider_id: &str,
6289 machine: &Value,
6290 setup_yaml: Option<&str>,
6291 ) -> anyhow::Result<()> {
6292 let file = std::fs::File::create(pack)?;
6293 let mut writer = ZipWriter::new(file);
6294 let options: FileOptions<'_, ()> =
6295 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6296 writer.start_file("pack.manifest.json", options)?;
6297 writer.write_all(
6298 serde_json::json!({
6299 "pack_id": provider_id,
6300 "extensions": {
6301 SETUP_MACHINE_EXTENSION: {
6302 "path": "assets/setup/setup-machine.v1.json",
6303 "schema": SETUP_MACHINE_SCHEMA_URL
6304 }
6305 }
6306 })
6307 .to_string()
6308 .as_bytes(),
6309 )?;
6310 if let Some(setup_yaml) = setup_yaml {
6311 writer.start_file("assets/setup.yaml", options)?;
6312 writer.write_all(setup_yaml.as_bytes())?;
6313 }
6314 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6315 writer.write_all(serde_json::to_string(machine)?.as_bytes())?;
6316 writer.finish()?;
6317 Ok(())
6318 }
6319
6320 #[test]
6321 fn doctor_accepts_setup_machine_template_inputs_from_answers_and_outputs() -> anyhow::Result<()>
6322 {
6323 let temp = tempfile::tempdir()?;
6324 let pack = temp.path().join("messaging-example.gtpack");
6325 let machine = serde_json::json!({
6326 "version": 1,
6327 "id": "messaging-example-machine",
6328 "entry_step": "public_url",
6329 "steps": [
6330 {
6331 "id": "public_url",
6332 "kind": "platform_public_url",
6333 "default": "https://example.test",
6334 "on_success": "persist"
6335 },
6336 {
6337 "id": "persist",
6338 "kind": "persist_runtime_config",
6339 "config": {
6340 "bot_app_id": "{bot_app_id}",
6341 "public_base_url": "{public_base_url}",
6342 "tenant_id": "{tenant}",
6343 "provider": "{provider_id}"
6344 }
6345 }
6346 ]
6347 });
6348 write_setup_machine_pack(
6349 &pack,
6350 "messaging-example",
6351 &machine,
6352 Some("title: Example\nquestions:\n - name: bot_app_id\n required: true\n"),
6353 )?;
6354
6355 let report = run_provider_setup_machine_doctor(&pack);
6356 assert_eq!(report.status, "ok");
6357 assert!(
6358 !report
6359 .diagnostics
6360 .iter()
6361 .any(|diagnostic| diagnostic.check_id == "setup.machine.template.input")
6362 );
6363 Ok(())
6364 }
6365
6366 #[test]
6367 fn doctor_rejects_setup_machine_unknown_template_input() -> anyhow::Result<()> {
6368 let temp = tempfile::tempdir()?;
6369 let pack = temp.path().join("messaging-example.gtpack");
6370 let machine = serde_json::json!({
6371 "version": 1,
6372 "id": "messaging-example-machine",
6373 "entry_step": "persist",
6374 "steps": [
6375 {
6376 "id": "persist",
6377 "kind": "persist_runtime_config",
6378 "config": {
6379 "bot_app_id": "{bot_ap_id}"
6380 }
6381 }
6382 ]
6383 });
6384 write_setup_machine_pack(
6385 &pack,
6386 "messaging-example",
6387 &machine,
6388 Some("title: Example\nquestions:\n - name: bot_app_id\n required: true\n"),
6389 )?;
6390
6391 let report = run_provider_setup_machine_doctor(&pack);
6392 assert_eq!(report.status, "error");
6393 assert!(report.diagnostics.iter().any(|diagnostic| {
6394 diagnostic.check_id == "setup.machine.template.input"
6395 && diagnostic
6396 .actual
6397 .as_deref()
6398 .is_some_and(|actual| actual.contains("bot_ap_id"))
6399 }));
6400 Ok(())
6401 }
6402
6403 #[test]
6404 fn loads_machine_from_pack_asset_extension() -> anyhow::Result<()> {
6405 let temp = tempfile::tempdir()?;
6406 let pack = temp.path().join("messaging-teams.gtpack");
6407 let file = std::fs::File::create(&pack)?;
6408 let mut writer = ZipWriter::new(file);
6409 let options: FileOptions<'_, ()> =
6410 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6411 writer.start_file("pack.manifest.json", options)?;
6412 writer.write_all(
6413 serde_json::json!({
6414 "pack_id": "messaging-teams",
6415 "extensions": {
6416 SETUP_MACHINE_EXTENSION: {
6417 "path": "assets/setup/setup-machine.v1.json",
6418 "schema": SETUP_MACHINE_SCHEMA_URL
6419 }
6420 }
6421 })
6422 .to_string()
6423 .as_bytes(),
6424 )?;
6425 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6426 writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6427 writer.finish()?;
6428
6429 let machine = load_setup_machine_from_pack(&pack)?.expect("machine");
6430 assert_eq!(machine.id, "messaging-teams-default");
6431 let report = run_provider_setup_machine_doctor(&pack);
6432 assert_eq!(report.status, "ok");
6433 Ok(())
6434 }
6435
6436 #[test]
6437 fn doctor_rejects_legacy_setup_actions_in_migrated_pack() -> anyhow::Result<()> {
6438 let temp = tempfile::tempdir()?;
6439 let pack = temp.path().join("messaging-teams.gtpack");
6440 let file = std::fs::File::create(&pack)?;
6441 let mut writer = ZipWriter::new(file);
6442 let options: FileOptions<'_, ()> =
6443 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6444 writer.start_file("pack.manifest.json", options)?;
6445 writer.write_all(
6446 serde_json::json!({
6447 "pack_id": "messaging-teams",
6448 "extensions": {
6449 SETUP_MACHINE_EXTENSION: {
6450 "path": "assets/setup/setup-machine.v1.json",
6451 "schema": SETUP_MACHINE_SCHEMA_URL
6452 }
6453 }
6454 })
6455 .to_string()
6456 .as_bytes(),
6457 )?;
6458 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6459 writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6460 writer.start_file("assets/setup.yaml", options)?;
6461 writer.write_all(
6462 br#"
6463title: Teams
6464questions: []
6465setup_actions:
6466 - id: legacy_install
6467 label: Legacy install
6468 kind: oauth_install_button
6469 authorize_url: https://login.example/install
6470"#,
6471 )?;
6472 writer.finish()?;
6473
6474 let report = run_provider_setup_machine_doctor(&pack);
6475
6476 assert_eq!(report.status, "error");
6477 assert!(report.diagnostics.iter().any(|d| {
6478 d.check_id == "setup.legacy_setup_actions.absent"
6479 && d.severity == DiagnosticSeverity::Error
6480 }));
6481 Ok(())
6482 }
6483
6484 #[test]
6485 fn doctor_rejects_missing_setup_i18n_message_key() -> anyhow::Result<()> {
6486 let temp = tempfile::tempdir()?;
6487 let pack = temp.path().join("messaging-teams.gtpack");
6488 let file = std::fs::File::create(&pack)?;
6489 let mut writer = ZipWriter::new(file);
6490 let options: FileOptions<'_, ()> =
6491 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6492 writer.start_file("pack.manifest.json", options)?;
6493 writer.write_all(
6494 serde_json::json!({
6495 "pack_id": "messaging-teams",
6496 "extensions": {
6497 SETUP_MACHINE_EXTENSION: {
6498 "path": "assets/setup/setup-machine.v1.json",
6499 "schema": SETUP_MACHINE_SCHEMA_URL
6500 },
6501 SETUP_I18N_EXTENSION: {
6502 "inline": {
6503 "schema_id": SETUP_I18N_EXTENSION,
6504 "provider_id": "messaging-teams",
6505 "messages": {
6506 "setup.present": "Present"
6507 }
6508 }
6509 }
6510 }
6511 })
6512 .to_string()
6513 .as_bytes(),
6514 )?;
6515 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6516 writer.write_all(
6517 serde_json::json!({
6518 "version": 1,
6519 "id": "messaging-teams-default",
6520 "entry_step": "approve",
6521 "steps": [
6522 {
6523 "id": "approve",
6524 "kind": "manual_action",
6525 "message_key": "setup.missing"
6526 }
6527 ]
6528 })
6529 .to_string()
6530 .as_bytes(),
6531 )?;
6532 writer.finish()?;
6533
6534 let report = run_provider_setup_machine_doctor(&pack);
6535
6536 assert_eq!(report.status, "error");
6537 assert!(report.diagnostics.iter().any(|d| {
6538 d.check_id == "setup.i18n.message_key.exists"
6539 && d.actual.as_deref() == Some("setup.missing")
6540 }));
6541 Ok(())
6542 }
6543
6544 #[test]
6545 fn doctor_accepts_setup_i18n_message_key_from_asset() -> anyhow::Result<()> {
6546 let temp = tempfile::tempdir()?;
6547 let pack = temp.path().join("messaging-teams.gtpack");
6548 let file = std::fs::File::create(&pack)?;
6549 let mut writer = ZipWriter::new(file);
6550 let options: FileOptions<'_, ()> =
6551 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6552 writer.start_file("pack.manifest.json", options)?;
6553 writer.write_all(
6554 serde_json::json!({
6555 "pack_id": "messaging-teams",
6556 "extensions": {
6557 SETUP_MACHINE_EXTENSION: {
6558 "path": "assets/setup/setup-machine.v1.json",
6559 "schema": SETUP_MACHINE_SCHEMA_URL
6560 },
6561 SETUP_I18N_EXTENSION: {
6562 "path": "assets/setup/i18n/en.json"
6563 }
6564 }
6565 })
6566 .to_string()
6567 .as_bytes(),
6568 )?;
6569 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6570 writer.write_all(
6571 serde_json::json!({
6572 "version": 1,
6573 "id": "messaging-teams-default",
6574 "entry_step": "approve",
6575 "steps": [
6576 {
6577 "id": "approve",
6578 "kind": "manual_action",
6579 "on_failure": [
6580 {
6581 "when": "manual_action_required",
6582 "action": "prompt",
6583 "message_key": "setup.approve"
6584 }
6585 ]
6586 }
6587 ]
6588 })
6589 .to_string()
6590 .as_bytes(),
6591 )?;
6592 writer.start_file("assets/setup/i18n/en.json", options)?;
6593 writer.write_all(
6594 serde_json::json!({
6595 "messages": {
6596 "setup.approve": "Approve setup"
6597 }
6598 })
6599 .to_string()
6600 .as_bytes(),
6601 )?;
6602 writer.finish()?;
6603
6604 let report = run_provider_setup_machine_doctor(&pack);
6605
6606 assert_eq!(report.status, "ok", "{report:#?}");
6607 Ok(())
6608 }
6609
6610 #[test]
6611 fn doctor_accepts_setup_machine_fixture_state() -> anyhow::Result<()> {
6612 let temp = tempfile::tempdir()?;
6613 let pack = temp.path().join("messaging-teams.gtpack");
6614 let file = std::fs::File::create(&pack)?;
6615 let mut writer = ZipWriter::new(file);
6616 let options: FileOptions<'_, ()> =
6617 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6618 writer.start_file("pack.manifest.json", options)?;
6619 writer.write_all(
6620 serde_json::json!({
6621 "pack_id": "messaging-teams",
6622 "extensions": {
6623 SETUP_MACHINE_EXTENSION: {
6624 "path": "assets/setup/setup-machine.v1.json",
6625 "schema": SETUP_MACHINE_SCHEMA_URL
6626 }
6627 }
6628 })
6629 .to_string()
6630 .as_bytes(),
6631 )?;
6632 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6633 writer.write_all(
6634 serde_json::json!({
6635 "version": 1,
6636 "id": "messaging-teams-default",
6637 "entry_step": "start",
6638 "fixture_paths": ["assets/setup/fixtures/paused.json"],
6639 "steps": [
6640 {
6641 "id": "start",
6642 "kind": "manual_action",
6643 "on_success": "complete"
6644 }
6645 ]
6646 })
6647 .to_string()
6648 .as_bytes(),
6649 )?;
6650 writer.start_file("assets/setup/fixtures/paused.json", options)?;
6651 writer.write_all(
6652 serde_json::json!({
6653 "state": {
6654 "schema_version": 1,
6655 "provider_id": "messaging-teams",
6656 "tenant": "demo",
6657 "team": "default",
6658 "machine_id": "messaging-teams-default",
6659 "machine_version": 1,
6660 "status": "paused",
6661 "current_step": "start",
6662 "completed_steps": [],
6663 "outputs": {},
6664 "created_resources": []
6665 }
6666 })
6667 .to_string()
6668 .as_bytes(),
6669 )?;
6670 writer.finish()?;
6671
6672 let report = run_provider_setup_machine_doctor(&pack);
6673
6674 assert_eq!(report.status, "ok", "{report:#?}");
6675 Ok(())
6676 }
6677
6678 #[test]
6679 fn doctor_rejects_setup_machine_fixture_with_missing_step() -> anyhow::Result<()> {
6680 let temp = tempfile::tempdir()?;
6681 let pack = temp.path().join("messaging-teams.gtpack");
6682 let file = std::fs::File::create(&pack)?;
6683 let mut writer = ZipWriter::new(file);
6684 let options: FileOptions<'_, ()> =
6685 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6686 writer.start_file("pack.manifest.json", options)?;
6687 writer.write_all(
6688 serde_json::json!({
6689 "pack_id": "messaging-teams",
6690 "extensions": {
6691 SETUP_MACHINE_EXTENSION: {
6692 "path": "assets/setup/setup-machine.v1.json",
6693 "schema": SETUP_MACHINE_SCHEMA_URL
6694 }
6695 }
6696 })
6697 .to_string()
6698 .as_bytes(),
6699 )?;
6700 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6701 writer.write_all(
6702 serde_json::json!({
6703 "version": 1,
6704 "id": "messaging-teams-default",
6705 "entry_step": "start",
6706 "fixtures": [{"path": "assets/setup/fixtures/bad.json"}],
6707 "steps": [
6708 {
6709 "id": "start",
6710 "kind": "manual_action",
6711 "on_success": "complete"
6712 }
6713 ]
6714 })
6715 .to_string()
6716 .as_bytes(),
6717 )?;
6718 writer.start_file("assets/setup/fixtures/bad.json", options)?;
6719 writer.write_all(
6720 serde_json::json!({
6721 "schema_version": 1,
6722 "provider_id": "messaging-teams",
6723 "tenant": "demo",
6724 "team": "default",
6725 "machine_id": "messaging-teams-default",
6726 "machine_version": 1,
6727 "status": "paused",
6728 "current_step": "missing",
6729 "completed_steps": ["start"],
6730 "outputs": {},
6731 "created_resources": []
6732 })
6733 .to_string()
6734 .as_bytes(),
6735 )?;
6736 writer.finish()?;
6737
6738 let report = run_provider_setup_machine_doctor(&pack);
6739
6740 assert_eq!(report.status, "error");
6741 assert!(report.diagnostics.iter().any(|diagnostic| {
6742 diagnostic.check_id == "setup.machine.fixture.current_step"
6743 && diagnostic.actual.as_deref() == Some("assets/setup/fixtures/bad.json: missing")
6744 }));
6745 Ok(())
6746 }
6747
6748 #[test]
6749 fn doctor_accepts_backend_contract_without_setup_machine() -> anyhow::Result<()> {
6750 let temp = tempfile::tempdir()?;
6751 let pack = temp.path().join("messaging-teams.gtpack");
6752 let file = std::fs::File::create(&pack)?;
6753 let mut writer = ZipWriter::new(file);
6754 let options: FileOptions<'_, ()> =
6755 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6756 writer.start_file("pack.manifest.json", options)?;
6757 writer.write_all(
6758 serde_json::json!({
6759 "pack_id": "messaging-teams",
6760 "extensions": {
6761 SETUP_WEB_COMPONENT_EXTENSION: {
6762 "inline": {
6763 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6764 "provider_id": "messaging-teams",
6765 "tag_name": "greentic-teams-setup-v4",
6766 "module_asset": "assets/setup/greentic-teams-setup.js"
6767 }
6768 },
6769 SETUP_BACKEND_CONTRACT_EXTENSION: {
6770 "inline": {
6771 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6772 "provider_id": "messaging-teams",
6773 "asset": "assets/setup/backend-contract.json"
6774 }
6775 },
6776 HTTP_ROUTES_EXTENSION: {
6777 "inline": {
6778 "routes": [
6779 {
6780 "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
6781 "methods": ["POST"],
6782 "setup_component_ref": "messaging-teams",
6783 "setup_op": "register"
6784 }
6785 ]
6786 }
6787 }
6788 }
6789 })
6790 .to_string()
6791 .as_bytes(),
6792 )?;
6793 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6794 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6795 writer.start_file("assets/setup/backend-contract.json", options)?;
6796 writer.write_all(
6797 serde_json::json!({
6798 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6799 "provider_id": "messaging-teams",
6800 "server_owned_config_keys": [
6801 "oauth_kind",
6802 "oauth_device_code",
6803 "oauth_user_code",
6804 "graph_access_token"
6805 ],
6806 "required_order": ["graph_admin_consent", "register_endpoint", "first_message"],
6807 "actions": [
6808 {
6809 "id": "graph_admin_consent",
6810 "executor": {
6811 "kind": "oauth_device_code",
6812 "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6813 "client_id_config_key": "graph_setup_client_id",
6814 "oauth_kind": "graph",
6815 "token_store_key": "graph_access_token",
6816 "scopes": ["https://graph.microsoft.com/User.Read"]
6817 },
6818 "completion": {"state_path": "oauth.graph.ok", "exists": true}
6819 },
6820 {
6821 "id": "register_endpoint",
6822 "requires": ["graph_admin_consent"],
6823 "executor": {
6824 "kind": "provider_http",
6825 "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
6826 },
6827 "completion": {"state_path": "last_reconcile.ok", "equals": true}
6828 },
6829 {
6830 "id": "first_message",
6831 "requires": ["register_endpoint"],
6832 "executor": {
6833 "kind": "runtime_observation",
6834 "source": "greentic-start",
6835 "event": "bot_framework_activity_received",
6836 "state_store_key": "last_activity"
6837 },
6838 "completion": {"state_path": "last_activity", "exists": true}
6839 }
6840 ]
6841 })
6842 .to_string()
6843 .as_bytes(),
6844 )?;
6845 writer.finish()?;
6846
6847 let report = run_provider_setup_machine_doctor(&pack);
6848 assert_eq!(report.status, "ok", "{report:#?}");
6849 assert!(
6850 report
6851 .diagnostics
6852 .iter()
6853 .any(|d| d.check_id == "setup.backend_contract.extension.present")
6854 );
6855 Ok(())
6856 }
6857
6858 #[test]
6859 fn doctor_rejects_invalid_setup_machine_even_with_backend_contract() -> anyhow::Result<()> {
6860 let temp = tempfile::tempdir()?;
6861 let pack = temp.path().join("messaging-teams.gtpack");
6862 let file = std::fs::File::create(&pack)?;
6863 let mut writer = ZipWriter::new(file);
6864 let options: FileOptions<'_, ()> =
6865 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6866 writer.start_file("pack.manifest.json", options)?;
6867 writer.write_all(
6868 serde_json::json!({
6869 "pack_id": "messaging-teams",
6870 "extensions": {
6871 SETUP_WEB_COMPONENT_EXTENSION: {
6872 "inline": {
6873 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6874 "provider_id": "messaging-teams",
6875 "tag_name": "greentic-teams-setup-v4",
6876 "module_asset": "assets/setup/greentic-teams-setup.js"
6877 }
6878 },
6879 SETUP_BACKEND_CONTRACT_EXTENSION: {
6880 "inline": {
6881 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6882 "provider_id": "messaging-teams",
6883 "asset": "assets/setup/backend-contract.json"
6884 }
6885 },
6886 SETUP_MACHINE_EXTENSION: {
6887 "path": "assets/setup/setup-machine.v1.json",
6888 "schema": SETUP_MACHINE_SCHEMA_URL
6889 }
6890 }
6891 })
6892 .to_string()
6893 .as_bytes(),
6894 )?;
6895 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6896 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6897 writer.start_file("assets/setup/backend-contract.json", options)?;
6898 writer.write_all(
6899 serde_json::json!({
6900 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6901 "provider_id": "messaging-teams",
6902 "server_owned_config_keys": [
6903 "oauth_kind",
6904 "oauth_device_code",
6905 "oauth_user_code",
6906 "graph_access_token"
6907 ],
6908 "required_order": ["graph_admin_consent"],
6909 "actions": [
6910 {
6911 "id": "graph_admin_consent",
6912 "executor": {
6913 "kind": "oauth_device_code",
6914 "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6915 "client_id_config_key": "graph_setup_client_id",
6916 "oauth_kind": "graph",
6917 "token_store_key": "graph_access_token",
6918 "scopes": ["https://graph.microsoft.com/User.Read"]
6919 },
6920 "completion": {"state_path": "oauth.graph.ok", "exists": true}
6921 }
6922 ]
6923 })
6924 .to_string()
6925 .as_bytes(),
6926 )?;
6927 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6928 writer.write_all(
6929 serde_json::json!({
6930 "version": 1,
6931 "id": "messaging-teams-broken",
6932 "entry_step": "missing",
6933 "steps": [
6934 {
6935 "id": "only",
6936 "kind": "auto_complete",
6937 "on_success": "complete"
6938 }
6939 ]
6940 })
6941 .to_string()
6942 .as_bytes(),
6943 )?;
6944 writer.finish()?;
6945
6946 let report = run_provider_setup_machine_doctor(&pack);
6947
6948 assert_eq!(report.status, "error");
6949 assert!(
6950 report
6951 .diagnostics
6952 .iter()
6953 .any(|d| d.check_id == "setup.backend_contract.extension.present")
6954 );
6955 assert!(
6956 report
6957 .diagnostics
6958 .iter()
6959 .any(|d| d.check_id == "setup.machine.extension.present")
6960 );
6961 assert!(report.diagnostics.iter().any(|d| {
6962 d.severity == DiagnosticSeverity::Error && d.check_id.starts_with("setup.machine.")
6963 }));
6964 Ok(())
6965 }
6966
6967 #[test]
6968 fn route_pattern_matches_provider_http_templates() {
6969 assert!(route_pattern_covers_path(
6970 "/v1/setup/messaging-teams/{tenant}/{team}/register",
6971 "/v1/setup/messaging-teams/{tenant}/{team}/register",
6972 ));
6973 assert!(route_pattern_covers_path(
6974 "/v1/messaging/setup/messaging-teams/{tenant}/{action*}",
6975 "/v1/messaging/setup/messaging-teams/{tenant}/oauth/{kind}/start",
6976 ));
6977 assert!(!route_pattern_covers_path(
6978 "/v1/setup/messaging-teams/{tenant}/{team}/publish",
6979 "/v1/setup/messaging-teams/{tenant}/{team}/register",
6980 ));
6981 }
6982
6983 #[test]
6984 fn backend_contract_doctor_rejects_uncovered_provider_http_route() -> anyhow::Result<()> {
6985 let temp = tempfile::tempdir()?;
6986 let pack = temp.path().join("messaging-teams.gtpack");
6987 let file = std::fs::File::create(&pack)?;
6988 let mut writer = ZipWriter::new(file);
6989 let options: FileOptions<'_, ()> =
6990 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6991 writer.start_file("pack.manifest.json", options)?;
6992 writer.write_all(
6993 serde_json::json!({
6994 "pack_id": "messaging-teams",
6995 "extensions": {
6996 SETUP_WEB_COMPONENT_EXTENSION: {
6997 "inline": {
6998 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6999 "provider_id": "messaging-teams",
7000 "tag_name": "greentic-teams-setup-v4",
7001 "module_asset": "assets/setup/greentic-teams-setup.js"
7002 }
7003 },
7004 SETUP_BACKEND_CONTRACT_EXTENSION: {
7005 "inline": {
7006 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7007 "provider_id": "messaging-teams",
7008 "asset": "assets/setup/backend-contract.json"
7009 }
7010 },
7011 HTTP_ROUTES_EXTENSION: {
7012 "inline": {
7013 "routes": [
7014 {
7015 "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/other",
7016 "methods": ["POST"],
7017 "setup_component_ref": "messaging-teams",
7018 "setup_op": "other"
7019 }
7020 ]
7021 }
7022 }
7023 }
7024 })
7025 .to_string()
7026 .as_bytes(),
7027 )?;
7028 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7029 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7030 writer.start_file("assets/setup/backend-contract.json", options)?;
7031 writer.write_all(
7032 serde_json::json!({
7033 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7034 "provider_id": "messaging-teams",
7035 "required_order": ["register_endpoint"],
7036 "actions": [
7037 {
7038 "id": "register_endpoint",
7039 "executor": {
7040 "kind": "provider_http",
7041 "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7042 },
7043 "completion": {"state_path": "last_reconcile.ok", "equals": true}
7044 }
7045 ]
7046 })
7047 .to_string()
7048 .as_bytes(),
7049 )?;
7050 writer.finish()?;
7051
7052 let report = run_provider_setup_machine_doctor(&pack);
7053 assert_eq!(report.status, "error");
7054 assert!(
7055 report
7056 .diagnostics
7057 .iter()
7058 .any(|d| { d.check_id == "setup.backend_contract.provider_http.route_covered" })
7059 );
7060 Ok(())
7061 }
7062
7063 #[test]
7064 fn backend_contract_doctor_rejects_provider_http_method_mismatch() -> anyhow::Result<()> {
7065 let temp = tempfile::tempdir()?;
7066 let pack = temp.path().join("messaging-teams.gtpack");
7067 let file = std::fs::File::create(&pack)?;
7068 let mut writer = ZipWriter::new(file);
7069 let options: FileOptions<'_, ()> =
7070 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7071 writer.start_file("pack.manifest.json", options)?;
7072 writer.write_all(
7073 serde_json::json!({
7074 "pack_id": "messaging-teams",
7075 "extensions": {
7076 SETUP_WEB_COMPONENT_EXTENSION: {
7077 "inline": {
7078 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7079 "provider_id": "messaging-teams",
7080 "tag_name": "greentic-teams-setup-v4",
7081 "module_asset": "assets/setup/greentic-teams-setup.js"
7082 }
7083 },
7084 SETUP_BACKEND_CONTRACT_EXTENSION: {
7085 "inline": {
7086 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7087 "provider_id": "messaging-teams",
7088 "asset": "assets/setup/backend-contract.json"
7089 }
7090 },
7091 HTTP_ROUTES_EXTENSION: {
7092 "inline": {
7093 "routes": [
7094 {
7095 "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
7096 "methods": ["GET"],
7097 "setup_component_ref": "messaging-teams",
7098 "setup_op": "register"
7099 }
7100 ]
7101 }
7102 }
7103 }
7104 })
7105 .to_string()
7106 .as_bytes(),
7107 )?;
7108 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7109 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7110 writer.start_file("assets/setup/backend-contract.json", options)?;
7111 writer.write_all(
7112 serde_json::json!({
7113 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7114 "provider_id": "messaging-teams",
7115 "required_order": ["register_endpoint"],
7116 "actions": [
7117 {
7118 "id": "register_endpoint",
7119 "executor": {
7120 "kind": "provider_http",
7121 "method": "POST",
7122 "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7123 },
7124 "completion": {"state_path": "last_reconcile.ok", "equals": true}
7125 }
7126 ]
7127 })
7128 .to_string()
7129 .as_bytes(),
7130 )?;
7131 writer.finish()?;
7132
7133 let report = run_provider_setup_machine_doctor(&pack);
7134 assert_eq!(report.status, "error");
7135 assert!(
7136 report
7137 .diagnostics
7138 .iter()
7139 .any(|d| d.check_id == "setup.backend_contract.provider_http.route_method")
7140 );
7141 Ok(())
7142 }
7143
7144 #[test]
7145 fn backend_contract_doctor_rejects_oauth_token_not_server_owned() -> anyhow::Result<()> {
7146 let temp = tempfile::tempdir()?;
7147 let pack = temp.path().join("messaging-teams.gtpack");
7148 let file = std::fs::File::create(&pack)?;
7149 let mut writer = ZipWriter::new(file);
7150 let options: FileOptions<'_, ()> =
7151 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7152 writer.start_file("pack.manifest.json", options)?;
7153 writer.write_all(
7154 serde_json::json!({
7155 "pack_id": "messaging-teams",
7156 "extensions": {
7157 SETUP_WEB_COMPONENT_EXTENSION: {
7158 "inline": {
7159 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7160 "provider_id": "messaging-teams",
7161 "tag_name": "greentic-teams-setup-v4",
7162 "module_asset": "assets/setup/greentic-teams-setup.js"
7163 }
7164 },
7165 SETUP_BACKEND_CONTRACT_EXTENSION: {
7166 "inline": {
7167 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7168 "provider_id": "messaging-teams",
7169 "asset": "assets/setup/backend-contract.json"
7170 }
7171 }
7172 }
7173 })
7174 .to_string()
7175 .as_bytes(),
7176 )?;
7177 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7178 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7179 writer.start_file("assets/setup/backend-contract.json", options)?;
7180 writer.write_all(
7181 serde_json::json!({
7182 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7183 "provider_id": "messaging-teams",
7184 "server_owned_config_keys": ["oauth_kind", "oauth_device_code", "oauth_user_code"],
7185 "required_order": ["graph_admin_consent"],
7186 "actions": [
7187 {
7188 "id": "graph_admin_consent",
7189 "executor": {
7190 "kind": "oauth_device_code",
7191 "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
7192 "client_id_config_key": "graph_setup_client_id",
7193 "oauth_kind": "graph",
7194 "token_store_key": "graph_access_token",
7195 "scopes": ["https://graph.microsoft.com/User.Read"]
7196 },
7197 "completion": {"state_path": "oauth.graph.ok", "exists": true}
7198 }
7199 ]
7200 })
7201 .to_string()
7202 .as_bytes(),
7203 )?;
7204 writer.finish()?;
7205
7206 let report = run_provider_setup_machine_doctor(&pack);
7207 assert_eq!(report.status, "error");
7208 assert!(
7209 report
7210 .diagnostics
7211 .iter()
7212 .any(|d| { d.check_id == "setup.backend_contract.server_owned_config_keys.oauth" })
7213 );
7214 Ok(())
7215 }
7216
7217 #[test]
7218 fn persists_state_and_appends_events() -> anyhow::Result<()> {
7219 let temp = tempfile::tempdir()?;
7220 let state = SetupMachineState {
7221 schema_version: 1,
7222 provider_id: "messaging-teams".to_string(),
7223 tenant: "demo".to_string(),
7224 team: "default".to_string(),
7225 machine_id: "messaging-teams-default".to_string(),
7226 machine_version: 1,
7227 status: SetupMachineStatus::Running,
7228 current_step: Some("login".to_string()),
7229 completed_steps: vec!["collect".to_string()],
7230 failed_step: None,
7231 answers_hash: None,
7232 pack_fingerprint: None,
7233 outputs: serde_json::json!({}),
7234 created_resources: vec![],
7235 last_error: None,
7236 updated_at: None,
7237 };
7238 let path = write_setup_machine_state(temp.path(), &state)?;
7239 let loaded = load_setup_machine_state(&path)?;
7240 assert_eq!(loaded.current_step.as_deref(), Some("login"));
7241
7242 let event = SetupMachineEvent {
7243 ts: "2026-06-17T00:00:00Z".to_string(),
7244 level: "info".to_string(),
7245 event: "setup.step.started".to_string(),
7246 provider_id: "messaging-teams".to_string(),
7247 tenant: "demo".to_string(),
7248 team: "default".to_string(),
7249 machine_id: "messaging-teams-default".to_string(),
7250 step_id: Some("login".to_string()),
7251 correlation_id: Some("corr".to_string()),
7252 fields: serde_json::Map::new(),
7253 };
7254 let event_path = append_setup_machine_event(temp.path(), &event)?;
7255 let events = std::fs::read_to_string(event_path)?;
7256 assert!(events.contains("setup.step.started"));
7257 Ok(())
7258 }
7259
7260 #[test]
7261 fn advance_setup_machine_blocks_incompatible_persisted_machine_state() -> anyhow::Result<()> {
7262 let temp = tempfile::tempdir()?;
7263 let machine = valid_machine();
7264 let state = SetupMachineState {
7265 schema_version: 1,
7266 provider_id: "messaging-teams".to_string(),
7267 tenant: "demo".to_string(),
7268 team: "default".to_string(),
7269 machine_id: machine.id.clone(),
7270 machine_version: 0,
7271 status: SetupMachineStatus::Running,
7272 current_step: Some(machine.entry_step.clone()),
7273 completed_steps: Vec::new(),
7274 failed_step: None,
7275 answers_hash: None,
7276 pack_fingerprint: None,
7277 outputs: serde_json::json!({}),
7278 created_resources: Vec::new(),
7279 last_error: None,
7280 updated_at: None,
7281 };
7282 write_setup_machine_state(temp.path(), &state)?;
7283
7284 let output = advance_setup_machine(
7285 temp.path(),
7286 "messaging-teams",
7287 "demo",
7288 "default",
7289 &machine,
7290 false,
7291 )?;
7292
7293 assert_eq!(output["ok"], false);
7294 assert!(
7295 output["next"]
7296 .as_str()
7297 .unwrap()
7298 .contains("machine_identity_changed")
7299 );
7300 let loaded = load_setup_machine_state(&setup_machine_state_path(
7301 temp.path(),
7302 "demo",
7303 "default",
7304 "messaging-teams",
7305 ))?;
7306 assert_eq!(loaded.status, SetupMachineStatus::Failed);
7307 assert_eq!(
7308 loaded
7309 .last_error
7310 .as_ref()
7311 .and_then(|error| error.get("error"))
7312 .and_then(Value::as_str),
7313 Some("machine_identity_changed")
7314 );
7315 let events = std::fs::read_to_string(setup_machine_events_path(
7316 temp.path(),
7317 "demo",
7318 "default",
7319 "messaging-teams",
7320 ))?;
7321 assert!(events.contains("setup.machine.incompatible_state"));
7322 Ok(())
7323 }
7324
7325 #[test]
7326 fn setup_machine_state_records_pack_and_answers_hashes() -> anyhow::Result<()> {
7327 let temp = tempfile::tempdir()?;
7328 let machine = valid_machine();
7329 let pack_path = temp.path().join("provider.gtpack");
7330 std::fs::write(&pack_path, b"pack-v1")?;
7331 let answers_path = temp
7332 .path()
7333 .join("state/config/messaging-teams/setup-answers.json");
7334 std::fs::create_dir_all(answers_path.parent().unwrap())?;
7335 std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7336
7337 let state = load_or_init_setup_machine_state_with_context(
7338 temp.path(),
7339 Some(&pack_path),
7340 "messaging-teams",
7341 "demo",
7342 "default",
7343 &machine,
7344 )?;
7345
7346 assert!(state.pack_fingerprint.as_deref().is_some_and(|hash| {
7347 hash.starts_with("sha256:") && hash == setup_machine_hash_file(&pack_path).unwrap()
7348 }));
7349 assert!(state.answers_hash.as_deref().is_some_and(|hash| {
7350 hash.starts_with("sha256:") && hash == setup_machine_hash_file(&answers_path).unwrap()
7351 }));
7352 Ok(())
7353 }
7354
7355 #[test]
7356 fn advance_setup_machine_blocks_changed_provider_pack_fingerprint() -> anyhow::Result<()> {
7357 let temp = tempfile::tempdir()?;
7358 let machine = valid_machine();
7359 let pack_path = temp.path().join("provider.gtpack");
7360 std::fs::write(&pack_path, b"pack-v1")?;
7361 let mut state = load_or_init_setup_machine_state_with_context(
7362 temp.path(),
7363 Some(&pack_path),
7364 "messaging-teams",
7365 "demo",
7366 "default",
7367 &machine,
7368 )?;
7369 state.status = SetupMachineStatus::Running;
7370 write_setup_machine_state(temp.path(), &state)?;
7371 std::fs::write(&pack_path, b"pack-v2")?;
7372
7373 let output = advance_setup_machine_with_pack(
7374 temp.path(),
7375 Some(&pack_path),
7376 "messaging-teams",
7377 "demo",
7378 "default",
7379 &machine,
7380 false,
7381 )?;
7382
7383 assert_eq!(output["ok"], false);
7384 assert!(
7385 output["next"]
7386 .as_str()
7387 .unwrap()
7388 .contains("pack_fingerprint_changed")
7389 );
7390 let loaded = load_setup_machine_state(&setup_machine_state_path(
7391 temp.path(),
7392 "demo",
7393 "default",
7394 "messaging-teams",
7395 ))?;
7396 assert_eq!(
7397 loaded
7398 .last_error
7399 .as_ref()
7400 .and_then(|error| error.get("error"))
7401 .and_then(Value::as_str),
7402 Some("pack_fingerprint_changed")
7403 );
7404 Ok(())
7405 }
7406
7407 #[test]
7408 fn advance_setup_machine_blocks_changed_setup_answers_hash() -> anyhow::Result<()> {
7409 let temp = tempfile::tempdir()?;
7410 let machine = valid_machine();
7411 let pack_path = temp.path().join("provider.gtpack");
7412 std::fs::write(&pack_path, b"pack-v1")?;
7413 let answers_path = temp
7414 .path()
7415 .join("state/config/messaging-teams/setup-answers.json");
7416 std::fs::create_dir_all(answers_path.parent().unwrap())?;
7417 std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7418 let mut state = load_or_init_setup_machine_state_with_context(
7419 temp.path(),
7420 Some(&pack_path),
7421 "messaging-teams",
7422 "demo",
7423 "default",
7424 &machine,
7425 )?;
7426 state.status = SetupMachineStatus::Running;
7427 write_setup_machine_state(temp.path(), &state)?;
7428 std::fs::write(&answers_path, br#"{"client_id":"changed"}"#)?;
7429
7430 let output = advance_setup_machine_with_pack(
7431 temp.path(),
7432 Some(&pack_path),
7433 "messaging-teams",
7434 "demo",
7435 "default",
7436 &machine,
7437 false,
7438 )?;
7439
7440 assert_eq!(output["ok"], false);
7441 assert!(
7442 output["next"]
7443 .as_str()
7444 .unwrap()
7445 .contains("setup_answers_changed")
7446 );
7447 let loaded = load_setup_machine_state(&setup_machine_state_path(
7448 temp.path(),
7449 "demo",
7450 "default",
7451 "messaging-teams",
7452 ))?;
7453 assert_eq!(
7454 loaded
7455 .last_error
7456 .as_ref()
7457 .and_then(|error| error.get("error"))
7458 .and_then(Value::as_str),
7459 Some("setup_answers_changed")
7460 );
7461 Ok(())
7462 }
7463
7464 #[test]
7465 fn advance_setup_machine_completes_auto_step_and_persists_state() -> anyhow::Result<()> {
7466 let temp = tempfile::tempdir()?;
7467 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7468 "version": 1,
7469 "id": "example-machine",
7470 "entry_step": "collect",
7471 "steps": [
7472 {
7473 "id": "collect",
7474 "kind": "qa_form",
7475 "auto_complete": true,
7476 "on_success": "manual"
7477 },
7478 {
7479 "id": "manual",
7480 "kind": "manual_action",
7481 "title": "Approve"
7482 }
7483 ]
7484 }))?;
7485
7486 let output = advance_setup_machine(
7487 temp.path(),
7488 "messaging-example",
7489 "demo",
7490 "default",
7491 &machine,
7492 false,
7493 )?;
7494
7495 assert_eq!(output["ok"], true);
7496 assert_eq!(output["step"], "collect");
7497 assert_eq!(output["status"]["current_step"], "manual");
7498 let state = load_setup_machine_state(&setup_machine_state_path(
7499 temp.path(),
7500 "demo",
7501 "default",
7502 "messaging-example",
7503 ))?;
7504 assert_eq!(state.status, SetupMachineStatus::Running);
7505 assert_eq!(state.completed_steps, vec!["collect"]);
7506 assert_eq!(state.current_step.as_deref(), Some("manual"));
7507 let events = std::fs::read_to_string(setup_machine_events_path(
7508 temp.path(),
7509 "demo",
7510 "default",
7511 "messaging-example",
7512 ))?;
7513 assert!(events.contains("setup.step.completed"));
7514 Ok(())
7515 }
7516
7517 #[test]
7518 fn advance_setup_machine_resumes_current_step_and_pauses_for_manual_action()
7519 -> anyhow::Result<()> {
7520 let temp = tempfile::tempdir()?;
7521 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7522 "version": 1,
7523 "id": "example-machine",
7524 "entry_step": "manual",
7525 "steps": [
7526 {
7527 "id": "manual",
7528 "kind": "manual_action",
7529 "title": "Approve"
7530 }
7531 ]
7532 }))?;
7533
7534 let output = advance_setup_machine(
7535 temp.path(),
7536 "messaging-example",
7537 "demo",
7538 "default",
7539 &machine,
7540 false,
7541 )?;
7542
7543 assert_eq!(output["ok"], false);
7544 assert_eq!(output["step"], "manual");
7545 assert_eq!(output["result"]["status"], "paused");
7546 assert_eq!(
7547 output["result"]["detail"]["error"],
7548 "manual_action_required"
7549 );
7550 let state = load_setup_machine_state(&setup_machine_state_path(
7551 temp.path(),
7552 "demo",
7553 "default",
7554 "messaging-example",
7555 ))?;
7556 assert_eq!(state.status, SetupMachineStatus::Paused);
7557 assert_eq!(state.current_step.as_deref(), Some("manual"));
7558
7559 let repeated = advance_setup_machine(
7560 temp.path(),
7561 "messaging-example",
7562 "demo",
7563 "default",
7564 &machine,
7565 true,
7566 )?;
7567 assert_eq!(repeated["step"], "manual");
7568 assert_eq!(repeated["dry_run"], true);
7569 Ok(())
7570 }
7571
7572 #[test]
7573 fn advance_setup_machine_follows_declared_failure_recovery_step() -> anyhow::Result<()> {
7574 let temp = tempfile::tempdir()?;
7575 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7576 "version": 1,
7577 "id": "example-machine",
7578 "entry_step": "public_url",
7579 "steps": [
7580 {
7581 "id": "public_url",
7582 "kind": "platform_public_url",
7583 "on_failure": [
7584 {
7585 "when": "missing_prerequisite",
7586 "target": "manual_public_url"
7587 }
7588 ],
7589 "on_success": "complete"
7590 },
7591 {
7592 "id": "manual_public_url",
7593 "kind": "manual_action",
7594 "on_success": "public_url"
7595 }
7596 ]
7597 }))?;
7598
7599 let output = advance_setup_machine(
7600 temp.path(),
7601 "messaging-example",
7602 "demo",
7603 "default",
7604 &machine,
7605 false,
7606 )?;
7607
7608 assert_eq!(output["ok"], false);
7609 assert_eq!(output["next"], "recovery scheduled: manual_public_url");
7610 assert_eq!(
7611 output["result"]["detail"]["recovery_step"],
7612 "manual_public_url"
7613 );
7614 let state = load_setup_machine_state(&setup_machine_state_path(
7615 temp.path(),
7616 "demo",
7617 "default",
7618 "messaging-example",
7619 ))?;
7620 assert_eq!(state.status, SetupMachineStatus::Running);
7621 assert_eq!(state.current_step.as_deref(), Some("manual_public_url"));
7622 assert_eq!(
7623 state
7624 .last_error
7625 .as_ref()
7626 .and_then(|error| error.get("recovery_step"))
7627 .and_then(Value::as_str),
7628 Some("manual_public_url")
7629 );
7630 let events = std::fs::read_to_string(setup_machine_events_path(
7631 temp.path(),
7632 "demo",
7633 "default",
7634 "messaging-example",
7635 ))?;
7636 assert!(events.contains("setup.step.recovery_scheduled"));
7637 Ok(())
7638 }
7639
7640 #[test]
7641 fn advance_setup_machine_resolves_platform_public_url_from_static_routes() -> anyhow::Result<()>
7642 {
7643 let temp = tempfile::tempdir()?;
7644 crate::platform_setup::persist_static_routes_artifact(
7645 temp.path(),
7646 &crate::platform_setup::StaticRoutesPolicy {
7647 public_web_enabled: true,
7648 public_base_url: Some("https://runtime.example.com/base/".to_string()),
7649 public_surface_policy: "enabled".to_string(),
7650 ..Default::default()
7651 },
7652 )?;
7653 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7654 "version": 1,
7655 "id": "example-machine",
7656 "entry_step": "public_url",
7657 "steps": [
7658 {
7659 "id": "public_url",
7660 "kind": "platform_public_url",
7661 "on_success": "complete"
7662 }
7663 ]
7664 }))?;
7665
7666 let output = advance_setup_machine(
7667 temp.path(),
7668 "messaging-example",
7669 "demo",
7670 "default",
7671 &machine,
7672 false,
7673 )?;
7674
7675 assert_eq!(output["ok"], true);
7676 let state = load_setup_machine_state(&setup_machine_state_path(
7677 temp.path(),
7678 "demo",
7679 "default",
7680 "messaging-example",
7681 ))?;
7682 assert_eq!(state.status, SetupMachineStatus::Complete);
7683 assert_eq!(
7684 state.outputs["public_base_url"],
7685 "https://runtime.example.com/base"
7686 );
7687 Ok(())
7688 }
7689
7690 #[test]
7691 fn advance_setup_machine_blocks_platform_public_url_when_missing() -> anyhow::Result<()> {
7692 let temp = tempfile::tempdir()?;
7693 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7694 "version": 1,
7695 "id": "example-machine",
7696 "entry_step": "public_url",
7697 "steps": [
7698 {
7699 "id": "public_url",
7700 "kind": "platform_public_url"
7701 }
7702 ]
7703 }))?;
7704
7705 let output = advance_setup_machine(
7706 temp.path(),
7707 "messaging-example",
7708 "demo",
7709 "default",
7710 &machine,
7711 false,
7712 )?;
7713
7714 assert_eq!(output["ok"], false);
7715 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7716 let state = load_setup_machine_state(&setup_machine_state_path(
7717 temp.path(),
7718 "demo",
7719 "default",
7720 "messaging-example",
7721 ))?;
7722 assert_eq!(state.status, SetupMachineStatus::Paused);
7723 assert_eq!(state.current_step.as_deref(), Some("public_url"));
7724 Ok(())
7725 }
7726
7727 #[test]
7728 fn advance_setup_machine_cloudflare_tunnel_resolves_existing_https_url() -> anyhow::Result<()> {
7729 let temp = tempfile::tempdir()?;
7730 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7731 "version": 1,
7732 "id": "example-machine",
7733 "entry_step": "tunnel",
7734 "steps": [
7735 {
7736 "id": "tunnel",
7737 "kind": "cloudflare_tunnel",
7738 "public_base_url": "https://setup.trycloudflare.com",
7739 "on_success": "complete"
7740 }
7741 ]
7742 }))?;
7743
7744 let output = advance_setup_machine(
7745 temp.path(),
7746 "messaging-example",
7747 "demo",
7748 "default",
7749 &machine,
7750 false,
7751 )?;
7752
7753 assert_eq!(output["ok"], true);
7754 assert_eq!(output["result"]["detail"]["ephemeral"], true);
7755 let state = load_setup_machine_state(&setup_machine_state_path(
7756 temp.path(),
7757 "demo",
7758 "default",
7759 "messaging-example",
7760 ))?;
7761 assert_eq!(state.status, SetupMachineStatus::Complete);
7762 assert_eq!(
7763 state.outputs["public_base_url"],
7764 "https://setup.trycloudflare.com"
7765 );
7766 Ok(())
7767 }
7768
7769 #[test]
7770 fn advance_setup_machine_cloudflare_tunnel_blocks_when_missing() -> anyhow::Result<()> {
7771 let temp = tempfile::tempdir()?;
7772 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7773 "version": 1,
7774 "id": "example-machine",
7775 "entry_step": "tunnel",
7776 "steps": [
7777 {
7778 "id": "tunnel",
7779 "kind": "cloudflare_tunnel"
7780 }
7781 ]
7782 }))?;
7783
7784 let output = advance_setup_machine(
7785 temp.path(),
7786 "messaging-example",
7787 "demo",
7788 "default",
7789 &machine,
7790 false,
7791 )?;
7792
7793 assert_eq!(output["ok"], false);
7794 assert_eq!(output["result"]["detail"]["error"], "tunnel_unreachable");
7795 let state = load_setup_machine_state(&setup_machine_state_path(
7796 temp.path(),
7797 "demo",
7798 "default",
7799 "messaging-example",
7800 ))?;
7801 assert_eq!(state.status, SetupMachineStatus::Paused);
7802 assert_eq!(state.current_step.as_deref(), Some("tunnel"));
7803 Ok(())
7804 }
7805
7806 #[test]
7807 fn advance_setup_machine_oauth_authorization_code_renders_signed_url() -> anyhow::Result<()> {
7808 let temp = tempfile::tempdir()?;
7809 let state = SetupMachineState {
7810 schema_version: 1,
7811 provider_id: "messaging-example".to_string(),
7812 tenant: "demo".to_string(),
7813 team: "default".to_string(),
7814 machine_id: "example-machine".to_string(),
7815 machine_version: 1,
7816 status: SetupMachineStatus::Running,
7817 current_step: Some("oauth".to_string()),
7818 completed_steps: Vec::new(),
7819 failed_step: None,
7820 answers_hash: None,
7821 pack_fingerprint: None,
7822 outputs: serde_json::json!({
7823 "public_base_url": "https://runtime.example.com",
7824 "client_id": "client-123"
7825 }),
7826 created_resources: Vec::new(),
7827 last_error: None,
7828 updated_at: None,
7829 };
7830 write_setup_machine_state(temp.path(), &state)?;
7831 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7832 "version": 1,
7833 "id": "example-machine",
7834 "entry_step": "oauth",
7835 "steps": [
7836 {
7837 "id": "oauth",
7838 "kind": "oauth_authorization_code",
7839 "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7840 "token_url": "https://login.example.com/oauth2/v2.0/token",
7841 "callback_path": "/oauth/callback",
7842 "scopes": ["User.Read", "offline_access"],
7843 "params": {"prompt": "consent"}
7844 }
7845 ]
7846 }))?;
7847
7848 let output = advance_setup_machine(
7849 temp.path(),
7850 "messaging-example",
7851 "demo",
7852 "default",
7853 &machine,
7854 false,
7855 )?;
7856
7857 assert_eq!(output["ok"], false);
7858 assert_eq!(
7859 output["result"]["detail"]["reason"],
7860 "oauth_authorization_required"
7861 );
7862 let state = load_setup_machine_state(&setup_machine_state_path(
7863 temp.path(),
7864 "demo",
7865 "default",
7866 "messaging-example",
7867 ))?;
7868 assert_eq!(state.status, SetupMachineStatus::Paused);
7869 let authorization_url = state.outputs["authorization_url"]
7870 .as_str()
7871 .expect("authorization_url");
7872 let parsed = url::Url::parse(authorization_url)?;
7873 let params: std::collections::BTreeMap<_, _> = parsed.query_pairs().collect();
7874 assert_eq!(
7875 params.get("client_id").map(|v| v.as_ref()),
7876 Some("client-123")
7877 );
7878 assert_eq!(
7879 params.get("redirect_uri").map(|v| v.as_ref()),
7880 Some("https://runtime.example.com/oauth/callback")
7881 );
7882 assert_eq!(
7883 params.get("scope").map(|v| v.as_ref()),
7884 Some("User.Read offline_access")
7885 );
7886 assert_eq!(params.get("prompt").map(|v| v.as_ref()), Some("consent"));
7887 let signed_state = params.get("state").expect("state");
7888 let key = crate::setup_actions::load_or_create_signing_key(temp.path())?;
7889 let payload = crate::setup_actions::validate_oauth_state(
7890 signed_state,
7891 &key,
7892 Some("messaging-example"),
7893 Some("demo"),
7894 Some("default"),
7895 crate::setup_actions::current_epoch_secs(),
7896 )?;
7897 assert_eq!(payload.action_id, "oauth");
7898 Ok(())
7899 }
7900
7901 #[test]
7902 fn advance_setup_machine_oauth_authorization_code_blocks_without_public_url()
7903 -> anyhow::Result<()> {
7904 let temp = tempfile::tempdir()?;
7905 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7906 "version": 1,
7907 "id": "example-machine",
7908 "entry_step": "oauth",
7909 "steps": [
7910 {
7911 "id": "oauth",
7912 "kind": "oauth_authorization_code",
7913 "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7914 "token_url": "https://login.example.com/oauth2/v2.0/token",
7915 "callback_path": "/oauth/callback"
7916 }
7917 ]
7918 }))?;
7919
7920 let output = advance_setup_machine(
7921 temp.path(),
7922 "messaging-example",
7923 "demo",
7924 "default",
7925 &machine,
7926 false,
7927 )?;
7928
7929 assert_eq!(output["ok"], false);
7930 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7931 assert_eq!(
7932 output["result"]["detail"]["missing_config_key"],
7933 "public_base_url"
7934 );
7935 let state = load_setup_machine_state(&setup_machine_state_path(
7936 temp.path(),
7937 "demo",
7938 "default",
7939 "messaging-example",
7940 ))?;
7941 assert_eq!(state.status, SetupMachineStatus::Paused);
7942 assert_eq!(state.current_step.as_deref(), Some("oauth"));
7943 Ok(())
7944 }
7945
7946 #[tokio::test]
7947 async fn complete_setup_machine_oauth_authorization_code_persists_tokens_as_secrets()
7948 -> anyhow::Result<()> {
7949 let temp = tempfile::tempdir()?;
7950 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7951 "version": 1,
7952 "id": "example-machine",
7953 "entry_step": "oauth",
7954 "steps": [
7955 {
7956 "id": "oauth",
7957 "kind": "oauth_authorization_code",
7958 "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7959 "token_url": "https://login.example.com/oauth2/v2.0/token",
7960 "token_store_key": "EXAMPLE_ACCESS_TOKEN",
7961 "refresh_token_store_key": "EXAMPLE_REFRESH_TOKEN",
7962 "id_token_store_key": "EXAMPLE_ID_TOKEN",
7963 "output_key": "oauth_result",
7964 "on_success": "complete"
7965 }
7966 ]
7967 }))?;
7968 let state = SetupMachineState {
7969 schema_version: 1,
7970 provider_id: "messaging-example".to_string(),
7971 tenant: "demo".to_string(),
7972 team: "default".to_string(),
7973 machine_id: "example-machine".to_string(),
7974 machine_version: 1,
7975 status: SetupMachineStatus::Paused,
7976 current_step: Some("oauth".to_string()),
7977 completed_steps: Vec::new(),
7978 failed_step: None,
7979 answers_hash: None,
7980 pack_fingerprint: None,
7981 outputs: serde_json::json!({}),
7982 created_resources: Vec::new(),
7983 last_error: Some(serde_json::json!({
7984 "error": "manual_action_required",
7985 "reason": "oauth_authorization_required",
7986 })),
7987 updated_at: None,
7988 };
7989 write_setup_machine_state(temp.path(), &state)?;
7990 let oauth_state = crate::setup_actions::OAuthStatePayload {
7991 provider_id: "messaging-example".into(),
7992 tenant: "demo".into(),
7993 team: "default".into(),
7994 action_id: "oauth".into(),
7995 nonce: "nonce".into(),
7996 expires_at: crate::setup_actions::current_epoch_secs() + 60,
7997 };
7998
7999 let mut report = complete_setup_machine_oauth_authorization_code_for_machine(
8000 temp.path(),
8001 "dev",
8002 None,
8003 &machine,
8004 &oauth_state,
8005 &serde_json::json!({
8006 "access_token": "access-secret",
8007 "refresh_token": "refresh-secret",
8008 "id_token": "id-secret",
8009 }),
8010 )
8011 .await?;
8012
8013 report.persisted_secret_keys.sort();
8014 assert_eq!(
8015 report.persisted_secret_keys,
8016 vec![
8017 "EXAMPLE_ACCESS_TOKEN",
8018 "EXAMPLE_ID_TOKEN",
8019 "EXAMPLE_REFRESH_TOKEN"
8020 ]
8021 );
8022 let state_path =
8023 setup_machine_state_path(temp.path(), "demo", "default", "messaging-example");
8024 let stored_state = std::fs::read_to_string(&state_path)?;
8025 assert!(!stored_state.contains("access-secret"));
8026 assert!(!stored_state.contains("refresh-secret"));
8027 assert!(!stored_state.contains("id-secret"));
8028
8029 let state = load_setup_machine_state(&state_path)?;
8030 assert_eq!(state.status, SetupMachineStatus::Complete);
8031 assert_eq!(state.outputs["oauth_result"]["ok"], true);
8032
8033 let store = crate::secrets::open_dev_store(temp.path())?;
8034 for (key, expected) in [
8035 ("EXAMPLE_ACCESS_TOKEN", "access-secret"),
8036 ("EXAMPLE_REFRESH_TOKEN", "refresh-secret"),
8037 ("EXAMPLE_ID_TOKEN", "id-secret"),
8038 ] {
8039 let uri = crate::canonical_secret_uri(
8040 "dev",
8041 "demo",
8042 Some("default"),
8043 "messaging-example",
8044 key,
8045 );
8046 let bytes = store.get(&uri).await?;
8047 assert_eq!(String::from_utf8(bytes)?, expected);
8048 }
8049 Ok(())
8050 }
8051
8052 #[test]
8053 fn advance_setup_machine_oauth_device_code_starts_then_polls_complete() -> anyhow::Result<()> {
8054 let temp = tempfile::tempdir()?;
8055 let base = serve_json_sequence(vec![
8056 (
8057 200,
8058 r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8059 ),
8060 (
8061 200,
8062 r#"{"access_token":"access-token-secret","refresh_token":"refresh-token-secret"}"#,
8063 ),
8064 ]);
8065 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8066 "version": 1,
8067 "id": "example-machine",
8068 "entry_step": "device",
8069 "steps": [
8070 {
8071 "id": "device",
8072 "kind": "oauth_device_code",
8073 "device_code_url": format!("{base}/device"),
8074 "token_url": format!("{base}/token"),
8075 "client_id": "client-123",
8076 "scopes": ["User.Read"],
8077 "token_store_key": "graph_access_token",
8078 "refresh_token_store_key": "graph_refresh_token",
8079 "on_success": "complete"
8080 }
8081 ]
8082 }))?;
8083
8084 let started = advance_setup_machine(
8085 temp.path(),
8086 "messaging-example",
8087 "demo",
8088 "default",
8089 &machine,
8090 false,
8091 )?;
8092
8093 assert_eq!(started["ok"], false);
8094 assert_eq!(
8095 started["result"]["detail"]["reason"],
8096 "oauth_device_code_pending"
8097 );
8098 let state_path =
8099 setup_machine_state_path(temp.path(), "demo", "default", "messaging-example");
8100 let raw_state = std::fs::read_to_string(&state_path)?;
8101 assert!(!raw_state.contains("raw-device-code"));
8102 assert!(!raw_state.contains("access-token-secret"));
8103 let state = load_setup_machine_state(&state_path)?;
8104 assert_eq!(state.status, SetupMachineStatus::Paused);
8105 assert_eq!(
8106 state.outputs["oauth_device_login"]["user_code"],
8107 "ABCD-EFGH"
8108 );
8109 assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8110
8111 let completed = advance_setup_machine(
8112 temp.path(),
8113 "messaging-example",
8114 "demo",
8115 "default",
8116 &machine,
8117 false,
8118 )?;
8119
8120 assert_eq!(completed["ok"], true);
8121 let state = load_setup_machine_state(&state_path)?;
8122 assert_eq!(state.status, SetupMachineStatus::Complete);
8123 assert_eq!(
8124 state.outputs["oauth_device_login"]["token_store_key"],
8125 "graph_access_token"
8126 );
8127 assert!(!setup_machine_oauth_device_session_path(temp.path(), &state, "device").exists());
8128 let raw_state = std::fs::read_to_string(&state_path)?;
8129 assert!(!raw_state.contains("access-token-secret"));
8130 assert!(!raw_state.contains("refresh-token-secret"));
8131 Ok(())
8132 }
8133
8134 #[test]
8135 fn advance_setup_machine_oauth_device_code_keeps_pending_resumable() -> anyhow::Result<()> {
8136 let temp = tempfile::tempdir()?;
8137 let base = serve_json_sequence(vec![
8138 (
8139 200,
8140 r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8141 ),
8142 (200, r#"{"error":"authorization_pending"}"#),
8143 ]);
8144 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8145 "version": 1,
8146 "id": "example-machine",
8147 "entry_step": "device",
8148 "steps": [
8149 {
8150 "id": "device",
8151 "kind": "oauth_device_code",
8152 "device_code_url": format!("{base}/device"),
8153 "token_url": format!("{base}/token"),
8154 "client_id": "client-123",
8155 "scope": "User.Read",
8156 "token_store_key": "graph_access_token",
8157 "on_success": "complete"
8158 }
8159 ]
8160 }))?;
8161
8162 let _ = advance_setup_machine(
8163 temp.path(),
8164 "messaging-example",
8165 "demo",
8166 "default",
8167 &machine,
8168 false,
8169 )?;
8170 let pending = advance_setup_machine(
8171 temp.path(),
8172 "messaging-example",
8173 "demo",
8174 "default",
8175 &machine,
8176 false,
8177 )?;
8178
8179 assert_eq!(pending["ok"], false);
8180 assert_eq!(pending["result"]["detail"]["error"], "oauth_pending");
8181 let state = load_setup_machine_state(&setup_machine_state_path(
8182 temp.path(),
8183 "demo",
8184 "default",
8185 "messaging-example",
8186 ))?;
8187 assert_eq!(state.status, SetupMachineStatus::Paused);
8188 assert_eq!(state.current_step.as_deref(), Some("device"));
8189 assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8190 Ok(())
8191 }
8192
8193 #[test]
8194 fn advance_setup_machine_http_json_stores_response_and_advances() -> anyhow::Result<()> {
8195 let temp = tempfile::tempdir()?;
8196 let url = serve_one_json(200, r#"{"registered":true,"tenant":"demo"}"#);
8197 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8198 "version": 1,
8199 "id": "example-machine",
8200 "entry_step": "register",
8201 "steps": [
8202 {
8203 "id": "register",
8204 "kind": "http_json",
8205 "url_template": url,
8206 "output_key": "registration",
8207 "on_success": "complete"
8208 }
8209 ]
8210 }))?;
8211
8212 let output = advance_setup_machine(
8213 temp.path(),
8214 "messaging-example",
8215 "demo",
8216 "default",
8217 &machine,
8218 false,
8219 )?;
8220
8221 assert_eq!(output["ok"], true);
8222 let state = load_setup_machine_state(&setup_machine_state_path(
8223 temp.path(),
8224 "demo",
8225 "default",
8226 "messaging-example",
8227 ))?;
8228 assert_eq!(state.status, SetupMachineStatus::Complete);
8229 assert_eq!(state.outputs["registration"]["registered"], true);
8230 assert_eq!(state.outputs["registration"]["tenant"], "demo");
8231 Ok(())
8232 }
8233
8234 #[test]
8235 fn advance_setup_machine_http_probe_blocks_on_unexpected_status() -> anyhow::Result<()> {
8236 let temp = tempfile::tempdir()?;
8237 let url = serve_one_json(503, r#"{"ok":false}"#);
8238 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8239 "version": 1,
8240 "id": "example-machine",
8241 "entry_step": "probe",
8242 "steps": [
8243 {
8244 "id": "probe",
8245 "kind": "http_probe",
8246 "url_template": url,
8247 "expected_status": 200
8248 }
8249 ]
8250 }))?;
8251
8252 let output = advance_setup_machine(
8253 temp.path(),
8254 "messaging-example",
8255 "demo",
8256 "default",
8257 &machine,
8258 false,
8259 )?;
8260
8261 assert_eq!(output["ok"], false);
8262 assert_eq!(
8263 output["result"]["detail"]["error"],
8264 "provider_contract_error"
8265 );
8266 assert_eq!(output["result"]["detail"]["status"], 503);
8267 let state = load_setup_machine_state(&setup_machine_state_path(
8268 temp.path(),
8269 "demo",
8270 "default",
8271 "messaging-example",
8272 ))?;
8273 assert_eq!(state.status, SetupMachineStatus::Paused);
8274 assert_eq!(state.current_step.as_deref(), Some("probe"));
8275 Ok(())
8276 }
8277
8278 #[test]
8279 fn advance_setup_machine_select_from_json_auto_selects_single_option() -> anyhow::Result<()> {
8280 let temp = tempfile::tempdir()?;
8281 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8282 "version": 1,
8283 "id": "example-machine",
8284 "entry_step": "select_team",
8285 "steps": [
8286 {
8287 "id": "select_team",
8288 "kind": "select_from_json",
8289 "source_output": "teams_response",
8290 "json_pointer": "/value",
8291 "value_key": "id",
8292 "output_key": "team_id",
8293 "on_success": "complete"
8294 }
8295 ]
8296 }))?;
8297 let state = SetupMachineState {
8298 schema_version: 1,
8299 provider_id: "messaging-example".to_string(),
8300 tenant: "demo".to_string(),
8301 team: "default".to_string(),
8302 machine_id: "example-machine".to_string(),
8303 machine_version: 1,
8304 status: SetupMachineStatus::Running,
8305 current_step: Some("select_team".to_string()),
8306 completed_steps: Vec::new(),
8307 failed_step: None,
8308 answers_hash: None,
8309 pack_fingerprint: None,
8310 outputs: serde_json::json!({
8311 "teams_response": {
8312 "value": [
8313 {"id": "team-123", "displayName": "Engineering"}
8314 ]
8315 }
8316 }),
8317 created_resources: Vec::new(),
8318 last_error: None,
8319 updated_at: None,
8320 };
8321 write_setup_machine_state(temp.path(), &state)?;
8322
8323 let output = advance_setup_machine(
8324 temp.path(),
8325 "messaging-example",
8326 "demo",
8327 "default",
8328 &machine,
8329 false,
8330 )?;
8331
8332 assert_eq!(output["ok"], true);
8333 let state = load_setup_machine_state(&setup_machine_state_path(
8334 temp.path(),
8335 "demo",
8336 "default",
8337 "messaging-example",
8338 ))?;
8339 assert_eq!(state.status, SetupMachineStatus::Complete);
8340 assert_eq!(state.outputs["team_id"], "team-123");
8341 assert_eq!(state.outputs["team_id_item"]["displayName"], "Engineering");
8342 Ok(())
8343 }
8344
8345 #[test]
8346 fn advance_setup_machine_select_from_json_pauses_for_multiple_options() -> anyhow::Result<()> {
8347 let temp = tempfile::tempdir()?;
8348 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8349 "version": 1,
8350 "id": "example-machine",
8351 "entry_step": "select_team",
8352 "steps": [
8353 {
8354 "id": "select_team",
8355 "kind": "select_from_json",
8356 "source_output": "teams_response",
8357 "json_pointer": "/value",
8358 "value_key": "id",
8359 "output_key": "team_id"
8360 }
8361 ]
8362 }))?;
8363 let state = SetupMachineState {
8364 schema_version: 1,
8365 provider_id: "messaging-example".to_string(),
8366 tenant: "demo".to_string(),
8367 team: "default".to_string(),
8368 machine_id: "example-machine".to_string(),
8369 machine_version: 1,
8370 status: SetupMachineStatus::Running,
8371 current_step: Some("select_team".to_string()),
8372 completed_steps: Vec::new(),
8373 failed_step: None,
8374 answers_hash: None,
8375 pack_fingerprint: None,
8376 outputs: serde_json::json!({
8377 "teams_response": {
8378 "value": [
8379 {"id": "team-123", "displayName": "Engineering"},
8380 {"id": "team-456", "displayName": "Support"}
8381 ]
8382 }
8383 }),
8384 created_resources: Vec::new(),
8385 last_error: None,
8386 updated_at: None,
8387 };
8388 write_setup_machine_state(temp.path(), &state)?;
8389
8390 let output = advance_setup_machine(
8391 temp.path(),
8392 "messaging-example",
8393 "demo",
8394 "default",
8395 &machine,
8396 false,
8397 )?;
8398
8399 assert_eq!(output["ok"], false);
8400 assert_eq!(output["result"]["detail"]["reason"], "selection_required");
8401 assert_eq!(output["result"]["detail"]["options"], 2);
8402 let state = load_setup_machine_state(&setup_machine_state_path(
8403 temp.path(),
8404 "demo",
8405 "default",
8406 "messaging-example",
8407 ))?;
8408 assert_eq!(state.status, SetupMachineStatus::Paused);
8409 assert_eq!(state.current_step.as_deref(), Some("select_team"));
8410 Ok(())
8411 }
8412
8413 #[test]
8414 fn advance_setup_machine_generate_file_writes_artifact_and_records_resource()
8415 -> anyhow::Result<()> {
8416 let temp = tempfile::tempdir()?;
8417 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8418 "version": 1,
8419 "id": "example-machine",
8420 "entry_step": "generate",
8421 "steps": [
8422 {
8423 "id": "generate",
8424 "kind": "generate_file",
8425 "artifact_path": "artifacts/app/config.json",
8426 "output_key": "config_artifact",
8427 "content": {
8428 "tenant": "{tenant}",
8429 "team": "{team}",
8430 "provider": "{provider_id}",
8431 "public_base_url": "{public_base_url}"
8432 },
8433 "on_success": "complete"
8434 }
8435 ]
8436 }))?;
8437 let state = SetupMachineState {
8438 schema_version: 1,
8439 provider_id: "messaging-example".to_string(),
8440 tenant: "demo".to_string(),
8441 team: "default".to_string(),
8442 machine_id: "example-machine".to_string(),
8443 machine_version: 1,
8444 status: SetupMachineStatus::Running,
8445 current_step: Some("generate".to_string()),
8446 completed_steps: Vec::new(),
8447 failed_step: None,
8448 answers_hash: None,
8449 pack_fingerprint: None,
8450 outputs: serde_json::json!({
8451 "public_base_url": "https://runtime.example.com"
8452 }),
8453 created_resources: Vec::new(),
8454 last_error: None,
8455 updated_at: None,
8456 };
8457 write_setup_machine_state(temp.path(), &state)?;
8458
8459 let output = advance_setup_machine(
8460 temp.path(),
8461 "messaging-example",
8462 "demo",
8463 "default",
8464 &machine,
8465 false,
8466 )?;
8467
8468 assert_eq!(output["ok"], true);
8469 let artifact_path =
8470 setup_machine_state_dir(temp.path(), "demo", "default", "messaging-example")
8471 .join("artifacts/app/config.json");
8472 let artifact = std::fs::read_to_string(artifact_path)?;
8473 let artifact: Value = serde_json::from_str(&artifact)?;
8474 assert_eq!(artifact["tenant"], "demo");
8475 assert_eq!(artifact["team"], "default");
8476 assert_eq!(artifact["provider"], "messaging-example");
8477 assert_eq!(artifact["public_base_url"], "https://runtime.example.com");
8478
8479 let state = load_setup_machine_state(&setup_machine_state_path(
8480 temp.path(),
8481 "demo",
8482 "default",
8483 "messaging-example",
8484 ))?;
8485 assert_eq!(state.status, SetupMachineStatus::Complete);
8486 assert_eq!(
8487 state.outputs["config_artifact"],
8488 "artifacts/app/config.json"
8489 );
8490 assert_eq!(
8491 state
8492 .created_resources
8493 .first()
8494 .and_then(|value| value.get("path")),
8495 Some(&Value::String("artifacts/app/config.json".to_string()))
8496 );
8497 Ok(())
8498 }
8499
8500 #[test]
8501 fn advance_setup_machine_generate_file_blocks_without_content() -> anyhow::Result<()> {
8502 let temp = tempfile::tempdir()?;
8503 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8504 "version": 1,
8505 "id": "example-machine",
8506 "entry_step": "generate",
8507 "steps": [
8508 {
8509 "id": "generate",
8510 "kind": "generate_file",
8511 "artifact_path": "artifacts/app/config.json"
8512 }
8513 ]
8514 }))?;
8515
8516 let output = advance_setup_machine(
8517 temp.path(),
8518 "messaging-example",
8519 "demo",
8520 "default",
8521 &machine,
8522 false,
8523 )?;
8524
8525 assert_eq!(output["ok"], false);
8526 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8527 let state = load_setup_machine_state(&setup_machine_state_path(
8528 temp.path(),
8529 "demo",
8530 "default",
8531 "messaging-example",
8532 ))?;
8533 assert_eq!(state.status, SetupMachineStatus::Paused);
8534 assert_eq!(state.current_step.as_deref(), Some("generate"));
8535 assert_eq!(
8536 state
8537 .last_error
8538 .as_ref()
8539 .and_then(|value| value.get("error")),
8540 Some(&Value::String("missing_prerequisite".to_string()))
8541 );
8542 Ok(())
8543 }
8544
8545 #[test]
8546 fn advance_setup_machine_persist_runtime_config_writes_setup_answers_and_strips_server_owned()
8547 -> anyhow::Result<()> {
8548 let temp = tempfile::tempdir()?;
8549 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8550 "version": 1,
8551 "id": "example-machine",
8552 "entry_step": "persist",
8553 "server_owned_config_keys": ["graph_access_token"],
8554 "steps": [
8555 {
8556 "id": "persist",
8557 "kind": "persist_runtime_config",
8558 "config": {
8559 "bot_app_id": "{bot_app_id}",
8560 "team_id": "{team_id}",
8561 "graph_access_token": "{graph_access_token}"
8562 },
8563 "on_success": "complete"
8564 }
8565 ]
8566 }))?;
8567 let state = SetupMachineState {
8568 schema_version: 1,
8569 provider_id: "messaging-example".to_string(),
8570 tenant: "demo".to_string(),
8571 team: "default".to_string(),
8572 machine_id: "example-machine".to_string(),
8573 machine_version: 1,
8574 status: SetupMachineStatus::Running,
8575 current_step: Some("persist".to_string()),
8576 completed_steps: Vec::new(),
8577 failed_step: None,
8578 answers_hash: None,
8579 pack_fingerprint: None,
8580 outputs: serde_json::json!({
8581 "bot_app_id": "app-123",
8582 "team_id": "team-123",
8583 "graph_access_token": "secret-token"
8584 }),
8585 created_resources: Vec::new(),
8586 last_error: None,
8587 updated_at: None,
8588 };
8589 write_setup_machine_state(temp.path(), &state)?;
8590
8591 let output = advance_setup_machine(
8592 temp.path(),
8593 "messaging-example",
8594 "demo",
8595 "default",
8596 &machine,
8597 false,
8598 )?;
8599
8600 assert_eq!(output["ok"], true);
8601 assert_eq!(output["result"]["detail"]["envelope_skipped"], true);
8602 let config_path = temp
8603 .path()
8604 .join("state/config/messaging-example/setup-answers.json");
8605 let config: Value = serde_json::from_str(&std::fs::read_to_string(config_path)?)?;
8606 assert_eq!(config["bot_app_id"], "app-123");
8607 assert_eq!(config["team_id"], "team-123");
8608 assert!(config.get("graph_access_token").is_none());
8609 let state = load_setup_machine_state(&setup_machine_state_path(
8610 temp.path(),
8611 "demo",
8612 "default",
8613 "messaging-example",
8614 ))?;
8615 assert_eq!(state.status, SetupMachineStatus::Complete);
8616 assert_eq!(
8617 state
8618 .created_resources
8619 .first()
8620 .and_then(|value| value.get("kind")),
8621 Some(&Value::String("runtime_config".to_string()))
8622 );
8623 Ok(())
8624 }
8625
8626 #[test]
8627 fn advance_setup_machine_persist_runtime_config_blocks_without_config() -> anyhow::Result<()> {
8628 let temp = tempfile::tempdir()?;
8629 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8630 "version": 1,
8631 "id": "example-machine",
8632 "entry_step": "persist",
8633 "steps": [
8634 {
8635 "id": "persist",
8636 "kind": "persist_runtime_config"
8637 }
8638 ]
8639 }))?;
8640
8641 let output = advance_setup_machine(
8642 temp.path(),
8643 "messaging-example",
8644 "demo",
8645 "default",
8646 &machine,
8647 false,
8648 )?;
8649
8650 assert_eq!(output["ok"], false);
8651 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8652 let state = load_setup_machine_state(&setup_machine_state_path(
8653 temp.path(),
8654 "demo",
8655 "default",
8656 "messaging-example",
8657 ))?;
8658 assert_eq!(state.status, SetupMachineStatus::Paused);
8659 assert_eq!(state.current_step.as_deref(), Some("persist"));
8660 Ok(())
8661 }
8662
8663 #[test]
8664 fn advance_setup_machine_provider_component_call_blocks_without_pack_path() -> anyhow::Result<()>
8665 {
8666 let temp = tempfile::tempdir()?;
8667 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8668 "version": 1,
8669 "id": "example-machine",
8670 "entry_step": "register",
8671 "steps": [
8672 {
8673 "id": "register",
8674 "kind": "provider_component_call",
8675 "component_ref": "messaging-example",
8676 "op": "register",
8677 "request": {"tenant": "{tenant}"}
8678 }
8679 ]
8680 }))?;
8681
8682 let output = advance_setup_machine(
8683 temp.path(),
8684 "messaging-example",
8685 "demo",
8686 "default",
8687 &machine,
8688 false,
8689 )?;
8690
8691 assert_eq!(output["ok"], false);
8692 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8693 assert_eq!(
8694 output["result"]["detail"]["missing_config_key"],
8695 "pack_path"
8696 );
8697 let state = load_setup_machine_state(&setup_machine_state_path(
8698 temp.path(),
8699 "demo",
8700 "default",
8701 "messaging-example",
8702 ))?;
8703 assert_eq!(state.status, SetupMachineStatus::Paused);
8704 assert_eq!(state.current_step.as_deref(), Some("register"));
8705 Ok(())
8706 }
8707
8708 #[test]
8709 fn retry_setup_machine_step_clears_paused_error_without_losing_completed_steps()
8710 -> anyhow::Result<()> {
8711 let temp = tempfile::tempdir()?;
8712 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8713 "version": 1,
8714 "id": "example-machine",
8715 "entry_step": "collect",
8716 "steps": [
8717 {"id": "collect", "kind": "qa_form", "on_success": "manual"},
8718 {"id": "manual", "kind": "manual_action"}
8719 ]
8720 }))?;
8721 let state = SetupMachineState {
8722 schema_version: 1,
8723 provider_id: "messaging-example".to_string(),
8724 tenant: "demo".to_string(),
8725 team: "default".to_string(),
8726 machine_id: "example-machine".to_string(),
8727 machine_version: 1,
8728 status: SetupMachineStatus::Paused,
8729 current_step: Some("manual".to_string()),
8730 completed_steps: vec!["collect".to_string()],
8731 failed_step: Some("manual".to_string()),
8732 answers_hash: None,
8733 pack_fingerprint: None,
8734 outputs: serde_json::json!({}),
8735 created_resources: Vec::new(),
8736 last_error: Some(serde_json::json!({"error": "manual_action_required"})),
8737 updated_at: None,
8738 };
8739 write_setup_machine_state(temp.path(), &state)?;
8740
8741 let output = retry_setup_machine_step(
8742 temp.path(),
8743 "messaging-example",
8744 "demo",
8745 "default",
8746 &machine,
8747 None,
8748 )?;
8749
8750 assert_eq!(output["changed"], true);
8751 assert_eq!(output["step"], "manual");
8752 let loaded = load_setup_machine_state(&setup_machine_state_path(
8753 temp.path(),
8754 "demo",
8755 "default",
8756 "messaging-example",
8757 ))?;
8758 assert_eq!(loaded.status, SetupMachineStatus::Running);
8759 assert_eq!(loaded.current_step.as_deref(), Some("manual"));
8760 assert_eq!(loaded.completed_steps, vec!["collect"]);
8761 assert!(loaded.last_error.is_none());
8762 assert!(loaded.failed_step.is_none());
8763 Ok(())
8764 }
8765
8766 #[test]
8767 fn reset_setup_machine_state_archives_and_removes_machine_json() -> anyhow::Result<()> {
8768 let temp = tempfile::tempdir()?;
8769 let state = SetupMachineState {
8770 schema_version: 1,
8771 provider_id: "messaging-example".to_string(),
8772 tenant: "demo".to_string(),
8773 team: "default".to_string(),
8774 machine_id: "example-machine".to_string(),
8775 machine_version: 1,
8776 status: SetupMachineStatus::Paused,
8777 current_step: Some("manual".to_string()),
8778 completed_steps: Vec::new(),
8779 failed_step: None,
8780 answers_hash: None,
8781 pack_fingerprint: None,
8782 outputs: serde_json::json!({}),
8783 created_resources: Vec::new(),
8784 last_error: None,
8785 updated_at: None,
8786 };
8787 let state_path = write_setup_machine_state(temp.path(), &state)?;
8788
8789 let archive = reset_setup_machine_state(
8790 temp.path(),
8791 "demo",
8792 "default",
8793 "messaging-example",
8794 "manual reset",
8795 )?
8796 .expect("archive");
8797
8798 assert!(!state_path.exists());
8799 assert!(archive.is_file());
8800 let events = std::fs::read_to_string(setup_machine_events_path(
8801 temp.path(),
8802 "demo",
8803 "default",
8804 "messaging-example",
8805 ))?;
8806 assert!(events.contains("setup.machine.reset"));
8807 Ok(())
8808 }
8809
8810 #[test]
8811 fn migrate_setup_machine_state_archives_legacy_artifacts_and_initializes_machine()
8812 -> anyhow::Result<()> {
8813 let temp = tempfile::tempdir()?;
8814 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8815 "version": 1,
8816 "id": "example-machine",
8817 "entry_step": "start",
8818 "steps": [
8819 {
8820 "id": "start",
8821 "kind": "manual_action",
8822 "on_success": "complete"
8823 }
8824 ]
8825 }))?;
8826 let legacy_backend = crate::setup_backend_contract::legacy_backend_state_path(
8827 temp.path(),
8828 "dev",
8829 "demo",
8830 "default",
8831 "messaging-example",
8832 )?;
8833 std::fs::create_dir_all(legacy_backend.parent().unwrap())?;
8834 std::fs::write(&legacy_backend, r#"{"config":{"bot_app_id":"old"}}"#)?;
8835 let legacy_actions = crate::setup_actions::setup_actions_state_path(
8836 temp.path(),
8837 "demo",
8838 "default",
8839 "messaging-example",
8840 );
8841 std::fs::create_dir_all(legacy_actions.parent().unwrap())?;
8842 std::fs::write(&legacy_actions, r#"{"actions":[]}"#)?;
8843
8844 let migration = migrate_setup_machine_state(
8845 temp.path(),
8846 "dev",
8847 "demo",
8848 "default",
8849 "messaging-example",
8850 &machine,
8851 )?;
8852
8853 assert_eq!(migration.source, "legacy-archived");
8854 assert!(migration.initialized);
8855 assert!(migration.legacy_backend_removed);
8856 assert!(migration.setup_actions_removed);
8857 assert!(!legacy_backend.exists());
8858 assert!(!legacy_actions.exists());
8859 assert!(migration.backend_archive_path.unwrap().is_file());
8860 assert!(migration.setup_actions_archive_path.unwrap().is_file());
8861 let state = load_setup_machine_state(&migration.state_path)?;
8862 assert_eq!(state.status, SetupMachineStatus::NotStarted);
8863 assert_eq!(state.current_step.as_deref(), Some("start"));
8864 assert!(migration.event_path.is_file());
8865
8866 let repeated = migrate_setup_machine_state(
8867 temp.path(),
8868 "dev",
8869 "demo",
8870 "default",
8871 "messaging-example",
8872 &machine,
8873 )?;
8874 assert_eq!(repeated.source, "machine");
8875 assert!(!repeated.initialized);
8876 assert!(!repeated.legacy_backend_removed);
8877 assert!(!repeated.setup_actions_removed);
8878 assert!(!repeated.empty);
8879 Ok(())
8880 }
8881}