Skip to content

Commit 67f4f9f

Browse files
authored
[nix profile] Changes to support format changes from nix 2.20 (#1770)
## Summary The latest nix version (2.20) changed how the nix profile output is represented: From the [release notes](https://nixos.org/manual/nix/stable/release-notes/rl-2.20): > nix profile now allows referring to elements by human-readable names NixOS/nix#8678 > [nix profile](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-profile) now uses names to refer to installed packages when running [list](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-profile-list), [remove](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-profile-remove) or [upgrade](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-profile-upgrade) as opposed to indices. Profile element names are generated when a package is installed and remain the same until the package is removed. > Warning: The manifest.nix file used to record the contents of profiles has changed. Nix will automatically upgrade profiles to the new version when you modify the profile. After that, the profile can no longer be used by older versions of Nix. and for `nix search`: > Disallow empty search regex in nix search [#9481](NixOS/nix#9481) > [nix search](https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-search) now requires a search regex to be passed. To show all packages, use ^. TODOs: - [x] update `nix.readManifest` to handle the new format - [x] `nix search` requires a regex to be passed - [x] manually test on nix < 2.20 on devbox.sh to verify the older nix still works Fixes #1767 ## How was it tested? CICD should pass Installed nix 2.20.1 locally and am using Devbox with it to add, remove packages and run scripts and shell. verified flake updating works: 1. `examples/flakes/remote`. Did `devbox shell`, dropped the `v0.43.1` from process-compose flake, did `devbox update`, and verified that `process-compose` now had the latest version (IIRC `0.80+`) 2. `examples/flakes/php`. Did `devbox shell`, edited the flake to drop `ds` and did `devbox update`. Verified no `ds` in `php -m | grep ds`
1 parent f14ed9d commit 67f4f9f

File tree

8 files changed

+118
-41
lines changed

8 files changed

+118
-41
lines changed

.github/workflows/cli-tests.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,9 @@ jobs:
129129
run-project-tests: ["project-tests", "project-tests-off"]
130130
# Run tests on:
131131
# 1. the oldest supported nix version (which is 2.9.0? But determinate-systems installer has 2.12.0)
132-
# 2. latest nix version
133-
nix-version: ["2.12.0", "2.19.2"]
132+
# 2. nix 2.19.2: version before nix profile changes
133+
# 2. latest nix version (note, 2.20.1 introduced a new profile change)
134+
nix-version: ["2.12.0", "2.19.2", "2.20.1"]
134135
exclude:
135136
- is-main: "not-main"
136137
os: "${{ inputs.run-mac-tests && 'dummy' || 'macos-latest' }}"

internal/nix/nixprofile/item.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ type NixProfileListItem struct {
1616
// invocations of nix profile remove and nix profile upgrade.
1717
index int
1818

19+
// name of the package
20+
// nix 2.20 introduced a new format for the output of nix profile list, which includes the package name.
21+
// This field is used instead of index for `list`, `remove` and `upgrade` subcommands of `nix profile`.
22+
name string
23+
1924
// The original ("unlocked") flake reference and output attribute path used at installation time.
2025
// NOTE that this will be empty if the package was added to the nix profile via store path.
2126
unlockedReference string
@@ -74,10 +79,10 @@ func (i *NixProfileListItem) addedByStorePath() bool {
7479
return i.unlockedReference == ""
7580
}
7681

77-
// String serializes the NixProfileListItem back into the format printed by `nix profile list`
82+
// String serializes the NixProfileListItem for debuggability
7883
func (i *NixProfileListItem) String() string {
79-
return fmt.Sprintf("{%d %s %s %s}",
80-
i.index,
84+
return fmt.Sprintf("{nameOrIndex:%s unlockedRef:%s lockedRef:%s, nixStorePaths:%s}",
85+
i.NameOrIndex(),
8186
i.unlockedReference,
8287
i.lockedReference,
8388
i.nixStorePaths,
@@ -87,3 +92,13 @@ func (i *NixProfileListItem) String() string {
8792
func (i *NixProfileListItem) StorePaths() []string {
8893
return i.nixStorePaths
8994
}
95+
96+
// NameOrIndex is a helper method to get the name of the package if it exists, or the index if it doesn't.
97+
// `nix profile` subcommands `list`, `remove`, and `upgrade` use either name (nix >= 2.20) or index (nix < 2.20)
98+
// to identify the package.
99+
func (i *NixProfileListItem) NameOrIndex() string {
100+
if i.name != "" {
101+
return i.name
102+
}
103+
return fmt.Sprintf("%d", i.index)
104+
}

internal/nix/nixprofile/profile.go

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,46 @@ func ProfileListItems(
4242
URL string `json:"url"`
4343
}
4444
type ProfileListOutput struct {
45-
Elements []ProfileListElement `json:"elements"`
46-
Version int `json:"version"`
45+
Elements map[string]ProfileListElement `json:"elements"`
46+
Version int `json:"version"`
4747
}
4848

49+
// Modern nix profiles: nix >= 2.20
4950
var structOutput ProfileListOutput
50-
if err := json.Unmarshal([]byte(output), &structOutput); err != nil {
51-
return nil, err
51+
if err := json.Unmarshal([]byte(output), &structOutput); err == nil {
52+
items := []*NixProfileListItem{}
53+
for name, element := range structOutput.Elements {
54+
items = append(items, &NixProfileListItem{
55+
name: name,
56+
unlockedReference: lo.Ternary(element.OriginalURL != "", element.OriginalURL+"#"+element.AttrPath, ""),
57+
lockedReference: lo.Ternary(element.URL != "", element.URL+"#"+element.AttrPath, ""),
58+
nixStorePaths: element.StorePaths,
59+
})
60+
}
61+
return items, nil
5262
}
63+
// Fall back to trying format for nix < version 2.20
5364

65+
// ProfileListOutputJSONLegacy is for parsing `nix profile list --json` in nix < version 2.20
66+
// that relied on index instead of name for each package installed.
67+
type ProfileListOutputJSONLegacy struct {
68+
Elements []ProfileListElement `json:"elements"`
69+
Version int `json:"version"`
70+
}
71+
var structOutput2 ProfileListOutputJSONLegacy
72+
if err := json.Unmarshal([]byte(output), &structOutput2); err != nil {
73+
return nil, err
74+
}
5475
items := []*NixProfileListItem{}
55-
for index, element := range structOutput.Elements {
76+
for index, element := range structOutput2.Elements {
5677
items = append(items, &NixProfileListItem{
5778
index: index,
5879
unlockedReference: lo.Ternary(element.OriginalURL != "", element.OriginalURL+"#"+element.AttrPath, ""),
5980
lockedReference: lo.Ternary(element.URL != "", element.URL+"#"+element.AttrPath, ""),
6081
nixStorePaths: element.StorePaths,
6182
})
6283
}
84+
6385
return items, nil
6486
}
6587

@@ -88,7 +110,7 @@ func profileListLegacy(
88110
if line == "" {
89111
continue
90112
}
91-
item, err := parseNixProfileListItem(line)
113+
item, err := parseNixProfileListItemLegacy(line)
92114
if err != nil {
93115
return nil, err
94116
}
@@ -98,7 +120,7 @@ func profileListLegacy(
98120
return items, nil
99121
}
100122

101-
type ProfileListIndexArgs struct {
123+
type ProfileListNameOrIndexArgs struct {
102124
// For performance, you can reuse the same list in multiple operations if you
103125
// are confident index has not changed.
104126
Items []*NixProfileListItem
@@ -108,21 +130,21 @@ type ProfileListIndexArgs struct {
108130
ProfileDir string
109131
}
110132

111-
// ProfileListIndex returns the index of args.Package in the nix profile specified by args.ProfileDir,
112-
// or -1 if it's not found. Callers can pass in args.Items to avoid having to call `nix-profile list` again.
113-
func ProfileListIndex(args *ProfileListIndexArgs) (int, error) {
133+
// ProfileListNameOrIndex returns the name or index of args.Package in the nix profile specified by args.ProfileDir,
134+
// or nix.ErrPackageNotFound if it's not found. Callers can pass in args.Items to avoid having to call `nix-profile list` again.
135+
func ProfileListNameOrIndex(args *ProfileListNameOrIndexArgs) (string, error) {
114136
var err error
115137
items := args.Items
116138
if items == nil {
117139
items, err = ProfileListItems(args.Writer, args.ProfileDir)
118140
if err != nil {
119-
return -1, err
141+
return "", err
120142
}
121143
}
122144

123145
inCache, err := args.Package.IsInBinaryCache()
124146
if err != nil {
125-
return -1, err
147+
return "", err
126148
}
127149

128150
if !inCache && args.Package.IsDevboxPackage {
@@ -131,28 +153,29 @@ func ProfileListIndex(args *ProfileListIndexArgs) (int, error) {
131153
// of an existing profile item.
132154
ref, err := args.Package.NormalizedDevboxPackageReference()
133155
if err != nil {
134-
return -1, errors.Wrapf(err, "failed to get installable for %s", args.Package.String())
156+
return "", errors.Wrapf(err, "failed to get installable for %s", args.Package.String())
135157
}
136158

137159
for _, item := range items {
138160
if ref == item.unlockedReference {
139-
return item.index, nil
161+
return item.NameOrIndex(), nil
140162
}
141163
}
142-
return -1, errors.Wrap(nix.ErrPackageNotFound, args.Package.String())
164+
return "", errors.Wrap(nix.ErrPackageNotFound, args.Package.String())
143165
}
144166

145167
for _, item := range items {
146168
if item.Matches(args.Package, args.Lockfile) {
147-
return item.index, nil
169+
return item.NameOrIndex(), nil
148170
}
149171
}
150-
return -1, errors.Wrap(nix.ErrPackageNotFound, args.Package.String())
172+
return "", errors.Wrap(nix.ErrPackageNotFound, args.Package.String())
151173
}
152174

153-
// parseNixProfileListItem reads each line of output (from `nix profile list`) and converts
175+
// parseNixProfileListItemLegacy reads each line of output (from `nix profile list`) and converts
154176
// into a golang struct. Refer to NixProfileListItem struct definition for explanation of each field.
155-
func parseNixProfileListItem(line string) (*NixProfileListItem, error) {
177+
// NOTE: this API is for legacy nix. Newer nix versions use --json output.
178+
func parseNixProfileListItemLegacy(line string) (*NixProfileListItem, error) {
156179
scanner := bufio.NewScanner(strings.NewReader(line))
157180
scanner.Split(bufio.ScanWords)
158181

internal/nix/nixprofile/profile_test.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ type expectedTestData struct {
1515
packageName string
1616
}
1717

18-
func TestNixProfileListItem(t *testing.T) {
18+
// TestNixProfileListItemLegacy tests the parsing of legacy nix profile list items.
19+
// It only applies to much older nix versions. Newer nix versions rely on the --json output
20+
// instead parsing the legacy output.
21+
func TestNixProfileListItemLegacy(t *testing.T) {
1922
testCases := map[string]struct {
2023
line string
2124
expected expectedTestData
@@ -49,10 +52,10 @@ func TestNixProfileListItem(t *testing.T) {
4952
),
5053
expected: expectedTestData{
5154
item: &NixProfileListItem{
52-
2,
53-
"github:NixOS/nixpkgs/52e3e80afff4b16ccb7c52e9f0f5220552f03d04#legacyPackages.x86_64-darwin.python39Packages.numpy",
54-
"github:NixOS/nixpkgs/52e3e80afff4b16ccb7c52e9f0f5220552f03d04#legacyPackages.x86_64-darwin.python39Packages.numpy",
55-
[]string{"/nix/store/qly36iy1p4q1h5p4rcbvsn3ll0zsd9pd-python3.9-numpy-1.23.3"},
55+
index: 2,
56+
unlockedReference: "github:NixOS/nixpkgs/52e3e80afff4b16ccb7c52e9f0f5220552f03d04#legacyPackages.x86_64-darwin.python39Packages.numpy",
57+
lockedReference: "github:NixOS/nixpkgs/52e3e80afff4b16ccb7c52e9f0f5220552f03d04#legacyPackages.x86_64-darwin.python39Packages.numpy",
58+
nixStorePaths: []string{"/nix/store/qly36iy1p4q1h5p4rcbvsn3ll0zsd9pd-python3.9-numpy-1.23.3"},
5659
},
5760
attrPath: "legacyPackages.x86_64-darwin.python39Packages.numpy",
5861
packageName: "python39Packages.numpy",
@@ -68,7 +71,7 @@ func TestNixProfileListItem(t *testing.T) {
6871
}
6972

7073
func testItem(t *testing.T, line string, expected expectedTestData) {
71-
item, err := parseNixProfileListItem(line)
74+
item, err := parseNixProfileListItemLegacy(line)
7275
if err != nil {
7376
t.Fatalf("unexpected error %v", err)
7477
}

internal/nix/nixprofile/upgrade.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import (
1212
)
1313

1414
func ProfileUpgrade(ProfileDir string, pkg *devpkg.Package, lock *lock.File) error {
15-
idx, err := ProfileListIndex(
16-
&ProfileListIndexArgs{
15+
nameOrIndex, err := ProfileListNameOrIndex(
16+
&ProfileListNameOrIndexArgs{
1717
Lockfile: lock,
1818
Writer: os.Stderr,
1919
Package: pkg,
@@ -24,5 +24,5 @@ func ProfileUpgrade(ProfileDir string, pkg *devpkg.Package, lock *lock.File) err
2424
return err
2525
}
2626

27-
return nix.ProfileUpgrade(ProfileDir, idx)
27+
return nix.ProfileUpgrade(ProfileDir, nameOrIndex)
2828
}

internal/nix/profiles.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ func ProfileRemove(profilePath string, indexes ...string) error {
9494

9595
type manifest struct {
9696
Elements []struct {
97-
Priority int `json:"priority"`
98-
} `json:"elements"`
97+
Priority int
98+
}
9999
}
100100

101101
func readManifest(profilePath string) (manifest, error) {
@@ -107,8 +107,37 @@ func readManifest(profilePath string) (manifest, error) {
107107
return manifest{}, err
108108
}
109109

110-
var m manifest
111-
return m, json.Unmarshal(data, &m)
110+
type manifestModern struct {
111+
Elements map[string]struct {
112+
Priority int `json:"priority"`
113+
} `json:"elements"`
114+
}
115+
var modernMani manifestModern
116+
if err := json.Unmarshal(data, &modernMani); err == nil {
117+
// Convert to the result format
118+
result := manifest{}
119+
for _, e := range modernMani.Elements {
120+
result.Elements = append(result.Elements, struct{ Priority int }{e.Priority})
121+
}
122+
return result, nil
123+
}
124+
125+
type manifestLegacy struct {
126+
Elements []struct {
127+
Priority int `json:"priority"`
128+
} `json:"elements"`
129+
}
130+
var legacyMani manifestLegacy
131+
if err := json.Unmarshal(data, &legacyMani); err != nil {
132+
return manifest{}, err
133+
}
134+
135+
// Convert to the result format
136+
result := manifest{}
137+
for _, e := range legacyMani.Elements {
138+
result.Elements = append(result.Elements, struct{ Priority int }{e.Priority})
139+
}
140+
return result, nil
112141
}
113142

114143
const DefaultPriority = 5

internal/nix/search.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,22 @@ func searchSystem(url, system string) (map[string]*Info, error) {
9898
_ = EnsureNixpkgsPrefetched(writer, hash)
9999
}
100100

101-
cmd := exec.Command("nix", "search", "--json", url)
101+
// The `^` is added to indicate we want to show all packages
102+
cmd := exec.Command("nix", "search", url, "^" /*regex*/, "--json")
102103
cmd.Args = append(cmd.Args, ExperimentalFlags()...)
103104
if system != "" {
104105
cmd.Args = append(cmd.Args, "--system", system)
105106
}
106107
debug.Log("running command: %s\n", cmd)
107108
out, err := cmd.Output()
108109
if err != nil {
110+
if exitErr := (&exec.ExitError{}); errors.As(err, &exitErr) {
111+
err = fmt.Errorf("nix search exit code: %d, stderr: %s, original error: %w", exitErr.ExitCode(), exitErr.Stderr, err)
112+
}
113+
109114
// for now, assume all errors are invalid packages.
115+
// TODO: check the error string for "did not find attribute" and
116+
// return ErrPackageNotFound only for that case.
110117
return nil, fmt.Errorf("error searching for pkg %s: %w", url, err)
111118
}
112119
return parseSearchResults(out), nil

internal/nix/upgrade.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package nix
55

66
import (
7-
"fmt"
87
"os"
98
"os/exec"
109

@@ -13,11 +12,11 @@ import (
1312
"go.jetpack.io/devbox/internal/vercheck"
1413
)
1514

16-
func ProfileUpgrade(ProfileDir string, idx int) error {
15+
func ProfileUpgrade(ProfileDir, indexOrName string) error {
1716
cmd := command(
1817
"profile", "upgrade",
1918
"--profile", ProfileDir,
20-
fmt.Sprintf("%d", idx),
19+
indexOrName,
2120
)
2221
out, err := cmd.CombinedOutput()
2322
if err != nil {

0 commit comments

Comments
 (0)