opencode_cloud_core/host/
provision.rs1use std::process::{Command, Stdio};
6
7use super::error::HostError;
8use super::schema::HostConfig;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum DistroFamily {
13 Debian,
15 RedHat,
17 Alpine,
19 Arch,
21 Suse,
23 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#[derive(Debug, Clone)]
42pub struct DistroInfo {
43 pub family: DistroFamily,
45 pub id: String,
47 pub pretty_name: String,
49 pub version_id: Option<String>,
51}
52
53pub 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
62fn 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 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 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
126pub fn get_docker_install_commands(distro: &DistroInfo) -> Result<Vec<&'static str>, HostError> {
130 match &distro.family {
131 DistroFamily::Debian => Ok(vec![
132 "sudo apt-get update",
134 "sudo apt-get install -y ca-certificates curl gnupg",
136 "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 "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 "sudo apt-get update",
144 "sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
145 "sudo systemctl enable docker",
147 "sudo systemctl start docker",
148 "sudo usermod -aG docker $USER",
150 ]),
151
152 DistroFamily::RedHat => {
153 Ok(vec![
156 "sudo yum install -y docker || sudo dnf install -y docker",
158 "sudo systemctl enable docker",
160 "sudo systemctl start docker",
161 "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
193pub 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 let combined = commands.join(" && ");
205
206 on_output(&format!("Installing Docker on {} host...", distro.family));
207
208 run_ssh_command_with_output(host, &combined, on_output)?;
210
211 Ok(())
212}
213
214fn 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
239fn 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 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 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
286fn build_ssh_command(host: &HostConfig) -> Command {
288 let mut cmd = Command::new("ssh");
289
290 cmd.arg("-o")
292 .arg("BatchMode=yes")
293 .arg("-o")
294 .arg("ConnectTimeout=30")
295 .arg("-o")
296 .arg("StrictHostKeyChecking=accept-new");
297
298 cmd.args(host.ssh_args());
300
301 cmd
302}
303
304pub fn verify_docker_installed(host: &HostConfig) -> Result<String, HostError> {
309 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}