Skip to content

Commit 0a2f6de

Browse files
committed
support sudo password env for runtime sudo
1 parent 7d5007a commit 0a2f6de

12 files changed

Lines changed: 227 additions & 5 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ automax run \
107107
--state-dir /var/lib/automax/runs
108108
```
109109

110+
When selected remote substeps use `sudo` and the target account requires a password, keep sudo authentication explicit instead of configuring NOPASSWD on the target:
111+
112+
```bash
113+
export AUTOMAX_SUDO_PASSWORD='...'
114+
automax run --job job.yaml --inventory inventory.yaml --sudo-password-env AUTOMAX_SUDO_PASSWORD
115+
```
116+
110117
Inspect an existing run:
111118

112119
```bash
@@ -119,6 +126,8 @@ Resume a failed run:
119126

120127
```bash
121128
automax resume <run-id> --state-dir /var/lib/automax/runs
129+
# If rerun substeps need sudo authentication:
130+
automax resume <run-id> --state-dir /var/lib/automax/runs --sudo-password-env AUTOMAX_SUDO_PASSWORD
122131
```
123132

124133
Resume from an explicit checkpoint:

docs/guides/first-ssh-job.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,18 @@ automax plan --job job.yaml --inventory inventory.yaml --secrets secrets.yaml
8383
automax run --job job.yaml --inventory inventory.yaml --secrets secrets.yaml
8484
```
8585

86+
If the job uses sudo-enabled remote plugins and the remote account requires a sudo password, export it on the controller and pass only the environment variable name:
87+
88+
```bash
89+
export AUTOMAX_SUDO_PASSWORD='...'
90+
automax run --job job.yaml --inventory inventory.yaml --secrets secrets.yaml --sudo-password-env AUTOMAX_SUDO_PASSWORD
91+
```
92+
8693
If the run fails, inspect and resume:
8794

8895
```bash
8996
automax runs show <run-id>
9097
automax resume <run-id> --skip-successful
98+
# If resumed substeps need sudo authentication:
99+
automax resume <run-id> --skip-successful --sudo-password-env AUTOMAX_SUDO_PASSWORD
91100
```

docs/plugins/linux-operations.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,15 @@ Automax can derive remote command dependencies from the selected job plan and re
245245
automax capabilities requirements --job jobs/site.yaml --inventory inventory/prod.yaml
246246
```
247247

248-
Normal `automax run` performs this OS detection and capability preflight implicitly before executing selected substeps. The older `--preflight-capabilities` flag remains accepted for compatibility, but the preflight is now the default for normal runs that require remote tools.
248+
Normal `automax run` performs this OS detection and capability preflight implicitly before executing selected substeps. The older `--preflight-capabilities` flag remains accepted for compatibility, but the preflight is now the default for normal runs that require remote tools. If selected substeps use `sudo` and the target account requires a password, run with `--sudo-password-env ENV_NAME` instead of installing a NOPASSWD sudoers drop-in.
249+
250+
```bash
251+
export AUTOMAX_SUDO_PASSWORD='...'
252+
automax run \
253+
--job jobs/site.yaml \
254+
--inventory inventory/prod.yaml \
255+
--sudo-password-env AUTOMAX_SUDO_PASSWORD
256+
```
249257

250258
Missing dependencies can be installed per target from the OS-aware requirement plan:
251259

docs/plugins/security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ paths.
103103

104104
## Capability preflight and redaction policy
105105

106-
Use `capabilities requirements` to derive remote tool requirements from the selected job plan after detecting each target OS family. Normal `automax run` performs the same OS-aware capability preflight implicitly before executing remote tools. Use `capabilities install --sudo-password-env ENV_NAME` to install only missing dependency packages for the selected job and target OS.
106+
Use `capabilities requirements` to derive remote tool requirements from the selected job plan after detecting each target OS family. Normal `automax run` performs the same OS-aware capability preflight implicitly before executing remote tools. When selected substeps use `sudo`, pass `automax run --sudo-password-env ENV_NAME` so the target account can keep password-protected sudo instead of a NOPASSWD sudoers drop-in. Use `capabilities install --sudo-password-env ENV_NAME` to install only missing dependency packages for the selected job and target OS.
107107

108108
The explicit capability plugins are:
109109

