Skip to content

Commit ef46564

Browse files
committed
upgrade-nix: fix profile symlink resolution in getProfileDir
`canonPath(profileDir, true)` (which resolves symlinks) was converted to `canonPath(profileDir)` (which doesn't) in a5a2562, so the profile path was never resolved to its store target. This is fixed by using `store->followLinksToStorePath` instead. The while loop that attempted to chase symlinks into `/profiles/` is removed. It called `readLink`, which returns relative targets for `nix-env` generation links (e.g. `.local-1-link`), crashing `canonPath` with "not an absolute path". The loop is also unnecessary now that `followLinksToStorePath` is used, since it resolves the full symlink chain to the store path regardless of whether the profile is under `/profiles/`. A NixOS VM test was added, which is based on nixpkgs' `nixosTests.nix-upgrade`, with three variants; one for stable, latest, and current.
1 parent 4d0a79d commit ef46564

File tree

3 files changed

+140
-10
lines changed

3 files changed

+140
-10
lines changed

src/nix/upgrade-nix.cc

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515

1616
using namespace nix;
1717

18+
/**
19+
* Check whether a path has a "profiles" component.
20+
*/
21+
static bool hasProfilesComponent(const std::filesystem::path & path)
22+
{
23+
return std::ranges::contains(path, OS_STR("profiles"));
24+
}
25+
1826
/**
1927
* Settings related to upgrading Nix itself.
2028
*/
@@ -157,14 +165,13 @@ struct CmdUpgradeNix : MixDryRun, StoreCommand
157165

158166
auto profileDir = where.parent_path();
159167

160-
// Resolve profile to /nix/var/nix/profiles/<name> link.
161-
while (canonPath(profileDir).string().find("/profiles/") == std::string::npos
162-
&& std::filesystem::is_symlink(profileDir))
163-
profileDir = readLink(profileDir);
164-
165-
printInfo("found profile %s", PathFmt(profileDir));
166-
167-
auto userEnv = canonPath(profileDir);
168+
// Chase symlinks until we find a path under a "profiles"
169+
// directory, or we run out of symlinks.
170+
auto resolved = profileDir;
171+
while (!hasProfilesComponent(resolved) && std::filesystem::is_symlink(resolved))
172+
// Note that operator/ replaces lhs when rhs is absolute.
173+
resolved = resolved.parent_path() / readLink(resolved);
174+
printInfo("found profile %s", PathFmt(resolved));
168175

169176
if (std::filesystem::exists(profileDir / "manifest.json"))
170177
throw Error(
@@ -174,8 +181,10 @@ struct CmdUpgradeNix : MixDryRun, StoreCommand
174181
if (!std::filesystem::exists(profileDir / "manifest.nix"))
175182
throw Error("directory %s does not appear to be part of a Nix profile", PathFmt(profileDir));
176183

177-
if (!store->isValidPath(store->parseStorePath(userEnv.string())))
178-
throw Error("directory %s is not in the Nix store", PathFmt(userEnv));
184+
auto userEnv = store->followLinksToStorePath(profileDir.string());
185+
186+
if (!store->isValidPath(userEnv))
187+
throw Error("directory %s is not in the Nix store", PathFmt(profileDir));
179188

180189
return profileDir;
181190
}

tests/nixos/default.nix

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,21 @@ in
207207
chrootStore = runNixOSTest ./chroot-store.nix;
208208

209209
storeRemount = runNixOSTest ./store-remount.nix;
210+
211+
upgrade-nix = runNixOSTest {
212+
imports = [ ./upgrade-nix.nix ];
213+
upgrade-nix.oldNix = nixComponents.nix-cli;
214+
};
215+
216+
upgrade-nix_fromStable = runNixOSTest {
217+
imports = [ ./upgrade-nix.nix ];
218+
name = lib.mkForce "upgrade-nix-from-stable";
219+
upgrade-nix.oldNix = pkgs.nixVersions.stable;
220+
};
221+
222+
upgrade-nix_fromLatest = runNixOSTest {
223+
imports = [ ./upgrade-nix.nix ];
224+
name = lib.mkForce "upgrade-nix-from-latest";
225+
upgrade-nix.oldNix = pkgs.nixVersions.latest;
226+
};
210227
}

tests/nixos/upgrade-nix.nix

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# This installs an older Nix into a nix-env profile, and then runs `nix upgrade-nix`
2+
# pointing at a local fallback-paths file containing the locally built nix.
3+
#
4+
# This is based on nixpkgs' nixosTests.nix-upgrade test.
5+
# See https://github.com/NixOS/nixpkgs/blob/e3469a82fbd496d9c8e6192bbaf7cf056c6449ff/nixos/tests/nix/upgrade.nix.
6+
7+
{
8+
config,
9+
lib,
10+
nixComponents,
11+
system,
12+
...
13+
}:
14+
let
15+
pkgs = config.nodes.machine.nixpkgs.pkgs;
16+
17+
newNix = nixComponents.nix-cli;
18+
oldNix = config.upgrade-nix.oldNix;
19+
20+
fallback-paths = pkgs.writeTextDir "fallback-paths.nix" ''
21+
{
22+
${system} = "${newNix}";
23+
}
24+
'';
25+
in
26+
{
27+
options.upgrade-nix.oldNix = lib.mkOption {
28+
type = lib.types.package;
29+
default = newNix;
30+
description = "The Nix package to install before upgrading.";
31+
};
32+
33+
config = {
34+
name = "upgrade-nix";
35+
36+
nodes.machine =
37+
{ lib, ... }:
38+
{
39+
virtualisation.writableStore = true;
40+
nix.settings.substituters = lib.mkForce [ ];
41+
nix.settings.hashed-mirrors = null;
42+
nix.settings.connect-timeout = 1;
43+
nix.extraOptions = "experimental-features = nix-command";
44+
environment.localBinInPath = true;
45+
system.extraDependencies = [
46+
fallback-paths
47+
newNix
48+
oldNix
49+
];
50+
users.users.alice = {
51+
isNormalUser = true;
52+
packages = [ newNix ];
53+
};
54+
};
55+
56+
testScript = /* py */ ''
57+
machine.start()
58+
machine.wait_for_unit("multi-user.target")
59+
60+
with subtest("nix-current"):
61+
# Create a profile to pretend we are on non-NixOS
62+
63+
print(machine.succeed("nix --version"))
64+
machine.succeed("nix-env -i ${oldNix} -p /root/.local")
65+
result = machine.succeed("/root/.local/bin/nix --version")
66+
print(f"installed: {result}")
67+
68+
with subtest("nix-upgrade"):
69+
machine.succeed(
70+
"nix upgrade-nix"
71+
" --nix-store-paths-url file://${fallback-paths}/fallback-paths.nix"
72+
" --profile /root/.local"
73+
)
74+
result = machine.succeed("/root/.local/bin/nix --version")
75+
print(f"after upgrade: {result}")
76+
assert "${newNix.version}" in result, \
77+
f"expected ${newNix.version} in: {result}"
78+
79+
with subtest("nix-build-with-mismatched-daemon"):
80+
# The daemon is still running oldNix; verify the new client works.
81+
machine.succeed(
82+
"runuser -u alice -- nix build"
83+
" --expr 'derivation { name = \"test\"; system = \"${system}\";"
84+
" builder = \"/bin/sh\"; args = [\"-c\" \"echo test > $out\"]; }'"
85+
" --print-out-paths"
86+
)
87+
88+
with subtest("nix-upgrade-auto-detect"):
89+
# Without passing in --profile, getProfileDir auto-detects the profile
90+
# by finding nix-env in PATH and resolving the symlink chain back
91+
# to the store.
92+
machine.succeed("nix-env -i ${oldNix} -p /root/.local")
93+
machine.succeed(
94+
"PATH=/root/.local/bin:$PATH"
95+
" ${newNix}/bin/nix upgrade-nix"
96+
" --nix-store-paths-url file://${fallback-paths}/fallback-paths.nix"
97+
)
98+
result = machine.succeed("/root/.local/bin/nix --version")
99+
print(f"after auto-detect upgrade: {result}")
100+
assert "${newNix.version}" in result, \
101+
f"expected ${newNix.version} in: {result}"
102+
'';
103+
};
104+
}

0 commit comments

Comments
 (0)