greentic-setup-dev 1.1.27570423049

End-to-end bundle setup engine for the Greentic platform — pack discovery, QA-driven configuration, secrets persistence, and bundle lifecycle management
Documentation
use std::process::{Child, Command, Stdio};
use std::time::Duration;

use anyhow::{Context, Result, anyhow};
use serde_json::{Map as JsonMap, Value};

pub struct SetupTunnel {
    pub mode: String,
    pub public_base_url: String,
    child: Child,
}

impl Drop for SetupTunnel {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
    matches!(mode, "cloudflared" | "ngrok")
        && answers.values().any(|provider_answers| {
            let Some(obj) = provider_answers.as_object() else {
                return false;
            };
            crate::provider_state::provider_enabled_from_map(obj)
                && !obj
                    .get("public_base_url")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .is_some_and(|value| value.starts_with("https://"))
        })
}

pub fn start_setup_tunnel(mode: &str, local_base_url: &str) -> Result<SetupTunnel> {
    let mut command = match mode {
        "cloudflared" => {
            let mut command = Command::new("cloudflared");
            command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
            command
        }
        "ngrok" => {
            let mut command = Command::new("ngrok");
            command.args(["http", local_base_url, "--log=stdout"]);
            command
        }
        other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
    };
    command.stdout(Stdio::piped()).stderr(Stdio::piped());
    let mut child = command
        .spawn()
        .with_context(|| format!("start {mode} for setup OAuth callbacks"))?;

    let (tx, rx) = std::sync::mpsc::channel::<String>();
    if let Some(stdout) = child.stdout.take() {
        spawn_tunnel_log_reader(stdout, tx.clone());
    }
    if let Some(stderr) = child.stderr.take() {
        spawn_tunnel_log_reader(stderr, tx.clone());
    }
    drop(tx);

    let deadline = std::time::Instant::now() + Duration::from_secs(25);
    while std::time::Instant::now() < deadline {
        if let Some(status) = child.try_wait()? {
            return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
        }
        match rx.recv_timeout(Duration::from_millis(250)) {
            Ok(line) => {
                if let Some(url) = extract_tunnel_https_url(mode, &line) {
                    eprintln!("Setup tunnel started via {mode}: {url}");
                    return Ok(SetupTunnel {
                        mode: mode.to_string(),
                        public_base_url: url,
                        child,
                    });
                }
            }
            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
        }
    }

    let _ = child.kill();
    let _ = child.wait();
    Err(anyhow!(
        "{mode} did not publish an https:// URL within 25 seconds"
    ))
}

fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
where
    R: std::io::Read + Send + 'static,
{
    std::thread::spawn(move || {
        use std::io::BufRead;
        let reader = std::io::BufReader::new(stream);
        for line in reader.lines().map_while(std::result::Result::ok) {
            let _ = tx.send(line);
        }
    });
}

pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
    extract_https_urls(line)
        .into_iter()
        .find(|url| tunnel_url_matches_mode(mode, url))
}

fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
    let Ok(parsed) = url::Url::parse(url) else {
        return false;
    };
    if parsed.scheme() != "https" {
        return false;
    }
    let Some(host) = parsed.host_str() else {
        return false;
    };
    match mode {
        "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
        "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
        _ => false,
    }
}

fn extract_https_urls(line: &str) -> Vec<String> {
    let mut urls = Vec::new();
    let mut offset = 0;
    while let Some(start) = line[offset..].find("https://") {
        let absolute_start = offset + start;
        let tail = &line[absolute_start..];
        let end = tail
            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
            .unwrap_or(tail.len());
        urls.push(tail[..end].trim_end_matches('/').to_string());
        offset = absolute_start + end;
    }
    urls
}

pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
    for provider_answers in answers.values_mut() {
        let Some(obj) = provider_answers.as_object_mut() else {
            continue;
        };
        if !crate::provider_state::provider_enabled_from_map(obj) {
            continue;
        }
        if obj
            .get("public_base_url")
            .and_then(Value::as_str)
            .map(str::trim)
            .is_some_and(|value| value.starts_with("https://"))
        {
            continue;
        }
        obj.insert(
            "public_base_url".to_string(),
            Value::String(public_base_url.to_string()),
        );
    }
}

#[cfg(test)]
mod tests {
    use serde_json::{Map as JsonMap, Value, json};

    use super::{
        extract_tunnel_https_url, inject_setup_public_base_url, should_start_setup_tunnel,
    };

    #[test]
    fn setup_tunnel_helpers_detect_public_url_need() {
        let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
            "messaging-slack": {}
        }))
        .expect("answers");
        assert!(should_start_setup_tunnel("cloudflared", &empty_answers));

        let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
            "messaging-slack": {
                "public_base_url": "https://operator.example.com"
            }
        }))
        .expect("answers");
        assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
        assert!(!should_start_setup_tunnel("off", &empty_answers));
        let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
            "messaging-slack": {
                "enabled": false
            }
        }))
        .expect("answers");
        assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));

        assert_eq!(
            extract_tunnel_https_url(
                "cloudflared",
                "INF tunnel running at https://demo.trycloudflare.com"
            ),
            Some("https://demo.trycloudflare.com".to_string())
        );
        assert_eq!(
            extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
            Some("https://demo.ngrok-free.app".to_string())
        );
        assert_eq!(
            extract_tunnel_https_url(
                "cloudflared",
                "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
            ),
            Some("https://demo.trycloudflare.com".to_string())
        );
        assert_eq!(
            extract_tunnel_https_url(
                "cloudflared",
                "Terms: https://www.cloudflare.com/website-terms"
            ),
            None
        );
        assert_eq!(
            extract_tunnel_https_url(
                "ngrok",
                "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
            ),
            Some("https://demo.ngrok-free.app".to_string())
        );
    }

    #[test]
    fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
            "messaging-slack": {
                "public_base_url": "http://127.0.0.1:35519",
                "slack_configuration_access_token": "x"
            },
            "messaging-teams": {
                "public_base_url": "https://stable.example.com"
            },
            "messaging-disabled": {
                "enabled": false
            },
            "messaging-webhook": {}
        }))
        .expect("answers");

        inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");

        assert_eq!(
            answers["messaging-slack"]["public_base_url"],
            json!("https://setup.trycloudflare.com")
        );
        assert_eq!(
            answers["messaging-webhook"]["public_base_url"],
            json!("https://setup.trycloudflare.com")
        );
        assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
        assert_eq!(
            answers["messaging-teams"]["public_base_url"],
            json!("https://stable.example.com")
        );
    }
}