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        let rt = tokio::runtime::Runtime::new()
182            .context("failed to create tokio runtime for secrets persistence")?;
183        let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
184            bundle_path,
185            &env,
186            &config.tenant,
187            config.team.as_deref(),
188            provider_id,
189            answers,
190            pack_path,
191        ))?;
192        if config.verbose && !persisted.is_empty() {
193            println!(
194                "  [secrets] persisted {} key(s) for {provider_id}",
195                persisted.len()
196            );
197        }
198
199        // Sync OAuth answers to tenant config JSON for webchat-gui providers
200        match crate::tenant_config::sync_oauth_to_tenant_config(
201            bundle_path,
202            &config.tenant,
203            provider_id,
204            answers,
205        ) {
206            Ok(true) => {
207                if config.verbose {
208                    println!("  [oauth] updated tenant config for {provider_id}");
209                }
210            }
211            Ok(false) => {}
212            Err(e) => {
213                println!("  [oauth] WARNING: failed to update tenant config: {e}");
214            }
215        }
216
217        // Register webhooks if the provider needs one (e.g. Telegram, Slack, Webex)
218        if let Some(result) = crate::webhook::register_webhook(
219            provider_id,
220            answers,
221            &config.tenant,
222            config.team.as_deref(),
223        ) {
224            let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
225            if ok {
226                println!("  [webhook] registered for {provider_id}");
227            } else {
228                let err = result
229                    .get("error")
230                    .and_then(Value::as_str)
231                    .unwrap_or("unknown");
232                println!("  [webhook] WARNING: registration failed for {provider_id}: {err}");
233            }
234        }
235
236        count += 1;
237    }
238
239    crate::platform_setup::persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
240    let _ = crate::deployment_targets::persist_explicit_deployment_targets(
241        bundle_path,
242        &metadata.deployment_targets,
243    );
244
245    // Print post-setup instructions for providers needing manual steps
246    let provider_configs: Vec<(String, Value)> = metadata
247        .setup_answers
248        .iter()
249        .map(|(id, val)| (id.clone(), val.clone()))
250        .collect();
251    let team = config.team.as_deref().unwrap_or("default");
252    crate::webhook::print_post_setup_instructions(&provider_configs, &config.tenant, team);
253
254    Ok(count)
255}
256
257/// Remove provider artifacts and config directories.
258pub fn execute_remove_provider_artifacts(
259    bundle_path: &Path,
260    providers_remove: &[String],
261) -> anyhow::Result<usize> {
262    let mut removed = 0usize;
263    let discovered = discovery::discover(bundle_path).ok();
264    for provider_id in providers_remove {
265        if let Some(discovered) = discovered.as_ref()
266            && let Some(provider) = discovered
267                .providers
268                .iter()
269                .find(|provider| provider.provider_id == *provider_id)
270        {
271            if provider.pack_path.exists() {
272                std::fs::remove_file(&provider.pack_path).with_context(|| {
273                    format!(
274                        "failed to remove provider pack {}",
275                        provider.pack_path.display()
276                    )
277                })?;
278            }
279            removed += 1;
280        } else {
281            let target_dir = get_pack_target_dir(bundle_path, provider_id);
282            let target_path = target_dir.join(format!("{provider_id}.gtpack"));
283            if target_path.exists() {
284                std::fs::remove_file(&target_path).with_context(|| {
285                    format!("failed to remove provider pack {}", target_path.display())
286                })?;
287                removed += 1;
288            }
289        }
290
291        let config_dir = bundle_path.join("state").join("config").join(provider_id);
292        if config_dir.exists() {
293            std::fs::remove_dir_all(&config_dir).with_context(|| {
294                format!(
295                    "failed to remove provider config dir {}",
296                    config_dir.display()
297                )
298            })?;
299        }
300    }
301    Ok(removed)
302}
303
304/// Search sibling bundles for provider packs referenced in setup_answers
305/// and install them into this bundle if missing.
306pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
307    let bundle_abs =
308        std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
309
310    for provider_id in metadata.setup_answers.keys() {
311        let target_dir = get_pack_target_dir(bundle_path, provider_id);
312        let target_path = target_dir.join(format!("{provider_id}.gtpack"));
313        if target_path.exists() {
314            continue;
315        }
316
317        // Determine the provider domain from the ID
318        let domain = domain_from_provider_id(provider_id);
319
320        // Search for the pack in sibling bundles and build output
321        if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
322            if let Err(err) = std::fs::create_dir_all(&target_dir) {
323                eprintln!(
324                    "  [provider] WARNING: failed to create {}: {err}",
325                    target_dir.display()
326                );
327                continue;
328            }
329            match std::fs::copy(&source, &target_path) {
330                Ok(_) => println!(
331                    "  [provider] installed {provider_id}.gtpack from {}",
332                    source.display()
333                ),
334                Err(err) => eprintln!(
335                    "  [provider] WARNING: failed to copy {}: {err}",
336                    source.display()
337                ),
338            }
339        } else {
340            eprintln!("  [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
341        }
342    }
343}
344
345/// Extract domain from a provider ID (e.g. "messaging-telegram" → "messaging").
346pub fn domain_from_provider_id(provider_id: &str) -> &str {
347    const DOMAIN_PREFIXES: &[&str] = &[
348        "messaging-",
349        "events-",
350        "oauth-",
351        "secrets-",
352        "mcp-",
353        "state-",
354        "telemetry-",
355    ];
356    for prefix in DOMAIN_PREFIXES {
357        if provider_id.starts_with(prefix) {
358            return prefix.trim_end_matches('-');
359        }
360    }
361    "messaging" // default
362}
363
364/// Search known locations for a provider pack file.
365///
366/// Search order:
367/// 1. Sibling bundle directories: `../<bundle>/providers/<domain>/<id>.gtpack`
368/// 2. Build output: `../greentic-messaging-providers/target/packs/<id>.gtpack`
369pub fn find_provider_pack_source(
370    provider_id: &str,
371    domain: &str,
372    bundle_abs: &Path,
373) -> Option<PathBuf> {
374    let parent = bundle_abs.parent()?;
375    let filename = format!("{provider_id}.gtpack");
376
377    // 1. Sibling bundles
378    if let Ok(entries) = std::fs::read_dir(parent) {
379        for entry in entries.flatten() {
380            let sibling = entry.path();
381            if sibling == *bundle_abs || !sibling.is_dir() {
382                continue;
383            }
384            let candidate = sibling.join("providers").join(domain).join(&filename);
385            if candidate.is_file() {
386                return Some(candidate);
387            }
388        }
389    }
390
391    // 2. Build output from greentic-messaging-providers
392    for ancestor in parent.ancestors().take(4) {
393        let candidate = ancestor
394            .join("greentic-messaging-providers")
395            .join("target")
396            .join("packs")
397            .join(&filename);
398        if candidate.is_file() {
399            return Some(candidate);
400        }
401    }
402
403    None
404}
405
406/// Execute the WriteGmapRules step.
407pub fn execute_write_gmap_rules(
408    bundle_path: &Path,
409    metadata: &SetupPlanMetadata,
410) -> anyhow::Result<()> {
411    for tenant_sel in &metadata.tenants {
412        let gmap_path =
413            bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
414
415        if let Some(parent) = gmap_path.parent() {
416            std::fs::create_dir_all(parent)?;
417        }
418
419        // Build gmap content from allow_paths
420        let mut content = String::new();
421        if tenant_sel.allow_paths.is_empty() {
422            content.push_str("_ = forbidden\n");
423        } else {
424            for path in &tenant_sel.allow_paths {
425                content.push_str(&format!("{} = allowed\n", path));
426            }
427            content.push_str("_ = forbidden\n");
428        }
429
430        std::fs::write(&gmap_path, content)
431            .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
432    }
433    Ok(())
434}
435
436/// Execute the CopyResolvedManifest step.
437pub fn execute_copy_resolved_manifests(
438    bundle_path: &Path,
439    metadata: &SetupPlanMetadata,
440) -> anyhow::Result<Vec<PathBuf>> {
441    let mut manifests = Vec::new();
442    let resolved_dir = bundle_path.join("resolved");
443    std::fs::create_dir_all(&resolved_dir)?;
444
445    for tenant_sel in &metadata.tenants {
446        let filename =
447            bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
448        let manifest_path = resolved_dir.join(&filename);
449
450        // Create an empty manifest placeholder if it doesn't exist
451        if !manifest_path.exists() {
452            std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
453        }
454        manifests.push(manifest_path);
455    }
456
457    Ok(manifests)
458}
459
460/// Execute the ValidateBundle step.
461pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
462    bundle::validate_bundle_exists(bundle_path)
463}
464
465/// Execute the BuildFlowIndex step.
466///
467/// Scans all flows in the bundle, builds a TF-IDF index and a routing-compatible
468/// index, and optionally generates intents.md documentation.
469/// Output is written to `bundle/state/indexes/`.
470///
471/// Requires the `fast2flow` feature AND the `fast2flow-bundle` crate wired as a
472/// dependency.  Until `fast2flow-bundle` is published or vendored, this is a
473/// no-op stub that logs a skip message.
474pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
475    tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
476    Ok(())
477}