nixos-rebuild-ng: add --elevate={sudo,run0} and polkit-stdin-agent (#512018)
This commit is contained in:
@@ -314,6 +314,27 @@ in
|
||||
name = "nixos-rebuild";
|
||||
package = config.system.build.nixos-rebuild;
|
||||
})
|
||||
(
|
||||
{ config, ... }:
|
||||
{
|
||||
options.system.tools.nixos-rebuild.enableRun0Elevation = lib.mkEnableOption ''
|
||||
support for being targeted by `nixos-rebuild --elevate=run0
|
||||
--ask-elevate-password`.
|
||||
|
||||
This enables polkit and adds {command}`polkit-stdin-agent` to
|
||||
{option}`environment.systemPackages` so that a deploying host
|
||||
can find a target-architecture agent at
|
||||
{file}`<toplevel>/sw/bin/polkit-stdin-agent` after copying the
|
||||
closure (which is required for cross-architecture deploys and
|
||||
mismatched nixpkgs revisions to work).
|
||||
'';
|
||||
|
||||
config = lib.mkIf config.system.tools.nixos-rebuild.enableRun0Elevation {
|
||||
security.polkit.enable = lib.mkDefault true;
|
||||
environment.systemPackages = [ pkgs.polkit-stdin-agent ];
|
||||
};
|
||||
}
|
||||
)
|
||||
(mkToolModule {
|
||||
name = "nixos-version";
|
||||
package = nixos-version;
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
};
|
||||
|
||||
system.includeBuildDependencies = true;
|
||||
# Needed so the offline build of the target config succeeds.
|
||||
system.extraDependencies = [ pkgs.polkit-stdin-agent ];
|
||||
|
||||
virtualisation = {
|
||||
cores = 2;
|
||||
@@ -49,6 +51,11 @@
|
||||
users.users.alice.extraGroups = [ "wheel" ];
|
||||
users.users.bob.extraGroups = [ "wheel" ];
|
||||
|
||||
# Needed for --elevate=run0. NixOS's default polkit admin rule is
|
||||
# `unix-group:wheel`, so bob (in wheel) can authenticate with his
|
||||
# own password via polkit-stdin-agent.
|
||||
system.tools.nixos-rebuild.enableRun0Elevation = true;
|
||||
|
||||
# Disable sudo for root to ensure sudo isn't called without `--sudo`
|
||||
security.sudo.extraRules = lib.mkForce [
|
||||
{
|
||||
@@ -142,6 +149,7 @@
|
||||
deployer.copy_from_host("${configFile "config-1-deployed"}", "/root/configuration-1.nix")
|
||||
deployer.copy_from_host("${configFile "config-2-deployed"}", "/root/configuration-2.nix")
|
||||
deployer.copy_from_host("${configFile "config-3-deployed"}", "/root/configuration-3.nix")
|
||||
deployer.copy_from_host("${configFile "config-4-deployed"}", "/root/configuration-4.nix")
|
||||
deployer.copy_from_host("${targetNetworkJSON}", "/root/target-network.json")
|
||||
deployer.copy_from_host("${targetConfigJSON}", "/root/target-configuration.json")
|
||||
|
||||
@@ -168,6 +176,20 @@
|
||||
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip()
|
||||
assert target_hostname == "config-3-deployed", f"{target_hostname=}"
|
||||
|
||||
with subtest("Deploy to bob@target with run0 and password"):
|
||||
# polkit-stdin-agent registers an agent for systemd-run on the
|
||||
# target and answers the PAM conversation with the password we
|
||||
# supply locally. The agent is resolved on the target from
|
||||
# <toplevel>/sw/bin (see Run0Elevator._remote_agent_argv).
|
||||
deployer.send_chars("nixos-rebuild switch -I nixos-config=/root/configuration-4.nix --target-host bob@target --elevate=run0 --ask-elevate-password\n")
|
||||
deployer.wait_until_tty_matches("1", "\\[run0\\] password for bob@target")
|
||||
deployer.send_chars("${nodes.target.users.users.bob.password}\n")
|
||||
deployer.wait_until_tty_matches("1", "Done. The new configuration is /nix/store/.*config-4-deployed")
|
||||
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip()
|
||||
assert target_hostname == "config-4-deployed", f"{target_hostname=}"
|
||||
# The target-arch agent is reachable at the stable sw/bin path.
|
||||
target.succeed("test -x /run/current-system/sw/bin/polkit-stdin-agent")
|
||||
|
||||
with subtest("Deploy works with very long TMPDIR"):
|
||||
tmp_dir = "/var/folder/veryveryveryveryverylongpathnamethatdoesnotworkwithcontrolpath"
|
||||
deployer.succeed(f"mkdir -p {tmp_dir}")
|
||||
|
||||
@@ -21,7 +21,7 @@ nixos-rebuild - reconfigure a NixOS machine
|
||||
_nixos-rebuild_ \[--verbose] [--quiet] [--max-jobs MAX_JOBS] [--cores CORES] [--log-format LOG_FORMAT] [--keep-going] [--keep-failed] [--fallback] [--repair] [--option OPTION OPTION] [--builders BUILDERS] [--include INCLUDE]++
|
||||
\[--print-build-logs] [--show-trace] [--accept-flake-config] [--refresh] [--impure] [--offline] [--no-net] [--recreate-lock-file] [--no-update-lock-file] [--no-write-lock-file] [--no-registries] [--commit-lock-file]++
|
||||
\[--update-input UPDATE_INPUT] [--override-input OVERRIDE_INPUT OVERRIDE_INPUT] [--no-build-output] [--use-substitutes] [--help] [--debug] [--file FILE] [--attr ATTR] [--flake [FLAKE]] [--no-flake] [--install-bootloader]++
|
||||
\[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--store-path STORE_PATH] [--upgrade] [--upgrade-all] [--json] [--ask-sudo-password] [--sudo] [--no-reexec]++
|
||||
\[--profile-name PROFILE_NAME] [--specialisation SPECIALISATION] [--rollback] [--store-path STORE_PATH] [--upgrade] [--upgrade-all] [--json] [--elevate {none,sudo,run0}] [--ask-elevate-password] [--no-reexec]++
|
||||
\[--build-host BUILD_HOST] [--target-host TARGET_HOST] [--no-build-nix] [--image-variant IMAGE_VARIANT]++
|
||||
\[{switch,boot,test,build,edit,repl,dry-build,dry-run,dry-activate,build-image,build-vm,build-vm-with-bootloader,list-generations}]
|
||||
|
||||
@@ -269,17 +269,41 @@ It must be one of the following:
|
||||
target-host connection to cache.nixos.org is faster than the connection
|
||||
between hosts.
|
||||
|
||||
*--elevate* {none,sudo,run0}
|
||||
Privilege-elevation method used for activation commands. Setting this
|
||||
option allows deploying as a non-root user.
|
||||
|
||||
_sudo_ prefixes commands with *sudo*. Additional sudo options can be
|
||||
passed via the NIX_SUDOOPTS environment variable.
|
||||
|
||||
_run0_ uses systemd's polkit-based elevation. Locally this runs *run0*
|
||||
directly so the user's normal polkit agent handles any prompts. With
|
||||
*--target-host* the equivalent _systemd-run --uid=0 --pipe_ form is
|
||||
used (no remote TTY is allocated). Unless *--ask-elevate-password* is
|
||||
also passed, the deploying user must be granted the polkit action
|
||||
_org.freedesktop.systemd1.manage-units_ on the target host without
|
||||
authentication, e.g. via _security.polkit.extraConfig_.
|
||||
|
||||
*--ask-elevate-password*, *-S*
|
||||
Prompt locally for a password and feed it to the elevation method.
|
||||
Implies *--elevate=sudo* if *--elevate* is not given.
|
||||
|
||||
For _sudo_ this uses *sudo --stdin*. For _run0_ the command is wrapped
|
||||
in *polkit-stdin-agent*, which registers a per-process polkit agent
|
||||
and answers the PAM conversation from the supplied password. The
|
||||
machine performing the elevation (the local host, or the target host
|
||||
with *--target-host*) must set
|
||||
_system.tools.nixos-rebuild.enableRun0Elevation = true_.
|
||||
*--elevate=run0 --ask-elevate-password* is not usable otherwise.
|
||||
|
||||
*--sudo*
|
||||
When set, *nixos-rebuild* prefixes activation commands with sudo.
|
||||
Setting this option allows deploying as a non-root user.
|
||||
Alias for *--elevate=sudo*.
|
||||
|
||||
You can set sudo options by defining the NIX_SUDOOPTS environment
|
||||
variable.
|
||||
*--ask-sudo-password*
|
||||
Alias for *--elevate=sudo --ask-elevate-password*.
|
||||
|
||||
*--ask-sudo-password*, *-S*
|
||||
When set, *nixos-rebuild* will ask for sudo password for remote
|
||||
activation (i.e.: on *--target-host*) at the start of the build process.
|
||||
Implies *--sudo*.
|
||||
*--use-remote-sudo*
|
||||
Deprecated, use *--elevate=sudo* instead.
|
||||
|
||||
*--file* _path_, *-f* _path_
|
||||
Build the NixOS system from the specified file. The file must
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
callPackage,
|
||||
installShellFiles,
|
||||
mkShell,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Final, assert_never
|
||||
|
||||
from . import nix, services
|
||||
from .constants import EXECUTABLE, WITH_SHELL_FILES
|
||||
from .elevate import NO_ELEVATOR, ElevatorKind
|
||||
from .models import Action, BuildAttr, Flake, GroupedNixArgs, Profile
|
||||
from .process import Remote
|
||||
from .utils import LogFormatter
|
||||
@@ -163,18 +164,32 @@ def get_parser() -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentPa
|
||||
help="JSON output, only implemented for 'list-generations' right now",
|
||||
)
|
||||
main_parser.add_argument(
|
||||
"--ask-sudo-password",
|
||||
"-S",
|
||||
action="store_true",
|
||||
help="Asks for sudo password for remote activation, implies --sudo",
|
||||
"--elevate",
|
||||
choices=ElevatorKind.choices(),
|
||||
default=None,
|
||||
help="Privilege-elevation method for activation commands",
|
||||
)
|
||||
main_parser.add_argument(
|
||||
"--sudo", action="store_true", help="Prefixes activation commands with sudo"
|
||||
"--ask-elevate-password",
|
||||
"-S",
|
||||
action="store_true",
|
||||
help="Prompt locally for the password to feed to the elevation "
|
||||
"method, implies --elevate=sudo if --elevate is not given",
|
||||
)
|
||||
main_parser.add_argument(
|
||||
"--sudo",
|
||||
action="store_true",
|
||||
help="Alias for '--elevate=sudo'",
|
||||
)
|
||||
main_parser.add_argument(
|
||||
"--ask-sudo-password",
|
||||
action="store_true",
|
||||
help="Alias for '--elevate=sudo --ask-elevate-password'",
|
||||
)
|
||||
main_parser.add_argument(
|
||||
"--use-remote-sudo",
|
||||
action="store_true",
|
||||
help="Deprecated, use '--sudo' instead",
|
||||
help="Deprecated, use '--elevate=sudo' instead",
|
||||
)
|
||||
main_parser.add_argument("--no-ssh-tty", action="store_true", help="Deprecated")
|
||||
main_parser.add_argument(
|
||||
@@ -245,16 +260,32 @@ def parse_args(
|
||||
args.action = Action.DRY_BUILD.value
|
||||
|
||||
if args.ask_sudo_password:
|
||||
args.sudo = True
|
||||
args.ask_elevate_password = True
|
||||
|
||||
if args.use_remote_sudo:
|
||||
parser_warn("--use-remote-sudo is deprecated, use --elevate=sudo instead")
|
||||
|
||||
# Map the elevate flags onto an Elevator. The password itself is
|
||||
# attached later via Elevator.with_prompted_password() once the
|
||||
# target host (used in the prompt) is known.
|
||||
if args.elevate is not None:
|
||||
args.elevator = ElevatorKind.from_name(args.elevate)
|
||||
elif args.sudo or args.use_remote_sudo or args.ask_sudo_password:
|
||||
args.elevator = ElevatorKind.SUDO.make()
|
||||
elif args.ask_elevate_password:
|
||||
# -S historically implied --sudo. Keep that for muscle memory
|
||||
# but be explicit now that there is more than one backend.
|
||||
parser_warn(
|
||||
"--ask-elevate-password without --elevate, falling back to --elevate=sudo"
|
||||
)
|
||||
args.elevator = ElevatorKind.SUDO.make()
|
||||
else:
|
||||
args.elevator = NO_ELEVATOR
|
||||
|
||||
if args.install_grub:
|
||||
parser_warn("--install-grub is deprecated, use --install-bootloader instead")
|
||||
args.install_bootloader = True
|
||||
|
||||
if args.use_remote_sudo:
|
||||
parser_warn("--use-remote-sudo is deprecated, use --sudo instead")
|
||||
args.sudo = True
|
||||
|
||||
if args.fast:
|
||||
parser_warn("--fast is deprecated, use --no-reexec instead")
|
||||
args.no_reexec = True
|
||||
@@ -321,7 +352,7 @@ def execute(argv: list[str]) -> None:
|
||||
args, grouped_nix_args = parse_args(argv)
|
||||
|
||||
if args.upgrade or args.upgrade_all:
|
||||
nix.upgrade_channels(args.upgrade_all, args.sudo)
|
||||
nix.upgrade_channels(args.upgrade_all, args.elevator)
|
||||
|
||||
action = Action(args.action)
|
||||
# Only run shell scripts from the Nixpkgs tree if the action is
|
||||
@@ -337,8 +368,12 @@ def execute(argv: list[str]) -> None:
|
||||
services.reexec(argv, args, grouped_nix_args)
|
||||
|
||||
profile = Profile.from_arg(args.profile_name)
|
||||
target_host = Remote.from_arg(args.target_host, args.ask_sudo_password)
|
||||
build_host = Remote.from_arg(args.build_host, False, validate_opts=False)
|
||||
target_host = Remote.from_arg(args.target_host)
|
||||
build_host = Remote.from_arg(args.build_host, validate_opts=False)
|
||||
args.elevator = args.elevator.with_prompted_password(
|
||||
ask=args.ask_elevate_password,
|
||||
host_label=target_host.host if target_host else "localhost",
|
||||
)
|
||||
build_attr = BuildAttr.from_arg(args.attr, args.file)
|
||||
flake = Flake.from_arg(args.flake, target_host)
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Privilege-elevation backends for activation commands.
|
||||
|
||||
An :class:`Elevator` describes how to wrap a command so it runs as root,
|
||||
both on the local machine and on a remote target host over SSH (where no
|
||||
controlling terminal is available), and how to feed it a pre-supplied
|
||||
password when the backend supports that. ``run_wrapper`` and its callers
|
||||
carry a single ``elevate: Elevator`` value and let it produce the command
|
||||
prefix and stdin.
|
||||
|
||||
The remote case has no controlling terminal and the elevated command's
|
||||
environment depends on the backend (``sudo`` inherits the SSH login env,
|
||||
while the run0 backend starts a transient unit with only systemd's
|
||||
default ``PATH``), so each backend builds the full remote argv itself
|
||||
via :meth:`Elevator.wrap_remote`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import shlex
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import ClassVar, Final, Literal, Self, override
|
||||
|
||||
# Kept here (rather than in process.py) so that elevators can build remote
|
||||
# argvs without a circular import.
|
||||
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
||||
type Args = Sequence[Arg]
|
||||
|
||||
|
||||
class _Env(Enum):
|
||||
PRESERVE_ENV = "PRESERVE"
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
#: Sentinel meaning "copy this variable from the environment the wrapped
|
||||
#: command would naturally see" (``os.environ`` locally, the SSH login
|
||||
#: shell's environment remotely).
|
||||
PRESERVE_ENV: Final = _Env.PRESERVE_ENV
|
||||
|
||||
type EnvValue = str | Literal[_Env.PRESERVE_ENV]
|
||||
|
||||
|
||||
def _remote_env_shell_argv(
|
||||
prefix: Sequence[str],
|
||||
env: Mapping[str, EnvValue],
|
||||
args: Args,
|
||||
) -> list[Arg]:
|
||||
"""Build ``<prefix> /bin/sh -c 'exec /usr/bin/env -i K=V… "$@"' sh <args>``.
|
||||
|
||||
The wrapper runs in the SSH login session, resolves ``PRESERVE_ENV``
|
||||
variables against that session's environment, and re-execs the command
|
||||
with exactly that set. ``/usr/bin/env`` is referenced by absolute path so
|
||||
the wrapper does not depend on ``PATH`` itself (provided on NixOS via
|
||||
``environment.usrbinenv``).
|
||||
"""
|
||||
assigns: list[str] = []
|
||||
for k, v in env.items():
|
||||
if v is PRESERVE_ENV:
|
||||
assigns.append(f'{k}="${{{k}-}}"')
|
||||
else:
|
||||
assigns.append(f"{k}={shlex.quote(v)}")
|
||||
script = f'exec /usr/bin/env -i {" ".join(assigns)} "$@"'
|
||||
return [*prefix, "/bin/sh", "-c", script, "sh", *args]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Wrapped:
|
||||
"""Result of wrapping a command for local elevation."""
|
||||
|
||||
#: Arguments to prepend to the command.
|
||||
prefix: list[str]
|
||||
#: Text to send on the wrapped command's stdin (typically a password
|
||||
#: followed by a newline), or ``None`` to leave stdin alone.
|
||||
stdin: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteWrapped:
|
||||
"""Result of wrapping a command for remote elevation over SSH.
|
||||
|
||||
Unlike :class:`Wrapped` this carries the *full* remote argv: backends
|
||||
differ in where the env-resolution shell wrapper must sit relative to
|
||||
the elevator (inside ``sudo``, but *around* ``systemd-run``), so a
|
||||
plain prefix is not expressive enough.
|
||||
"""
|
||||
|
||||
argv: list[Arg]
|
||||
stdin: str | None = None
|
||||
|
||||
|
||||
class Elevator(ABC):
|
||||
"""How to gain root for activation commands."""
|
||||
|
||||
#: CLI name, e.g. ``sudo`` or ``run0``.
|
||||
name: str
|
||||
|
||||
@property
|
||||
def elevates(self) -> bool:
|
||||
"""Whether this elevator actually changes privileges.
|
||||
|
||||
``run_wrapper`` uses this to decide between passing ``env`` to
|
||||
:func:`subprocess.run` directly (unprivileged local case) and
|
||||
injecting it via ``env -i`` inside the wrapped command (where the
|
||||
elevator may otherwise scrub the environment).
|
||||
"""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def wrap_local(self) -> Wrapped:
|
||||
"""Wrap a command run on the local machine."""
|
||||
|
||||
@abstractmethod
|
||||
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
|
||||
"""Wrap a command run on a target host over SSH.
|
||||
|
||||
The remote side has no controlling terminal, so backends that need
|
||||
interactive prompts must either accept a pre-supplied password
|
||||
(see :meth:`with_password`) or rely on a passwordless policy on
|
||||
the target.
|
||||
|
||||
*env* is the environment to establish for the elevated command.
|
||||
:data:`PRESERVE_ENV` values are resolved against the SSH login
|
||||
shell's environment, and the backend must do so before any step
|
||||
that replaces it with a service-style one.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def with_password(self, password: str) -> Self:
|
||||
"""Return a copy that will feed *password* to the backend.
|
||||
|
||||
Backends that have no stdin path for credentials must raise
|
||||
:class:`ElevateError` with a hint pointing at the alternative
|
||||
(e.g. a polkit rule).
|
||||
"""
|
||||
|
||||
def with_prompted_password(self, *, ask: bool, host_label: str) -> Self:
|
||||
"""Prompt locally for a password and return a copy carrying it.
|
||||
|
||||
No-op when *ask* is false. May raise :class:`ElevateError` (e.g.
|
||||
on :class:`NoElevator`).
|
||||
"""
|
||||
if not ask:
|
||||
return self
|
||||
password = getpass.getpass(f"[{self.name}] password for {host_label}: ")
|
||||
return self.with_password(password)
|
||||
|
||||
def for_target_config(self, toplevel: PurePosixPath | Path) -> Self:
|
||||
"""Return a copy bound to the toplevel being activated on the target.
|
||||
|
||||
Backends that need a helper binary on the remote
|
||||
(:class:`Run0Elevator`'s ``polkit-stdin-agent``) use this to find
|
||||
a target-architecture copy inside the just-copied closure. No-op
|
||||
by default.
|
||||
"""
|
||||
del toplevel # unused in the base implementation
|
||||
return self
|
||||
|
||||
def on_remote_failure(self) -> str | None:
|
||||
"""Optional hint to print when a remote elevated command fails."""
|
||||
return None
|
||||
|
||||
|
||||
class ElevateError(Exception):
|
||||
"""Raised for invalid elevator/flag combinations."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoElevator(Elevator):
|
||||
name: str = "none"
|
||||
|
||||
@property
|
||||
@override
|
||||
def elevates(self) -> bool:
|
||||
return False
|
||||
|
||||
@override
|
||||
def wrap_local(self) -> Wrapped:
|
||||
return Wrapped(prefix=[])
|
||||
|
||||
@override
|
||||
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
|
||||
return RemoteWrapped(argv=_remote_env_shell_argv([], env, args))
|
||||
|
||||
@override
|
||||
def with_password(self, password: str) -> Self:
|
||||
raise ElevateError(
|
||||
"--ask-elevate-password requires --elevate to select an elevation method"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SudoElevator(Elevator):
|
||||
"""Wrap with ``sudo``, optionally feeding the password on stdin.
|
||||
|
||||
Extra arguments come from ``NIX_SUDOOPTS`` for backwards
|
||||
compatibility with the previous implementation in ``run_wrapper``.
|
||||
"""
|
||||
|
||||
name: str = "sudo"
|
||||
password: str | None = None
|
||||
extra_opts: list[str] = field(
|
||||
default_factory=lambda: shlex.split(os.getenv("NIX_SUDOOPTS", ""))
|
||||
)
|
||||
|
||||
@override
|
||||
def wrap_local(self) -> Wrapped:
|
||||
# Local sudo can prompt on /dev/tty itself, so the password is
|
||||
# only piped when one was supplied explicitly.
|
||||
if self.password is not None:
|
||||
return Wrapped(
|
||||
prefix=["sudo", "--prompt=", "--stdin", *self.extra_opts],
|
||||
stdin=self.password + "\n",
|
||||
)
|
||||
return Wrapped(prefix=["sudo", *self.extra_opts])
|
||||
|
||||
@override
|
||||
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
|
||||
# sudo runs inside the SSH login session, so the env wrapper can
|
||||
# sit *inside* it and ${VAR-} resolves against the login env.
|
||||
if self.password is not None:
|
||||
prefix = ["sudo", "--prompt=", "--stdin", *self.extra_opts]
|
||||
stdin = self.password + "\n"
|
||||
else:
|
||||
prefix = ["sudo", *self.extra_opts]
|
||||
stdin = None
|
||||
return RemoteWrapped(
|
||||
argv=_remote_env_shell_argv(prefix, env, args),
|
||||
stdin=stdin,
|
||||
)
|
||||
|
||||
@override
|
||||
def with_password(self, password: str) -> Self:
|
||||
return replace(self, password=password)
|
||||
|
||||
@override
|
||||
def on_remote_failure(self) -> str | None:
|
||||
if self.password is None:
|
||||
return (
|
||||
"while running command with remote sudo, did you forget to "
|
||||
"use --ask-elevate-password?"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Run0Elevator(Elevator):
|
||||
"""Wrap with systemd's polkit-based ``run0``.
|
||||
|
||||
Locally, ``run0`` is used directly and the user's polkit agent
|
||||
(graphical or ``pkttyagent``) handles any prompts.
|
||||
|
||||
Remotely we spell out the explicit ``systemd-run --uid=0 --pipe``
|
||||
form instead. ``run0`` would internally do the same thing when stdio
|
||||
is not a TTY (see systemd ``src/run/run.c``), but going through
|
||||
``systemd-run`` directly gives us ``--setenv K=V``, which we need to
|
||||
forward the SSH login environment into the transient unit (whose own
|
||||
``PATH`` on NixOS is just the systemd store path), and keeps the
|
||||
argv independent of whether the SSH session happens to have a TTY.
|
||||
|
||||
Authorisation comes from either a polkit rule on the target granting
|
||||
``org.freedesktop.systemd1.manage-units`` to the deploying user, or
|
||||
from ``polkit-stdin-agent`` for ``--ask-elevate-password``.
|
||||
"""
|
||||
|
||||
name: str = "run0"
|
||||
password: str | None = None
|
||||
#: ``${toplevel}/sw/bin/polkit-stdin-agent`` on the target, set via
|
||||
#: :meth:`for_target_config`. ``None`` falls back to the target's ``PATH``.
|
||||
remote_agent: str | None = None
|
||||
|
||||
#: Non-interactive equivalent of ``run0`` (see the class docstring).
|
||||
REMOTE_BASE: ClassVar[tuple[str, ...]] = (
|
||||
"systemd-run",
|
||||
"--uid=0",
|
||||
"--pipe",
|
||||
"--quiet",
|
||||
"--wait",
|
||||
"--collect",
|
||||
"--service-type=exec",
|
||||
"--send-sighup",
|
||||
)
|
||||
|
||||
@override
|
||||
def wrap_local(self) -> Wrapped:
|
||||
if self.password is not None:
|
||||
# Resolved from PATH, same requirement as the remote case: the
|
||||
# machine doing the elevation needs
|
||||
# system.tools.nixos-rebuild.enableRun0Elevation.
|
||||
return Wrapped(
|
||||
prefix=["polkit-stdin-agent", "--password-fd=0", "--", "run0", "--"],
|
||||
stdin=self.password + "\n",
|
||||
)
|
||||
return Wrapped(prefix=["run0", "--"])
|
||||
|
||||
@override
|
||||
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
|
||||
# /bin/sh wrapper resolves PRESERVE_ENV in the SSH login session
|
||||
# and forwards the result into the unit via --setenv.
|
||||
setenvs: list[str] = []
|
||||
for k, v in env.items():
|
||||
if v is PRESERVE_ENV:
|
||||
setenvs.append(f'--setenv={k}="${{{k}-}}"')
|
||||
else:
|
||||
setenvs.append(f"--setenv={shlex.quote(f'{k}={v}')}")
|
||||
script = f'exec {shlex.join(self.REMOTE_BASE)} {" ".join(setenvs)} -- "$@"'
|
||||
argv: list[Arg] = ["/bin/sh", "-c", script, "sh", *args]
|
||||
if self.password is not None:
|
||||
# polkit has no `sudo --stdin` equivalent. polkit-stdin-agent
|
||||
# registers a per-process agent for the wrapped command and
|
||||
# answers the PAM conversation from its stdin.
|
||||
argv = self._remote_agent_argv(argv)
|
||||
return RemoteWrapped(argv=argv, stdin=self.password + "\n")
|
||||
return RemoteWrapped(argv=argv)
|
||||
|
||||
#: POSIX sh fragment that picks the first runnable agent from the
|
||||
#: positional parameters up to ``--`` and execs it with the remainder.
|
||||
#: ``command -v`` covers both absolute paths and ``PATH`` lookups.
|
||||
_AGENT_PICKER: ClassVar[str] = (
|
||||
"agent=; "
|
||||
"for a; do "
|
||||
"shift; "
|
||||
'[ "$a" = -- ] && break; '
|
||||
'[ -z "$agent" ] && command -v "$a" >/dev/null 2>&1 && agent="$a"; '
|
||||
"done; "
|
||||
'[ -n "$agent" ] && exec "$agent" --password-fd=0 -- "$@"; '
|
||||
'echo "nixos-rebuild: polkit-stdin-agent not found on target host '
|
||||
'(set system.tools.nixos-rebuild.enableRun0Elevation = true)" >&2; '
|
||||
"exit 127"
|
||||
)
|
||||
|
||||
def _remote_agent_argv(self, inner: list[Arg]) -> list[Arg]:
|
||||
"""Wrap *inner* in a target-side agent lookup.
|
||||
|
||||
The deployer's own agent may be the wrong arch/nixpkgs (cross-arch
|
||||
deploys, Darwin deployers, ``--no-reexec``), so resolve on the
|
||||
target instead: first ``${toplevel}/sw/bin/polkit-stdin-agent``
|
||||
(present when ``system.tools.nixos-rebuild.enableRun0Elevation``
|
||||
is set), then bare ``polkit-stdin-agent`` on the SSH login PATH.
|
||||
:data:`_AGENT_PICKER` exits 127 with a hint if neither is found.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
if self.remote_agent is not None:
|
||||
candidates.append(self.remote_agent)
|
||||
candidates.append("polkit-stdin-agent")
|
||||
return ["/bin/sh", "-c", self._AGENT_PICKER, "sh", *candidates, "--", *inner]
|
||||
|
||||
@override
|
||||
def with_password(self, password: str) -> Self:
|
||||
return replace(self, password=password)
|
||||
|
||||
@override
|
||||
def for_target_config(self, toplevel: PurePosixPath | Path) -> Self:
|
||||
return replace(
|
||||
self, remote_agent=str(toplevel / "sw" / "bin" / "polkit-stdin-agent")
|
||||
)
|
||||
|
||||
@override
|
||||
def on_remote_failure(self) -> str | None:
|
||||
if self.password is None:
|
||||
return (
|
||||
"while running command with remote run0. Either pass "
|
||||
"--ask-elevate-password, or grant the deploying user the "
|
||||
"polkit action 'org.freedesktop.systemd1.manage-units' on "
|
||||
"the target host (security.polkit.extraConfig)."
|
||||
)
|
||||
return (
|
||||
"while running command with remote run0. If the error above "
|
||||
"mentions polkit-stdin-agent or PolicyKit1, the target host "
|
||||
"needs system.tools.nixos-rebuild.enableRun0Elevation = true."
|
||||
)
|
||||
|
||||
|
||||
class ElevatorKind(Enum):
|
||||
"""CLI-selectable elevation backends.
|
||||
|
||||
The enum *value* is the :class:`Elevator` subclass to instantiate,
|
||||
``str(member)`` is what ``--elevate`` accepts on the command line.
|
||||
"""
|
||||
|
||||
NONE = NoElevator
|
||||
SUDO = SudoElevator
|
||||
RUN0 = Run0Elevator
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
def make(self) -> Elevator:
|
||||
"""Instantiate a fresh, unparameterised elevator of this kind."""
|
||||
cls: type[Elevator] = self.value
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
def choices(cls) -> list[str]:
|
||||
"""All ``--elevate`` values, for argparse ``choices=``."""
|
||||
return [str(m) for m in cls]
|
||||
|
||||
@classmethod
|
||||
def from_name(cls, name: str) -> Elevator:
|
||||
try:
|
||||
return cls[name.upper()].make()
|
||||
except KeyError:
|
||||
raise ElevateError(
|
||||
f"unknown elevation method {name!r}, choose from: "
|
||||
+ ", ".join(cls.choices())
|
||||
) from None
|
||||
|
||||
|
||||
#: Singleton used as the default ``elevate=`` argument throughout.
|
||||
NO_ELEVATOR: Final[Elevator] = NoElevator()
|
||||
@@ -14,6 +14,7 @@ from textwrap import dedent
|
||||
from typing import Final, Literal
|
||||
|
||||
from . import tmpdir
|
||||
from .elevate import NO_ELEVATOR, PRESERVE_ENV, Elevator
|
||||
from .models import (
|
||||
Action,
|
||||
BuildAttr,
|
||||
@@ -25,7 +26,7 @@ from .models import (
|
||||
Profile,
|
||||
Remote,
|
||||
)
|
||||
from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper
|
||||
from .process import SSH_DEFAULT_OPTS, run_wrapper
|
||||
from .utils import Args, dict_to_flags
|
||||
|
||||
FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"]
|
||||
@@ -453,7 +454,7 @@ def get_generations(profile: Profile) -> list[Generation]:
|
||||
def get_generations_from_nix_env(
|
||||
profile: Profile,
|
||||
target_host: Remote | None = None,
|
||||
sudo: bool = False,
|
||||
elevate: Elevator = NO_ELEVATOR,
|
||||
) -> list[Generation]:
|
||||
"""Get all NixOS generations from profile with nix-env. Needs root.
|
||||
|
||||
@@ -468,7 +469,7 @@ def get_generations_from_nix_env(
|
||||
["nix-env", "-p", profile.path, "--list-generations"],
|
||||
stdout=PIPE,
|
||||
remote=target_host,
|
||||
sudo=sudo,
|
||||
elevate=elevate,
|
||||
)
|
||||
|
||||
def parse_line(line: str) -> Generation:
|
||||
@@ -600,12 +601,12 @@ def repl_flake(flake: Flake, flake_flags: Args | None = None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def rollback(profile: Profile, target_host: Remote | None, sudo: bool) -> Path:
|
||||
def rollback(profile: Profile, target_host: Remote | None, elevate: Elevator) -> Path:
|
||||
"Rollback Nix profile, like one created by `nixos-rebuild switch`."
|
||||
run_wrapper(
|
||||
["nix-env", "--rollback", "-p", profile.path],
|
||||
remote=target_host,
|
||||
sudo=sudo,
|
||||
elevate=elevate,
|
||||
)
|
||||
# Rollback config PATH is the own profile
|
||||
return profile.path
|
||||
@@ -614,11 +615,11 @@ def rollback(profile: Profile, target_host: Remote | None, sudo: bool) -> Path:
|
||||
def rollback_temporary_profile(
|
||||
profile: Profile,
|
||||
target_host: Remote | None,
|
||||
sudo: bool,
|
||||
elevate: Elevator,
|
||||
) -> Path | None:
|
||||
"Rollback a temporary Nix profile, like one created by `nixos-rebuild test`."
|
||||
generations = get_generations_from_nix_env(
|
||||
profile, target_host=target_host, sudo=sudo
|
||||
profile, target_host=target_host, elevate=elevate
|
||||
)
|
||||
previous_gen_id = None
|
||||
for generation in generations:
|
||||
@@ -635,7 +636,7 @@ def set_profile(
|
||||
profile: Profile,
|
||||
path_to_config: Path,
|
||||
target_host: Remote | None,
|
||||
sudo: bool,
|
||||
elevate: Elevator,
|
||||
) -> None:
|
||||
"Set a path as the current active Nix profile."
|
||||
if not os.environ.get(
|
||||
@@ -668,7 +669,7 @@ def set_profile(
|
||||
run_wrapper(
|
||||
["nix-env", "-p", profile.path, "--set", path_to_config],
|
||||
remote=target_host,
|
||||
sudo=sudo,
|
||||
elevate=elevate,
|
||||
)
|
||||
|
||||
|
||||
@@ -676,7 +677,7 @@ def switch_to_configuration(
|
||||
path_to_config: Path,
|
||||
action: Literal[Action.SWITCH, Action.BOOT, Action.TEST, Action.DRY_ACTIVATE],
|
||||
target_host: Remote | None,
|
||||
sudo: bool,
|
||||
elevate: Elevator,
|
||||
install_bootloader: bool = False,
|
||||
specialisation: str | None = None,
|
||||
) -> None:
|
||||
@@ -716,7 +717,7 @@ def switch_to_configuration(
|
||||
"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0",
|
||||
},
|
||||
remote=target_host,
|
||||
sudo=sudo,
|
||||
elevate=elevate,
|
||||
# switch-to-configuration is not expected to produce meaningful
|
||||
# stdout, but if it (or any of its children) does, it would leak
|
||||
# into our stdout and break the "only the store path on stdout"
|
||||
@@ -728,7 +729,7 @@ def switch_to_configuration(
|
||||
|
||||
def upgrade_channels(
|
||||
all_channels: bool = False,
|
||||
sudo: bool = False,
|
||||
elevate: Elevator = NO_ELEVATOR,
|
||||
channels_dir: Path = Path("/nix/var/nix/profiles/per-user/root/channels/"),
|
||||
) -> None:
|
||||
"""Upgrade channels for classic Nix.
|
||||
@@ -736,10 +737,10 @@ def upgrade_channels(
|
||||
It will either upgrade just the `nixos` channel (including any channel
|
||||
that has a `.update-on-nixos-rebuild` file) or all.
|
||||
"""
|
||||
if not sudo and os.geteuid() != 0:
|
||||
if not elevate.elevates and os.geteuid() != 0:
|
||||
raise NixOSRebuildError(
|
||||
"if you pass the '--upgrade' or '--upgrade-all' flag, you must "
|
||||
"also pass '--sudo' or run the command as root (e.g., with sudo)"
|
||||
"also pass '--elevate' or run the command as root"
|
||||
)
|
||||
|
||||
channel_updated = False
|
||||
@@ -752,7 +753,7 @@ def upgrade_channels(
|
||||
run_wrapper(
|
||||
["nix-channel", "--update", channel_path.name],
|
||||
check=False,
|
||||
sudo=sudo,
|
||||
elevate=elevate,
|
||||
)
|
||||
channel_updated = True
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import atexit
|
||||
import getpass
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from ipaddress import AddressValueError, IPv6Address
|
||||
from typing import Final, Literal, Self, TextIO, TypedDict, Unpack, override
|
||||
from typing import Final, Self, TextIO, TypedDict, Unpack
|
||||
|
||||
from . import tmpdir
|
||||
from .elevate import (
|
||||
NO_ELEVATOR,
|
||||
PRESERVE_ENV,
|
||||
Arg,
|
||||
Args,
|
||||
Elevator,
|
||||
EnvValue,
|
||||
)
|
||||
|
||||
logger: Final = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,34 +31,16 @@ SSH_DEFAULT_OPTS: Final = [
|
||||
]
|
||||
|
||||
|
||||
class _Env(Enum):
|
||||
PRESERVE_ENV = "PRESERVE"
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
PRESERVE_ENV: Final = _Env.PRESERVE_ENV
|
||||
|
||||
|
||||
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
||||
type Args = Sequence[Arg]
|
||||
type EnvValue = str | Literal[_Env.PRESERVE_ENV]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Remote:
|
||||
host: str
|
||||
opts: list[str]
|
||||
sudo_password: str | None
|
||||
store_type: str
|
||||
|
||||
@classmethod
|
||||
def from_arg(
|
||||
cls,
|
||||
host: str | None,
|
||||
ask_sudo_password: bool | None,
|
||||
validate_opts: bool = True,
|
||||
) -> Self | None:
|
||||
if not host:
|
||||
@@ -65,25 +53,21 @@ class Remote:
|
||||
|
||||
opts = shlex.split(os.getenv("NIX_SSHOPTS", ""))
|
||||
if validate_opts:
|
||||
cls._validate_opts(opts, ask_sudo_password)
|
||||
sudo_password = None
|
||||
if ask_sudo_password:
|
||||
sudo_password = getpass.getpass(f"[sudo] password for {host}: ")
|
||||
return cls(host, opts, sudo_password, store_type)
|
||||
cls._validate_opts(opts)
|
||||
return cls(host, opts, store_type)
|
||||
|
||||
@staticmethod
|
||||
def _validate_opts(opts: list[str], ask_sudo_password: bool | None) -> None:
|
||||
def _validate_opts(opts: list[str]) -> None:
|
||||
for o in opts:
|
||||
if o in ["-t", "-tt", "RequestTTY=yes", "RequestTTY=force"]:
|
||||
logger.warning(
|
||||
f"detected option '{o}' in NIX_SSHOPTS. SSH's TTY may "
|
||||
"cause issues, it is recommended to remove this option"
|
||||
)
|
||||
if not ask_sudo_password:
|
||||
logger.warning(
|
||||
"if you want to prompt for sudo password use "
|
||||
"'--ask-sudo-password' option instead"
|
||||
)
|
||||
logger.warning(
|
||||
"if you want to prompt for a password for remote "
|
||||
"elevation use '--ask-elevate-password' instead"
|
||||
)
|
||||
|
||||
def ssh_host(self) -> str:
|
||||
"""Fix up host string for SSH.
|
||||
@@ -130,7 +114,7 @@ def run_wrapper(
|
||||
env: Mapping[str, EnvValue] | None = None,
|
||||
append_local_env: Mapping[str, str] | None = None,
|
||||
remote: Remote | None = None,
|
||||
sudo: bool = False,
|
||||
elevate: Elevator = NO_ELEVATOR,
|
||||
**kwargs: Unpack[RunKwargs],
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"Wrapper around `subprocess.run` that supports extra functionality."
|
||||
@@ -142,27 +126,9 @@ def run_wrapper(
|
||||
resolved_env = _resolve_env_local(normalized_env)
|
||||
|
||||
if remote:
|
||||
remote_run_args: list[Arg] = [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
_remote_shell_script(normalized_env),
|
||||
"sh",
|
||||
*run_args,
|
||||
]
|
||||
|
||||
if sudo:
|
||||
sudo_args = shlex.split(os.getenv("NIX_SUDOOPTS", ""))
|
||||
if remote.sudo_password:
|
||||
remote_run_args = [
|
||||
"sudo",
|
||||
"--prompt=",
|
||||
"--stdin",
|
||||
*sudo_args,
|
||||
*remote_run_args,
|
||||
]
|
||||
process_input = remote.sudo_password + "\n"
|
||||
else:
|
||||
remote_run_args = ["sudo", *sudo_args, *remote_run_args]
|
||||
rwrapped = elevate.wrap_remote(normalized_env, run_args)
|
||||
process_input = rwrapped.stdin
|
||||
remote_run_args: list[Arg] = rwrapped.argv
|
||||
|
||||
ssh_args: list[Arg] = [
|
||||
"ssh",
|
||||
@@ -176,22 +142,19 @@ def run_wrapper(
|
||||
popen_env = None # keep ssh's environment normal
|
||||
|
||||
else:
|
||||
if sudo:
|
||||
# subprocess.run(env=...) would affect sudo, but sudo may drop env
|
||||
# for the target command.
|
||||
# So we inject env via `sudo env ... cmd`.
|
||||
wrapped = elevate.wrap_local()
|
||||
process_input = wrapped.stdin
|
||||
if elevate.elevates:
|
||||
# subprocess.run(env=...) would affect the elevator process,
|
||||
# which may then drop env for the target command. Inject env
|
||||
# via `env -i ... cmd` instead so it survives.
|
||||
if env is not None and resolved_env:
|
||||
run_args = _prefix_env_cmd(run_args, resolved_env)
|
||||
|
||||
sudo_args = shlex.split(os.getenv("NIX_SUDOOPTS", ""))
|
||||
final_args = ["sudo", *sudo_args, *run_args]
|
||||
|
||||
# No need to pass env to subprocess.run; keep sudo's own env
|
||||
# default.
|
||||
final_args = [*wrapped.prefix, *run_args]
|
||||
popen_env = None
|
||||
else:
|
||||
# Non-sudo local: we can fully control the environment with
|
||||
# subprocess.run(env=...)
|
||||
# Unprivileged local: we can fully control the environment
|
||||
# with subprocess.run(env=...)
|
||||
final_args = run_args
|
||||
popen_env = None if env is None else resolved_env
|
||||
|
||||
@@ -225,16 +188,14 @@ def run_wrapper(
|
||||
|
||||
return r
|
||||
except KeyboardInterrupt:
|
||||
# sudo commands are activation only and unlikely to be long running
|
||||
if remote and not sudo:
|
||||
# elevated commands are activation only and unlikely to be long
|
||||
# running
|
||||
if remote and not elevate.elevates:
|
||||
_kill_long_running_ssh_process(args, remote)
|
||||
raise
|
||||
except subprocess.CalledProcessError:
|
||||
if sudo and remote and remote.sudo_password is None:
|
||||
logger.error(
|
||||
"while running command with remote sudo, did you forget to use "
|
||||
"--ask-sudo-password?"
|
||||
)
|
||||
if remote and (hint := elevate.on_remote_failure()):
|
||||
logger.error(hint)
|
||||
raise
|
||||
|
||||
|
||||
@@ -268,7 +229,7 @@ def _resolve_env_local(env: dict[str, EnvValue]) -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Arg]:
|
||||
def _prefix_env_cmd(cmd: Args, resolved_env: dict[str, str]) -> list[Arg]:
|
||||
"""
|
||||
Prefix a command with `env -i K=V ... -- <cmd...>` to set vars for the
|
||||
command.
|
||||
@@ -280,24 +241,6 @@ def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Ar
|
||||
return ["env", "-i", *assigns, *cmd]
|
||||
|
||||
|
||||
def _remote_shell_script(env: Mapping[str, EnvValue]) -> str:
|
||||
"""
|
||||
Build the POSIX shell wrapper used for remote execution over SSH.
|
||||
|
||||
SSH sends the remote command as a shell-interpreted command line, so we
|
||||
need a wrapper to establish a clean environment before `exec`-ing the real
|
||||
command. This wrapper is always run under `/bin/sh -c` so preserved
|
||||
variables like `${PATH-}` do not depend on the remote user's login shell.
|
||||
"""
|
||||
shell_assigns: list[str] = []
|
||||
for k, v in env.items():
|
||||
if v is PRESERVE_ENV:
|
||||
shell_assigns.append(f'{k}="${{{k}-}}"')
|
||||
else:
|
||||
shell_assigns.append(f"{k}={shlex.quote(v)}")
|
||||
return f'exec env -i {" ".join(shell_assigns)} "$@"'
|
||||
|
||||
|
||||
def _quote_remote_arg(arg: Arg) -> str:
|
||||
return shlex.quote(str(arg))
|
||||
|
||||
|
||||
@@ -35,11 +35,7 @@ def reexec(
|
||||
return
|
||||
|
||||
drv = None
|
||||
# Parsing the args here but ignore ask_sudo_password since it is not
|
||||
# needed and we would end up asking sudo password twice
|
||||
if flake := Flake.from_arg(
|
||||
args.flake, Remote.from_arg(args.target_host, ask_sudo_password=None)
|
||||
):
|
||||
if flake := Flake.from_arg(args.flake, Remote.from_arg(args.target_host)):
|
||||
drv = nix.build_flake(
|
||||
NIXOS_REBUILD_ATTR,
|
||||
flake,
|
||||
@@ -132,12 +128,12 @@ def _rollback_system(
|
||||
) -> Path:
|
||||
match action:
|
||||
case Action.SWITCH | Action.BOOT:
|
||||
path_to_config = nix.rollback(profile, target_host, sudo=args.sudo)
|
||||
path_to_config = nix.rollback(profile, target_host, elevate=args.elevator)
|
||||
case Action.TEST | Action.BUILD:
|
||||
maybe_path_to_config = nix.rollback_temporary_profile(
|
||||
profile,
|
||||
target_host,
|
||||
sudo=args.sudo,
|
||||
elevate=args.elevator,
|
||||
)
|
||||
if maybe_path_to_config:
|
||||
path_to_config = maybe_path_to_config
|
||||
@@ -231,13 +227,13 @@ def _activate_system(
|
||||
profile,
|
||||
path_to_config,
|
||||
target_host=target_host,
|
||||
sudo=args.sudo,
|
||||
elevate=args.elevator,
|
||||
)
|
||||
nix.switch_to_configuration(
|
||||
path_to_config,
|
||||
action,
|
||||
target_host=target_host,
|
||||
sudo=args.sudo,
|
||||
elevate=args.elevator,
|
||||
specialisation=args.specialisation,
|
||||
install_bootloader=args.install_bootloader,
|
||||
)
|
||||
@@ -247,7 +243,7 @@ def _activate_system(
|
||||
path_to_config,
|
||||
action,
|
||||
target_host=target_host,
|
||||
sudo=args.sudo,
|
||||
elevate=args.elevator,
|
||||
specialisation=args.specialisation,
|
||||
install_bootloader=args.install_bootloader,
|
||||
)
|
||||
@@ -302,6 +298,11 @@ def build_and_activate_system(
|
||||
copy_flags=grouped_nix_args.copy_flags,
|
||||
)
|
||||
elif args.rollback:
|
||||
if target_host is not None:
|
||||
# The elevated `nix-env --rollback` runs before path_to_config
|
||||
# is known, so point the elevator at the profile to find a
|
||||
# target-arch helper in the *current* generation's sw/bin.
|
||||
args.elevator = args.elevator.for_target_config(profile.path)
|
||||
path_to_config = _rollback_system(
|
||||
action=action,
|
||||
args=args,
|
||||
@@ -319,6 +320,11 @@ def build_and_activate_system(
|
||||
grouped_nix_args=grouped_nix_args,
|
||||
)
|
||||
|
||||
if target_host is not None and not args.rollback:
|
||||
# Prefer the helper from the toplevel we just copied to the
|
||||
# target (correct arch, independent of re-exec / nixpkgs pin).
|
||||
args.elevator = args.elevator.for_target_config(path_to_config)
|
||||
|
||||
current_config = Path("/run/current-system")
|
||||
if args.diff:
|
||||
if current_config.exists():
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import nixos_rebuild.elevate as e
|
||||
|
||||
|
||||
def test_no_elevator() -> None:
|
||||
n = e.NoElevator()
|
||||
assert not n.elevates
|
||||
assert n.wrap_local() == e.Wrapped(prefix=[])
|
||||
rw = n.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
|
||||
assert rw.stdin is None
|
||||
assert rw.argv[:2] == ["/bin/sh", "-c"]
|
||||
assert rw.argv[-1] == "cmd"
|
||||
with pytest.raises(e.ElevateError):
|
||||
n.with_password("x")
|
||||
|
||||
|
||||
def test_sudo_elevator(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("NIX_SUDOOPTS", raising=False)
|
||||
|
||||
s = e.SudoElevator()
|
||||
assert s.elevates
|
||||
assert s.wrap_local() == e.Wrapped(prefix=["sudo"])
|
||||
rw = s.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
|
||||
assert rw.argv[0] == "sudo"
|
||||
assert rw.argv[1:3] == ["/bin/sh", "-c"]
|
||||
assert rw.stdin is None
|
||||
assert s.on_remote_failure() is not None
|
||||
|
||||
sp = s.with_password("hunter2")
|
||||
rw = sp.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
|
||||
assert rw.argv[:3] == ["sudo", "--prompt=", "--stdin"]
|
||||
assert rw.stdin == "hunter2\n"
|
||||
assert sp.on_remote_failure() is None
|
||||
# original unchanged
|
||||
assert s.password is None
|
||||
|
||||
|
||||
def test_sudo_elevator_extra_opts(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NIX_SUDOOPTS", "--preserve-env=FOO -H")
|
||||
s = e.SudoElevator()
|
||||
assert s.wrap_local() == e.Wrapped(prefix=["sudo", "--preserve-env=FOO", "-H"])
|
||||
rw = s.with_password("p").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
|
||||
assert rw.argv[:5] == ["sudo", "--prompt=", "--stdin", "--preserve-env=FOO", "-H"]
|
||||
assert rw.stdin == "p\n"
|
||||
|
||||
|
||||
def test_run0_elevator() -> None:
|
||||
r = e.Run0Elevator()
|
||||
assert r.elevates
|
||||
assert r.wrap_local() == e.Wrapped(prefix=["run0", "--"])
|
||||
assert r.on_remote_failure() is not None
|
||||
|
||||
rp = r.with_password("hunter2")
|
||||
w = rp.wrap_local()
|
||||
assert w.prefix[0] == "polkit-stdin-agent"
|
||||
assert "run0" in w.prefix
|
||||
assert w.stdin == "hunter2\n"
|
||||
# With a password the failure hint points at the agent, not at -S.
|
||||
hint = rp.on_remote_failure()
|
||||
assert hint is not None and "polkit-stdin-agent" in hint
|
||||
|
||||
|
||||
def test_run0_elevator_remote() -> None:
|
||||
r = e.Run0Elevator()
|
||||
|
||||
# No password: /bin/sh wrapper around systemd-run, env passed via
|
||||
# --setenv so it is resolved in the SSH login shell rather than
|
||||
# inside the transient unit (which has a useless PATH on NixOS).
|
||||
rw = r.wrap_remote(
|
||||
{"PATH": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1"},
|
||||
["nix-env", "-p", "/profile"],
|
||||
)
|
||||
assert rw.stdin is None
|
||||
assert rw.argv[:2] == ["/bin/sh", "-c"]
|
||||
script = rw.argv[2]
|
||||
assert isinstance(script, str)
|
||||
assert script.startswith("exec systemd-run --uid=0 --pipe ")
|
||||
assert '--setenv=PATH="${PATH-}"' in script
|
||||
assert "--setenv=NIXOS_INSTALL_BOOTLOADER=1" in script
|
||||
assert script.endswith(' -- "$@"')
|
||||
assert rw.argv[3:] == ["sh", "nix-env", "-p", "/profile"]
|
||||
|
||||
# With password: an agent-picker /bin/sh wraps the inner /bin/sh, so
|
||||
# the agent is registered for the inner shell (and the systemd-run it
|
||||
# execs into). With no toplevel bound the only candidate is bare-name
|
||||
# PATH lookup.
|
||||
rw = r.with_password("pw").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
|
||||
assert rw.stdin == "pw\n"
|
||||
assert rw.argv[:3] == ["/bin/sh", "-c", e.Run0Elevator._AGENT_PICKER]
|
||||
sep = rw.argv.index("--")
|
||||
assert rw.argv[3:sep] == ["sh", "polkit-stdin-agent"]
|
||||
assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"]
|
||||
|
||||
# Explicit values containing spaces are shell-quoted inside the
|
||||
# script (the whole thing is later shlex.quoted again for SSH).
|
||||
rw = r.wrap_remote({"FOO": "a b"}, ["cmd"])
|
||||
script = rw.argv[2]
|
||||
assert isinstance(script, str)
|
||||
assert "--setenv='FOO=a b'" in script
|
||||
|
||||
|
||||
def test_run0_for_target_config() -> None:
|
||||
toplevel = PurePosixPath("/nix/store/aaaa-nixos-system")
|
||||
r = e.Run0Elevator().with_password("pw").for_target_config(toplevel)
|
||||
assert r.remote_agent == f"{toplevel}/sw/bin/polkit-stdin-agent"
|
||||
|
||||
rw = r.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
|
||||
sep = rw.argv.index("--")
|
||||
# Order matters: target-arch toplevel first, then PATH.
|
||||
assert rw.argv[4:sep] == [
|
||||
f"{toplevel}/sw/bin/polkit-stdin-agent",
|
||||
"polkit-stdin-agent",
|
||||
]
|
||||
# Inner argv is preserved verbatim after the separator.
|
||||
assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"]
|
||||
assert rw.argv[-1] == "cmd"
|
||||
|
||||
# Non-run0 elevators ignore the toplevel.
|
||||
s = e.SudoElevator()
|
||||
assert s.for_target_config(toplevel) is s
|
||||
|
||||
|
||||
def test_elevator_kind() -> None:
|
||||
assert isinstance(e.ElevatorKind.from_name("sudo"), e.SudoElevator)
|
||||
assert isinstance(e.ElevatorKind.from_name("run0"), e.Run0Elevator)
|
||||
assert isinstance(e.ElevatorKind.from_name("none"), e.NoElevator)
|
||||
with pytest.raises(e.ElevateError):
|
||||
e.ElevatorKind.from_name("doas")
|
||||
assert set(e.ElevatorKind.choices()) == {"none", "sudo", "run0"}
|
||||
|
||||
|
||||
def test_with_prompted_password(monkeypatch: MonkeyPatch) -> None:
|
||||
prompts: list[str] = []
|
||||
|
||||
def fake_getpass(prompt: str) -> str:
|
||||
prompts.append(prompt)
|
||||
return "hunter2"
|
||||
|
||||
monkeypatch.setattr(e.getpass, "getpass", fake_getpass)
|
||||
|
||||
s = e.SudoElevator()
|
||||
assert s.with_prompted_password(ask=False, host_label="x") is s
|
||||
assert prompts == []
|
||||
|
||||
sp = s.with_prompted_password(ask=True, host_label="user@host")
|
||||
assert isinstance(sp, e.SudoElevator)
|
||||
assert sp.password == "hunter2"
|
||||
assert prompts == ["[sudo] password for user@host: "]
|
||||
|
||||
with pytest.raises(e.ElevateError):
|
||||
e.NoElevator().with_prompted_password(ask=True, host_label="localhost")
|
||||
@@ -21,6 +21,37 @@ DEFAULT_RUN_KWARGS = {
|
||||
}
|
||||
|
||||
|
||||
def test_parse_args_elevate() -> None:
|
||||
r, _ = nr.parse_args(["nixos-rebuild", "switch"])
|
||||
assert r.elevator is nr.elevate.NO_ELEVATOR
|
||||
|
||||
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=sudo"])
|
||||
assert isinstance(r.elevator, nr.elevate.SudoElevator)
|
||||
|
||||
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=run0"])
|
||||
assert isinstance(r.elevator, nr.elevate.Run0Elevator)
|
||||
|
||||
# back-compat aliases
|
||||
for flag in ("--sudo", "--use-remote-sudo"):
|
||||
r, _ = nr.parse_args(["nixos-rebuild", "switch", flag])
|
||||
assert isinstance(r.elevator, nr.elevate.SudoElevator)
|
||||
|
||||
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--ask-sudo-password"])
|
||||
assert isinstance(r.elevator, nr.elevate.SudoElevator)
|
||||
assert r.ask_elevate_password
|
||||
|
||||
# -S without --elevate implies sudo
|
||||
r, _ = nr.parse_args(["nixos-rebuild", "switch", "-S"])
|
||||
assert isinstance(r.elevator, nr.elevate.SudoElevator)
|
||||
|
||||
# explicit --elevate wins over the --sudo alias
|
||||
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=none", "--sudo"])
|
||||
assert isinstance(r.elevator, nr.elevate.NoElevator)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
nr.parse_args(["nixos-rebuild", "switch", "--elevate=doas"])
|
||||
|
||||
|
||||
def test_parse_args() -> None:
|
||||
with pytest.raises(SystemExit) as e:
|
||||
nr.parse_args(["nixos-rebuild", "unknown-action"])
|
||||
@@ -663,7 +694,7 @@ def test_execute_nix_switch_build_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"mktemp",
|
||||
"-d",
|
||||
@@ -682,7 +713,7 @@ def test_execute_nix_switch_build_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"nix-store",
|
||||
"--realise",
|
||||
@@ -702,7 +733,7 @@ def test_execute_nix_switch_build_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"readlink",
|
||||
"-f",
|
||||
@@ -720,7 +751,7 @@ def test_execute_nix_switch_build_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"rm",
|
||||
"-rf",
|
||||
@@ -753,7 +784,7 @@ def test_execute_nix_switch_build_target_host(
|
||||
"sudo",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"nix-env",
|
||||
"-p",
|
||||
@@ -772,7 +803,7 @@ def test_execute_nix_switch_build_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"test",
|
||||
"-d",
|
||||
@@ -790,7 +821,7 @@ def test_execute_nix_switch_build_target_host(
|
||||
"sudo",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
|
||||
"sh",
|
||||
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
|
||||
str(config_path / "bin/switch-to-configuration"),
|
||||
@@ -879,7 +910,7 @@ def test_execute_nix_switch_flake_target_host(
|
||||
"sudo",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"nix-env",
|
||||
"-p",
|
||||
@@ -898,7 +929,7 @@ def test_execute_nix_switch_flake_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"test",
|
||||
"-d",
|
||||
@@ -916,7 +947,7 @@ def test_execute_nix_switch_flake_target_host(
|
||||
"sudo",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
|
||||
"sh",
|
||||
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
|
||||
str(config_path / "bin/switch-to-configuration"),
|
||||
@@ -1004,7 +1035,7 @@ def test_execute_nix_switch_flake_build_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"nix",
|
||||
"--extra-experimental-features",
|
||||
@@ -1243,7 +1274,7 @@ def test_execute_build_dry_run_build_and_target_remote(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"nix",
|
||||
"--extra-experimental-features",
|
||||
@@ -1498,7 +1529,7 @@ def test_execute_switch_store_path_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"test",
|
||||
"-f",
|
||||
@@ -1516,7 +1547,7 @@ def test_execute_switch_store_path_target_host(
|
||||
"sudo",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"nix-env",
|
||||
"-p",
|
||||
@@ -1535,7 +1566,7 @@ def test_execute_switch_store_path_target_host(
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"test",
|
||||
"-d",
|
||||
@@ -1553,7 +1584,7 @@ def test_execute_switch_store_path_target_host(
|
||||
"sudo",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
|
||||
"sh",
|
||||
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
|
||||
str(config_path / "bin/switch-to-configuration"),
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_flake_parse(mock_node: Mock, tmpdir: Path, monkeypatch: MonkeyPatch) ->
|
||||
autospec=True,
|
||||
return_value=subprocess.CompletedProcess([], 0, stdout="remote\n"),
|
||||
):
|
||||
target_host = m.Remote("target@remote", [], None, "ssh")
|
||||
target_host = m.Remote("target@remote", [], "ssh")
|
||||
assert m.Flake.parse("/path/to/flake", target_host) == m.Flake(
|
||||
"/path/to/flake", 'nixosConfigurations."remote"'
|
||||
)
|
||||
@@ -201,7 +201,7 @@ def test_flake_from_arg(
|
||||
),
|
||||
):
|
||||
assert m.Flake.from_arg(
|
||||
"/path/to", m.Remote("user@host", [], None, "ssh")
|
||||
"/path/to", m.Remote("user@host", [], "ssh")
|
||||
) == m.Flake("/path/to", 'nixosConfigurations."remote-hostname"')
|
||||
|
||||
|
||||
|
||||
@@ -9,12 +9,15 @@ from unittest.mock import ANY, Mock, call, patch
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import nixos_rebuild.elevate as e
|
||||
import nixos_rebuild.models as m
|
||||
import nixos_rebuild.nix as n
|
||||
import nixos_rebuild.process as p
|
||||
|
||||
from .helpers import get_qualified_name
|
||||
|
||||
SUDO = e.SudoElevator()
|
||||
|
||||
|
||||
@patch(
|
||||
get_qualified_name(n.run_wrapper, n),
|
||||
@@ -83,7 +86,7 @@ def test_build_flake(mock_run: Mock, monkeypatch: MonkeyPatch, tmpdir: Path) ->
|
||||
def test_build_remote(
|
||||
mock_uuid4: Mock, mock_run: Mock, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
build_host = m.Remote("user@host", [], None, "ssh")
|
||||
build_host = m.Remote("user@host", [], "ssh")
|
||||
monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts")
|
||||
|
||||
def run_wrapper_side_effect(
|
||||
@@ -177,7 +180,7 @@ def test_build_remote_flake(
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
flake = m.Flake.parse("/flake.nix#hostname")
|
||||
build_host = m.Remote("user@host", [], None, "ssh")
|
||||
build_host = m.Remote("user@host", [], "ssh")
|
||||
monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts")
|
||||
|
||||
assert n.build_remote_flake(
|
||||
@@ -237,9 +240,9 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None:
|
||||
n.copy_closure(closure, None)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
target_host = m.Remote("user@target.host", [], None, "ssh")
|
||||
build_host = m.Remote("user@build.host", [], None, "ssh")
|
||||
target_host_ng = m.Remote("user@target.host", [], None, "ssh-ng")
|
||||
target_host = m.Remote("user@target.host", [], "ssh")
|
||||
build_host = m.Remote("user@build.host", [], "ssh")
|
||||
target_host_ng = m.Remote("user@target.host", [], "ssh-ng")
|
||||
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
|
||||
n.copy_closure(closure, target_host)
|
||||
mock_run.assert_called_with(
|
||||
@@ -531,15 +534,15 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None:
|
||||
["nix-env", "-p", path, "--list-generations"],
|
||||
stdout=PIPE,
|
||||
remote=None,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
)
|
||||
|
||||
remote = m.Remote("user@host", [], "password", "ssh")
|
||||
remote = m.Remote("user@host", [], "ssh")
|
||||
with patch(
|
||||
get_qualified_name(n.run_wrapper, n), autospec=True, return_value=return_value
|
||||
) as mock_run:
|
||||
assert n.get_generations_from_nix_env(
|
||||
m.Profile("system", path), remote, True
|
||||
m.Profile("system", path), remote, SUDO
|
||||
) == [
|
||||
m.Generation(id=2082, current=False, timestamp="2024-11-07 22:58:56"),
|
||||
m.Generation(id=2083, current=False, timestamp="2024-11-07 22:59:41"),
|
||||
@@ -549,7 +552,7 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None:
|
||||
["nix-env", "-p", path, "--list-generations"],
|
||||
stdout=PIPE,
|
||||
remote=remote,
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
)
|
||||
|
||||
|
||||
@@ -640,19 +643,19 @@ def test_rollback(mock_run: Mock, tmp_path: Path) -> None:
|
||||
|
||||
profile = m.Profile("system", path)
|
||||
|
||||
assert n.rollback(profile, None, False) == profile.path
|
||||
assert n.rollback(profile, None, e.NO_ELEVATOR) == profile.path
|
||||
mock_run.assert_called_with(
|
||||
["nix-env", "--rollback", "-p", path],
|
||||
remote=None,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
)
|
||||
|
||||
target_host = m.Remote("user@localhost", [], None, "ssh")
|
||||
assert n.rollback(profile, target_host, True) == profile.path
|
||||
target_host = m.Remote("user@localhost", [], "ssh")
|
||||
assert n.rollback(profile, target_host, SUDO) == profile.path
|
||||
mock_run.assert_called_with(
|
||||
["nix-env", "--rollback", "-p", path],
|
||||
remote=target_host,
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
)
|
||||
|
||||
|
||||
@@ -672,7 +675,7 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None:
|
||||
"""),
|
||||
)
|
||||
assert (
|
||||
n.rollback_temporary_profile(m.Profile("system", path), None, False)
|
||||
n.rollback_temporary_profile(m.Profile("system", path), None, e.NO_ELEVATOR)
|
||||
== path.parent / "system-2083-link"
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
@@ -684,12 +687,12 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None:
|
||||
],
|
||||
stdout=PIPE,
|
||||
remote=None,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
)
|
||||
|
||||
target_host = m.Remote("user@localhost", [], None, "ssh")
|
||||
target_host = m.Remote("user@localhost", [], "ssh")
|
||||
assert (
|
||||
n.rollback_temporary_profile(m.Profile("foo", path), target_host, True)
|
||||
n.rollback_temporary_profile(m.Profile("foo", path), target_host, SUDO)
|
||||
== path.parent / "foo-2083-link"
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
@@ -701,12 +704,12 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None:
|
||||
],
|
||||
stdout=PIPE,
|
||||
remote=target_host,
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
)
|
||||
|
||||
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
|
||||
mock_run.return_value = CompletedProcess([], 0, stdout="")
|
||||
assert n.rollback_temporary_profile(profile, None, False) is None
|
||||
assert n.rollback_temporary_profile(profile, None, e.NO_ELEVATOR) is None
|
||||
|
||||
|
||||
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
|
||||
@@ -719,25 +722,25 @@ def test_set_profile(mock_run: Mock) -> None:
|
||||
m.Profile("system", profile_path),
|
||||
config_path,
|
||||
target_host=None,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
)
|
||||
|
||||
mock_run.assert_called_with(
|
||||
["nix-env", "-p", profile_path, "--set", config_path],
|
||||
remote=None,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
)
|
||||
|
||||
mock_run.return_value = CompletedProcess([], 1)
|
||||
|
||||
with pytest.raises(m.NixOSRebuildError) as e:
|
||||
with pytest.raises(m.NixOSRebuildError) as exc:
|
||||
n.set_profile(
|
||||
m.Profile("system", profile_path),
|
||||
config_path,
|
||||
target_host=None,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
)
|
||||
assert str(e.value).startswith(
|
||||
assert str(exc.value).startswith(
|
||||
"error: your NixOS configuration path seems to be missing essential files."
|
||||
)
|
||||
|
||||
@@ -756,7 +759,7 @@ def test_switch_to_configuration_without_systemd_run(
|
||||
n.switch_to_configuration(
|
||||
profile_path,
|
||||
m.Action.SWITCH,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
target_host=None,
|
||||
specialisation=None,
|
||||
install_bootloader=False,
|
||||
@@ -764,29 +767,29 @@ def test_switch_to_configuration_without_systemd_run(
|
||||
mock_run.assert_called_with(
|
||||
[profile_path / "bin/switch-to-configuration", "switch"],
|
||||
env={
|
||||
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
|
||||
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
|
||||
"NIXOS_INSTALL_BOOTLOADER": "0",
|
||||
},
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
remote=None,
|
||||
stdout=sys.stderr,
|
||||
)
|
||||
|
||||
with pytest.raises(m.NixOSRebuildError) as e:
|
||||
with pytest.raises(m.NixOSRebuildError) as exc:
|
||||
n.switch_to_configuration(
|
||||
config_path,
|
||||
m.Action.BOOT,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
target_host=None,
|
||||
specialisation="special",
|
||||
)
|
||||
assert (
|
||||
str(e.value)
|
||||
str(exc.value)
|
||||
== "error: '--specialisation' can only be used with 'switch' and 'test'"
|
||||
)
|
||||
|
||||
target_host = m.Remote("user@localhost", [], None, "ssh")
|
||||
target_host = m.Remote("user@localhost", [], "ssh")
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
|
||||
mp.setenv("PATH", "/path/to/bin")
|
||||
@@ -795,7 +798,7 @@ def test_switch_to_configuration_without_systemd_run(
|
||||
n.switch_to_configuration(
|
||||
Path("/path/to/config"),
|
||||
m.Action.TEST,
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
target_host=target_host,
|
||||
install_bootloader=True,
|
||||
specialisation="special",
|
||||
@@ -806,11 +809,11 @@ def test_switch_to_configuration_without_systemd_run(
|
||||
"test",
|
||||
],
|
||||
env={
|
||||
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
|
||||
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
|
||||
"NIXOS_INSTALL_BOOTLOADER": "1",
|
||||
},
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
remote=target_host,
|
||||
stdout=sys.stderr,
|
||||
)
|
||||
@@ -830,7 +833,7 @@ def test_switch_to_configuration_with_systemd_run(
|
||||
n.switch_to_configuration(
|
||||
profile_path,
|
||||
m.Action.SWITCH,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
target_host=None,
|
||||
specialisation=None,
|
||||
install_bootloader=False,
|
||||
@@ -842,16 +845,16 @@ def test_switch_to_configuration_with_systemd_run(
|
||||
"switch",
|
||||
],
|
||||
env={
|
||||
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
|
||||
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
|
||||
"NIXOS_INSTALL_BOOTLOADER": "0",
|
||||
},
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
remote=None,
|
||||
stdout=sys.stderr,
|
||||
)
|
||||
|
||||
target_host = m.Remote("user@localhost", [], None, "ssh")
|
||||
target_host = m.Remote("user@localhost", [], "ssh")
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
|
||||
mp.setenv("PATH", "/path/to/bin")
|
||||
@@ -860,7 +863,7 @@ def test_switch_to_configuration_with_systemd_run(
|
||||
n.switch_to_configuration(
|
||||
Path("/path/to/config"),
|
||||
m.Action.TEST,
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
target_host=target_host,
|
||||
install_bootloader=True,
|
||||
specialisation="special",
|
||||
@@ -872,11 +875,11 @@ def test_switch_to_configuration_with_systemd_run(
|
||||
"test",
|
||||
],
|
||||
env={
|
||||
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
|
||||
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
|
||||
"NIXOS_INSTALL_BOOTLOADER": "1",
|
||||
},
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
remote=target_host,
|
||||
stdout=sys.stderr,
|
||||
)
|
||||
@@ -887,11 +890,13 @@ def test_switch_to_configuration_with_systemd_run(
|
||||
def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> None:
|
||||
tmp_path = Path(tmpdir)
|
||||
|
||||
with pytest.raises(m.NixOSRebuildError) as e:
|
||||
n.upgrade_channels(all_channels=False, sudo=False, channels_dir=tmp_path)
|
||||
assert str(e.value) == (
|
||||
with pytest.raises(m.NixOSRebuildError) as exc:
|
||||
n.upgrade_channels(
|
||||
all_channels=False, elevate=e.NO_ELEVATOR, channels_dir=tmp_path
|
||||
)
|
||||
assert str(exc.value) == (
|
||||
"error: if you pass the '--upgrade' or '--upgrade-all' flag, you must "
|
||||
"also pass '--sudo' or run the command as root (e.g., with sudo)"
|
||||
"also pass '--elevate' or run the command as root"
|
||||
)
|
||||
|
||||
(tmp_path / "nixos").mkdir()
|
||||
@@ -899,19 +904,20 @@ def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> N
|
||||
(tmp_path / "nixos-hardware" / ".update-on-nixos-rebuild").touch()
|
||||
(tmp_path / "home-manager").mkdir()
|
||||
|
||||
# should work because we are passing sudo=True even with os.geteuid == 1000
|
||||
n.upgrade_channels(all_channels=False, sudo=True, channels_dir=tmp_path)
|
||||
# should work because we are passing an elevator even with os.geteuid == 1000
|
||||
n.upgrade_channels(all_channels=False, elevate=SUDO, channels_dir=tmp_path)
|
||||
# Path.glob order is filesystem-dependent, so don't assert call order.
|
||||
mock_run.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
["nix-channel", "--update", "nixos-hardware"],
|
||||
check=False,
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
),
|
||||
call(
|
||||
["nix-channel", "--update", "nixos"],
|
||||
check=False,
|
||||
sudo=True,
|
||||
elevate=SUDO,
|
||||
),
|
||||
],
|
||||
any_order=True,
|
||||
@@ -921,23 +927,23 @@ def test_upgrade_channels(mock_run: Mock, mock_geteuid: Mock, tmpdir: Path) -> N
|
||||
# root check
|
||||
mock_geteuid.return_value = 0
|
||||
|
||||
n.upgrade_channels(all_channels=True, sudo=False, channels_dir=tmp_path)
|
||||
n.upgrade_channels(all_channels=True, elevate=e.NO_ELEVATOR, channels_dir=tmp_path)
|
||||
mock_run.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
["nix-channel", "--update", "home-manager"],
|
||||
check=False,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
),
|
||||
call(
|
||||
["nix-channel", "--update", "nixos-hardware"],
|
||||
check=False,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
),
|
||||
call(
|
||||
["nix-channel", "--update", "nixos"],
|
||||
check=False,
|
||||
sudo=False,
|
||||
elevate=e.NO_ELEVATOR,
|
||||
),
|
||||
],
|
||||
any_order=True,
|
||||
|
||||
@@ -3,28 +3,46 @@ from unittest.mock import patch
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import nixos_rebuild.elevate as e
|
||||
import nixos_rebuild.models as m
|
||||
import nixos_rebuild.process as p
|
||||
|
||||
|
||||
def test_remote_shell_script() -> None:
|
||||
assert p._remote_shell_script({"PATH": p.PRESERVE_ENV}) == (
|
||||
'''exec env -i PATH="${PATH-}" "$@"'''
|
||||
)
|
||||
assert p._remote_shell_script(
|
||||
def test_remote_env_shell_argv() -> None:
|
||||
assert e._remote_env_shell_argv([], {"PATH": e.PRESERVE_ENV}, ["cmd"]) == [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'''exec /usr/bin/env -i PATH="${PATH-}" "$@"''',
|
||||
"sh",
|
||||
"cmd",
|
||||
]
|
||||
assert e._remote_env_shell_argv(
|
||||
["sudo"],
|
||||
{
|
||||
"PATH": p.PRESERVE_ENV,
|
||||
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
|
||||
"PATH": e.PRESERVE_ENV,
|
||||
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
|
||||
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
|
||||
"NIXOS_INSTALL_BOOTLOADER": "0",
|
||||
}
|
||||
) == (
|
||||
"""exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """
|
||||
'''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'''
|
||||
)
|
||||
assert p._remote_shell_script({"PATH": p.PRESERVE_ENV, "FOO": "some value"}) == (
|
||||
'''exec env -i PATH="${PATH-}" FOO='some value' "$@"'''
|
||||
)
|
||||
},
|
||||
["cmd", "arg"],
|
||||
) == [
|
||||
"sudo",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """
|
||||
'''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"''',
|
||||
"sh",
|
||||
"cmd",
|
||||
"arg",
|
||||
]
|
||||
assert e._remote_env_shell_argv(
|
||||
[], {"PATH": e.PRESERVE_ENV, "FOO": "some value"}, []
|
||||
) == [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'''exec /usr/bin/env -i PATH="${PATH-}" FOO='some value' "$@"''',
|
||||
"sh",
|
||||
]
|
||||
|
||||
|
||||
@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True)
|
||||
@@ -43,7 +61,7 @@ def test_run_wrapper(mock_run: Any) -> None:
|
||||
p.run_wrapper(
|
||||
["test", "--with", "flags"],
|
||||
check=False,
|
||||
sudo=True,
|
||||
elevate=e.SudoElevator(),
|
||||
env={"FOO": "bar"},
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
@@ -67,7 +85,6 @@ def test_run_wrapper(mock_run: Any) -> None:
|
||||
p.run_wrapper(
|
||||
["test", "--with", "flags"],
|
||||
check=False,
|
||||
sudo=False,
|
||||
append_local_env={"FOO": "bar"},
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
@@ -89,7 +106,6 @@ def test_run_wrapper(mock_run: Any) -> None:
|
||||
p.run_wrapper(
|
||||
["test", "--with", "flags"],
|
||||
check=False,
|
||||
sudo=False,
|
||||
env={"PATH": "/"},
|
||||
append_local_env={"FOO": "bar"},
|
||||
)
|
||||
@@ -112,7 +128,7 @@ def test_run_wrapper(mock_run: Any) -> None:
|
||||
p.run_wrapper(
|
||||
["test", "--with", "some flags"],
|
||||
check=True,
|
||||
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"),
|
||||
remote=m.Remote("user@localhost", ["--ssh", "opt"], "ssh"),
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
[
|
||||
@@ -124,7 +140,7 @@ def test_run_wrapper(mock_run: Any) -> None:
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"test",
|
||||
"--with",
|
||||
@@ -140,9 +156,9 @@ def test_run_wrapper(mock_run: Any) -> None:
|
||||
p.run_wrapper(
|
||||
["test", "--with", "flags"],
|
||||
check=True,
|
||||
sudo=True,
|
||||
elevate=e.SudoElevator(password="password"),
|
||||
env={"FOO": "bar"},
|
||||
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"),
|
||||
remote=m.Remote("user@localhost", ["--ssh", "opt"], "ssh"),
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
[
|
||||
@@ -157,7 +173,7 @@ def test_run_wrapper(mock_run: Any) -> None:
|
||||
"--stdin",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" FOO=bar "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" FOO=bar "$@"'""",
|
||||
"sh",
|
||||
"test",
|
||||
"--with",
|
||||
@@ -181,7 +197,7 @@ def test__kill_long_running_ssh_process(mock_run: Any) -> None:
|
||||
"build",
|
||||
"/nix/store/la0c8nmpr9xfclla0n4f3qq9iwgdrq4g-nixos-system-sankyuu-nixos-25.05.20250424.f771eb4.drv^*",
|
||||
],
|
||||
m.Remote("user@localhost", opts=[], sudo_password=None, store_type="ssh"),
|
||||
m.Remote("user@localhost", opts=[], store_type="ssh"),
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
[
|
||||
@@ -204,21 +220,18 @@ def test__kill_long_running_ssh_process(mock_run: Any) -> None:
|
||||
|
||||
def test_remote_from_name(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NIX_SSHOPTS", "")
|
||||
assert m.Remote.from_arg("user@localhost", None, False) == m.Remote(
|
||||
assert m.Remote.from_arg("user@localhost", validate_opts=False) == m.Remote(
|
||||
"user@localhost",
|
||||
opts=[],
|
||||
sudo_password=None,
|
||||
store_type="ssh",
|
||||
)
|
||||
|
||||
with patch("getpass.getpass", autospec=True, return_value="password"):
|
||||
monkeypatch.setenv("NIX_SSHOPTS", "-f foo -b bar -t")
|
||||
assert m.Remote.from_arg("user@localhost", True, True) == m.Remote(
|
||||
"user@localhost",
|
||||
opts=["-f", "foo", "-b", "bar", "-t"],
|
||||
sudo_password="password",
|
||||
store_type="ssh",
|
||||
)
|
||||
monkeypatch.setenv("NIX_SSHOPTS", "-f foo -b bar -t")
|
||||
assert m.Remote.from_arg("user@localhost") == m.Remote(
|
||||
"user@localhost",
|
||||
opts=["-f", "foo", "-b", "bar", "-t"],
|
||||
store_type="ssh",
|
||||
)
|
||||
|
||||
|
||||
def test_ssh_host() -> None:
|
||||
@@ -240,18 +253,94 @@ def test_ssh_host() -> None:
|
||||
}
|
||||
|
||||
for host_input, expected in ssh_remotes.items():
|
||||
remote = m.Remote.from_arg(host_input, None, False)
|
||||
remote = m.Remote.from_arg(host_input, validate_opts=False)
|
||||
assert remote is not None
|
||||
assert remote.ssh_host() == expected
|
||||
assert remote.store_type == "ssh"
|
||||
|
||||
for host_input, expected in ssh_ng_remotes.items():
|
||||
remote = m.Remote.from_arg(host_input, None, False)
|
||||
remote = m.Remote.from_arg(host_input, validate_opts=False)
|
||||
assert remote is not None
|
||||
assert remote.ssh_host() == expected
|
||||
assert remote.store_type == "ssh-ng"
|
||||
|
||||
|
||||
@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True)
|
||||
@patch("subprocess.run", autospec=True)
|
||||
def test_run_wrapper_run0(mock_run: Any) -> None:
|
||||
p.run_wrapper(["cmd", "arg"], elevate=e.Run0Elevator())
|
||||
mock_run.assert_called_with(
|
||||
["run0", "--", "cmd", "arg"],
|
||||
check=True,
|
||||
text=True,
|
||||
errors="surrogateescape",
|
||||
env=None,
|
||||
input=None,
|
||||
)
|
||||
|
||||
run0_script = (
|
||||
"exec systemd-run --uid=0 --pipe --quiet --wait --collect "
|
||||
"--service-type=exec --send-sighup "
|
||||
'--setenv=PATH="${PATH-}" -- "$@"'
|
||||
)
|
||||
|
||||
p.run_wrapper(
|
||||
["cmd", "arg"],
|
||||
elevate=e.Run0Elevator(),
|
||||
remote=m.Remote("user@host", [], "ssh"),
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
[
|
||||
"ssh",
|
||||
*p.SSH_DEFAULT_OPTS,
|
||||
"user@host",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
p._quote_remote_arg(run0_script),
|
||||
"sh",
|
||||
"cmd",
|
||||
"arg",
|
||||
],
|
||||
check=True,
|
||||
text=True,
|
||||
errors="surrogateescape",
|
||||
env=None,
|
||||
input=None,
|
||||
)
|
||||
|
||||
p.run_wrapper(
|
||||
["cmd"],
|
||||
elevate=e.Run0Elevator().with_password("pw"),
|
||||
remote=m.Remote("user@host", [], "ssh"),
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
[
|
||||
"ssh",
|
||||
*p.SSH_DEFAULT_OPTS,
|
||||
"user@host",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
p._quote_remote_arg(e.Run0Elevator._AGENT_PICKER),
|
||||
"sh",
|
||||
# No toplevel bound, so the only candidate is PATH lookup.
|
||||
"polkit-stdin-agent",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
p._quote_remote_arg(run0_script),
|
||||
"sh",
|
||||
"cmd",
|
||||
],
|
||||
check=True,
|
||||
text=True,
|
||||
errors="surrogateescape",
|
||||
env=None,
|
||||
input="pw\n",
|
||||
)
|
||||
|
||||
|
||||
@patch("subprocess.run", autospec=True)
|
||||
def test_custom_sudo_args(mock_run: Any) -> None:
|
||||
with patch.dict(
|
||||
@@ -262,7 +351,7 @@ def test_custom_sudo_args(mock_run: Any) -> None:
|
||||
p.run_wrapper(
|
||||
["test"],
|
||||
check=False,
|
||||
sudo=True,
|
||||
elevate=e.SudoElevator(),
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
[
|
||||
@@ -287,8 +376,8 @@ def test_custom_sudo_args(mock_run: Any) -> None:
|
||||
p.run_wrapper(
|
||||
["test"],
|
||||
check=False,
|
||||
sudo=True,
|
||||
remote=m.Remote("user@localhost", [], None, "ssh"),
|
||||
elevate=e.SudoElevator(),
|
||||
remote=m.Remote("user@localhost", [], "ssh"),
|
||||
)
|
||||
mock_run.assert_called_with(
|
||||
[
|
||||
@@ -302,7 +391,7 @@ def test_custom_sudo_args(mock_run: Any) -> None:
|
||||
"--args",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""'exec env -i PATH="${PATH-}" "$@"'""",
|
||||
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
|
||||
"sh",
|
||||
"test",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
lib,
|
||||
rustPlatform,
|
||||
fetchFromGitea,
|
||||
nix-update-script,
|
||||
nixosTests,
|
||||
}:
|
||||
|
||||
rustPlatform.buildRustPackage (finalAttrs: {
|
||||
pname = "polkit-stdin-agent";
|
||||
version = "0.3.0";
|
||||
|
||||
src = fetchFromGitea {
|
||||
domain = "codeberg.org";
|
||||
owner = "r-vdp";
|
||||
repo = "polkit-stdin-agent";
|
||||
tag = "v${finalAttrs.version}";
|
||||
hash = "sha256-Nl/+IBbUEsxSKSWLXwUB3mV4iAG0z9mv+Bl6CSeFzR4=";
|
||||
};
|
||||
|
||||
cargoHash = "sha256-Eb/7ejVmtG5FNSh66gZO3337KCPNi+xtYVC5qyFKJzg=";
|
||||
|
||||
strictDeps = true;
|
||||
__structuredAttrs = true;
|
||||
|
||||
passthru = {
|
||||
updateScript = nix-update-script { };
|
||||
tests = { inherit (nixosTests) nixos-rebuild-target-host; };
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Non-interactive polkit authentication agent that answers PAM prompts from a file descriptor";
|
||||
longDescription = ''
|
||||
Registers a per-process polkit authentication agent for a wrapped
|
||||
command and answers the PAM conversation from a file descriptor
|
||||
instead of /dev/tty, giving run0 / systemd-run the same
|
||||
"password on stdin" ergonomics as `sudo --stdin`.
|
||||
|
||||
Used by `nixos-rebuild --elevate=run0 --ask-elevate-password` to
|
||||
authenticate on a target host over SSH without allocating a TTY.
|
||||
'';
|
||||
homepage = "https://codeberg.org/r-vdp/polkit-stdin-agent";
|
||||
license = lib.licenses.mit;
|
||||
maintainers = with lib.maintainers; [ rvdp ];
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "polkit-stdin-agent";
|
||||
};
|
||||
})
|
||||
Reference in New Issue
Block a user