docs/reference/cli.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ automax plan --job job.yaml --inventory inventory.yaml --format=json
5454
automax run --job job.yaml --inventory inventory.yaml --state-dir .automax/runs
5555
```
5656

57+
For sudo-enabled remote substeps on targets that require a sudo password, pass the controller-side environment variable name instead of configuring passwordless sudo on the target:
58+
59+
```bash
60+
export AUTOMAX_SUDO_PASSWORD='...'
61+
automax run --job job.yaml --inventory inventory.yaml --sudo-password-env AUTOMAX_SUDO_PASSWORD
62+
```
63+
5764
Preview the selected run without creating run state:
5865

5966
```bash
@@ -92,6 +99,7 @@ Resume modes:
9299
automax resume <run-id> --skip-successful
93100
automax resume <run-id> --only-failed
94101
automax resume <run-id> --from task.deploy:step.restart:substep.service
102+
automax resume <run-id> --skip-successful --sudo-password-env AUTOMAX_SUDO_PASSWORD
95103
```
96104

97105
## Runs

src/automax/cli/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ def _apply_common_options(function):
322322
@click.option("--lock-scope", type=click.Choice(["job", "target", "both"]), default="both", show_default=True, help="Lock job, targets or both.")
323323
@click.option("--lock-timeout", type=float, default=0.0, show_default=True, help="Seconds to wait for locks.")
324324
@click.option("--preflight-capabilities", is_flag=True, help="Compatibility flag; capability preflight is implicit for normal runs.")
325+
@click.option("--sudo-password-env", help="Environment variable containing the sudo password for sudo-enabled remote substeps.")
325326
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True, help="Output format for the final run summary.")
326327
def run(
327328
job_path: str,
@@ -342,6 +343,7 @@ def run(
342343
lock_scope: str,
343344
lock_timeout: float,
344345
preflight_capabilities: bool,
346+
sudo_password_env: str | None,
345347
output_format: str,
346348
) -> None:
347349
"""Run a job from external YAML definitions."""
@@ -381,6 +383,7 @@ def run(
381383
lock_scope=lock_scope,
382384
lock_timeout=lock_timeout,
383385
preflight_capabilities=preflight_capabilities,
386+
sudo_password_env=sudo_password_env,
384387
)
385388
except (AutomaxError, ValueError, RuntimeError) as exc:
386389
raise click.ClickException(str(exc)) from exc
@@ -899,6 +902,7 @@ def add(name: str, ok: bool, detail: str) -> None:
899902
@click.option("--lock-scope", type=click.Choice(["job", "target", "both"]), default="both", show_default=True, help="Lock job, targets or both.")
900903
@click.option("--lock-timeout", type=float, default=0.0, show_default=True, help="Seconds to wait for locks.")
901904
@click.option("--preflight-capabilities", is_flag=True, help="Check remote tools required by the selected job before execution.")
905+
@click.option("--sudo-password-env", help="Environment variable containing the sudo password for sudo-enabled remote substeps.")
902906
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True, help="Output format for the final resume summary.")
903907
def resume(
904908
run_id: str,
@@ -917,6 +921,7 @@ def resume(
917921
lock_scope: str,
918922
lock_timeout: float,
919923
preflight_capabilities: bool,
924+
sudo_password_env: str | None,
920925
output_format: str,
921926
) -> None:
922927
"""Resume an existing run from failed or explicit checkpoint."""
@@ -934,6 +939,7 @@ def resume(
934939
skip_successful=skip_successful,
935940
only_failed=only_failed,
936941
output_format=output_format,
942+
sudo_password_env=sudo_password_env,
937943
)
938944
except (AutomaxError, ValueError, RuntimeError) as exc:
939945
raise click.ClickException(str(exc)) from exc

src/automax/core/engine.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def run(
100100
lock_scope: str = "both",
101101
lock_timeout: float = 0,
102102
preflight_capabilities: bool = False,
103+
sudo_password_env: str | None = None,
103104
) -> int:
104105
"""Execute a new run from external YAML files."""
105106
self._validate_output_format(output_format)
@@ -125,6 +126,7 @@ def run(
125126
inventory = Inventory(inventory_document, context)
126127
job = documents["job"]
127128
self.validate_job(job)
129+
sudo_password = self._resolve_sudo_password(sudo_password_env)
128130

129131
run_id = run_id or self._build_run_id(job)
130132
store = StateStore(state_dir, run_id)
@@ -145,6 +147,7 @@ def run(
145147
"lock_scope": lock_scope,
146148
"lock_timeout": lock_timeout,
147149
"preflight_capabilities": preflight_capabilities,
150+
"sudo_password_env": sudo_password_env,
148151
},
149152
)
150153
store.record_event("job_started", payload={"job": self._job_name(job)})
@@ -194,6 +197,7 @@ def run(
194197
skip_successful=skip_successful,
195198
only_failed=only_failed,
196199
output_format=output_format,
200+
sudo_password=sudo_password,
197201
)
198202
finally:
199203
if lock_manager:
@@ -225,6 +229,7 @@ def resume(
225229
lock: bool = False,
226230
lock_scope: str = "both",
227231
lock_timeout: float = 0,
232+
sudo_password_env: str | None = None,
228233
) -> int:
229234
"""Resume a previous run using paths stored in the run state."""
230235
self._validate_output_format(output_format)
@@ -260,6 +265,7 @@ def resume(
260265
lock=lock,
261266
lock_scope=lock_scope,
262267
lock_timeout=lock_timeout,
268+
sudo_password_env=sudo_password_env,
263269
)
264270

265271
def inspect_job(
@@ -899,7 +905,7 @@ def install_capability_requirements_job(
899905
)
900906
os_by_target = self._detect_os_for_plan(resolved.plan, resolved.secrets)
901907
requirements = collect_requirements(self.iter_rendered_plan_items(resolved, dry_run=True), os_by_target)
902-
sudo_password = os.environ.get(sudo_password_env) if sudo_password_env else None
908+
sudo_password = self._resolve_sudo_password(sudo_password_env)
903909
targets = []
904910
ok = True
905911
emit = progress_callback or (lambda event: None)
@@ -1370,6 +1376,14 @@ def _missing_tools(self, target: Target, tools: Iterable[str]) -> list[str]:
13701376
out = stdout.read().decode("utf-8", errors="replace")
13711377
return sorted({line.strip() for line in out.splitlines() if line.strip()})
13721378

1379+
@staticmethod
1380+
def _resolve_sudo_password(sudo_password_env: str | None) -> str | None:
1381+
if not sudo_password_env:
1382+
return None
1383+
if sudo_password_env not in os.environ:
1384+
raise AutomaxError(f"sudo password environment variable is not set: {sudo_password_env}")
1385+
return os.environ[sudo_password_env]
1386+
13731387
def _install_packages_for_os(
13741388
self,
13751389
*,
@@ -1460,6 +1474,7 @@ def _execute_plan(
14601474
skip_successful: bool = False,
14611475
only_failed: bool = False,
14621476
output_format: str = "text",
1477+
sudo_password: str | None = None,
14631478
) -> int:
14641479
outputs: Dict[str, Any] = {}
14651480
started = from_node is None
@@ -1523,6 +1538,7 @@ def _execute_plan(
15231538
outputs=outputs,
15241539
strategy=strategy,
15251540
output_format=output_format,
1541+
sudo_password=sudo_password,
15261542
)
15271543
for group, group_rc, failed_by_exception in results:
15281544
if group_rc == 0:
@@ -1583,6 +1599,7 @@ def _execute_stage(
15831599
outputs: Dict[str, Any],
15841600
strategy: Dict[str, Any],
15851601
output_format: str,
1602+
sudo_password: str | None,
15861603
) -> List[tuple[List[Dict[str, Any]], int, bool]]:
15871604
mode = strategy["mode"]
15881605
if mode == "serial":
@@ -1597,6 +1614,7 @@ def _execute_stage(
15971614
secrets=secrets,
15981615
outputs=outputs,
15991616
output_format=output_format,
1617+
sudo_password=sudo_password,
16001618
)
16011619
for group in groups
16021620
]
@@ -1613,6 +1631,7 @@ def _execute_stage(
16131631
secrets=secrets,
16141632
outputs=outputs,
16151633
output_format=output_format,
1634+
sudo_password=sudo_password,
16161635
)
16171636

16181637
if mode == "rolling":
@@ -1632,6 +1651,7 @@ def _execute_stage(
16321651
secrets=secrets,
16331652
outputs=outputs,
16341653
output_format=output_format,
1654+
sudo_password=sudo_password,
16351655
)
16361656
)
16371657
if pause > 0 and index < len(chunks) - 1:
@@ -1652,6 +1672,7 @@ def _execute_group_chunks(
16521672
secrets: Dict[str, Any],
16531673
outputs: Dict[str, Any],
16541674
output_format: str,
1675+
sudo_password: str | None,
16551676
) -> List[tuple[List[Dict[str, Any]], int, bool]]:
16561677
results: List[tuple[List[Dict[str, Any]], int, bool]] = []
16571678
for chunk in groups:
@@ -1668,6 +1689,7 @@ def _execute_group_chunks(
16681689
secrets=secrets,
16691690
outputs=outputs,
16701691
output_format=output_format,
1692+
sudo_password=sudo_password,
16711693
): group
16721694
for group in chunk
16731695
}
@@ -1687,6 +1709,7 @@ def _execute_group_with_connection(
16871709
secrets: Dict[str, Any],
16881710
outputs: Dict[str, Any],
16891711
output_format: str,
1712+
sudo_password: str | None,
16901713
) -> tuple[List[Dict[str, Any]], int, bool]:
16911714
first = group[0]
16921715
task = first["task"]
@@ -1708,6 +1731,7 @@ def _execute_group_with_connection(
17081731
outputs=outputs,
17091732
ssh_client=ssh_client,
17101733
output_format=output_format,
1734+
sudo_password=sudo_password,
17111735
)
17121736
else:
17131737
group_rc = self._execute_step_group(
@@ -1721,6 +1745,7 @@ def _execute_group_with_connection(
17211745
outputs=outputs,
17221746
ssh_client=None,
17231747
output_format=output_format,
1748+
sudo_password=sudo_password,
17241749
)
17251750
return group, group_rc, False
17261751
except Exception as exc:
@@ -1754,6 +1779,7 @@ def _execute_step_group(
17541779
outputs: Dict[str, Any],
17551780
ssh_client: Any,
17561781
output_format: str,
1782+
sudo_password: str | None,
17571783
) -> int:
17581784
step_state: Dict[str, Any] = {}
17591785
for item in group:
@@ -1785,6 +1811,7 @@ def _execute_step_group(
17851811
ssh_client=ssh_client,
17861812
step_state=step_state,
17871813
output_format=output_format,
1814+
sudo_password=sudo_password,
17881815
)
17891816

17901817
with self._output_lock:
@@ -1845,6 +1872,7 @@ def _execute_substep_with_retry(
18451872
ssh_client: Any,
18461873
step_state: Dict[str, Any],
18471874
output_format: str,
1875+
sudo_password: str | None,
18481876
) -> PluginResult:
18491877
"""Execute one substep with inherited retry policy and visible retry events."""
18501878
policy = self._resolve_retry_policy(job, task, step, substep)
@@ -1866,6 +1894,7 @@ def _execute_substep_with_retry(
18661894
outputs=outputs,
18671895
ssh_client=ssh_client,
18681896
step_state=step_state,
1897+
sudo_password=sudo_password,
18691898
)
18701899
result = self._apply_error_policy(job, task, step, substep, result, secrets)
18711900
except Exception as exc:
@@ -1920,6 +1949,7 @@ def _execute_substep(
19201949
outputs: Dict[str, Any],
19211950
ssh_client: Any,
19221951
step_state: Dict[str, Any],
1952+
sudo_password: str | None = None,
19231953
) -> PluginResult:
19241954
effective_vars = deepcopy(variables)
19251955
effective_vars.update(target.vars)
@@ -1962,6 +1992,7 @@ def _execute_substep(
19621992
ssh_client=ssh_client,
19631993
logger=self.logger,
19641994
command_timeout=self._resolve_command_timeout(job, task, step, substep),
1995+
sudo_password=sudo_password,
19651996
step_state=step_state,
19661997
)
19671998
if dry_run:

src/automax/core/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,5 @@ class ExecutionContext:
139139
ssh_client: Any = None
140140
logger: Any = None
141141
command_timeout: int | None = None
142+
sudo_password: str | None = None
142143
step_state: Dict[str, Any] = field(default_factory=dict)

src/automax/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def run_automax(
2727
tags: Iterable[str] = (),
2828
skip_tags: Iterable[str] = (),
2929
cli_vars: Optional[Dict[str, Any]] = None,
30+
sudo_password_env: str | None = None,
3031
) -> int:
3132
"""Run Automax from external job/inventory/vars/secrets files."""
3233
engine = AutomaxEngine()
@@ -44,4 +45,5 @@ def run_automax(
4445
tags=tags,
4546
skip_tags=skip_tags,
4647
cli_vars=cli_vars,
48+
sudo_password_env=sudo_password_env,
4749
)

src/automax/plugins/remote_command.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from automax.core.models import ExecutionContext, PluginResult
1313
from automax.plugins.base import BasePlugin
14-
from automax.plugins.remote_utils import apply_cwd
14+
from automax.plugins.remote_utils import apply_cwd, prepare_sudo_password_command
1515

1616

1717
class RemoteCommandPlugin(BasePlugin):
@@ -36,14 +36,21 @@ def execute(self, params: Dict[str, Any], context: ExecutionContext) -> PluginRe
3636
return PluginResult.failure(message="remote.command requires an SSH session")
3737

3838
command = apply_cwd(str(params["command"]), context, params.get("cwd"))
39+
command, sudo_stdin = prepare_sudo_password_command(command, context.sudo_password)
3940
timeout = params.get("timeout", context.command_timeout)
4041
stdin, stdout, stderr = context.ssh_client.exec_command(
4142
command,
4243
timeout=timeout,
4344
get_pty=bool(params.get("pty", False)),
4445
)
46+
wrote_stdin = False
47+
if sudo_stdin:
48+
stdin.write(sudo_stdin)
49+
wrote_stdin = True
4550
if params.get("stdin"):
4651
stdin.write(str(params["stdin"]))
52+
wrote_stdin = True
53+
if wrote_stdin:
4754
stdin.channel.shutdown_write()
4855

4956
rc = stdout.channel.recv_exit_status()

0 commit comments

Comments
 (0)