@opencode-cloud/core 1.0.8 → 3.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.toml +9 -2
- package/README.md +56 -30
- package/package.json +1 -1
- package/src/config/mod.rs +8 -3
- package/src/config/paths.rs +14 -0
- package/src/config/schema.rs +561 -0
- package/src/config/validation.rs +271 -0
- package/src/docker/Dockerfile +353 -207
- package/src/docker/README.dockerhub.md +39 -0
- package/src/docker/client.rs +132 -3
- package/src/docker/container.rs +204 -36
- package/src/docker/dockerfile.rs +15 -12
- package/src/docker/exec.rs +278 -0
- package/src/docker/health.rs +165 -0
- package/src/docker/image.rs +2 -4
- package/src/docker/mod.rs +72 -8
- package/src/docker/mount.rs +330 -0
- package/src/docker/progress.rs +4 -4
- package/src/docker/state.rs +120 -0
- package/src/docker/update.rs +156 -0
- package/src/docker/users.rs +357 -0
- package/src/docker/version.rs +95 -0
- package/src/host/error.rs +61 -0
- package/src/host/mod.rs +29 -0
- package/src/host/provision.rs +394 -0
- package/src/host/schema.rs +308 -0
- package/src/host/ssh_config.rs +282 -0
- package/src/host/storage.rs +118 -0
- package/src/host/tunnel.rs +268 -0
- package/src/lib.rs +10 -1
- package/src/platform/launchd.rs +1 -1
- package/src/platform/systemd.rs +6 -6
- package/src/singleton/mod.rs +1 -1
- package/src/version.rs +1 -6
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
//! Remote host provisioning
|
|
2
|
+
//!
|
|
3
|
+
//! Functions to detect Linux distribution and install Docker on remote hosts.
|
|
4
|
+
|
|
5
|
+
use std::process::{Command, Stdio};
|
|
6
|
+
|
|
7
|
+
use super::error::HostError;
|
|
8
|
+
use super::schema::HostConfig;
|
|
9
|
+
|
|
10
|
+
/// Linux distribution family
|
|
11
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
12
|
+
pub 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
|
+
|
|
27
|
+
impl 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)]
|
|
42
|
+
pub 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.
|
|
56
|
+
pub 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
|
|
63
|
+
fn 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).
|
|
129
|
+
pub 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.
|
|
196
|
+
pub 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
|
|
215
|
+
fn 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
|
|
240
|
+
fn 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
|
|
287
|
+
fn 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.
|
|
308
|
+
pub 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)]
|
|
324
|
+
mod tests {
|
|
325
|
+
use super::*;
|
|
326
|
+
|
|
327
|
+
#[test]
|
|
328
|
+
fn test_parse_os_release_ubuntu() {
|
|
329
|
+
let content = r#"
|
|
330
|
+
PRETTY_NAME="Ubuntu 22.04.3 LTS"
|
|
331
|
+
NAME="Ubuntu"
|
|
332
|
+
VERSION_ID="22.04"
|
|
333
|
+
VERSION="22.04.3 LTS (Jammy Jellyfish)"
|
|
334
|
+
VERSION_CODENAME=jammy
|
|
335
|
+
ID=ubuntu
|
|
336
|
+
ID_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#"
|
|
347
|
+
NAME="Amazon Linux"
|
|
348
|
+
VERSION="2023"
|
|
349
|
+
ID="amzn"
|
|
350
|
+
ID_LIKE="fedora"
|
|
351
|
+
VERSION_ID="2023"
|
|
352
|
+
PRETTY_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#"
|
|
362
|
+
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
|
363
|
+
NAME="Debian GNU/Linux"
|
|
364
|
+
VERSION_ID="12"
|
|
365
|
+
VERSION="12 (bookworm)"
|
|
366
|
+
ID=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
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
//! Host configuration schema
|
|
2
|
+
//!
|
|
3
|
+
//! Data structures for storing remote host configurations.
|
|
4
|
+
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
use std::collections::HashMap;
|
|
7
|
+
|
|
8
|
+
/// Configuration for a remote host
|
|
9
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
10
|
+
#[serde(deny_unknown_fields)]
|
|
11
|
+
pub struct HostConfig {
|
|
12
|
+
/// SSH hostname or IP address
|
|
13
|
+
pub hostname: String,
|
|
14
|
+
|
|
15
|
+
/// SSH username (default: current user from whoami)
|
|
16
|
+
#[serde(default = "default_user")]
|
|
17
|
+
pub user: String,
|
|
18
|
+
|
|
19
|
+
/// SSH port (default: 22)
|
|
20
|
+
#[serde(default)]
|
|
21
|
+
pub port: Option<u16>,
|
|
22
|
+
|
|
23
|
+
/// Path to SSH identity file (private key)
|
|
24
|
+
#[serde(default)]
|
|
25
|
+
pub identity_file: Option<String>,
|
|
26
|
+
|
|
27
|
+
/// Jump host for ProxyJump (user@host:port format)
|
|
28
|
+
#[serde(default)]
|
|
29
|
+
pub jump_host: Option<String>,
|
|
30
|
+
|
|
31
|
+
/// Organization groups/tags for this host
|
|
32
|
+
#[serde(default)]
|
|
33
|
+
pub groups: Vec<String>,
|
|
34
|
+
|
|
35
|
+
/// Optional description
|
|
36
|
+
#[serde(default)]
|
|
37
|
+
pub description: Option<String>,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fn default_user() -> String {
|
|
41
|
+
whoami::username()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
impl Default for HostConfig {
|
|
45
|
+
fn default() -> Self {
|
|
46
|
+
Self {
|
|
47
|
+
hostname: String::new(),
|
|
48
|
+
user: default_user(),
|
|
49
|
+
port: None,
|
|
50
|
+
identity_file: None,
|
|
51
|
+
jump_host: None,
|
|
52
|
+
groups: Vec::new(),
|
|
53
|
+
description: None,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl HostConfig {
|
|
59
|
+
/// Create a new host config with just hostname
|
|
60
|
+
pub fn new(hostname: impl Into<String>) -> Self {
|
|
61
|
+
Self {
|
|
62
|
+
hostname: hostname.into(),
|
|
63
|
+
..Default::default()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Builder pattern: set user
|
|
68
|
+
pub fn with_user(mut self, user: impl Into<String>) -> Self {
|
|
69
|
+
self.user = user.into();
|
|
70
|
+
self
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Builder pattern: set port
|
|
74
|
+
pub fn with_port(mut self, port: u16) -> Self {
|
|
75
|
+
self.port = Some(port);
|
|
76
|
+
self
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Builder pattern: set identity file
|
|
80
|
+
pub fn with_identity_file(mut self, path: impl Into<String>) -> Self {
|
|
81
|
+
self.identity_file = Some(path.into());
|
|
82
|
+
self
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Builder pattern: set jump host
|
|
86
|
+
pub fn with_jump_host(mut self, jump: impl Into<String>) -> Self {
|
|
87
|
+
self.jump_host = Some(jump.into());
|
|
88
|
+
self
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Builder pattern: add group
|
|
92
|
+
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
|
93
|
+
self.groups.push(group.into());
|
|
94
|
+
self
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Builder pattern: set description
|
|
98
|
+
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
|
99
|
+
self.description = Some(desc.into());
|
|
100
|
+
self
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Get SSH command arguments for this host
|
|
104
|
+
///
|
|
105
|
+
/// Returns arguments for port, identity file, jump host, and target (user@hostname).
|
|
106
|
+
/// Does NOT include standard options like BatchMode or ConnectTimeout.
|
|
107
|
+
pub fn ssh_args(&self) -> Vec<String> {
|
|
108
|
+
let mut args = Vec::new();
|
|
109
|
+
|
|
110
|
+
// Port (if specified)
|
|
111
|
+
if let Some(port) = self.port {
|
|
112
|
+
args.push("-p".to_string());
|
|
113
|
+
args.push(port.to_string());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Identity file
|
|
117
|
+
if let Some(key) = &self.identity_file {
|
|
118
|
+
args.push("-i".to_string());
|
|
119
|
+
args.push(key.clone());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Jump host
|
|
123
|
+
if let Some(jump) = &self.jump_host {
|
|
124
|
+
args.push("-J".to_string());
|
|
125
|
+
args.push(jump.clone());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Target: user@hostname
|
|
129
|
+
args.push(format!("{}@{}", self.user, self.hostname));
|
|
130
|
+
|
|
131
|
+
args
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Format the effective SSH command for display
|
|
135
|
+
///
|
|
136
|
+
/// Returns a human-readable SSH command string showing how the connection
|
|
137
|
+
/// will be made, useful for debugging and user feedback.
|
|
138
|
+
pub fn format_ssh_command(&self) -> String {
|
|
139
|
+
let mut parts = vec!["ssh".to_string()];
|
|
140
|
+
|
|
141
|
+
// Port (if non-default)
|
|
142
|
+
if let Some(port) = self.port {
|
|
143
|
+
if port != 22 {
|
|
144
|
+
parts.push(format!("-p {port}"));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Identity file
|
|
149
|
+
if let Some(key) = &self.identity_file {
|
|
150
|
+
parts.push(format!("-i {key}"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Jump host
|
|
154
|
+
if let Some(jump) = &self.jump_host {
|
|
155
|
+
parts.push(format!("-J {jump}"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Target: user@hostname
|
|
159
|
+
parts.push(format!("{}@{}", self.user, self.hostname));
|
|
160
|
+
|
|
161
|
+
parts.join(" ")
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Root structure for hosts.json file
|
|
166
|
+
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
167
|
+
#[serde(deny_unknown_fields)]
|
|
168
|
+
pub struct HostsFile {
|
|
169
|
+
/// Schema version for future migrations
|
|
170
|
+
#[serde(default = "default_version")]
|
|
171
|
+
pub version: u32,
|
|
172
|
+
|
|
173
|
+
/// Default host name (None = local Docker)
|
|
174
|
+
#[serde(default)]
|
|
175
|
+
pub default_host: Option<String>,
|
|
176
|
+
|
|
177
|
+
/// Map of host name to configuration
|
|
178
|
+
#[serde(default)]
|
|
179
|
+
pub hosts: HashMap<String, HostConfig>,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn default_version() -> u32 {
|
|
183
|
+
1
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
impl HostsFile {
|
|
187
|
+
/// Create empty hosts file
|
|
188
|
+
pub fn new() -> Self {
|
|
189
|
+
Self::default()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Add a host
|
|
193
|
+
pub fn add_host(&mut self, name: impl Into<String>, config: HostConfig) {
|
|
194
|
+
self.hosts.insert(name.into(), config);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Remove a host
|
|
198
|
+
pub fn remove_host(&mut self, name: &str) -> Option<HostConfig> {
|
|
199
|
+
// Clear default if removing the default host
|
|
200
|
+
if self.default_host.as_deref() == Some(name) {
|
|
201
|
+
self.default_host = None;
|
|
202
|
+
}
|
|
203
|
+
self.hosts.remove(name)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// Get a host by name
|
|
207
|
+
pub fn get_host(&self, name: &str) -> Option<&HostConfig> {
|
|
208
|
+
self.hosts.get(name)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Get mutable reference to a host
|
|
212
|
+
pub fn get_host_mut(&mut self, name: &str) -> Option<&mut HostConfig> {
|
|
213
|
+
self.hosts.get_mut(name)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Check if host exists
|
|
217
|
+
pub fn has_host(&self, name: &str) -> bool {
|
|
218
|
+
self.hosts.contains_key(name)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// Set the default host
|
|
222
|
+
pub fn set_default(&mut self, name: Option<String>) {
|
|
223
|
+
self.default_host = name;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Get list of host names
|
|
227
|
+
pub fn host_names(&self) -> Vec<&str> {
|
|
228
|
+
self.hosts.keys().map(|s| s.as_str()).collect()
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#[cfg(test)]
|
|
233
|
+
mod tests {
|
|
234
|
+
use super::*;
|
|
235
|
+
|
|
236
|
+
#[test]
|
|
237
|
+
fn test_host_config_defaults() {
|
|
238
|
+
let config = HostConfig::default();
|
|
239
|
+
assert!(config.hostname.is_empty());
|
|
240
|
+
assert!(!config.user.is_empty()); // Should be current user
|
|
241
|
+
assert!(config.port.is_none());
|
|
242
|
+
assert!(config.identity_file.is_none());
|
|
243
|
+
assert!(config.jump_host.is_none());
|
|
244
|
+
assert!(config.groups.is_empty());
|
|
245
|
+
assert!(config.description.is_none());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#[test]
|
|
249
|
+
fn test_host_config_builder() {
|
|
250
|
+
let config = HostConfig::new("example.com")
|
|
251
|
+
.with_user("admin")
|
|
252
|
+
.with_port(2222)
|
|
253
|
+
.with_identity_file("~/.ssh/prod_key")
|
|
254
|
+
.with_group("production");
|
|
255
|
+
|
|
256
|
+
assert_eq!(config.hostname, "example.com");
|
|
257
|
+
assert_eq!(config.user, "admin");
|
|
258
|
+
assert_eq!(config.port, Some(2222));
|
|
259
|
+
assert_eq!(config.identity_file, Some("~/.ssh/prod_key".to_string()));
|
|
260
|
+
assert_eq!(config.groups, vec!["production"]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[test]
|
|
264
|
+
fn test_hosts_file_operations() {
|
|
265
|
+
let mut hosts = HostsFile::new();
|
|
266
|
+
assert!(hosts.hosts.is_empty());
|
|
267
|
+
|
|
268
|
+
// Add host
|
|
269
|
+
hosts.add_host("prod-1", HostConfig::new("prod1.example.com"));
|
|
270
|
+
assert!(hosts.has_host("prod-1"));
|
|
271
|
+
assert!(!hosts.has_host("prod-2"));
|
|
272
|
+
|
|
273
|
+
// Set default
|
|
274
|
+
hosts.set_default(Some("prod-1".to_string()));
|
|
275
|
+
assert_eq!(hosts.default_host, Some("prod-1".to_string()));
|
|
276
|
+
|
|
277
|
+
// Remove host clears default
|
|
278
|
+
hosts.remove_host("prod-1");
|
|
279
|
+
assert!(!hosts.has_host("prod-1"));
|
|
280
|
+
assert!(hosts.default_host.is_none());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#[test]
|
|
284
|
+
fn test_serialize_deserialize() {
|
|
285
|
+
let mut hosts = HostsFile::new();
|
|
286
|
+
hosts.add_host(
|
|
287
|
+
"test",
|
|
288
|
+
HostConfig::new("test.example.com")
|
|
289
|
+
.with_user("testuser")
|
|
290
|
+
.with_port(22),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
let json = serde_json::to_string_pretty(&hosts).unwrap();
|
|
294
|
+
let parsed: HostsFile = serde_json::from_str(&json).unwrap();
|
|
295
|
+
|
|
296
|
+
assert_eq!(hosts, parsed);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn test_deserialize_minimal() {
|
|
301
|
+
// Minimal JSON should work with defaults
|
|
302
|
+
let json = r#"{"version": 1}"#;
|
|
303
|
+
let hosts: HostsFile = serde_json::from_str(json).unwrap();
|
|
304
|
+
assert_eq!(hosts.version, 1);
|
|
305
|
+
assert!(hosts.hosts.is_empty());
|
|
306
|
+
assert!(hosts.default_host.is_none());
|
|
307
|
+
}
|
|
308
|
+
}
|