Skip to content

Commit 4c5e7d3

Browse files
authored
feat(lock): Add lock file foundations (#90)
Adds the initial structure for saving and loading lock files. The lock files will track component identities and other metadata to help determine which components have changed, and to assist with generating changelogs and managing releases.
1 parent ac37d02 commit 4c5e7d3

File tree

2 files changed

+333
-0
lines changed

2 files changed

+333
-0
lines changed

internal/lockfile/lockfile.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
// Package lockfile reads and writes azldev.lock files, which pin resolved
5+
// upstream commit hashes for deterministic builds. The lock file is a TOML
6+
// file at the project root, managed by [azldev component update].
7+
package lockfile
8+
9+
import (
10+
"fmt"
11+
"strings"
12+
13+
"github.com/microsoft/azure-linux-dev-tools/internal/global/opctx"
14+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
15+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
16+
toml "github.com/pelletier/go-toml/v2"
17+
)
18+
19+
// FileName is the lock file name, placed at the project root.
20+
const FileName = "azldev.lock"
21+
22+
// currentVersion is the lock file format version.
23+
const currentVersion = 1
24+
25+
// LockFile holds the parsed contents of an azldev.lock file.
26+
type LockFile struct {
27+
// Version is the lock file format version.
28+
Version int `toml:"version" comment:"azldev.lock - Managed by azldev component update. Do not edit manually."`
29+
// Components maps component name → locked state.
30+
Components map[string]ComponentLock `toml:"components"`
31+
}
32+
33+
// ComponentLock holds the locked state for a single component.
34+
// Upstream components have [ComponentLock.UpstreamCommit] set to the resolved
35+
// commit hash. Local components have an entry but with an empty commit field.
36+
type ComponentLock struct {
37+
// UpstreamCommit is the resolved full commit hash from the upstream dist-git.
38+
// Empty for local components.
39+
UpstreamCommit string `toml:"upstream-commit,omitempty"`
40+
}
41+
42+
// New creates an empty lock file with the current format version.
43+
func New() *LockFile {
44+
return &LockFile{
45+
Version: currentVersion,
46+
Components: make(map[string]ComponentLock),
47+
}
48+
}
49+
50+
// Load reads and parses a lock file from the given path. Returns an error if the
51+
// file cannot be read or parsed, or if the format version is unsupported.
52+
func Load(fs opctx.FS, path string) (*LockFile, error) {
53+
data, err := fileutils.ReadFile(fs, path)
54+
if err != nil {
55+
return nil, fmt.Errorf("reading lock file %#q:\n%w", path, err)
56+
}
57+
58+
var lockFile LockFile
59+
if err := toml.Unmarshal(data, &lockFile); err != nil {
60+
return nil, fmt.Errorf("parsing lock file %#q:\n%w", path, err)
61+
}
62+
63+
if lockFile.Version != currentVersion {
64+
return nil, fmt.Errorf(
65+
// Backwards compatibility is a future consideration if we need to make non-compatible changes.
66+
// For now, we can just error on unsupported versions.
67+
"unsupported lock file version %d in %#q (expected %d)",
68+
lockFile.Version, path, currentVersion)
69+
}
70+
71+
if lockFile.Components == nil {
72+
lockFile.Components = make(map[string]ComponentLock)
73+
}
74+
75+
return &lockFile, nil
76+
}
77+
78+
// Save writes the lock file to the given path. [toml.Marshal] sorts map keys
79+
// alphabetically, producing deterministic output. Additionally, we post-process the output to insert extra blank lines
80+
// between component entries, which helps reduce git merge conflicts when parallel PRs modify adjacent entries.
81+
func (lockFile *LockFile) Save(fs opctx.FS, path string) error {
82+
data, err := toml.Marshal(lockFile)
83+
if err != nil {
84+
return fmt.Errorf("marshaling lock file:\n%w", err)
85+
}
86+
87+
// Post-process: insert extra blank lines before each [components.<name>] header.
88+
// This helps reduce git merge conflicts when parallel PRs modify adjacent entries.
89+
output := addPerComponentPadding(string(data))
90+
91+
if err := fileutils.WriteFile(fs, path, []byte(output), fileperms.PublicFile); err != nil {
92+
return fmt.Errorf("writing lock file %#q:\n%w", path, err)
93+
}
94+
95+
return nil
96+
}
97+
98+
// addPerComponentPadding inserts extra blank lines between component entries in the marshaled TOML output. This
99+
// padding prevents git merge conflicts when parallel PRs add, remove, or modify adjacent component entries — git's
100+
// default 3-line diff context won't overlap between padded entries.
101+
//
102+
// This is a best-effort approach, and won't prevent all conflicts (e.g. if two PRs modify the same component entry),
103+
// but it should help in the common case of parallel PRs modifying different components.
104+
// The other option would be to have each component in a separate file, but that adds complexity and overhead
105+
// to the loading process, and clutters the project with more files. The files cannot live in the rendered specs
106+
// directory since they are required to detect changes in package state and would be removed by the rendering process or
107+
// a manual folder removal.
108+
func addPerComponentPadding(tomlData string) string {
109+
const prefix = "[components."
110+
111+
var result strings.Builder
112+
113+
result.Grow(len(tomlData))
114+
115+
for line := range strings.SplitSeq(tomlData, "\n") {
116+
if strings.HasPrefix(strings.TrimSpace(line), prefix) {
117+
// Add extra blank lines before each component section header.
118+
result.WriteString("\n\n")
119+
}
120+
121+
result.WriteString(line)
122+
result.WriteString("\n")
123+
}
124+
125+
return result.String()
126+
}
127+
128+
// SetUpstreamCommit sets the locked upstream commit for a component.
129+
func (lockFile *LockFile) SetUpstreamCommit(componentName, commitHash string) {
130+
if lockFile.Components == nil {
131+
lockFile.Components = make(map[string]ComponentLock)
132+
}
133+
134+
entry := lockFile.Components[componentName]
135+
entry.UpstreamCommit = commitHash
136+
lockFile.Components[componentName] = entry
137+
}
138+
139+
// GetUpstreamCommit returns the locked upstream commit for a component.
140+
// Returns empty string and false if the component has no lock entry or
141+
// if the entry has an empty upstream commit.
142+
func (lockFile *LockFile) GetUpstreamCommit(componentName string) (string, bool) {
143+
entry, ok := lockFile.Components[componentName]
144+
if !ok || entry.UpstreamCommit == "" {
145+
return "", false
146+
}
147+
148+
return entry.UpstreamCommit, true
149+
}

internal/lockfile/lockfile_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package lockfile_test
5+
6+
import (
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"github.com/microsoft/azure-linux-dev-tools/internal/lockfile"
12+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
13+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
14+
"github.com/spf13/afero"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
const testProjectDir = "/project"
20+
21+
func TestNew(t *testing.T) {
22+
lf := lockfile.New()
23+
assert.Equal(t, 1, lf.Version)
24+
assert.NotNil(t, lf.Components)
25+
assert.Empty(t, lf.Components)
26+
}
27+
28+
func TestSetAndGetUpstreamCommit(t *testing.T) {
29+
lf := lockfile.New()
30+
31+
lf.SetUpstreamCommit("curl", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
32+
33+
commit, ok := lf.GetUpstreamCommit("curl")
34+
assert.True(t, ok)
35+
assert.Equal(t, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", commit)
36+
}
37+
38+
func TestGetUpstreamCommitMissing(t *testing.T) {
39+
lf := lockfile.New()
40+
41+
commit, ok := lf.GetUpstreamCommit("nonexistent")
42+
assert.False(t, ok)
43+
assert.Empty(t, commit)
44+
}
45+
46+
func TestSaveAndLoad(t *testing.T) {
47+
memFS := afero.NewMemMapFs()
48+
lockPath := filepath.Join(testProjectDir, lockfile.FileName)
49+
50+
require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir))
51+
52+
// Create and save a lock file.
53+
original := lockfile.New()
54+
original.SetUpstreamCommit("curl", "aaaa")
55+
original.SetUpstreamCommit("bash", "bbbb")
56+
original.SetUpstreamCommit("vim", "cccc")
57+
58+
require.NoError(t, original.Save(memFS, lockPath))
59+
60+
// Load it back.
61+
loaded, err := lockfile.Load(memFS, lockPath)
62+
require.NoError(t, err)
63+
64+
assert.Equal(t, 1, loaded.Version)
65+
66+
commit, found := loaded.GetUpstreamCommit("curl")
67+
assert.True(t, found)
68+
assert.Equal(t, "aaaa", commit)
69+
70+
commit, found = loaded.GetUpstreamCommit("bash")
71+
assert.True(t, found)
72+
assert.Equal(t, "bbbb", commit)
73+
74+
commit, found = loaded.GetUpstreamCommit("vim")
75+
assert.True(t, found)
76+
assert.Equal(t, "cccc", commit)
77+
}
78+
79+
func TestSaveSortsComponents(t *testing.T) {
80+
memFS := afero.NewMemMapFs()
81+
lockPath := filepath.Join(testProjectDir, lockfile.FileName)
82+
83+
require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir))
84+
85+
lockFile := lockfile.New()
86+
// Insert in non-alphabetical order.
87+
lockFile.SetUpstreamCommit("zlib", "zzzz")
88+
lockFile.SetUpstreamCommit("curl", "aaaa")
89+
lockFile.SetUpstreamCommit("bash", "bbbb")
90+
91+
require.NoError(t, lockFile.Save(memFS, lockPath))
92+
93+
data, err := fileutils.ReadFile(memFS, lockPath)
94+
require.NoError(t, err)
95+
96+
content := string(data)
97+
98+
// bash should appear before curl, which should appear before zlib.
99+
bashIdx := strings.Index(content, "[components.bash]")
100+
curlIdx := strings.Index(content, "[components.curl]")
101+
zlibIdx := strings.Index(content, "[components.zlib]")
102+
103+
assert.Less(t, bashIdx, curlIdx, "bash should come before curl")
104+
assert.Less(t, curlIdx, zlibIdx, "curl should come before zlib")
105+
}
106+
107+
func TestLoadUnsupportedVersion(t *testing.T) {
108+
memFS := afero.NewMemMapFs()
109+
lockPath := filepath.Join(testProjectDir, lockfile.FileName)
110+
111+
content := "version = 99\n"
112+
113+
require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir))
114+
require.NoError(t, fileutils.WriteFile(memFS, lockPath, []byte(content), fileperms.PublicFile))
115+
116+
_, err := lockfile.Load(memFS, lockPath)
117+
assert.ErrorContains(t, err, "unsupported lock file version")
118+
}
119+
120+
func TestLoadMissingFile(t *testing.T) {
121+
fs := afero.NewMemMapFs()
122+
123+
_, err := lockfile.Load(fs, "/nonexistent/azldev.lock")
124+
assert.Error(t, err)
125+
}
126+
127+
func TestLoadInvalidTOML(t *testing.T) {
128+
memFS := afero.NewMemMapFs()
129+
lockPath := filepath.Join(testProjectDir, lockfile.FileName)
130+
131+
require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir))
132+
require.NoError(t, fileutils.WriteFile(memFS, lockPath, []byte("not valid toml {{{"), fileperms.PublicFile))
133+
134+
_, err := lockfile.Load(memFS, lockPath)
135+
assert.ErrorContains(t, err, "parsing lock file")
136+
}
137+
138+
func TestSaveContainsVersion(t *testing.T) {
139+
memFS := afero.NewMemMapFs()
140+
lockPath := filepath.Join(testProjectDir, lockfile.FileName)
141+
142+
require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir))
143+
144+
lockFile := lockfile.New()
145+
require.NoError(t, lockFile.Save(memFS, lockPath))
146+
147+
data, err := fileutils.ReadFile(memFS, lockPath)
148+
require.NoError(t, err)
149+
150+
assert.Contains(t, string(data), "version = 1")
151+
assert.Contains(t, string(data), "# azldev.lock")
152+
}
153+
154+
func TestRoundTripLocalComponent(t *testing.T) {
155+
memFS := afero.NewMemMapFs()
156+
lockPath := filepath.Join(testProjectDir, lockfile.FileName)
157+
158+
require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir))
159+
160+
// Create a lock file with a local component (empty upstream commit)
161+
// alongside an upstream component.
162+
original := lockfile.New()
163+
original.SetUpstreamCommit("curl", "aaaa")
164+
original.Components["local-pkg"] = lockfile.ComponentLock{}
165+
166+
require.NoError(t, original.Save(memFS, lockPath))
167+
168+
// Load it back and verify both entries survived.
169+
loaded, err := lockfile.Load(memFS, lockPath)
170+
require.NoError(t, err)
171+
172+
// Upstream component round-trips with its commit.
173+
commit, found := loaded.GetUpstreamCommit("curl")
174+
assert.True(t, found)
175+
assert.Equal(t, "aaaa", commit)
176+
177+
// Local component has an entry but no upstream commit.
178+
_, hasEntry := loaded.Components["local-pkg"]
179+
assert.True(t, hasEntry, "local component entry should survive round-trip")
180+
181+
commit, found = loaded.GetUpstreamCommit("local-pkg")
182+
assert.False(t, found, "local component should not have an upstream commit")
183+
assert.Empty(t, commit)
184+
}

0 commit comments

Comments
 (0)