diff --git a/internal/providers/sourceproviders/fedorasourceprovider.go b/internal/providers/sourceproviders/fedorasourceprovider.go index d86117f..58e177a 100644 --- a/internal/providers/sourceproviders/fedorasourceprovider.go +++ b/internal/providers/sourceproviders/fedorasourceprovider.go @@ -221,54 +221,142 @@ func (g *FedoraSourcesProviderImpl) renameSpecIfNeeded(dir, upstreamName, compon return nil } -// checkoutTargetCommit determines the appropriate commit to use and checks it out. -// Priority order: -// 1. Explicit upstream commit hash - specified per-component via upstream-commit -// 2. Upstream distro snapshot - snapshot time from the provider's resolved distro -// 3. Default - use current HEAD (no checkout needed) +// checkoutTargetCommit resolves the effective commit via [resolveEffectiveCommitHash] +// and checks it out in the cloned repository. func (g *FedoraSourcesProviderImpl) checkoutTargetCommit( ctx context.Context, upstreamCommit string, repoDir string, ) error { + commitHash, err := g.resolveEffectiveCommitHash(ctx, repoDir, upstreamCommit, slog.LevelInfo) + if err != nil { + return err + } + + if err := g.gitProvider.Checkout(ctx, repoDir, commitHash); err != nil { + return fmt.Errorf("failed to checkout commit %#q:\n%w", commitHash, err) + } + + return nil +} + +// ResolveIdentity implements [SourceIdentityProvider] by resolving the upstream +// commit hash for the component. All resolution priority logic is in +// [resolveEffectiveCommitHash], called via [resolveCommit]. +func (g *FedoraSourcesProviderImpl) ResolveIdentity( + ctx context.Context, + component components.Component, +) (string, error) { + if component.GetName() == "" { + return "", errors.New("component name cannot be empty") + } + + upstreamName := component.GetConfig().Spec.UpstreamName + if upstreamName == "" { + upstreamName = component.GetName() + } + + gitRepoURL := strings.ReplaceAll(g.distroGitBaseURI, "$pkg", upstreamName) + + return g.resolveCommit(ctx, gitRepoURL, upstreamName, component.GetConfig().Spec.UpstreamCommit) +} + +// resolveCommit determines the effective commit via [resolveEffectiveCommitHash]. +// For pinned commits (case 1), it returns immediately without cloning. For snapshot +// and HEAD cases, it performs a metadata-only clone to resolve the commit hash. +func (g *FedoraSourcesProviderImpl) resolveCommit( + ctx context.Context, gitRepoURL string, upstreamName string, upstreamCommit string, +) (string, error) { // Case 1: Explicit upstream commit hash specified per-component if upstreamCommit != "" { - slog.Info("Using explicit upstream commit hash", - "commitHash", upstreamCommit) + return g.resolveEffectiveCommitHash(ctx, "", upstreamCommit, slog.LevelDebug) + } - if err := g.gitProvider.Checkout(ctx, repoDir, upstreamCommit); err != nil { - return fmt.Errorf("failed to checkout upstream commit %#q:\n%w", upstreamCommit, err) + // Cases 2 & 3: need a metadata-only clone to resolve snapshot or HEAD commit. + tempDir, err := fileutils.MkdirTempInTempDir(g.fs, "azldev-identity-snapshot-") + if err != nil { + return "", fmt.Errorf("creating temp directory for snapshot clone:\n%w", err) + } + + defer func() { + if removeErr := g.fs.RemoveAll(tempDir); removeErr != nil { + slog.Debug("Failed to clean up snapshot clone temp directory", + "path", tempDir, "error", removeErr) } + }() - return nil + // Clone a single branch to resolve the snapshot commit. We use a full + // (non-shallow) clone because not all git servers support --shallow-since + // (e.g., Pagure returns "the remote end hung up unexpectedly"). + err = retry.Do(ctx, g.retryConfig, func() error { + _ = g.fs.RemoveAll(tempDir) + _ = fileutils.MkdirAll(g.fs, tempDir) + + return g.gitProvider.Clone(ctx, gitRepoURL, tempDir, + git.WithGitBranch(g.distroGitBranch), + git.WithMetadataOnly(), + git.WithQuiet(), + ) + }) + if err != nil { + return "", fmt.Errorf("partial clone for identity of %#q:\n%w", upstreamName, err) + } + + commitHash, err := g.resolveEffectiveCommitHash(ctx, tempDir, "", slog.LevelDebug) + if err != nil { + return "", fmt.Errorf("resolving commit for %#q:\n%w", upstreamName, err) + } + + return commitHash, nil +} + +// resolveEffectiveCommitHash is the single source of truth for which commit a +// component should use from a cloned repository. +// +// Priority: +// 1. Explicit upstream commit hash (pinned per-component). +// 2. Snapshot time — commit immediately before the snapshot date. +// 3. Default — current HEAD. +func (g *FedoraSourcesProviderImpl) resolveEffectiveCommitHash( + ctx context.Context, + repoDir string, + upstreamCommit string, + logLevel slog.Level, +) (string, error) { + // Case 1: Explicit upstream commit hash specified per-component. + if upstreamCommit != "" { + slog.Log(ctx, logLevel, "Using explicit upstream commit hash", "commitHash", upstreamCommit) + + return upstreamCommit, nil } - // Case 2: Provider has a snapshot time configured from the resolved distro + // Case 2: Provider has a snapshot time configured from the resolved distro. if g.snapshotTime != "" { snapshotDateTime, err := time.Parse(time.RFC3339, g.snapshotTime) if err != nil { - return fmt.Errorf("invalid snapshot time %#q:\n%w", g.snapshotTime, err) + return "", fmt.Errorf("invalid snapshot time %#q:\n%w", g.snapshotTime, err) } commitHash, err := g.gitProvider.GetCommitHashBeforeDate(ctx, repoDir, snapshotDateTime) if err != nil { - return fmt.Errorf("failed to get commit hash for snapshot time %s:\n%w", + return "", fmt.Errorf("resolving commit for snapshot time %s:\n%w", snapshotDateTime.Format(time.RFC3339), err) } - slog.Info("Using upstream distro snapshot time", + slog.Log(ctx, logLevel, "Using upstream distro snapshot time", "snapshotDateTime", snapshotDateTime.Format(time.RFC3339), "commitHash", commitHash) - if err := g.gitProvider.Checkout(ctx, repoDir, commitHash); err != nil { - return fmt.Errorf("failed to checkout snapshot commit %#q:\n%w", commitHash, err) - } + return commitHash, nil + } - return nil + // Case 3: Default — use current HEAD. + commitHash, err := g.gitProvider.GetCurrentCommit(ctx, repoDir) + if err != nil { + return "", fmt.Errorf("resolving current HEAD commit:\n%w", err) } - // Case 3: Default - use current HEAD (already checked out by clone) - slog.Info("Using current HEAD (no snapshot time configured)") + slog.Log(ctx, logLevel, "Using current HEAD", "commitHash", commitHash) - return nil + return commitHash, nil } diff --git a/internal/providers/sourceproviders/fedorasourceprovider_test.go b/internal/providers/sourceproviders/fedorasourceprovider_test.go index 2e28cc7..a703f8e 100644 --- a/internal/providers/sourceproviders/fedorasourceprovider_test.go +++ b/internal/providers/sourceproviders/fedorasourceprovider_test.go @@ -169,6 +169,14 @@ func TestGetComponentFromGit(t *testing.T) { return fileutils.WriteFile(env.FS(), sourcesPath, []byte(sourcesContent), fileperms.PublicFile) }) + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return("head123abc", nil) + + mockGitProvider.EXPECT(). + Checkout(gomock.Any(), gomock.Any(), "head123abc"). + Return(nil) + provider, err := sourceproviders.NewFedoraSourcesProviderImpl( env.FS(), env.DryRunnable, @@ -255,6 +263,14 @@ func TestGetComponentFromGit(t *testing.T) { return fileutils.WriteFile(env.FS(), sourcesPath, []byte(sourcesContent), fileperms.PublicFile) }) + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return("head123abc", nil) + + mockGitProvider.EXPECT(). + Checkout(gomock.Any(), gomock.Any(), "head123abc"). + Return(nil) + const testDestDir = "/output-preexisting" provider, err := sourceproviders.NewFedoraSourcesProviderImpl( @@ -323,6 +339,14 @@ func TestGetComponentFromGit(t *testing.T) { return fileutils.WriteFile(env.FS(), specPath, []byte("Name: "+testPackageName), fileperms.PublicFile) }) + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return("head123abc", nil) + + mockGitProvider.EXPECT(). + Checkout(gomock.Any(), gomock.Any(), "head123abc"). + Return(nil) + provider, err := sourceproviders.NewFedoraSourcesProviderImpl( env.FS(), env.DryRunnable, @@ -437,6 +461,14 @@ func TestGetComponentFromGit(t *testing.T) { Clone(gomock.Any(), repoURL, gomock.Any(), gomock.Any()). Return(nil) + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return("head123abc", nil) + + mockGitProvider.EXPECT(). + Checkout(gomock.Any(), gomock.Any(), "head123abc"). + Return(nil) + // But extractor fails - note it receives the component name, not destDir extractorError := errors.New("extraction failed") mockExtractor.EXPECT(). @@ -490,6 +522,14 @@ func TestGetComponentFromGit(t *testing.T) { ) }) + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return("head123abc", nil) + + mockGitProvider.EXPECT(). + Checkout(gomock.Any(), gomock.Any(), "head123abc"). + Return(nil) + // Extractor succeeds mockExtractor.EXPECT(). ExtractSourcesFromRepo(gomock.Any(), gomock.Any(), upstreamName, gomock.Any(), gomock.Any()). @@ -544,6 +584,14 @@ func TestGetComponentFromGit(t *testing.T) { Clone(gomock.Any(), upstreamRepoURL, gomock.Any(), gomock.Any()). Return(nil) + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return("head123abc", nil) + + mockGitProvider.EXPECT(). + Checkout(gomock.Any(), gomock.Any(), "head123abc"). + Return(nil) + // Extractor succeeds mockExtractor.EXPECT(). ExtractSourcesFromRepo(gomock.Any(), gomock.Any(), upstreamName, gomock.Any(), gomock.Any()). @@ -652,7 +700,17 @@ func TestCheckoutTargetCommit(t *testing.T) { return fileutils.WriteFile(env.FS(), specPath, []byte("Name: "+testPackageName), fileperms.PublicFile) }) - // Should NOT call GetCommitHashBeforeDate or Checkout - uses HEAD from clone + headCommitHash := "head123abc" + + // GetCurrentCommit is called to resolve HEAD + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return(headCommitHash, nil) + + // Then checkout that commit + mockGitProvider.EXPECT(). + Checkout(gomock.Any(), gomock.Any(), headCommitHash). + Return(nil) // Extractor succeeds mockExtractor.EXPECT(). @@ -700,7 +758,7 @@ func TestCheckoutTargetCommit(t *testing.T) { err = provider.GetComponent(context.Background(), mockComponent, destDir) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get commit hash for snapshot time") + assert.Contains(t, err.Error(), "resolving commit for snapshot time") assert.ErrorIs(t, err, hashError) }) @@ -747,7 +805,7 @@ func TestCheckoutTargetCommit(t *testing.T) { err = provider.GetComponent(context.Background(), mockComponent, destDir) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to checkout snapshot commit") + assert.Contains(t, err.Error(), "failed to checkout commit") assert.ErrorIs(t, err, checkoutError) }) @@ -929,7 +987,7 @@ func TestCheckoutTargetCommit_UpstreamCommit(t *testing.T) { err = provider.GetComponent(context.Background(), mockComponent, destDir) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to checkout upstream commit") + assert.Contains(t, err.Error(), "failed to checkout commit") assert.ErrorIs(t, err, checkoutError) }) } diff --git a/internal/providers/sourceproviders/identityprovider_test.go b/internal/providers/sourceproviders/identityprovider_test.go new file mode 100644 index 0000000..ef1b399 --- /dev/null +++ b/internal/providers/sourceproviders/identityprovider_test.go @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sourceproviders_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components/components_testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/providers/rpmprovider/rpmprovider_test" + "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" + "github.com/microsoft/azure-linux-dev-tools/internal/rpm/rpm_test" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/git/git_test" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/retry" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// --- ResolveLocalSourceIdentity tests --- + +func TestResolveLocalSourceIdentity_EmptyDir(t *testing.T) { + _, err := sourceproviders.ResolveLocalSourceIdentity(afero.NewMemMapFs(), "") + require.Error(t, err) +} + +func TestResolveLocalSourceIdentity_NoFiles(t *testing.T) { + filesystem := afero.NewMemMapFs() + require.NoError(t, filesystem.MkdirAll("/specs", fileperms.PublicDir)) + + _, err := sourceproviders.ResolveLocalSourceIdentity(filesystem, "/specs") + require.Error(t, err) + assert.Contains(t, err.Error(), "contains no files") +} + +func TestResolveLocalSourceIdentity_Deterministic(t *testing.T) { + filesystem := afero.NewMemMapFs() + require.NoError(t, fileutils.WriteFile(filesystem, "/specs/test.spec", + []byte("Name: test\nVersion: 1.0"), fileperms.PublicFile)) + + identity1, err := sourceproviders.ResolveLocalSourceIdentity(filesystem, "/specs") + require.NoError(t, err) + + identity2, err := sourceproviders.ResolveLocalSourceIdentity(filesystem, "/specs") + require.NoError(t, err) + + assert.Equal(t, identity1, identity2) + assert.NotEmpty(t, identity1) + assert.Contains(t, identity1, "sha256:", "identity should have sha256: prefix") +} + +func TestResolveLocalSourceIdentity_ContentChange(t *testing.T) { + fs1 := afero.NewMemMapFs() + require.NoError(t, fileutils.WriteFile(fs1, "/specs/test.spec", []byte("Version: 1.0"), fileperms.PublicFile)) + + fs2 := afero.NewMemMapFs() + require.NoError(t, fileutils.WriteFile(fs2, "/specs/test.spec", []byte("Version: 2.0"), fileperms.PublicFile)) + + identity1, err := sourceproviders.ResolveLocalSourceIdentity(fs1, "/specs") + require.NoError(t, err) + + identity2, err := sourceproviders.ResolveLocalSourceIdentity(fs2, "/specs") + require.NoError(t, err) + + assert.NotEqual(t, identity1, identity2) +} + +func TestResolveLocalSourceIdentity_SidecarFileChangesIdentity(t *testing.T) { + fsSpecOnly := afero.NewMemMapFs() + require.NoError(t, fileutils.WriteFile(fsSpecOnly, "/specs/test.spec", []byte("spec"), fileperms.PublicFile)) + + fsWithPatch := afero.NewMemMapFs() + require.NoError(t, fileutils.WriteFile(fsWithPatch, "/specs/test.spec", []byte("spec"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(fsWithPatch, "/specs/fix.patch", []byte("patch"), fileperms.PublicFile)) + + identity1, err := sourceproviders.ResolveLocalSourceIdentity(fsSpecOnly, "/specs") + require.NoError(t, err) + + identity2, err := sourceproviders.ResolveLocalSourceIdentity(fsWithPatch, "/specs") + require.NoError(t, err) + + assert.NotEqual(t, identity1, identity2, "adding a sidecar file must change identity") +} + +// --- FedoraSourcesProviderImpl.ResolveIdentity tests --- + +func TestFedoraProvider_ResolveIdentity(t *testing.T) { + ctrl := gomock.NewController(t) + mockGitProvider := git_test.NewMockGitProvider(ctrl) + + provider, err := sourceproviders.NewFedoraSourcesProviderImpl( + afero.NewMemMapFs(), + newNoOpDryRunnable(), + mockGitProvider, + newNoOpDownloader(), + testResolvedDistro(), + retry.Disabled(), + ) + require.NoError(t, err) + + t.Run("resolves commit via clone", func(t *testing.T) { + expectedCommit := "abc123def456" + + // Expect: metadata-only clone, then GetCurrentCommit. + mockGitProvider.EXPECT(). + Clone(gomock.Any(), repoURL, gomock.Any(), gomock.Any()). + Return(nil) + mockGitProvider.EXPECT(). + GetCurrentCommit(gomock.Any(), gomock.Any()). + Return(expectedCommit, nil) + + comp := newMockComp(ctrl, testPackageName) + identity, resolveErr := provider.ResolveIdentity(t.Context(), comp) + require.NoError(t, resolveErr) + assert.Equal(t, expectedCommit, identity) + }) + + t.Run("returns error on clone failure", func(t *testing.T) { + mockGitProvider.EXPECT(). + Clone(gomock.Any(), repoURL, gomock.Any(), gomock.Any()). + Return(errors.New("network error")) + + comp := newMockComp(ctrl, testPackageName) + _, resolveErr := provider.ResolveIdentity(t.Context(), comp) + require.Error(t, resolveErr) + assert.Contains(t, resolveErr.Error(), testPackageName) + }) + + t.Run("returns pinned commit without network call", func(t *testing.T) { + pinnedCommit := "deadbeef12345678" + comp := newMockCompWithConfig(ctrl, testPackageName, &projectconfig.ComponentConfig{ + Name: testPackageName, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + UpstreamCommit: pinnedCommit, + }, + }) + + // No LsRemoteHead expectation — the pinned commit should be returned directly. + identity, resolveErr := provider.ResolveIdentity(t.Context(), comp) + require.NoError(t, resolveErr) + assert.Equal(t, pinnedCommit, identity) + }) +} + +func TestFedoraProvider_ResolveIdentity_Snapshot(t *testing.T) { + ctrl := gomock.NewController(t) + mockGitProvider := git_test.NewMockGitProvider(ctrl) + + snapshotTimeStr := "2025-06-15T00:00:00Z" + snapshotTime, _ := time.Parse(time.RFC3339, snapshotTimeStr) + + provider, err := sourceproviders.NewFedoraSourcesProviderImpl( + afero.NewMemMapFs(), + newNoOpDryRunnable(), + mockGitProvider, + newNoOpDownloader(), + testResolvedDistroWithSnapshot(snapshotTimeStr), + retry.Disabled(), + ) + require.NoError(t, err) + + t.Run("resolves commit via clone for snapshot", func(t *testing.T) { + expectedCommit := "snapshot123abc" + + // Expect: full single-branch clone, then rev-list --before. + mockGitProvider.EXPECT(). + Clone(gomock.Any(), repoURL, gomock.Any(), + gomock.Any()). // branch option + Return(nil) + mockGitProvider.EXPECT(). + GetCommitHashBeforeDate(gomock.Any(), gomock.Any(), snapshotTime). + Return(expectedCommit, nil) + + comp := newMockComp(ctrl, testPackageName) + identity, resolveErr := provider.ResolveIdentity(t.Context(), comp) + require.NoError(t, resolveErr) + assert.Equal(t, expectedCommit, identity) + }) + + t.Run("pinned commit takes priority over snapshot", func(t *testing.T) { + pinnedCommit := "pinned999" + comp := newMockCompWithConfig(ctrl, testPackageName, &projectconfig.ComponentConfig{ + Name: testPackageName, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + UpstreamCommit: pinnedCommit, + }, + }) + + // No Clone/Deepen/GetCommitHashBeforeDate expectations — pinned commit is returned directly. + identity, resolveErr := provider.ResolveIdentity(t.Context(), comp) + require.NoError(t, resolveErr) + assert.Equal(t, pinnedCommit, identity) + }) +} + +// --- RPMContentsProviderImpl.ResolveIdentity tests --- + +func TestRPMProvider_ResolveIdentity(t *testing.T) { + ctrl := gomock.NewController(t) + + t.Run("hashes downloaded RPM", func(t *testing.T) { + rpmContent := "test-rpm-file-content" + mockRPMProvider := rpmprovider_test.NewMockRPMProvider(ctrl) + mockRPMProvider.EXPECT(). + GetRPM(gomock.Any(), "test-pkg", nil). + Return(io.NopCloser(strings.NewReader(rpmContent)), nil) + + provider, provErr := sourceproviders.NewRPMContentsProviderImpl( + rpm_test.NewMockRPMExtractor(ctrl), mockRPMProvider) + require.NoError(t, provErr) + + comp := newMockComp(ctrl, "test-pkg") + identity, resolveErr := provider.ResolveIdentity(t.Context(), comp) + require.NoError(t, resolveErr) + assert.Equal(t, "sha256:"+sha256Hex(rpmContent), identity) + }) + + t.Run("returns error on RPM download failure", func(t *testing.T) { + mockRPMProvider := rpmprovider_test.NewMockRPMProvider(ctrl) + mockRPMProvider.EXPECT(). + GetRPM(gomock.Any(), "test-pkg", nil). + Return(nil, errors.New("download failed")) + + provider, provErr := sourceproviders.NewRPMContentsProviderImpl( + rpm_test.NewMockRPMExtractor(ctrl), mockRPMProvider) + require.NoError(t, provErr) + + comp := newMockComp(ctrl, "test-pkg") + _, resolveErr := provider.ResolveIdentity(t.Context(), comp) + require.Error(t, resolveErr) + assert.Contains(t, resolveErr.Error(), "test-pkg") + }) +} + +// --- Helpers --- + +// newMockComp creates a mock component with the given name and an empty upstream config. +func newMockComp(ctrl *gomock.Controller, name string) *components_testutils.MockComponent { + return newMockCompWithConfig(ctrl, name, &projectconfig.ComponentConfig{ + Name: name, + Spec: projectconfig.SpecSource{}, + }) +} + +// newMockCompWithConfig creates a mock component with the given name and a custom config. +func newMockCompWithConfig( + ctrl *gomock.Controller, name string, config *projectconfig.ComponentConfig, +) *components_testutils.MockComponent { + comp := components_testutils.NewMockComponent(ctrl) + comp.EXPECT().GetName().AnyTimes().Return(name) + comp.EXPECT().GetConfig().AnyTimes().Return(config) + + return comp +} + +func sha256Hex(content string) string { + hasher := sha256.New() + fmt.Fprint(hasher, content) + + return hex.EncodeToString(hasher.Sum(nil)) +} + +// newNoOpDryRunnable returns a mock that reports dry-run as false. +func newNoOpDryRunnable() *opctxNoOpDryRunnable { + return &opctxNoOpDryRunnable{} +} + +type opctxNoOpDryRunnable struct{} + +func (d *opctxNoOpDryRunnable) DryRun() bool { return false } + +// newNoOpDownloader returns a stub FedoraSourceDownloader that does nothing. +func newNoOpDownloader() *noOpDownloader { + return &noOpDownloader{} +} + +type noOpDownloader struct{} + +func (d *noOpDownloader) ExtractSourcesFromRepo( + _ context.Context, _, _, _ string, _ []string, +) error { + return nil +} diff --git a/internal/providers/sourceproviders/localidentity.go b/internal/providers/sourceproviders/localidentity.go new file mode 100644 index 0000000..e64bf04 --- /dev/null +++ b/internal/providers/sourceproviders/localidentity.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sourceproviders + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "path/filepath" + "sort" + + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/spf13/afero" +) + +// ResolveLocalSourceIdentity computes a SHA256 hash over all files in the given +// spec directory (spec file + sidecar files like patches and scripts). +// Files are sorted by path for determinism. Returns an empty string if the +// directory contains no files. +func ResolveLocalSourceIdentity(filesystem opctx.FS, specDir string) (string, error) { + if specDir == "" { + return "", errors.New("spec directory cannot be empty") + } + + // Collect all files in the spec directory. + var filePaths []string + + err := afero.Walk(filesystem, specDir, + func(path string, info fs.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if !info.IsDir() { + filePaths = append(filePaths, path) + } + + return nil + }) + if err != nil { + return "", fmt.Errorf("walking spec directory %#q:\n%w", specDir, err) + } + + if len(filePaths) == 0 { + return "", fmt.Errorf("spec directory %#q contains no files", specDir) + } + + // Sort for determinism across runs. + sort.Strings(filePaths) + + // Hash each file and combine into a single digest. + combinedHasher := sha256.New() + + for _, filePath := range filePaths { + fileHash, hashErr := fileutils.ComputeFileHash( + filesystem, fileutils.HashTypeSHA256, filePath, + ) + if hashErr != nil { + return "", fmt.Errorf("hashing file %#q:\n%w", filePath, hashErr) + } + + relPath, relErr := filepath.Rel(specDir, filePath) + if relErr != nil { + return "", fmt.Errorf("computing relative path for %#q:\n%w", filePath, relErr) + } + + fmt.Fprintf(combinedHasher, "%s=%s\n", relPath, fileHash) + } + + return "sha256:" + hex.EncodeToString(combinedHasher.Sum(nil)), nil +} diff --git a/internal/providers/sourceproviders/rpmcontentsprovider.go b/internal/providers/sourceproviders/rpmcontentsprovider.go index a02525c..0807f7e 100644 --- a/internal/providers/sourceproviders/rpmcontentsprovider.go +++ b/internal/providers/sourceproviders/rpmcontentsprovider.go @@ -5,8 +5,11 @@ package sourceproviders import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" + "io" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" "github.com/microsoft/azure-linux-dev-tools/internal/providers/rpmprovider" @@ -73,3 +76,32 @@ func (r *RPMContentsProviderImpl) GetComponent( return nil } + +// ResolveIdentity implements [SourceIdentityProvider] by downloading the source RPM +// and computing its SHA256 hash. This is a heavyweight operation since it requires a full +// RPM download. +func (r *RPMContentsProviderImpl) ResolveIdentity( + ctx context.Context, + component components.Component, +) (identity string, err error) { + if component.GetName() == "" { + return "", errors.New("component name cannot be empty") + } + + rpmReader, err := r.rpmProvider.GetRPM(ctx, component.GetName(), nil) + if err != nil { + return "", fmt.Errorf("failed to get RPM for identity of component %#q:\n%w", + component.GetName(), err) + } + + defer defers.HandleDeferError(rpmReader.Close, &err) + + hasher := sha256.New() + + if _, err := io.Copy(hasher, rpmReader); err != nil { + return "", fmt.Errorf("failed to hash RPM for component %#q:\n%w", + component.GetName(), err) + } + + return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/internal/providers/sourceproviders/sourcemanager.go b/internal/providers/sourceproviders/sourcemanager.go index 51fe503..13c2fc5 100644 --- a/internal/providers/sourceproviders/sourcemanager.go +++ b/internal/providers/sourceproviders/sourcemanager.go @@ -37,6 +37,18 @@ type FileSourceProvider interface { GetFiles(ctx context.Context, fileRefs []projectconfig.SourceFileReference, destDirPath string) error } +// SourceIdentityProvider resolves a reproducible identity string for a component's source. +// The identity changes whenever the source content would change — the exact representation +// depends on the source type (e.g., a commit hash for dist-git, a content hash for local files). +// +// Consumers should treat the returned string as opaque; it is only meaningful for equality +// comparison between two runs. +type SourceIdentityProvider interface { + // ResolveIdentity returns a deterministic identity string for the component's source. + // Returns an error if the identity cannot be determined (e.g., network failure for upstream sources). + ResolveIdentity(ctx context.Context, component components.Component) (string, error) +} + // FetchComponentOptions holds optional parameters for component fetching operations. type FetchComponentOptions struct { // PreserveGitDir, when true, instructs the provider to keep the upstream .git directory @@ -72,9 +84,10 @@ func resolveFetchComponentOptions(opts []FetchComponentOption) FetchComponentOpt } // ComponentSourceProvider is an abstract interface implemented by a source provider that can retrieve the -// full file contents of a given component. +// full file contents of a given component or calculate an identity. type ComponentSourceProvider interface { Provider + SourceIdentityProvider // GetComponent retrieves the `.spec` for the specified component along with any sidecar // files stored along with it, placing the fetched files in the provided directory. @@ -96,6 +109,11 @@ type SourceManager interface { ctx context.Context, component components.Component, destDirPath string, opts ...FetchComponentOption, ) error + + // ResolveSourceIdentity returns a deterministic identity string for the component's source. + // For local components, this is a content hash of the spec directory. + // For upstream components, this is the resolved commit hash from the dist-git provider. + ResolveSourceIdentity(ctx context.Context, component components.Component) (string, error) } // ResolvedDistro holds the fully resolved distro configuration for a component. @@ -443,6 +461,55 @@ func (m *sourceManager) FetchComponent( component.GetName()) } +func (m *sourceManager) ResolveSourceIdentity( + ctx context.Context, component components.Component, +) (string, error) { + if component.GetName() == "" { + return "", errors.New("component name is empty") + } + + sourceType := component.GetConfig().Spec.SourceType + + switch sourceType { + case projectconfig.SpecSourceTypeLocal, projectconfig.SpecSourceTypeUnspecified: + specPath := component.GetConfig().Spec.Path + if specPath == "" { + return "", fmt.Errorf("component %#q has no spec path configured", component.GetName()) + } + + return ResolveLocalSourceIdentity(m.fs, filepath.Dir(specPath)) + + case projectconfig.SpecSourceTypeUpstream: + return m.resolveUpstreamSourceIdentity(ctx, component) + } + + return "", fmt.Errorf("no identity provider for source type %#q on component %#q", + sourceType, component.GetName()) +} + +func (m *sourceManager) resolveUpstreamSourceIdentity( + ctx context.Context, component components.Component, +) (string, error) { + if len(m.upstreamComponentProviders) == 0 { + return "", fmt.Errorf("no upstream providers configured for component %#q", + component.GetName()) + } + + var lastError error + + for _, provider := range m.upstreamComponentProviders { + identity, err := provider.ResolveIdentity(ctx, component) + if err == nil { + return identity, nil + } + + lastError = err + } + + return "", fmt.Errorf("failed to resolve source identity for upstream component %#q:\n%w", + component.GetName(), lastError) +} + func (m *sourceManager) fetchLocalComponent( ctx context.Context, component components.Component, destDirPath string, ) error { diff --git a/internal/providers/sourceproviders/sourcemanager_test.go b/internal/providers/sourceproviders/sourcemanager_test.go index fa94707..0ec66a9 100644 --- a/internal/providers/sourceproviders/sourcemanager_test.go +++ b/internal/providers/sourceproviders/sourcemanager_test.go @@ -16,6 +16,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -369,3 +370,140 @@ func TestSourceManager_FetchFiles_Errors(t *testing.T) { }) } } + +func TestSourceManager_ResolveSourceIdentity_EmptyComponentName(t *testing.T) { + env := testutils.NewTestEnv(t) + ctrl := gomock.NewController(t) + component := components_testutils.NewMockComponent(ctrl) + + component.EXPECT().GetName().Return("") + + sourceManager, err := sourceproviders.NewSourceManager(env.Env, testDefaultDistro()) + require.NoError(t, err) + + _, err = sourceManager.ResolveSourceIdentity(t.Context(), component) + require.Error(t, err) + require.Contains(t, err.Error(), "component name is empty") +} + +func TestSourceManager_ResolveSourceIdentity_LocalNoSpecPath(t *testing.T) { + env := testutils.NewTestEnv(t) + ctrl := gomock.NewController(t) + component := components_testutils.NewMockComponent(ctrl) + + componentConfig := &projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + }, + } + + component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(componentConfig) + + sourceManager, err := sourceproviders.NewSourceManager(env.Env, testDefaultDistro()) + require.NoError(t, err) + + _, err = sourceManager.ResolveSourceIdentity(t.Context(), component) + require.Error(t, err) + require.Contains(t, err.Error(), "no spec path configured") +} + +func TestSourceManager_ResolveSourceIdentity_LocalSuccess(t *testing.T) { + env := testutils.NewTestEnv(t) + ctrl := gomock.NewController(t) + component := components_testutils.NewMockComponent(ctrl) + + specContent := []byte("Name: test\nVersion: 1.0\n") + require.NoError(t, fileutils.WriteFile(env.TestFS, "/specs/test.spec", specContent, fileperms.PrivateFile)) + + componentConfig := &projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/specs/test.spec", + }, + } + + component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(componentConfig) + + sourceManager, err := sourceproviders.NewSourceManager(env.Env, testDefaultDistro()) + require.NoError(t, err) + + identity, err := sourceManager.ResolveSourceIdentity(t.Context(), component) + require.NoError(t, err) + assert.Contains(t, identity, "sha256:") +} + +func TestSourceManager_ResolveSourceIdentity_UpstreamNoProviders(t *testing.T) { + env := testutils.NewTestEnv(t) + ctrl := gomock.NewController(t) + component := components_testutils.NewMockComponent(ctrl) + + // Clear the distro so no upstream providers are registered. + emptyDistro := sourceproviders.ResolvedDistro{} + + componentConfig := &projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + }, + } + + component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(componentConfig) + + sourceManager, err := sourceproviders.NewSourceManager(env.Env, emptyDistro) + require.NoError(t, err) + + _, err = sourceManager.ResolveSourceIdentity(t.Context(), component) + require.Error(t, err) + require.Contains(t, err.Error(), "no upstream providers configured") +} + +func TestSourceManager_ResolveSourceIdentity_UpstreamAllProvidersFail(t *testing.T) { + env := testutils.NewTestEnv(t) + ctrl := gomock.NewController(t) + component := components_testutils.NewMockComponent(ctrl) + + componentConfig := &projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + }, + } + + component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(componentConfig) + + // Make git commands fail so all providers return errors. + env.CmdFactory.RunHandler = func(cmd *exec.Cmd) error { + return errors.New("simulated git failure") + } + + sourceManager, err := sourceproviders.NewSourceManager(env.Env, testDefaultDistro()) + require.NoError(t, err) + + _, err = sourceManager.ResolveSourceIdentity(t.Context(), component) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to resolve source identity") +} + +func TestSourceManager_ResolveSourceIdentity_UnknownSourceType(t *testing.T) { + env := testutils.NewTestEnv(t) + ctrl := gomock.NewController(t) + component := components_testutils.NewMockComponent(ctrl) + + componentConfig := &projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: "unknown-type", + }, + } + + component.EXPECT().GetName().AnyTimes().Return("test-component") + component.EXPECT().GetConfig().AnyTimes().Return(componentConfig) + + sourceManager, err := sourceproviders.NewSourceManager(env.Env, testDefaultDistro()) + require.NoError(t, err) + + _, err = sourceManager.ResolveSourceIdentity(t.Context(), component) + require.Error(t, err) + require.Contains(t, err.Error(), "no identity provider for source type") +} diff --git a/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go b/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go index db09ac8..6707ba0 100644 --- a/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go +++ b/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go @@ -74,3 +74,18 @@ func (mr *MockSourceManagerMockRecorder) FetchFiles(ctx, component, destDirPath mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchFiles", reflect.TypeOf((*MockSourceManager)(nil).FetchFiles), ctx, component, destDirPath) } + +// ResolveSourceIdentity mocks base method. +func (m *MockSourceManager) ResolveSourceIdentity(ctx context.Context, component components.Component) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveSourceIdentity", ctx, component) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveSourceIdentity indicates an expected call of ResolveSourceIdentity. +func (mr *MockSourceManagerMockRecorder) ResolveSourceIdentity(ctx, component any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveSourceIdentity", reflect.TypeOf((*MockSourceManager)(nil).ResolveSourceIdentity), ctx, component) +}