diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go new file mode 100644 index 0000000..dd1c6f7 --- /dev/null +++ b/internal/lockfile/lockfile.go @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package lockfile reads and writes azldev.lock files, which pin resolved +// upstream commit hashes for deterministic builds. The lock file is a TOML +// file at the project root, managed by [azldev component update]. +package lockfile + +import ( + "fmt" + "strings" + + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + toml "github.com/pelletier/go-toml/v2" +) + +// FileName is the lock file name, placed at the project root. +const FileName = "azldev.lock" + +// currentVersion is the lock file format version. +const currentVersion = 1 + +// LockFile holds the parsed contents of an azldev.lock file. +type LockFile struct { + // Version is the lock file format version. + Version int `toml:"version" comment:"azldev.lock - Managed by azldev component update. Do not edit manually."` + // Components maps component name → locked state. + Components map[string]ComponentLock `toml:"components"` +} + +// ComponentLock holds the locked state for a single component. +// Upstream components have [ComponentLock.UpstreamCommit] set to the resolved +// commit hash. Local components have an entry but with an empty commit field. +type ComponentLock struct { + // UpstreamCommit is the resolved full commit hash from the upstream dist-git. + // Empty for local components. + UpstreamCommit string `toml:"upstream-commit,omitempty"` +} + +// New creates an empty lock file with the current format version. +func New() *LockFile { + return &LockFile{ + Version: currentVersion, + Components: make(map[string]ComponentLock), + } +} + +// Load reads and parses a lock file from the given path. Returns an error if the +// file cannot be read or parsed, or if the format version is unsupported. +func Load(fs opctx.FS, path string) (*LockFile, error) { + data, err := fileutils.ReadFile(fs, path) + if err != nil { + return nil, fmt.Errorf("reading lock file %#q:\n%w", path, err) + } + + var lockFile LockFile + if err := toml.Unmarshal(data, &lockFile); err != nil { + return nil, fmt.Errorf("parsing lock file %#q:\n%w", path, err) + } + + if lockFile.Version != currentVersion { + return nil, fmt.Errorf( + // Backwards compatibility is a future consideration if we need to make non-compatible changes. + // For now, we can just error on unsupported versions. + "unsupported lock file version %d in %#q (expected %d)", + lockFile.Version, path, currentVersion) + } + + if lockFile.Components == nil { + lockFile.Components = make(map[string]ComponentLock) + } + + return &lockFile, nil +} + +// Save writes the lock file to the given path. [toml.Marshal] sorts map keys +// alphabetically, producing deterministic output. Additionally, we post-process the output to insert extra blank lines +// between component entries, which helps reduce git merge conflicts when parallel PRs modify adjacent entries. +func (lockFile *LockFile) Save(fs opctx.FS, path string) error { + data, err := toml.Marshal(lockFile) + if err != nil { + return fmt.Errorf("marshaling lock file:\n%w", err) + } + + // Post-process: insert extra blank lines before each [components.] header. + // This helps reduce git merge conflicts when parallel PRs modify adjacent entries. + output := addPerComponentPadding(string(data)) + + if err := fileutils.WriteFile(fs, path, []byte(output), fileperms.PublicFile); err != nil { + return fmt.Errorf("writing lock file %#q:\n%w", path, err) + } + + return nil +} + +// addPerComponentPadding inserts extra blank lines between component entries in the marshaled TOML output. This +// padding prevents git merge conflicts when parallel PRs add, remove, or modify adjacent component entries — git's +// default 3-line diff context won't overlap between padded entries. +// +// This is a best-effort approach, and won't prevent all conflicts (e.g. if two PRs modify the same component entry), +// but it should help in the common case of parallel PRs modifying different components. +// The other option would be to have each component in a separate file, but that adds complexity and overhead +// to the loading process, and clutters the project with more files. The files cannot live in the rendered specs +// directory since they are required to detect changes in package state and would be removed by the rendering process or +// a manual folder removal. +func addPerComponentPadding(tomlData string) string { + const prefix = "[components." + + var result strings.Builder + + result.Grow(len(tomlData)) + + for line := range strings.SplitSeq(tomlData, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), prefix) { + // Add extra blank lines before each component section header. + result.WriteString("\n\n") + } + + result.WriteString(line) + result.WriteString("\n") + } + + return result.String() +} + +// SetUpstreamCommit sets the locked upstream commit for a component. +func (lockFile *LockFile) SetUpstreamCommit(componentName, commitHash string) { + if lockFile.Components == nil { + lockFile.Components = make(map[string]ComponentLock) + } + + entry := lockFile.Components[componentName] + entry.UpstreamCommit = commitHash + lockFile.Components[componentName] = entry +} + +// GetUpstreamCommit returns the locked upstream commit for a component. +// Returns empty string and false if the component has no lock entry or +// if the entry has an empty upstream commit. +func (lockFile *LockFile) GetUpstreamCommit(componentName string) (string, bool) { + entry, ok := lockFile.Components[componentName] + if !ok || entry.UpstreamCommit == "" { + return "", false + } + + return entry.UpstreamCommit, true +} diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go new file mode 100644 index 0000000..42d7251 --- /dev/null +++ b/internal/lockfile/lockfile_test.go @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package lockfile_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/lockfile" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testProjectDir = "/project" + +func TestNew(t *testing.T) { + lf := lockfile.New() + assert.Equal(t, 1, lf.Version) + assert.NotNil(t, lf.Components) + assert.Empty(t, lf.Components) +} + +func TestSetAndGetUpstreamCommit(t *testing.T) { + lf := lockfile.New() + + lf.SetUpstreamCommit("curl", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + + commit, ok := lf.GetUpstreamCommit("curl") + assert.True(t, ok) + assert.Equal(t, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", commit) +} + +func TestGetUpstreamCommitMissing(t *testing.T) { + lf := lockfile.New() + + commit, ok := lf.GetUpstreamCommit("nonexistent") + assert.False(t, ok) + assert.Empty(t, commit) +} + +func TestSaveAndLoad(t *testing.T) { + memFS := afero.NewMemMapFs() + lockPath := filepath.Join(testProjectDir, lockfile.FileName) + + require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) + + // Create and save a lock file. + original := lockfile.New() + original.SetUpstreamCommit("curl", "aaaa") + original.SetUpstreamCommit("bash", "bbbb") + original.SetUpstreamCommit("vim", "cccc") + + require.NoError(t, original.Save(memFS, lockPath)) + + // Load it back. + loaded, err := lockfile.Load(memFS, lockPath) + require.NoError(t, err) + + assert.Equal(t, 1, loaded.Version) + + commit, found := loaded.GetUpstreamCommit("curl") + assert.True(t, found) + assert.Equal(t, "aaaa", commit) + + commit, found = loaded.GetUpstreamCommit("bash") + assert.True(t, found) + assert.Equal(t, "bbbb", commit) + + commit, found = loaded.GetUpstreamCommit("vim") + assert.True(t, found) + assert.Equal(t, "cccc", commit) +} + +func TestSaveSortsComponents(t *testing.T) { + memFS := afero.NewMemMapFs() + lockPath := filepath.Join(testProjectDir, lockfile.FileName) + + require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) + + lockFile := lockfile.New() + // Insert in non-alphabetical order. + lockFile.SetUpstreamCommit("zlib", "zzzz") + lockFile.SetUpstreamCommit("curl", "aaaa") + lockFile.SetUpstreamCommit("bash", "bbbb") + + require.NoError(t, lockFile.Save(memFS, lockPath)) + + data, err := fileutils.ReadFile(memFS, lockPath) + require.NoError(t, err) + + content := string(data) + + // bash should appear before curl, which should appear before zlib. + bashIdx := strings.Index(content, "[components.bash]") + curlIdx := strings.Index(content, "[components.curl]") + zlibIdx := strings.Index(content, "[components.zlib]") + + assert.Less(t, bashIdx, curlIdx, "bash should come before curl") + assert.Less(t, curlIdx, zlibIdx, "curl should come before zlib") +} + +func TestLoadUnsupportedVersion(t *testing.T) { + memFS := afero.NewMemMapFs() + lockPath := filepath.Join(testProjectDir, lockfile.FileName) + + content := "version = 99\n" + + require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) + require.NoError(t, fileutils.WriteFile(memFS, lockPath, []byte(content), fileperms.PublicFile)) + + _, err := lockfile.Load(memFS, lockPath) + assert.ErrorContains(t, err, "unsupported lock file version") +} + +func TestLoadMissingFile(t *testing.T) { + fs := afero.NewMemMapFs() + + _, err := lockfile.Load(fs, "/nonexistent/azldev.lock") + assert.Error(t, err) +} + +func TestLoadInvalidTOML(t *testing.T) { + memFS := afero.NewMemMapFs() + lockPath := filepath.Join(testProjectDir, lockfile.FileName) + + require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) + require.NoError(t, fileutils.WriteFile(memFS, lockPath, []byte("not valid toml {{{"), fileperms.PublicFile)) + + _, err := lockfile.Load(memFS, lockPath) + assert.ErrorContains(t, err, "parsing lock file") +} + +func TestSaveContainsVersion(t *testing.T) { + memFS := afero.NewMemMapFs() + lockPath := filepath.Join(testProjectDir, lockfile.FileName) + + require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) + + lockFile := lockfile.New() + require.NoError(t, lockFile.Save(memFS, lockPath)) + + data, err := fileutils.ReadFile(memFS, lockPath) + require.NoError(t, err) + + assert.Contains(t, string(data), "version = 1") + assert.Contains(t, string(data), "# azldev.lock") +} + +func TestRoundTripLocalComponent(t *testing.T) { + memFS := afero.NewMemMapFs() + lockPath := filepath.Join(testProjectDir, lockfile.FileName) + + require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) + + // Create a lock file with a local component (empty upstream commit) + // alongside an upstream component. + original := lockfile.New() + original.SetUpstreamCommit("curl", "aaaa") + original.Components["local-pkg"] = lockfile.ComponentLock{} + + require.NoError(t, original.Save(memFS, lockPath)) + + // Load it back and verify both entries survived. + loaded, err := lockfile.Load(memFS, lockPath) + require.NoError(t, err) + + // Upstream component round-trips with its commit. + commit, found := loaded.GetUpstreamCommit("curl") + assert.True(t, found) + assert.Equal(t, "aaaa", commit) + + // Local component has an entry but no upstream commit. + _, hasEntry := loaded.Components["local-pkg"] + assert.True(t, hasEntry, "local component entry should survive round-trip") + + commit, found = loaded.GetUpstreamCommit("local-pkg") + assert.False(t, found, "local component should not have an upstream commit") + assert.Empty(t, commit) +}