Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions internal/lockfile/lockfile.go
Original file line number Diff line number Diff line change
@@ -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.<name>] 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
}
184 changes: 184 additions & 0 deletions internal/lockfile/lockfile_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading