Skip to content

Commit be6c05a

Browse files
committed
feat(lock): Add lock file foundations
Add 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 d08fdc0 commit be6c05a

File tree

2 files changed

+258
-0
lines changed

2 files changed

+258
-0
lines changed

internal/lockfile/lockfile.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
12+
"github.com/microsoft/azure-linux-dev-tools/internal/global/opctx"
13+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
14+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
15+
toml "github.com/pelletier/go-toml/v2"
16+
)
17+
18+
// FileName is the lock file name, placed at the project root.
19+
const FileName = "azldev.lock"
20+
21+
// currentVersion is the lock file format version.
22+
const currentVersion = 1
23+
24+
// LockFile holds the parsed contents of an azldev.lock file.
25+
type LockFile struct {
26+
// Version is the lock file format version.
27+
Version int `toml:"version" comment:"azldev.lock - Managed by azldev component update. Do not edit manually."`
28+
// Components maps component name → locked state.
29+
Components map[string]ComponentLock `toml:"components"`
30+
}
31+
32+
// ComponentLock holds the locked state for a single component.
33+
type ComponentLock struct {
34+
// UpstreamCommit is the resolved full commit hash from the upstream dist-git.
35+
// Empty for local components.
36+
UpstreamCommit string `toml:"upstream-commit,omitempty"`
37+
}
38+
39+
// New creates an empty lock file with the current format version.
40+
func New() *LockFile {
41+
return &LockFile{
42+
Version: currentVersion,
43+
Components: make(map[string]ComponentLock),
44+
}
45+
}
46+
47+
// Load reads and parses a lock file from the given path. Returns an error if the
48+
// file cannot be read or parsed, or if the format version is unsupported.
49+
func Load(fs opctx.FS, path string) (*LockFile, error) {
50+
data, err := fileutils.ReadFile(fs, path)
51+
if err != nil {
52+
return nil, fmt.Errorf("reading lock file %#q:\n%w", path, err)
53+
}
54+
55+
var lockFile LockFile
56+
if err := toml.Unmarshal(data, &lockFile); err != nil {
57+
return nil, fmt.Errorf("parsing lock file %#q:\n%w", path, err)
58+
}
59+
60+
if lockFile.Version != currentVersion {
61+
return nil, fmt.Errorf(
62+
// Backwards compatibility is a future consideration if we need to make non-compatible changes.
63+
// For now, we can just error on unsupported versions.
64+
"unsupported lock file version %d in %#q (expected %d)",
65+
lockFile.Version, path, currentVersion)
66+
}
67+
68+
if lockFile.Components == nil {
69+
lockFile.Components = make(map[string]ComponentLock)
70+
}
71+
72+
return &lockFile, nil
73+
}
74+
75+
// Save writes the lock file to the given path. [toml.Marshal] sorts map keys
76+
// alphabetically, producing deterministic output.
77+
func (lockFile *LockFile) Save(fs opctx.FS, path string) error {
78+
data, err := toml.Marshal(lockFile)
79+
if err != nil {
80+
return fmt.Errorf("marshaling lock file:\n%w", err)
81+
}
82+
83+
if err := fileutils.WriteFile(fs, path, data, fileperms.PublicFile); err != nil {
84+
return fmt.Errorf("writing lock file %#q:\n%w", path, err)
85+
}
86+
87+
return nil
88+
}
89+
90+
// SetUpstreamCommit sets the locked upstream commit for a component.
91+
func (lockFile *LockFile) SetUpstreamCommit(componentName, commitHash string) {
92+
entry := lockFile.Components[componentName]
93+
entry.UpstreamCommit = commitHash
94+
lockFile.Components[componentName] = entry
95+
}
96+
97+
// GetUpstreamCommit returns the locked upstream commit for a component.
98+
// Returns empty string and false if the component has no lock entry.
99+
func (lockFile *LockFile) GetUpstreamCommit(componentName string) (string, bool) {
100+
entry, ok := lockFile.Components[componentName]
101+
if !ok || entry.UpstreamCommit == "" {
102+
return "", false
103+
}
104+
105+
return entry.UpstreamCommit, true
106+
}

internal/lockfile/lockfile_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
}

0 commit comments

Comments
 (0)