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 crate::http_client::api_agent()
2409 .post(&device_code_url)
2410 .send_form([
2411 ("client_id", client_id.as_str()),
2412 ("scope", scopes.as_str()),
2413 ]) {
2414 Ok(response) => response,
2415 Err(err) => {
2416 return pause_oauth_device_error(
2417 step,
2418 state,
2419 "external_timeout",
2420 "OAuth device-code endpoint is not reachable",
2421 serde_json::json!({"detail": err.to_string(), "target": device_code_url}),
2422 );
2423 }
2424 };
2425 let body = match response.body_mut().read_json::<Value>() {
2426 Ok(body) => body,
2427 Err(err) => {
2428 return pause_oauth_device_error(
2429 step,
2430 state,
2431 "provider_contract_error",
2432 "OAuth device-code response was not valid JSON",
2433 serde_json::json!({"detail": err.to_string()}),
2434 );
2435 }
2436 };
2437 let device_code = match body
2438 .get("device_code")
2439 .and_then(Value::as_str)
2440 .map(str::trim)
2441 {
2442 Some(value) if !value.is_empty() => value.to_string(),
2443 _ => {
2444 return pause_oauth_device_error(
2445 step,
2446 state,
2447 "provider_contract_error",
2448 "OAuth device-code response did not include device_code",
2449 serde_json::json!({"body": public_oauth_device_response(&body)}),
2450 );
2451 }
2452 };
2453 let user_code = body
2454 .get("user_code")
2455 .and_then(Value::as_str)
2456 .map(str::trim)
2457 .filter(|value| !value.is_empty())
2458 .unwrap_or_default()
2459 .to_string();
2460 let verification_uri = body
2461 .get("verification_uri")
2462 .or_else(|| body.get("verification_url"))
2463 .and_then(Value::as_str)
2464 .map(str::trim)
2465 .filter(|value| !value.is_empty())
2466 .or_else(|| step.extra.get("verification_uri").and_then(Value::as_str))
2467 .unwrap_or_default()
2468 .to_string();
2469 if user_code.is_empty() || verification_uri.is_empty() {
2470 return pause_oauth_device_error(
2471 step,
2472 state,
2473 "provider_contract_error",
2474 "OAuth device-code response did not include user_code or verification_uri",
2475 serde_json::json!({"body": public_oauth_device_response(&body)}),
2476 );
2477 }
2478 let now = crate::setup_actions::current_epoch_secs();
2479 let expires_in = body
2480 .get("expires_in")
2481 .and_then(Value::as_u64)
2482 .unwrap_or(900);
2483 let interval = body
2484 .get("interval")
2485 .and_then(Value::as_u64)
2486 .unwrap_or(5)
2487 .max(1);
2488 let token_store_key = step
2489 .extra
2490 .get("token_store_key")
2491 .and_then(Value::as_str)
2492 .map(str::trim)
2493 .filter(|value| !value.is_empty())
2494 .unwrap_or("oauth_access_token")
2495 .to_string();
2496 let refresh_token_store_key = step
2497 .extra
2498 .get("refresh_token_store_key")
2499 .and_then(Value::as_str)
2500 .map(str::trim)
2501 .filter(|value| !value.is_empty())
2502 .map(str::to_string);
2503 let session = SetupMachineOAuthDeviceSession {
2504 step_id: step.id.clone(),
2505 provider_id: state.provider_id.clone(),
2506 tenant: state.tenant.clone(),
2507 team: state.team.clone(),
2508 device_code,
2509 client_id,
2510 token_url,
2511 token_store_key,
2512 refresh_token_store_key,
2513 interval,
2514 expires_at: now + expires_in,
2515 created_at: now,
2516 };
2517 if let Err(err) = write_setup_machine_oauth_device_session(bundle_root, state, &session) {
2518 return pause_oauth_device_error(
2519 step,
2520 state,
2521 "provider_contract_error",
2522 "OAuth device-code session could not be saved",
2523 serde_json::json!({"detail": err.to_string()}),
2524 );
2525 }
2526
2527 let output_key = step
2528 .extra
2529 .get("output_key")
2530 .and_then(Value::as_str)
2531 .unwrap_or("oauth_device_login");
2532 ensure_outputs_object(state).insert(
2533 output_key.to_string(),
2534 serde_json::json!({
2535 "verification_uri": verification_uri,
2536 "verification_uri_complete": body.get("verification_uri_complete").cloned().unwrap_or(Value::Null),
2537 "user_code": user_code,
2538 "expires_at": session.expires_at,
2539 "interval": session.interval,
2540 }),
2541 );
2542 state.status = SetupMachineStatus::Paused;
2543 state.current_step = Some(step.id.clone());
2544 state.last_error = Some(serde_json::json!({
2545 "error": "manual_action_required",
2546 "reason": "oauth_device_code_pending",
2547 "step": step.id,
2548 "recoverable": true,
2549 }));
2550 MachineStepResult {
2551 ok: false,
2552 next: "open device-code URL, enter the code, then run setup-next again".to_string(),
2553 detail: serde_json::json!({
2554 "blocked": true,
2555 "retryable": true,
2556 "error": "manual_action_required",
2557 "reason": "oauth_device_code_pending",
2558 "login": state.outputs.get(output_key).cloned().unwrap_or(Value::Null),
2559 "output_key": output_key,
2560 }),
2561 }
2562}
2563
2564fn execute_oauth_device_code_poll(
2565 bundle_root: &Path,
2566 machine: &SetupMachine,
2567 step: &SetupMachineStep,
2568 state: &mut SetupMachineState,
2569 session: SetupMachineOAuthDeviceSession,
2570) -> MachineStepResult {
2571 if crate::setup_actions::current_epoch_secs() >= session.expires_at {
2572 let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2573 return pause_oauth_device_error(
2574 step,
2575 state,
2576 "oauth_token_expired",
2577 "OAuth device code expired; retry to start a new login",
2578 serde_json::json!({"expired": true}),
2579 );
2580 }
2581 let agent = crate::http_client::api_agent_any_status();
2582 let mut response = match agent.post(&session.token_url).send_form([
2583 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
2584 ("client_id", session.client_id.as_str()),
2585 ("device_code", session.device_code.as_str()),
2586 ]) {
2587 Ok(response) => response,
2588 Err(err) => {
2589 return pause_oauth_device_error(
2590 step,
2591 state,
2592 "external_timeout",
2593 "OAuth token endpoint is not reachable",
2594 serde_json::json!({"detail": err.to_string(), "target": session.token_url}),
2595 );
2596 }
2597 };
2598 let status = response.status().as_u16();
2599 let body = match response.body_mut().read_json::<Value>() {
2600 Ok(body) => body,
2601 Err(err) => {
2602 return pause_oauth_device_error(
2603 step,
2604 state,
2605 "provider_contract_error",
2606 "OAuth token response was not valid JSON",
2607 serde_json::json!({"detail": err.to_string(), "status": status}),
2608 );
2609 }
2610 };
2611 if let Some(error) = body.get("error").and_then(Value::as_str)
2612 && matches!(error, "authorization_pending" | "slow_down")
2613 {
2614 state.status = SetupMachineStatus::Paused;
2615 state.current_step = Some(step.id.clone());
2616 state.last_error = Some(serde_json::json!({
2617 "error": "oauth_pending",
2618 "step": step.id,
2619 "recoverable": true,
2620 }));
2621 return MachineStepResult {
2622 ok: false,
2623 next: "OAuth authorization is still pending".to_string(),
2624 detail: serde_json::json!({
2625 "blocked": true,
2626 "retryable": true,
2627 "error": "oauth_pending",
2628 "interval": session.interval,
2629 }),
2630 };
2631 }
2632 if status >= 400 || body.get("access_token").and_then(Value::as_str).is_none() {
2633 return pause_oauth_device_error(
2634 step,
2635 state,
2636 "oauth_refresh_failed",
2637 "OAuth token polling failed",
2638 serde_json::json!({"status": status, "body": public_oauth_device_response(&body)}),
2639 );
2640 }
2641 let mut config = serde_json::Map::new();
2642 if let Some(access_token) = body.get("access_token").and_then(Value::as_str) {
2643 config.insert(
2644 session.token_store_key.clone(),
2645 Value::String(access_token.to_string()),
2646 );
2647 }
2648 if let Some(refresh_key) = session.refresh_token_store_key.as_deref()
2649 && let Some(refresh_token) = body.get("refresh_token").and_then(Value::as_str)
2650 {
2651 config.insert(
2652 refresh_key.to_string(),
2653 Value::String(refresh_token.to_string()),
2654 );
2655 }
2656 if config.is_empty() {
2657 return pause_oauth_device_error(
2658 step,
2659 state,
2660 "provider_contract_error",
2661 "OAuth token response had no persistable tokens",
2662 serde_json::json!({"status": status}),
2663 );
2664 }
2665 let config = Value::Object(config);
2666 let env = step
2667 .extra
2668 .get("env")
2669 .and_then(Value::as_str)
2670 .unwrap_or("dev");
2671 let persist_result = tokio::runtime::Runtime::new()
2672 .map_err(|err| err.to_string())
2673 .and_then(|runtime| {
2674 runtime
2675 .block_on(crate::qa::persist::persist_all_config_as_secrets(
2676 bundle_root,
2677 env,
2678 &state.tenant,
2679 Some(&state.team),
2680 &state.provider_id,
2681 &config,
2682 None,
2683 ))
2684 .map_err(|err| err.to_string())
2685 });
2686 let persisted_keys = match persist_result {
2687 Ok(keys) => keys,
2688 Err(err) => {
2689 return pause_oauth_device_error(
2690 step,
2691 state,
2692 "provider_contract_error",
2693 "OAuth token secrets could not be persisted",
2694 serde_json::json!({"detail": err}),
2695 );
2696 }
2697 };
2698 let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2699 ensure_outputs_object(state).insert(
2700 step.extra
2701 .get("output_key")
2702 .and_then(Value::as_str)
2703 .unwrap_or("oauth_device_login")
2704 .to_string(),
2705 serde_json::json!({
2706 "ok": true,
2707 "token_store_key": session.token_store_key,
2708 "persisted_keys": persisted_keys,
2709 }),
2710 );
2711 mark_machine_step_complete(machine, step, state);
2712 MachineStepResult {
2713 ok: true,
2714 next: "OAuth device-code step completed".to_string(),
2715 detail: serde_json::json!({
2716 "kind": step.kind,
2717 "persisted_keys": persisted_keys,
2718 }),
2719 }
2720}
2721
2722fn pause_oauth_device_error(
2723 step: &SetupMachineStep,
2724 state: &mut SetupMachineState,
2725 error: &str,
2726 next: &str,
2727 detail: Value,
2728) -> MachineStepResult {
2729 state.status = SetupMachineStatus::Paused;
2730 state.current_step = Some(step.id.clone());
2731 state.last_error = Some(serde_json::json!({
2732 "error": error,
2733 "step": step.id,
2734 "detail": detail,
2735 "recoverable": true,
2736 }));
2737 let mut detail_obj = serde_json::json!({
2738 "blocked": true,
2739 "retryable": true,
2740 "error": error,
2741 });
2742 if let (Some(obj), Some(extra)) = (detail_obj.as_object_mut(), detail.as_object()) {
2743 for (key, value) in extra {
2744 obj.insert(key.clone(), value.clone());
2745 }
2746 }
2747 MachineStepResult {
2748 ok: false,
2749 next: next.to_string(),
2750 detail: detail_obj,
2751 }
2752}
2753
2754fn oauth_device_endpoint(
2755 step: &SetupMachineStep,
2756 direct_key: &str,
2757 authority_suffix: &str,
2758) -> anyhow::Result<String> {
2759 if let Some(value) = step
2760 .extra
2761 .get(direct_key)
2762 .or_else(|| step.extra.get(&format!("{direct_key}_template")))
2763 .and_then(Value::as_str)
2764 .map(str::trim)
2765 .filter(|value| !value.is_empty())
2766 {
2767 return Ok(value.to_string());
2768 }
2769 let authority = step
2770 .extra
2771 .get("authority_url_template")
2772 .or_else(|| step.extra.get("authority_url"))
2773 .and_then(Value::as_str)
2774 .map(str::trim)
2775 .filter(|value| !value.is_empty())
2776 .ok_or_else(|| anyhow::anyhow!("missing {direct_key}"))?;
2777 Ok(format!(
2778 "{}/oauth2/v2.0/{}",
2779 authority.trim_end_matches('/'),
2780 authority_suffix
2781 ))
2782}
2783
2784fn public_oauth_device_response(body: &Value) -> Value {
2785 let mut public = serde_json::Map::new();
2786 for key in [
2787 "user_code",
2788 "verification_uri",
2789 "verification_url",
2790 "verification_uri_complete",
2791 "expires_in",
2792 "interval",
2793 "message",
2794 "error",
2795 "error_description",
2796 ] {
2797 if let Some(value) = body.get(key) {
2798 public.insert(key.to_string(), value.clone());
2799 }
2800 }
2801 Value::Object(public)
2802}
2803
2804fn setup_machine_oauth_device_session_path(
2805 bundle_root: &Path,
2806 state: &SetupMachineState,
2807 step_id: &str,
2808) -> PathBuf {
2809 setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
2810 .join("oauth-device-sessions")
2811 .join(format!("{step_id}.json"))
2812}
2813
2814fn load_setup_machine_oauth_device_session(
2815 bundle_root: &Path,
2816 state: &SetupMachineState,
2817 step_id: &str,
2818) -> anyhow::Result<Option<SetupMachineOAuthDeviceSession>> {
2819 let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2820 if !path.is_file() {
2821 return Ok(None);
2822 }
2823 let raw = std::fs::read_to_string(&path)?;
2824 Ok(Some(serde_json::from_str(&raw)?))
2825}
2826
2827fn write_setup_machine_oauth_device_session(
2828 bundle_root: &Path,
2829 state: &SetupMachineState,
2830 session: &SetupMachineOAuthDeviceSession,
2831) -> anyhow::Result<PathBuf> {
2832 let path = setup_machine_oauth_device_session_path(bundle_root, state, &session.step_id);
2833 if let Some(parent) = path.parent() {
2834 std::fs::create_dir_all(parent)?;
2835 }
2836 std::fs::write(&path, serde_json::to_vec_pretty(session)?)?;
2837 #[cfg(unix)]
2838 {
2839 use std::os::unix::fs::PermissionsExt;
2840 let mut permissions = std::fs::metadata(&path)?.permissions();
2841 permissions.set_mode(0o600);
2842 std::fs::set_permissions(&path, permissions)?;
2843 }
2844 Ok(path)
2845}
2846
2847fn remove_setup_machine_oauth_device_session(
2848 bundle_root: &Path,
2849 state: &SetupMachineState,
2850 step_id: &str,
2851) -> anyhow::Result<()> {
2852 let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2853 if path.exists() {
2854 std::fs::remove_file(path)?;
2855 }
2856 Ok(())
2857}
2858
2859fn execute_oauth_authorization_code_step(
2860 bundle_root: &Path,
2861 _machine: &SetupMachine,
2862 step: &SetupMachineStep,
2863 state: &mut SetupMachineState,
2864) -> MachineStepResult {
2865 let authorization_url = match build_oauth_authorization_url(bundle_root, step, state) {
2866 Ok(url) => url,
2867 Err(OauthAuthorizationError::Missing(key)) => {
2868 state.status = SetupMachineStatus::Paused;
2869 state.current_step = Some(step.id.clone());
2870 state.last_error = Some(serde_json::json!({
2871 "error": "missing_prerequisite",
2872 "step": step.id,
2873 "missing": key,
2874 "recoverable": true,
2875 }));
2876 return MachineStepResult {
2877 ok: false,
2878 next: "provide OAuth authorization prerequisites, then retry".to_string(),
2879 detail: serde_json::json!({
2880 "blocked": true,
2881 "retryable": true,
2882 "error": "missing_prerequisite",
2883 "missing_config_key": key,
2884 }),
2885 };
2886 }
2887 Err(OauthAuthorizationError::Invalid(detail)) => {
2888 state.status = SetupMachineStatus::Paused;
2889 state.current_step = Some(step.id.clone());
2890 state.last_error = Some(serde_json::json!({
2891 "error": "invalid_answer",
2892 "step": step.id,
2893 "detail": detail,
2894 "recoverable": true,
2895 }));
2896 return MachineStepResult {
2897 ok: false,
2898 next: "fix OAuth authorization URL inputs, then retry".to_string(),
2899 detail: serde_json::json!({
2900 "blocked": true,
2901 "retryable": true,
2902 "error": "invalid_answer",
2903 "detail": detail,
2904 }),
2905 };
2906 }
2907 };
2908
2909 let output_key = step
2910 .extra
2911 .get("output_key")
2912 .and_then(Value::as_str)
2913 .unwrap_or("authorization_url");
2914 ensure_outputs_object(state).insert(
2915 output_key.to_string(),
2916 Value::String(authorization_url.clone()),
2917 );
2918 state.status = SetupMachineStatus::Paused;
2919 state.current_step = Some(step.id.clone());
2920 state.last_error = Some(serde_json::json!({
2921 "error": "manual_action_required",
2922 "reason": "oauth_authorization_required",
2923 "step": step.id,
2924 "recoverable": true,
2925 }));
2926 MachineStepResult {
2927 ok: false,
2928 next: "open authorization URL and complete OAuth callback".to_string(),
2929 detail: serde_json::json!({
2930 "blocked": true,
2931 "retryable": true,
2932 "error": "manual_action_required",
2933 "reason": "oauth_authorization_required",
2934 "authorization_url": authorization_url,
2935 "output_key": output_key,
2936 "kind": step.kind,
2937 }),
2938 }
2939}
2940
2941#[derive(Debug)]
2942enum OauthAuthorizationError {
2943 Missing(&'static str),
2944 Invalid(String),
2945}
2946
2947impl std::fmt::Display for OauthAuthorizationError {
2948 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2949 match self {
2950 Self::Missing(key) => write!(f, "missing {key}"),
2951 Self::Invalid(detail) => f.write_str(detail),
2952 }
2953 }
2954}
2955
2956impl std::error::Error for OauthAuthorizationError {}
2957
2958fn build_oauth_authorization_url(
2959 bundle_root: &Path,
2960 step: &SetupMachineStep,
2961 state: &SetupMachineState,
2962) -> Result<String, OauthAuthorizationError> {
2963 let template = step
2964 .extra
2965 .get("authorize_url")
2966 .or_else(|| step.extra.get("authorization_url"))
2967 .or_else(|| step.extra.get("auth_url"))
2968 .and_then(Value::as_str)
2969 .map(str::trim)
2970 .filter(|value| !value.is_empty())
2971 .ok_or(OauthAuthorizationError::Missing("authorize_url"))?;
2972 let expanded = expand_machine_template(template, state)
2973 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2974 let mut parsed = url::Url::parse(&expanded)
2975 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2976 if parsed.scheme() != "https" || parsed.host_str().is_none() {
2977 return Err(OauthAuthorizationError::Invalid(
2978 "authorize_url must be an https URL with a host".to_string(),
2979 ));
2980 }
2981
2982 let key = crate::setup_actions::load_or_create_signing_key(bundle_root)
2983 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2984 let ttl = step
2985 .extra
2986 .get("state_ttl_secs")
2987 .and_then(Value::as_u64)
2988 .unwrap_or(15 * 60);
2989 let payload = crate::setup_actions::OAuthStatePayload {
2990 provider_id: state.provider_id.clone(),
2991 tenant: state.tenant.clone(),
2992 team: state.team.clone(),
2993 action_id: step.id.clone(),
2994 nonce: oauth_nonce(),
2995 expires_at: crate::setup_actions::current_epoch_secs() + ttl.max(1),
2996 };
2997 let signed_state = crate::setup_actions::sign_oauth_state(&payload, &key)
2998 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2999
3000 append_oauth_query_param(&mut parsed, "response_type", oauth_response_type(step));
3001 if let Some(client_id) = oauth_step_value(step, state, "client_id", "client_id_output")? {
3002 append_oauth_query_param(&mut parsed, "client_id", &client_id);
3003 }
3004 if let Some(redirect_uri) = oauth_redirect_uri(bundle_root, step, state)? {
3005 append_oauth_query_param(&mut parsed, "redirect_uri", &redirect_uri);
3006 }
3007 if let Some(scope) = oauth_scope(step)? {
3008 append_oauth_query_param(&mut parsed, "scope", &scope);
3009 }
3010 append_oauth_query_param(&mut parsed, "state", &signed_state);
3011 if let Some(extra_params) = step.extra.get("params").and_then(Value::as_object) {
3012 let expanded = expand_machine_value_templates(Value::Object(extra_params.clone()), state);
3013 if let Some(params) = expanded.as_object() {
3014 for (key, value) in params {
3015 if let Some(value) = value.as_str() {
3016 append_oauth_query_param(&mut parsed, key, value);
3017 }
3018 }
3019 }
3020 }
3021 Ok(parsed.to_string())
3022}
3023
3024fn exchange_setup_machine_oauth_authorization_code(
3025 bundle_root: &Path,
3026 machine: &SetupMachine,
3027 oauth_state: &crate::setup_actions::OAuthStatePayload,
3028 code: &str,
3029) -> anyhow::Result<Value> {
3030 let state = load_or_init_setup_machine_state(
3031 bundle_root,
3032 &oauth_state.provider_id,
3033 &oauth_state.tenant,
3034 &oauth_state.team,
3035 machine,
3036 )?;
3037 let step = machine
3038 .steps
3039 .iter()
3040 .find(|candidate| candidate.id == oauth_state.action_id)
3041 .ok_or_else(|| {
3042 anyhow::anyhow!("setup-machine step not found: {}", oauth_state.action_id)
3043 })?;
3044 if step.kind != "oauth_authorization_code" {
3045 anyhow::bail!(
3046 "setup-machine step is not an oauth_authorization_code step: {}",
3047 step.id
3048 );
3049 }
3050 let token_url = oauth_authorization_token_url(step, &state)?;
3051 let redirect_uri = oauth_redirect_uri(bundle_root, step, &state)?
3052 .ok_or(OauthAuthorizationError::Missing("redirect_uri"))?;
3053 let client_id = oauth_step_value(step, &state, "client_id", "client_id_output")?
3054 .ok_or(OauthAuthorizationError::Missing("client_id"))?;
3055 let client_secret = oauth_step_value(step, &state, "client_secret", "client_secret_output")?;
3056
3057 let mut form = vec![
3058 ("grant_type".to_string(), "authorization_code".to_string()),
3059 ("code".to_string(), code.to_string()),
3060 ("redirect_uri".to_string(), redirect_uri),
3061 ("client_id".to_string(), client_id),
3062 ];
3063 if let Some(client_secret) = client_secret {
3064 form.push(("client_secret".to_string(), client_secret));
3065 }
3066 let agent = crate::http_client::api_agent_any_status();
3067 let mut response = agent
3068 .post(&token_url)
3069 .send_form(form)
3070 .context("OAuth token exchange failed")?;
3071 let status = response.status().as_u16();
3072 let body = response
3073 .body_mut()
3074 .read_json::<Value>()
3075 .context("failed to parse OAuth token response")?;
3076 if status >= 400 {
3077 anyhow::bail!("OAuth token exchange failed with status {status}: {body}");
3078 }
3079 Ok(body)
3080}
3081
3082fn oauth_nonce() -> String {
3083 use base64::Engine;
3084 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>())
3085}
3086
3087fn oauth_response_type(step: &SetupMachineStep) -> &str {
3088 step.extra
3089 .get("response_type")
3090 .and_then(Value::as_str)
3091 .map(str::trim)
3092 .filter(|value| !value.is_empty())
3093 .unwrap_or("code")
3094}
3095
3096fn oauth_step_value(
3097 step: &SetupMachineStep,
3098 state: &SetupMachineState,
3099 literal_key: &'static str,
3100 output_key_key: &'static str,
3101) -> Result<Option<String>, OauthAuthorizationError> {
3102 if let Some(value) = step
3103 .extra
3104 .get(literal_key)
3105 .and_then(Value::as_str)
3106 .map(str::trim)
3107 .filter(|value| !value.is_empty())
3108 {
3109 return expand_machine_template(value, state)
3110 .map(Some)
3111 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3112 }
3113 let output_key = step
3114 .extra
3115 .get(output_key_key)
3116 .and_then(Value::as_str)
3117 .unwrap_or(literal_key);
3118 Ok(output_string(&state.outputs, output_key))
3119}
3120
3121fn oauth_redirect_uri(
3122 bundle_root: &Path,
3123 step: &SetupMachineStep,
3124 state: &SetupMachineState,
3125) -> Result<Option<String>, OauthAuthorizationError> {
3126 if let Some(value) = step
3127 .extra
3128 .get("redirect_uri")
3129 .or_else(|| step.extra.get("redirect_uri_template"))
3130 .and_then(Value::as_str)
3131 .map(str::trim)
3132 .filter(|value| !value.is_empty())
3133 {
3134 return expand_machine_template(value, state)
3135 .map(Some)
3136 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3137 }
3138 let Some(callback_path) = step
3139 .extra
3140 .get("callback_path")
3141 .and_then(Value::as_str)
3142 .map(str::trim)
3143 .filter(|value| !value.is_empty())
3144 else {
3145 return Ok(None);
3146 };
3147 let public_base_url = output_string(&state.outputs, "public_base_url")
3148 .or_else(|| {
3149 crate::platform_setup::load_effective_static_routes_defaults(
3150 bundle_root,
3151 &state.tenant,
3152 Some(&state.team),
3153 )
3154 .ok()
3155 .flatten()
3156 .and_then(|policy| policy.public_base_url)
3157 })
3158 .ok_or(OauthAuthorizationError::Missing("public_base_url"))?;
3159 Ok(Some(format!(
3160 "{}{}",
3161 public_base_url.trim_end_matches('/'),
3162 ensure_leading_slash(callback_path)
3163 )))
3164}
3165
3166fn oauth_authorization_token_url(
3167 step: &SetupMachineStep,
3168 state: &SetupMachineState,
3169) -> Result<String, OauthAuthorizationError> {
3170 let template = step
3171 .extra
3172 .get("token_url")
3173 .or_else(|| step.extra.get("token_url_template"))
3174 .and_then(Value::as_str)
3175 .map(str::trim)
3176 .filter(|value| !value.is_empty())
3177 .ok_or(OauthAuthorizationError::Missing("token_url"))?;
3178 let token_url = expand_machine_template(template, state)
3179 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3180 let parsed = url::Url::parse(&token_url)
3181 .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3182 if !(parsed.scheme() == "https" && parsed.host_str().is_some()
3183 || parsed.scheme() == "http"
3184 && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "::1")))
3185 {
3186 return Err(OauthAuthorizationError::Invalid(
3187 "token_url must be https or loopback http".to_string(),
3188 ));
3189 }
3190 Ok(token_url)
3191}
3192
3193fn oauth_scope(step: &SetupMachineStep) -> Result<Option<String>, OauthAuthorizationError> {
3194 if let Some(scope) = step.extra.get("scope").and_then(Value::as_str) {
3195 let scope = scope.trim();
3196 return Ok((!scope.is_empty()).then(|| scope.to_string()));
3197 }
3198 if let Some(scopes) = step.extra.get("scopes").and_then(Value::as_array) {
3199 let scope = scopes
3200 .iter()
3201 .filter_map(Value::as_str)
3202 .map(str::trim)
3203 .filter(|value| !value.is_empty())
3204 .collect::<Vec<_>>()
3205 .join(" ");
3206 return Ok((!scope.is_empty()).then_some(scope));
3207 }
3208 Ok(None)
3209}
3210
3211fn append_oauth_query_param(parsed: &mut url::Url, key: &str, value: &str) {
3212 if value.trim().is_empty() || parsed.query_pairs().any(|(existing, _)| existing == key) {
3213 return;
3214 }
3215 parsed.query_pairs_mut().append_pair(key, value);
3216}
3217
3218fn map_oauth_authorization_token_response(
3219 step: &SetupMachineStep,
3220 token_response: &Value,
3221) -> anyhow::Result<serde_json::Map<String, Value>> {
3222 let mut config = serde_json::Map::new();
3223 if let Some(map) = step
3224 .extra
3225 .get("response_secret_map")
3226 .and_then(Value::as_object)
3227 {
3228 for (response_key, secret_key) in map {
3229 let Some(secret_key) = secret_key
3230 .as_str()
3231 .map(str::trim)
3232 .filter(|value| !value.is_empty())
3233 else {
3234 anyhow::bail!(
3235 "setup-machine OAuth response_secret_map value must be a non-empty string: {response_key}"
3236 );
3237 };
3238 if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3239 && !value.trim().is_empty()
3240 {
3241 config.insert(secret_key.to_string(), Value::String(value.to_string()));
3242 }
3243 }
3244 } else {
3245 insert_oauth_token_field(
3246 &mut config,
3247 token_response,
3248 "access_token",
3249 step.extra
3250 .get("token_store_key")
3251 .and_then(Value::as_str)
3252 .unwrap_or("oauth_access_token"),
3253 );
3254 if let Some(secret_key) = step
3255 .extra
3256 .get("refresh_token_store_key")
3257 .and_then(Value::as_str)
3258 {
3259 insert_oauth_token_field(&mut config, token_response, "refresh_token", secret_key);
3260 }
3261 if let Some(secret_key) = step.extra.get("id_token_store_key").and_then(Value::as_str) {
3262 insert_oauth_token_field(&mut config, token_response, "id_token", secret_key);
3263 }
3264 }
3265
3266 if config.is_empty() {
3267 anyhow::bail!("OAuth token response had no persistable tokens");
3268 }
3269 Ok(config)
3270}
3271
3272fn insert_oauth_token_field(
3273 config: &mut serde_json::Map<String, Value>,
3274 token_response: &Value,
3275 response_key: &str,
3276 secret_key: &str,
3277) {
3278 let secret_key = secret_key.trim();
3279 if secret_key.is_empty() {
3280 return;
3281 }
3282 if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3283 && !value.trim().is_empty()
3284 {
3285 config.insert(secret_key.to_string(), Value::String(value.to_string()));
3286 }
3287}
3288
3289fn ensure_leading_slash(value: &str) -> String {
3290 if value.starts_with('/') {
3291 value.to_string()
3292 } else {
3293 format!("/{value}")
3294 }
3295}
3296
3297fn execute_select_from_json_step(
3298 machine: &SetupMachine,
3299 step: &SetupMachineStep,
3300 state: &mut SetupMachineState,
3301) -> MachineStepResult {
3302 let Some(source_output) = step
3303 .extra
3304 .get("source_output")
3305 .or_else(|| step.extra.get("source"))
3306 .and_then(Value::as_str)
3307 .map(str::trim)
3308 .filter(|value| !value.is_empty())
3309 else {
3310 return pause_select_from_json(
3311 step,
3312 state,
3313 "missing_prerequisite",
3314 "source_output",
3315 "declare source_output, then retry",
3316 );
3317 };
3318 let Some(source) = state.outputs.get(source_output) else {
3319 return pause_select_from_json(
3320 step,
3321 state,
3322 "missing_prerequisite",
3323 source_output,
3324 "run prerequisite JSON step, then retry",
3325 );
3326 };
3327 let selected_scope = match select_json_scope(source, step) {
3328 Some(value) => value,
3329 None => {
3330 return pause_select_from_json(
3331 step,
3332 state,
3333 "missing_prerequisite",
3334 "json_path",
3335 "fix source JSON path, then retry",
3336 );
3337 }
3338 };
3339
3340 let selected = match choose_json_selection(selected_scope, step, state) {
3341 Ok(selected) => selected,
3342 Err(SelectFromJsonError::SelectionRequired(count)) => {
3343 state.status = SetupMachineStatus::Paused;
3344 state.current_step = Some(step.id.clone());
3345 state.last_error = Some(serde_json::json!({
3346 "error": "manual_action_required",
3347 "reason": "selection_required",
3348 "step": step.id,
3349 "options": count,
3350 "recoverable": true,
3351 }));
3352 return MachineStepResult {
3353 ok: false,
3354 next: "operator selection required".to_string(),
3355 detail: serde_json::json!({
3356 "blocked": true,
3357 "retryable": true,
3358 "error": "manual_action_required",
3359 "reason": "selection_required",
3360 "options": count,
3361 "source_output": source_output,
3362 }),
3363 };
3364 }
3365 Err(SelectFromJsonError::InvalidSelection(detail)) => {
3366 state.status = SetupMachineStatus::Paused;
3367 state.current_step = Some(step.id.clone());
3368 state.last_error = Some(serde_json::json!({
3369 "error": "invalid_answer",
3370 "step": step.id,
3371 "detail": detail,
3372 "recoverable": true,
3373 }));
3374 return MachineStepResult {
3375 ok: false,
3376 next: "fix selected JSON value, then retry".to_string(),
3377 detail: serde_json::json!({
3378 "blocked": true,
3379 "retryable": true,
3380 "error": "invalid_answer",
3381 "detail": detail,
3382 }),
3383 };
3384 }
3385 };
3386
3387 let output_key = step
3388 .extra
3389 .get("output_key")
3390 .and_then(Value::as_str)
3391 .unwrap_or(step.id.as_str());
3392 let value_key = step.extra.get("value_key").and_then(Value::as_str);
3393 let stored = value_key
3394 .and_then(|key| selected.get(key))
3395 .cloned()
3396 .unwrap_or_else(|| selected.clone());
3397 let outputs = ensure_outputs_object(state);
3398 outputs.insert(output_key.to_string(), stored.clone());
3399 if selected.is_object() {
3400 outputs.insert(format!("{output_key}_item"), selected.clone());
3401 }
3402 mark_machine_step_complete(machine, step, state);
3403 MachineStepResult {
3404 ok: true,
3405 next: "JSON selection completed".to_string(),
3406 detail: serde_json::json!({
3407 "kind": step.kind,
3408 "source_output": source_output,
3409 "output_key": output_key,
3410 "selected": redact_large_json_detail(&stored),
3411 }),
3412 }
3413}
3414
3415#[derive(Debug)]
3416enum SelectFromJsonError {
3417 SelectionRequired(usize),
3418 InvalidSelection(String),
3419}
3420
3421fn pause_select_from_json(
3422 step: &SetupMachineStep,
3423 state: &mut SetupMachineState,
3424 error: &str,
3425 missing: &str,
3426 next: &str,
3427) -> MachineStepResult {
3428 state.status = SetupMachineStatus::Paused;
3429 state.current_step = Some(step.id.clone());
3430 state.last_error = Some(serde_json::json!({
3431 "error": error,
3432 "step": step.id,
3433 "missing": missing,
3434 "recoverable": true,
3435 }));
3436 MachineStepResult {
3437 ok: false,
3438 next: next.to_string(),
3439 detail: serde_json::json!({
3440 "blocked": true,
3441 "retryable": true,
3442 "error": error,
3443 "missing_config_key": missing,
3444 }),
3445 }
3446}
3447
3448fn select_json_scope<'a>(source: &'a Value, step: &SetupMachineStep) -> Option<&'a Value> {
3449 let Some(path) = step
3450 .extra
3451 .get("json_pointer")
3452 .or_else(|| step.extra.get("path"))
3453 .or_else(|| step.extra.get("json_path"))
3454 .and_then(Value::as_str)
3455 .map(str::trim)
3456 .filter(|value| !value.is_empty())
3457 else {
3458 return Some(source);
3459 };
3460 if path.starts_with('/') {
3461 source.pointer(path)
3462 } else {
3463 let mut current = source;
3464 for segment in path.split('.').filter(|segment| !segment.is_empty()) {
3465 current = current.get(segment)?;
3466 }
3467 Some(current)
3468 }
3469}
3470
3471fn choose_json_selection(
3472 value: &Value,
3473 step: &SetupMachineStep,
3474 state: &SetupMachineState,
3475) -> Result<Value, SelectFromJsonError> {
3476 let Some(items) = value.as_array() else {
3477 return Ok(value.clone());
3478 };
3479 if items.is_empty() {
3480 return Err(SelectFromJsonError::InvalidSelection(
3481 "source JSON selection array is empty".to_string(),
3482 ));
3483 }
3484 if let Some(index) = step.extra.get("selected_index").and_then(Value::as_u64) {
3485 return items.get(index as usize).cloned().ok_or_else(|| {
3486 SelectFromJsonError::InvalidSelection(format!("selected_index {index} is out of range"))
3487 });
3488 }
3489 if let Some(value) = step
3490 .extra
3491 .get("selected_value")
3492 .or_else(|| step.extra.get("default_value"))
3493 {
3494 return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3495 SelectFromJsonError::InvalidSelection(
3496 "selected_value did not match any option".to_string(),
3497 )
3498 });
3499 }
3500 let choice_key = step
3501 .extra
3502 .get("choice_output_key")
3503 .and_then(Value::as_str)
3504 .unwrap_or(step.id.as_str());
3505 if let Some(value) = state.outputs.get(choice_key) {
3506 return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3507 SelectFromJsonError::InvalidSelection(format!(
3508 "choice output `{choice_key}` did not match any option"
3509 ))
3510 });
3511 }
3512 if items.len() == 1 {
3513 return Ok(items[0].clone());
3514 }
3515 Err(SelectFromJsonError::SelectionRequired(items.len()))
3516}
3517
3518fn find_json_selection_by_value(
3519 items: &[Value],
3520 step: &SetupMachineStep,
3521 selected_value: &Value,
3522) -> Option<Value> {
3523 let value_key = step.extra.get("value_key").and_then(Value::as_str);
3524 items.iter().find_map(|item| {
3525 let candidate = value_key.and_then(|key| item.get(key)).unwrap_or(item);
3526 values_match_for_selection(candidate, selected_value).then(|| item.clone())
3527 })
3528}
3529
3530fn values_match_for_selection(candidate: &Value, selected_value: &Value) -> bool {
3531 if candidate == selected_value {
3532 return true;
3533 }
3534 match (candidate, selected_value) {
3535 (Value::String(left), Value::String(right)) => left == right,
3536 (Value::String(left), other) => other.as_str() == Some(left.as_str()),
3537 (other, Value::String(right)) => other.as_str() == Some(right.as_str()),
3538 _ => false,
3539 }
3540}
3541
3542fn redact_large_json_detail(value: &Value) -> Value {
3543 match value {
3544 Value::Array(items) if items.len() > 10 => serde_json::json!({
3545 "kind": "array",
3546 "len": items.len(),
3547 }),
3548 Value::Object(map) if map.len() > 20 => serde_json::json!({
3549 "kind": "object",
3550 "keys": map.len(),
3551 }),
3552 other => other.clone(),
3553 }
3554}
3555
3556fn execute_generate_file_step(
3557 bundle_root: &Path,
3558 machine: &SetupMachine,
3559 step: &SetupMachineStep,
3560 state: &mut SetupMachineState,
3561) -> MachineStepResult {
3562 let Some(artifact_path) = setup_artifact_relative_path(step) else {
3563 state.status = SetupMachineStatus::Paused;
3564 state.current_step = Some(step.id.clone());
3565 state.last_error = Some(serde_json::json!({
3566 "error": "missing_prerequisite",
3567 "step": step.id,
3568 "missing": "artifact_path",
3569 "recoverable": true,
3570 }));
3571 return MachineStepResult {
3572 ok: false,
3573 next: "declare artifact_path, then retry".to_string(),
3574 detail: serde_json::json!({
3575 "blocked": true,
3576 "retryable": true,
3577 "error": "missing_prerequisite",
3578 "missing_config_key": "artifact_path",
3579 }),
3580 };
3581 };
3582 if !is_safe_setup_artifact_path(artifact_path) {
3583 state.status = SetupMachineStatus::Paused;
3584 state.current_step = Some(step.id.clone());
3585 state.last_error = Some(serde_json::json!({
3586 "error": "provider_contract_error",
3587 "step": step.id,
3588 "artifact_path": artifact_path,
3589 "recoverable": true,
3590 }));
3591 return MachineStepResult {
3592 ok: false,
3593 next: "fix setup artifact path, then retry".to_string(),
3594 detail: serde_json::json!({
3595 "blocked": true,
3596 "retryable": true,
3597 "error": "provider_contract_error",
3598 "artifact_path": artifact_path,
3599 }),
3600 };
3601 }
3602
3603 let rendered = match render_generate_file_content(step, state) {
3604 Ok(rendered) => rendered,
3605 Err(err) => {
3606 state.status = SetupMachineStatus::Paused;
3607 state.current_step = Some(step.id.clone());
3608 state.last_error = Some(serde_json::json!({
3609 "error": "missing_prerequisite",
3610 "step": step.id,
3611 "detail": err.to_string(),
3612 "recoverable": true,
3613 }));
3614 return MachineStepResult {
3615 ok: false,
3616 next: "provide file content inputs, then retry".to_string(),
3617 detail: serde_json::json!({
3618 "blocked": true,
3619 "retryable": true,
3620 "error": "missing_prerequisite",
3621 "detail": err.to_string(),
3622 }),
3623 };
3624 }
3625 };
3626
3627 let artifact_file = resolve_setup_artifact_path(bundle_root, state, artifact_path);
3628 if let Some(parent) = artifact_file.parent()
3629 && let Err(err) = std::fs::create_dir_all(parent)
3630 {
3631 return pause_generate_file_write_error(step, state, artifact_path, err);
3632 }
3633 if let Err(err) = std::fs::write(&artifact_file, rendered.as_bytes()) {
3634 return pause_generate_file_write_error(step, state, artifact_path, err);
3635 }
3636
3637 let output_key = step
3638 .extra
3639 .get("output_key")
3640 .and_then(Value::as_str)
3641 .unwrap_or(step.id.as_str());
3642 ensure_outputs_object(state).insert(
3643 output_key.to_string(),
3644 Value::String(artifact_path.to_string()),
3645 );
3646 state.created_resources.push(serde_json::json!({
3647 "kind": "setup_artifact",
3648 "step": step.id,
3649 "path": artifact_path,
3650 }));
3651 mark_machine_step_complete(machine, step, state);
3652 MachineStepResult {
3653 ok: true,
3654 next: "setup artifact generated".to_string(),
3655 detail: serde_json::json!({
3656 "kind": step.kind,
3657 "artifact_path": artifact_path,
3658 "bytes": rendered.len(),
3659 "output_key": output_key,
3660 }),
3661 }
3662}
3663
3664fn execute_persist_runtime_config_step(
3665 bundle_root: &Path,
3666 pack_path: Option<&Path>,
3667 machine: &SetupMachine,
3668 step: &SetupMachineStep,
3669 state: &mut SetupMachineState,
3670) -> MachineStepResult {
3671 let config = match render_runtime_config(machine, step, state) {
3672 Ok(config) => config,
3673 Err(err) => {
3674 state.status = SetupMachineStatus::Paused;
3675 state.current_step = Some(step.id.clone());
3676 state.last_error = Some(serde_json::json!({
3677 "error": "missing_prerequisite",
3678 "step": step.id,
3679 "detail": err.to_string(),
3680 "recoverable": true,
3681 }));
3682 return MachineStepResult {
3683 ok: false,
3684 next: "provide runtime config inputs, then retry".to_string(),
3685 detail: serde_json::json!({
3686 "blocked": true,
3687 "retryable": true,
3688 "error": "missing_prerequisite",
3689 "detail": err.to_string(),
3690 }),
3691 };
3692 }
3693 };
3694
3695 let config_dir = bundle_root
3696 .join("state")
3697 .join("config")
3698 .join(&state.provider_id);
3699 let config_path = config_dir.join("setup-answers.json");
3700 if let Some(parent) = config_path.parent()
3701 && let Err(err) = std::fs::create_dir_all(parent)
3702 {
3703 return pause_runtime_config_write_error(step, state, "setup_answers", err);
3704 }
3705 let bytes = match serde_json::to_vec_pretty(&config) {
3706 Ok(bytes) => bytes,
3707 Err(err) => {
3708 state.status = SetupMachineStatus::Paused;
3709 state.current_step = Some(step.id.clone());
3710 state.last_error = Some(serde_json::json!({
3711 "error": "provider_contract_error",
3712 "step": step.id,
3713 "detail": err.to_string(),
3714 "recoverable": true,
3715 }));
3716 return MachineStepResult {
3717 ok: false,
3718 next: "runtime config could not be serialized".to_string(),
3719 detail: serde_json::json!({
3720 "blocked": true,
3721 "retryable": true,
3722 "error": "provider_contract_error",
3723 "detail": err.to_string(),
3724 }),
3725 };
3726 }
3727 };
3728 if let Err(err) = std::fs::write(&config_path, bytes) {
3729 return pause_runtime_config_write_error(step, state, "setup_answers", err);
3730 }
3731
3732 let mut envelope_path = Value::Null;
3733 let mut envelope_skipped = false;
3734 if let Some(pack_path) = pack_path {
3735 match crate::config_envelope::write_provider_config_envelope(
3736 &bundle_root.join(".providers"),
3737 &state.provider_id,
3738 &step.id,
3739 &config,
3740 pack_path,
3741 true,
3742 ) {
3743 Ok(path) => envelope_path = Value::String(path.display().to_string()),
3744 Err(err) => {
3745 state.status = SetupMachineStatus::Paused;
3746 state.current_step = Some(step.id.clone());
3747 state.last_error = Some(serde_json::json!({
3748 "error": "provider_contract_error",
3749 "step": step.id,
3750 "detail": err.to_string(),
3751 "recoverable": true,
3752 }));
3753 return MachineStepResult {
3754 ok: false,
3755 next: "provider config envelope could not be written".to_string(),
3756 detail: serde_json::json!({
3757 "blocked": true,
3758 "retryable": true,
3759 "error": "provider_contract_error",
3760 "detail": err.to_string(),
3761 }),
3762 };
3763 }
3764 }
3765 } else {
3766 envelope_skipped = true;
3767 }
3768
3769 state.created_resources.push(serde_json::json!({
3770 "kind": "runtime_config",
3771 "step": step.id,
3772 "path": config_path.display().to_string(),
3773 "envelope_path": envelope_path,
3774 }));
3775 let output_key = step
3776 .extra
3777 .get("output_key")
3778 .and_then(Value::as_str)
3779 .unwrap_or("runtime_config_path");
3780 ensure_outputs_object(state).insert(
3781 output_key.to_string(),
3782 Value::String(config_path.display().to_string()),
3783 );
3784 mark_machine_step_complete(machine, step, state);
3785 MachineStepResult {
3786 ok: true,
3787 next: "runtime config persisted".to_string(),
3788 detail: serde_json::json!({
3789 "kind": step.kind,
3790 "config_path": config_path,
3791 "envelope_path": envelope_path,
3792 "envelope_skipped": envelope_skipped,
3793 }),
3794 }
3795}
3796
3797fn render_runtime_config(
3798 machine: &SetupMachine,
3799 step: &SetupMachineStep,
3800 state: &SetupMachineState,
3801) -> anyhow::Result<Value> {
3802 let mut config = if let Some(config) = step.extra.get("config") {
3803 expand_machine_value_templates(config.clone(), state)
3804 } else if let Some(mappings) = step.extra.get("mappings") {
3805 expand_machine_value_templates(mappings.clone(), state)
3806 } else {
3807 anyhow::bail!("persist_runtime_config requires config or mappings")
3808 };
3809 let server_owned = setup_machine_server_owned_keys(machine, step);
3810 if let Some(config) = config.as_object_mut() {
3811 for key in server_owned {
3812 config.remove(&key);
3813 }
3814 } else {
3815 anyhow::bail!("persist_runtime_config config must render to an object");
3816 }
3817 Ok(config)
3818}
3819
3820fn setup_machine_server_owned_keys(
3821 machine: &SetupMachine,
3822 step: &SetupMachineStep,
3823) -> BTreeSet<String> {
3824 machine
3825 .extra
3826 .get("server_owned_config_keys")
3827 .into_iter()
3828 .chain(step.extra.get("server_owned_config_keys"))
3829 .flat_map(|value| value.as_array().into_iter().flatten())
3830 .filter_map(Value::as_str)
3831 .map(str::trim)
3832 .filter(|value| !value.is_empty())
3833 .map(str::to_string)
3834 .collect()
3835}
3836
3837fn pause_runtime_config_write_error(
3838 step: &SetupMachineStep,
3839 state: &mut SetupMachineState,
3840 target: &str,
3841 err: std::io::Error,
3842) -> MachineStepResult {
3843 state.status = SetupMachineStatus::Paused;
3844 state.current_step = Some(step.id.clone());
3845 state.last_error = Some(serde_json::json!({
3846 "error": "provider_contract_error",
3847 "step": step.id,
3848 "target": target,
3849 "detail": err.to_string(),
3850 "recoverable": true,
3851 }));
3852 MachineStepResult {
3853 ok: false,
3854 next: "runtime config could not be written".to_string(),
3855 detail: serde_json::json!({
3856 "blocked": true,
3857 "retryable": true,
3858 "error": "provider_contract_error",
3859 "target": target,
3860 "detail": err.to_string(),
3861 }),
3862 }
3863}
3864
3865fn pause_generate_file_write_error(
3866 step: &SetupMachineStep,
3867 state: &mut SetupMachineState,
3868 artifact_path: &str,
3869 err: std::io::Error,
3870) -> MachineStepResult {
3871 state.status = SetupMachineStatus::Paused;
3872 state.current_step = Some(step.id.clone());
3873 state.last_error = Some(serde_json::json!({
3874 "error": "artifact_write_failed",
3875 "step": step.id,
3876 "artifact_path": artifact_path,
3877 "detail": err.to_string(),
3878 "recoverable": true,
3879 }));
3880 MachineStepResult {
3881 ok: false,
3882 next: "setup artifact could not be written".to_string(),
3883 detail: serde_json::json!({
3884 "blocked": true,
3885 "retryable": true,
3886 "error": "artifact_write_failed",
3887 "artifact_path": artifact_path,
3888 "detail": err.to_string(),
3889 }),
3890 }
3891}
3892
3893fn setup_artifact_relative_path(step: &SetupMachineStep) -> Option<&str> {
3894 step.extra
3895 .get("artifact_path")
3896 .or_else(|| step.extra.get("path"))
3897 .or_else(|| step.extra.get("output_path"))
3898 .and_then(Value::as_str)
3899 .map(str::trim)
3900 .filter(|value| !value.is_empty())
3901}
3902
3903fn is_safe_setup_artifact_path(path: &str) -> bool {
3904 !path.starts_with('/')
3905 && !path.contains("..")
3906 && (path.starts_with("artifacts/") || path.starts_with("state/setup/"))
3907}
3908
3909fn resolve_setup_artifact_path(
3910 bundle_root: &Path,
3911 state: &SetupMachineState,
3912 artifact_path: &str,
3913) -> PathBuf {
3914 if artifact_path.starts_with("state/setup/") {
3915 bundle_root.join(artifact_path)
3916 } else {
3917 setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
3918 .join(artifact_path)
3919 }
3920}
3921
3922fn render_generate_file_content(
3923 step: &SetupMachineStep,
3924 state: &SetupMachineState,
3925) -> anyhow::Result<String> {
3926 if let Some(template) = step.extra.get("template").and_then(Value::as_str) {
3927 return expand_machine_template(template, state);
3928 }
3929 if let Some(content) = step.extra.get("content") {
3930 return render_generated_value(content.clone(), state);
3931 }
3932 if let Some(template_json) = step.extra.get("template_json") {
3933 return render_generated_value(template_json.clone(), state);
3934 }
3935 anyhow::bail!("generate_file requires template, content, or template_json")
3936}
3937
3938fn render_generated_value(value: Value, state: &SetupMachineState) -> anyhow::Result<String> {
3939 match value {
3940 Value::String(text) => expand_machine_template(&text, state),
3941 other => Ok(serde_json::to_string_pretty(
3942 &expand_machine_value_templates(other, state),
3943 )?),
3944 }
3945}
3946
3947fn execute_http_step(
3948 machine: &SetupMachine,
3949 step: &SetupMachineStep,
3950 state: &mut SetupMachineState,
3951) -> MachineStepResult {
3952 let Some(template) = step
3953 .extra
3954 .get("url_template")
3955 .or_else(|| step.extra.get("url"))
3956 .and_then(Value::as_str)
3957 else {
3958 state.status = SetupMachineStatus::Paused;
3959 state.current_step = Some(step.id.clone());
3960 state.last_error = Some(serde_json::json!({
3961 "error": "missing_prerequisite",
3962 "step": step.id,
3963 "missing": "url_template",
3964 "recoverable": true,
3965 }));
3966 return MachineStepResult {
3967 ok: false,
3968 next: "declare url_template, then retry".to_string(),
3969 detail: serde_json::json!({
3970 "blocked": true,
3971 "retryable": true,
3972 "error": "missing_prerequisite",
3973 "missing_config_key": "url_template",
3974 }),
3975 };
3976 };
3977 let target = match expand_machine_template(template, state) {
3978 Ok(target) => target,
3979 Err(err) => {
3980 state.status = SetupMachineStatus::Paused;
3981 state.current_step = Some(step.id.clone());
3982 state.last_error = Some(serde_json::json!({
3983 "error": "missing_prerequisite",
3984 "step": step.id,
3985 "detail": err.to_string(),
3986 "recoverable": true,
3987 }));
3988 return MachineStepResult {
3989 ok: false,
3990 next: "provide missing template values, then retry".to_string(),
3991 detail: serde_json::json!({
3992 "blocked": true,
3993 "retryable": true,
3994 "error": "missing_prerequisite",
3995 "detail": err.to_string(),
3996 }),
3997 };
3998 }
3999 };
4000 if let Err(err) = validate_http_step_url(&target) {
4001 state.status = SetupMachineStatus::Paused;
4002 state.current_step = Some(step.id.clone());
4003 state.last_error = Some(serde_json::json!({
4004 "error": "invalid_answer",
4005 "step": step.id,
4006 "detail": err.to_string(),
4007 "recoverable": true,
4008 }));
4009 return MachineStepResult {
4010 ok: false,
4011 next: "fix setup HTTP URL, then retry".to_string(),
4012 detail: serde_json::json!({
4013 "blocked": true,
4014 "retryable": true,
4015 "error": "invalid_answer",
4016 "detail": err.to_string(),
4017 }),
4018 };
4019 }
4020
4021 let method = step
4022 .extra
4023 .get("method")
4024 .and_then(Value::as_str)
4025 .unwrap_or("GET")
4026 .to_ascii_uppercase();
4027 let expected_status = step
4028 .extra
4029 .get("expected_status")
4030 .or_else(|| step.extra.get("status"))
4031 .and_then(Value::as_u64)
4032 .unwrap_or(200) as u16;
4033 let response = match method.as_str() {
4034 "GET" => crate::http_client::api_agent().get(&target).call(),
4035 "POST" => {
4036 let payload = step
4037 .extra
4038 .get("body")
4039 .cloned()
4040 .unwrap_or_else(|| serde_json::json!({}));
4041 let payload = expand_machine_value_templates(payload, state);
4042 crate::http_client::api_agent()
4043 .post(&target)
4044 .send_json(payload)
4045 }
4046 _ => {
4047 state.status = SetupMachineStatus::Paused;
4048 state.current_step = Some(step.id.clone());
4049 state.last_error = Some(serde_json::json!({
4050 "error": "unsupported_http_method",
4051 "step": step.id,
4052 "method": method,
4053 "recoverable": true,
4054 }));
4055 return MachineStepResult {
4056 ok: false,
4057 next: "fix setup HTTP method, then retry".to_string(),
4058 detail: serde_json::json!({
4059 "blocked": true,
4060 "retryable": true,
4061 "error": "unsupported_http_method",
4062 "method": method,
4063 }),
4064 };
4065 }
4066 };
4067 let mut response = match response {
4068 Ok(response) => response,
4069 Err(ureq::Error::StatusCode(status)) => {
4070 state.status = SetupMachineStatus::Paused;
4071 state.current_step = Some(step.id.clone());
4072 state.last_error = Some(serde_json::json!({
4073 "error": "provider_contract_error",
4074 "step": step.id,
4075 "status": status,
4076 "expected_status": expected_status,
4077 "recoverable": true,
4078 }));
4079 return MachineStepResult {
4080 ok: false,
4081 next: "setup HTTP endpoint returned an unexpected status".to_string(),
4082 detail: serde_json::json!({
4083 "blocked": true,
4084 "retryable": true,
4085 "error": "provider_contract_error",
4086 "status": status,
4087 "expected_status": expected_status,
4088 "target": target,
4089 }),
4090 };
4091 }
4092 Err(err) => {
4093 state.status = SetupMachineStatus::Paused;
4094 state.current_step = Some(step.id.clone());
4095 state.last_error = Some(serde_json::json!({
4096 "error": "external_timeout",
4097 "step": step.id,
4098 "detail": err.to_string(),
4099 "recoverable": true,
4100 }));
4101 return MachineStepResult {
4102 ok: false,
4103 next: "setup HTTP endpoint is not reachable".to_string(),
4104 detail: serde_json::json!({
4105 "blocked": true,
4106 "retryable": true,
4107 "error": "external_timeout",
4108 "detail": err.to_string(),
4109 "target": target,
4110 }),
4111 };
4112 }
4113 };
4114 let status = response.status().as_u16();
4115 if status != expected_status {
4116 state.status = SetupMachineStatus::Paused;
4117 state.current_step = Some(step.id.clone());
4118 state.last_error = Some(serde_json::json!({
4119 "error": "provider_contract_error",
4120 "step": step.id,
4121 "status": status,
4122 "expected_status": expected_status,
4123 "recoverable": true,
4124 }));
4125 return MachineStepResult {
4126 ok: false,
4127 next: "setup HTTP endpoint returned an unexpected status".to_string(),
4128 detail: serde_json::json!({
4129 "blocked": true,
4130 "retryable": true,
4131 "error": "provider_contract_error",
4132 "status": status,
4133 "expected_status": expected_status,
4134 "target": target,
4135 }),
4136 };
4137 }
4138
4139 let body = response
4140 .body_mut()
4141 .read_json::<Value>()
4142 .unwrap_or(Value::Null);
4143 if step.kind == "http_json" {
4144 let output_key = step
4145 .extra
4146 .get("output_key")
4147 .and_then(Value::as_str)
4148 .unwrap_or(step.id.as_str());
4149 ensure_outputs_object(state).insert(output_key.to_string(), body.clone());
4150 }
4151 mark_machine_step_complete(machine, step, state);
4152 MachineStepResult {
4153 ok: true,
4154 next: "HTTP setup step completed".to_string(),
4155 detail: serde_json::json!({
4156 "kind": step.kind,
4157 "method": method,
4158 "status": status,
4159 "target": target,
4160 "body": if step.kind == "http_json" { body } else { Value::Null },
4161 }),
4162 }
4163}
4164
4165fn execute_platform_public_url_step(
4166 bundle_root: &Path,
4167 machine: &SetupMachine,
4168 step: &SetupMachineStep,
4169 state: &mut SetupMachineState,
4170) -> MachineStepResult {
4171 let key = step
4172 .extra
4173 .get("output_key")
4174 .and_then(Value::as_str)
4175 .unwrap_or("public_base_url");
4176 let candidate = output_string(&state.outputs, key)
4177 .or_else(|| {
4178 step.extra
4179 .get(key)
4180 .and_then(Value::as_str)
4181 .map(str::to_string)
4182 })
4183 .or_else(|| {
4184 step.extra
4185 .get("default")
4186 .or_else(|| step.extra.get("default_url"))
4187 .and_then(Value::as_str)
4188 .map(str::to_string)
4189 })
4190 .or_else(|| {
4191 crate::platform_setup::load_effective_static_routes_defaults(
4192 bundle_root,
4193 &state.tenant,
4194 Some(&state.team),
4195 )
4196 .ok()
4197 .flatten()
4198 .and_then(|policy| policy.public_base_url)
4199 })
4200 .map(|value| value.trim_end_matches('/').to_string())
4201 .filter(|value| !value.trim().is_empty());
4202
4203 let Some(public_base_url) = candidate else {
4204 state.status = SetupMachineStatus::Paused;
4205 state.current_step = Some(step.id.clone());
4206 state.last_error = Some(serde_json::json!({
4207 "error": "missing_prerequisite",
4208 "step": step.id,
4209 "missing": key,
4210 "recoverable": true,
4211 }));
4212 return MachineStepResult {
4213 ok: false,
4214 next: "configure public_base_url, then retry".to_string(),
4215 detail: serde_json::json!({
4216 "blocked": true,
4217 "retryable": true,
4218 "error": "missing_prerequisite",
4219 "missing_config_key": key,
4220 }),
4221 };
4222 };
4223
4224 if url::Url::parse(&public_base_url).is_err() {
4225 state.status = SetupMachineStatus::Paused;
4226 state.current_step = Some(step.id.clone());
4227 state.last_error = Some(serde_json::json!({
4228 "error": "invalid_answer",
4229 "step": step.id,
4230 "field": key,
4231 "recoverable": true,
4232 }));
4233 return MachineStepResult {
4234 ok: false,
4235 next: "fix public_base_url, then retry".to_string(),
4236 detail: serde_json::json!({
4237 "blocked": true,
4238 "retryable": true,
4239 "error": "invalid_answer",
4240 "field": key,
4241 }),
4242 };
4243 }
4244
4245 ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4246 mark_machine_step_complete(machine, step, state);
4247 MachineStepResult {
4248 ok: true,
4249 next: "public URL resolved".to_string(),
4250 detail: serde_json::json!({
4251 "kind": step.kind,
4252 "output_key": key,
4253 "public_base_url": public_base_url,
4254 }),
4255 }
4256}
4257
4258fn execute_cloudflare_tunnel_step(
4259 bundle_root: &Path,
4260 machine: &SetupMachine,
4261 step: &SetupMachineStep,
4262 state: &mut SetupMachineState,
4263) -> MachineStepResult {
4264 let key = step
4265 .extra
4266 .get("output_key")
4267 .and_then(Value::as_str)
4268 .unwrap_or("public_base_url");
4269 let candidate = output_string(&state.outputs, key)
4270 .or_else(|| {
4271 step.extra
4272 .get(key)
4273 .and_then(Value::as_str)
4274 .map(str::to_string)
4275 })
4276 .or_else(|| {
4277 step.extra
4278 .get("public_base_url")
4279 .or_else(|| step.extra.get("default"))
4280 .or_else(|| step.extra.get("default_url"))
4281 .and_then(Value::as_str)
4282 .map(str::to_string)
4283 })
4284 .or_else(|| {
4285 crate::platform_setup::load_effective_static_routes_defaults(
4286 bundle_root,
4287 &state.tenant,
4288 Some(&state.team),
4289 )
4290 .ok()
4291 .flatten()
4292 .and_then(|policy| policy.public_base_url)
4293 })
4294 .map(|value| value.trim_end_matches('/').to_string())
4295 .filter(|value| !value.trim().is_empty());
4296
4297 let Some(public_base_url) = candidate else {
4298 state.status = SetupMachineStatus::Paused;
4299 state.current_step = Some(step.id.clone());
4300 state.last_error = Some(serde_json::json!({
4301 "error": "tunnel_unreachable",
4302 "step": step.id,
4303 "missing": key,
4304 "recoverable": true,
4305 }));
4306 return MachineStepResult {
4307 ok: false,
4308 next: "start or configure a setup tunnel, then retry".to_string(),
4309 detail: serde_json::json!({
4310 "blocked": true,
4311 "retryable": true,
4312 "error": "tunnel_unreachable",
4313 "missing_config_key": key,
4314 }),
4315 };
4316 };
4317
4318 let parsed = match url::Url::parse(&public_base_url) {
4319 Ok(parsed) => parsed,
4320 Err(err) => {
4321 state.status = SetupMachineStatus::Paused;
4322 state.current_step = Some(step.id.clone());
4323 state.last_error = Some(serde_json::json!({
4324 "error": "invalid_answer",
4325 "step": step.id,
4326 "field": key,
4327 "detail": err.to_string(),
4328 "recoverable": true,
4329 }));
4330 return MachineStepResult {
4331 ok: false,
4332 next: "fix setup tunnel URL, then retry".to_string(),
4333 detail: serde_json::json!({
4334 "blocked": true,
4335 "retryable": true,
4336 "error": "invalid_answer",
4337 "field": key,
4338 "detail": err.to_string(),
4339 }),
4340 };
4341 }
4342 };
4343 if parsed.scheme() != "https" || parsed.host_str().is_none() {
4344 state.status = SetupMachineStatus::Paused;
4345 state.current_step = Some(step.id.clone());
4346 state.last_error = Some(serde_json::json!({
4347 "error": "invalid_answer",
4348 "step": step.id,
4349 "field": key,
4350 "detail": "setup tunnel URL must be an https URL with a host",
4351 "recoverable": true,
4352 }));
4353 return MachineStepResult {
4354 ok: false,
4355 next: "fix setup tunnel URL, then retry".to_string(),
4356 detail: serde_json::json!({
4357 "blocked": true,
4358 "retryable": true,
4359 "error": "invalid_answer",
4360 "field": key,
4361 "detail": "setup tunnel URL must be an https URL with a host",
4362 }),
4363 };
4364 }
4365
4366 ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4367 mark_machine_step_complete(machine, step, state);
4368 MachineStepResult {
4369 ok: true,
4370 next: "setup tunnel URL resolved".to_string(),
4371 detail: serde_json::json!({
4372 "kind": step.kind,
4373 "output_key": key,
4374 "public_base_url": public_base_url,
4375 "ephemeral": crate::setup_tunnel::is_ephemeral_tunnel_url(&public_base_url),
4376 }),
4377 }
4378}
4379
4380fn ensure_outputs_object(state: &mut SetupMachineState) -> &mut serde_json::Map<String, Value> {
4381 if !state.outputs.is_object() {
4382 state.outputs = serde_json::json!({});
4383 }
4384 state.outputs.as_object_mut().expect("outputs object")
4385}
4386
4387fn output_string(outputs: &Value, key: &str) -> Option<String> {
4388 outputs
4389 .get(key)
4390 .and_then(Value::as_str)
4391 .map(str::trim)
4392 .filter(|value| !value.is_empty())
4393 .map(str::to_string)
4394}
4395
4396fn validate_http_step_url(value: &str) -> anyhow::Result<()> {
4397 let url = url::Url::parse(value)?;
4398 match url.scheme() {
4399 "http" if url.host_str().is_some_and(is_loopback_host) => Ok(()),
4400 "https" => Ok(()),
4401 _ => anyhow::bail!("setup HTTP URL must use https or loopback http"),
4402 }
4403}
4404
4405fn is_loopback_host(host: &str) -> bool {
4406 matches!(host, "localhost" | "127.0.0.1" | "::1")
4407}
4408
4409fn expand_machine_template(template: &str, state: &SetupMachineState) -> anyhow::Result<String> {
4410 let mut expanded = template
4411 .replace("{tenant}", &state.tenant)
4412 .replace("{team}", &state.team)
4413 .replace("{provider_id}", &state.provider_id);
4414 let mut start = 0;
4415 while let Some(open) = expanded[start..].find('{') {
4416 let open = start + open;
4417 let Some(close_rel) = expanded[open + 1..].find('}') else {
4418 break;
4419 };
4420 let close = open + 1 + close_rel;
4421 let key = &expanded[open + 1..close];
4422 let Some(value) = output_string(&state.outputs, key) else {
4423 anyhow::bail!("missing setup-machine output `{key}`");
4424 };
4425 expanded.replace_range(open..=close, &value);
4426 start = open + value.len();
4427 }
4428 Ok(expanded)
4429}
4430
4431fn expand_machine_value_templates(value: Value, state: &SetupMachineState) -> Value {
4432 match value {
4433 Value::String(text) => expand_machine_template(&text, state)
4434 .map(Value::String)
4435 .unwrap_or(Value::String(text)),
4436 Value::Array(values) => Value::Array(
4437 values
4438 .into_iter()
4439 .map(|value| expand_machine_value_templates(value, state))
4440 .collect(),
4441 ),
4442 Value::Object(map) => Value::Object(
4443 map.into_iter()
4444 .map(|(key, value)| (key, expand_machine_value_templates(value, state)))
4445 .collect(),
4446 ),
4447 other => other,
4448 }
4449}
4450
4451fn mark_machine_step_complete(
4452 machine: &SetupMachine,
4453 step: &SetupMachineStep,
4454 state: &mut SetupMachineState,
4455) {
4456 if !state.completed_steps.iter().any(|id| id == &step.id) {
4457 state.completed_steps.push(step.id.clone());
4458 }
4459 state.last_error = None;
4460 state.failed_step = None;
4461 match step.on_success.as_deref() {
4462 Some(TERMINAL_SUCCESS) | None => {
4463 state.status = SetupMachineStatus::Complete;
4464 state.current_step = None;
4465 }
4466 Some(TERMINAL_FAILED) => {
4467 state.status = SetupMachineStatus::Failed;
4468 state.current_step = None;
4469 state.failed_step = Some(step.id.clone());
4470 state.last_error = Some(serde_json::json!({
4471 "error": "transitioned_to_failed",
4472 "step": step.id,
4473 }));
4474 }
4475 Some(next) if machine.steps.iter().any(|candidate| candidate.id == next) => {
4476 state.status = SetupMachineStatus::Running;
4477 state.current_step = Some(next.to_string());
4478 }
4479 Some(next) => {
4480 state.status = SetupMachineStatus::Failed;
4481 state.current_step = Some(step.id.clone());
4482 state.failed_step = Some(step.id.clone());
4483 state.last_error = Some(serde_json::json!({
4484 "error": "invalid_transition_target",
4485 "step": step.id,
4486 "target": next,
4487 }));
4488 }
4489 }
4490}
4491
4492fn machine_step_output(
4493 machine: &SetupMachine,
4494 state: &SetupMachineState,
4495 step_id: &str,
4496 dry_run: bool,
4497 ok: bool,
4498 next: &str,
4499 event_path: Option<PathBuf>,
4500) -> Value {
4501 serde_json::json!({
4502 "provider_id": state.provider_id,
4503 "tenant": state.tenant,
4504 "team": state.team,
4505 "machine_id": machine.id,
4506 "dry_run": dry_run,
4507 "step": step_id,
4508 "ok": ok,
4509 "next": next,
4510 "result": {
4511 "ok": ok,
4512 "status": state.status,
4513 },
4514 "event_path": event_path,
4515 "status": render_setup_machine_status(machine, state),
4516 })
4517}
4518
4519fn append_machine_step_event(
4520 bundle_root: &Path,
4521 state: &SetupMachineState,
4522 event_name: &str,
4523 step_id: Option<&str>,
4524 detail: Option<Value>,
4525) -> anyhow::Result<PathBuf> {
4526 let mut fields = serde_json::Map::new();
4527 if let Some(detail) = detail {
4528 fields.insert("detail".to_string(), detail);
4529 }
4530 append_setup_machine_event(
4531 bundle_root,
4532 &SetupMachineEvent {
4533 ts: now_setup_ts(),
4534 level: "info".to_string(),
4535 event: event_name.to_string(),
4536 provider_id: state.provider_id.clone(),
4537 tenant: state.tenant.clone(),
4538 team: state.team.clone(),
4539 machine_id: state.machine_id.clone(),
4540 step_id: step_id.map(str::to_string),
4541 correlation_id: Some(format!("setup-{}", now_setup_ms())),
4542 fields,
4543 },
4544 )
4545}
4546
4547fn now_setup_ts() -> String {
4548 now_setup_ms().to_string()
4549}
4550
4551fn now_setup_ms() -> u128 {
4552 SystemTime::now()
4553 .duration_since(UNIX_EPOCH)
4554 .unwrap_or_default()
4555 .as_millis()
4556}
4557
4558fn safe_archive_segment(value: &str) -> String {
4559 let mut out = value
4560 .chars()
4561 .map(|ch| {
4562 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
4563 ch
4564 } else {
4565 '-'
4566 }
4567 })
4568 .collect::<String>();
4569 while out.contains("--") {
4570 out = out.replace("--", "-");
4571 }
4572 let out = out.trim_matches('-');
4573 if out.is_empty() {
4574 "archive".to_string()
4575 } else {
4576 out.to_string()
4577 }
4578}
4579
4580fn resolve_machine_value(pack_path: &Path, extension: Value) -> anyhow::Result<Value> {
4581 if let Some(inline) = extension.get("inline") {
4582 return Ok(inline.clone());
4583 }
4584 if let Some(path) = extension.get("path").and_then(Value::as_str) {
4585 return discovery::read_pack_json_asset(pack_path, path);
4586 }
4587 Ok(extension)
4588}
4589
4590fn extension_inline(extension: &Value) -> Option<&Value> {
4591 extension.get("inline").or(Some(extension))
4592}
4593
4594fn validate_schema_id(value: &Value, expected: &str, label: &str) -> anyhow::Result<()> {
4595 let schema_id = value
4596 .get("schema_id")
4597 .and_then(Value::as_str)
4598 .unwrap_or(expected);
4599 if schema_id != expected {
4600 anyhow::bail!("{label} has schema_id {schema_id}, expected {expected}");
4601 }
4602 Ok(())
4603}
4604
4605fn validate_provider_id(
4606 provider_id: Option<&str>,
4607 expected_provider_id: Option<&str>,
4608) -> anyhow::Result<()> {
4609 let Some(expected) = expected_provider_id else {
4610 return Ok(());
4611 };
4612 let Some(actual) = provider_id else {
4613 anyhow::bail!("setup backend contract provider_id is missing");
4614 };
4615 if actual != expected {
4616 anyhow::bail!(
4617 "setup backend contract provider_id {actual} does not match pack id {expected}"
4618 );
4619 }
4620 Ok(())
4621}
4622
4623fn string_array(value: Option<&Value>) -> Vec<String> {
4624 value
4625 .and_then(Value::as_array)
4626 .into_iter()
4627 .flatten()
4628 .filter_map(Value::as_str)
4629 .map(str::trim)
4630 .filter(|value| !value.is_empty())
4631 .map(str::to_string)
4632 .collect()
4633}
4634
4635fn validate_unique_strings(check_id: &str, message: &str, values: &[String]) -> Vec<Diagnostic> {
4636 let mut diagnostics = Vec::new();
4637 let mut seen = BTreeSet::new();
4638 for value in values {
4639 if !seen.insert(value) {
4640 diagnostics.push(error_with_actual(check_id, message, value));
4641 }
4642 }
4643 diagnostics
4644}
4645
4646fn require_executor_str(
4647 diagnostics: &mut Vec<Diagnostic>,
4648 action_id: &str,
4649 executor: &serde_json::Map<String, Value>,
4650 key: &str,
4651) {
4652 if executor
4653 .get(key)
4654 .and_then(Value::as_str)
4655 .is_none_or(|value| value.trim().is_empty())
4656 {
4657 diagnostics.push(error_with_actual(
4658 "setup.backend_contract.executor.required_field",
4659 "setup backend executor is missing a required field",
4660 &format!("{action_id}.{key}"),
4661 ));
4662 }
4663}
4664
4665fn validate_transition(
4666 diagnostics: &mut Vec<Diagnostic>,
4667 step_ids: &BTreeSet<String>,
4668 source_step: &str,
4669 check_id: &str,
4670 target: Option<&str>,
4671) {
4672 let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
4673 return;
4674 };
4675 if matches!(target, TERMINAL_SUCCESS | TERMINAL_FAILED) || step_ids.contains(target) {
4676 return;
4677 }
4678 diagnostics.push(Diagnostic {
4679 check_id: check_id.to_string(),
4680 severity: DiagnosticSeverity::Error,
4681 component: "provider_setup".to_string(),
4682 message: "setup transition target must reference an existing step or terminal state"
4683 .to_string(),
4684 evidence: Some(format!("{source_step} -> {target}")),
4685 expected: Some("existing step id, complete, or failed".to_string()),
4686 actual: Some(target.to_string()),
4687 fix_hint: Some("fix the transition target or add the missing setup step".to_string()),
4688 related_file: None,
4689 related_pack: None,
4690 related_component: None,
4691 });
4692}
4693
4694fn rule_targets(rule: &Value) -> Vec<String> {
4695 let Some(obj) = rule.as_object() else {
4696 return Vec::new();
4697 };
4698 ["step", "next", "target", "on_success"]
4699 .iter()
4700 .filter_map(|key| obj.get(*key).and_then(Value::as_str).map(str::to_string))
4701 .collect()
4702}
4703
4704fn validate_reachability(machine: &SetupMachine, step_ids: &BTreeSet<String>) -> Vec<Diagnostic> {
4705 if !step_ids.contains(&machine.entry_step) {
4706 return Vec::new();
4707 }
4708
4709 let mut seen = BTreeSet::new();
4710 let mut queue = VecDeque::from([machine.entry_step.clone()]);
4711 while let Some(step_id) = queue.pop_front() {
4712 if !seen.insert(step_id.clone()) {
4713 continue;
4714 }
4715 let Some(step) = machine.steps.iter().find(|step| step.id == step_id) else {
4716 continue;
4717 };
4718 if let Some(target) = step.on_success.as_deref()
4719 && step_ids.contains(target)
4720 {
4721 queue.push_back(target.to_string());
4722 }
4723 for rule in step.on_failure.iter().chain(step.recover.iter()) {
4724 for target in rule_targets(rule) {
4725 if step_ids.contains(&target) {
4726 queue.push_back(target);
4727 }
4728 }
4729 }
4730 }
4731
4732 step_ids
4733 .difference(&seen)
4734 .map(|step_id| {
4735 error_with_actual(
4736 "setup.machine.step.reachable",
4737 "setup step is not reachable from entry_step",
4738 step_id,
4739 )
4740 })
4741 .collect()
4742}
4743
4744fn validate_unbounded_cycles(
4745 machine: &SetupMachine,
4746 step_ids: &BTreeSet<String>,
4747) -> Vec<Diagnostic> {
4748 let steps: BTreeMap<&str, &SetupMachineStep> = machine
4749 .steps
4750 .iter()
4751 .map(|step| (step.id.as_str(), step))
4752 .collect();
4753 let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
4754 for step in &machine.steps {
4755 let mut targets = Vec::new();
4756 if let Some(target) = step.on_success.as_deref()
4757 && step_ids.contains(target)
4758 {
4759 targets.push(target.to_string());
4760 }
4761 for rule in step.on_failure.iter().chain(step.recover.iter()) {
4762 for target in rule_targets(rule) {
4763 if step_ids.contains(&target) {
4764 targets.push(target);
4765 }
4766 }
4767 }
4768 edges.insert(step.id.clone(), targets);
4769 }
4770
4771 let mut diagnostics = Vec::new();
4772 let mut visited = BTreeSet::new();
4773 let mut stack = Vec::<String>::new();
4774 let mut reported = BTreeSet::new();
4775 for step_id in step_ids {
4776 detect_cycle(
4777 step_id,
4778 &edges,
4779 &steps,
4780 &mut visited,
4781 &mut stack,
4782 &mut reported,
4783 &mut diagnostics,
4784 );
4785 }
4786 diagnostics
4787}
4788
4789fn validate_artifact_paths(machine: &SetupMachine) -> Vec<Diagnostic> {
4790 let mut diagnostics = Vec::new();
4791 for step in &machine.steps {
4792 if !matches!(step.kind.as_str(), "generate_file" | "download_file") {
4793 continue;
4794 }
4795 let path = step
4796 .extra
4797 .get("artifact_path")
4798 .or_else(|| step.extra.get("path"))
4799 .or_else(|| step.extra.get("output_path"))
4800 .and_then(Value::as_str)
4801 .map(str::trim)
4802 .filter(|value| !value.is_empty());
4803 let Some(path) = path else {
4804 diagnostics.push(error_with_actual(
4805 "setup.machine.artifact.path",
4806 "setup artifact steps must declare artifact_path",
4807 &step.id,
4808 ));
4809 continue;
4810 };
4811 if path.starts_with('/')
4812 || path.contains("..")
4813 || !(path.starts_with("artifacts/") || path.starts_with("state/setup/"))
4814 {
4815 diagnostics.push(error_with_actual(
4816 "setup.machine.artifact.path.safe",
4817 "setup artifact path must be relative and stay under setup artifacts",
4818 &format!("{}: {}", step.id, path),
4819 ));
4820 }
4821 }
4822 diagnostics
4823}
4824
4825fn validate_http_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4826 let mut diagnostics = Vec::new();
4827 for step in &machine.steps {
4828 if !matches!(step.kind.as_str(), "http_probe" | "http_json") {
4829 continue;
4830 }
4831 let url = step
4832 .extra
4833 .get("url_template")
4834 .or_else(|| step.extra.get("url"))
4835 .and_then(Value::as_str)
4836 .map(str::trim)
4837 .filter(|value| !value.is_empty());
4838 let Some(url) = url else {
4839 diagnostics.push(error_with_actual(
4840 "setup.machine.http.url",
4841 "setup HTTP steps must declare url_template",
4842 &step.id,
4843 ));
4844 continue;
4845 };
4846 if !url.contains('{')
4847 && let Err(err) = validate_http_step_url(url)
4848 {
4849 diagnostics.push(error_with_actual(
4850 "setup.machine.http.url.safe",
4851 "setup HTTP URL must use https or loopback http",
4852 &format!("{}: {url} ({err})", step.id),
4853 ));
4854 }
4855 let method = step
4856 .extra
4857 .get("method")
4858 .and_then(Value::as_str)
4859 .unwrap_or("GET")
4860 .to_ascii_uppercase();
4861 if !matches!(method.as_str(), "GET" | "POST") {
4862 diagnostics.push(error_with_actual(
4863 "setup.machine.http.method",
4864 "setup HTTP method must be GET or POST",
4865 &format!("{}: {method}", step.id),
4866 ));
4867 }
4868 if step.kind == "http_json" && method != "GET" && !has_idempotency_key(step) {
4869 diagnostics.push(error_with_actual(
4870 "setup.machine.step.idempotency_key",
4871 "side-effecting setup steps must declare idempotency_key",
4872 &step.id,
4873 ));
4874 }
4875 if let Some(status) = step
4876 .extra
4877 .get("expected_status")
4878 .or_else(|| step.extra.get("status"))
4879 .and_then(Value::as_u64)
4880 && !(100..=599).contains(&status)
4881 {
4882 diagnostics.push(error_with_actual(
4883 "setup.machine.http.expected_status",
4884 "setup HTTP expected_status must be a valid HTTP status code",
4885 &format!("{}: {status}", step.id),
4886 ));
4887 }
4888 }
4889 diagnostics
4890}
4891
4892fn validate_tunnel_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4893 let mut diagnostics = Vec::new();
4894 for step in &machine.steps {
4895 if step.kind != "cloudflare_tunnel" {
4896 continue;
4897 }
4898 if let Some(mode) = step.extra.get("mode").and_then(Value::as_str)
4899 && !matches!(mode, "cloudflared" | "cloudflare" | "external")
4900 {
4901 diagnostics.push(error_with_actual(
4902 "setup.machine.cloudflare_tunnel.mode",
4903 "cloudflare_tunnel mode must be cloudflared, cloudflare, or external",
4904 &format!("{}: {}", step.id, mode),
4905 ));
4906 }
4907 if let Some(url) = step
4908 .extra
4909 .get("public_base_url")
4910 .or_else(|| step.extra.get("default"))
4911 .or_else(|| step.extra.get("default_url"))
4912 .and_then(Value::as_str)
4913 .map(str::trim)
4914 .filter(|value| !value.is_empty())
4915 && !url.contains('{')
4916 {
4917 match url::Url::parse(url) {
4918 Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
4919 Ok(_) | Err(_) => diagnostics.push(error_with_actual(
4920 "setup.machine.cloudflare_tunnel.public_base_url",
4921 "cloudflare_tunnel declared public URL must be https with a host",
4922 &format!("{}: {}", step.id, url),
4923 )),
4924 }
4925 }
4926 }
4927 diagnostics
4928}
4929
4930fn validate_oauth_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4931 let mut diagnostics = Vec::new();
4932 for step in &machine.steps {
4933 match step.kind.as_str() {
4934 "oauth_device_code" => validate_oauth_device_step(&mut diagnostics, step),
4935 "oauth_authorization_code" => validate_oauth_authorization_step(&mut diagnostics, step),
4936 _ => {}
4937 }
4938 }
4939 diagnostics
4940}
4941
4942fn validate_oauth_device_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
4943 let has_device_url = step
4944 .extra
4945 .get("device_code_url")
4946 .or_else(|| step.extra.get("device_code_url_template"))
4947 .and_then(Value::as_str)
4948 .map(str::trim)
4949 .filter(|value| !value.is_empty())
4950 .is_some();
4951 let has_token_url = step
4952 .extra
4953 .get("token_url")
4954 .or_else(|| step.extra.get("token_url_template"))
4955 .and_then(Value::as_str)
4956 .map(str::trim)
4957 .filter(|value| !value.is_empty())
4958 .is_some();
4959 let has_authority = step
4960 .extra
4961 .get("authority_url_template")
4962 .or_else(|| step.extra.get("authority_url"))
4963 .and_then(Value::as_str)
4964 .map(str::trim)
4965 .filter(|value| !value.is_empty())
4966 .is_some();
4967 if !has_authority && (!has_device_url || !has_token_url) {
4968 diagnostics.push(error_with_actual(
4969 "setup.machine.oauth_device_code.endpoints",
4970 "oauth_device_code steps must declare authority_url_template or both device_code_url and token_url",
4971 &step.id,
4972 ));
4973 }
4974 for key in ["device_code_url", "token_url", "authority_url"] {
4975 if let Some(url) = step
4976 .extra
4977 .get(key)
4978 .or_else(|| step.extra.get(&format!("{key}_template")))
4979 .and_then(Value::as_str)
4980 .map(str::trim)
4981 .filter(|value| !value.is_empty())
4982 && !url.contains('{')
4983 && let Err(err) = validate_http_step_url(url)
4984 {
4985 diagnostics.push(error_with_actual(
4986 "setup.machine.oauth_device_code.url.safe",
4987 "oauth_device_code URLs must use https or loopback http",
4988 &format!("{}: {url} ({err})", step.id),
4989 ));
4990 }
4991 }
4992 let has_client_id = step
4993 .extra
4994 .get("client_id")
4995 .or_else(|| step.extra.get("client_id_output"))
4996 .and_then(Value::as_str)
4997 .map(str::trim)
4998 .filter(|value| !value.is_empty())
4999 .is_some();
5000 if !has_client_id {
5001 diagnostics.push(error_with_actual(
5002 "setup.machine.oauth_device_code.client_id",
5003 "oauth_device_code steps must declare client_id or client_id_output",
5004 &step.id,
5005 ));
5006 }
5007 if step.extra.get("scope").is_none() && step.extra.get("scopes").is_none() {
5008 diagnostics.push(error_with_actual(
5009 "setup.machine.oauth_device_code.scopes",
5010 "oauth_device_code steps must declare scope or scopes",
5011 &step.id,
5012 ));
5013 }
5014 if let Some(scopes) = step.extra.get("scopes")
5015 && !scopes.is_array()
5016 {
5017 diagnostics.push(error_with_actual(
5018 "setup.machine.oauth_device_code.scopes",
5019 "oauth_device_code scopes must be an array",
5020 &step.id,
5021 ));
5022 }
5023}
5024
5025fn validate_oauth_authorization_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
5026 let authorize_url = step
5027 .extra
5028 .get("authorize_url")
5029 .or_else(|| step.extra.get("authorization_url"))
5030 .or_else(|| step.extra.get("auth_url"))
5031 .and_then(Value::as_str)
5032 .map(str::trim)
5033 .filter(|value| !value.is_empty());
5034 let Some(authorize_url) = authorize_url else {
5035 diagnostics.push(error_with_actual(
5036 "setup.machine.oauth_authorization_code.authorize_url",
5037 "oauth_authorization_code steps must declare authorize_url",
5038 &step.id,
5039 ));
5040 return;
5041 };
5042 if !authorize_url.contains('{') {
5043 match url::Url::parse(authorize_url) {
5044 Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
5045 Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5046 "setup.machine.oauth_authorization_code.authorize_url.safe",
5047 "oauth_authorization_code authorize_url must be https with a host",
5048 &format!("{}: {}", step.id, authorize_url),
5049 )),
5050 }
5051 }
5052 let token_url = step
5053 .extra
5054 .get("token_url")
5055 .or_else(|| step.extra.get("token_url_template"))
5056 .and_then(Value::as_str)
5057 .map(str::trim)
5058 .filter(|value| !value.is_empty());
5059 match token_url {
5060 Some(token_url) if !token_url.contains('{') => match url::Url::parse(token_url) {
5061 Ok(parsed)
5062 if (parsed.host_str().is_some() && parsed.scheme() == "https")
5063 || (parsed.scheme() == "http"
5064 && matches!(
5065 parsed.host_str(),
5066 Some("127.0.0.1" | "localhost" | "::1")
5067 )) => {}
5068 Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5069 "setup.machine.oauth_authorization_code.token_url.safe",
5070 "oauth_authorization_code token_url must be https or loopback http",
5071 &format!("{}: {}", step.id, token_url),
5072 )),
5073 },
5074 Some(_) => {}
5075 None => diagnostics.push(error_with_actual(
5076 "setup.machine.oauth_authorization_code.token_url",
5077 "oauth_authorization_code steps must declare token_url",
5078 &step.id,
5079 )),
5080 }
5081 if let Some(scopes) = step.extra.get("scopes")
5082 && !scopes.is_array()
5083 {
5084 diagnostics.push(error_with_actual(
5085 "setup.machine.oauth_authorization_code.scopes",
5086 "oauth_authorization_code scopes must be an array",
5087 &step.id,
5088 ));
5089 }
5090 if let Some(params) = step.extra.get("params")
5091 && !params.is_object()
5092 {
5093 diagnostics.push(error_with_actual(
5094 "setup.machine.oauth_authorization_code.params",
5095 "oauth_authorization_code params must be a JSON object",
5096 &step.id,
5097 ));
5098 }
5099 if let Some(ttl) = step.extra.get("state_ttl_secs").and_then(Value::as_u64)
5100 && ttl == 0
5101 {
5102 diagnostics.push(error_with_actual(
5103 "setup.machine.oauth_authorization_code.state_ttl_secs",
5104 "oauth_authorization_code state_ttl_secs must be greater than zero",
5105 &step.id,
5106 ));
5107 }
5108}
5109
5110fn validate_data_mapping_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
5111 let mut diagnostics = Vec::new();
5112 for step in &machine.steps {
5113 match step.kind.as_str() {
5114 "select_from_json" => {
5115 if step
5116 .extra
5117 .get("source_output")
5118 .or_else(|| step.extra.get("source"))
5119 .and_then(Value::as_str)
5120 .map(str::trim)
5121 .filter(|value| !value.is_empty())
5122 .is_none()
5123 {
5124 diagnostics.push(error_with_actual(
5125 "setup.machine.select_from_json.source_output",
5126 "select_from_json steps must declare source_output",
5127 &step.id,
5128 ));
5129 }
5130 if let Some(pointer) = step.extra.get("json_pointer").and_then(Value::as_str)
5131 && !pointer.starts_with('/')
5132 {
5133 diagnostics.push(error_with_actual(
5134 "setup.machine.select_from_json.json_pointer",
5135 "select_from_json json_pointer must use JSON Pointer syntax",
5136 &format!("{}: {}", step.id, pointer),
5137 ));
5138 }
5139 }
5140 "persist_runtime_config" => {
5141 let config = step.extra.get("config");
5142 let mappings = step.extra.get("mappings");
5143 if config.is_none() && mappings.is_none() {
5144 diagnostics.push(error_with_actual(
5145 "setup.machine.persist_runtime_config.input",
5146 "persist_runtime_config steps must declare config or mappings",
5147 &step.id,
5148 ));
5149 }
5150 for (name, value) in [("config", config), ("mappings", mappings)] {
5151 if let Some(value) = value
5152 && !value.is_object()
5153 {
5154 diagnostics.push(error_with_actual(
5155 "setup.machine.persist_runtime_config.object",
5156 "persist_runtime_config config and mappings must be JSON objects",
5157 &format!("{}: {}", step.id, name),
5158 ));
5159 }
5160 }
5161 if let Some(server_owned) = step.extra.get("server_owned_config_keys")
5162 && !server_owned.is_array()
5163 {
5164 diagnostics.push(error_with_actual(
5165 "setup.machine.persist_runtime_config.server_owned_config_keys",
5166 "persist_runtime_config server_owned_config_keys must be an array",
5167 &step.id,
5168 ));
5169 }
5170 let server_owned = setup_machine_server_owned_keys(machine, step);
5171 for (name, value) in [("config", config), ("mappings", mappings)] {
5172 if let Some(object) = value.and_then(Value::as_object) {
5173 for key in object.keys() {
5174 if server_owned.contains(key) {
5175 diagnostics.push(error_with_actual(
5176 "setup.machine.persist_runtime_config.server_owned_output",
5177 "persist_runtime_config config and mappings must not write server-owned keys",
5178 &format!("{}: {}.{}", step.id, name, key),
5179 ));
5180 }
5181 }
5182 }
5183 }
5184 }
5185 "provider_component_call" => {
5186 if !has_idempotency_key(step) {
5187 diagnostics.push(error_with_actual(
5188 "setup.machine.step.idempotency_key",
5189 "side-effecting setup steps must declare idempotency_key",
5190 &step.id,
5191 ));
5192 }
5193 if step
5194 .extra
5195 .get("component_ref")
5196 .or_else(|| step.extra.get("component"))
5197 .and_then(Value::as_str)
5198 .map(str::trim)
5199 .filter(|value| !value.is_empty())
5200 .is_none()
5201 {
5202 diagnostics.push(error_with_actual(
5203 "setup.machine.provider_component_call.component_ref",
5204 "provider_component_call steps must declare component_ref",
5205 &step.id,
5206 ));
5207 }
5208 if step
5209 .extra
5210 .get("op")
5211 .or_else(|| step.extra.get("operation"))
5212 .and_then(Value::as_str)
5213 .map(str::trim)
5214 .filter(|value| !value.is_empty())
5215 .is_none()
5216 {
5217 diagnostics.push(error_with_actual(
5218 "setup.machine.provider_component_call.op",
5219 "provider_component_call steps must declare op",
5220 &step.id,
5221 ));
5222 }
5223 if let Some(request) = step.extra.get("request").or_else(|| step.extra.get("body"))
5224 && !request.is_object()
5225 {
5226 diagnostics.push(error_with_actual(
5227 "setup.machine.provider_component_call.request",
5228 "provider_component_call request/body must be a JSON object",
5229 &step.id,
5230 ));
5231 }
5232 }
5233 _ => {}
5234 }
5235 }
5236 diagnostics
5237}
5238
5239fn validate_setup_machine_template_inputs(
5240 pack_path: &Path,
5241 provider_id: Option<&str>,
5242 machine: &SetupMachine,
5243) -> Vec<Diagnostic> {
5244 let mut available = BTreeSet::from([
5245 "tenant".to_string(),
5246 "team".to_string(),
5247 "provider_id".to_string(),
5248 ]);
5249
5250 if let Some(provider_id) = provider_id
5251 && let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
5252 {
5253 for question in form_spec.questions {
5254 if !question.id.trim().is_empty() {
5255 available.insert(question.id);
5256 }
5257 }
5258 }
5259
5260 for step in &machine.steps {
5261 available.extend(setup_machine_step_output_keys(step));
5262 }
5263
5264 let mut diagnostics = Vec::new();
5265 for step in &machine.steps {
5266 for (path, key) in setup_machine_step_template_references(step) {
5267 if !available.contains(&key) {
5268 diagnostics.push(error_with_actual(
5269 "setup.machine.template.input",
5270 "setup-machine template placeholder must reference a built-in value, setup answer, or declared setup output",
5271 &format!("{}: {} -> {}", step.id, path, key),
5272 ));
5273 }
5274 }
5275 }
5276 diagnostics
5277}
5278
5279fn setup_machine_step_template_references(step: &SetupMachineStep) -> Vec<(String, String)> {
5280 let mut references = Vec::new();
5281 for (key, value) in &step.extra {
5282 collect_template_references(value, key, &mut references);
5283 }
5284 references
5285}
5286
5287fn collect_template_references(value: &Value, path: &str, references: &mut Vec<(String, String)>) {
5288 match value {
5289 Value::String(text) => {
5290 for key in template_placeholders(text) {
5291 references.push((path.to_string(), key));
5292 }
5293 }
5294 Value::Array(values) => {
5295 for (idx, value) in values.iter().enumerate() {
5296 collect_template_references(value, &format!("{path}[{idx}]"), references);
5297 }
5298 }
5299 Value::Object(map) => {
5300 for (key, value) in map {
5301 collect_template_references(value, &format!("{path}.{key}"), references);
5302 }
5303 }
5304 _ => {}
5305 }
5306}
5307
5308fn template_placeholders(text: &str) -> Vec<String> {
5309 let mut keys = Vec::new();
5310 let mut start = 0;
5311 while let Some(open_rel) = text[start..].find('{') {
5312 let open = start + open_rel;
5313 let Some(close_rel) = text[open + 1..].find('}') else {
5314 break;
5315 };
5316 let close = open + 1 + close_rel;
5317 let key = text[open + 1..close].trim();
5318 if !key.is_empty() {
5319 keys.push(key.to_string());
5320 }
5321 start = close + 1;
5322 }
5323 keys
5324}
5325
5326fn setup_machine_step_output_keys(step: &SetupMachineStep) -> BTreeSet<String> {
5327 let mut keys = BTreeSet::new();
5328 for output in &step.outputs {
5329 match output {
5330 Value::String(key) if !key.trim().is_empty() => {
5331 keys.insert(key.trim().to_string());
5332 }
5333 Value::Object(object) => {
5334 for field in ["key", "name", "id"] {
5335 if let Some(key) = object
5336 .get(field)
5337 .and_then(Value::as_str)
5338 .map(str::trim)
5339 .filter(|value| !value.is_empty())
5340 {
5341 keys.insert(key.to_string());
5342 break;
5343 }
5344 }
5345 }
5346 _ => {}
5347 }
5348 }
5349
5350 let output_key = step
5351 .extra
5352 .get("output_key")
5353 .and_then(Value::as_str)
5354 .map(str::trim)
5355 .filter(|value| !value.is_empty());
5356 match step.kind.as_str() {
5357 "platform_public_url" | "cloudflare_tunnel" => {
5358 keys.insert(output_key.unwrap_or("public_base_url").to_string());
5359 }
5360 "oauth_device_code" => {
5361 keys.insert(output_key.unwrap_or("oauth_device_login").to_string());
5362 }
5363 "oauth_authorization_code" => {
5364 keys.insert(output_key.unwrap_or("authorization_url").to_string());
5365 }
5366 "http_json" | "select_from_json" | "generate_file" | "provider_component_call" => {
5367 let key = output_key.unwrap_or(step.id.as_str()).to_string();
5368 if step.kind == "select_from_json" {
5369 keys.insert(format!("{key}_item"));
5370 }
5371 keys.insert(key);
5372 }
5373 "persist_runtime_config" => {
5374 keys.insert(output_key.unwrap_or("runtime_config_path").to_string());
5375 }
5376 _ => {}
5377 }
5378 keys
5379}
5380
5381fn has_idempotency_key(step: &SetupMachineStep) -> bool {
5382 step.extra
5383 .get("idempotency_key")
5384 .and_then(Value::as_str)
5385 .map(str::trim)
5386 .is_some_and(|value| !value.is_empty())
5387}
5388
5389fn validate_setup_i18n_message_keys(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5390 let extension = match discovery::read_pack_extension(pack_path, SETUP_I18N_EXTENSION) {
5391 Ok(Some(extension)) => extension,
5392 Ok(None) => return Vec::new(),
5393 Err(err) => {
5394 return vec![load_error(
5395 "setup.i18n.load",
5396 "setup i18n descriptor could not be loaded",
5397 SETUP_I18N_EXTENSION,
5398 err,
5399 pack_path,
5400 )];
5401 }
5402 };
5403 let mut diagnostics = Vec::new();
5404 let declared_keys = match collect_setup_i18n_keys(pack_path, &extension) {
5405 Ok(keys) => keys,
5406 Err(err) => {
5407 diagnostics.push(load_error(
5408 "setup.i18n.messages.load",
5409 "setup i18n messages could not be loaded",
5410 "valid setup i18n JSON",
5411 err,
5412 pack_path,
5413 ));
5414 return diagnostics;
5415 }
5416 };
5417 if declared_keys.is_empty() {
5418 diagnostics.push(Diagnostic {
5419 check_id: "setup.i18n.messages.present".to_string(),
5420 severity: DiagnosticSeverity::Error,
5421 component: "provider_setup".to_string(),
5422 message: "setup i18n extension does not declare any message keys".to_string(),
5423 evidence: Some(SETUP_I18N_EXTENSION.to_string()),
5424 expected: Some("inline messages or message asset with at least one key".to_string()),
5425 actual: Some("empty".to_string()),
5426 fix_hint: Some(
5427 "add localized setup messages or remove the setup i18n extension".to_string(),
5428 ),
5429 related_file: Some(pack_path.display().to_string()),
5430 related_pack: Some(machine.id.clone()),
5431 related_component: None,
5432 });
5433 return diagnostics;
5434 }
5435
5436 for key in collect_setup_message_key_references(machine) {
5437 if !declared_keys.contains(&key) {
5438 diagnostics.push(error_with_actual(
5439 "setup.i18n.message_key.exists",
5440 "setup message_key must exist in greentic.setup.i18n.v1 messages",
5441 &key,
5442 ));
5443 }
5444 }
5445 diagnostics
5446}
5447
5448fn validate_setup_machine_fixtures(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5449 let mut diagnostics = Vec::new();
5450 let fixture_paths = setup_machine_fixture_paths(machine);
5451 if fixture_paths.is_empty() {
5452 return diagnostics;
5453 }
5454
5455 let step_ids = machine
5456 .steps
5457 .iter()
5458 .map(|step| step.id.as_str())
5459 .collect::<BTreeSet<_>>();
5460 for path in fixture_paths {
5461 let value = match discovery::read_pack_json_asset(pack_path, &path) {
5462 Ok(value) => value,
5463 Err(err) => {
5464 diagnostics.push(load_error(
5465 "setup.machine.fixture.load",
5466 "setup-machine fixture could not be loaded",
5467 "JSON fixture asset inside the provider pack",
5468 err,
5469 pack_path,
5470 ));
5471 continue;
5472 }
5473 };
5474 let state_value = value.get("state").cloned().unwrap_or(value);
5475 let state: SetupMachineState = match serde_json::from_value(state_value) {
5476 Ok(state) => state,
5477 Err(err) => {
5478 diagnostics.push(error_with_actual(
5479 "setup.machine.fixture.state",
5480 "setup-machine fixture must contain a valid setup-machine state",
5481 &format!("{path}: {err}"),
5482 ));
5483 continue;
5484 }
5485 };
5486 if state.machine_id != machine.id {
5487 diagnostics.push(error_with_actual(
5488 "setup.machine.fixture.machine_id",
5489 "setup-machine fixture machine_id must match the current machine",
5490 &format!("{}: {}", path, state.machine_id),
5491 ));
5492 }
5493 if state.machine_version != machine.version {
5494 diagnostics.push(error_with_actual(
5495 "setup.machine.fixture.machine_version",
5496 "setup-machine fixture machine_version must match the current machine version",
5497 &format!("{}: {}", path, state.machine_version),
5498 ));
5499 }
5500 if let Some(current_step) = state.current_step.as_deref()
5501 && !step_ids.contains(current_step)
5502 {
5503 diagnostics.push(error_with_actual(
5504 "setup.machine.fixture.current_step",
5505 "setup-machine fixture current_step must reference a declared step",
5506 &format!("{path}: {current_step}"),
5507 ));
5508 }
5509 if let Some(failed_step) = state.failed_step.as_deref()
5510 && !step_ids.contains(failed_step)
5511 {
5512 diagnostics.push(error_with_actual(
5513 "setup.machine.fixture.failed_step",
5514 "setup-machine fixture failed_step must reference a declared step",
5515 &format!("{path}: {failed_step}"),
5516 ));
5517 }
5518 for completed_step in &state.completed_steps {
5519 if !step_ids.contains(completed_step.as_str()) {
5520 diagnostics.push(error_with_actual(
5521 "setup.machine.fixture.completed_step",
5522 "setup-machine fixture completed_steps must reference declared steps",
5523 &format!("{path}: {completed_step}"),
5524 ));
5525 }
5526 }
5527 }
5528
5529 diagnostics
5530}
5531
5532fn setup_machine_fixture_paths(machine: &SetupMachine) -> Vec<String> {
5533 let mut paths = Vec::new();
5534 if let Some(fixtures) = machine.extra.get("fixtures").and_then(Value::as_array) {
5535 for fixture in fixtures {
5536 if let Some(path) = fixture.as_str() {
5537 paths.push(path.to_string());
5538 } else if let Some(path) = fixture.get("path").and_then(Value::as_str) {
5539 paths.push(path.to_string());
5540 }
5541 }
5542 }
5543 if let Some(fixture_paths) = machine.extra.get("fixture_paths").and_then(Value::as_array) {
5544 for path in fixture_paths.iter().filter_map(Value::as_str) {
5545 paths.push(path.to_string());
5546 }
5547 }
5548 paths
5549 .into_iter()
5550 .map(|path| path.trim().to_string())
5551 .filter(|path| !path.is_empty())
5552 .collect()
5553}
5554
5555fn collect_setup_i18n_keys(
5556 pack_path: &Path,
5557 extension: &Value,
5558) -> anyhow::Result<BTreeSet<String>> {
5559 let descriptor = extension_inline(extension).unwrap_or(extension);
5560 let mut keys = BTreeSet::new();
5561 collect_message_keys_from_value(&mut keys, descriptor);
5562
5563 for key in ["asset", "path", "messages_asset", "messages_path"] {
5564 if let Some(path) = descriptor.get(key).and_then(Value::as_str) {
5565 let value = discovery::read_pack_json_asset(pack_path, path)?;
5566 collect_message_keys_from_value(&mut keys, &value);
5567 }
5568 }
5569
5570 for key in ["assets", "paths", "locales"] {
5571 if let Some(map) = descriptor.get(key).and_then(Value::as_object) {
5572 for value in map.values() {
5573 if let Some(path) = value.as_str() {
5574 let messages = discovery::read_pack_json_asset(pack_path, path)?;
5575 collect_message_keys_from_value(&mut keys, &messages);
5576 } else {
5577 collect_message_keys_from_value(&mut keys, value);
5578 }
5579 }
5580 }
5581 }
5582
5583 Ok(keys)
5584}
5585
5586fn collect_message_keys_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5587 if let Some(messages) = value.get("messages").and_then(Value::as_object) {
5588 collect_message_keys_from_object(keys, messages);
5589 }
5590 if let Some(translations) = value.get("translations").and_then(Value::as_object) {
5591 collect_message_keys_from_object(keys, translations);
5592 }
5593 if let Some(obj) = value.as_object() {
5594 collect_message_keys_from_object(keys, obj);
5595 }
5596}
5597
5598fn collect_message_keys_from_object(
5599 keys: &mut BTreeSet<String>,
5600 obj: &serde_json::Map<String, Value>,
5601) {
5602 for (key, value) in obj {
5603 if [
5604 "schema_id",
5605 "provider_id",
5606 "default_locale",
5607 "asset",
5608 "path",
5609 "messages",
5610 "translations",
5611 "assets",
5612 "paths",
5613 "locales",
5614 ]
5615 .contains(&key.as_str())
5616 {
5617 continue;
5618 }
5619 match value {
5620 Value::String(_) => {
5621 keys.insert(key.clone());
5622 }
5623 Value::Object(map) if map.values().all(|candidate| candidate.as_str().is_some()) => {
5624 keys.insert(key.clone());
5625 }
5626 Value::Object(_) => collect_message_keys_from_value(keys, value),
5627 _ => {}
5628 }
5629 }
5630}
5631
5632fn collect_setup_message_key_references(machine: &SetupMachine) -> BTreeSet<String> {
5633 let mut keys = BTreeSet::new();
5634 for step in &machine.steps {
5635 collect_message_key_from_value(&mut keys, &Value::Object(step.extra.clone()));
5636 for rule in step.on_failure.iter().chain(step.recover.iter()) {
5637 collect_message_key_from_value(&mut keys, rule);
5638 }
5639 }
5640 keys
5641}
5642
5643fn collect_message_key_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5644 match value {
5645 Value::Object(map) => {
5646 for (key, nested) in map {
5647 if (key == "message_key" || key == "title_key" || key == "description_key")
5648 && let Some(message_key) = nested.as_str()
5649 && !message_key.trim().is_empty()
5650 {
5651 keys.insert(message_key.to_string());
5652 }
5653 collect_message_key_from_value(keys, nested);
5654 }
5655 }
5656 Value::Array(values) => {
5657 for nested in values {
5658 collect_message_key_from_value(keys, nested);
5659 }
5660 }
5661 _ => {}
5662 }
5663}
5664
5665fn detect_cycle(
5666 step_id: &str,
5667 edges: &BTreeMap<String, Vec<String>>,
5668 steps: &BTreeMap<&str, &SetupMachineStep>,
5669 visited: &mut BTreeSet<String>,
5670 stack: &mut Vec<String>,
5671 reported: &mut BTreeSet<String>,
5672 diagnostics: &mut Vec<Diagnostic>,
5673) {
5674 if let Some(position) = stack.iter().position(|candidate| candidate == step_id) {
5675 let cycle = stack[position..].to_vec();
5676 let key = cycle.join(" -> ");
5677 if reported.insert(key.clone())
5678 && !cycle.iter().any(|id| {
5679 steps.get(id.as_str()).is_some_and(|step| {
5680 step.extra.get("retry").is_some() || step.extra.get("timeout").is_some()
5681 })
5682 })
5683 {
5684 diagnostics.push(error_with_actual(
5685 "setup.machine.step.cycle",
5686 "setup step cycle must declare bounded retry or timeout policy",
5687 &key,
5688 ));
5689 }
5690 return;
5691 }
5692 if !visited.insert(step_id.to_string()) {
5693 return;
5694 }
5695 stack.push(step_id.to_string());
5696 for target in edges.get(step_id).into_iter().flatten() {
5697 detect_cycle(target, edges, steps, visited, stack, reported, diagnostics);
5698 }
5699 stack.pop();
5700}
5701
5702fn error(check_id: &str, message: &str) -> Diagnostic {
5703 Diagnostic {
5704 check_id: check_id.to_string(),
5705 severity: DiagnosticSeverity::Error,
5706 component: "provider_setup".to_string(),
5707 message: message.to_string(),
5708 evidence: None,
5709 expected: None,
5710 actual: None,
5711 fix_hint: None,
5712 related_file: None,
5713 related_pack: None,
5714 related_component: None,
5715 }
5716}
5717
5718fn error_with_actual(check_id: &str, message: &str, actual: &str) -> Diagnostic {
5719 Diagnostic {
5720 check_id: check_id.to_string(),
5721 severity: DiagnosticSeverity::Error,
5722 component: "provider_setup".to_string(),
5723 message: message.to_string(),
5724 evidence: None,
5725 expected: None,
5726 actual: Some(actual.to_string()),
5727 fix_hint: None,
5728 related_file: None,
5729 related_pack: None,
5730 related_component: None,
5731 }
5732}
5733
5734fn info(
5735 check_id: &str,
5736 message: &str,
5737 evidence: &str,
5738 pack_path: &Path,
5739 related_pack: Option<&str>,
5740) -> Diagnostic {
5741 Diagnostic {
5742 check_id: check_id.to_string(),
5743 severity: DiagnosticSeverity::Info,
5744 component: "provider_setup".to_string(),
5745 message: message.to_string(),
5746 evidence: Some(evidence.to_string()),
5747 expected: None,
5748 actual: None,
5749 fix_hint: None,
5750 related_file: Some(pack_path.display().to_string()),
5751 related_pack: related_pack.map(str::to_string),
5752 related_component: None,
5753 }
5754}
5755
5756fn load_error(
5757 check_id: &str,
5758 message: &str,
5759 expected: &str,
5760 err: anyhow::Error,
5761 pack_path: &Path,
5762) -> Diagnostic {
5763 Diagnostic {
5764 check_id: check_id.to_string(),
5765 severity: DiagnosticSeverity::Error,
5766 component: "provider_setup".to_string(),
5767 message: message.to_string(),
5768 evidence: Some(err.to_string()),
5769 expected: Some(expected.to_string()),
5770 actual: None,
5771 fix_hint: None,
5772 related_file: Some(pack_path.display().to_string()),
5773 related_pack: None,
5774 related_component: None,
5775 }
5776}
5777
5778fn report(pack_path: &Path, diagnostics: Vec<Diagnostic>) -> DoctorReport {
5779 let error_count = diagnostics
5780 .iter()
5781 .filter(|d| d.severity == DiagnosticSeverity::Error)
5782 .count();
5783 let warn_count = diagnostics
5784 .iter()
5785 .filter(|d| d.severity == DiagnosticSeverity::Warn)
5786 .count();
5787 let info_count = diagnostics
5788 .iter()
5789 .filter(|d| d.severity == DiagnosticSeverity::Info)
5790 .count();
5791 DoctorReport {
5792 bundle: pack_path.display().to_string(),
5793 status: if error_count > 0 {
5794 "error".to_string()
5795 } else if warn_count > 0 {
5796 "warn".to_string()
5797 } else {
5798 "ok".to_string()
5799 },
5800 error_count,
5801 warn_count,
5802 info_count,
5803 diagnostics,
5804 }
5805}
5806
5807#[cfg(test)]
5808mod tests {
5809 use super::*;
5810 use greentic_secrets_lib::SecretsStore;
5811 use std::io::Read;
5812 use std::io::Write;
5813 use zip::write::{FileOptions, ZipWriter};
5814
5815 fn valid_machine() -> SetupMachine {
5816 serde_json::from_value(serde_json::json!({
5817 "version": 1,
5818 "id": "messaging-teams-default",
5819 "entry_step": "collect",
5820 "steps": [
5821 {
5822 "id": "collect",
5823 "kind": "qa_form",
5824 "on_success": "login"
5825 },
5826 {
5827 "id": "login",
5828 "kind": "oauth_device_code",
5829 "device_code_url": "https://login.example.com/device",
5830 "token_url": "https://login.example.com/token",
5831 "client_id": "client-123",
5832 "scopes": ["User.Read"],
5833 "recover": [
5834 {"when": "refresh_token_invalid", "action": "reauthorize"}
5835 ],
5836 "on_success": "complete"
5837 }
5838 ]
5839 }))
5840 .unwrap()
5841 }
5842
5843 fn serve_one_json(status: u16, body: &'static str) -> String {
5844 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5845 let addr = listener.local_addr().unwrap();
5846 std::thread::spawn(move || {
5847 let (mut stream, _) = listener.accept().unwrap();
5848 let mut buffer = [0_u8; 2048];
5849 let _ = stream.read(&mut buffer);
5850 let wire = format!(
5851 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5852 body.len(),
5853 body
5854 );
5855 stream.write_all(wire.as_bytes()).unwrap();
5856 });
5857 format!("http://{addr}")
5858 }
5859
5860 fn serve_json_sequence(responses: Vec<(u16, &'static str)>) -> String {
5861 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5862 let addr = listener.local_addr().unwrap();
5863 std::thread::spawn(move || {
5864 for (status, body) in responses {
5865 let (mut stream, _) = listener.accept().unwrap();
5866 let mut buffer = [0_u8; 4096];
5867 let _ = stream.read(&mut buffer);
5868 let wire = format!(
5869 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5870 body.len(),
5871 body
5872 );
5873 stream.write_all(wire.as_bytes()).unwrap();
5874 }
5875 });
5876 format!("http://{addr}")
5877 }
5878
5879 #[test]
5880 fn validates_valid_machine() {
5881 let diagnostics = validate_setup_machine(&valid_machine());
5882 assert!(diagnostics.is_empty(), "{diagnostics:?}");
5883 }
5884
5885 #[test]
5886 fn rejects_missing_transition_target_and_unreachable_step() {
5887 let mut machine = valid_machine();
5888 machine.steps[0].on_success = Some("missing".to_string());
5889 machine.steps.push(SetupMachineStep {
5890 id: "never".to_string(),
5891 title: None,
5892 kind: "qa_form".to_string(),
5893 requires: vec![],
5894 outputs: vec![],
5895 on_success: None,
5896 on_failure: vec![],
5897 recover: vec![],
5898 extra: serde_json::Map::new(),
5899 });
5900
5901 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5902 .into_iter()
5903 .map(|d| d.check_id)
5904 .collect();
5905 assert!(ids.contains("setup.machine.step.on_success"));
5906 assert!(ids.contains("setup.machine.step.reachable"));
5907 }
5908
5909 #[test]
5910 fn rejects_unbounded_setup_machine_cycles() {
5911 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5912 "version": 1,
5913 "id": "cycle-machine",
5914 "entry_step": "a",
5915 "steps": [
5916 {"id": "a", "kind": "qa_form", "on_success": "b"},
5917 {"id": "b", "kind": "manual_action", "on_success": "a"}
5918 ]
5919 }))
5920 .unwrap();
5921
5922 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5923 .into_iter()
5924 .map(|d| d.check_id)
5925 .collect();
5926 assert!(ids.contains("setup.machine.step.cycle"));
5927 }
5928
5929 #[test]
5930 fn allows_bounded_setup_machine_cycles() {
5931 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5932 "version": 1,
5933 "id": "cycle-machine",
5934 "entry_step": "a",
5935 "steps": [
5936 {"id": "a", "kind": "qa_form", "on_success": "b"},
5937 {
5938 "id": "b",
5939 "kind": "manual_action",
5940 "retry": {"max_attempts": 3},
5941 "on_success": "a"
5942 }
5943 ]
5944 }))
5945 .unwrap();
5946
5947 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5948 .into_iter()
5949 .map(|d| d.check_id)
5950 .collect();
5951 assert!(!ids.contains("setup.machine.step.cycle"));
5952 }
5953
5954 #[test]
5955 fn rejects_unsafe_setup_machine_artifact_paths() {
5956 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5957 "version": 1,
5958 "id": "artifact-machine",
5959 "entry_step": "generate",
5960 "steps": [
5961 {"id": "generate", "kind": "generate_file", "artifact_path": "../secret.json"},
5962 {"id": "download", "kind": "download_file", "on_success": "complete"}
5963 ]
5964 }))
5965 .unwrap();
5966
5967 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5968 .into_iter()
5969 .map(|d| d.check_id)
5970 .collect();
5971 assert!(ids.contains("setup.machine.artifact.path.safe"));
5972 assert!(ids.contains("setup.machine.artifact.path"));
5973 }
5974
5975 #[test]
5976 fn accepts_setup_machine_artifact_paths_under_artifacts() {
5977 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5978 "version": 1,
5979 "id": "artifact-machine",
5980 "entry_step": "generate",
5981 "steps": [
5982 {
5983 "id": "generate",
5984 "kind": "generate_file",
5985 "artifact_path": "artifacts/teams/app.zip",
5986 "on_success": "download"
5987 },
5988 {
5989 "id": "download",
5990 "kind": "download_file",
5991 "artifact_path": "artifacts/teams/app.zip",
5992 "on_success": "complete"
5993 }
5994 ]
5995 }))
5996 .unwrap();
5997
5998 let ids: BTreeSet<_> = validate_setup_machine(&machine)
5999 .into_iter()
6000 .map(|d| d.check_id)
6001 .collect();
6002 assert!(!ids.contains("setup.machine.artifact.path"));
6003 assert!(!ids.contains("setup.machine.artifact.path.safe"));
6004 }
6005
6006 #[test]
6007 fn rejects_invalid_setup_machine_http_steps() {
6008 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6009 "version": 1,
6010 "id": "http-machine",
6011 "entry_step": "missing_url",
6012 "steps": [
6013 {"id": "missing_url", "kind": "http_probe", "on_success": "bad_url"},
6014 {
6015 "id": "bad_url",
6016 "kind": "http_json",
6017 "url_template": "http://example.com/setup",
6018 "method": "PUT",
6019 "expected_status": 999
6020 }
6021 ]
6022 }))
6023 .unwrap();
6024
6025 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6026 .into_iter()
6027 .map(|d| d.check_id)
6028 .collect();
6029 assert!(ids.contains("setup.machine.http.url"));
6030 assert!(ids.contains("setup.machine.http.url.safe"));
6031 assert!(ids.contains("setup.machine.http.method"));
6032 assert!(ids.contains("setup.machine.http.expected_status"));
6033 assert!(ids.contains("setup.machine.step.idempotency_key"));
6034 }
6035
6036 #[test]
6037 fn rejects_invalid_setup_machine_tunnel_steps() {
6038 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6039 "version": 1,
6040 "id": "tunnel-machine",
6041 "entry_step": "tunnel",
6042 "steps": [
6043 {
6044 "id": "tunnel",
6045 "kind": "cloudflare_tunnel",
6046 "mode": "unsupported",
6047 "public_base_url": "http://example.com"
6048 }
6049 ]
6050 }))
6051 .unwrap();
6052
6053 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6054 .into_iter()
6055 .map(|d| d.check_id)
6056 .collect();
6057 assert!(ids.contains("setup.machine.cloudflare_tunnel.mode"));
6058 assert!(ids.contains("setup.machine.cloudflare_tunnel.public_base_url"));
6059 }
6060
6061 #[test]
6062 fn rejects_invalid_setup_machine_oauth_authorization_steps() {
6063 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6064 "version": 1,
6065 "id": "oauth-machine",
6066 "entry_step": "oauth",
6067 "steps": [
6068 {
6069 "id": "oauth",
6070 "kind": "oauth_authorization_code",
6071 "authorize_url": "http://login.example.com/auth",
6072 "token_url": "http://login.example.com/token",
6073 "scopes": "User.Read",
6074 "params": ["bad"],
6075 "state_ttl_secs": 0
6076 }
6077 ]
6078 }))
6079 .unwrap();
6080
6081 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6082 .into_iter()
6083 .map(|d| d.check_id)
6084 .collect();
6085 assert!(ids.contains("setup.machine.oauth_authorization_code.authorize_url.safe"));
6086 assert!(ids.contains("setup.machine.oauth_authorization_code.token_url.safe"));
6087 assert!(ids.contains("setup.machine.oauth_authorization_code.scopes"));
6088 assert!(ids.contains("setup.machine.oauth_authorization_code.params"));
6089 assert!(ids.contains("setup.machine.oauth_authorization_code.state_ttl_secs"));
6090 }
6091
6092 #[test]
6093 fn rejects_invalid_setup_machine_oauth_device_steps() {
6094 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6095 "version": 1,
6096 "id": "oauth-machine",
6097 "entry_step": "device",
6098 "steps": [
6099 {
6100 "id": "device",
6101 "kind": "oauth_device_code",
6102 "device_code_url": "http://example.com/device",
6103 "scopes": "User.Read"
6104 }
6105 ]
6106 }))
6107 .unwrap();
6108
6109 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6110 .into_iter()
6111 .map(|d| d.check_id)
6112 .collect();
6113 assert!(ids.contains("setup.machine.oauth_device_code.endpoints"));
6114 assert!(ids.contains("setup.machine.oauth_device_code.url.safe"));
6115 assert!(ids.contains("setup.machine.oauth_device_code.client_id"));
6116 assert!(ids.contains("setup.machine.oauth_device_code.scopes"));
6117 }
6118
6119 #[test]
6120 fn rejects_invalid_setup_machine_data_mapping_steps() {
6121 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6122 "version": 1,
6123 "id": "mapping-machine",
6124 "entry_step": "select",
6125 "steps": [
6126 {
6127 "id": "select",
6128 "kind": "select_from_json",
6129 "json_pointer": "value",
6130 "on_success": "persist"
6131 },
6132 {
6133 "id": "persist",
6134 "kind": "persist_runtime_config",
6135 "config": ["not", "object"],
6136 "server_owned_config_keys": "graph_access_token"
6137 },
6138 {
6139 "id": "component",
6140 "kind": "provider_component_call",
6141 "request": ["not", "object"]
6142 }
6143 ]
6144 }))
6145 .unwrap();
6146
6147 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6148 .into_iter()
6149 .map(|d| d.check_id)
6150 .collect();
6151 assert!(ids.contains("setup.machine.select_from_json.source_output"));
6152 assert!(ids.contains("setup.machine.select_from_json.json_pointer"));
6153 assert!(ids.contains("setup.machine.persist_runtime_config.object"));
6154 assert!(ids.contains("setup.machine.persist_runtime_config.server_owned_config_keys"));
6155 assert!(ids.contains("setup.machine.provider_component_call.component_ref"));
6156 assert!(ids.contains("setup.machine.provider_component_call.op"));
6157 assert!(ids.contains("setup.machine.provider_component_call.request"));
6158 assert!(ids.contains("setup.machine.step.idempotency_key"));
6159 }
6160
6161 #[test]
6162 fn accepts_valid_setup_machine_data_mapping_steps() {
6163 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6164 "version": 1,
6165 "id": "mapping-machine",
6166 "entry_step": "select",
6167 "steps": [
6168 {
6169 "id": "select",
6170 "kind": "select_from_json",
6171 "source_output": "teams",
6172 "json_pointer": "/value",
6173 "value_key": "id",
6174 "on_success": "persist"
6175 },
6176 {
6177 "id": "persist",
6178 "kind": "persist_runtime_config",
6179 "config": {"team_id": "{select}"},
6180 "server_owned_config_keys": ["graph_access_token"],
6181 "on_success": "component"
6182 },
6183 {
6184 "id": "component",
6185 "kind": "provider_component_call",
6186 "component_ref": "messaging-example",
6187 "op": "register",
6188 "idempotency_key": "register:{select}",
6189 "request": {"team_id": "{select}"}
6190 }
6191 ]
6192 }))
6193 .unwrap();
6194
6195 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6196 .into_iter()
6197 .map(|d| d.check_id)
6198 .collect();
6199 assert!(!ids.contains("setup.machine.select_from_json.source_output"));
6200 assert!(!ids.contains("setup.machine.select_from_json.json_pointer"));
6201 assert!(!ids.contains("setup.machine.persist_runtime_config.input"));
6202 assert!(!ids.contains("setup.machine.persist_runtime_config.object"));
6203 assert!(!ids.contains("setup.machine.provider_component_call.component_ref"));
6204 assert!(!ids.contains("setup.machine.provider_component_call.op"));
6205 assert!(!ids.contains("setup.machine.provider_component_call.request"));
6206 assert!(!ids.contains("setup.machine.step.idempotency_key"));
6207 }
6208
6209 #[test]
6210 fn rejects_persist_runtime_config_writing_server_owned_keys() {
6211 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6212 "version": 1,
6213 "id": "mapping-machine",
6214 "entry_step": "persist",
6215 "server_owned_config_keys": ["tenant_secret"],
6216 "steps": [
6217 {
6218 "id": "persist",
6219 "kind": "persist_runtime_config",
6220 "config": {
6221 "graph_access_token": "{token}",
6222 "team_id": "{team}"
6223 },
6224 "mappings": {
6225 "tenant_secret": "{secret}"
6226 },
6227 "server_owned_config_keys": ["graph_access_token"]
6228 }
6229 ]
6230 }))
6231 .unwrap();
6232
6233 let diagnostics = validate_setup_machine(&machine);
6234 let actual: BTreeSet<_> = diagnostics
6235 .iter()
6236 .filter(|diagnostic| {
6237 diagnostic.check_id == "setup.machine.persist_runtime_config.server_owned_output"
6238 })
6239 .filter_map(|diagnostic| diagnostic.actual.as_deref())
6240 .collect();
6241 assert!(actual.contains("persist: config.graph_access_token"));
6242 assert!(actual.contains("persist: mappings.tenant_secret"));
6243 }
6244
6245 #[test]
6246 fn accepts_templated_setup_machine_http_steps() {
6247 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6248 "version": 1,
6249 "id": "http-machine",
6250 "entry_step": "probe",
6251 "steps": [
6252 {
6253 "id": "probe",
6254 "kind": "http_probe",
6255 "url_template": "{public_base_url}/health",
6256 "method": "GET",
6257 "expected_status": 204,
6258 "on_success": "register"
6259 },
6260 {
6261 "id": "register",
6262 "kind": "http_json",
6263 "url_template": "{public_base_url}/register",
6264 "method": "POST",
6265 "expected_status": 200,
6266 "idempotency_key": "register:{tenant}",
6267 "on_success": "complete"
6268 }
6269 ]
6270 }))
6271 .unwrap();
6272
6273 let ids: BTreeSet<_> = validate_setup_machine(&machine)
6274 .into_iter()
6275 .map(|d| d.check_id)
6276 .collect();
6277 assert!(!ids.contains("setup.machine.http.url"));
6278 assert!(!ids.contains("setup.machine.http.url.safe"));
6279 assert!(!ids.contains("setup.machine.http.method"));
6280 assert!(!ids.contains("setup.machine.http.expected_status"));
6281 assert!(!ids.contains("setup.machine.step.idempotency_key"));
6282 }
6283
6284 fn write_setup_machine_pack(
6285 pack: &Path,
6286 provider_id: &str,
6287 machine: &Value,
6288 setup_yaml: Option<&str>,
6289 ) -> anyhow::Result<()> {
6290 let file = std::fs::File::create(pack)?;
6291 let mut writer = ZipWriter::new(file);
6292 let options: FileOptions<'_, ()> =
6293 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6294 writer.start_file("pack.manifest.json", options)?;
6295 writer.write_all(
6296 serde_json::json!({
6297 "pack_id": provider_id,
6298 "extensions": {
6299 SETUP_MACHINE_EXTENSION: {
6300 "path": "assets/setup/setup-machine.v1.json",
6301 "schema": SETUP_MACHINE_SCHEMA_URL
6302 }
6303 }
6304 })
6305 .to_string()
6306 .as_bytes(),
6307 )?;
6308 if let Some(setup_yaml) = setup_yaml {
6309 writer.start_file("assets/setup.yaml", options)?;
6310 writer.write_all(setup_yaml.as_bytes())?;
6311 }
6312 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6313 writer.write_all(serde_json::to_string(machine)?.as_bytes())?;
6314 writer.finish()?;
6315 Ok(())
6316 }
6317
6318 #[test]
6319 fn doctor_accepts_setup_machine_template_inputs_from_answers_and_outputs() -> anyhow::Result<()>
6320 {
6321 let temp = tempfile::tempdir()?;
6322 let pack = temp.path().join("messaging-example.gtpack");
6323 let machine = serde_json::json!({
6324 "version": 1,
6325 "id": "messaging-example-machine",
6326 "entry_step": "public_url",
6327 "steps": [
6328 {
6329 "id": "public_url",
6330 "kind": "platform_public_url",
6331 "default": "https://example.test",
6332 "on_success": "persist"
6333 },
6334 {
6335 "id": "persist",
6336 "kind": "persist_runtime_config",
6337 "config": {
6338 "bot_app_id": "{bot_app_id}",
6339 "public_base_url": "{public_base_url}",
6340 "tenant_id": "{tenant}",
6341 "provider": "{provider_id}"
6342 }
6343 }
6344 ]
6345 });
6346 write_setup_machine_pack(
6347 &pack,
6348 "messaging-example",
6349 &machine,
6350 Some("title: Example\nquestions:\n - name: bot_app_id\n required: true\n"),
6351 )?;
6352
6353 let report = run_provider_setup_machine_doctor(&pack);
6354 assert_eq!(report.status, "ok");
6355 assert!(
6356 !report
6357 .diagnostics
6358 .iter()
6359 .any(|diagnostic| diagnostic.check_id == "setup.machine.template.input")
6360 );
6361 Ok(())
6362 }
6363
6364 #[test]
6365 fn doctor_rejects_setup_machine_unknown_template_input() -> anyhow::Result<()> {
6366 let temp = tempfile::tempdir()?;
6367 let pack = temp.path().join("messaging-example.gtpack");
6368 let machine = serde_json::json!({
6369 "version": 1,
6370 "id": "messaging-example-machine",
6371 "entry_step": "persist",
6372 "steps": [
6373 {
6374 "id": "persist",
6375 "kind": "persist_runtime_config",
6376 "config": {
6377 "bot_app_id": "{bot_ap_id}"
6378 }
6379 }
6380 ]
6381 });
6382 write_setup_machine_pack(
6383 &pack,
6384 "messaging-example",
6385 &machine,
6386 Some("title: Example\nquestions:\n - name: bot_app_id\n required: true\n"),
6387 )?;
6388
6389 let report = run_provider_setup_machine_doctor(&pack);
6390 assert_eq!(report.status, "error");
6391 assert!(report.diagnostics.iter().any(|diagnostic| {
6392 diagnostic.check_id == "setup.machine.template.input"
6393 && diagnostic
6394 .actual
6395 .as_deref()
6396 .is_some_and(|actual| actual.contains("bot_ap_id"))
6397 }));
6398 Ok(())
6399 }
6400
6401 #[test]
6402 fn loads_machine_from_pack_asset_extension() -> anyhow::Result<()> {
6403 let temp = tempfile::tempdir()?;
6404 let pack = temp.path().join("messaging-teams.gtpack");
6405 let file = std::fs::File::create(&pack)?;
6406 let mut writer = ZipWriter::new(file);
6407 let options: FileOptions<'_, ()> =
6408 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6409 writer.start_file("pack.manifest.json", options)?;
6410 writer.write_all(
6411 serde_json::json!({
6412 "pack_id": "messaging-teams",
6413 "extensions": {
6414 SETUP_MACHINE_EXTENSION: {
6415 "path": "assets/setup/setup-machine.v1.json",
6416 "schema": SETUP_MACHINE_SCHEMA_URL
6417 }
6418 }
6419 })
6420 .to_string()
6421 .as_bytes(),
6422 )?;
6423 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6424 writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6425 writer.finish()?;
6426
6427 let machine = load_setup_machine_from_pack(&pack)?.expect("machine");
6428 assert_eq!(machine.id, "messaging-teams-default");
6429 let report = run_provider_setup_machine_doctor(&pack);
6430 assert_eq!(report.status, "ok");
6431 Ok(())
6432 }
6433
6434 #[test]
6435 fn doctor_rejects_legacy_setup_actions_in_migrated_pack() -> anyhow::Result<()> {
6436 let temp = tempfile::tempdir()?;
6437 let pack = temp.path().join("messaging-teams.gtpack");
6438 let file = std::fs::File::create(&pack)?;
6439 let mut writer = ZipWriter::new(file);
6440 let options: FileOptions<'_, ()> =
6441 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6442 writer.start_file("pack.manifest.json", options)?;
6443 writer.write_all(
6444 serde_json::json!({
6445 "pack_id": "messaging-teams",
6446 "extensions": {
6447 SETUP_MACHINE_EXTENSION: {
6448 "path": "assets/setup/setup-machine.v1.json",
6449 "schema": SETUP_MACHINE_SCHEMA_URL
6450 }
6451 }
6452 })
6453 .to_string()
6454 .as_bytes(),
6455 )?;
6456 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6457 writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6458 writer.start_file("assets/setup.yaml", options)?;
6459 writer.write_all(
6460 br#"
6461title: Teams
6462questions: []
6463setup_actions:
6464 - id: legacy_install
6465 label: Legacy install
6466 kind: oauth_install_button
6467 authorize_url: https://login.example/install
6468"#,
6469 )?;
6470 writer.finish()?;
6471
6472 let report = run_provider_setup_machine_doctor(&pack);
6473
6474 assert_eq!(report.status, "error");
6475 assert!(report.diagnostics.iter().any(|d| {
6476 d.check_id == "setup.legacy_setup_actions.absent"
6477 && d.severity == DiagnosticSeverity::Error
6478 }));
6479 Ok(())
6480 }
6481
6482 #[test]
6483 fn doctor_rejects_missing_setup_i18n_message_key() -> anyhow::Result<()> {
6484 let temp = tempfile::tempdir()?;
6485 let pack = temp.path().join("messaging-teams.gtpack");
6486 let file = std::fs::File::create(&pack)?;
6487 let mut writer = ZipWriter::new(file);
6488 let options: FileOptions<'_, ()> =
6489 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6490 writer.start_file("pack.manifest.json", options)?;
6491 writer.write_all(
6492 serde_json::json!({
6493 "pack_id": "messaging-teams",
6494 "extensions": {
6495 SETUP_MACHINE_EXTENSION: {
6496 "path": "assets/setup/setup-machine.v1.json",
6497 "schema": SETUP_MACHINE_SCHEMA_URL
6498 },
6499 SETUP_I18N_EXTENSION: {
6500 "inline": {
6501 "schema_id": SETUP_I18N_EXTENSION,
6502 "provider_id": "messaging-teams",
6503 "messages": {
6504 "setup.present": "Present"
6505 }
6506 }
6507 }
6508 }
6509 })
6510 .to_string()
6511 .as_bytes(),
6512 )?;
6513 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6514 writer.write_all(
6515 serde_json::json!({
6516 "version": 1,
6517 "id": "messaging-teams-default",
6518 "entry_step": "approve",
6519 "steps": [
6520 {
6521 "id": "approve",
6522 "kind": "manual_action",
6523 "message_key": "setup.missing"
6524 }
6525 ]
6526 })
6527 .to_string()
6528 .as_bytes(),
6529 )?;
6530 writer.finish()?;
6531
6532 let report = run_provider_setup_machine_doctor(&pack);
6533
6534 assert_eq!(report.status, "error");
6535 assert!(report.diagnostics.iter().any(|d| {
6536 d.check_id == "setup.i18n.message_key.exists"
6537 && d.actual.as_deref() == Some("setup.missing")
6538 }));
6539 Ok(())
6540 }
6541
6542 #[test]
6543 fn doctor_accepts_setup_i18n_message_key_from_asset() -> anyhow::Result<()> {
6544 let temp = tempfile::tempdir()?;
6545 let pack = temp.path().join("messaging-teams.gtpack");
6546 let file = std::fs::File::create(&pack)?;
6547 let mut writer = ZipWriter::new(file);
6548 let options: FileOptions<'_, ()> =
6549 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6550 writer.start_file("pack.manifest.json", options)?;
6551 writer.write_all(
6552 serde_json::json!({
6553 "pack_id": "messaging-teams",
6554 "extensions": {
6555 SETUP_MACHINE_EXTENSION: {
6556 "path": "assets/setup/setup-machine.v1.json",
6557 "schema": SETUP_MACHINE_SCHEMA_URL
6558 },
6559 SETUP_I18N_EXTENSION: {
6560 "path": "assets/setup/i18n/en.json"
6561 }
6562 }
6563 })
6564 .to_string()
6565 .as_bytes(),
6566 )?;
6567 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6568 writer.write_all(
6569 serde_json::json!({
6570 "version": 1,
6571 "id": "messaging-teams-default",
6572 "entry_step": "approve",
6573 "steps": [
6574 {
6575 "id": "approve",
6576 "kind": "manual_action",
6577 "on_failure": [
6578 {
6579 "when": "manual_action_required",
6580 "action": "prompt",
6581 "message_key": "setup.approve"
6582 }
6583 ]
6584 }
6585 ]
6586 })
6587 .to_string()
6588 .as_bytes(),
6589 )?;
6590 writer.start_file("assets/setup/i18n/en.json", options)?;
6591 writer.write_all(
6592 serde_json::json!({
6593 "messages": {
6594 "setup.approve": "Approve setup"
6595 }
6596 })
6597 .to_string()
6598 .as_bytes(),
6599 )?;
6600 writer.finish()?;
6601
6602 let report = run_provider_setup_machine_doctor(&pack);
6603
6604 assert_eq!(report.status, "ok", "{report:#?}");
6605 Ok(())
6606 }
6607
6608 #[test]
6609 fn doctor_accepts_setup_machine_fixture_state() -> anyhow::Result<()> {
6610 let temp = tempfile::tempdir()?;
6611 let pack = temp.path().join("messaging-teams.gtpack");
6612 let file = std::fs::File::create(&pack)?;
6613 let mut writer = ZipWriter::new(file);
6614 let options: FileOptions<'_, ()> =
6615 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6616 writer.start_file("pack.manifest.json", options)?;
6617 writer.write_all(
6618 serde_json::json!({
6619 "pack_id": "messaging-teams",
6620 "extensions": {
6621 SETUP_MACHINE_EXTENSION: {
6622 "path": "assets/setup/setup-machine.v1.json",
6623 "schema": SETUP_MACHINE_SCHEMA_URL
6624 }
6625 }
6626 })
6627 .to_string()
6628 .as_bytes(),
6629 )?;
6630 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6631 writer.write_all(
6632 serde_json::json!({
6633 "version": 1,
6634 "id": "messaging-teams-default",
6635 "entry_step": "start",
6636 "fixture_paths": ["assets/setup/fixtures/paused.json"],
6637 "steps": [
6638 {
6639 "id": "start",
6640 "kind": "manual_action",
6641 "on_success": "complete"
6642 }
6643 ]
6644 })
6645 .to_string()
6646 .as_bytes(),
6647 )?;
6648 writer.start_file("assets/setup/fixtures/paused.json", options)?;
6649 writer.write_all(
6650 serde_json::json!({
6651 "state": {
6652 "schema_version": 1,
6653 "provider_id": "messaging-teams",
6654 "tenant": "demo",
6655 "team": "default",
6656 "machine_id": "messaging-teams-default",
6657 "machine_version": 1,
6658 "status": "paused",
6659 "current_step": "start",
6660 "completed_steps": [],
6661 "outputs": {},
6662 "created_resources": []
6663 }
6664 })
6665 .to_string()
6666 .as_bytes(),
6667 )?;
6668 writer.finish()?;
6669
6670 let report = run_provider_setup_machine_doctor(&pack);
6671
6672 assert_eq!(report.status, "ok", "{report:#?}");
6673 Ok(())
6674 }
6675
6676 #[test]
6677 fn doctor_rejects_setup_machine_fixture_with_missing_step() -> anyhow::Result<()> {
6678 let temp = tempfile::tempdir()?;
6679 let pack = temp.path().join("messaging-teams.gtpack");
6680 let file = std::fs::File::create(&pack)?;
6681 let mut writer = ZipWriter::new(file);
6682 let options: FileOptions<'_, ()> =
6683 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6684 writer.start_file("pack.manifest.json", options)?;
6685 writer.write_all(
6686 serde_json::json!({
6687 "pack_id": "messaging-teams",
6688 "extensions": {
6689 SETUP_MACHINE_EXTENSION: {
6690 "path": "assets/setup/setup-machine.v1.json",
6691 "schema": SETUP_MACHINE_SCHEMA_URL
6692 }
6693 }
6694 })
6695 .to_string()
6696 .as_bytes(),
6697 )?;
6698 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6699 writer.write_all(
6700 serde_json::json!({
6701 "version": 1,
6702 "id": "messaging-teams-default",
6703 "entry_step": "start",
6704 "fixtures": [{"path": "assets/setup/fixtures/bad.json"}],
6705 "steps": [
6706 {
6707 "id": "start",
6708 "kind": "manual_action",
6709 "on_success": "complete"
6710 }
6711 ]
6712 })
6713 .to_string()
6714 .as_bytes(),
6715 )?;
6716 writer.start_file("assets/setup/fixtures/bad.json", options)?;
6717 writer.write_all(
6718 serde_json::json!({
6719 "schema_version": 1,
6720 "provider_id": "messaging-teams",
6721 "tenant": "demo",
6722 "team": "default",
6723 "machine_id": "messaging-teams-default",
6724 "machine_version": 1,
6725 "status": "paused",
6726 "current_step": "missing",
6727 "completed_steps": ["start"],
6728 "outputs": {},
6729 "created_resources": []
6730 })
6731 .to_string()
6732 .as_bytes(),
6733 )?;
6734 writer.finish()?;
6735
6736 let report = run_provider_setup_machine_doctor(&pack);
6737
6738 assert_eq!(report.status, "error");
6739 assert!(report.diagnostics.iter().any(|diagnostic| {
6740 diagnostic.check_id == "setup.machine.fixture.current_step"
6741 && diagnostic.actual.as_deref() == Some("assets/setup/fixtures/bad.json: missing")
6742 }));
6743 Ok(())
6744 }
6745
6746 #[test]
6747 fn doctor_accepts_backend_contract_without_setup_machine() -> anyhow::Result<()> {
6748 let temp = tempfile::tempdir()?;
6749 let pack = temp.path().join("messaging-teams.gtpack");
6750 let file = std::fs::File::create(&pack)?;
6751 let mut writer = ZipWriter::new(file);
6752 let options: FileOptions<'_, ()> =
6753 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6754 writer.start_file("pack.manifest.json", options)?;
6755 writer.write_all(
6756 serde_json::json!({
6757 "pack_id": "messaging-teams",
6758 "extensions": {
6759 SETUP_WEB_COMPONENT_EXTENSION: {
6760 "inline": {
6761 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6762 "provider_id": "messaging-teams",
6763 "tag_name": "greentic-teams-setup-v4",
6764 "module_asset": "assets/setup/greentic-teams-setup.js"
6765 }
6766 },
6767 SETUP_BACKEND_CONTRACT_EXTENSION: {
6768 "inline": {
6769 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6770 "provider_id": "messaging-teams",
6771 "asset": "assets/setup/backend-contract.json"
6772 }
6773 },
6774 HTTP_ROUTES_EXTENSION: {
6775 "inline": {
6776 "routes": [
6777 {
6778 "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
6779 "methods": ["POST"],
6780 "setup_component_ref": "messaging-teams",
6781 "setup_op": "register"
6782 }
6783 ]
6784 }
6785 }
6786 }
6787 })
6788 .to_string()
6789 .as_bytes(),
6790 )?;
6791 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6792 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6793 writer.start_file("assets/setup/backend-contract.json", options)?;
6794 writer.write_all(
6795 serde_json::json!({
6796 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6797 "provider_id": "messaging-teams",
6798 "server_owned_config_keys": [
6799 "oauth_kind",
6800 "oauth_device_code",
6801 "oauth_user_code",
6802 "graph_access_token"
6803 ],
6804 "required_order": ["graph_admin_consent", "register_endpoint", "first_message"],
6805 "actions": [
6806 {
6807 "id": "graph_admin_consent",
6808 "executor": {
6809 "kind": "oauth_device_code",
6810 "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6811 "client_id_config_key": "graph_setup_client_id",
6812 "oauth_kind": "graph",
6813 "token_store_key": "graph_access_token",
6814 "scopes": ["https://graph.microsoft.com/User.Read"]
6815 },
6816 "completion": {"state_path": "oauth.graph.ok", "exists": true}
6817 },
6818 {
6819 "id": "register_endpoint",
6820 "requires": ["graph_admin_consent"],
6821 "executor": {
6822 "kind": "provider_http",
6823 "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
6824 },
6825 "completion": {"state_path": "last_reconcile.ok", "equals": true}
6826 },
6827 {
6828 "id": "first_message",
6829 "requires": ["register_endpoint"],
6830 "executor": {
6831 "kind": "runtime_observation",
6832 "source": "greentic-start",
6833 "event": "bot_framework_activity_received",
6834 "state_store_key": "last_activity"
6835 },
6836 "completion": {"state_path": "last_activity", "exists": true}
6837 }
6838 ]
6839 })
6840 .to_string()
6841 .as_bytes(),
6842 )?;
6843 writer.finish()?;
6844
6845 let report = run_provider_setup_machine_doctor(&pack);
6846 assert_eq!(report.status, "ok", "{report:#?}");
6847 assert!(
6848 report
6849 .diagnostics
6850 .iter()
6851 .any(|d| d.check_id == "setup.backend_contract.extension.present")
6852 );
6853 Ok(())
6854 }
6855
6856 #[test]
6857 fn doctor_rejects_invalid_setup_machine_even_with_backend_contract() -> anyhow::Result<()> {
6858 let temp = tempfile::tempdir()?;
6859 let pack = temp.path().join("messaging-teams.gtpack");
6860 let file = std::fs::File::create(&pack)?;
6861 let mut writer = ZipWriter::new(file);
6862 let options: FileOptions<'_, ()> =
6863 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6864 writer.start_file("pack.manifest.json", options)?;
6865 writer.write_all(
6866 serde_json::json!({
6867 "pack_id": "messaging-teams",
6868 "extensions": {
6869 SETUP_WEB_COMPONENT_EXTENSION: {
6870 "inline": {
6871 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6872 "provider_id": "messaging-teams",
6873 "tag_name": "greentic-teams-setup-v4",
6874 "module_asset": "assets/setup/greentic-teams-setup.js"
6875 }
6876 },
6877 SETUP_BACKEND_CONTRACT_EXTENSION: {
6878 "inline": {
6879 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6880 "provider_id": "messaging-teams",
6881 "asset": "assets/setup/backend-contract.json"
6882 }
6883 },
6884 SETUP_MACHINE_EXTENSION: {
6885 "path": "assets/setup/setup-machine.v1.json",
6886 "schema": SETUP_MACHINE_SCHEMA_URL
6887 }
6888 }
6889 })
6890 .to_string()
6891 .as_bytes(),
6892 )?;
6893 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6894 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6895 writer.start_file("assets/setup/backend-contract.json", options)?;
6896 writer.write_all(
6897 serde_json::json!({
6898 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6899 "provider_id": "messaging-teams",
6900 "server_owned_config_keys": [
6901 "oauth_kind",
6902 "oauth_device_code",
6903 "oauth_user_code",
6904 "graph_access_token"
6905 ],
6906 "required_order": ["graph_admin_consent"],
6907 "actions": [
6908 {
6909 "id": "graph_admin_consent",
6910 "executor": {
6911 "kind": "oauth_device_code",
6912 "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6913 "client_id_config_key": "graph_setup_client_id",
6914 "oauth_kind": "graph",
6915 "token_store_key": "graph_access_token",
6916 "scopes": ["https://graph.microsoft.com/User.Read"]
6917 },
6918 "completion": {"state_path": "oauth.graph.ok", "exists": true}
6919 }
6920 ]
6921 })
6922 .to_string()
6923 .as_bytes(),
6924 )?;
6925 writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6926 writer.write_all(
6927 serde_json::json!({
6928 "version": 1,
6929 "id": "messaging-teams-broken",
6930 "entry_step": "missing",
6931 "steps": [
6932 {
6933 "id": "only",
6934 "kind": "auto_complete",
6935 "on_success": "complete"
6936 }
6937 ]
6938 })
6939 .to_string()
6940 .as_bytes(),
6941 )?;
6942 writer.finish()?;
6943
6944 let report = run_provider_setup_machine_doctor(&pack);
6945
6946 assert_eq!(report.status, "error");
6947 assert!(
6948 report
6949 .diagnostics
6950 .iter()
6951 .any(|d| d.check_id == "setup.backend_contract.extension.present")
6952 );
6953 assert!(
6954 report
6955 .diagnostics
6956 .iter()
6957 .any(|d| d.check_id == "setup.machine.extension.present")
6958 );
6959 assert!(report.diagnostics.iter().any(|d| {
6960 d.severity == DiagnosticSeverity::Error && d.check_id.starts_with("setup.machine.")
6961 }));
6962 Ok(())
6963 }
6964
6965 #[test]
6966 fn route_pattern_matches_provider_http_templates() {
6967 assert!(route_pattern_covers_path(
6968 "/v1/setup/messaging-teams/{tenant}/{team}/register",
6969 "/v1/setup/messaging-teams/{tenant}/{team}/register",
6970 ));
6971 assert!(route_pattern_covers_path(
6972 "/v1/messaging/setup/messaging-teams/{tenant}/{action*}",
6973 "/v1/messaging/setup/messaging-teams/{tenant}/oauth/{kind}/start",
6974 ));
6975 assert!(!route_pattern_covers_path(
6976 "/v1/setup/messaging-teams/{tenant}/{team}/publish",
6977 "/v1/setup/messaging-teams/{tenant}/{team}/register",
6978 ));
6979 }
6980
6981 #[test]
6982 fn backend_contract_doctor_rejects_uncovered_provider_http_route() -> anyhow::Result<()> {
6983 let temp = tempfile::tempdir()?;
6984 let pack = temp.path().join("messaging-teams.gtpack");
6985 let file = std::fs::File::create(&pack)?;
6986 let mut writer = ZipWriter::new(file);
6987 let options: FileOptions<'_, ()> =
6988 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6989 writer.start_file("pack.manifest.json", options)?;
6990 writer.write_all(
6991 serde_json::json!({
6992 "pack_id": "messaging-teams",
6993 "extensions": {
6994 SETUP_WEB_COMPONENT_EXTENSION: {
6995 "inline": {
6996 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6997 "provider_id": "messaging-teams",
6998 "tag_name": "greentic-teams-setup-v4",
6999 "module_asset": "assets/setup/greentic-teams-setup.js"
7000 }
7001 },
7002 SETUP_BACKEND_CONTRACT_EXTENSION: {
7003 "inline": {
7004 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7005 "provider_id": "messaging-teams",
7006 "asset": "assets/setup/backend-contract.json"
7007 }
7008 },
7009 HTTP_ROUTES_EXTENSION: {
7010 "inline": {
7011 "routes": [
7012 {
7013 "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/other",
7014 "methods": ["POST"],
7015 "setup_component_ref": "messaging-teams",
7016 "setup_op": "other"
7017 }
7018 ]
7019 }
7020 }
7021 }
7022 })
7023 .to_string()
7024 .as_bytes(),
7025 )?;
7026 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7027 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7028 writer.start_file("assets/setup/backend-contract.json", options)?;
7029 writer.write_all(
7030 serde_json::json!({
7031 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7032 "provider_id": "messaging-teams",
7033 "required_order": ["register_endpoint"],
7034 "actions": [
7035 {
7036 "id": "register_endpoint",
7037 "executor": {
7038 "kind": "provider_http",
7039 "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7040 },
7041 "completion": {"state_path": "last_reconcile.ok", "equals": true}
7042 }
7043 ]
7044 })
7045 .to_string()
7046 .as_bytes(),
7047 )?;
7048 writer.finish()?;
7049
7050 let report = run_provider_setup_machine_doctor(&pack);
7051 assert_eq!(report.status, "error");
7052 assert!(
7053 report
7054 .diagnostics
7055 .iter()
7056 .any(|d| { d.check_id == "setup.backend_contract.provider_http.route_covered" })
7057 );
7058 Ok(())
7059 }
7060
7061 #[test]
7062 fn backend_contract_doctor_rejects_provider_http_method_mismatch() -> anyhow::Result<()> {
7063 let temp = tempfile::tempdir()?;
7064 let pack = temp.path().join("messaging-teams.gtpack");
7065 let file = std::fs::File::create(&pack)?;
7066 let mut writer = ZipWriter::new(file);
7067 let options: FileOptions<'_, ()> =
7068 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7069 writer.start_file("pack.manifest.json", options)?;
7070 writer.write_all(
7071 serde_json::json!({
7072 "pack_id": "messaging-teams",
7073 "extensions": {
7074 SETUP_WEB_COMPONENT_EXTENSION: {
7075 "inline": {
7076 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7077 "provider_id": "messaging-teams",
7078 "tag_name": "greentic-teams-setup-v4",
7079 "module_asset": "assets/setup/greentic-teams-setup.js"
7080 }
7081 },
7082 SETUP_BACKEND_CONTRACT_EXTENSION: {
7083 "inline": {
7084 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7085 "provider_id": "messaging-teams",
7086 "asset": "assets/setup/backend-contract.json"
7087 }
7088 },
7089 HTTP_ROUTES_EXTENSION: {
7090 "inline": {
7091 "routes": [
7092 {
7093 "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
7094 "methods": ["GET"],
7095 "setup_component_ref": "messaging-teams",
7096 "setup_op": "register"
7097 }
7098 ]
7099 }
7100 }
7101 }
7102 })
7103 .to_string()
7104 .as_bytes(),
7105 )?;
7106 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7107 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7108 writer.start_file("assets/setup/backend-contract.json", options)?;
7109 writer.write_all(
7110 serde_json::json!({
7111 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7112 "provider_id": "messaging-teams",
7113 "required_order": ["register_endpoint"],
7114 "actions": [
7115 {
7116 "id": "register_endpoint",
7117 "executor": {
7118 "kind": "provider_http",
7119 "method": "POST",
7120 "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7121 },
7122 "completion": {"state_path": "last_reconcile.ok", "equals": true}
7123 }
7124 ]
7125 })
7126 .to_string()
7127 .as_bytes(),
7128 )?;
7129 writer.finish()?;
7130
7131 let report = run_provider_setup_machine_doctor(&pack);
7132 assert_eq!(report.status, "error");
7133 assert!(
7134 report
7135 .diagnostics
7136 .iter()
7137 .any(|d| d.check_id == "setup.backend_contract.provider_http.route_method")
7138 );
7139 Ok(())
7140 }
7141
7142 #[test]
7143 fn backend_contract_doctor_rejects_oauth_token_not_server_owned() -> anyhow::Result<()> {
7144 let temp = tempfile::tempdir()?;
7145 let pack = temp.path().join("messaging-teams.gtpack");
7146 let file = std::fs::File::create(&pack)?;
7147 let mut writer = ZipWriter::new(file);
7148 let options: FileOptions<'_, ()> =
7149 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7150 writer.start_file("pack.manifest.json", options)?;
7151 writer.write_all(
7152 serde_json::json!({
7153 "pack_id": "messaging-teams",
7154 "extensions": {
7155 SETUP_WEB_COMPONENT_EXTENSION: {
7156 "inline": {
7157 "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7158 "provider_id": "messaging-teams",
7159 "tag_name": "greentic-teams-setup-v4",
7160 "module_asset": "assets/setup/greentic-teams-setup.js"
7161 }
7162 },
7163 SETUP_BACKEND_CONTRACT_EXTENSION: {
7164 "inline": {
7165 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7166 "provider_id": "messaging-teams",
7167 "asset": "assets/setup/backend-contract.json"
7168 }
7169 }
7170 }
7171 })
7172 .to_string()
7173 .as_bytes(),
7174 )?;
7175 writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7176 writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7177 writer.start_file("assets/setup/backend-contract.json", options)?;
7178 writer.write_all(
7179 serde_json::json!({
7180 "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7181 "provider_id": "messaging-teams",
7182 "server_owned_config_keys": ["oauth_kind", "oauth_device_code", "oauth_user_code"],
7183 "required_order": ["graph_admin_consent"],
7184 "actions": [
7185 {
7186 "id": "graph_admin_consent",
7187 "executor": {
7188 "kind": "oauth_device_code",
7189 "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
7190 "client_id_config_key": "graph_setup_client_id",
7191 "oauth_kind": "graph",
7192 "token_store_key": "graph_access_token",
7193 "scopes": ["https://graph.microsoft.com/User.Read"]
7194 },
7195 "completion": {"state_path": "oauth.graph.ok", "exists": true}
7196 }
7197 ]
7198 })
7199 .to_string()
7200 .as_bytes(),
7201 )?;
7202 writer.finish()?;
7203
7204 let report = run_provider_setup_machine_doctor(&pack);
7205 assert_eq!(report.status, "error");
7206 assert!(
7207 report
7208 .diagnostics
7209 .iter()
7210 .any(|d| { d.check_id == "setup.backend_contract.server_owned_config_keys.oauth" })
7211 );
7212 Ok(())
7213 }
7214
7215 #[test]
7216 fn persists_state_and_appends_events() -> anyhow::Result<()> {
7217 let temp = tempfile::tempdir()?;
7218 let state = SetupMachineState {
7219 schema_version: 1,
7220 provider_id: "messaging-teams".to_string(),
7221 tenant: "demo".to_string(),
7222 team: "default".to_string(),
7223 machine_id: "messaging-teams-default".to_string(),
7224 machine_version: 1,
7225 status: SetupMachineStatus::Running,
7226 current_step: Some("login".to_string()),
7227 completed_steps: vec!["collect".to_string()],
7228 failed_step: None,
7229 answers_hash: None,
7230 pack_fingerprint: None,
7231 outputs: serde_json::json!({}),
7232 created_resources: vec![],
7233 last_error: None,
7234 updated_at: None,
7235 };
7236 let path = write_setup_machine_state(temp.path(), &state)?;
7237 let loaded = load_setup_machine_state(&path)?;
7238 assert_eq!(loaded.current_step.as_deref(), Some("login"));
7239
7240 let event = SetupMachineEvent {
7241 ts: "2026-06-17T00:00:00Z".to_string(),
7242 level: "info".to_string(),
7243 event: "setup.step.started".to_string(),
7244 provider_id: "messaging-teams".to_string(),
7245 tenant: "demo".to_string(),
7246 team: "default".to_string(),
7247 machine_id: "messaging-teams-default".to_string(),
7248 step_id: Some("login".to_string()),
7249 correlation_id: Some("corr".to_string()),
7250 fields: serde_json::Map::new(),
7251 };
7252 let event_path = append_setup_machine_event(temp.path(), &event)?;
7253 let events = std::fs::read_to_string(event_path)?;
7254 assert!(events.contains("setup.step.started"));
7255 Ok(())
7256 }
7257
7258 #[test]
7259 fn advance_setup_machine_blocks_incompatible_persisted_machine_state() -> anyhow::Result<()> {
7260 let temp = tempfile::tempdir()?;
7261 let machine = valid_machine();
7262 let state = SetupMachineState {
7263 schema_version: 1,
7264 provider_id: "messaging-teams".to_string(),
7265 tenant: "demo".to_string(),
7266 team: "default".to_string(),
7267 machine_id: machine.id.clone(),
7268 machine_version: 0,
7269 status: SetupMachineStatus::Running,
7270 current_step: Some(machine.entry_step.clone()),
7271 completed_steps: Vec::new(),
7272 failed_step: None,
7273 answers_hash: None,
7274 pack_fingerprint: None,
7275 outputs: serde_json::json!({}),
7276 created_resources: Vec::new(),
7277 last_error: None,
7278 updated_at: None,
7279 };
7280 write_setup_machine_state(temp.path(), &state)?;
7281
7282 let output = advance_setup_machine(
7283 temp.path(),
7284 "messaging-teams",
7285 "demo",
7286 "default",
7287 &machine,
7288 false,
7289 )?;
7290
7291 assert_eq!(output["ok"], false);
7292 assert!(
7293 output["next"]
7294 .as_str()
7295 .unwrap()
7296 .contains("machine_identity_changed")
7297 );
7298 let loaded = load_setup_machine_state(&setup_machine_state_path(
7299 temp.path(),
7300 "demo",
7301 "default",
7302 "messaging-teams",
7303 ))?;
7304 assert_eq!(loaded.status, SetupMachineStatus::Failed);
7305 assert_eq!(
7306 loaded
7307 .last_error
7308 .as_ref()
7309 .and_then(|error| error.get("error"))
7310 .and_then(Value::as_str),
7311 Some("machine_identity_changed")
7312 );
7313 let events = std::fs::read_to_string(setup_machine_events_path(
7314 temp.path(),
7315 "demo",
7316 "default",
7317 "messaging-teams",
7318 ))?;
7319 assert!(events.contains("setup.machine.incompatible_state"));
7320 Ok(())
7321 }
7322
7323 #[test]
7324 fn setup_machine_state_records_pack_and_answers_hashes() -> anyhow::Result<()> {
7325 let temp = tempfile::tempdir()?;
7326 let machine = valid_machine();
7327 let pack_path = temp.path().join("provider.gtpack");
7328 std::fs::write(&pack_path, b"pack-v1")?;
7329 let answers_path = temp
7330 .path()
7331 .join("state/config/messaging-teams/setup-answers.json");
7332 std::fs::create_dir_all(answers_path.parent().unwrap())?;
7333 std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7334
7335 let state = load_or_init_setup_machine_state_with_context(
7336 temp.path(),
7337 Some(&pack_path),
7338 "messaging-teams",
7339 "demo",
7340 "default",
7341 &machine,
7342 )?;
7343
7344 assert!(state.pack_fingerprint.as_deref().is_some_and(|hash| {
7345 hash.starts_with("sha256:") && hash == setup_machine_hash_file(&pack_path).unwrap()
7346 }));
7347 assert!(state.answers_hash.as_deref().is_some_and(|hash| {
7348 hash.starts_with("sha256:") && hash == setup_machine_hash_file(&answers_path).unwrap()
7349 }));
7350 Ok(())
7351 }
7352
7353 #[test]
7354 fn advance_setup_machine_blocks_changed_provider_pack_fingerprint() -> anyhow::Result<()> {
7355 let temp = tempfile::tempdir()?;
7356 let machine = valid_machine();
7357 let pack_path = temp.path().join("provider.gtpack");
7358 std::fs::write(&pack_path, b"pack-v1")?;
7359 let mut state = load_or_init_setup_machine_state_with_context(
7360 temp.path(),
7361 Some(&pack_path),
7362 "messaging-teams",
7363 "demo",
7364 "default",
7365 &machine,
7366 )?;
7367 state.status = SetupMachineStatus::Running;
7368 write_setup_machine_state(temp.path(), &state)?;
7369 std::fs::write(&pack_path, b"pack-v2")?;
7370
7371 let output = advance_setup_machine_with_pack(
7372 temp.path(),
7373 Some(&pack_path),
7374 "messaging-teams",
7375 "demo",
7376 "default",
7377 &machine,
7378 false,
7379 )?;
7380
7381 assert_eq!(output["ok"], false);
7382 assert!(
7383 output["next"]
7384 .as_str()
7385 .unwrap()
7386 .contains("pack_fingerprint_changed")
7387 );
7388 let loaded = load_setup_machine_state(&setup_machine_state_path(
7389 temp.path(),
7390 "demo",
7391 "default",
7392 "messaging-teams",
7393 ))?;
7394 assert_eq!(
7395 loaded
7396 .last_error
7397 .as_ref()
7398 .and_then(|error| error.get("error"))
7399 .and_then(Value::as_str),
7400 Some("pack_fingerprint_changed")
7401 );
7402 Ok(())
7403 }
7404
7405 #[test]
7406 fn advance_setup_machine_blocks_changed_setup_answers_hash() -> anyhow::Result<()> {
7407 let temp = tempfile::tempdir()?;
7408 let machine = valid_machine();
7409 let pack_path = temp.path().join("provider.gtpack");
7410 std::fs::write(&pack_path, b"pack-v1")?;
7411 let answers_path = temp
7412 .path()
7413 .join("state/config/messaging-teams/setup-answers.json");
7414 std::fs::create_dir_all(answers_path.parent().unwrap())?;
7415 std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7416 let mut state = load_or_init_setup_machine_state_with_context(
7417 temp.path(),
7418 Some(&pack_path),
7419 "messaging-teams",
7420 "demo",
7421 "default",
7422 &machine,
7423 )?;
7424 state.status = SetupMachineStatus::Running;
7425 write_setup_machine_state(temp.path(), &state)?;
7426 std::fs::write(&answers_path, br#"{"client_id":"changed"}"#)?;
7427
7428 let output = advance_setup_machine_with_pack(
7429 temp.path(),
7430 Some(&pack_path),
7431 "messaging-teams",
7432 "demo",
7433 "default",
7434 &machine,
7435 false,
7436 )?;
7437
7438 assert_eq!(output["ok"], false);
7439 assert!(
7440 output["next"]
7441 .as_str()
7442 .unwrap()
7443 .contains("setup_answers_changed")
7444 );
7445 let loaded = load_setup_machine_state(&setup_machine_state_path(
7446 temp.path(),
7447 "demo",
7448 "default",
7449 "messaging-teams",
7450 ))?;
7451 assert_eq!(
7452 loaded
7453 .last_error
7454 .as_ref()
7455 .and_then(|error| error.get("error"))
7456 .and_then(Value::as_str),
7457 Some("setup_answers_changed")
7458 );
7459 Ok(())
7460 }
7461
7462 #[test]
7463 fn advance_setup_machine_completes_auto_step_and_persists_state() -> anyhow::Result<()> {
7464 let temp = tempfile::tempdir()?;
7465 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7466 "version": 1,
7467 "id": "example-machine",
7468 "entry_step": "collect",
7469 "steps": [
7470 {
7471 "id": "collect",
7472 "kind": "qa_form",
7473 "auto_complete": true,
7474 "on_success": "manual"
7475 },
7476 {
7477 "id": "manual",
7478 "kind": "manual_action",
7479 "title": "Approve"
7480 }
7481 ]
7482 }))?;
7483
7484 let output = advance_setup_machine(
7485 temp.path(),
7486 "messaging-example",
7487 "demo",
7488 "default",
7489 &machine,
7490 false,
7491 )?;
7492
7493 assert_eq!(output["ok"], true);
7494 assert_eq!(output["step"], "collect");
7495 assert_eq!(output["status"]["current_step"], "manual");
7496 let state = load_setup_machine_state(&setup_machine_state_path(
7497 temp.path(),
7498 "demo",
7499 "default",
7500 "messaging-example",
7501 ))?;
7502 assert_eq!(state.status, SetupMachineStatus::Running);
7503 assert_eq!(state.completed_steps, vec!["collect"]);
7504 assert_eq!(state.current_step.as_deref(), Some("manual"));
7505 let events = std::fs::read_to_string(setup_machine_events_path(
7506 temp.path(),
7507 "demo",
7508 "default",
7509 "messaging-example",
7510 ))?;
7511 assert!(events.contains("setup.step.completed"));
7512 Ok(())
7513 }
7514
7515 #[test]
7516 fn advance_setup_machine_resumes_current_step_and_pauses_for_manual_action()
7517 -> anyhow::Result<()> {
7518 let temp = tempfile::tempdir()?;
7519 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7520 "version": 1,
7521 "id": "example-machine",
7522 "entry_step": "manual",
7523 "steps": [
7524 {
7525 "id": "manual",
7526 "kind": "manual_action",
7527 "title": "Approve"
7528 }
7529 ]
7530 }))?;
7531
7532 let output = advance_setup_machine(
7533 temp.path(),
7534 "messaging-example",
7535 "demo",
7536 "default",
7537 &machine,
7538 false,
7539 )?;
7540
7541 assert_eq!(output["ok"], false);
7542 assert_eq!(output["step"], "manual");
7543 assert_eq!(output["result"]["status"], "paused");
7544 assert_eq!(
7545 output["result"]["detail"]["error"],
7546 "manual_action_required"
7547 );
7548 let state = load_setup_machine_state(&setup_machine_state_path(
7549 temp.path(),
7550 "demo",
7551 "default",
7552 "messaging-example",
7553 ))?;
7554 assert_eq!(state.status, SetupMachineStatus::Paused);
7555 assert_eq!(state.current_step.as_deref(), Some("manual"));
7556
7557 let repeated = advance_setup_machine(
7558 temp.path(),
7559 "messaging-example",
7560 "demo",
7561 "default",
7562 &machine,
7563 true,
7564 )?;
7565 assert_eq!(repeated["step"], "manual");
7566 assert_eq!(repeated["dry_run"], true);
7567 Ok(())
7568 }
7569
7570 #[test]
7571 fn advance_setup_machine_follows_declared_failure_recovery_step() -> anyhow::Result<()> {
7572 let temp = tempfile::tempdir()?;
7573 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7574 "version": 1,
7575 "id": "example-machine",
7576 "entry_step": "public_url",
7577 "steps": [
7578 {
7579 "id": "public_url",
7580 "kind": "platform_public_url",
7581 "on_failure": [
7582 {
7583 "when": "missing_prerequisite",
7584 "target": "manual_public_url"
7585 }
7586 ],
7587 "on_success": "complete"
7588 },
7589 {
7590 "id": "manual_public_url",
7591 "kind": "manual_action",
7592 "on_success": "public_url"
7593 }
7594 ]
7595 }))?;
7596
7597 let output = advance_setup_machine(
7598 temp.path(),
7599 "messaging-example",
7600 "demo",
7601 "default",
7602 &machine,
7603 false,
7604 )?;
7605
7606 assert_eq!(output["ok"], false);
7607 assert_eq!(output["next"], "recovery scheduled: manual_public_url");
7608 assert_eq!(
7609 output["result"]["detail"]["recovery_step"],
7610 "manual_public_url"
7611 );
7612 let state = load_setup_machine_state(&setup_machine_state_path(
7613 temp.path(),
7614 "demo",
7615 "default",
7616 "messaging-example",
7617 ))?;
7618 assert_eq!(state.status, SetupMachineStatus::Running);
7619 assert_eq!(state.current_step.as_deref(), Some("manual_public_url"));
7620 assert_eq!(
7621 state
7622 .last_error
7623 .as_ref()
7624 .and_then(|error| error.get("recovery_step"))
7625 .and_then(Value::as_str),
7626 Some("manual_public_url")
7627 );
7628 let events = std::fs::read_to_string(setup_machine_events_path(
7629 temp.path(),
7630 "demo",
7631 "default",
7632 "messaging-example",
7633 ))?;
7634 assert!(events.contains("setup.step.recovery_scheduled"));
7635 Ok(())
7636 }
7637
7638 #[test]
7639 fn advance_setup_machine_resolves_platform_public_url_from_static_routes() -> anyhow::Result<()>
7640 {
7641 let temp = tempfile::tempdir()?;
7642 crate::platform_setup::persist_static_routes_artifact(
7643 temp.path(),
7644 &crate::platform_setup::StaticRoutesPolicy {
7645 public_web_enabled: true,
7646 public_base_url: Some("https://runtime.example.com/base/".to_string()),
7647 public_surface_policy: "enabled".to_string(),
7648 ..Default::default()
7649 },
7650 )?;
7651 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7652 "version": 1,
7653 "id": "example-machine",
7654 "entry_step": "public_url",
7655 "steps": [
7656 {
7657 "id": "public_url",
7658 "kind": "platform_public_url",
7659 "on_success": "complete"
7660 }
7661 ]
7662 }))?;
7663
7664 let output = advance_setup_machine(
7665 temp.path(),
7666 "messaging-example",
7667 "demo",
7668 "default",
7669 &machine,
7670 false,
7671 )?;
7672
7673 assert_eq!(output["ok"], true);
7674 let state = load_setup_machine_state(&setup_machine_state_path(
7675 temp.path(),
7676 "demo",
7677 "default",
7678 "messaging-example",
7679 ))?;
7680 assert_eq!(state.status, SetupMachineStatus::Complete);
7681 assert_eq!(
7682 state.outputs["public_base_url"],
7683 "https://runtime.example.com/base"
7684 );
7685 Ok(())
7686 }
7687
7688 #[test]
7689 fn advance_setup_machine_blocks_platform_public_url_when_missing() -> anyhow::Result<()> {
7690 let temp = tempfile::tempdir()?;
7691 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7692 "version": 1,
7693 "id": "example-machine",
7694 "entry_step": "public_url",
7695 "steps": [
7696 {
7697 "id": "public_url",
7698 "kind": "platform_public_url"
7699 }
7700 ]
7701 }))?;
7702
7703 let output = advance_setup_machine(
7704 temp.path(),
7705 "messaging-example",
7706 "demo",
7707 "default",
7708 &machine,
7709 false,
7710 )?;
7711
7712 assert_eq!(output["ok"], false);
7713 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7714 let state = load_setup_machine_state(&setup_machine_state_path(
7715 temp.path(),
7716 "demo",
7717 "default",
7718 "messaging-example",
7719 ))?;
7720 assert_eq!(state.status, SetupMachineStatus::Paused);
7721 assert_eq!(state.current_step.as_deref(), Some("public_url"));
7722 Ok(())
7723 }
7724
7725 #[test]
7726 fn advance_setup_machine_cloudflare_tunnel_resolves_existing_https_url() -> anyhow::Result<()> {
7727 let temp = tempfile::tempdir()?;
7728 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7729 "version": 1,
7730 "id": "example-machine",
7731 "entry_step": "tunnel",
7732 "steps": [
7733 {
7734 "id": "tunnel",
7735 "kind": "cloudflare_tunnel",
7736 "public_base_url": "https://setup.trycloudflare.com",
7737 "on_success": "complete"
7738 }
7739 ]
7740 }))?;
7741
7742 let output = advance_setup_machine(
7743 temp.path(),
7744 "messaging-example",
7745 "demo",
7746 "default",
7747 &machine,
7748 false,
7749 )?;
7750
7751 assert_eq!(output["ok"], true);
7752 assert_eq!(output["result"]["detail"]["ephemeral"], true);
7753 let state = load_setup_machine_state(&setup_machine_state_path(
7754 temp.path(),
7755 "demo",
7756 "default",
7757 "messaging-example",
7758 ))?;
7759 assert_eq!(state.status, SetupMachineStatus::Complete);
7760 assert_eq!(
7761 state.outputs["public_base_url"],
7762 "https://setup.trycloudflare.com"
7763 );
7764 Ok(())
7765 }
7766
7767 #[test]
7768 fn advance_setup_machine_cloudflare_tunnel_blocks_when_missing() -> anyhow::Result<()> {
7769 let temp = tempfile::tempdir()?;
7770 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7771 "version": 1,
7772 "id": "example-machine",
7773 "entry_step": "tunnel",
7774 "steps": [
7775 {
7776 "id": "tunnel",
7777 "kind": "cloudflare_tunnel"
7778 }
7779 ]
7780 }))?;
7781
7782 let output = advance_setup_machine(
7783 temp.path(),
7784 "messaging-example",
7785 "demo",
7786 "default",
7787 &machine,
7788 false,
7789 )?;
7790
7791 assert_eq!(output["ok"], false);
7792 assert_eq!(output["result"]["detail"]["error"], "tunnel_unreachable");
7793 let state = load_setup_machine_state(&setup_machine_state_path(
7794 temp.path(),
7795 "demo",
7796 "default",
7797 "messaging-example",
7798 ))?;
7799 assert_eq!(state.status, SetupMachineStatus::Paused);
7800 assert_eq!(state.current_step.as_deref(), Some("tunnel"));
7801 Ok(())
7802 }
7803
7804 #[test]
7805 fn advance_setup_machine_oauth_authorization_code_renders_signed_url() -> anyhow::Result<()> {
7806 let temp = tempfile::tempdir()?;
7807 let state = SetupMachineState {
7808 schema_version: 1,
7809 provider_id: "messaging-example".to_string(),
7810 tenant: "demo".to_string(),
7811 team: "default".to_string(),
7812 machine_id: "example-machine".to_string(),
7813 machine_version: 1,
7814 status: SetupMachineStatus::Running,
7815 current_step: Some("oauth".to_string()),
7816 completed_steps: Vec::new(),
7817 failed_step: None,
7818 answers_hash: None,
7819 pack_fingerprint: None,
7820 outputs: serde_json::json!({
7821 "public_base_url": "https://runtime.example.com",
7822 "client_id": "client-123"
7823 }),
7824 created_resources: Vec::new(),
7825 last_error: None,
7826 updated_at: None,
7827 };
7828 write_setup_machine_state(temp.path(), &state)?;
7829 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7830 "version": 1,
7831 "id": "example-machine",
7832 "entry_step": "oauth",
7833 "steps": [
7834 {
7835 "id": "oauth",
7836 "kind": "oauth_authorization_code",
7837 "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7838 "token_url": "https://login.example.com/oauth2/v2.0/token",
7839 "callback_path": "/oauth/callback",
7840 "scopes": ["User.Read", "offline_access"],
7841 "params": {"prompt": "consent"}
7842 }
7843 ]
7844 }))?;
7845
7846 let output = advance_setup_machine(
7847 temp.path(),
7848 "messaging-example",
7849 "demo",
7850 "default",
7851 &machine,
7852 false,
7853 )?;
7854
7855 assert_eq!(output["ok"], false);
7856 assert_eq!(
7857 output["result"]["detail"]["reason"],
7858 "oauth_authorization_required"
7859 );
7860 let state = load_setup_machine_state(&setup_machine_state_path(
7861 temp.path(),
7862 "demo",
7863 "default",
7864 "messaging-example",
7865 ))?;
7866 assert_eq!(state.status, SetupMachineStatus::Paused);
7867 let authorization_url = state.outputs["authorization_url"]
7868 .as_str()
7869 .expect("authorization_url");
7870 let parsed = url::Url::parse(authorization_url)?;
7871 let params: std::collections::BTreeMap<_, _> = parsed.query_pairs().collect();
7872 assert_eq!(
7873 params.get("client_id").map(|v| v.as_ref()),
7874 Some("client-123")
7875 );
7876 assert_eq!(
7877 params.get("redirect_uri").map(|v| v.as_ref()),
7878 Some("https://runtime.example.com/oauth/callback")
7879 );
7880 assert_eq!(
7881 params.get("scope").map(|v| v.as_ref()),
7882 Some("User.Read offline_access")
7883 );
7884 assert_eq!(params.get("prompt").map(|v| v.as_ref()), Some("consent"));
7885 let signed_state = params.get("state").expect("state");
7886 let key = crate::setup_actions::load_or_create_signing_key(temp.path())?;
7887 let payload = crate::setup_actions::validate_oauth_state(
7888 signed_state,
7889 &key,
7890 Some("messaging-example"),
7891 Some("demo"),
7892 Some("default"),
7893 crate::setup_actions::current_epoch_secs(),
7894 )?;
7895 assert_eq!(payload.action_id, "oauth");
7896 Ok(())
7897 }
7898
7899 #[test]
7900 fn advance_setup_machine_oauth_authorization_code_blocks_without_public_url()
7901 -> anyhow::Result<()> {
7902 let temp = tempfile::tempdir()?;
7903 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7904 "version": 1,
7905 "id": "example-machine",
7906 "entry_step": "oauth",
7907 "steps": [
7908 {
7909 "id": "oauth",
7910 "kind": "oauth_authorization_code",
7911 "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7912 "token_url": "https://login.example.com/oauth2/v2.0/token",
7913 "callback_path": "/oauth/callback"
7914 }
7915 ]
7916 }))?;
7917
7918 let output = advance_setup_machine(
7919 temp.path(),
7920 "messaging-example",
7921 "demo",
7922 "default",
7923 &machine,
7924 false,
7925 )?;
7926
7927 assert_eq!(output["ok"], false);
7928 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7929 assert_eq!(
7930 output["result"]["detail"]["missing_config_key"],
7931 "public_base_url"
7932 );
7933 let state = load_setup_machine_state(&setup_machine_state_path(
7934 temp.path(),
7935 "demo",
7936 "default",
7937 "messaging-example",
7938 ))?;
7939 assert_eq!(state.status, SetupMachineStatus::Paused);
7940 assert_eq!(state.current_step.as_deref(), Some("oauth"));
7941 Ok(())
7942 }
7943
7944 #[tokio::test]
7945 async fn complete_setup_machine_oauth_authorization_code_persists_tokens_as_secrets()
7946 -> anyhow::Result<()> {
7947 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
7948 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
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_for_env(temp.path(), "dev")?;
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 _store = crate::secrets::test_support::isolated_store();
8055 let temp = tempfile::tempdir()?;
8056 let base = serve_json_sequence(vec![
8057 (
8058 200,
8059 r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8060 ),
8061 (
8062 200,
8063 r#"{"access_token":"access-token-secret","refresh_token":"refresh-token-secret"}"#,
8064 ),
8065 ]);
8066 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8067 "version": 1,
8068 "id": "example-machine",
8069 "entry_step": "device",
8070 "steps": [
8071 {
8072 "id": "device",
8073 "kind": "oauth_device_code",
8074 "device_code_url": format!("{base}/device"),
8075 "token_url": format!("{base}/token"),
8076 "client_id": "client-123",
8077 "scopes": ["User.Read"],
8078 "token_store_key": "graph_access_token",
8079 "refresh_token_store_key": "graph_refresh_token",
8080 "on_success": "complete"
8081 }
8082 ]
8083 }))?;
8084
8085 let started = advance_setup_machine(
8086 temp.path(),
8087 "messaging-example",
8088 "demo",
8089 "default",
8090 &machine,
8091 false,
8092 )?;
8093
8094 assert_eq!(started["ok"], false);
8095 assert_eq!(
8096 started["result"]["detail"]["reason"],
8097 "oauth_device_code_pending"
8098 );
8099 let state_path =
8100 setup_machine_state_path(temp.path(), "demo", "default", "messaging-example");
8101 let raw_state = std::fs::read_to_string(&state_path)?;
8102 assert!(!raw_state.contains("raw-device-code"));
8103 assert!(!raw_state.contains("access-token-secret"));
8104 let state = load_setup_machine_state(&state_path)?;
8105 assert_eq!(state.status, SetupMachineStatus::Paused);
8106 assert_eq!(
8107 state.outputs["oauth_device_login"]["user_code"],
8108 "ABCD-EFGH"
8109 );
8110 assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8111
8112 let completed = advance_setup_machine(
8113 temp.path(),
8114 "messaging-example",
8115 "demo",
8116 "default",
8117 &machine,
8118 false,
8119 )?;
8120
8121 assert_eq!(completed["ok"], true);
8122 let state = load_setup_machine_state(&state_path)?;
8123 assert_eq!(state.status, SetupMachineStatus::Complete);
8124 assert_eq!(
8125 state.outputs["oauth_device_login"]["token_store_key"],
8126 "graph_access_token"
8127 );
8128 assert!(!setup_machine_oauth_device_session_path(temp.path(), &state, "device").exists());
8129 let raw_state = std::fs::read_to_string(&state_path)?;
8130 assert!(!raw_state.contains("access-token-secret"));
8131 assert!(!raw_state.contains("refresh-token-secret"));
8132 Ok(())
8133 }
8134
8135 #[test]
8136 fn advance_setup_machine_oauth_device_code_keeps_pending_resumable() -> anyhow::Result<()> {
8137 let temp = tempfile::tempdir()?;
8138 let base = serve_json_sequence(vec![
8139 (
8140 200,
8141 r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8142 ),
8143 (200, r#"{"error":"authorization_pending"}"#),
8144 ]);
8145 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8146 "version": 1,
8147 "id": "example-machine",
8148 "entry_step": "device",
8149 "steps": [
8150 {
8151 "id": "device",
8152 "kind": "oauth_device_code",
8153 "device_code_url": format!("{base}/device"),
8154 "token_url": format!("{base}/token"),
8155 "client_id": "client-123",
8156 "scope": "User.Read",
8157 "token_store_key": "graph_access_token",
8158 "on_success": "complete"
8159 }
8160 ]
8161 }))?;
8162
8163 let _ = advance_setup_machine(
8164 temp.path(),
8165 "messaging-example",
8166 "demo",
8167 "default",
8168 &machine,
8169 false,
8170 )?;
8171 let pending = advance_setup_machine(
8172 temp.path(),
8173 "messaging-example",
8174 "demo",
8175 "default",
8176 &machine,
8177 false,
8178 )?;
8179
8180 assert_eq!(pending["ok"], false);
8181 assert_eq!(pending["result"]["detail"]["error"], "oauth_pending");
8182 let state = load_setup_machine_state(&setup_machine_state_path(
8183 temp.path(),
8184 "demo",
8185 "default",
8186 "messaging-example",
8187 ))?;
8188 assert_eq!(state.status, SetupMachineStatus::Paused);
8189 assert_eq!(state.current_step.as_deref(), Some("device"));
8190 assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8191 Ok(())
8192 }
8193
8194 #[test]
8195 fn advance_setup_machine_http_json_stores_response_and_advances() -> anyhow::Result<()> {
8196 let temp = tempfile::tempdir()?;
8197 let url = serve_one_json(200, r#"{"registered":true,"tenant":"demo"}"#);
8198 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8199 "version": 1,
8200 "id": "example-machine",
8201 "entry_step": "register",
8202 "steps": [
8203 {
8204 "id": "register",
8205 "kind": "http_json",
8206 "url_template": url,
8207 "output_key": "registration",
8208 "on_success": "complete"
8209 }
8210 ]
8211 }))?;
8212
8213 let output = advance_setup_machine(
8214 temp.path(),
8215 "messaging-example",
8216 "demo",
8217 "default",
8218 &machine,
8219 false,
8220 )?;
8221
8222 assert_eq!(output["ok"], true);
8223 let state = load_setup_machine_state(&setup_machine_state_path(
8224 temp.path(),
8225 "demo",
8226 "default",
8227 "messaging-example",
8228 ))?;
8229 assert_eq!(state.status, SetupMachineStatus::Complete);
8230 assert_eq!(state.outputs["registration"]["registered"], true);
8231 assert_eq!(state.outputs["registration"]["tenant"], "demo");
8232 Ok(())
8233 }
8234
8235 #[test]
8236 fn advance_setup_machine_http_probe_blocks_on_unexpected_status() -> anyhow::Result<()> {
8237 let temp = tempfile::tempdir()?;
8238 let url = serve_one_json(503, r#"{"ok":false}"#);
8239 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8240 "version": 1,
8241 "id": "example-machine",
8242 "entry_step": "probe",
8243 "steps": [
8244 {
8245 "id": "probe",
8246 "kind": "http_probe",
8247 "url_template": url,
8248 "expected_status": 200
8249 }
8250 ]
8251 }))?;
8252
8253 let output = advance_setup_machine(
8254 temp.path(),
8255 "messaging-example",
8256 "demo",
8257 "default",
8258 &machine,
8259 false,
8260 )?;
8261
8262 assert_eq!(output["ok"], false);
8263 assert_eq!(
8264 output["result"]["detail"]["error"],
8265 "provider_contract_error"
8266 );
8267 assert_eq!(output["result"]["detail"]["status"], 503);
8268 let state = load_setup_machine_state(&setup_machine_state_path(
8269 temp.path(),
8270 "demo",
8271 "default",
8272 "messaging-example",
8273 ))?;
8274 assert_eq!(state.status, SetupMachineStatus::Paused);
8275 assert_eq!(state.current_step.as_deref(), Some("probe"));
8276 Ok(())
8277 }
8278
8279 #[test]
8280 fn advance_setup_machine_select_from_json_auto_selects_single_option() -> anyhow::Result<()> {
8281 let temp = tempfile::tempdir()?;
8282 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8283 "version": 1,
8284 "id": "example-machine",
8285 "entry_step": "select_team",
8286 "steps": [
8287 {
8288 "id": "select_team",
8289 "kind": "select_from_json",
8290 "source_output": "teams_response",
8291 "json_pointer": "/value",
8292 "value_key": "id",
8293 "output_key": "team_id",
8294 "on_success": "complete"
8295 }
8296 ]
8297 }))?;
8298 let state = SetupMachineState {
8299 schema_version: 1,
8300 provider_id: "messaging-example".to_string(),
8301 tenant: "demo".to_string(),
8302 team: "default".to_string(),
8303 machine_id: "example-machine".to_string(),
8304 machine_version: 1,
8305 status: SetupMachineStatus::Running,
8306 current_step: Some("select_team".to_string()),
8307 completed_steps: Vec::new(),
8308 failed_step: None,
8309 answers_hash: None,
8310 pack_fingerprint: None,
8311 outputs: serde_json::json!({
8312 "teams_response": {
8313 "value": [
8314 {"id": "team-123", "displayName": "Engineering"}
8315 ]
8316 }
8317 }),
8318 created_resources: Vec::new(),
8319 last_error: None,
8320 updated_at: None,
8321 };
8322 write_setup_machine_state(temp.path(), &state)?;
8323
8324 let output = advance_setup_machine(
8325 temp.path(),
8326 "messaging-example",
8327 "demo",
8328 "default",
8329 &machine,
8330 false,
8331 )?;
8332
8333 assert_eq!(output["ok"], true);
8334 let state = load_setup_machine_state(&setup_machine_state_path(
8335 temp.path(),
8336 "demo",
8337 "default",
8338 "messaging-example",
8339 ))?;
8340 assert_eq!(state.status, SetupMachineStatus::Complete);
8341 assert_eq!(state.outputs["team_id"], "team-123");
8342 assert_eq!(state.outputs["team_id_item"]["displayName"], "Engineering");
8343 Ok(())
8344 }
8345
8346 #[test]
8347 fn advance_setup_machine_select_from_json_pauses_for_multiple_options() -> anyhow::Result<()> {
8348 let temp = tempfile::tempdir()?;
8349 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8350 "version": 1,
8351 "id": "example-machine",
8352 "entry_step": "select_team",
8353 "steps": [
8354 {
8355 "id": "select_team",
8356 "kind": "select_from_json",
8357 "source_output": "teams_response",
8358 "json_pointer": "/value",
8359 "value_key": "id",
8360 "output_key": "team_id"
8361 }
8362 ]
8363 }))?;
8364 let state = SetupMachineState {
8365 schema_version: 1,
8366 provider_id: "messaging-example".to_string(),
8367 tenant: "demo".to_string(),
8368 team: "default".to_string(),
8369 machine_id: "example-machine".to_string(),
8370 machine_version: 1,
8371 status: SetupMachineStatus::Running,
8372 current_step: Some("select_team".to_string()),
8373 completed_steps: Vec::new(),
8374 failed_step: None,
8375 answers_hash: None,
8376 pack_fingerprint: None,
8377 outputs: serde_json::json!({
8378 "teams_response": {
8379 "value": [
8380 {"id": "team-123", "displayName": "Engineering"},
8381 {"id": "team-456", "displayName": "Support"}
8382 ]
8383 }
8384 }),
8385 created_resources: Vec::new(),
8386 last_error: None,
8387 updated_at: None,
8388 };
8389 write_setup_machine_state(temp.path(), &state)?;
8390
8391 let output = advance_setup_machine(
8392 temp.path(),
8393 "messaging-example",
8394 "demo",
8395 "default",
8396 &machine,
8397 false,
8398 )?;
8399
8400 assert_eq!(output["ok"], false);
8401 assert_eq!(output["result"]["detail"]["reason"], "selection_required");
8402 assert_eq!(output["result"]["detail"]["options"], 2);
8403 let state = load_setup_machine_state(&setup_machine_state_path(
8404 temp.path(),
8405 "demo",
8406 "default",
8407 "messaging-example",
8408 ))?;
8409 assert_eq!(state.status, SetupMachineStatus::Paused);
8410 assert_eq!(state.current_step.as_deref(), Some("select_team"));
8411 Ok(())
8412 }
8413
8414 #[test]
8415 fn advance_setup_machine_generate_file_writes_artifact_and_records_resource()
8416 -> anyhow::Result<()> {
8417 let temp = tempfile::tempdir()?;
8418 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8419 "version": 1,
8420 "id": "example-machine",
8421 "entry_step": "generate",
8422 "steps": [
8423 {
8424 "id": "generate",
8425 "kind": "generate_file",
8426 "artifact_path": "artifacts/app/config.json",
8427 "output_key": "config_artifact",
8428 "content": {
8429 "tenant": "{tenant}",
8430 "team": "{team}",
8431 "provider": "{provider_id}",
8432 "public_base_url": "{public_base_url}"
8433 },
8434 "on_success": "complete"
8435 }
8436 ]
8437 }))?;
8438 let state = SetupMachineState {
8439 schema_version: 1,
8440 provider_id: "messaging-example".to_string(),
8441 tenant: "demo".to_string(),
8442 team: "default".to_string(),
8443 machine_id: "example-machine".to_string(),
8444 machine_version: 1,
8445 status: SetupMachineStatus::Running,
8446 current_step: Some("generate".to_string()),
8447 completed_steps: Vec::new(),
8448 failed_step: None,
8449 answers_hash: None,
8450 pack_fingerprint: None,
8451 outputs: serde_json::json!({
8452 "public_base_url": "https://runtime.example.com"
8453 }),
8454 created_resources: Vec::new(),
8455 last_error: None,
8456 updated_at: None,
8457 };
8458 write_setup_machine_state(temp.path(), &state)?;
8459
8460 let output = advance_setup_machine(
8461 temp.path(),
8462 "messaging-example",
8463 "demo",
8464 "default",
8465 &machine,
8466 false,
8467 )?;
8468
8469 assert_eq!(output["ok"], true);
8470 let artifact_path =
8471 setup_machine_state_dir(temp.path(), "demo", "default", "messaging-example")
8472 .join("artifacts/app/config.json");
8473 let artifact = std::fs::read_to_string(artifact_path)?;
8474 let artifact: Value = serde_json::from_str(&artifact)?;
8475 assert_eq!(artifact["tenant"], "demo");
8476 assert_eq!(artifact["team"], "default");
8477 assert_eq!(artifact["provider"], "messaging-example");
8478 assert_eq!(artifact["public_base_url"], "https://runtime.example.com");
8479
8480 let state = load_setup_machine_state(&setup_machine_state_path(
8481 temp.path(),
8482 "demo",
8483 "default",
8484 "messaging-example",
8485 ))?;
8486 assert_eq!(state.status, SetupMachineStatus::Complete);
8487 assert_eq!(
8488 state.outputs["config_artifact"],
8489 "artifacts/app/config.json"
8490 );
8491 assert_eq!(
8492 state
8493 .created_resources
8494 .first()
8495 .and_then(|value| value.get("path")),
8496 Some(&Value::String("artifacts/app/config.json".to_string()))
8497 );
8498 Ok(())
8499 }
8500
8501 #[test]
8502 fn advance_setup_machine_generate_file_blocks_without_content() -> anyhow::Result<()> {
8503 let temp = tempfile::tempdir()?;
8504 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8505 "version": 1,
8506 "id": "example-machine",
8507 "entry_step": "generate",
8508 "steps": [
8509 {
8510 "id": "generate",
8511 "kind": "generate_file",
8512 "artifact_path": "artifacts/app/config.json"
8513 }
8514 ]
8515 }))?;
8516
8517 let output = advance_setup_machine(
8518 temp.path(),
8519 "messaging-example",
8520 "demo",
8521 "default",
8522 &machine,
8523 false,
8524 )?;
8525
8526 assert_eq!(output["ok"], false);
8527 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8528 let state = load_setup_machine_state(&setup_machine_state_path(
8529 temp.path(),
8530 "demo",
8531 "default",
8532 "messaging-example",
8533 ))?;
8534 assert_eq!(state.status, SetupMachineStatus::Paused);
8535 assert_eq!(state.current_step.as_deref(), Some("generate"));
8536 assert_eq!(
8537 state
8538 .last_error
8539 .as_ref()
8540 .and_then(|value| value.get("error")),
8541 Some(&Value::String("missing_prerequisite".to_string()))
8542 );
8543 Ok(())
8544 }
8545
8546 #[test]
8547 fn advance_setup_machine_persist_runtime_config_writes_setup_answers_and_strips_server_owned()
8548 -> anyhow::Result<()> {
8549 let temp = tempfile::tempdir()?;
8550 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8551 "version": 1,
8552 "id": "example-machine",
8553 "entry_step": "persist",
8554 "server_owned_config_keys": ["graph_access_token"],
8555 "steps": [
8556 {
8557 "id": "persist",
8558 "kind": "persist_runtime_config",
8559 "config": {
8560 "bot_app_id": "{bot_app_id}",
8561 "team_id": "{team_id}",
8562 "graph_access_token": "{graph_access_token}"
8563 },
8564 "on_success": "complete"
8565 }
8566 ]
8567 }))?;
8568 let state = SetupMachineState {
8569 schema_version: 1,
8570 provider_id: "messaging-example".to_string(),
8571 tenant: "demo".to_string(),
8572 team: "default".to_string(),
8573 machine_id: "example-machine".to_string(),
8574 machine_version: 1,
8575 status: SetupMachineStatus::Running,
8576 current_step: Some("persist".to_string()),
8577 completed_steps: Vec::new(),
8578 failed_step: None,
8579 answers_hash: None,
8580 pack_fingerprint: None,
8581 outputs: serde_json::json!({
8582 "bot_app_id": "app-123",
8583 "team_id": "team-123",
8584 "graph_access_token": "secret-token"
8585 }),
8586 created_resources: Vec::new(),
8587 last_error: None,
8588 updated_at: None,
8589 };
8590 write_setup_machine_state(temp.path(), &state)?;
8591
8592 let output = advance_setup_machine(
8593 temp.path(),
8594 "messaging-example",
8595 "demo",
8596 "default",
8597 &machine,
8598 false,
8599 )?;
8600
8601 assert_eq!(output["ok"], true);
8602 assert_eq!(output["result"]["detail"]["envelope_skipped"], true);
8603 let config_path = temp
8604 .path()
8605 .join("state/config/messaging-example/setup-answers.json");
8606 let config: Value = serde_json::from_str(&std::fs::read_to_string(config_path)?)?;
8607 assert_eq!(config["bot_app_id"], "app-123");
8608 assert_eq!(config["team_id"], "team-123");
8609 assert!(config.get("graph_access_token").is_none());
8610 let state = load_setup_machine_state(&setup_machine_state_path(
8611 temp.path(),
8612 "demo",
8613 "default",
8614 "messaging-example",
8615 ))?;
8616 assert_eq!(state.status, SetupMachineStatus::Complete);
8617 assert_eq!(
8618 state
8619 .created_resources
8620 .first()
8621 .and_then(|value| value.get("kind")),
8622 Some(&Value::String("runtime_config".to_string()))
8623 );
8624 Ok(())
8625 }
8626
8627 #[test]
8628 fn advance_setup_machine_persist_runtime_config_blocks_without_config() -> anyhow::Result<()> {
8629 let temp = tempfile::tempdir()?;
8630 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8631 "version": 1,
8632 "id": "example-machine",
8633 "entry_step": "persist",
8634 "steps": [
8635 {
8636 "id": "persist",
8637 "kind": "persist_runtime_config"
8638 }
8639 ]
8640 }))?;
8641
8642 let output = advance_setup_machine(
8643 temp.path(),
8644 "messaging-example",
8645 "demo",
8646 "default",
8647 &machine,
8648 false,
8649 )?;
8650
8651 assert_eq!(output["ok"], false);
8652 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8653 let state = load_setup_machine_state(&setup_machine_state_path(
8654 temp.path(),
8655 "demo",
8656 "default",
8657 "messaging-example",
8658 ))?;
8659 assert_eq!(state.status, SetupMachineStatus::Paused);
8660 assert_eq!(state.current_step.as_deref(), Some("persist"));
8661 Ok(())
8662 }
8663
8664 #[test]
8665 fn advance_setup_machine_provider_component_call_blocks_without_pack_path() -> anyhow::Result<()>
8666 {
8667 let temp = tempfile::tempdir()?;
8668 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8669 "version": 1,
8670 "id": "example-machine",
8671 "entry_step": "register",
8672 "steps": [
8673 {
8674 "id": "register",
8675 "kind": "provider_component_call",
8676 "component_ref": "messaging-example",
8677 "op": "register",
8678 "request": {"tenant": "{tenant}"}
8679 }
8680 ]
8681 }))?;
8682
8683 let output = advance_setup_machine(
8684 temp.path(),
8685 "messaging-example",
8686 "demo",
8687 "default",
8688 &machine,
8689 false,
8690 )?;
8691
8692 assert_eq!(output["ok"], false);
8693 assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8694 assert_eq!(
8695 output["result"]["detail"]["missing_config_key"],
8696 "pack_path"
8697 );
8698 let state = load_setup_machine_state(&setup_machine_state_path(
8699 temp.path(),
8700 "demo",
8701 "default",
8702 "messaging-example",
8703 ))?;
8704 assert_eq!(state.status, SetupMachineStatus::Paused);
8705 assert_eq!(state.current_step.as_deref(), Some("register"));
8706 Ok(())
8707 }
8708
8709 #[test]
8710 fn retry_setup_machine_step_clears_paused_error_without_losing_completed_steps()
8711 -> anyhow::Result<()> {
8712 let temp = tempfile::tempdir()?;
8713 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8714 "version": 1,
8715 "id": "example-machine",
8716 "entry_step": "collect",
8717 "steps": [
8718 {"id": "collect", "kind": "qa_form", "on_success": "manual"},
8719 {"id": "manual", "kind": "manual_action"}
8720 ]
8721 }))?;
8722 let state = SetupMachineState {
8723 schema_version: 1,
8724 provider_id: "messaging-example".to_string(),
8725 tenant: "demo".to_string(),
8726 team: "default".to_string(),
8727 machine_id: "example-machine".to_string(),
8728 machine_version: 1,
8729 status: SetupMachineStatus::Paused,
8730 current_step: Some("manual".to_string()),
8731 completed_steps: vec!["collect".to_string()],
8732 failed_step: Some("manual".to_string()),
8733 answers_hash: None,
8734 pack_fingerprint: None,
8735 outputs: serde_json::json!({}),
8736 created_resources: Vec::new(),
8737 last_error: Some(serde_json::json!({"error": "manual_action_required"})),
8738 updated_at: None,
8739 };
8740 write_setup_machine_state(temp.path(), &state)?;
8741
8742 let output = retry_setup_machine_step(
8743 temp.path(),
8744 "messaging-example",
8745 "demo",
8746 "default",
8747 &machine,
8748 None,
8749 )?;
8750
8751 assert_eq!(output["changed"], true);
8752 assert_eq!(output["step"], "manual");
8753 let loaded = load_setup_machine_state(&setup_machine_state_path(
8754 temp.path(),
8755 "demo",
8756 "default",
8757 "messaging-example",
8758 ))?;
8759 assert_eq!(loaded.status, SetupMachineStatus::Running);
8760 assert_eq!(loaded.current_step.as_deref(), Some("manual"));
8761 assert_eq!(loaded.completed_steps, vec!["collect"]);
8762 assert!(loaded.last_error.is_none());
8763 assert!(loaded.failed_step.is_none());
8764 Ok(())
8765 }
8766
8767 #[test]
8768 fn reset_setup_machine_state_archives_and_removes_machine_json() -> anyhow::Result<()> {
8769 let temp = tempfile::tempdir()?;
8770 let state = SetupMachineState {
8771 schema_version: 1,
8772 provider_id: "messaging-example".to_string(),
8773 tenant: "demo".to_string(),
8774 team: "default".to_string(),
8775 machine_id: "example-machine".to_string(),
8776 machine_version: 1,
8777 status: SetupMachineStatus::Paused,
8778 current_step: Some("manual".to_string()),
8779 completed_steps: Vec::new(),
8780 failed_step: None,
8781 answers_hash: None,
8782 pack_fingerprint: None,
8783 outputs: serde_json::json!({}),
8784 created_resources: Vec::new(),
8785 last_error: None,
8786 updated_at: None,
8787 };
8788 let state_path = write_setup_machine_state(temp.path(), &state)?;
8789
8790 let archive = reset_setup_machine_state(
8791 temp.path(),
8792 "demo",
8793 "default",
8794 "messaging-example",
8795 "manual reset",
8796 )?
8797 .expect("archive");
8798
8799 assert!(!state_path.exists());
8800 assert!(archive.is_file());
8801 let events = std::fs::read_to_string(setup_machine_events_path(
8802 temp.path(),
8803 "demo",
8804 "default",
8805 "messaging-example",
8806 ))?;
8807 assert!(events.contains("setup.machine.reset"));
8808 Ok(())
8809 }
8810
8811 #[test]
8812 fn migrate_setup_machine_state_archives_legacy_artifacts_and_initializes_machine()
8813 -> anyhow::Result<()> {
8814 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
8815 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
8816 let temp = tempfile::tempdir()?;
8817 let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8818 "version": 1,
8819 "id": "example-machine",
8820 "entry_step": "start",
8821 "steps": [
8822 {
8823 "id": "start",
8824 "kind": "manual_action",
8825 "on_success": "complete"
8826 }
8827 ]
8828 }))?;
8829 let legacy_backend = crate::setup_backend_contract::legacy_backend_state_path(
8830 temp.path(),
8831 "dev",
8832 "demo",
8833 "default",
8834 "messaging-example",
8835 )?;
8836 std::fs::create_dir_all(legacy_backend.parent().unwrap())?;
8837 std::fs::write(&legacy_backend, r#"{"config":{"bot_app_id":"old"}}"#)?;
8838 let legacy_actions = crate::setup_actions::setup_actions_state_path(
8839 temp.path(),
8840 "demo",
8841 "default",
8842 "messaging-example",
8843 );
8844 std::fs::create_dir_all(legacy_actions.parent().unwrap())?;
8845 std::fs::write(&legacy_actions, r#"{"actions":[]}"#)?;
8846
8847 let migration = migrate_setup_machine_state(
8848 temp.path(),
8849 "dev",
8850 "demo",
8851 "default",
8852 "messaging-example",
8853 &machine,
8854 )?;
8855
8856 assert_eq!(migration.source, "legacy-archived");
8857 assert!(migration.initialized);
8858 assert!(migration.legacy_backend_removed);
8859 assert!(migration.setup_actions_removed);
8860 assert!(!legacy_backend.exists());
8861 assert!(!legacy_actions.exists());
8862 assert!(migration.backend_archive_path.unwrap().is_file());
8863 assert!(migration.setup_actions_archive_path.unwrap().is_file());
8864 let state = load_setup_machine_state(&migration.state_path)?;
8865 assert_eq!(state.status, SetupMachineStatus::NotStarted);
8866 assert_eq!(state.current_step.as_deref(), Some("start"));
8867 assert!(migration.event_path.is_file());
8868
8869 let repeated = migrate_setup_machine_state(
8870 temp.path(),
8871 "dev",
8872 "demo",
8873 "default",
8874 "messaging-example",
8875 &machine,
8876 )?;
8877 assert_eq!(repeated.source, "machine");
8878 assert!(!repeated.initialized);
8879 assert!(!repeated.legacy_backend_removed);
8880 assert!(!repeated.setup_actions_removed);
8881 assert!(!repeated.empty);
8882 Ok(())
8883 }
8884}