1use std::collections::BTreeSet;
6
7use anyhow::anyhow;
8
9use crate::plan::*;
10use crate::setup_input::SetupQuestion;
11use serde_json::Value;
12
13use super::types::SetupRequest;
14
15pub fn apply_create(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
17 if request.tenants.is_empty() {
18 return Err(anyhow!("at least one tenant selection is required"));
19 }
20
21 let pack_refs = dedup_sorted(&request.pack_refs);
22 let tenants = normalize_tenants(&request.tenants);
23 let pack_setup_action_count = pack_setup_action_provider_count(&request.bundle);
24
25 let mut steps = Vec::new();
26 if !pack_refs.is_empty() {
27 steps.push(step(
28 SetupStepKind::ResolvePacks,
29 "Resolve selected pack refs via distributor client",
30 [("count", pack_refs.len().to_string())],
31 ));
32 } else {
33 steps.push(step(
34 SetupStepKind::NoOp,
35 "No pack refs selected; skipping pack resolution",
36 [("reason", "empty_pack_refs".to_string())],
37 ));
38 }
39 steps.push(step(
40 SetupStepKind::CreateBundle,
41 "Create demo bundle scaffold using existing conventions",
42 [("bundle", request.bundle.display().to_string())],
43 ));
44 if !pack_refs.is_empty() {
45 steps.push(step(
46 SetupStepKind::AddPacksToBundle,
47 "Copy fetched packs into bundle/packs",
48 [("count", pack_refs.len().to_string())],
49 ));
50 steps.push(step(
51 SetupStepKind::ValidateCapabilities,
52 "Validate provider packs have capabilities extension",
53 [("check", "greentic.ext.capabilities.v1".to_string())],
54 ));
55 steps.push(step(
56 SetupStepKind::ApplyPackSetup,
57 "Apply pack-declared setup outputs through internal setup hooks",
58 [("status", "planned".to_string())],
59 ));
60 } else if !request.setup_answers.is_empty() || pack_setup_action_count > 0 {
61 steps.push(step(
63 SetupStepKind::ValidateCapabilities,
64 "Validate provider packs have capabilities extension",
65 [("check", "greentic.ext.capabilities.v1".to_string())],
66 ));
67 steps.push(step(
68 SetupStepKind::ApplyPackSetup,
69 "Apply setup answers to existing bundle packs",
70 [(
71 "providers",
72 request
73 .setup_answers
74 .len()
75 .max(pack_setup_action_count)
76 .to_string(),
77 )],
78 ));
79 } else {
80 steps.push(step(
81 SetupStepKind::NoOp,
82 "No fetched packs to add or setup",
83 [("reason", "empty_pack_refs".to_string())],
84 ));
85 }
86 steps.push(step(
87 SetupStepKind::WriteGmapRules,
88 "Write tenant/team allow rules to gmap",
89 [("targets", tenants.len().to_string())],
90 ));
91 steps.push(step(
92 SetupStepKind::RunResolver,
93 "Run resolver pipeline (same as demo allow)",
94 [("resolver", "project::sync_project".to_string())],
95 ));
96 steps.push(step(
97 SetupStepKind::CopyResolvedManifest,
98 "Copy state/resolved manifests into resolved/ for demo start",
99 [("targets", tenants.len().to_string())],
100 ));
101 steps.push(step(
102 SetupStepKind::ValidateBundle,
103 "Validate bundle is loadable by internal demo pipeline",
104 [("check", "resolved manifests present".to_string())],
105 ));
106 steps.push(step(
107 SetupStepKind::BuildFlowIndex,
108 "Build fast2flow routing indexes and intents.md",
109 [("output", "state/indexes/".to_string())],
110 ));
111
112 Ok(SetupPlan {
113 mode: "create".to_string(),
114 dry_run,
115 bundle: request.bundle.clone(),
116 steps,
117 metadata: build_metadata(request, pack_refs, tenants),
118 })
119}
120
121pub fn apply_update(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
123 let pack_refs = dedup_sorted(&request.pack_refs);
124 let tenants = normalize_tenants(&request.tenants);
125
126 let mut ops = request.update_ops.clone();
127 if ops.is_empty() {
128 infer_update_ops(&mut ops, &pack_refs, request, &tenants);
129 }
130
131 let mut steps = vec![step(
132 SetupStepKind::ValidateBundle,
133 "Validate target bundle exists before update",
134 [("mode", "update".to_string())],
135 )];
136
137 if ops.is_empty() {
138 steps.push(step(
139 SetupStepKind::NoOp,
140 "No update operations selected",
141 [("reason", "empty_update_ops".to_string())],
142 ));
143 }
144 if ops.contains(&UpdateOp::PacksAdd) {
145 if pack_refs.is_empty() {
146 steps.push(step(
147 SetupStepKind::NoOp,
148 "packs_add selected without pack refs",
149 [("reason", "empty_pack_refs".to_string())],
150 ));
151 } else {
152 steps.push(step(
153 SetupStepKind::ResolvePacks,
154 "Resolve selected pack refs via distributor client",
155 [("count", pack_refs.len().to_string())],
156 ));
157 steps.push(step(
158 SetupStepKind::AddPacksToBundle,
159 "Copy fetched packs into bundle/packs",
160 [("count", pack_refs.len().to_string())],
161 ));
162 }
163 }
164 if ops.contains(&UpdateOp::PacksRemove) {
165 if request.packs_remove.is_empty() {
166 steps.push(step(
167 SetupStepKind::NoOp,
168 "packs_remove selected without targets",
169 [("reason", "empty_packs_remove".to_string())],
170 ));
171 } else {
172 steps.push(step(
173 SetupStepKind::AddPacksToBundle,
174 "Remove pack artifacts/default links from bundle",
175 [("count", request.packs_remove.len().to_string())],
176 ));
177 }
178 }
179 if ops.contains(&UpdateOp::ProvidersAdd) {
180 if request.providers.is_empty() && pack_refs.is_empty() {
181 steps.push(step(
182 SetupStepKind::NoOp,
183 "providers_add selected without providers or new packs",
184 [("reason", "empty_providers_add".to_string())],
185 ));
186 } else {
187 steps.push(step(
188 SetupStepKind::ApplyPackSetup,
189 "Enable providers in providers/providers.json",
190 [("count", request.providers.len().to_string())],
191 ));
192 }
193 }
194 if ops.contains(&UpdateOp::ProvidersRemove) {
195 if request.providers_remove.is_empty() {
196 steps.push(step(
197 SetupStepKind::NoOp,
198 "providers_remove selected without providers",
199 [("reason", "empty_providers_remove".to_string())],
200 ));
201 } else {
202 steps.push(step(
203 SetupStepKind::ApplyPackSetup,
204 "Disable/remove providers in providers/providers.json",
205 [("count", request.providers_remove.len().to_string())],
206 ));
207 }
208 }
209 if ops.contains(&UpdateOp::TenantsAdd) {
210 if tenants.is_empty() {
211 steps.push(step(
212 SetupStepKind::NoOp,
213 "tenants_add selected without tenant targets",
214 [("reason", "empty_tenants_add".to_string())],
215 ));
216 } else {
217 steps.push(step(
218 SetupStepKind::WriteGmapRules,
219 "Ensure tenant/team directories and allow rules",
220 [("targets", tenants.len().to_string())],
221 ));
222 }
223 }
224 if ops.contains(&UpdateOp::TenantsRemove) {
225 if request.tenants_remove.is_empty() {
226 steps.push(step(
227 SetupStepKind::NoOp,
228 "tenants_remove selected without tenant targets",
229 [("reason", "empty_tenants_remove".to_string())],
230 ));
231 } else {
232 steps.push(step(
233 SetupStepKind::WriteGmapRules,
234 "Remove tenant/team directories and related rules",
235 [("targets", request.tenants_remove.len().to_string())],
236 ));
237 }
238 }
239 if ops.contains(&UpdateOp::AccessChange) {
240 let access_count = request.access_changes.len()
241 + tenants.iter().filter(|t| !t.allow_paths.is_empty()).count();
242 if access_count == 0 {
243 steps.push(step(
244 SetupStepKind::NoOp,
245 "access_change selected without mutations",
246 [("reason", "empty_access_changes".to_string())],
247 ));
248 } else {
249 steps.push(step(
250 SetupStepKind::WriteGmapRules,
251 "Apply access rule updates",
252 [("changes", access_count.to_string())],
253 ));
254 steps.push(step(
255 SetupStepKind::RunResolver,
256 "Run resolver pipeline (same as demo allow/forbid)",
257 [("resolver", "project::sync_project".to_string())],
258 ));
259 steps.push(step(
260 SetupStepKind::CopyResolvedManifest,
261 "Copy state/resolved manifests into resolved/ for demo start",
262 [("targets", tenants.len().to_string())],
263 ));
264 }
265 }
266 steps.push(step(
267 SetupStepKind::ValidateBundle,
268 "Validate bundle is loadable by internal demo pipeline",
269 [("check", "resolved manifests present".to_string())],
270 ));
271 steps.push(step(
272 SetupStepKind::BuildFlowIndex,
273 "Rebuild fast2flow routing indexes after update",
274 [("output", "state/indexes/".to_string())],
275 ));
276
277 Ok(SetupPlan {
278 mode: SetupMode::Update.as_str().to_string(),
279 dry_run,
280 bundle: request.bundle.clone(),
281 steps,
282 metadata: build_metadata_with_ops(request, pack_refs, tenants, ops),
283 })
284}
285
286pub fn apply_remove(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
288 let tenants = normalize_tenants(&request.tenants);
289
290 let mut targets = request.remove_targets.clone();
291 if targets.is_empty() {
292 if !request.packs_remove.is_empty() {
293 targets.insert(RemoveTarget::Packs);
294 }
295 if !request.providers_remove.is_empty() {
296 targets.insert(RemoveTarget::Providers);
297 }
298 if !request.tenants_remove.is_empty() {
299 targets.insert(RemoveTarget::TenantsTeams);
300 }
301 }
302
303 let mut steps = vec![step(
304 SetupStepKind::ValidateBundle,
305 "Validate target bundle exists before remove",
306 [("mode", "remove".to_string())],
307 )];
308
309 if targets.is_empty() {
310 steps.push(step(
311 SetupStepKind::NoOp,
312 "No remove targets selected",
313 [("reason", "empty_remove_targets".to_string())],
314 ));
315 }
316 if targets.contains(&RemoveTarget::Packs) {
317 if request.packs_remove.is_empty() {
318 steps.push(step(
319 SetupStepKind::NoOp,
320 "packs target selected without pack identifiers",
321 [("reason", "empty_packs_remove".to_string())],
322 ));
323 } else {
324 steps.push(step(
325 SetupStepKind::AddPacksToBundle,
326 "Delete pack files/default links from bundle",
327 [("count", request.packs_remove.len().to_string())],
328 ));
329 }
330 }
331 if targets.contains(&RemoveTarget::Providers) {
332 if request.providers_remove.is_empty() {
333 steps.push(step(
334 SetupStepKind::NoOp,
335 "providers target selected without provider ids",
336 [("reason", "empty_providers_remove".to_string())],
337 ));
338 } else {
339 steps.push(step(
340 SetupStepKind::ApplyPackSetup,
341 "Remove provider entries from providers/providers.json",
342 [("count", request.providers_remove.len().to_string())],
343 ));
344 }
345 }
346 if targets.contains(&RemoveTarget::TenantsTeams) {
347 if request.tenants_remove.is_empty() {
348 steps.push(step(
349 SetupStepKind::NoOp,
350 "tenants_teams target selected without tenant/team ids",
351 [("reason", "empty_tenants_remove".to_string())],
352 ));
353 } else {
354 steps.push(step(
355 SetupStepKind::WriteGmapRules,
356 "Delete tenant/team directories and access rules",
357 [("count", request.tenants_remove.len().to_string())],
358 ));
359 steps.push(step(
360 SetupStepKind::RunResolver,
361 "Run resolver pipeline after tenant/team removals",
362 [("resolver", "project::sync_project".to_string())],
363 ));
364 steps.push(step(
365 SetupStepKind::CopyResolvedManifest,
366 "Copy state/resolved manifests into resolved/ for demo start",
367 [("targets", tenants.len().to_string())],
368 ));
369 }
370 }
371 steps.push(step(
372 SetupStepKind::ValidateBundle,
373 "Validate bundle is loadable by internal demo pipeline",
374 [("check", "resolved manifests present".to_string())],
375 ));
376
377 Ok(SetupPlan {
378 mode: SetupMode::Remove.as_str().to_string(),
379 dry_run,
380 bundle: request.bundle.clone(),
381 steps,
382 metadata: SetupPlanMetadata {
383 bundle_name: request.bundle_name.clone(),
384 pack_refs: Vec::new(),
385 tenants,
386 default_assignments: request.default_assignments.clone(),
387 providers: request.providers.clone(),
388 update_ops: request.update_ops.clone(),
389 remove_targets: targets,
390 packs_remove: request.packs_remove.clone(),
391 providers_remove: request.providers_remove.clone(),
392 tenants_remove: request.tenants_remove.clone(),
393 access_changes: request.access_changes.clone(),
394 static_routes: request.static_routes.clone(),
395 deployment_targets: request.deployment_targets.clone(),
396 tunnel: request.tunnel.clone(),
397 telemetry: request.telemetry.clone(),
398 setup_answers: request.setup_answers.clone(),
399 },
400 })
401}
402
403pub fn print_plan_summary(plan: &SetupPlan) {
405 println!("wizard plan: mode={} dry_run={}", plan.mode, plan.dry_run);
406 println!("bundle: {}", plan.bundle.display());
407 let noop_count = plan
408 .steps
409 .iter()
410 .filter(|s| s.kind == SetupStepKind::NoOp)
411 .count();
412 if noop_count > 0 {
413 println!("no-op steps: {noop_count}");
414 }
415 for (index, s) in plan.steps.iter().enumerate() {
416 println!("{}. {}", index + 1, s.description);
417 }
418}
419
420pub fn dedup_sorted(refs: &[String]) -> Vec<String> {
424 let mut v: Vec<String> = refs
425 .iter()
426 .map(|r| r.trim().to_string())
427 .filter(|r| !r.is_empty())
428 .collect();
429 v.sort();
430 v.dedup();
431 v
432}
433
434pub fn normalize_tenants(tenants: &[TenantSelection]) -> Vec<TenantSelection> {
436 let mut result: Vec<TenantSelection> = tenants
437 .iter()
438 .map(|t| {
439 let mut t = t.clone();
440 t.allow_paths.sort();
441 t.allow_paths.dedup();
442 t
443 })
444 .collect();
445 result.sort_by(|a, b| {
446 a.tenant
447 .cmp(&b.tenant)
448 .then_with(|| a.team.cmp(&b.team))
449 .then_with(|| a.allow_paths.cmp(&b.allow_paths))
450 });
451 result
452}
453
454pub fn infer_update_ops(
456 ops: &mut BTreeSet<UpdateOp>,
457 pack_refs: &[String],
458 request: &SetupRequest,
459 tenants: &[TenantSelection],
460) {
461 if !pack_refs.is_empty() {
462 ops.insert(UpdateOp::PacksAdd);
463 }
464 if !request.providers.is_empty() {
465 ops.insert(UpdateOp::ProvidersAdd);
466 }
467 if !request.providers_remove.is_empty() {
468 ops.insert(UpdateOp::ProvidersRemove);
469 }
470 if !request.packs_remove.is_empty() {
471 ops.insert(UpdateOp::PacksRemove);
472 }
473 if !tenants.is_empty() {
474 ops.insert(UpdateOp::TenantsAdd);
475 }
476 if !request.tenants_remove.is_empty() {
477 ops.insert(UpdateOp::TenantsRemove);
478 }
479 if !request.access_changes.is_empty() || tenants.iter().any(|t| !t.allow_paths.is_empty()) {
480 ops.insert(UpdateOp::AccessChange);
481 }
482}
483
484pub fn build_metadata(
486 request: &SetupRequest,
487 pack_refs: Vec<String>,
488 tenants: Vec<TenantSelection>,
489) -> SetupPlanMetadata {
490 SetupPlanMetadata {
491 bundle_name: request.bundle_name.clone(),
492 pack_refs,
493 tenants,
494 default_assignments: request.default_assignments.clone(),
495 providers: request.providers.clone(),
496 update_ops: request.update_ops.clone(),
497 remove_targets: request.remove_targets.clone(),
498 packs_remove: request.packs_remove.clone(),
499 providers_remove: request.providers_remove.clone(),
500 tenants_remove: request.tenants_remove.clone(),
501 access_changes: request.access_changes.clone(),
502 static_routes: request.static_routes.clone(),
503 deployment_targets: request.deployment_targets.clone(),
504 tunnel: request.tunnel.clone(),
505 telemetry: request.telemetry.clone(),
506 setup_answers: request.setup_answers.clone(),
507 }
508}
509
510pub fn build_metadata_with_ops(
512 request: &SetupRequest,
513 pack_refs: Vec<String>,
514 tenants: Vec<TenantSelection>,
515 ops: BTreeSet<UpdateOp>,
516) -> SetupPlanMetadata {
517 let mut meta = build_metadata(request, pack_refs, tenants);
518 meta.update_ops = ops;
519 meta
520}
521
522fn pack_setup_action_provider_count(bundle: &std::path::Path) -> usize {
523 let Ok(discovered) = crate::discovery::discover(bundle) else {
524 return 0;
525 };
526 discovered
527 .setup_targets()
528 .into_iter()
529 .filter(|provider| {
530 crate::setup_input::load_setup_spec(&provider.pack_path)
531 .ok()
532 .flatten()
533 .is_some_and(|spec| !spec.setup_actions.is_empty())
534 })
535 .count()
536}
537
538pub fn compute_simple_hash(input: &str) -> String {
540 use std::collections::hash_map::DefaultHasher;
541 use std::hash::{Hash, Hasher};
542
543 let mut hasher = DefaultHasher::new();
544 input.hash(&mut hasher);
545 format!("{:016x}", hasher.finish())
546}
547
548pub fn infer_default_value(question: &SetupQuestion) -> Value {
555 if let Some(default) = question.default.clone() {
557 return default;
558 }
559
560 if let Some(ref help) = question.help
563 && let Some(default) = extract_default_from_help(help)
564 {
565 return Value::String(default);
566 }
567
568 Value::String(String::new())
570}
571
572pub fn extract_default_from_help(help: &str) -> Option<String> {
579 use regex::Regex;
580
581 let re = Regex::new(r"(?i)[\(\[]?\s*default:\s*([^\)\]\n,]+)\s*[\)\]]?").ok()?;
583 if let Some(caps) = re.captures(help) {
584 let value = caps.get(1)?.as_str().trim();
585 let cleaned = value.trim_end_matches(|c: char| c == '.' || c == ',' || c.is_whitespace());
587 if !cleaned.is_empty() {
588 return Some(cleaned.to_string());
589 }
590 }
591
592 None
593}