mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
is_persistent_control_path_dir_name is only referenced from prepare_socket_root, which is #[cfg(unix)]. On Windows nothing used it, so -D dead-code failed the build. Gate the function and its unit test to unix, matching the reaping code they support. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2974 lines
102 KiB
Rust
2974 lines
102 KiB
Rust
#[cfg(unix)]
|
|
use std::{collections::HashMap, os::unix::fs::PermissionsExt, path::PathBuf, process::Stdio};
|
|
|
|
#[cfg(windows)]
|
|
use std::{collections::HashMap, path::PathBuf, process::Stdio};
|
|
|
|
use anyhow::anyhow;
|
|
use futures::future::try_join_all;
|
|
use itertools::Itertools;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::value::RawValue;
|
|
use tokio::process::Command;
|
|
use uuid::Uuid;
|
|
use windmill_common::{
|
|
error,
|
|
git_sync_oss::{prepend_token_to_github_url, sanitize_git_url},
|
|
worker::{
|
|
is_allowed_file_location, split_python_requirements, to_raw_value, write_file,
|
|
write_file_at_user_defined_location, Connection, PyVAlias, WORKER_CONFIG,
|
|
},
|
|
};
|
|
use windmill_queue::MiniPulledJob;
|
|
|
|
use windmill_parser_yaml::{
|
|
validate_vault_id, AnsibleRequirements, GitRepo, PreexistingAnsibleInventory,
|
|
ResourceOrVariablePath,
|
|
};
|
|
use windmill_queue::{append_logs, CanceledBy};
|
|
|
|
use crate::{
|
|
bash_executor::BIN_BASH,
|
|
common::{
|
|
build_command_with_isolation, check_executor_binary_exists, get_reserved_variables,
|
|
read_and_check_result, render_nsjail_rlimit_as, resolve_nsjail_timeout,
|
|
resolve_nsjail_tmp_mount_block, start_child_process, transform_json, OccupancyMetrics,
|
|
},
|
|
handle_child::handle_child,
|
|
is_sandboxing_enabled,
|
|
python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile},
|
|
DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_ANSIBLE_RLIMIT_AS_MB, NSJAIL_PATH, PATH_ENV,
|
|
PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV,
|
|
};
|
|
use windmill_common::client::AuthedClient;
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref ANSIBLE_PLAYBOOK_PATH: String =
|
|
std::env::var("ANSIBLE_PLAYBOOK_PATH").unwrap_or("/usr/local/bin/ansible-playbook".to_string());
|
|
|
|
static ref ANSIBLE_GALAXY_PATH: String =
|
|
std::env::var("ANSIBLE_GALAXY_PATH").unwrap_or("/usr/local/bin/ansible-galaxy".to_string());
|
|
}
|
|
|
|
const NSJAIL_CONFIG_RUN_ANSIBLE_CONTENT: &str = include_str!("../nsjail/run.ansible.config.proto");
|
|
const WINDMILL_ANSIBLE_PASSWORD_FILENAME: &str = ".windmill.ansible_vault_password_file";
|
|
|
|
const DELEGATE_GIT_REPO_TARGET: &str = "delegate_git_repository";
|
|
|
|
/// Usable bytes in `sockaddr_un.sun_path` (108 minus the NUL). An ABI constant, not a
|
|
/// filesystem limit — which is why only the socket breaks while every regular file in the
|
|
/// same job dir is fine.
|
|
const AF_UNIX_PATH_LIMIT: usize = 107;
|
|
|
|
/// Root for the per-job dir in which ansible's persistent-connection plugins
|
|
/// (`network_cli`, `httpapi`, `netconf`) bind their unix socket, named after a digest of the
|
|
/// connection. `sockaddr_un.sun_path` caps the whole socket path at [`AF_UNIX_PATH_LIMIT`],
|
|
/// which the job dir alone already exhausts, so the socket dir must stay short and cannot
|
|
/// live under `ANSIBLE_HOME` (which Windmill pins into the job dir).
|
|
///
|
|
/// Fixed, and directly under `/tmp`, for two reasons that are easy to undo by accident:
|
|
/// `/tmp`'s sticky bit is what stops another uid renaming our root away, the one property
|
|
/// [`prepare_socket_root`] needs from a parent; and every component of a fixed path is one
|
|
/// nobody can point elsewhere, so trusting the root does not mean trusting an ancestor
|
|
/// chain. Notably NOT under `WINDMILL_DIR`: the shipped image chmods that tree to a
|
|
/// non-sticky 0777 so any UID can write it (`Dockerfile`, "Make directories
|
|
/// world-accessible for any UID"), which is exactly the parent an attacker can swap entries
|
|
/// in.
|
|
const PERSISTENT_CONTROL_PATH_ROOT: &str = "/tmp/wm-pc";
|
|
|
|
/// Ansible's env var for `[persistent_connection] control_path_dir`.
|
|
const ANSIBLE_CONTROL_PATH_DIR_ENV: &str = "ANSIBLE_PERSISTENT_CONTROL_PATH_DIR";
|
|
|
|
/// The budget this whole change exists to protect: root + `/` + a 32-char job uuid + `/` +
|
|
/// a socket name, allowing a full 40-char sha1 (ansible truncates it far shorter today, but
|
|
/// a custom control path may not).
|
|
const _: () = assert!(PERSISTENT_CONTROL_PATH_ROOT.len() + 1 + 32 + 1 + 40 <= AF_UNIX_PATH_LIMIT);
|
|
|
|
/// Cleared when the root cannot be trusted (see [`prepare_socket_root`]), which makes jobs
|
|
/// stop naming it and fall back to ansible's own `{ANSIBLE_HOME}/pc` default — inside the
|
|
/// job dir, so worker-owned. Network playbooks then fail on the path length as they did
|
|
/// before this dir existed, which beats handing an attacker the socket a device session
|
|
/// runs over. Defaults to trusted: the check runs at worker start, before any job.
|
|
static SOCKET_ROOT_TRUSTED: std::sync::atomic::AtomicBool =
|
|
std::sync::atomic::AtomicBool::new(true);
|
|
|
|
/// Socket dir for `job_id`, or `None` when the root is untrusted. Per-job on purpose:
|
|
/// socket names hash host+credentials, so concurrent jobs sharing a dir would reuse each
|
|
/// other's connection daemon.
|
|
fn persistent_control_path_dir(job_id: &Uuid) -> Option<String> {
|
|
SOCKET_ROOT_TRUSTED
|
|
.load(std::sync::atomic::Ordering::Relaxed)
|
|
.then(|| format!("{PERSISTENT_CONTROL_PATH_ROOT}/{}", job_id.simple()))
|
|
}
|
|
|
|
/// Whether `name` is one this module could have created, i.e. `Uuid::simple` (32 hex, no
|
|
/// hyphens). Belt to the root check's braces: nothing else should ever be in there.
|
|
#[cfg(unix)]
|
|
fn is_persistent_control_path_dir_name(name: &str) -> bool {
|
|
name.len() == 32 && Uuid::try_parse(name).is_ok()
|
|
}
|
|
|
|
/// Removes the job's socket dir on the way out. It lives outside `job_dir`, so the
|
|
/// worker's job-dir sweep does not cover it.
|
|
struct PersistentControlPathGuard(String);
|
|
|
|
impl Drop for PersistentControlPathGuard {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
/// Claim the socket-dir root at worker start, then reap dirs left behind by workers that
|
|
/// died before their guard could run.
|
|
#[cfg(unix)]
|
|
pub async fn prepare_persistent_control_path_root() {
|
|
// A play holds its socket dir for as long as it runs, touching the mtime only when
|
|
// connections open, so anything younger than the longest permitted job may still be
|
|
// live — including on another worker sharing this host.
|
|
let stale_after = std::time::Duration::from_secs(
|
|
windmill_common::worker::MAX_TIMEOUT.saturating_add(24 * 60 * 60),
|
|
);
|
|
prepare_socket_root(PERSISTENT_CONTROL_PATH_ROOT, stale_after).await
|
|
}
|
|
|
|
/// Reject a root that another local user could control, and mark it untrusted so jobs stop
|
|
/// naming it. Returns without sweeping in that case.
|
|
///
|
|
/// SECURITY: the root sits in a world-writable `/tmp`, so a local user who wins the race to
|
|
/// create it owns the parent of every job's socket dir —
|
|
/// enough to hand ansible a socket of their choosing (a device session, credentials and
|
|
/// all, runs over it), or to swap in a symlink and redirect the sweep's path-based
|
|
/// `remove_dir_all` onto a target of their choosing, as the worker's uid. Three things must
|
|
/// hold: the root is a real directory (`symlink_metadata` reports the link's own type
|
|
/// without following it, so `is_dir()` cannot be satisfied by a symlink), we own it and
|
|
/// nobody else can write it, and its parent cannot be used to replace it — which needs the
|
|
/// parent either not writable by others, or sticky, since the sticky bit is exactly what
|
|
/// stops a non-owner renaming an entry out of a shared dir. The root is validated after the
|
|
/// create attempt, never before: anything else races whoever creates it first.
|
|
#[cfg(unix)]
|
|
async fn prepare_socket_root(root: &str, stale_after: std::time::Duration) {
|
|
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
|
|
|
let untrusted = |reason: String| {
|
|
tracing::error!(
|
|
"Refusing to use the ansible persistent-connection socket root at {root}: {reason}. \
|
|
Ansible network playbooks on this host will keep failing with `AF_UNIX path too \
|
|
long` until this is resolved."
|
|
);
|
|
SOCKET_ROOT_TRUSTED.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
};
|
|
|
|
if let Some(parent) = std::path::Path::new(root).parent() {
|
|
// Resolved, not `symlink_metadata`: what matters is the mode of the directory the
|
|
// entries actually live in, and a symlinked parent is normal (macOS `/tmp`).
|
|
match tokio::fs::metadata(parent).await {
|
|
Ok(meta) => {
|
|
let mode = meta.permissions().mode();
|
|
if mode & 0o022 != 0 && mode & 0o1000 == 0 {
|
|
return untrusted(format!(
|
|
"its parent {} is writable by other users and not sticky (mode={:o}), \
|
|
so they could replace the root",
|
|
parent.display(),
|
|
mode & 0o7777
|
|
));
|
|
}
|
|
}
|
|
Err(e) => return untrusted(format!("cannot stat its parent: {e}")),
|
|
}
|
|
}
|
|
|
|
// Non-recursive on purpose: `recursive` reports success for a path that already
|
|
// exists, which under a sticky parent (where others may still *create* the
|
|
// not-yet-existing `pc`, only not rename ours away) would hand us whatever another uid
|
|
// raced into place. Create-or-EEXIST, then validate whatever is actually there.
|
|
match tokio::fs::DirBuilder::new().mode(0o700).create(root).await {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
|
Err(e) => return untrusted(format!("it could not be created: {e}")),
|
|
}
|
|
|
|
match tokio::fs::symlink_metadata(root).await {
|
|
Ok(meta) if meta.is_dir() => {
|
|
let mode = meta.permissions().mode();
|
|
// Trusted means usable as well as safe: without owner rwx ansible cannot create
|
|
// its per-job dir, and a trusted-but-unusable root would hand every network
|
|
// playbook a permission error instead of the working fallback.
|
|
if meta.uid() != nix::unistd::Uid::effective().as_raw()
|
|
|| mode & 0o022 != 0
|
|
|| mode & 0o700 != 0o700
|
|
{
|
|
return untrusted(format!(
|
|
"it is not owned by this worker, is writable by others, or is not \
|
|
writable by us (uid={}, mode={:o})",
|
|
meta.uid(),
|
|
mode & 0o7777
|
|
));
|
|
}
|
|
}
|
|
Ok(_) => {
|
|
return untrusted(
|
|
"it is not a directory (possibly a symlink planted by another local user)"
|
|
.to_string(),
|
|
)
|
|
}
|
|
Err(e) => return untrusted(format!("it could not be stat'd: {e}")),
|
|
}
|
|
|
|
let Ok(mut entries) = tokio::fs::read_dir(root).await else {
|
|
return;
|
|
};
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
// Only reap what we could have created. Nothing else should ever be in a root we
|
|
// made 0700 ourselves, but this is a recursive delete running as the worker's uid:
|
|
// cheap to bound by name, expensive to get wrong.
|
|
if !entry
|
|
.file_name()
|
|
.to_str()
|
|
.is_some_and(is_persistent_control_path_dir_name)
|
|
{
|
|
continue;
|
|
}
|
|
// `DirEntry::metadata` does not traverse symlinks, so a planted link is never
|
|
// followed here either.
|
|
let stale = match entry.metadata().await.and_then(|m| m.modified()) {
|
|
Ok(modified) => modified.elapsed().is_ok_and(|e| e > stale_after),
|
|
Err(_) => false,
|
|
};
|
|
if stale {
|
|
let _ = tokio::fs::remove_dir_all(entry.path()).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref TEMPLATE_RE: regex::Regex = regex::Regex::new(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}").unwrap();
|
|
}
|
|
|
|
/// Substitute `{{ arg_name }}` placeholders with values from `args`.
|
|
/// Strings are used raw; numbers/bools are stringified. Other types are rejected.
|
|
fn interpolate_template(
|
|
template: &str,
|
|
args: Option<&HashMap<String, Box<RawValue>>>,
|
|
field_name: &str,
|
|
) -> error::Result<String> {
|
|
let mut last_err: Option<error::Error> = None;
|
|
let result = TEMPLATE_RE.replace_all(template, |caps: ®ex::Captures| {
|
|
let name = &caps[1];
|
|
let raw = args.and_then(|a| a.get(name));
|
|
let Some(raw) = raw else {
|
|
last_err = Some(error::Error::BadRequest(format!(
|
|
"`{}` references `{{{{ {} }}}}` but no such argument was provided",
|
|
field_name, name
|
|
)));
|
|
return String::new();
|
|
};
|
|
let json: serde_json::Value = match serde_json::from_str(raw.get()) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
last_err = Some(error::Error::BadRequest(format!(
|
|
"`{}` could not parse argument `{}` as JSON: {e}",
|
|
field_name, name
|
|
)));
|
|
return String::new();
|
|
}
|
|
};
|
|
match json {
|
|
serde_json::Value::String(s) => s,
|
|
serde_json::Value::Number(n) => n.to_string(),
|
|
serde_json::Value::Bool(b) => b.to_string(),
|
|
serde_json::Value::Null => {
|
|
last_err = Some(error::Error::BadRequest(format!(
|
|
"`{}` references `{{{{ {} }}}}` but the argument is null",
|
|
field_name, name
|
|
)));
|
|
String::new()
|
|
}
|
|
_ => {
|
|
last_err = Some(error::Error::BadRequest(format!(
|
|
"`{}` references `{{{{ {} }}}}` but the argument is not a primitive (string/number/bool)",
|
|
field_name, name
|
|
)));
|
|
String::new()
|
|
}
|
|
}
|
|
});
|
|
if let Some(e) = last_err {
|
|
return Err(e);
|
|
}
|
|
Ok(result.into_owned())
|
|
}
|
|
|
|
/// Reject absolute paths and `..` segments to prevent escaping the cloned repo directory.
|
|
fn validate_relative_path(path: &str, field_name: &str) -> error::Result<()> {
|
|
let trimmed = path.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(error::Error::BadRequest(format!(
|
|
"`{}` resolved to an empty path",
|
|
field_name
|
|
)));
|
|
}
|
|
let p = std::path::Path::new(trimmed);
|
|
for component in p.components() {
|
|
match component {
|
|
// RootDir catches leading `/` or `\`; Prefix catches Windows drive
|
|
// letters and UNC paths. `Path::is_absolute()` alone misses
|
|
// RootDir-only paths on Windows (e.g. `/etc/passwd`).
|
|
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
|
|
return Err(error::Error::BadRequest(format!(
|
|
"`{}` must be a relative path inside the cloned repo, got: {}",
|
|
field_name, trimmed
|
|
)));
|
|
}
|
|
std::path::Component::ParentDir => {
|
|
return Err(error::Error::BadRequest(format!(
|
|
"`{}` must not contain `..` segments, got: {}",
|
|
field_name, trimmed
|
|
)));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn clone_repo(
|
|
repo: &GitRepo,
|
|
job_dir: &str,
|
|
job_id: &Uuid,
|
|
worker_name: &str,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
w_id: &str,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
git_ssh_cmd: &str,
|
|
) -> error::Result<String> {
|
|
let target_path = is_allowed_file_location(job_dir, &repo.target_path)?;
|
|
|
|
let mut clone_cmd = Command::new(GIT_PATH.as_str());
|
|
clone_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.args(["clone", "--quiet"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
if let Some(branch) = &repo.branch {
|
|
clone_cmd.args(["--branch", branch]);
|
|
}
|
|
clone_cmd.arg(&repo.url);
|
|
clone_cmd.arg(&target_path);
|
|
|
|
let clone_cmd_child = start_child_process(clone_cmd, GIT_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
clone_cmd_child,
|
|
false,
|
|
worker_name,
|
|
w_id,
|
|
"git clone",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
// Checkout specific commit if provided
|
|
if let Some(commit) = &repo.commit {
|
|
let mut checkout_cmd = Command::new(GIT_PATH.as_str());
|
|
checkout_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.arg("-C")
|
|
.arg(&target_path)
|
|
.args(["checkout", "--quiet", commit])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let checkout_cmd_child =
|
|
start_child_process(checkout_cmd, GIT_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
checkout_cmd_child,
|
|
false,
|
|
worker_name,
|
|
w_id,
|
|
"git checkout",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let mut rev_parse_cmd = Command::new(GIT_PATH.as_str());
|
|
|
|
let commit_hash_output = rev_parse_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.arg("-C")
|
|
.arg(&target_path)
|
|
.args(["rev-parse", "HEAD"])
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.await?;
|
|
|
|
if !commit_hash_output.status.success() {
|
|
let stderr = String::from_utf8(commit_hash_output.stderr)?;
|
|
return Err(anyhow!("Error getting git repo commit hash: {stderr}").into());
|
|
}
|
|
|
|
let commit_hash = String::from_utf8(commit_hash_output.stdout)?
|
|
.trim()
|
|
.to_string();
|
|
|
|
Ok(commit_hash)
|
|
}
|
|
|
|
pub fn create_empty_dir(path: &PathBuf) -> std::io::Result<()> {
|
|
if path.exists() {
|
|
if path.is_dir() {
|
|
let mut entries = std::fs::read_dir(&path)?;
|
|
if entries.next().is_some() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::AlreadyExists,
|
|
format!(
|
|
"Directory '{}' already exists and is not empty",
|
|
path.display()
|
|
),
|
|
));
|
|
}
|
|
Ok(())
|
|
} else {
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::AlreadyExists,
|
|
format!("Path '{}' exists and is not a directory", path.display()),
|
|
))
|
|
}
|
|
} else {
|
|
std::fs::create_dir_all(path)
|
|
}
|
|
}
|
|
|
|
async fn clone_repo_without_history(
|
|
repo: &GitRepo,
|
|
full_commit: &str,
|
|
job_dir: &str,
|
|
job_id: &Uuid,
|
|
worker_name: &str,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
w_id: &str,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
git_ssh_cmd: &str,
|
|
) -> error::Result<()> {
|
|
let target_path = is_allowed_file_location(job_dir, &repo.target_path)?;
|
|
|
|
create_empty_dir(&target_path)?;
|
|
|
|
let mut init_cmd = Command::new(GIT_PATH.as_str());
|
|
init_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.arg("-C")
|
|
.arg(&target_path)
|
|
.args(["init", "--quiet"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
if let Some(branch) = &repo.branch {
|
|
init_cmd.args(["--initial-branch", branch]);
|
|
}
|
|
|
|
let init_cmd_child = start_child_process(init_cmd, GIT_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
init_cmd_child,
|
|
false,
|
|
worker_name,
|
|
w_id,
|
|
"git init",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let mut add_remote_cmd = Command::new(GIT_PATH.as_str());
|
|
add_remote_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.arg("-C")
|
|
.arg(&target_path)
|
|
.args(vec!["remote", "add", "origin", &repo.url])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let add_remote_cmd_child =
|
|
start_child_process(add_remote_cmd, GIT_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
add_remote_cmd_child,
|
|
false,
|
|
worker_name,
|
|
w_id,
|
|
"git add remote",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let mut fetch_cmd = Command::new(GIT_PATH.as_str());
|
|
fetch_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.arg("-C")
|
|
.arg(&target_path)
|
|
.args(vec!["fetch", "--depth=1", "--quiet", "origin", full_commit])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let fetch_cmd_child = start_child_process(fetch_cmd, GIT_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
fetch_cmd_child,
|
|
false,
|
|
worker_name,
|
|
w_id,
|
|
"git fetch",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let mut checkout_cmd = Command::new(GIT_PATH.as_str());
|
|
checkout_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.arg("-C")
|
|
.arg(&target_path)
|
|
.args(["checkout", "--quiet", "FETCH_HEAD"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let checkout_cmd_child = start_child_process(checkout_cmd, GIT_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
checkout_cmd_child,
|
|
false,
|
|
worker_name,
|
|
w_id,
|
|
"git checkout",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
async fn handle_ansible_python_deps(
|
|
job_dir: &str,
|
|
requirements_o: Option<&String>,
|
|
ansible_reqs: Option<&AnsibleRequirements>,
|
|
w_id: &str,
|
|
job_id: &Uuid,
|
|
conn: &Connection,
|
|
worker_name: &str,
|
|
worker_dir: &str,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
) -> error::Result<Vec<String>> {
|
|
create_dependencies_dir(job_dir).await;
|
|
|
|
let mut additional_python_paths: Vec<String> = WORKER_CONFIG
|
|
.load()
|
|
.additional_python_paths
|
|
.clone()
|
|
.unwrap_or_else(|| vec![]);
|
|
|
|
let mut requirements;
|
|
let requirements = match requirements_o {
|
|
Some(r) => r,
|
|
None => {
|
|
requirements = ansible_reqs
|
|
.map(|x| x.python_reqs.join("\n"))
|
|
.unwrap_or("".to_string());
|
|
if !requirements.is_empty() {
|
|
requirements = uv_pip_compile(
|
|
job_id,
|
|
&requirements,
|
|
mem_peak,
|
|
canceled_by,
|
|
job_dir,
|
|
conn,
|
|
worker_name,
|
|
w_id,
|
|
&mut Some(occupancy_metrics),
|
|
PyVAlias::Py311.into(),
|
|
false,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error::Error::ExecutionErr(format!("pip compile failed: {}", e.to_string()))
|
|
})?;
|
|
}
|
|
&requirements
|
|
}
|
|
};
|
|
|
|
if requirements.len() > 0 {
|
|
let mut venv_path = handle_python_reqs(
|
|
split_python_requirements(requirements),
|
|
job_id,
|
|
w_id,
|
|
mem_peak,
|
|
canceled_by,
|
|
conn,
|
|
worker_name,
|
|
job_dir,
|
|
worker_dir,
|
|
&mut Some(occupancy_metrics),
|
|
PyVAlias::default().into(),
|
|
None,
|
|
)
|
|
.await?;
|
|
additional_python_paths.append(&mut venv_path);
|
|
}
|
|
Ok(additional_python_paths)
|
|
}
|
|
|
|
pub async fn install_galaxy_collections(
|
|
collections_yml: &str,
|
|
job_dir: &str,
|
|
job_id: &Uuid,
|
|
worker_name: &str,
|
|
w_id: &str,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
conn: &Connection,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
git_ssh_cmd: &str,
|
|
) -> anyhow::Result<()> {
|
|
write_file(job_dir, "requirements.yml", collections_yml)?;
|
|
|
|
append_logs(
|
|
job_id,
|
|
w_id,
|
|
"\n\n--- ANSIBLE GALAXY INSTALL ---\n".to_string(),
|
|
conn,
|
|
)
|
|
.await;
|
|
|
|
run_galaxy_install_from_requirements(
|
|
"requirements.yml",
|
|
job_dir,
|
|
job_id,
|
|
worker_name,
|
|
w_id,
|
|
mem_peak,
|
|
canceled_by,
|
|
conn,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Run `ansible-galaxy role install -r <path>` then `ansible-galaxy collection install -r <path>`.
|
|
/// `requirements_path` is relative to `job_dir`.
|
|
async fn run_galaxy_install_from_requirements(
|
|
requirements_path: &str,
|
|
job_dir: &str,
|
|
job_id: &Uuid,
|
|
worker_name: &str,
|
|
w_id: &str,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
conn: &Connection,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
git_ssh_cmd: &str,
|
|
) -> anyhow::Result<()> {
|
|
let mut galaxy_roles_cmd = Command::new(ANSIBLE_GALAXY_PATH.as_str());
|
|
galaxy_roles_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.args(vec![
|
|
"role",
|
|
"install",
|
|
"-r",
|
|
requirements_path,
|
|
"-p",
|
|
"./roles",
|
|
])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let child = start_child_process(galaxy_roles_cmd, ANSIBLE_GALAXY_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
child,
|
|
is_sandboxing_enabled(),
|
|
worker_name,
|
|
w_id,
|
|
"ansible-galaxy role install",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let mut galaxy_collections_cmd = Command::new(ANSIBLE_GALAXY_PATH.as_str());
|
|
galaxy_collections_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.args(vec![
|
|
"collection",
|
|
"install",
|
|
"-r",
|
|
requirements_path,
|
|
"-p",
|
|
"./",
|
|
])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let child =
|
|
start_child_process(galaxy_collections_cmd, ANSIBLE_GALAXY_PATH.as_str(), false).await?;
|
|
handle_child(
|
|
job_id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
child,
|
|
is_sandboxing_enabled(),
|
|
worker_name,
|
|
w_id,
|
|
"ansible-galaxy collection install",
|
|
None,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Look for `requirements.yml`, `collections/requirements.yml`, and `roles/requirements.yml`
|
|
/// inside a cloned repo (relative to `job_dir`) and run ansible-galaxy install on each one found.
|
|
async fn install_requirements_from_cloned_repo(
|
|
repo_target: &str,
|
|
job_dir: &str,
|
|
job_id: &Uuid,
|
|
worker_name: &str,
|
|
w_id: &str,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
conn: &Connection,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
git_ssh_cmd: &str,
|
|
) -> anyhow::Result<()> {
|
|
let candidates = [
|
|
"requirements.yml",
|
|
"requirements.yaml",
|
|
"collections/requirements.yml",
|
|
"collections/requirements.yaml",
|
|
"roles/requirements.yml",
|
|
"roles/requirements.yaml",
|
|
];
|
|
let mut found: Vec<String> = vec![];
|
|
for candidate in candidates {
|
|
let abs = std::path::Path::new(job_dir)
|
|
.join(repo_target)
|
|
.join(candidate);
|
|
if abs.is_file() {
|
|
found.push(format!("{}/{}", repo_target, candidate));
|
|
}
|
|
}
|
|
|
|
if found.is_empty() {
|
|
append_logs(
|
|
job_id,
|
|
w_id,
|
|
format!(
|
|
"\nNo requirements.yml found in `{}`, skipping repo dependency install.\n",
|
|
repo_target
|
|
),
|
|
conn,
|
|
)
|
|
.await;
|
|
return Ok(());
|
|
}
|
|
|
|
append_logs(
|
|
job_id,
|
|
w_id,
|
|
format!(
|
|
"\n\n--- INSTALLING REPO REQUIREMENTS ({}) ---\n",
|
|
found.join(", ")
|
|
),
|
|
conn,
|
|
)
|
|
.await;
|
|
|
|
for path in &found {
|
|
run_galaxy_install_from_requirements(
|
|
path,
|
|
job_dir,
|
|
job_id,
|
|
worker_name,
|
|
w_id,
|
|
mem_peak,
|
|
canceled_by,
|
|
conn,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct AnsibleDependencyLocks {
|
|
pub python_lockfile: String,
|
|
pub git_repos: HashMap<String, String>, // URL to full commit hash
|
|
pub collections_and_roles: String,
|
|
pub collections_and_roles_logs: String,
|
|
// pub collection_versions: HashMap<String, String>, //
|
|
// pub role_versions: HashMap<String, String>,
|
|
}
|
|
|
|
pub async fn get_collection_locks(
|
|
job_dir: &str,
|
|
) -> anyhow::Result<(HashMap<String, String>, String)> {
|
|
let mut ansible_cmd = Command::new(ANSIBLE_GALAXY_PATH.as_str());
|
|
|
|
ansible_cmd
|
|
.current_dir(job_dir)
|
|
.args(["collection", "list", "--format", "json", "-p", "./"]);
|
|
|
|
let output = ansible_cmd.output().await?;
|
|
|
|
let mut ret = HashMap::new();
|
|
let mut logs = String::new();
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8(output.stderr)?;
|
|
return Err(anyhow!(
|
|
"Error getting ansible collection versions: {stderr}"
|
|
));
|
|
}
|
|
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
|
|
let val: serde_json::Value = serde_json::from_str(&stdout)?;
|
|
|
|
let Some(own_collections) = val.get(format!("{}/ansible_collections", job_dir)) else {
|
|
return Ok((ret, logs));
|
|
};
|
|
|
|
let collections = own_collections.as_object().ok_or(anyhow!(
|
|
"Expected an object (map) for the `ansible-galaxy collection list` command output and got {}",
|
|
own_collections
|
|
))?;
|
|
|
|
for (c_name, c) in collections.iter() {
|
|
if let Some(v) = c.get("version").and_then(|v| v.as_str()) {
|
|
// TODO: Check if version is not something like `(undefined)`
|
|
ret.insert(c_name.clone(), v.to_string());
|
|
} else {
|
|
logs.push_str(&format!("Failed to get version for collection `{}`. Expected an object with a string in the `version` field but received {}\n", c_name, c));
|
|
}
|
|
}
|
|
|
|
Ok((ret, logs))
|
|
}
|
|
|
|
pub async fn get_role_locks(job_dir: &str) -> anyhow::Result<(HashMap<String, String>, String)> {
|
|
let mut ansible_cmd = Command::new(ANSIBLE_GALAXY_PATH.as_str());
|
|
|
|
ansible_cmd
|
|
.current_dir(job_dir)
|
|
.args(["role", "list", "-p", "./roles"]);
|
|
|
|
let output = ansible_cmd.output().await?;
|
|
let mut ret = HashMap::new();
|
|
let mut logs = String::new();
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8(output.stderr)?;
|
|
logs.push_str(&format!("Error getting ansible role versions: {stderr}"));
|
|
return Ok((ret, logs));
|
|
}
|
|
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
|
|
let mut lines = stdout.lines();
|
|
|
|
while let Some(line) = lines.next() {
|
|
if line == format!("# {}/roles", job_dir) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
for line in lines {
|
|
let line = line.strip_prefix("-").unwrap_or(line);
|
|
let mut cols = line.split(",");
|
|
|
|
if let Some(name) = cols.next().map(|n| n.trim()) {
|
|
if let Some(version) = cols.next().map(|v| v.trim()) {
|
|
// TODO: Check if version is not something like `(undefined)`
|
|
ret.insert(name.to_string(), version.to_string());
|
|
} else {
|
|
logs.push_str(&format!("Failed to get version for role `{}`.", name));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((ret, logs))
|
|
}
|
|
|
|
pub async fn get_git_repo_full_head_commit_hash(
|
|
repo: &GitRepo,
|
|
git_ssh_cmd: &str,
|
|
) -> anyhow::Result<String> {
|
|
let mut git_cmd = Command::new(GIT_PATH.as_str());
|
|
|
|
git_cmd
|
|
.env("GIT_SSH_COMMAND", git_ssh_cmd)
|
|
.args(["ls-remote", &repo.url, "HEAD"]);
|
|
|
|
let output = git_cmd.stderr(Stdio::piped()).output().await?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8(output.stderr)?;
|
|
return Err(anyhow!("Error getting git repo commit hash: {stderr}"));
|
|
}
|
|
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
|
|
let lines: Vec<&str> = stdout.lines().collect();
|
|
|
|
if lines.len() != 1 {
|
|
return Err(anyhow!("Unexpected output format for git ls-remote",));
|
|
}
|
|
|
|
Ok(lines
|
|
.first()
|
|
.ok_or(anyhow!(
|
|
"The HEAD commit hash was not found for repo `{}`",
|
|
sanitize_git_url(&repo.url)
|
|
))?
|
|
.split_whitespace()
|
|
.next()
|
|
.map(|s| s.to_string())
|
|
.ok_or(anyhow!("Unexpected output format for git ls-remote"))?)
|
|
}
|
|
|
|
pub async fn get_git_repos_lock(
|
|
repos: &Vec<GitRepo>,
|
|
job_dir: &str,
|
|
job_id: &Uuid,
|
|
worker_name: &str,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
w_id: &str,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
git_ssh_cmd: &str,
|
|
) -> anyhow::Result<HashMap<String, String>> {
|
|
let mut ret = HashMap::new();
|
|
|
|
for repo in repos {
|
|
if repo.commit.is_some() {
|
|
ret.insert(
|
|
repo.url.to_string(),
|
|
clone_repo(
|
|
repo,
|
|
job_dir,
|
|
job_id,
|
|
worker_name,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
w_id,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await?,
|
|
);
|
|
} else {
|
|
ret.insert(
|
|
repo.url.to_string(),
|
|
get_git_repo_full_head_commit_hash(repo, git_ssh_cmd).await?,
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(ret)
|
|
}
|
|
|
|
pub fn create_ansible_cfg(
|
|
reqs: Option<&AnsibleRequirements>,
|
|
job_dir: &str,
|
|
vault_password_file_exists: bool,
|
|
job_id: &Uuid,
|
|
) -> error::Result<()> {
|
|
let mut passwords_cfg = String::new();
|
|
if vault_password_file_exists {
|
|
passwords_cfg.push_str(&format!(
|
|
"vault_password_file = {WINDMILL_ANSIBLE_PASSWORD_FILENAME}\n"
|
|
));
|
|
}
|
|
if let Some(vault_ids) = reqs.as_ref().map(|r| &r.vault_id) {
|
|
if !vault_ids.is_empty() {
|
|
// Defense in depth: entries are validated at parse time, but re-check here
|
|
// since they are interpolated raw into ansible.cfg (config-directive injection).
|
|
for vault_id in vault_ids {
|
|
validate_vault_id(vault_id)?;
|
|
}
|
|
let password_files = vault_ids.join(",");
|
|
|
|
passwords_cfg.push_str(&format!("vault_identity_list = {password_files}\n"));
|
|
}
|
|
}
|
|
let persistent_cfg = persistent_control_path_dir(job_id)
|
|
.map(|dir| format!("[persistent_connection]\ncontrol_path_dir = {dir}\n"))
|
|
.unwrap_or_default();
|
|
let ansible_cfg_content = format!(
|
|
r#"
|
|
[defaults]
|
|
collections_path = ./
|
|
roles_path = ./roles
|
|
home={job_dir}/.ansible
|
|
local_tmp={job_dir}/.ansible/tmp
|
|
remote_tmp={job_dir}/.ansible/tmp
|
|
{passwords_cfg}
|
|
{persistent_cfg}"#
|
|
);
|
|
|
|
write_file(job_dir, "ansible.cfg", &ansible_cfg_content)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The section a header line opens, if it is one. Mirrors configparser's `SECTCRE`
|
|
/// (`\[(?P<header>.+)\]`, matched not fullmatched, `.+` greedy): the name runs to the
|
|
/// *last* `]`, and anything after it — an inline comment, say — is ignored.
|
|
fn parse_ansible_cfg_section_header(trimmed: &str) -> Option<&str> {
|
|
let rest = trimmed.strip_prefix('[')?;
|
|
let end = rest.rfind(']')?;
|
|
Some(rest[..end].trim())
|
|
}
|
|
|
|
/// Read a colon-separated path list (e.g. `roles_path`, `collections_path`) from
|
|
/// the `[defaults]` section of an ansible.cfg. Returns the raw entries as written,
|
|
/// unresolved. Deliberately minimal: no inline-comment or continuation handling,
|
|
/// which ansible's configparser also does not apply to these values. `key` and `=`
|
|
/// or `:` as the delimiter are both matched (Python configparser accepts either),
|
|
/// with the value itself split on `:` (`os.pathsep`).
|
|
fn parse_ansible_cfg_path_list(content: &str, key: &str) -> Option<Vec<String>> {
|
|
let mut in_defaults = false;
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if let Some(section) = parse_ansible_cfg_section_header(trimmed) {
|
|
in_defaults = section.eq_ignore_ascii_case("defaults");
|
|
continue;
|
|
}
|
|
if !in_defaults || trimmed.starts_with('#') || trimmed.starts_with(';') {
|
|
continue;
|
|
}
|
|
// configparser delimits key/value on the first `=` or `:`, whichever
|
|
// comes first; the remaining `:` in the value are path separators.
|
|
let sep = trimmed.find('=').into_iter().chain(trimmed.find(':')).min();
|
|
if let Some(sep) = sep {
|
|
let (k, rest) = trimmed.split_at(sep);
|
|
let v = &rest[1..];
|
|
if k.trim().eq_ignore_ascii_case(key) {
|
|
let entries: Vec<String> = v
|
|
.split(':')
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
return (!entries.is_empty()).then_some(entries);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether `section` declares `key` in an ansible.cfg. Same deliberately minimal
|
|
/// parsing as [`parse_ansible_cfg_path_list`], for a scalar key in a named section.
|
|
fn ansible_cfg_declares(content: &str, section: &str, key: &str) -> bool {
|
|
let mut in_section = false;
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if let Some(header) = parse_ansible_cfg_section_header(trimmed) {
|
|
in_section = header.eq_ignore_ascii_case(section);
|
|
continue;
|
|
}
|
|
if !in_section || trimmed.starts_with('#') || trimmed.starts_with(';') {
|
|
continue;
|
|
}
|
|
let sep = trimmed.find('=').into_iter().chain(trimmed.find(':')).min();
|
|
if let Some(sep) = sep {
|
|
if trimmed[..sep].trim().eq_ignore_ascii_case(key) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Prepend Windmill's dependency install dir to the repo cfg's declared path list.
|
|
/// Relative entries from the repo cfg are resolved against `cfg_dir` to match how
|
|
/// ansible resolves them relative to the config file's own directory.
|
|
fn resolve_and_prepend_path(
|
|
base: String,
|
|
repo_entries: Option<Vec<String>>,
|
|
cfg_dir: &str,
|
|
) -> String {
|
|
let mut paths = vec![base];
|
|
if let Some(entries) = repo_entries {
|
|
for e in entries {
|
|
if e.starts_with('/') || e.starts_with('~') {
|
|
paths.push(e);
|
|
} else {
|
|
paths.push(format!("{cfg_dir}/{e}"));
|
|
}
|
|
}
|
|
}
|
|
paths.join(":")
|
|
}
|
|
|
|
/// Build the environment overrides applied when delegating to a git repo that
|
|
/// ships its own ansible.cfg. See the call site for the layering rationale.
|
|
async fn build_ansible_cfg_override_envs(
|
|
cfg_path: &str,
|
|
job_dir: &str,
|
|
vault_password_file_exists: bool,
|
|
reqs: Option<&AnsibleRequirements>,
|
|
job_id: &Uuid,
|
|
job_envs: &HashMap<String, String>,
|
|
) -> error::Result<Vec<(String, String)>> {
|
|
let mut envs = vec![
|
|
("ANSIBLE_CONFIG".to_string(), cfg_path.to_string()),
|
|
// Runtime-bound: reference the ephemeral job dir, cannot be set statically.
|
|
("ANSIBLE_HOME".to_string(), format!("{job_dir}/.ansible")),
|
|
(
|
|
"ANSIBLE_LOCAL_TEMP".to_string(),
|
|
format!("{job_dir}/.ansible/tmp"),
|
|
),
|
|
(
|
|
"ANSIBLE_REMOTE_TEMP".to_string(),
|
|
format!("{job_dir}/.ansible/tmp"),
|
|
),
|
|
];
|
|
|
|
// Vault: Windmill manages the secret, so its config wins over the repo cfg.
|
|
if vault_password_file_exists {
|
|
envs.push((
|
|
"ANSIBLE_VAULT_PASSWORD_FILE".to_string(),
|
|
format!("{job_dir}/{WINDMILL_ANSIBLE_PASSWORD_FILENAME}"),
|
|
));
|
|
}
|
|
if let Some(vault_ids) = reqs.map(|r| &r.vault_id).filter(|v| !v.is_empty()) {
|
|
// Defense in depth: entries are validated at parse time, but re-check
|
|
// here since they are interpolated raw into the env value.
|
|
for vault_id in vault_ids {
|
|
validate_vault_id(vault_id)?;
|
|
}
|
|
envs.push((
|
|
"ANSIBLE_VAULT_IDENTITY_LIST".to_string(),
|
|
vault_ids.join(","),
|
|
));
|
|
}
|
|
|
|
// Dependency search paths: additive. Windmill installs galaxy roles into
|
|
// `{job_dir}/roles` and collections into `{job_dir}`; prepend those to the
|
|
// repo cfg's declared paths so both Windmill-installed and repo deps resolve.
|
|
let cfg_dir = std::path::Path::new(cfg_path)
|
|
.parent()
|
|
.and_then(|p| p.to_str())
|
|
.unwrap_or(job_dir);
|
|
let cfg_content = tokio::fs::read_to_string(cfg_path).await.map_err(|e| {
|
|
windmill_common::error::Error::internal_err(format!(
|
|
"Failed to read delegated ansible.cfg at `{cfg_path}`: {e}"
|
|
))
|
|
})?;
|
|
|
|
// Persistent-connection socket dir: only a default. Unlike ANSIBLE_HOME this value is
|
|
// not runtime-bound, so a repo that picks its own dir keeps it — and so does a job that
|
|
// sets the env var itself, which these overrides are applied after and would otherwise
|
|
// silently outrank.
|
|
if !ansible_cfg_declares(&cfg_content, "persistent_connection", "control_path_dir")
|
|
&& !job_envs.contains_key(ANSIBLE_CONTROL_PATH_DIR_ENV)
|
|
{
|
|
if let Some(dir) = persistent_control_path_dir(job_id) {
|
|
envs.push((ANSIBLE_CONTROL_PATH_DIR_ENV.to_string(), dir));
|
|
}
|
|
}
|
|
|
|
envs.push((
|
|
"ANSIBLE_ROLES_PATH".to_string(),
|
|
resolve_and_prepend_path(
|
|
format!("{job_dir}/roles"),
|
|
parse_ansible_cfg_path_list(&cfg_content, "roles_path"),
|
|
cfg_dir,
|
|
),
|
|
));
|
|
envs.push((
|
|
"ANSIBLE_COLLECTIONS_PATH".to_string(),
|
|
resolve_and_prepend_path(
|
|
job_dir.to_string(),
|
|
// Also probe the deprecated plural ini alias; env vars replace (not
|
|
// merge) the cfg value, so a repo using it would otherwise be dropped.
|
|
parse_ansible_cfg_path_list(&cfg_content, "collections_path")
|
|
.or_else(|| parse_ansible_cfg_path_list(&cfg_content, "collections_paths")),
|
|
cfg_dir,
|
|
),
|
|
));
|
|
|
|
Ok(envs)
|
|
}
|
|
|
|
pub async fn get_git_ssh_cmd(
|
|
reqs: &AnsibleRequirements,
|
|
job_dir: &str,
|
|
client: &AuthedClient,
|
|
) -> error::Result<String> {
|
|
let ssh_id_files = try_join_all(reqs.git_ssh_identity.iter().enumerate().map(
|
|
async |(i, var_path)| -> error::Result<String> {
|
|
let id_file_name = format!(".ssh_id_priv_{}", i);
|
|
let loc = is_allowed_file_location(job_dir, &id_file_name)?;
|
|
|
|
let mut content = client.get_variable_value(var_path).await.map_err(|e| {
|
|
error::Error::NotFound(format!(
|
|
"Variable {var_path} not found for git ssh identity: {e:#}"
|
|
))
|
|
})?;
|
|
content.push_str("\n");
|
|
|
|
#[cfg(not(unix))]
|
|
let _ = write_file(job_dir, &id_file_name, &content)?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
let file = write_file(job_dir, &id_file_name, &content)?;
|
|
let perm = std::os::unix::fs::PermissionsExt::from_mode(0o600);
|
|
file.set_permissions(perm)?;
|
|
}
|
|
|
|
Ok(format!(
|
|
" -i '{}'",
|
|
loc.to_string_lossy().replace('\'', r"'\''")
|
|
))
|
|
},
|
|
))
|
|
.await?;
|
|
|
|
let git_ssh_cmd = format!("ssh -o StrictHostKeyChecking=no{}", ssh_id_files.join(""));
|
|
Ok(git_ssh_cmd)
|
|
}
|
|
|
|
pub async fn handle_ansible_job(
|
|
requirements_o: Option<&String>,
|
|
job_dir: &str,
|
|
worker_dir: &str,
|
|
worker_name: &str,
|
|
job: &MiniPulledJob,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
conn: &Connection,
|
|
client: &AuthedClient,
|
|
parent_runnable_path: Option<String>,
|
|
inner_content: &String,
|
|
shared_mount: &str,
|
|
base_internal_url: &str,
|
|
envs: HashMap<String, String>,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
) -> windmill_common::error::Result<Box<RawValue>> {
|
|
check_executor_binary_exists(
|
|
"ansible-playbook",
|
|
ANSIBLE_PLAYBOOK_PATH.as_str(),
|
|
"ansible",
|
|
)?;
|
|
|
|
let req_lockfiles: Option<AnsibleDependencyLocks> = if let Some(s) = requirements_o {
|
|
if let Ok(lockfile) = serde_json::from_str(s) {
|
|
Some(lockfile)
|
|
} else {
|
|
if !s.trim_start().starts_with('{') {
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!("WARN: lockfile seems to be in an older version, roles and collections are therefore using the latest version and not the one locked at deployment. Redeploy the script to correct this"),
|
|
conn,
|
|
)
|
|
.await;
|
|
Some(AnsibleDependencyLocks {
|
|
python_lockfile: s.to_string(),
|
|
git_repos: HashMap::new(),
|
|
collections_and_roles: String::new(),
|
|
collections_and_roles_logs: String::new(),
|
|
})
|
|
} else {
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!("WARN: lockfile could not be parsed: {s}"),
|
|
conn,
|
|
)
|
|
.await;
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (logs, reqs, playbook) = windmill_parser_yaml::parse_ansible_reqs(inner_content)?;
|
|
append_logs(&job.id, &job.workspace_id, logs, conn).await;
|
|
write_file(job_dir, "main.yml", &playbook)?;
|
|
|
|
let additional_python_paths = handle_ansible_python_deps(
|
|
job_dir,
|
|
req_lockfiles.as_ref().map(|r| &r.python_lockfile),
|
|
reqs.as_ref(),
|
|
&job.workspace_id,
|
|
&job.id,
|
|
conn,
|
|
worker_name,
|
|
worker_dir,
|
|
mem_peak,
|
|
canceled_by,
|
|
occupancy_metrics,
|
|
)
|
|
.await?;
|
|
|
|
let git_ssh_cmd = &match &reqs {
|
|
Some(r) => get_git_ssh_cmd(r, job_dir, client).await?,
|
|
None => "ssh".to_string(),
|
|
};
|
|
|
|
let interpolated_args;
|
|
if let Some(args) = &job.args {
|
|
let mut args = args.0.clone();
|
|
if let Some(reqs) = reqs.clone() {
|
|
for (name, path) in reqs.resources.iter().chain(reqs.vars.iter()) {
|
|
args.insert(name.clone(), to_raw_value(path));
|
|
}
|
|
}
|
|
if let Some(x) = transform_json(client, &job.workspace_id, &args, job, conn).await? {
|
|
write_file(
|
|
job_dir,
|
|
"args.json",
|
|
&serde_json::to_string(&x).unwrap_or_else(|_| "{}".to_string()),
|
|
)?;
|
|
interpolated_args = Some(x);
|
|
} else {
|
|
write_file(
|
|
job_dir,
|
|
"args.json",
|
|
&serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string()),
|
|
)?;
|
|
interpolated_args = Some(args);
|
|
}
|
|
} else {
|
|
interpolated_args = None;
|
|
write_file(job_dir, "args.json", "{}")?;
|
|
};
|
|
write_file(job_dir, "result.json", "")?;
|
|
|
|
let cmd_options: Vec<String> = if let Some(r) = reqs.as_ref() {
|
|
let mut opts = r.options.clone();
|
|
if let Some(limit) = opts.limit.as_ref() {
|
|
opts.limit = Some(interpolate_template(
|
|
limit,
|
|
interpolated_args.as_ref(),
|
|
"options.limit",
|
|
)?);
|
|
}
|
|
get_cmd_options(opts)
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
let mut inventories: Vec<String> = reqs
|
|
.as_ref()
|
|
.map(|x| -> Result<Vec<String>, _> {
|
|
let mut ret: Vec<String> = x
|
|
.inventories
|
|
.clone()
|
|
.iter()
|
|
.flat_map(|i| vec!["-i".to_string(), i.name.clone()].into_iter())
|
|
.collect();
|
|
|
|
let additional: Vec<String> = x
|
|
.additional_inventories
|
|
.iter()
|
|
.map(|i| match i {
|
|
PreexistingAnsibleInventory::Static(name) => Ok(Some(vec![name.clone()])),
|
|
PreexistingAnsibleInventory::PassedInArgs(inv_def) => interpolated_args
|
|
.as_ref()
|
|
.and_then(|args| args.get(&inv_def.name))
|
|
.and_then(|v| serde_json::from_str(v.get()).transpose())
|
|
.transpose(),
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.into_iter()
|
|
.flatten()
|
|
.flatten()
|
|
.flat_map(|name| vec!["-i".to_string(), name])
|
|
.collect();
|
|
|
|
ret.extend(additional);
|
|
Ok::<_, windmill_common::error::Error>(ret)
|
|
})
|
|
.transpose()?
|
|
.unwrap_or_else(|| vec![]);
|
|
|
|
let mut nsjail_extra_mounts = vec![];
|
|
let mut playbook_override = None;
|
|
// Absolute path of a repo-provided `ansible.cfg` to use as the effective
|
|
// config, set when `delegate_to_git_repo.ansible_cfg` is provided.
|
|
let mut ansible_config_override: Option<String> = None;
|
|
|
|
if let Some(r) = reqs.as_ref() {
|
|
nsjail_extra_mounts = create_file_resources(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
job_dir,
|
|
interpolated_args.as_ref(),
|
|
&r,
|
|
&client,
|
|
conn,
|
|
)
|
|
.await?;
|
|
|
|
if let Some(delegated_git_repo) = r.delegate_to_git_repo.as_ref() {
|
|
let interpolated_playbook = delegated_git_repo
|
|
.playbook
|
|
.as_ref()
|
|
.map(|p| -> error::Result<String> {
|
|
let p = interpolate_template(
|
|
p,
|
|
interpolated_args.as_ref(),
|
|
"delegate_to_git_repo.playbook",
|
|
)?;
|
|
validate_relative_path(&p, "delegate_to_git_repo.playbook")?;
|
|
Ok(p)
|
|
})
|
|
.transpose()?;
|
|
let interpolated_commit = delegated_git_repo
|
|
.commit
|
|
.as_ref()
|
|
.map(|c| {
|
|
interpolate_template(
|
|
c,
|
|
interpolated_args.as_ref(),
|
|
"delegate_to_git_repo.commit",
|
|
)
|
|
})
|
|
.transpose()?;
|
|
let interpolated_inventories_location = delegated_git_repo
|
|
.inventories_location
|
|
.as_ref()
|
|
.map(|p| -> error::Result<String> {
|
|
let p = interpolate_template(
|
|
p,
|
|
interpolated_args.as_ref(),
|
|
"delegate_to_git_repo.inventories_location",
|
|
)?;
|
|
validate_relative_path(&p, "delegate_to_git_repo.inventories_location")?;
|
|
Ok(p)
|
|
})
|
|
.transpose()?;
|
|
|
|
let serde_json::Value::Object(git_repo_resource) = client
|
|
.get_resource_value_interpolated::<serde_json::Value>(
|
|
&delegated_git_repo.resource,
|
|
Some(job.id.to_string()),
|
|
)
|
|
.await?
|
|
else {
|
|
return Err(windmill_common::error::Error::BadRequest(
|
|
"Git repository resource is not an object".to_string(),
|
|
));
|
|
};
|
|
|
|
let mut secret_url = git_repo_resource.get("url").and_then(|s| s.as_str()).map(|s| s.to_string())
|
|
.ok_or(anyhow!("Failed to get url from git repo resource, please check that the resource has the correct type (git_repository)"))?;
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
let is_github_app = git_repo_resource.get("is_github_app").and_then(|s| s.as_bool())
|
|
.ok_or(anyhow!("Failed to get `is_github_app` field from git repo resource, please check that the resource has the correct type (git_repository)"))?;
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
if is_github_app {
|
|
if let Connection::Sql(db) = conn {
|
|
let token = windmill_common::git_sync_oss::get_github_app_token_internal(
|
|
db,
|
|
&client.token,
|
|
)
|
|
.await?;
|
|
secret_url = prepend_token_to_github_url(&secret_url, &token)?;
|
|
} else {
|
|
return Err(windmill_common::error::Error::BadRequest("Github App authentication is currently unavailable for agent workers. Contact the windmill team to request this feature".to_string()));
|
|
}
|
|
}
|
|
|
|
let branch = Some(git_repo_resource.get("branch").and_then(|s| s.as_str()).map(|s| s.to_string())
|
|
.ok_or(anyhow!("Failed to get branch from git repo resource, please check that the resource has the correct type (git_repository)"))?).filter(|s| !s.is_empty());
|
|
|
|
let target_path = DELEGATE_GIT_REPO_TARGET.to_string();
|
|
|
|
let repo = GitRepo {
|
|
url: secret_url,
|
|
commit: interpolated_commit.clone(),
|
|
branch,
|
|
target_path,
|
|
};
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!("\nCloning {}...\n", delegated_git_repo.resource),
|
|
conn,
|
|
)
|
|
.await;
|
|
if let Some(commit) = interpolated_commit.as_ref() {
|
|
clone_repo_without_history(
|
|
&repo,
|
|
commit,
|
|
job_dir,
|
|
&job.id,
|
|
worker_name,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
&job.workspace_id,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow!(
|
|
"Failed to clone git repo `{}`: {e}",
|
|
sanitize_git_url(&repo.url)
|
|
)
|
|
})?;
|
|
} else {
|
|
clone_repo(
|
|
&repo,
|
|
job_dir,
|
|
&job.id,
|
|
worker_name,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
&job.workspace_id,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow!(
|
|
"Failed to clone git repo `{}`: {e}",
|
|
sanitize_git_url(&repo.url)
|
|
)
|
|
})?;
|
|
}
|
|
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!(
|
|
"Cloned {} into {}\n",
|
|
delegated_git_repo.resource, &repo.target_path
|
|
),
|
|
conn,
|
|
)
|
|
.await;
|
|
|
|
playbook_override =
|
|
Some(interpolated_playbook.map(|p| format!("{}/{}", &repo.target_path, p)));
|
|
|
|
if let Some(inv) = interpolated_inventories_location {
|
|
inventories.push("-i".to_string());
|
|
inventories.push(format!("{}/{}", &repo.target_path, inv));
|
|
}
|
|
|
|
if let Some(cfg_rel) = delegated_git_repo.ansible_cfg.as_ref() {
|
|
let cfg_rel = interpolate_template(
|
|
cfg_rel,
|
|
interpolated_args.as_ref(),
|
|
"delegate_to_git_repo.ansible_cfg",
|
|
)?;
|
|
validate_relative_path(&cfg_rel, "delegate_to_git_repo.ansible_cfg")?;
|
|
let cfg_path = format!("{}/{}/{}", job_dir, &repo.target_path, cfg_rel);
|
|
if !tokio::fs::try_exists(&cfg_path).await.unwrap_or(false) {
|
|
return Err(windmill_common::error::Error::BadRequest(format!(
|
|
"delegate_to_git_repo.ansible_cfg: no ansible.cfg found in the cloned repo at `{}/{}`",
|
|
&repo.target_path, cfg_rel
|
|
)));
|
|
}
|
|
ansible_config_override = Some(cfg_path);
|
|
}
|
|
|
|
if delegated_git_repo.install_requirements {
|
|
install_requirements_from_cloned_repo(
|
|
&repo.target_path,
|
|
job_dir,
|
|
&job.id,
|
|
worker_name,
|
|
&job.workspace_id,
|
|
mem_peak,
|
|
canceled_by,
|
|
conn,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if playbook_override.clone().flatten().is_none() && playbook.is_empty() {
|
|
return Err(windmill_common::error::Error::BadRequest("No playbook was specified. Append a playbook to your script or specify one in the delegate_to_git_repo -> playbook section.".to_string()));
|
|
}
|
|
|
|
for repo in &r.git_repos {
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!("\nCloning {}...\n", sanitize_git_url(&repo.url)),
|
|
conn,
|
|
)
|
|
.await;
|
|
if let Some(full_commit_hash) = req_lockfiles
|
|
.as_ref()
|
|
.and_then(|r| r.git_repos.get(&repo.url))
|
|
{
|
|
clone_repo_without_history(
|
|
repo,
|
|
full_commit_hash,
|
|
job_dir,
|
|
&job.id,
|
|
worker_name,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
&job.workspace_id,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow!(
|
|
"Failed to clone git repo `{}`: {e}",
|
|
sanitize_git_url(&repo.url)
|
|
)
|
|
})?;
|
|
} else {
|
|
if req_lockfiles.is_some() {
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!("Warning: `{}` is using latest commit because the lockfile didn't store a commit hash for this repo. Updates to the repo could break the deployed playbook.\n", sanitize_git_url(&repo.url)),
|
|
conn,
|
|
)
|
|
.await;
|
|
}
|
|
clone_repo(
|
|
repo,
|
|
job_dir,
|
|
&job.id,
|
|
worker_name,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
&job.workspace_id,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow!(
|
|
"Failed to clone git repo `{}`: {e}",
|
|
sanitize_git_url(&repo.url)
|
|
)
|
|
})?;
|
|
}
|
|
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
format!(
|
|
"Cloned {} into {}\n",
|
|
sanitize_git_url(&repo.url),
|
|
&repo.target_path
|
|
),
|
|
conn,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
if let Some(collections) = r.roles_and_collections.as_ref() {
|
|
let empty = String::new();
|
|
let (lockfile, logs) = req_lockfiles
|
|
.as_ref()
|
|
.and_then(|r| {
|
|
if r.collections_and_roles.is_empty() {
|
|
None
|
|
} else {
|
|
Some((&r.collections_and_roles, &r.collections_and_roles_logs))
|
|
}
|
|
})
|
|
.unwrap_or((collections, &empty));
|
|
|
|
if !logs.is_empty() {
|
|
append_logs(&job.id, &job.workspace_id, logs, conn).await;
|
|
}
|
|
|
|
install_galaxy_collections(
|
|
lockfile,
|
|
job_dir,
|
|
&job.id,
|
|
worker_name,
|
|
&job.workspace_id,
|
|
mem_peak,
|
|
canceled_by,
|
|
conn,
|
|
occupancy_metrics,
|
|
git_ssh_cmd,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
append_logs(
|
|
&job.id,
|
|
&job.workspace_id,
|
|
"\n\n--- ANSIBLE PLAYBOOK EXECUTION ---\n".to_string(),
|
|
conn,
|
|
)
|
|
.await;
|
|
|
|
let vault_password_file_exists = match reqs.as_ref().and_then(|x| x.vault_password.as_ref()) {
|
|
Some(var_path) => {
|
|
let password = client.get_variable_value(&var_path).await?;
|
|
write_file(job_dir, WINDMILL_ANSIBLE_PASSWORD_FILENAME, &password)?;
|
|
true
|
|
}
|
|
None => false,
|
|
};
|
|
|
|
create_ansible_cfg(reqs.as_ref(), job_dir, vault_password_file_exists, &job.id)?;
|
|
let _control_path_guard = persistent_control_path_dir(&job.id).map(PersistentControlPathGuard);
|
|
|
|
// When the run delegates to a git repo that ships its own ansible.cfg, that
|
|
// file becomes the effective config (ansible loads exactly one config file and
|
|
// does not merge). These env vars layer Windmill's runtime-bound settings back
|
|
// on top — env vars outrank ansible.cfg. Only applied on the non-sandboxed
|
|
// path: git-repo delegation clones into `job_dir` which the nsjail profile does
|
|
// not mount, so it already requires DISABLE_NSJAIL.
|
|
let ansible_env_overrides = match ansible_config_override.as_ref() {
|
|
Some(cfg_path) => {
|
|
if is_sandboxing_enabled() {
|
|
tracing::warn!(
|
|
"delegate_to_git_repo.ansible_cfg is set but sandboxing is enabled; \
|
|
git-repo delegation requires DISABLE_NSJAIL, the ansible.cfg override \
|
|
will not take effect"
|
|
);
|
|
}
|
|
build_ansible_cfg_override_envs(
|
|
cfg_path,
|
|
job_dir,
|
|
vault_password_file_exists,
|
|
reqs.as_ref(),
|
|
&job.id,
|
|
&envs,
|
|
)
|
|
.await?
|
|
}
|
|
None => vec![],
|
|
};
|
|
|
|
let mut reserved_variables =
|
|
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
|
let additional_python_paths_folders = additional_python_paths.join(":");
|
|
|
|
if is_sandboxing_enabled() {
|
|
let shared_deps = additional_python_paths
|
|
.into_iter()
|
|
.map(|pp| {
|
|
format!(
|
|
r#"
|
|
mount {{
|
|
src: "{pp}"
|
|
dst: "{pp}"
|
|
is_bind: true
|
|
rw: false
|
|
}}
|
|
"#
|
|
)
|
|
})
|
|
.join("\n");
|
|
let nsjail_timeout =
|
|
resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await;
|
|
let _ = write_file(
|
|
job_dir,
|
|
"run.config.proto",
|
|
&NSJAIL_CONFIG_RUN_ANSIBLE_CONTENT
|
|
.replace(
|
|
"{RLIMIT_AS}",
|
|
&render_nsjail_rlimit_as(NSJAIL_ANSIBLE_RLIMIT_AS_MB.as_deref(), 4096),
|
|
)
|
|
.replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR)
|
|
.replace("{JOB_DIR}", job_dir)
|
|
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
|
|
.replace("{SHARED_MOUNT}", shared_mount)
|
|
.replace("{SHARED_DEPENDENCIES}", shared_deps.as_str())
|
|
.replace("{FILE_RESOURCES}", nsjail_extra_mounts.join("\n").as_str())
|
|
.replace(
|
|
"{ADDITIONAL_PYTHON_PATHS}",
|
|
additional_python_paths_folders.as_str(),
|
|
)
|
|
.replace(
|
|
"{TMP_MOUNT_BLOCK}",
|
|
&resolve_nsjail_tmp_mount_block(job_dir).await,
|
|
)
|
|
.replace("{TIMEOUT}", &nsjail_timeout),
|
|
)?;
|
|
} else {
|
|
reserved_variables.insert("PYTHONPATH".to_string(), additional_python_paths_folders);
|
|
}
|
|
|
|
let playbook = playbook_override
|
|
.flatten()
|
|
.unwrap_or("main.yml".to_string());
|
|
let mut cmd_args = vec![playbook.as_str(), "--extra-vars", "@args.json"];
|
|
cmd_args.extend(inventories.iter().map(|s| s.as_str()));
|
|
cmd_args.extend(cmd_options.iter().map(|s| s.as_str()));
|
|
|
|
let child = if is_sandboxing_enabled() {
|
|
let wrapper = format!(
|
|
r#"set -eou pipefail
|
|
{0} "$@"
|
|
if [ -f "result" ]; then
|
|
cat result > result_nsjail_mount.json
|
|
fi
|
|
if [ -f "result.json" ]; then
|
|
cat result.json > result_nsjail_mount.json
|
|
fi
|
|
"#,
|
|
ANSIBLE_PLAYBOOK_PATH.as_str()
|
|
);
|
|
|
|
let _file = write_file(job_dir, "wrapper.sh", &wrapper)?;
|
|
|
|
#[cfg(unix)]
|
|
_file.metadata()?.permissions().set_mode(0o777);
|
|
// let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
|
|
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
|
|
nsjail_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(PROXY_ENVS.clone())
|
|
// inject PYTHONPATH here - for some reason I had to do it in nsjail conf
|
|
.envs(reserved_variables)
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("BASE_INTERNAL_URL", base_internal_url)
|
|
.env("BASE_URL", base_internal_url)
|
|
.args(
|
|
vec![
|
|
"--config",
|
|
"run.config.proto",
|
|
"--",
|
|
BIN_BASH.as_str(),
|
|
"/tmp/wrapper.sh",
|
|
]
|
|
.into_iter()
|
|
.chain(cmd_args),
|
|
)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?
|
|
} else {
|
|
let ansible_args: Vec<&str> = cmd_args.iter().map(|s| s.as_ref()).collect();
|
|
let mut ansible_cmd =
|
|
build_command_with_isolation(ANSIBLE_PLAYBOOK_PATH.as_str(), &ansible_args);
|
|
ansible_cmd
|
|
.current_dir(job_dir)
|
|
.env_clear()
|
|
.envs(envs)
|
|
.envs(reserved_variables)
|
|
.env("PATH", PATH_ENV.as_str())
|
|
.env("TZ", TZ_ENV.as_str())
|
|
.env("BASE_INTERNAL_URL", base_internal_url)
|
|
.env("HOME", HOME_ENV.as_str())
|
|
.envs(ansible_env_overrides)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
#[cfg(windows)]
|
|
ansible_cmd.env("USERPROFILE", crate::USERPROFILE_ENV.as_str());
|
|
|
|
start_child_process(ansible_cmd, ANSIBLE_PLAYBOOK_PATH.as_str(), false).await?
|
|
};
|
|
|
|
handle_child(
|
|
&job.id,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
child,
|
|
is_sandboxing_enabled(),
|
|
worker_name,
|
|
&job.workspace_id,
|
|
"python run",
|
|
job.timeout,
|
|
false,
|
|
&mut Some(occupancy_metrics),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
read_and_check_result(job_dir).await
|
|
}
|
|
|
|
fn get_cmd_options(r: windmill_parser_yaml::AnsiblePlaybookOptions) -> Vec<String> {
|
|
let mut ret = vec![];
|
|
|
|
if let Some(v) = r.verbosity {
|
|
if v.chars().all(|c| c == 'v') && v.len() <= 6 {
|
|
ret.push(format!("-{}", v));
|
|
}
|
|
}
|
|
|
|
if let Some(o) = r.timeout {
|
|
ret.push("--timeout".to_string());
|
|
ret.push(o.to_string());
|
|
}
|
|
|
|
if let Some(o) = r.forks {
|
|
ret.push("--forks".to_string());
|
|
ret.push(o.to_string());
|
|
}
|
|
|
|
if r.flush_cache.is_some() {
|
|
ret.push("--flush-cache".to_string());
|
|
}
|
|
|
|
if r.force_handlers.is_some() {
|
|
ret.push("--force-handlers".to_string());
|
|
}
|
|
|
|
if let Some(limit) = r.limit {
|
|
ret.push("--limit".to_string());
|
|
ret.push(limit);
|
|
}
|
|
|
|
ret
|
|
}
|
|
|
|
fn define_nsjail_mount(job_dir: &str, path: &PathBuf) -> anyhow::Result<String> {
|
|
Ok(format!(
|
|
r#"
|
|
mount {{
|
|
src: "{0}/{1}"
|
|
dst: "/tmp/{1}"
|
|
is_bind: true
|
|
rw: false
|
|
mandatory: false
|
|
}}
|
|
"#,
|
|
job_dir,
|
|
path.strip_prefix(job_dir)?
|
|
.to_str()
|
|
.ok_or(anyhow!("Invalid path."))?
|
|
))
|
|
}
|
|
|
|
async fn create_file_resources(
|
|
job_id: &Uuid,
|
|
w_id: &str,
|
|
job_dir: &str,
|
|
args: Option<&HashMap<String, Box<RawValue>>>,
|
|
r: &AnsibleRequirements,
|
|
client: &AuthedClient,
|
|
conn: &Connection,
|
|
) -> error::Result<Vec<String>> {
|
|
let mut logs = String::new();
|
|
let mut nsjail_mounts: Vec<String> = vec![];
|
|
|
|
for inventory in &r.inventories {
|
|
let content;
|
|
if let Some(resource_path) = &inventory.pinned_resource {
|
|
content = client
|
|
.get_resource_value_interpolated::<serde_json::Value>(
|
|
resource_path,
|
|
Some(job_id.to_string()),
|
|
)
|
|
.await?;
|
|
} else {
|
|
let o = args
|
|
.as_ref()
|
|
.and_then(|g| g.get(&inventory.name))
|
|
.ok_or(anyhow!(
|
|
"Specified inventory was missing in the script arguments"
|
|
))?;
|
|
|
|
content = serde_json::from_str(o.get())
|
|
.map_err(|e| anyhow!("Failed to parse inventory arg: {}", e))?;
|
|
|
|
if content == serde_json::value::Value::Null {
|
|
Err(anyhow!("The inventory argument was left empty. If you do not wish to specify an inventory for this script, remove the `inventory:` section from the yaml."))?;
|
|
}
|
|
}
|
|
|
|
let validated_path = write_file_at_user_defined_location(
|
|
job_dir,
|
|
&inventory.name,
|
|
content
|
|
.get("content")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or(anyhow!(
|
|
"Invalid inventory resource, `content` field absent or invalid"
|
|
))?,
|
|
None,
|
|
)
|
|
.map_err(|e| anyhow!("Couldn't write inventory: {}", e))?;
|
|
|
|
nsjail_mounts.push(
|
|
define_nsjail_mount(job_dir, &validated_path)
|
|
.map_err(|e| anyhow!("Inventory path (a.k.a. `name`) is invalid: {}", e))?,
|
|
);
|
|
|
|
logs.push_str(&format!("\nCreated inventory `{}`", inventory.name));
|
|
}
|
|
|
|
for file_res in &r.file_resources {
|
|
let r =
|
|
get_resource_or_variable_content(client, &file_res.resource_path, job_id.to_string())
|
|
.await?;
|
|
let path = file_res.target_path.clone();
|
|
let validated_path =
|
|
write_file_at_user_defined_location(job_dir, path.as_str(), &r, file_res.mode)
|
|
.map_err(|e| anyhow!("Couldn't write text file at {}: {}", path, e))?;
|
|
|
|
nsjail_mounts.push(
|
|
define_nsjail_mount(job_dir, &validated_path)
|
|
.map_err(|e| anyhow!("File resource path is invalid: {}", e))?,
|
|
);
|
|
|
|
logs.push_str(&format!(
|
|
"\nCreated {} from {:?}",
|
|
file_res.target_path, file_res.resource_path
|
|
));
|
|
}
|
|
append_logs(job_id, w_id, logs, conn).await;
|
|
|
|
Ok(nsjail_mounts)
|
|
}
|
|
|
|
async fn get_resource_or_variable_content(
|
|
client: &AuthedClient,
|
|
path: &ResourceOrVariablePath,
|
|
job_id: String,
|
|
) -> anyhow::Result<String> {
|
|
Ok(match path {
|
|
ResourceOrVariablePath::Resource(p) => {
|
|
let r = client
|
|
.get_resource_value_interpolated::<serde_json::Value>(&p, Some(job_id))
|
|
.await?;
|
|
|
|
r.get("content")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or(anyhow!(
|
|
"Invalid text file resource {}, `content` field absent or invalid",
|
|
p
|
|
))?
|
|
.to_string()
|
|
}
|
|
ResourceOrVariablePath::Variable(p) => client.get_variable_value(&p).await?,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn no_job_envs() -> HashMap<String, String> {
|
|
HashMap::new()
|
|
}
|
|
|
|
fn args_from_json(v: serde_json::Value) -> HashMap<String, Box<RawValue>> {
|
|
let serde_json::Value::Object(map) = v else {
|
|
panic!("expected object");
|
|
};
|
|
map.into_iter()
|
|
.map(|(k, v)| (k, RawValue::from_string(v.to_string()).unwrap()))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_template_string() {
|
|
let args = args_from_json(serde_json::json!({"playbook": "site.yml"}));
|
|
let out =
|
|
interpolate_template("playbooks/{{ playbook }}", Some(&args), "playbook").unwrap();
|
|
assert_eq!(out, "playbooks/site.yml");
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_template_number_and_bool() {
|
|
let args = args_from_json(serde_json::json!({"n": 42, "b": true}));
|
|
let out = interpolate_template("{{ n }}-{{ b }}", Some(&args), "x").unwrap();
|
|
assert_eq!(out, "42-true");
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_template_no_placeholders() {
|
|
let out = interpolate_template("plain.yml", None, "playbook").unwrap();
|
|
assert_eq!(out, "plain.yml");
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_template_missing_arg_errors() {
|
|
let args = args_from_json(serde_json::json!({}));
|
|
let err = interpolate_template("{{ missing }}", Some(&args), "playbook").unwrap_err();
|
|
assert!(err.to_string().contains("missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_template_object_arg_errors() {
|
|
let args = args_from_json(serde_json::json!({"o": {"k": "v"}}));
|
|
let err = interpolate_template("{{ o }}", Some(&args), "x").unwrap_err();
|
|
assert!(err.to_string().contains("not a primitive"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_relative_path_ok() {
|
|
validate_relative_path("playbooks/site.yml", "playbook").unwrap();
|
|
validate_relative_path("./site.yml", "playbook").unwrap();
|
|
validate_relative_path("a/b/c.yml", "playbook").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_relative_path_rejects_absolute() {
|
|
// `/etc/passwd` isn't `is_absolute()` on Windows (no drive prefix), but
|
|
// its leading RootDir still escapes the cloned repo, so reject it on
|
|
// every platform.
|
|
assert!(validate_relative_path("/etc/passwd", "playbook").is_err());
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn test_validate_relative_path_rejects_windows_absolute() {
|
|
assert!(validate_relative_path("\\etc\\passwd", "playbook").is_err());
|
|
assert!(validate_relative_path("C:\\Windows\\System32", "playbook").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_relative_path_rejects_parent_dir() {
|
|
assert!(validate_relative_path("../escape.yml", "playbook").is_err());
|
|
assert!(validate_relative_path("a/../../escape.yml", "playbook").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_relative_path_rejects_empty() {
|
|
assert!(validate_relative_path("", "playbook").is_err());
|
|
assert!(validate_relative_path(" ", "playbook").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_ansible_cfg_writes_valid_vault_id() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let reqs = AnsibleRequirements {
|
|
vault_id: vec!["dev@vault_pass.txt".to_string()],
|
|
..Default::default()
|
|
};
|
|
create_ansible_cfg(Some(&reqs), job_dir, false, &Uuid::new_v4()).unwrap();
|
|
let cfg = std::fs::read_to_string(dir.path().join("ansible.cfg")).unwrap();
|
|
assert!(cfg.contains("vault_identity_list = dev@vault_pass.txt"));
|
|
assert!(!cfg.contains("library"));
|
|
}
|
|
|
|
/// The socket ansible binds under `control_path_dir` must fit `sun_path` (107
|
|
/// usable bytes), which the job dir alone blows past — hence a short dir outside
|
|
/// `ANSIBLE_HOME`.
|
|
#[test]
|
|
fn test_create_ansible_cfg_control_path_dir_fits_af_unix_limit() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let job_id = Uuid::new_v4();
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
create_ansible_cfg(None, job_dir, false, &job_id).unwrap();
|
|
|
|
let cfg = std::fs::read_to_string(dir.path().join("ansible.cfg")).unwrap();
|
|
let control_path_dir = persistent_control_path_dir(&job_id).unwrap();
|
|
assert!(cfg.contains("[persistent_connection]"));
|
|
assert!(cfg.contains(&format!("control_path_dir = {control_path_dir}")));
|
|
// The whole point: the socket dir must escape the job dir, whose length is what
|
|
// blows the budget.
|
|
assert!(!control_path_dir.starts_with(job_dir));
|
|
|
|
// dir + `/` + socket name, budgeted at a full 40-char sha1 (ansible truncates
|
|
// it far shorter today, but a custom control path may not).
|
|
assert!(
|
|
control_path_dir.len() + 1 + 40 <= AF_UNIX_PATH_LIMIT,
|
|
"socket path would exceed sun_path: {control_path_dir}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_ansible_cfg_rejects_vault_id_injection() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let reqs = AnsibleRequirements {
|
|
vault_id: vec!["default@/tmp/wm/x\nlibrary = /tmp/wm/evil_modules".to_string()],
|
|
..Default::default()
|
|
};
|
|
// Defense-in-depth boundary: a poisoned entry must error before any config is written.
|
|
assert!(create_ansible_cfg(Some(&reqs), job_dir, false, &Uuid::new_v4()).is_err());
|
|
assert!(!dir.path().join("ansible.cfg").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_ansible_cfg_path_list() {
|
|
let cfg = "\
|
|
[defaults]
|
|
roles_path = roles:extra/roles
|
|
collections_path=/opt/collections
|
|
host_key_checking = False
|
|
|
|
[inventory]
|
|
roles_path = ignored/section
|
|
";
|
|
assert_eq!(
|
|
parse_ansible_cfg_path_list(cfg, "roles_path"),
|
|
Some(vec!["roles".to_string(), "extra/roles".to_string()])
|
|
);
|
|
assert_eq!(
|
|
parse_ansible_cfg_path_list(cfg, "collections_path"),
|
|
Some(vec!["/opt/collections".to_string()])
|
|
);
|
|
// Keys only in another section are not picked up.
|
|
assert_eq!(parse_ansible_cfg_path_list(cfg, "library"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_ansible_cfg_path_list_ignores_comments() {
|
|
let cfg = "\
|
|
[defaults]
|
|
# roles_path = commented
|
|
; roles_path = also_commented
|
|
";
|
|
assert_eq!(parse_ansible_cfg_path_list(cfg, "roles_path"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_ansible_cfg_path_list_colon_delimiter() {
|
|
// configparser accepts `:` as a key/value delimiter, and the value can
|
|
// itself be a `:`-separated list.
|
|
let cfg = "\
|
|
[defaults]
|
|
roles_path: my_roles
|
|
collections_path : a/col:b/col
|
|
";
|
|
assert_eq!(
|
|
parse_ansible_cfg_path_list(cfg, "roles_path"),
|
|
Some(vec!["my_roles".to_string()])
|
|
);
|
|
assert_eq!(
|
|
parse_ansible_cfg_path_list(cfg, "collections_path"),
|
|
Some(vec!["a/col".to_string(), "b/col".to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_and_prepend_path() {
|
|
// No repo entries: only Windmill's install dir.
|
|
assert_eq!(
|
|
resolve_and_prepend_path("/job/roles".to_string(), None, "/job/repo"),
|
|
"/job/roles"
|
|
);
|
|
// Relative repo entries resolve against the cfg dir; absolute/~ kept as-is.
|
|
assert_eq!(
|
|
resolve_and_prepend_path(
|
|
"/job/roles".to_string(),
|
|
Some(vec![
|
|
"roles".to_string(),
|
|
"/abs/roles".to_string(),
|
|
"~/r".to_string()
|
|
]),
|
|
"/job/repo/config"
|
|
),
|
|
"/job/roles:/job/repo/config/roles:/abs/roles:~/r"
|
|
);
|
|
}
|
|
|
|
fn ansible_playbook_available() -> bool {
|
|
std::process::Command::new("ansible-playbook")
|
|
.arg("--version")
|
|
.output()
|
|
.is_ok()
|
|
}
|
|
|
|
/// End-to-end: with a delegated repo that ships its own `ansible.cfg` pointing
|
|
/// `roles_path` at an in-repo directory, the override env vars must make the
|
|
/// real `ansible-playbook` resolve a role it otherwise cannot. Requires the
|
|
/// `ansible-playbook` binary; self-skips when absent (e.g. standard CI). Run on
|
|
/// a worker devbox with `cargo test -p windmill-worker --features python`.
|
|
#[tokio::test]
|
|
async fn test_ansible_cfg_override_resolves_repo_roles_e2e() {
|
|
if !ansible_playbook_available() {
|
|
eprintln!(
|
|
"SKIP test_ansible_cfg_override_resolves_repo_roles_e2e: ansible-playbook not found on PATH"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let repo = dir.path().join(DELEGATE_GIT_REPO_TARGET);
|
|
let role_tasks = repo.join("my_roles/greet/tasks");
|
|
std::fs::create_dir_all(&role_tasks).unwrap();
|
|
|
|
std::fs::write(
|
|
repo.join("ansible.cfg"),
|
|
"[defaults]\nroles_path = my_roles\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
role_tasks.join("main.yml"),
|
|
"- debug:\n msg: \"hello from greet role\"\n",
|
|
)
|
|
.unwrap();
|
|
let play = "- hosts: localhost\n connection: local\n gather_facts: false\n roles:\n - greet\n";
|
|
std::fs::write(repo.join("play.yml"), play).unwrap();
|
|
|
|
// Windmill's own generated cfg (the negative-control config that exists today).
|
|
create_ansible_cfg(None, job_dir, false, &Uuid::new_v4()).unwrap();
|
|
|
|
let playbook = format!("{DELEGATE_GIT_REPO_TARGET}/play.yml");
|
|
let run = |envs: Vec<(String, String)>| {
|
|
std::process::Command::new("ansible-playbook")
|
|
.arg(&playbook)
|
|
.current_dir(job_dir)
|
|
.envs(envs)
|
|
.output()
|
|
.unwrap()
|
|
};
|
|
|
|
// Negative control: today's behavior (Windmill cfg via cwd, no override) —
|
|
// the role lives in the repo subdir and is not found.
|
|
let cfg_path = repo.join("ansible.cfg");
|
|
let before = run(vec![]);
|
|
assert!(
|
|
!before.status.success(),
|
|
"without the override the repo role must NOT resolve; stdout={}",
|
|
String::from_utf8_lossy(&before.stdout)
|
|
);
|
|
|
|
// With the override: ANSIBLE_CONFIG points at the repo cfg and roles_path
|
|
// is honored, so the role runs.
|
|
let envs = build_ansible_cfg_override_envs(
|
|
cfg_path.to_str().unwrap(),
|
|
job_dir,
|
|
false,
|
|
None,
|
|
&Uuid::new_v4(),
|
|
&no_job_envs(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let after = run(envs);
|
|
let stdout = String::from_utf8_lossy(&after.stdout);
|
|
assert!(
|
|
after.status.success() && stdout.contains("hello from greet role"),
|
|
"with the override the repo role must resolve; status={:?} stdout={stdout} stderr={}",
|
|
after.status,
|
|
String::from_utf8_lossy(&after.stderr)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_ansible_cfg_override_envs() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let repo_dir = dir.path().join("delegate_git_repository");
|
|
std::fs::create_dir_all(&repo_dir).unwrap();
|
|
let cfg_path = repo_dir.join("ansible.cfg");
|
|
std::fs::write(&cfg_path, "[defaults]\nroles_path = my_roles\n").unwrap();
|
|
let cfg_path = cfg_path.to_str().unwrap();
|
|
|
|
let reqs = AnsibleRequirements {
|
|
vault_id: vec!["dev@vault_pass.txt".to_string()],
|
|
..Default::default()
|
|
};
|
|
let job_id = Uuid::new_v4();
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
let envs = build_ansible_cfg_override_envs(
|
|
cfg_path,
|
|
job_dir,
|
|
true,
|
|
Some(&reqs),
|
|
&job_id,
|
|
&no_job_envs(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let map: std::collections::HashMap<_, _> = envs.into_iter().collect();
|
|
|
|
assert_eq!(
|
|
map.get("ANSIBLE_CONFIG").map(|s| s.as_str()),
|
|
Some(cfg_path)
|
|
);
|
|
// The repo cfg declares no control_path_dir, so Windmill's short default applies.
|
|
assert_eq!(
|
|
map.get("ANSIBLE_PERSISTENT_CONTROL_PATH_DIR"),
|
|
persistent_control_path_dir(&job_id).as_ref()
|
|
);
|
|
assert_eq!(
|
|
map.get("ANSIBLE_HOME"),
|
|
Some(&format!("{job_dir}/.ansible"))
|
|
);
|
|
assert_eq!(
|
|
map.get("ANSIBLE_VAULT_PASSWORD_FILE"),
|
|
Some(&format!("{job_dir}/{WINDMILL_ANSIBLE_PASSWORD_FILENAME}"))
|
|
);
|
|
assert_eq!(
|
|
map.get("ANSIBLE_VAULT_IDENTITY_LIST").map(|s| s.as_str()),
|
|
Some("dev@vault_pass.txt")
|
|
);
|
|
// Windmill's `{job_dir}/roles` is prepended to the repo cfg's own path,
|
|
// which is resolved against the cfg directory.
|
|
assert_eq!(
|
|
map.get("ANSIBLE_ROLES_PATH"),
|
|
Some(&format!(
|
|
"{job_dir}/roles:{}/my_roles",
|
|
repo_dir.to_str().unwrap()
|
|
))
|
|
);
|
|
// No collections_path in the repo cfg → only Windmill's job dir.
|
|
assert_eq!(
|
|
map.get("ANSIBLE_COLLECTIONS_PATH").map(|s| s.as_str()),
|
|
Some(job_dir)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_ansible_cfg_override_envs_collections_paths_alias() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let repo_dir = dir.path().join("delegate_git_repository");
|
|
std::fs::create_dir_all(&repo_dir).unwrap();
|
|
let cfg_path = repo_dir.join("ansible.cfg");
|
|
// Deprecated plural alias must still be picked up so the repo's collections
|
|
// are not silently dropped when the env override replaces the cfg value.
|
|
std::fs::write(&cfg_path, "[defaults]\ncollections_paths = my_cols\n").unwrap();
|
|
|
|
let envs = build_ansible_cfg_override_envs(
|
|
cfg_path.to_str().unwrap(),
|
|
job_dir,
|
|
false,
|
|
None,
|
|
&Uuid::new_v4(),
|
|
&no_job_envs(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let map: std::collections::HashMap<_, _> = envs.into_iter().collect();
|
|
assert_eq!(
|
|
map.get("ANSIBLE_COLLECTIONS_PATH"),
|
|
Some(&format!("{job_dir}:{}/my_cols", repo_dir.to_str().unwrap()))
|
|
);
|
|
}
|
|
|
|
/// These overrides are applied after the job's own env, so a default that ignores what
|
|
/// the job set would silently outrank it. Not runtime-bound, so the job wins.
|
|
#[tokio::test]
|
|
async fn test_build_ansible_cfg_override_envs_keeps_job_env_control_path_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let repo_dir = dir.path().join(DELEGATE_GIT_REPO_TARGET);
|
|
std::fs::create_dir_all(&repo_dir).unwrap();
|
|
let cfg_path = repo_dir.join("ansible.cfg");
|
|
// Cfg is silent on control_path_dir; the job env is not.
|
|
std::fs::write(&cfg_path, "[defaults]\nroles_path = my_roles\n").unwrap();
|
|
|
|
let job_envs = HashMap::from([(
|
|
ANSIBLE_CONTROL_PATH_DIR_ENV.to_string(),
|
|
"/tmp/job-picked".to_string(),
|
|
)]);
|
|
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
let envs = build_ansible_cfg_override_envs(
|
|
cfg_path.to_str().unwrap(),
|
|
job_dir,
|
|
false,
|
|
None,
|
|
&Uuid::new_v4(),
|
|
&job_envs,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
!envs.iter().any(|(k, _)| k == ANSIBLE_CONTROL_PATH_DIR_ENV),
|
|
"must not override a control_path_dir the job set itself"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_ansible_cfg_override_envs_keeps_user_control_path_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
let repo_dir = dir.path().join(DELEGATE_GIT_REPO_TARGET);
|
|
std::fs::create_dir_all(&repo_dir).unwrap();
|
|
let cfg_path = repo_dir.join("ansible.cfg");
|
|
std::fs::write(
|
|
&cfg_path,
|
|
"[defaults]\nroles_path = my_roles\n\n[persistent_connection]\ncontrol_path_dir = /tmp/my_pc\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let envs = build_ansible_cfg_override_envs(
|
|
cfg_path.to_str().unwrap(),
|
|
job_dir,
|
|
false,
|
|
None,
|
|
&Uuid::new_v4(),
|
|
&no_job_envs(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let map: std::collections::HashMap<_, _> = envs.into_iter().collect();
|
|
assert_eq!(map.get("ANSIBLE_PERSISTENT_CONTROL_PATH_DIR"), None);
|
|
}
|
|
|
|
/// `SOCKET_ROOT_TRUSTED` is process-global and cargo runs tests in parallel: hold this
|
|
/// while reading or flipping it, and the default is restored on the way out.
|
|
struct TrustFlag(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
|
|
|
impl TrustFlag {
|
|
fn lock() -> Self {
|
|
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
Self(LOCK.lock().unwrap_or_else(|e| e.into_inner()))
|
|
}
|
|
fn set(&self, trusted: bool) {
|
|
SOCKET_ROOT_TRUSTED.store(trusted, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
fn get(&self) -> bool {
|
|
SOCKET_ROOT_TRUSTED.load(std::sync::atomic::Ordering::Relaxed)
|
|
}
|
|
}
|
|
|
|
impl Drop for TrustFlag {
|
|
fn drop(&mut self) {
|
|
SOCKET_ROOT_TRUSTED.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn backdate(path: &std::path::Path, age: std::time::Duration) {
|
|
let times = std::fs::FileTimes::new().set_modified(std::time::SystemTime::now() - age);
|
|
std::fs::File::open(path).unwrap().set_times(times).unwrap();
|
|
}
|
|
|
|
/// The sweep only reaps what no live job can own: a play may hold its socket dir for
|
|
/// the whole of MAX_TIMEOUT without touching the mtime again. And it only ever touches
|
|
/// names it could have created itself.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_sweeps_only_stale_dirs() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path().join("wm-pc");
|
|
std::fs::create_dir(&root).unwrap();
|
|
|
|
let stale = root.join(Uuid::new_v4().simple().to_string());
|
|
let live = root.join(Uuid::new_v4().simple().to_string());
|
|
let foreign = root.join("someone-elses-data");
|
|
for p in [&stale, &live, &foreign] {
|
|
std::fs::create_dir(p).unwrap();
|
|
}
|
|
backdate(&stale, std::time::Duration::from_secs(48 * 60 * 60));
|
|
backdate(&live, std::time::Duration::from_secs(12 * 60 * 60));
|
|
backdate(&foreign, std::time::Duration::from_secs(48 * 60 * 60));
|
|
|
|
let _flag = TrustFlag::lock();
|
|
prepare_socket_root(
|
|
root.to_str().unwrap(),
|
|
std::time::Duration::from_secs(24 * 60 * 60),
|
|
)
|
|
.await;
|
|
|
|
assert!(!stale.exists(), "dir older than the cutoff must be reaped");
|
|
assert!(live.exists(), "a dir a live job may still own must be kept");
|
|
assert!(
|
|
foreign.exists(),
|
|
"a stale dir we never created must be left alone"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn test_is_persistent_control_path_dir_name() {
|
|
assert!(is_persistent_control_path_dir_name(
|
|
&Uuid::new_v4().simple().to_string()
|
|
));
|
|
// Hyphenated form is not what we create, so it is not ours to delete.
|
|
assert!(!is_persistent_control_path_dir_name(
|
|
&Uuid::new_v4().to_string()
|
|
));
|
|
assert!(!is_persistent_control_path_dir_name("someone-elses-data"));
|
|
assert!(!is_persistent_control_path_dir_name(""));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_creates_root_private() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path().join("wm-pc");
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
prepare_socket_root(root.to_str().unwrap(), std::time::Duration::from_secs(1)).await;
|
|
|
|
let meta = std::fs::metadata(&root).unwrap();
|
|
assert!(meta.is_dir());
|
|
// Owning the root 0700 is what stops another local user replacing it later.
|
|
assert_eq!(meta.permissions().mode() & 0o777, 0o700);
|
|
assert!(flag.get(), "a root we created ourselves is trusted");
|
|
}
|
|
|
|
/// The root must be validated *after* the create attempt, not before: under a sticky
|
|
/// parent another uid may still win the race to create the not-yet-existing `pc`
|
|
/// (sticky stops them renaming ours away, not creating it first), and a create that
|
|
/// tolerates `AlreadyExists` would otherwise hand us their directory unchecked.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_validates_raced_creation() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let parent = dir.path().join("windmill");
|
|
std::fs::create_dir(&parent).unwrap();
|
|
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o1777)).unwrap();
|
|
|
|
// Stand in for the racer's dir: present before we look, and not exclusively ours.
|
|
let root = parent.join("pc");
|
|
std::fs::create_dir(&root).unwrap();
|
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap();
|
|
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
prepare_socket_root(root.to_str().unwrap(), std::time::Duration::from_secs(1)).await;
|
|
|
|
assert!(
|
|
!flag.get(),
|
|
"a root raced into place under a sticky parent must not be trusted"
|
|
);
|
|
}
|
|
|
|
/// Safe but unusable is still not trusted: ansible cannot create its per-job dir under
|
|
/// a root we cannot write, and naming it anyway would swap the working fallback for a
|
|
/// permission error on every network playbook.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_refuses_unwritable_root() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path().join("wm-pc");
|
|
std::fs::create_dir(&root).unwrap();
|
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
prepare_socket_root(root.to_str().unwrap(), std::time::Duration::from_secs(1)).await;
|
|
|
|
assert!(!flag.get(), "a root we cannot write must not be trusted");
|
|
// Let the tempdir clean itself up.
|
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
}
|
|
|
|
/// A root we do not exclusively own may have been pre-planted by another local user,
|
|
/// who then controls the parent of every job's socket dir — and could swap a symlink
|
|
/// in after this check, redirecting the sweep's path-based `remove_dir_all`.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_refuses_world_writable_root() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path().join("wm-pc");
|
|
std::fs::create_dir(&root).unwrap();
|
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap();
|
|
|
|
// UUID-named, so survival proves the trust check stopped the sweep rather than the
|
|
// name filter.
|
|
let stale = root.join(Uuid::new_v4().simple().to_string());
|
|
std::fs::create_dir(&stale).unwrap();
|
|
backdate(&stale, std::time::Duration::from_secs(48 * 60 * 60));
|
|
|
|
let _flag = TrustFlag::lock();
|
|
prepare_socket_root(
|
|
root.to_str().unwrap(),
|
|
std::time::Duration::from_secs(24 * 60 * 60),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
stale.exists(),
|
|
"must not sweep a root that others can write to"
|
|
);
|
|
}
|
|
|
|
/// The root must hang off `/tmp`, whose sticky bit is what protects it. The trap this
|
|
/// guards: the shipped image chmods the whole `WINDMILL_DIR` tree to a non-sticky 0777
|
|
/// so any UID can write it, so parenting the root there would make it untrusted and
|
|
/// silently disable this fix in the standard image while every local test still passed.
|
|
#[test]
|
|
fn test_control_path_root_hangs_off_tmp() {
|
|
assert_eq!(
|
|
std::path::Path::new(PERSISTENT_CONTROL_PATH_ROOT).parent(),
|
|
Some(std::path::Path::new("/tmp"))
|
|
);
|
|
}
|
|
|
|
/// A parent that others can write (and that is not sticky) lets them rename the root
|
|
/// away and drop a symlink in its place after the checks — so the root cannot be
|
|
/// trusted no matter how it currently looks.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_refuses_writable_parent() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let parent = dir.path().join("windmill");
|
|
let root = parent.join("pc");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
// UUID-named, so survival proves the trust check stopped the sweep rather than the
|
|
// name filter.
|
|
let stale = root.join(Uuid::new_v4().simple().to_string());
|
|
std::fs::create_dir(&stale).unwrap();
|
|
backdate(&stale, std::time::Duration::from_secs(48 * 60 * 60));
|
|
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o777)).unwrap();
|
|
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
prepare_socket_root(
|
|
root.to_str().unwrap(),
|
|
std::time::Duration::from_secs(24 * 60 * 60),
|
|
)
|
|
.await;
|
|
|
|
assert!(stale.exists(), "must not sweep under a replaceable parent");
|
|
assert!(
|
|
!flag.get(),
|
|
"an untrusted root must be marked so jobs stop naming it"
|
|
);
|
|
}
|
|
|
|
/// A sticky parent (like /tmp itself) is fine: the sticky bit is what stops a
|
|
/// non-owner renaming our root out of it.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_accepts_sticky_world_writable_parent() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let parent = dir.path().join("windmill");
|
|
let root = parent.join("pc");
|
|
std::fs::create_dir_all(&parent).unwrap();
|
|
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o1777)).unwrap();
|
|
|
|
let flag = TrustFlag::lock();
|
|
flag.set(true);
|
|
prepare_socket_root(
|
|
root.to_str().unwrap(),
|
|
std::time::Duration::from_secs(24 * 60 * 60),
|
|
)
|
|
.await;
|
|
|
|
assert!(root.is_dir(), "root must be created under a sticky parent");
|
|
assert!(flag.get());
|
|
}
|
|
|
|
/// Fail closed: when the root is untrusted the cfg must not name it, so ansible falls
|
|
/// back to its own `{ANSIBLE_HOME}/pc` default inside the worker-owned job dir.
|
|
#[test]
|
|
fn test_create_ansible_cfg_omits_untrusted_control_path_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let job_dir = dir.path().to_str().unwrap();
|
|
|
|
let flag = TrustFlag::lock();
|
|
flag.set(false);
|
|
create_ansible_cfg(None, job_dir, false, &Uuid::new_v4()).unwrap();
|
|
|
|
let cfg = std::fs::read_to_string(dir.path().join("ansible.cfg")).unwrap();
|
|
assert!(!cfg.contains("control_path_dir"));
|
|
assert!(!cfg.contains("[persistent_connection]"));
|
|
}
|
|
|
|
/// A symlinked root must never be swept: `remove_dir_all` through it would delete
|
|
/// whatever the link points at, as the worker's uid.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn test_prepare_socket_root_refuses_symlinked_root() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let victim = dir.path().join("victim");
|
|
// UUID-named, so survival proves the symlink was not followed rather than the name
|
|
// filter sparing it.
|
|
let victim_child = victim.join(Uuid::new_v4().simple().to_string());
|
|
std::fs::create_dir_all(&victim_child).unwrap();
|
|
backdate(&victim_child, std::time::Duration::from_secs(48 * 60 * 60));
|
|
|
|
let root = dir.path().join("wm-pc");
|
|
std::os::unix::fs::symlink(&victim, &root).unwrap();
|
|
|
|
let _flag = TrustFlag::lock();
|
|
prepare_socket_root(
|
|
root.to_str().unwrap(),
|
|
std::time::Duration::from_secs(24 * 60 * 60),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
victim_child.exists(),
|
|
"sweep must not follow a symlinked root"
|
|
);
|
|
}
|
|
|
|
/// configparser matches `\[(?P<header>.+)\]` without anchoring the end of the line, so
|
|
/// a header with anything trailing it is still that section — and missing it here
|
|
/// would silently override the user's own control_path_dir.
|
|
#[test]
|
|
fn test_ansible_cfg_section_header_with_trailing_text() {
|
|
assert_eq!(
|
|
parse_ansible_cfg_section_header("[persistent_connection] ; note"),
|
|
Some("persistent_connection")
|
|
);
|
|
assert_eq!(parse_ansible_cfg_section_header("not a header"), None);
|
|
// Greedy `.+` runs to the last `]`.
|
|
assert_eq!(parse_ansible_cfg_section_header("[a]b]"), Some("a]b"));
|
|
|
|
assert!(ansible_cfg_declares(
|
|
"[persistent_connection] ; note\ncontrol_path_dir = /tmp/mine\n",
|
|
"persistent_connection",
|
|
"control_path_dir"
|
|
));
|
|
assert_eq!(
|
|
parse_ansible_cfg_path_list("[defaults] # note\nroles_path = my_roles\n", "roles_path"),
|
|
Some(vec!["my_roles".to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ansible_cfg_declares_scoped_to_section() {
|
|
let cfg = "\
|
|
[defaults]
|
|
control_path_dir = /wrong/section
|
|
|
|
[persistent_connection]
|
|
# control_path_dir = /commented
|
|
connect_timeout = 30
|
|
";
|
|
assert!(!ansible_cfg_declares(
|
|
cfg,
|
|
"persistent_connection",
|
|
"control_path_dir"
|
|
));
|
|
assert!(ansible_cfg_declares(
|
|
cfg,
|
|
"persistent_connection",
|
|
"connect_timeout"
|
|
));
|
|
}
|
|
}
|