Skip to content

Commit 3c6d52b

Browse files
Zorlinclaude
andcommitted
feat: chroot connection and pull-from-URL support
Add ChrootConnection — executes all commands via `chroot <path> sh -c`, writes files to <chroot_path>/<remote_path>. Enables provisioning OS images before first boot (like arch-chroot). Add --chroot <path> flag for pull mode: all playbook tasks execute inside the chroot environment. Add --url <url> flag for pull mode: download playbook from HTTPS URL or clone a git repository before running. Supports .yml direct download and git repo detection (github, gitlab, codeberg, .git suffix). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c4c1e8f commit 3c6d52b

4 files changed

Lines changed: 467 additions & 11 deletions

File tree

src/cli/parser.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ pub struct CliParser {
6363
pub argument_map: HashMap<String, Arguments>,
6464
pub play_groups: Option<Vec<String>>,
6565
pub async_mode: bool,
66+
pub pull_url: Option<String>,
67+
pub chroot_path: Option<String>,
6668
}
6769

6870
// subcommands are usually required
@@ -134,7 +136,9 @@ pub enum Arguments {
134136
ARGUMENT_MODULES,
135137
ARGUMENT_MODULES_SHORT,
136138
ARGUMENT_GROUPS,
137-
ARGUMENT_ASYNC
139+
ARGUMENT_ASYNC,
140+
ARGUMENT_URL,
141+
ARGUMENT_CHROOT,
138142
}
139143

140144
impl Arguments {
@@ -172,6 +176,8 @@ impl Arguments {
172176
Arguments::ARGUMENT_ASK_LOGIN_PASSWORD => "--ask-login-password",
173177
Arguments::ARGUMENT_GROUPS => "--groups",
174178
Arguments::ARGUMENT_ASYNC => "--async",
179+
Arguments::ARGUMENT_URL => "--url",
180+
Arguments::ARGUMENT_CHROOT => "--chroot",
175181
}
176182
}
177183
}
@@ -211,6 +217,8 @@ fn build_argument_map() -> HashMap<String, Arguments> {
211217
(Arguments::ARGUMENT_ASK_LOGIN_PASSWORD, "--ask-login-password"),
212218
(Arguments::ARGUMENT_GROUPS, "--groups"),
213219
(Arguments::ARGUMENT_ASYNC, "--async"),
220+
(Arguments::ARGUMENT_URL, "--url"),
221+
(Arguments::ARGUMENT_CHROOT, "--chroot"),
214222
];
215223
let mut map : HashMap<String, Arguments> = HashMap::new();
216224
for (e,i) in inputs.iter() {
@@ -376,6 +384,8 @@ impl CliParser {
376384
argument_map: build_argument_map(),
377385
play_groups: None,
378386
async_mode: false,
387+
pull_url: None,
388+
chroot_path: None,
379389
};
380390
return p;
381391
}
@@ -495,6 +505,8 @@ impl CliParser {
495505
Arguments::ARGUMENT_EXTRA_VARS => self.store_extra_vars(&args[arg_count]),
496506
Arguments::ARGUMENT_EXTRA_VARS_SHORT => self.store_extra_vars(&args[arg_count]),
497507
Arguments::ARGUMENT_GROUPS => self.store_groups(&args[arg_count]),
508+
Arguments::ARGUMENT_URL => self.store_url(&args[arg_count]),
509+
Arguments::ARGUMENT_CHROOT => self.store_chroot(&args[arg_count]),
498510
_ => Err(format!("invalid flag: {}", argument_str)),
499511
};
500512
}
@@ -832,6 +844,20 @@ impl CliParser {
832844
Ok(())
833845
}
834846

847+
fn store_url(&mut self, value: &String) -> Result<(), String> {
848+
self.pull_url = Some(value.clone());
849+
Ok(())
850+
}
851+
852+
fn store_chroot(&mut self, value: &String) -> Result<(), String> {
853+
let path = Path::new(value);
854+
if !path.is_dir() {
855+
return Err(format!("--chroot path does not exist: {}", value));
856+
}
857+
self.chroot_path = Some(value.clone());
858+
Ok(())
859+
}
860+
835861
}
836862

837863
#[cfg(test)]

src/cli/playbooks.rs

Lines changed: 144 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ use crate::cli::parser::CliParser;
1818

