Skip to main content

greentic_setup/engine/
executors.rs

1//! Step executor implementations for the setup engine.
2//!
3//! Each executor handles a specific `SetupStepKind`.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::Context;
8use serde_json::Value;
9
10use crate::plan::{ResolvedPackInfo, SetupPlanMetadata};
11use crate::{bundle, discovery};
12
13use super::plan_builders::compute_simple_hash;
14use super::types::SetupConfig;
15
16/// Execute the CreateBundle step.
17pub fn execute_create_bundle(
18    bundle_path: &Path,
19    metadata: &SetupPlanMetadata,
20) -> anyhow::Result<()> {
21    bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
22        .context("failed to create bundle structure")
23}
24
25/// Execute the ResolvePacks step.
26pub fn execute_resolve_packs(
27    _bundle_path: &Path,
28    metadata: &SetupPlanMetadata,
29) -> anyhow::Result<Vec<ResolvedPackInfo>> {
30    let mut resolved = Vec::new();
31
32    for pack_ref in &metadata.pack_refs {
33        // For now, we only support local pack refs (file paths)
34        // OCI resolution requires async and the distributor client
35        let path = PathBuf::from(pack_ref);
36
37        // Try to canonicalize the path to handle relative paths correctly
38        let resolved_path = if path.is_absolute() {
39            path.clone()
40        } else {
41            std::env::current_dir()
42                .ok()
43                .map(|cwd| cwd.join(&path))
44                .unwrap_or_else(|| path.clone())
45        };
46
47        if resolved_path.exists() {
48            let canonical = resolved_path
49                .canonicalize()
50                .unwrap_or(resolved_path.clone());
51            resolved.push(ResolvedPackInfo {
52                source_ref: pack_ref.clone(),
53                mapped_ref: canonical.display().to_string(),
54                resolved_digest: format!("sha256:{}", compute_simple_hash(pack_ref)),
55                pack_id: canonical
56                    .file_stem()
57                    .and_then(|s| s.to_str())
58                    .unwrap_or("unknown")
59                    .to_string(),
60                entry_flows: Vec::new(),
61                cached_path: canonical.clone(),
62                output_path: canonical,
63            });
64        } else if pack_ref.starts_with("oci://")
65            || pack_ref.starts_with("repo://")
66            || pack_ref.starts_with("store://")
67        {
68            // Remote packs need async resolution via distributor-client
69            // For now, we'll skip and let the caller handle this
70            tracing::warn!("remote pack ref requires async resolution: {}", pack_ref);
71        } else {
72            // Log warning for unresolved local paths
73            tracing::warn!(
74                "pack ref not found: {} (resolved to: {})",
75                pack_ref,
76                resolved_path.display()
77            );
78        }
79    }
80
81    Ok(resolved)
82}
83
84/// Execute the AddPacksToBundle step.
85pub fn execute_add_packs_to_bundle(
86    bundle_path: &Path,
87    resolved_packs: &[ResolvedPackInfo],
88) -> anyhow::Result<()> {
89    for pack in resolved_packs {
90        // Determine target directory based on pack ID domain prefix
91        let target_dir = get_pack_target_dir(bundle_path, &pack.pack_id);
92        std::fs::create_dir_all(&target_dir)?;
93
94        let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
95        if pack.cached_path.exists() && !target_path.exists() {
96            std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
97                format!(
98                    "failed to copy pack {} to {}",
99                    pack.cached_path.display(),
100                    target_path.display()
101                )
102            })?;
103        }
104    }
105    Ok(())
106}
107
108/// Determine the target directory for a pack based on its ID.
109///
110/// Packs with domain prefixes (e.g., `messaging-telegram`, `events-webhook`)
111/// go to `providers/<domain>/`. Other packs go to `packs/`.
112pub fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
113    const DOMAIN_PREFIXES: &[&str] = &[
114        "messaging-",
115        "events-",
116        "oauth-",
117        "secrets-",
118        "mcp-",
119        "state-",
120    ];
121
122    for prefix in DOMAIN_PREFIXES {
123        if pack_id.starts_with(prefix) {
124            let domain = prefix.trim_end_matches('-');
125            return bundle_path.join("providers").join(domain);
126        }
127    }
128
129    // Default to packs/ for non-provider packs
130    bundle_path.join("packs")
131}
132
133/// Execute the ApplyPackSetup step.
134pub fn execute_apply_pack_setup(
135    bundle_path: &Path,
136    metadata: &SetupPlanMetadata,
137    config: &SetupConfig,
138) -> anyhow::Result<usize> {
139    let mut count = 0;
140
141    if !metadata.providers_remove.is_empty() {
142        count += execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
143    }
144
145    // Auto-install provider packs that are referenced in setup_answers
146    // but not yet present in the bundle.
147    auto_install_provider_packs(bundle_path, metadata);
148
149    // Discover packs so we can find pack_path for secret alias seeding
150    let discovered = if bundle_path.exists() {
151        discovery::discover(bundle_path).ok()
152    } else {
153        None
154    };
155
156    // Persist setup answers to local config files and dev secrets store
157    for (provider_id, answers) in &metadata.setup_answers {
158        // Write answers to provider config directory
159        let config_dir = bundle_path.join("state").join("config").join(provider_id);
160        std::fs::create_dir_all(&config_dir)?;
161
162        let config_path = config_dir.join("setup-answers.json");
163        let content =
164            serde_json::to_string_pretty(answers).context("failed to serialize setup answers")?;
165        std::fs::write(&config_path, content).with_context(|| {
166            format!(
167                "failed to write setup answers to: {}",
168                config_path.display()
169            )
170        })?;
171
172        // Persist all answer values to the dev secrets store so that
173        // WASM components can read them via the secrets API at runtime.
174        let pack_path = discovered.as_ref().and_then(|d| {
175            d.providers
176                .iter()
177                .find(|p| p.provider_id == *provider_id)
178                .map(|p| p.pack_path.as_path())
179        });
180        let env = crate::resolve_env(Some(&config.env));
181        if config.verbose {
182            let team_display = config.team.as_deref().unwrap_or("(none)");
183            println!(
184                "  [secrets] scope: env={env}, tenant={}, team={team_display}, provider={provider_id}",
185                config.tenant
186            );
187            let example_uri = crate::canonical_secret_uri(
188                &env,
189                &config.tenant,
190                config.team.as_deref(),
191                provider_id,
192                "_example_key",
193            );
194            println!("  [secrets] URI pattern: {example_uri}");
195            if let Some(config_map) = answers.as_object() {
196                let keys: Vec<&String> = config_map.keys().collect();
197                println!("  [secrets] answer keys: {keys:?}");
198            }
199        }
200        let rt = tokio::runtime::Runtime::new()
201            .context("failed to create tokio runtime for secrets persistence")?;
202        let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
203            bundle_path,
204            &env,
205            &config.tenant,
206            config.team.as_deref(),
207            provider_id,
208            answers,
209            pack_path,
210        ))?;
211        if config.verbose {
212            if persisted.is_empty() {
213                println!(
214                    "  [secrets] WARNING: 0 key(s) persisted for {provider_id} (all values empty?)"
215                );
216            } else {
217                println!(
218                    "  [secrets] persisted {} key(s) for {provider_id}: {:?}",
219                    persisted.len(),
220                    persisted
221                );
222            }
223        }
224
225        // Sync OAuth answers to tenant config JSON for webchat-gui providers
226        match crate::tenant_config::sync_oauth_to_tenant_config(
227            bundle_path,
228            &config.tenant,
229            provider_id,
230            answers,
231        ) {
232            Ok(true) => {
233                if config.verbose {
234                    println!("  [oauth] updated tenant config for {provider_id}");
235                }
236            }
237            Ok(false) => {}
238            Err(e) => {
239                println!("  [oauth] WARNING: failed to update tenant config: {e}");
240            }
241        }
242
243        // Register webhooks if the provider needs one (e.g. Telegram, Slack, Webex)
244        if let Some(result) = crate::webhook::register_webhook(
245            provider_id,
246            answers,
247            &config.tenant,
248            config.team.as_deref(),
249        ) {
250            let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
251            if ok {
252                println!("  [webhook] registered for {provider_id}");
253            } else {
254                let err = result
255                    .get("error")
256                    .and_then(Value::as_str)
257                    .unwrap_or("unknown");
258                println!("  [webhook] WARNING: registration failed for {provider_id}: {err}");
259            }
260        }
261
262        count += 1;
263    }
264
265    crate::platform_setup::persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
266    let _ = crate::deployment_targets::persist_explicit_deployment_targets(
267        bundle_path,
268        &metadata.deployment_targets,
269    );
270
271    // Print post-setup instructions for providers needing manual steps
272    let provider_configs: Vec<(String, Value)> = metadata
273        .setup_answers
274        .iter()
275        .map(|(id, val)| (id.clone(), val.clone()))
276        .collect();
277    let team = config.team.as_deref().unwrap_or("default");
278    crate::webhook::print_post_setup_instructions(&provider_configs, &config.tenant, team);
279
280    Ok(count)
281}
282
283/// Remove provider artifacts and config directories.
284pub fn execute_remove_provider_artifacts(
285    bundle_path: &Path,
286    providers_remove: &[String],
287) -> anyhow::Result<usize> {
288    let mut removed = 0usize;
289    let discovered = discovery::discover(bundle_path).ok();
290    for provider_id in providers_remove {
291        if let Some(discovered) = discovered.as_ref()
292            && let Some(provider) = discovered
293                .providers
294                .iter()
295                .find(|provider| provider.provider_id == *provider_id)
296        {
297            if provider.pack_path.exists() {
298                std::fs::remove_file(&provider.pack_path).with_context(|| {
299                    format!(
300                        "failed to remove provider pack {}",
301                        provider.pack_path.display()
302                    )
303                })?;
304            }
305            removed += 1;
306        } else {
307            let target_dir = get_pack_target_dir(bundle_path, provider_id);
308            let target_path = target_dir.join(format!("{provider_id}.gtpack"));
309            if target_path.exists() {
310                std::fs::remove_file(&target_path).with_context(|| {
311                    format!("failed to remove provider pack {}", target_path.display())
312                })?;
313                removed += 1;
314            }
315        }
316
317        let config_dir = bundle_path.join("state").join("config").join(provider_id);
318        if config_dir.exists() {
319            std::fs::remove_dir_all(&config_dir).with_context(|| {
320                format!(
321                    "failed to remove provider config dir {}",
322                    config_dir.display()
323                )
324            })?;
325        }
326    }
327    Ok(removed)
328}
329
330/// Search sibling bundles for provider packs referenced in setup_answers
331/// and install them into this bundle if missing.
332pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
333    let bundle_abs =
334        std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
335
336    for provider_id in metadata.setup_answers.keys() {
337        let target_dir = get_pack_target_dir(bundle_path, provider_id);
338        let target_path = target_dir.join(format!("{provider_id}.gtpack"));
339        if target_path.exists() {
340            continue;
341        }
342
343        // Determine the provider domain from the ID
344        let domain = domain_from_provider_id(provider_id);
345
346        // Search for the pack in sibling bundles and build output
347        if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
348            if let Err(err) = std::fs::create_dir_all(&target_dir) {
349                eprintln!(
350                    "  [provider] WARNING: failed to create {}: {err}",
351                    target_dir.display()
352                );
353                continue;
354            }
355            match std::fs::copy(&source, &target_path) {
356                Ok(_) => println!(
357                    "  [provider] installed {provider_id}.gtpack from {}",
358                    source.display()
359                ),
360                Err(err) => eprintln!(
361                    "  [provider] WARNING: failed to copy {}: {err}",
362                    source.display()
363                ),
364            }
365        } else {
366            eprintln!("  [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
367        }
368    }
369}
370
371/// Extract domain from a provider ID (e.g. "messaging-telegram" → "messaging").
372pub fn domain_from_provider_id(provider_id: &str) -> &str {
373    const DOMAIN_PREFIXES: &[&str] = &[
374        "messaging-",
375        "events-",
376        "oauth-",
377        "secrets-",
378        "mcp-",
379        "state-",
380        "telemetry-",
381    ];
382    for prefix in DOMAIN_PREFIXES {
383        if provider_id.starts_with(prefix) {
384            return prefix.trim_end_matches('-');
385        }
386    }
387    "messaging" // default
388}
389
390/// Search known locations for a provider pack file.
391///
392/// Search order:
393/// 1. Sibling bundle directories: `../<bundle>/providers/<domain>/<id>.gtpack`
394/// 2. Build output: `../greentic-messaging-providers/target/packs/<id>.gtpack`
395pub fn find_provider_pack_source(
396    provider_id: &str,
397    domain: &str,
398    bundle_abs: &Path,
399) -> Option<PathBuf> {
400    let parent = bundle_abs.parent()?;
401    let filename = format!("{provider_id}.gtpack");
402
403    // 1. Sibling bundles
404    if let Ok(entries) = std::fs::read_dir(parent) {
405        for entry in entries.flatten() {
406            let sibling = entry.path();
407            if sibling == *bundle_abs || !sibling.is_dir() {
408                continue;
409            }
410            let candidate = sibling.join("providers").join(domain).join(&filename);
411            if candidate.is_file() {
412                return Some(candidate);
413            }
414        }
415    }
416
417    // 2. Build output from greentic-messaging-providers
418    for ancestor in parent.ancestors().take(4) {
419        let candidate = ancestor
420            .join("greentic-messaging-providers")
421            .join("target")
422            .join("packs")
423            .join(&filename);
424        if candidate.is_file() {
425            return Some(candidate);
426        }
427    }
428
429    None
430}
431
432/// Execute the WriteGmapRules step.
433pub fn execute_write_gmap_rules(
434    bundle_path: &Path,
435    metadata: &SetupPlanMetadata,
436) -> anyhow::Result<()> {
437    for tenant_sel in &metadata.tenants {
438        let gmap_path =
439            bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
440
441        if let Some(parent) = gmap_path.parent() {
442            std::fs::create_dir_all(parent)?;
443        }
444
445        // Build gmap content from allow_paths
446        let mut content = String::new();
447        if tenant_sel.allow_paths.is_empty() {
448            content.push_str("_ = forbidden\n");
449        } else {
450            for path in &tenant_sel.allow_paths {
451                content.push_str(&format!("{} = allowed\n", path));
452            }
453            content.push_str("_ = forbidden\n");
454        }
455
456        std::fs::write(&gmap_path, content)
457            .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
458    }
459    Ok(())
460}
461
462/// Execute the CopyResolvedManifest step.
463pub fn execute_copy_resolved_manifests(
464    bundle_path: &Path,
465    metadata: &SetupPlanMetadata,
466) -> anyhow::Result<Vec<PathBuf>> {
467    let mut manifests = Vec::new();
468    let resolved_dir = bundle_path.join("resolved");
469    std::fs::create_dir_all(&resolved_dir)?;
470
471    for tenant_sel in &metadata.tenants {
472        let filename =
473            bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
474        let manifest_path = resolved_dir.join(&filename);
475
476        // Create an empty manifest placeholder if it doesn't exist
477        if !manifest_path.exists() {
478            std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
479        }
480        manifests.push(manifest_path);
481    }
482
483    Ok(manifests)
484}
485
486/// Execute the ValidateBundle step.
487pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
488    bundle::validate_bundle_exists(bundle_path)
489}
490
491/// Execute the BuildFlowIndex step.
492///
493/// Scans all flows in the bundle, builds a TF-IDF index and a routing-compatible
494/// index, and optionally generates intents.md documentation.
495/// Output is written to `bundle/state/indexes/`.
496///
497/// Requires the `fast2flow` feature AND the `fast2flow-bundle` crate wired as a
498/// dependency.  Until `fast2flow-bundle` is published or vendored, this is a
499/// no-op stub that logs a skip message.
500pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
501    tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
502    Ok(())
503}