Unverified Commit 280cff38 authored by Jared Baur's avatar Jared Baur Committed by GitHub
Browse files

switch-to-configuration-ng: stop removed drop-in template instances (#512360)

parents f65bd22c 99b70cdb
Loading
Loading
Loading
Loading
+91 −0
Original line number Diff line number Diff line
{ lib, ... }:
{
  name = "stc-template-dropin";

  nodes.machine =
    { pkgs, lib, ... }:
    {
      # Define the base template. This file exists in both generations.
      systemd.services."test-template@" = {
        description = "A base template for testing";
        serviceConfig.ExecStart = "${pkgs.coreutils}/bin/sleep infinity";
      };

      # Define the managed instance using drop-ins.
      systemd.services."test-template@managed" = {
        overrideStrategy = "asDropin";
        wantedBy = [ "multi-user.target" ];
        serviceConfig.Environment = "TEST_VAR=1";
      };

      # Also define a service which will be changed
      systemd.services."test-template@changed" = {
        overrideStrategy = "asDropin";
        wantedBy = [ "multi-user.target" ];
        serviceConfig.Environment = "TEST_VAR=1";
      };

      # Create a new generation that explicitly removes the managed instance
      specialisation.new-generation.configuration = {
        systemd.services."test-template@managed" = {
          enable = lib.mkForce false;
          wantedBy = lib.mkForce [ ];
        };
        systemd.services."test-template@changed" = {
          serviceConfig.Environment = lib.mkForce "TEST_VAR=2";
        };
      };
    };

  testScript = # python
    ''
      managed_unit = "test-template@managed.service"
      changed_unit = "test-template@changed.service"
      manual_unit = "test-template@manual.service"

      with subtest("Start the machine and ensure the managed instance is running"):
          machine.wait_for_unit("multi-user.target")
          machine.wait_for_unit(managed_unit)
          machine.wait_for_unit(changed_unit)

      with subtest("Imperatively start an unmanaged instance"):
          machine.succeed(f"systemctl start {manual_unit}")
          machine.wait_for_unit(manual_unit)

      with subtest("Run dry-activate on the new generation"):
          new_gen = "/run/booted-system/specialisation/new-generation"

          # switch-to-configuration prints to stderr, so we redirect it to stdout for parsing
          output = machine.succeed(f"{new_gen}/bin/switch-to-configuration dry-activate 2>&1")
          machine.log("dry-activate output:\n" + output)

          found_stop = False
          found_start = False
          found_changed = False
          found_manual_stop = False
          for line in output.splitlines():
              if line.startswith("would stop"):
                  found_stop = found_stop or managed_unit in line
                  found_manual_stop = found_manual_stop or manual_unit in line
              elif line.startswith("would start"):
                  found_start = found_start or managed_unit in line
                  found_changed = found_changed or changed_unit in line

          assert found_stop, "The managed instance was not marked for stopping."
          assert found_changed, "The changed unit was not marked for stopping + starting (restarting)."
          assert not found_start, "switch-to-configuration wants to start the removed managed instance!"
          assert not found_manual_stop, "switch-to-configuration wants to stop the manual instance!"

      with subtest("Perform the actual switch and verify system state"):
          machine.succeed(f"{new_gen}/bin/switch-to-configuration switch")

          # The managed instance should be dead
          machine.fail(f"systemctl is-active {managed_unit}")

          # The changed instance should be running
          machine.succeed(f"systemctl is-active {changed_unit}")

          # The manual instance should survive the configuration switch untouched
          machine.succeed(f"systemctl is-active {manual_unit}")
    '';
}
+1 −0
Original line number Diff line number Diff line
@@ -189,6 +189,7 @@ in
  activation-nix-channel = runTest ./activation/nix-channel.nix;
  activation-nixos-init = runTest ./activation/nixos-init.nix;
  activation-perlless = runTest ./activation/perlless.nix;
  activation-template-dropin = runTest ./activation/template-dropin.nix;
  activation-var = runTest ./activation/var.nix;
  actual = runTest ./actual.nix;
  adguardhome = runTest ./adguardhome.nix;
+1 −1
Original line number Diff line number Diff line
@@ -30,7 +30,7 @@ rustPlatform.buildRustPackage {
    cargo clippy -- -Dwarnings
  '';

  passthru.tests = { inherit (nixosTests) switchTest; };
  passthru.tests = { inherit (nixosTests) switchTest activation-template-dropin; };

  meta = {
    description = "NixOS switch-to-configuration program";
+72 −11
Original line number Diff line number Diff line
@@ -410,6 +410,14 @@ fn parse_systemd_ini(data: &mut UnitInfo, mut unit_file: impl Read) -> Result<()
    Ok(())
}

/// Glob for `<unit_path>.d/*.conf`, escaping any glob metacharacters in the
/// path prefix so unit names containing e.g. `\` (from systemd-escape) are
/// matched literally.
fn unit_dropin_glob(unit_path: &Path) -> Result<glob::Paths> {
    let prefix = glob::Pattern::escape(&format!("{}.d", unit_path.display()));
    glob(&format!("{prefix}/*.conf")).context("Invalid glob pattern")
}

// This function takes the path to a systemd configuration file (like a unit configuration) and
// parses it into a UnitInfo structure.
//
@@ -428,9 +436,7 @@ fn parse_unit(unit_file: &Path, base_unit_path: &Path) -> Result<UnitInfo> {
        )
    })?;

    for entry in
        glob(&format!("{}.d/*.conf", base_unit_path.display())).context("Invalid glob pattern")?
    {
    for entry in unit_dropin_glob(base_unit_path)? {
        let Ok(entry) = entry else {
            continue;
        };
@@ -442,9 +448,7 @@ fn parse_unit(unit_file: &Path, base_unit_path: &Path) -> Result<UnitInfo> {

    // Handle drop-in template-unit instance overrides
    if unit_file != base_unit_path {
        for entry in
            glob(&format!("{}.d/*.conf", unit_file.display())).context("Invalid glob pattern")?
        {
        for entry in unit_dropin_glob(unit_file)? {
            let Ok(entry) = entry else {
                continue;
            };
@@ -1036,6 +1040,37 @@ fn remove_file_if_exists(p: impl AsRef<Path>) -> std::io::Result<()> {
    }
}

#[derive(Debug, PartialEq)]
enum UnitFileState {
    /// The file exists and resolves to a real unit file.
    Present,
    /// The file is a (chain of) symlink(s) to /dev/null, i.e. masked.
    Masked,
    /// The file does not exist, or is a dangling symlink.
    Missing,
}

impl UnitFileState {
    /// Whether the unit file is absent from the configuration, either because
    /// it does not exist or because it has been masked to /dev/null.
    fn is_gone(&self) -> bool {
        matches!(self, UnitFileState::Masked | UnitFileState::Missing)
    }
}

/// Classify a unit-file path. Unexpected I/O errors are propagated.
fn unit_file_state(unit_file: impl AsRef<Path>) -> Result<UnitFileState> {
    let path = unit_file.as_ref();
    match path.canonicalize() {
        Ok(target) if target == Path::new("/dev/null") => Ok(UnitFileState::Masked),
        Ok(_) => Ok(UnitFileState::Present),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(UnitFileState::Missing),
        Err(err) => {
            Err(err).with_context(|| format!("Failed to canonicalize unit file {}", path.display()))
        }
    }
}

/// Iterate over currently active units in the given scope, compare the unit
/// file in `old_unit_dir` against the one in `new_unit_dir`, and populate the
/// action maps accordingly.
@@ -1088,6 +1123,7 @@ fn collect_unit_changes(
        let mut base_unit = unit.clone();
        let mut current_base_unit_file = current_unit_file.clone();
        let mut new_base_unit_file = new_unit_file.clone();
        let mut dropins_removed = false;

        // Detect template instances
        if let Some((Some(template_name), Some(template_instance))) =
@@ -1102,6 +1138,33 @@ fn collect_unit_changes(
                base_unit = format!("{template_name}@.{template_instance}");
                current_base_unit_file = old_unit_dir.join(&base_unit);
                new_base_unit_file = new_unit_dir.join(&base_unit);

                // Handle instances defined as drop-ins. When the unit is
                // disabled, the override files will be a symlink to
                // /dev/null instead.

                // The instance was NixOS-managed in the old generation iff
                // at least one current drop-in is a real file (not masked).
                let mut currently_managed = false;
                for entry in unit_dropin_glob(&current_unit_file)?.flatten() {
                    if unit_file_state(&entry)? == UnitFileState::Present {
                        currently_managed = true;
                        break;
                    }
                }

                // If the instance was managed before, check whether it
                // still is: gone iff no new drop-ins remain (or all are
                // masked to /dev/null).
                if currently_managed {
                    dropins_removed = true;
                    for entry in unit_dropin_glob(&new_unit_file)?.flatten() {
                        if unit_file_state(&entry)? == UnitFileState::Present {
                            dropins_removed = false;
                            break;
                        }
                    }
                }
            }
        }

@@ -1116,11 +1179,9 @@ fn collect_unit_changes(
        if current_base_unit_file.exists()
            && (unit_state.state == "active" || unit_state.state == "activating")
        {
            if new_base_unit_file
                .canonicalize()
                .map(|full_path| full_path == Path::new("/dev/null"))
                .unwrap_or(true)
            {
            // Account for template unit instances where overrideStrategy == "asDropin"
            // whilst also allowing manual instances to keep running.
            if dropins_removed || unit_file_state(&new_base_unit_file)?.is_gone() {
                let current_unit_info = parse_unit(&current_unit_file, &current_base_unit_file)?;
                if parse_systemd_bool(Some(&current_unit_info), "Unit", "X-StopOnRemoval", true) {
                    _ = units_to_stop.insert(unit.to_string(), ());