1919
use crate::connection::ssh::SshFactory;
2020
use crate::connection::local::LocalFactory;
21+
use crate::connection::chroot::ChrootFactory;
2122
use crate::connection::no::NoFactory;
2223
use crate::playbooks::traversal::{playbook_traversal,RunState};
2324
use crate::playbooks::context::PlaybookContext;
2425
use crate::playbooks::visitor::{PlaybookVisitor,CheckMode};
2526
use crate::inventory::inventory::Inventory;
2627
use std::sync::{Arc,RwLock};
28+
use std::path::PathBuf;
2729

2830
// code behind *most* playbook related CLI commands, launched from main.rs
2931

@@ -54,9 +56,25 @@ pub fn playbook_simulate(inventory: &Arc<RwLock<Inventory>>, parser: &CliParser)
5456
}
5557

5658
pub fn playbook_pull(inventory: &Arc<RwLock<Inventory>>, parser: &CliParser) -> i32 {
57-
// Pull mode is essentially local mode, but designed for external integration
58-
// It runs playbooks locally on the target host with optional inventory for variables
59-
return playbook_with_pull(inventory, parser, CheckMode::No);
59+
// Pull mode: runs playbooks locally (or inside a chroot).
60+
// With --url, downloads the playbook from an HTTPS URL or clones a git repo first.
61+
// With --chroot, executes all commands inside the chroot environment.
62+
63+
// Handle --url: download playbook to temp location
64+
let url_playbook_path: Option<PathBuf> = match &parser.pull_url {
65+
Some(url) => {
66+
match fetch_playbook_url(url) {
67+
Ok(path) => Some(path),
68+
Err(e) => {
69+
println!("failed to fetch playbook from URL: {}", e);
70+
return 1;
71+
}
72+
}
73+
}
74+
None => None,
75+
};
76+
77+
return playbook_with_pull(inventory, parser, CheckMode::No, url_playbook_path.as_ref());
6078
}
6179

6280
fn playbook(inventory: &Arc<RwLock<Inventory>>, parser: &CliParser, check_mode: CheckMode, connection_mode: ConnectionMode) -> i32 {
@@ -96,22 +114,38 @@ fn playbook(inventory: &Arc<RwLock<Inventory>>, parser: &CliParser, check_mode:
96114
};
97115
}
98116

