Skip to content

Commit 37de8f0

Browse files
authored
[Outputs] Add store-paths of package outputs to devbox.lock (#1814)
## Summary This PR updates the `devbox.lock` file to incorporate the expanded response from `/v2/resolve` api. This `/v2/response` incorporates store-paths for each output of the nix package. To do so, we update `lock.SystemInfo` to expect `[]Output` instead of the previous `StorePath string`. This `SystemInfo` struct needs to be backwards-compatible to read existing `devbox.lock` files that have the `StorePath` string. So, the read-path in `lock.GetFile` has some fallback code to handle this scenario, where the `StorePath` is added as `Output{ Name: "out", path: <path>, Default: true}`. The main challenge with writing this PR was that we don't want to update `devbox.lock` _unless_ there's been an explicit user action of the form `devbox update` or `devbox add` or `devbox remove`. Unfortunately, the `lock.File` gets internally updated by various code paths using its `file.Resolve()` function, and so it was (previously) hard for the lockfile to track when an add/remove/update action happened. To work around this, I have more explicitly invoked `lockfile.Add` and `lockfile.Remove` in the "add package" and "remove package" code paths. I think this is safe to do, but let me know if you feel a better path exists. This lets us track whether to write the lockfile with the legacy format of `lock.SystemInfo` (i.e. Store Path) or the modern format (i.e. []Output). ## How was it tested? 1. `devbox update` in devbox-repo doesn't have changes so the lock file wasn't changed. 2. `rm -rf .devbox && devbox shell`. The lockfile wasn't changed. 3. `devbox add prometheus` added the package in the modern []Output format of SystemInfo, and also updated the other packages to use the modern SystemInfo format.
1 parent 7f1633f commit 37de8f0

File tree

7 files changed

+172
-27
lines changed

7 files changed

+172
-27
lines changed

internal/devbox/update.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func (d *Devbox) mergeResolvedPackageToLockfile(
149149
// overwrite an existing StorePath, but to ensure correctness we should ensure that all StorePaths
150150
// come from the same package version.
151151
existingSysInfo, exists := existing.Systems[sysName]
152-
if !exists || existingSysInfo.StorePath != newSysInfo.StorePath {
152+
if !exists || !existingSysInfo.Equals(newSysInfo) {
153153
updated = true
154154
}
155155
}

internal/devbox/update_test.go

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ func TestUpdateNewCurrentSysInfoIsAdded(t *testing.T) {
3939
Resolved: "resolved-flake-reference",
4040
Systems: map[string]*lock.SystemInfo{
4141
sys: {
42-
StorePath: "store_path1",
42+
Outputs: []lock.Output{
43+
{
44+
Name: "out",
45+
Default: true,
46+
Path: "store_path1",
47+
},
48+
},
4349
},
4450
},
4551
}
@@ -57,7 +63,7 @@ func TestUpdateNewCurrentSysInfoIsAdded(t *testing.T) {
5763

5864
require.Contains(t, lockfile.Packages, raw)
5965
require.Contains(t, lockfile.Packages[raw].Systems, sys)
60-
require.Equal(t, "store_path1", lockfile.Packages[raw].Systems[sys].StorePath)
66+
require.Equal(t, "store_path1", lockfile.Packages[raw].Systems[sys].DefaultStorePath())
6167
}
6268

6369
func TestUpdateNewSysInfoIsAdded(t *testing.T) {
@@ -72,10 +78,22 @@ func TestUpdateNewSysInfoIsAdded(t *testing.T) {
7278
Resolved: "resolved-flake-reference",
7379
Systems: map[string]*lock.SystemInfo{
7480
sys1: {
75-
StorePath: "store_path1",
81+
Outputs: []lock.Output{
82+
{
83+
Name: "out",
84+
Default: true,
85+
Path: "store_path1",
86+
},
87+
},
7688
},
7789
sys2: {
78-
StorePath: "store_path2",
90+
Outputs: []lock.Output{
91+
{
92+
Name: "out",
93+
Default: true,
94+
Path: "store_path2",
95+
},
96+
},
7997
},
8098
},
8199
}
@@ -85,7 +103,13 @@ func TestUpdateNewSysInfoIsAdded(t *testing.T) {
85103
Resolved: "resolved-flake-reference",
86104
Systems: map[string]*lock.SystemInfo{
87105
sys1: {
88-
StorePath: "store_path1",
106+
Outputs: []lock.Output{
107+
{
108+
Name: "out",
109+
Default: true,
110+
Path: "store_path1",
111+
},
112+
},
89113
},
90114
// Missing sys2
91115
},
@@ -99,7 +123,7 @@ func TestUpdateNewSysInfoIsAdded(t *testing.T) {
99123
require.Contains(t, lockfile.Packages, raw)
100124
require.Contains(t, lockfile.Packages[raw].Systems, sys1)
101125
require.Contains(t, lockfile.Packages[raw].Systems, sys2)
102-
require.Equal(t, "store_path2", lockfile.Packages[raw].Systems[sys2].StorePath)
126+
require.Equal(t, "store_path2", lockfile.Packages[raw].Systems[sys2].DefaultStorePath())
103127
}
104128

105129
func TestUpdateOtherSysInfoIsReplaced(t *testing.T) {
@@ -114,10 +138,22 @@ func TestUpdateOtherSysInfoIsReplaced(t *testing.T) {
114138
Resolved: "resolved-flake-reference",
115139
Systems: map[string]*lock.SystemInfo{
116140
sys1: {
117-
StorePath: "store_path1",
141+
Outputs: []lock.Output{
142+
{
143+
Name: "out",
144+
Default: true,
145+
Path: "store_path1",
146+
},
147+
},
118148
},
119149
sys2: {
120-
StorePath: "store_path2",
150+
Outputs: []lock.Output{
151+
{
152+
Name: "out",
153+
Default: true,
154+
Path: "store_path2",
155+
},
156+
},
121157
},
122158
},
123159
}
@@ -127,10 +163,22 @@ func TestUpdateOtherSysInfoIsReplaced(t *testing.T) {
127163
Resolved: "resolved-flake-reference",
128164
Systems: map[string]*lock.SystemInfo{
129165
sys1: {
130-
StorePath: "store_path1",
166+
Outputs: []lock.Output{
167+
{
168+
Name: "out",
169+
Default: true,
170+
Path: "store_path1",
171+
},
172+
},
131173
},
132174
sys2: {
133-
StorePath: "mismatching_store_path",
175+
Outputs: []lock.Output{
176+
{
177+
Name: "out",
178+
Default: true,
179+
Path: "mismatching_store_path",
180+
},
181+
},
134182
},
135183
},
136184
},
@@ -143,8 +191,8 @@ func TestUpdateOtherSysInfoIsReplaced(t *testing.T) {
143191
require.Contains(t, lockfile.Packages, raw)
144192
require.Contains(t, lockfile.Packages[raw].Systems, sys1)
145193
require.Contains(t, lockfile.Packages[raw].Systems, sys2)
146-
require.Equal(t, "store_path1", lockfile.Packages[raw].Systems[sys1].StorePath)
147-
require.Equal(t, "store_path2", lockfile.Packages[raw].Systems[sys2].StorePath)
194+
require.Equal(t, "store_path1", lockfile.Packages[raw].Systems[sys1].DefaultStorePath())
195+
require.Equal(t, "store_path2", lockfile.Packages[raw].Systems[sys2].DefaultStorePath())
148196
}
149197

150198
func currentSystem(_t *testing.T) string {

internal/devpkg/narinfo_cache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func (p *Package) fetchNarInfoStatus() (bool, error) {
112112
)
113113
}
114114

115-
pathParts := nix.NewStorePathParts(sysInfo.StorePath)
115+
pathParts := nix.NewStorePathParts(sysInfo.DefaultStorePath())
116116
reqURL := BinaryCache + "/" + pathParts.Hash + ".narinfo"
117117
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
118118
defer cancel()

internal/devpkg/package.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ func resolve(pkg *Package) error {
207207
return err
208208
}
209209
if inCache, err := pkg.IsInBinaryCache(); err == nil && inCache {
210-
pkg.storePath = resolved.Systems[nix.System()].StorePath
210+
pkg.storePath = resolved.Systems[nix.System()].DefaultStorePath()
211211
}
212212
parsed, err := flake.ParseInstallable(resolved.Resolved)
213213
if err != nil {
@@ -576,7 +576,7 @@ func (p *Package) InputAddressedPath() (string, error) {
576576
}
577577

578578
sysInfo := entry.Systems[nix.System()]
579-
return sysInfo.StorePath, nil
579+
return sysInfo.DefaultStorePath(), nil
580580
}
581581

582582
func (p *Package) HasAllowInsecure() bool {
@@ -600,6 +600,7 @@ func (p *Package) StoreName() (string, error) {
600600
}
601601

602602
func (p *Package) EnsureUninstallableIsInLockfile() error {
603+
// TODO savil: Should !p.isInstallable() be the opposite i.e. p.IsInstallable()?
603604
// TODO savil: Do we need the IsDevboxPackage check here?
604605
if !p.IsInstallable() || !p.IsDevboxPackage {
605606
return nil

internal/lock/lockfile.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ func GetFile(project devboxProject) (*File, error) {
4646
if err != nil {
4747
return nil, err
4848
}
49+
50+
// If the lockfile has legacy StorePath fields, we need to convert them to the new format
51+
ensurePackagesHaveOutputs(lockFile.Packages)
52+
4953
return lockFile, nil
5054
}
5155

@@ -92,12 +96,27 @@ func (f *File) Resolve(pkg string) (*Package, error) {
9296
return f.Packages[pkg], nil
9397
}
9498

95-
func (f *File) ForceResolve(pkg string) (*Package, error) {
96-
delete(f.Packages, pkg)
97-
return f.Resolve(pkg)
98-
}
99-
10099
func (f *File) Save() error {
100+
isDirty, err := f.isDirty()
101+
if err != nil {
102+
return err
103+
}
104+
// In SystemInfo, preserve legacy StorePath field and clear out modern Outputs before writing
105+
// Reason: We want to update `devbox.lock` file only upon a user action
106+
// such as `devbox update` or `devbox add` or `devbox remove`.
107+
for pkgName, pkg := range f.Packages {
108+
for sys, sysInfo := range pkg.Systems {
109+
if !isDirty && sysInfo.StorePath != "" {
110+
f.Packages[pkgName].Systems[sys].Outputs = nil
111+
} else {
112+
f.Packages[pkgName].Systems[sys].StorePath = ""
113+
}
114+
}
115+
}
116+
// We set back the Outputs, if needed, after writing the file, so that future
117+
// users of the `lock.File` struct will have the correct data.
118+
defer ensurePackagesHaveOutputs(f.Packages)
119+
101120
return cuecfg.WriteFile(lockFilePath(f.devboxProject.ProjectDir()), f)
102121
}
103122

internal/lock/package.go

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33

44
package lock
55

6+
import (
7+
"fmt"
8+
"slices"
9+
)
10+
611
const (
712
nixpkgSource string = "nixpkg"
813
devboxSearchSource string = "devbox-search"
@@ -22,13 +27,30 @@ type Package struct {
2227
}
2328

2429
type SystemInfo struct {
25-
// StorePath is the input-addressed path for the nix package in /nix/store
26-
// It is the cache key in the Binary Cache Store (cache.nixos.org)
27-
// It is of the form /nix/store/<hash>-<name>-<version>
28-
// <name> may be different from the canonicalName so we store the full store path.
30+
Outputs []Output `json:"outputs,omitempty"`
31+
32+
// Legacy Format
2933
StorePath string `json:"store_path,omitempty"`
3034
}
3135

36+
// Output refers to a nix package output. This struct is derived from searcher.Output
37+
type Output struct {
38+
// Name is the output's name. Nix appends the name to
39+
// the output's store path unless it's the default name
40+
// of "out". Output names can be anything, but
41+
// conventionally they follow the various "make install"
42+
// directories such as "bin", "lib", "src", "man", etc.
43+
Name string `json:"name,omitempty"`
44+
45+
// Path is the absolute store path (with the /nix/store/
46+
// prefix) of the output.
47+
Path string `json:"path,omitempty"`
48+
49+
// Default indicates if Nix installs this output by
50+
// default.
51+
Default bool `json:"default,omitempty"`
52+
}
53+
3254
func (p *Package) GetSource() string {
3355
if p == nil {
3456
return ""
@@ -43,9 +65,49 @@ func (p *Package) IsAllowInsecure() bool {
4365
return p.AllowInsecure
4466
}
4567

68+
// Useful for debugging when we print the struct
69+
func (i *SystemInfo) String() string {
70+
return fmt.Sprintf("%+v", *i)
71+
}
72+
73+
func (i *SystemInfo) DefaultStorePath() string {
74+
if i == nil || len(i.Outputs) == 0 {
75+
return ""
76+
}
77+
78+
for _, output := range i.Outputs {
79+
if output.Default {
80+
return output.Path
81+
}
82+
}
83+
84+
return i.Outputs[0].Path
85+
}
86+
4687
func (i *SystemInfo) Equals(other *SystemInfo) bool {
4788
if i == nil || other == nil {
4889
return i == other
4990
}
50-
return *i == *other
91+
92+
return slices.Equal(i.Outputs, other.Outputs)
93+
}
94+
95+
// ensurePackagesHaveOutputs is used for backwards-compatibility with the old
96+
// lockfile format where each SystemInfo had a StorePath but no Outputs.
97+
func ensurePackagesHaveOutputs(packages map[string]*Package) {
98+
for _, pkg := range packages {
99+
for sys, sysInfo := range pkg.Systems {
100+
// If we have a StorePath and no Outputs, we need to convert to the new format.
101+
// Note: for a non-empty StorePath, Outputs should be empty, but being cautious.
102+
if sysInfo.StorePath != "" && len(sysInfo.Outputs) == 0 {
103+
pkg.Systems[sys].Outputs = []Output{
104+
{
105+
Default: true,
106+
Name: "out",
107+
Path: sysInfo.StorePath,
108+
},
109+
}
110+
}
111+
}
112+
}
51113
}

internal/lock/resolve.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,16 @@ func resolveV2(ctx context.Context, name, version string) (*Package, error) {
102102
}
103103
for sys, info := range resolved.Systems {
104104
if len(info.Outputs) != 0 {
105+
outputs := make([]Output, len(info.Outputs))
106+
for i, out := range info.Outputs {
107+
outputs[i] = Output{
108+
Name: out.Name,
109+
Path: out.Path,
110+
Default: out.Default,
111+
}
112+
}
105113
pkg.Systems[sys] = &SystemInfo{
106-
StorePath: info.Outputs[0].Path,
114+
Outputs: outputs,
107115
}
108116
}
109117
}
@@ -166,6 +174,13 @@ func buildLockSystemInfos(pkg *searcher.PackageVersion) (map[string]*SystemInfo,
166174
sysInfos := map[string]*SystemInfo{}
167175
for sysName, storePath := range sysStorePaths {
168176
sysInfos[sysName] = &SystemInfo{
177+
Outputs: []Output{
178+
{
179+
Default: true,
180+
Name: "out",
181+
Path: storePath,
182+
},
183+
},
169184
StorePath: storePath,
170185
}
171186
}

0 commit comments

Comments
 (0)