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