99-
fn playbook_with_pull(inventory: &Arc<RwLock<Inventory>>, parser: &CliParser, check_mode: CheckMode) -> i32 {
117+
fn playbook_with_pull(inventory: &Arc<RwLock<Inventory>>, parser: &CliParser, check_mode: CheckMode, url_playbook: Option<&PathBuf>) -> i32 {
118+
// If a URL-fetched playbook exists, add it to playbook_paths
119+
let playbook_paths = if let Some(pb_path) = url_playbook {
120+
let paths = Arc::new(RwLock::new(vec![pb_path.clone()]));
121+
// Also include any explicitly specified playbooks
122+
for p in parser.playbook_paths.read().unwrap().iter() {
123+
paths.write().unwrap().push(p.clone());
124+
}
125+
paths
126+
} else {
127+
Arc::clone(&parser.playbook_paths)
128+
};
129+
130+
// Choose connection factory: ChrootFactory if --chroot, otherwise LocalFactory
131+
let connection_factory: Arc<RwLock<dyn crate::connection::factory::ConnectionFactory>> =
132+
if let Some(ref chroot_path) = parser.chroot_path {
133+
Arc::new(RwLock::new(ChrootFactory::new(inventory, chroot_path.clone())))
134+
} else {
135+
Arc::new(RwLock::new(LocalFactory::new(inventory)))
136+
};
137+
100138
let run_state = Arc::new(RunState {
101-
// every object gets an inventory, though with local modes it's empty.
102139
inventory: Arc::clone(inventory),
103-
playbook_paths: Arc::clone(&parser.playbook_paths),
140+
playbook_paths,
104141
role_paths: Arc::clone(&parser.role_paths),
105142
module_paths: Arc::clone(&parser.module_paths),
106143
limit_hosts: parser.limit_hosts.clone(),
107144
limit_groups: parser.limit_groups.clone(),
108145
batch_size: parser.batch_size.clone(),
109-
// the context is constructed with an instance of the parser instead of having a back-reference
110-
// to run-state. Context should mostly *not* get parameters from the parser unless they
111-
// are going to appear in variables.
112146
context: Arc::new(RwLock::new(PlaybookContext::new(parser))),
113147
visitor: Arc::new(RwLock::new(PlaybookVisitor::new(check_mode))),
114-
connection_factory: Arc::new(RwLock::new(LocalFactory::new(inventory))),
148+
connection_factory,
115149
tags: parser.tags.clone(),
116150
allow_localhost_delegation: parser.allow_localhost_delegation,
117151
is_pull_mode: true,
@@ -129,3 +163,103 @@ fn playbook_with_pull(inventory: &Arc<RwLock<Inventory>>, parser: &CliParser, ch
129163
};
130164
}
131165

166+
/// Fetch a playbook from a URL (HTTPS) or clone a git repository.
167+
///
168+
/// - HTTPS URL ending in .yml/.yaml → downloaded to a temp file
169+
/// - Git URL (ending in .git or containing github/gitlab) → shallow clone
170+
/// - Returns the path to the playbook file or directory
171+
fn fetch_playbook_url(url: &str) -> Result<PathBuf, String> {
172+
let temp_dir = std::env::temp_dir().join("jetpack-pull");
173+
let _ = std::fs::create_dir_all(&temp_dir);
174+
175+
if url.ends_with(".yml") || url.ends_with(".yaml") {
176+
// HTTPS direct download of a single playbook file
177+
let filename = url.rsplit('/').next().unwrap_or("playbook.yml");
178+
let dest = temp_dir.join(filename);
179+
180+
println!("pulling playbook from {}", url);
181+
182+
// Use curl (universally available) to download
183+
let output = std::process::Command::new("curl")
184+
.arg("-sfL")
185+
.arg("--connect-timeout")
186+
.arg("30")
187+
.arg("-o")
188+
.arg(dest.to_str().unwrap())
189+
.arg(url)
190+
.output()
191+
.map_err(|e| format!("curl failed: {}", e))?;
192+
193+
if !output.status.success() {
194+
let stderr = String::from_utf8_lossy(&output.stderr);
195+
return Err(format!("curl failed ({}): {}", output.status, stderr));
196+
}
197+
198+
if !dest.exists() {
199+
return Err(format!("download succeeded but file not found: {:?}", dest));
200+
}
201+
202+
Ok(dest)
203+
} else if url.ends_with(".git")
204+
|| url.contains("github.com")
205+
|| url.contains("gitlab.com")
206+
|| url.contains("codeberg.org")
207+
|| url.starts_with("git@")
208+
{
209+
// Git clone
210+
let repo_dir = temp_dir.join("repo");
211+
let _ = std::fs::remove_dir_all(&repo_dir); // clean previous
212+
213+
println!("cloning repository from {}", url);
214+
215+
let output = std::process::Command::new("git")
216+
.arg("clone")
217+
.arg("--depth")
218+
.arg("1")
219+
.arg(url)
220+
.arg(repo_dir.to_str().unwrap())
221+
.output()
222+
.map_err(|e| format!("git clone failed: {}", e))?;
223+
224+
if !output.status.success() {
225+
let stderr = String::from_utf8_lossy(&output.stderr);
226+
return Err(format!("git clone failed ({}): {}", output.status, stderr));
227+
}
228+
229+
// Look for playbook files in the cloned repo
230+
// Check common locations: playbook.yml, site.yml, main.yml, playbooks/
231+
for candidate in &["playbook.yml", "site.yml", "main.yml", "playbook.yaml", "site.yaml"] {
232+
let path = repo_dir.join(candidate);
233+
if path.exists() {
234+
return Ok(path);
235+
}
236+
}
237+
238+
// If no standard playbook found, return the repo dir
239+
// (user should specify --playbook to pick the right file)
240+
Ok(repo_dir)
241+
} else {
242+
// Assume HTTPS URL to a raw file
243+
let dest = temp_dir.join("playbook.yml");
244+
245+
println!("pulling playbook from {}", url);
246+
247+
let output = std::process::Command::new("curl")
248+
.arg("-sfL")
249+
.arg("--connect-timeout")
250+
.arg("30")
251+
.arg("-o")
252+
.arg(dest.to_str().unwrap())
253+
.arg(url)
254+
.output()
255+
.map_err(|e| format!("curl failed: {}", e))?;
256+
257+
if !output.status.success() {
258+
let stderr = String::from_utf8_lossy(&output.stderr);
259+
return Err(format!("curl failed ({}): {}", output.status, stderr));
260+
}
261+
262+
Ok(dest)
263+
}
264+
}
265+

0 commit comments

Comments
 (0)