Skip to main content

opencode_cloud_core/host/
provision.rs

1//! Remote host provisioning
2//!
3//! Functions to detect Linux distribution and install Docker on remote hosts.
4
5use std::process::{Command, Stdio};
6
7use super::error::HostError;
8use super::schema::HostConfig;
9
10/// Linux distribution family
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum DistroFamily {
13    /// Debian, Ubuntu, and derivatives (apt-based)
14    Debian,
15    /// RHEL, CentOS, Fedora, Amazon Linux (dnf/yum-based)
16    RedHat,
17    /// Alpine Linux (apk-based)
18    Alpine,
19    /// Arch Linux (pacman-based)
20    Arch,
21    /// SUSE/openSUSE (zypper-based)
22    Suse,
23    /// Unknown distribution
24    Unknown(String),
25}
26
27impl std::fmt::Display for DistroFamily {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            DistroFamily::Debian => write!(f, "Debian/Ubuntu"),
31            DistroFamily::RedHat => write!(f, "RHEL/Amazon Linux"),
32            DistroFamily::Alpine => write!(f, "Alpine"),
33            DistroFamily::Arch => write!(f, "Arch"),
34            DistroFamily::Suse => write!(f, "SUSE"),
35            DistroFamily::Unknown(id) => write!(f, "Unknown ({id})"),
36        }
37    }
38}
39
40/// Detected distribution information
41#[derive(Debug, Clone)]
42pub struct DistroInfo {
43    /// Distribution family (Debian, RedHat, etc.)
44    pub family: DistroFamily,
45    /// Distribution ID (e.g., "ubuntu", "amzn", "debian")
46    pub id: String,
47    /// Pretty name (e.g., "Ubuntu 22.04.3 LTS")
48    pub pretty_name: String,
49    /// Version ID (e.g., "22.04", "2023")
50    pub version_id: Option<String>,
51}
52
53/// Detect the Linux distribution on a remote host
54///
55/// Runs `cat /etc/os-release` via SSH to parse distribution info.
56pub fn detect_distro(host: &HostConfig) -> Result<DistroInfo, HostError> {
57    let output = run_ssh_command(host, "cat /etc/os-release")?;
58
59    parse_os_release(&output)
60}
61
62/// Parse /etc/os-release content into DistroInfo
63fn parse_os_release(content: &str) -> Result<DistroInfo, HostError> {
64    let mut id = String::new();
65    let mut id_like = String::new();
66    let mut pretty_name = String::new();
67    let mut version_id = None;
68
69    for line in content.lines() {
70        if let Some((key, value)) = line.split_once('=') {
71            let value = value.trim_matches('"');
72            match key {
73                "ID" => id = value.to_lowercase(),
74                "ID_LIKE" => id_like = value.to_lowercase(),
75                "PRETTY_NAME" => pretty_name = value.to_string(),
76                "VERSION_ID" => version_id = Some(value.to_string()),
77                _ => {}
78            }
79        }
80    }
81
82    if id.is_empty() {
83        return Err(HostError::ConnectionFailed(
84            "Could not detect Linux distribution".to_string(),
85        ));
86    }
87
88    // Determine distribution family
89    let family = match id.as_str() {
90        "ubuntu" | "debian" | "linuxmint" | "pop" | "elementary" | "raspbian" => {
91            DistroFamily::Debian
92        }
93        "amzn" | "rhel" | "centos" | "fedora" | "rocky" | "almalinux" | "ol" => {
94            DistroFamily::RedHat
95        }
96        "alpine" => DistroFamily::Alpine,
97        "arch" | "manjaro" | "endeavouros" => DistroFamily::Arch,
98        "opensuse" | "sles" | "opensuse-leap" | "opensuse-tumbleweed" => DistroFamily::Suse,
99        _ => {
100            // Check ID_LIKE for derivatives
101            if id_like.contains("debian") || id_like.contains("ubuntu") {
102                DistroFamily::Debian
103            } else if id_like.contains("rhel")
104                || id_like.contains("fedora")
105                || id_like.contains("centos")
106            {
107                DistroFamily::RedHat
108            } else if id_like.contains("arch") {
109                DistroFamily::Arch
110            } else if id_like.contains("suse") {
111                DistroFamily::Suse
112            } else {
113                DistroFamily::Unknown(id.clone())
114            }
115        }
116    };
117
118    Ok(DistroInfo {
119        family,
120        id,
121        pretty_name,
122        version_id,
123    })
124}
125
126/// Install Docker on a remote host
127///
128/// Returns a vector of commands that will be executed (for user review).
129pub fn get_docker_install_commands(distro: &DistroInfo) -> Result<Vec<&'static str>, HostError> {
130    match &distro.family {
131        DistroFamily::Debian => Ok(vec![
132            // Update package index
133            "sudo apt-get update",
134            // Install prerequisites
135            "sudo apt-get install -y ca-certificates curl gnupg",
136            // Add Docker's official GPG key
137            "sudo install -m 0755 -d /etc/apt/keyrings",
138            "curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\")/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg",
139            "sudo chmod a+r /etc/apt/keyrings/docker.gpg",
140            // Set up the repository
141            "echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\") $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable\" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null",
142            // Install Docker
143            "sudo apt-get update",
144            "sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
145            // Start Docker
146            "sudo systemctl enable docker",
147            "sudo systemctl start docker",
148            // Add current user to docker group
149            "sudo usermod -aG docker $USER",
150        ]),
151
152        DistroFamily::RedHat => {
153            // Amazon Linux 2023 uses dnf, Amazon Linux 2 uses yum
154            // We'll use a command that works for both
155            Ok(vec![
156                // Install Docker (Amazon Linux uses amazon-linux-extras or dnf)
157                "sudo yum install -y docker || sudo dnf install -y docker",
158                // Start Docker
159                "sudo systemctl enable docker",
160                "sudo systemctl start docker",
161                // Add current user to docker group
162                "sudo usermod -aG docker $USER",
163            ])
164        }
165
166        DistroFamily::Alpine => Ok(vec![
167            "sudo apk add docker docker-cli-compose",
168            "sudo rc-update add docker boot",
169            "sudo service docker start",
170            "sudo addgroup $USER docker",
171        ]),
172
173        DistroFamily::Arch => Ok(vec![
174            "sudo pacman -Sy --noconfirm docker docker-compose",
175            "sudo systemctl enable docker",
176            "sudo systemctl start docker",
177            "sudo usermod -aG docker $USER",
178        ]),
179
180        DistroFamily::Suse => Ok(vec![
181            "sudo zypper install -y docker docker-compose",
182            "sudo systemctl enable docker",
183            "sudo systemctl start docker",
184            "sudo usermod -aG docker $USER",
185        ]),
186
187        DistroFamily::Unknown(id) => Err(HostError::ConnectionFailed(format!(
188            "Unsupported Linux distribution: {id}. Please install Docker manually."
189        ))),
190    }
191}
192
193/// Execute Docker installation on remote host
194///
195/// Runs the installation commands via SSH and captures output.
196pub fn install_docker(
197    host: &HostConfig,
198    distro: &DistroInfo,
199    on_output: impl Fn(&str),
200) -> Result<(), HostError> {
201    let commands = get_docker_install_commands(distro)?;
202
203    // Combine all commands with && to fail fast
204    let combined = commands.join(" && ");
205
206    on_output(&format!("Installing Docker on {} host...", distro.family));
207
208    // Run the installation
209    run_ssh_command_with_output(host, &combined, on_output)?;
210
211    Ok(())
212}
213
214/// Run a command on remote host via SSH and return output
215fn run_ssh_command(host: &HostConfig, command: &str) -> Result<String, HostError> {
216    let mut cmd = build_ssh_command(host);
217    cmd.arg(command);
218
219    cmd.stdin(Stdio::null())
220        .stdout(Stdio::piped())
221        .stderr(Stdio::piped());
222
223    let output = cmd.output().map_err(|e| {
224        if e.kind() == std::io::ErrorKind::NotFound {
225            HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
226        } else {
227            HostError::SshSpawn(e.to_string())
228        }
229    })?;
230
231    if output.status.success() {
232        Ok(String::from_utf8_lossy(&output.stdout).to_string())
233    } else {
234        let stderr = String::from_utf8_lossy(&output.stderr);
235        Err(HostError::ConnectionFailed(stderr.to_string()))
236    }
237}
238
239/// Run a command on remote host via SSH with streaming output
240fn run_ssh_command_with_output(
241    host: &HostConfig,
242    command: &str,
243    on_output: impl Fn(&str),
244) -> Result<(), HostError> {
245    use std::io::{BufRead, BufReader};
246
247    let mut cmd = build_ssh_command(host);
248
249    // Request a pseudo-terminal for interactive commands (like sudo)
250    cmd.arg("-t").arg("-t");
251    cmd.arg(command);
252
253    cmd.stdin(Stdio::null())
254        .stdout(Stdio::piped())
255        .stderr(Stdio::piped());
256
257    let mut child = cmd.spawn().map_err(|e| {
258        if e.kind() == std::io::ErrorKind::NotFound {
259            HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
260        } else {
261            HostError::SshSpawn(e.to_string())
262        }
263    })?;
264
265    // Stream stdout
266    if let Some(stdout) = child.stdout.take() {
267        let reader = BufReader::new(stdout);
268        for line in reader.lines().map_while(Result::ok) {
269            on_output(&line);
270        }
271    }
272
273    let status = child
274        .wait()
275        .map_err(|e| HostError::SshSpawn(e.to_string()))?;
276
277    if status.success() {
278        Ok(())
279    } else {
280        Err(HostError::ConnectionFailed(
281            "Docker installation failed".to_string(),
282        ))
283    }
284}
285
286/// Build base SSH command with host config
287fn build_ssh_command(host: &HostConfig) -> Command {
288    let mut cmd = Command::new("ssh");
289
290    // Standard options
291    cmd.arg("-o")
292        .arg("BatchMode=yes")
293        .arg("-o")
294        .arg("ConnectTimeout=30")
295        .arg("-o")
296        .arg("StrictHostKeyChecking=accept-new");
297
298    // Host-specific options (port, identity, jump, user@host)
299    cmd.args(host.ssh_args());
300
301    cmd
302}
303
304/// Verify Docker is working after installation
305///
306/// Note: Due to group membership changes, this may fail until the user
307/// reconnects. We run with sudo as a fallback.
308pub fn verify_docker_installed(host: &HostConfig) -> Result<String, HostError> {
309    // Try without sudo first (if group membership is active)
310    let output = run_ssh_command(
311        host,
312        "docker version --format '{{.Server.Version}}' 2>/dev/null || sudo docker version --format '{{.Server.Version}}'",
313    );
314
315    match output {
316        Ok(version) => Ok(version.trim().to_string()),
317        Err(_) => Err(HostError::RemoteDockerUnavailable(
318            "Docker installed but not accessible. You may need to reconnect for group membership to take effect.".to_string(),
319        )),
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_parse_os_release_ubuntu() {
329        let content = r#"
330PRETTY_NAME="Ubuntu 22.04.3 LTS"
331NAME="Ubuntu"
332VERSION_ID="22.04"
333VERSION="22.04.3 LTS (Jammy Jellyfish)"
334VERSION_CODENAME=jammy
335ID=ubuntu
336ID_LIKE=debian
337"#;
338        let info = parse_os_release(content).unwrap();
339        assert_eq!(info.family, DistroFamily::Debian);
340        assert_eq!(info.id, "ubuntu");
341        assert_eq!(info.version_id, Some("22.04".to_string()));
342    }
343
344    #[test]
345    fn test_parse_os_release_amazon_linux() {
346        let content = r#"
347NAME="Amazon Linux"
348VERSION="2023"
349ID="amzn"
350ID_LIKE="fedora"
351VERSION_ID="2023"
352PRETTY_NAME="Amazon Linux 2023"
353"#;
354        let info = parse_os_release(content).unwrap();
355        assert_eq!(info.family, DistroFamily::RedHat);
356        assert_eq!(info.id, "amzn");
357    }
358
359    #[test]
360    fn test_parse_os_release_debian() {
361        let content = r#"
362PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
363NAME="Debian GNU/Linux"
364VERSION_ID="12"
365VERSION="12 (bookworm)"
366ID=debian
367"#;
368        let info = parse_os_release(content).unwrap();
369        assert_eq!(info.family, DistroFamily::Debian);
370        assert_eq!(info.id, "debian");
371    }
372
373    #[test]
374    fn test_get_docker_install_commands() {
375        let debian_info = DistroInfo {
376            family: DistroFamily::Debian,
377            id: "ubuntu".to_string(),
378            pretty_name: "Ubuntu 22.04".to_string(),
379            version_id: Some("22.04".to_string()),
380        };
381        let commands = get_docker_install_commands(&debian_info).unwrap();
382        assert!(!commands.is_empty());
383        assert!(commands.iter().any(|c| c.contains("docker")));
384
385        let redhat_info = DistroInfo {
386            family: DistroFamily::RedHat,
387            id: "amzn".to_string(),
388            pretty_name: "Amazon Linux 2023".to_string(),
389            version_id: Some("2023".to_string()),
390        };
391        let commands = get_docker_install_commands(&redhat_info).unwrap();
392        assert!(!commands.is_empty());
393    }
394}