Skip to content

Commit 695fb24

Browse files
committed
feat(Provider): Add ResolveSourceIdentity() to the source provider interface
1 parent 05270b0 commit 695fb24

File tree

6 files changed

+564
-1
lines changed

6 files changed

+564
-1
lines changed

internal/providers/sourceproviders/fedorasourceprovider.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,93 @@ func (g *FedoraSourcesProviderImpl) checkoutTargetCommit(
272272

273273
return nil
274274
}
275+
276+
// ResolveSourceIdentity implements [SourceIdentityProvider] by resolving the upstream
277+
// commit hash for the component. Resolution priority matches [checkoutTargetCommit]:
278+
// 1. Explicit upstream commit hash (pinned per-component) — returned directly.
279+
// 2. Snapshot time — shallow clone + rev-list to find the commit at the snapshot date.
280+
// 3. Default — query HEAD of the dist-git branch via ls-remote.
281+
func (g *FedoraSourcesProviderImpl) ResolveSourceIdentity(
282+
ctx context.Context,
283+
component components.Component,
284+
) (string, error) {
285+
// Case 1: Explicit upstream commit hash — no network call needed.
286+
if pinnedCommit := component.GetConfig().Spec.UpstreamCommit; pinnedCommit != "" {
287+
slog.Debug("Using pinned upstream commit for identity",
288+
"component", component.GetName(),
289+
"commit", pinnedCommit)
290+
291+
return pinnedCommit, nil
292+
}
293+
294+
// Case 2: Need to resolve the commit for the snapshot time or current HEAD
295+
upstreamName := component.GetConfig().Spec.UpstreamName
296+
if upstreamName == "" {
297+
upstreamName = component.GetName()
298+
}
299+
300+
gitRepoURL := strings.ReplaceAll(g.distroGitBaseURI, "$pkg", upstreamName)
301+
302+
return g.resolveCommit(ctx, gitRepoURL, upstreamName)
303+
}
304+
305+
// resolveCommit clones the branch and determines the effective commit, either
306+
// at the snapshot time, or at the latest commit if no snapshot time is configured.
307+
func (g *FedoraSourcesProviderImpl) resolveCommit(
308+
ctx context.Context, gitRepoURL string, upstreamName string,
309+
) (string, error) {
310+
tempDir, err := fileutils.MkdirTempInTempDir(g.fs, "azldev-identity-snapshot-")
311+
if err != nil {
312+
return "", fmt.Errorf("creating temp directory for snapshot clone:\n%w", err)
313+
}
314+
315+
defer func() {
316+
if removeErr := g.fs.RemoveAll(tempDir); removeErr != nil {
317+
slog.Debug("Failed to clean up snapshot clone temp directory",
318+
"path", tempDir, "error", removeErr)
319+
}
320+
}()
321+
322+
// Clone a single branch to resolve the snapshot commit. We use a full
323+
// (non-shallow) clone because not all git servers support --shallow-since
324+
// (e.g., Pagure returns "the remote end hung up unexpectedly").
325+
err = retry.Do(ctx, g.retryConfig, func() error {
326+
_ = g.fs.RemoveAll(tempDir)
327+
_ = fileutils.MkdirAll(g.fs, tempDir)
328+
329+
return g.gitProvider.Clone(ctx, gitRepoURL, tempDir,
330+
git.WithGitBranch(g.distroGitBranch),
331+
git.WithMetadataOnly(),
332+
git.WithQuiet(),
333+
)
334+
})
335+
if err != nil {
336+
return "", fmt.Errorf("partial clone for identity of %#q:\n%w", upstreamName, err)
337+
}
338+
339+
var commitHash string
340+
if g.snapshotTime != "" {
341+
snapshotDateTime, parseErr := time.Parse(time.RFC3339, g.snapshotTime)
342+
if parseErr != nil {
343+
return "", fmt.Errorf("invalid snapshot time %#q:\n%w", g.snapshotTime, parseErr)
344+
}
345+
346+
commitHash, err = g.gitProvider.GetCommitHashBeforeDate(ctx, tempDir, snapshotDateTime)
347+
if err != nil {
348+
return "", fmt.Errorf("resolving snapshot commit for %#q at %s:\n%w",
349+
upstreamName, snapshotDateTime.Format(time.RFC3339), err)
350+
}
351+
} else {
352+
commitHash, err = g.gitProvider.GetCurrentCommit(ctx, tempDir)
353+
if err != nil {
354+
return "", fmt.Errorf("resolving current commit for %#q:\n%w", upstreamName, err)
355+
}
356+
}
357+
358+
slog.Debug("Resolved snapshot commit for identity",
359+
"component", upstreamName,
360+
"snapshot", g.snapshotTime,
361+
"commit", commitHash)
362+
363+
return commitHash, nil
364+
}
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package sourceproviders_test
5+
6+
import (
7+
"context"
8+
"crypto/sha256"
9+
"encoding/hex"
10+
"errors"
11+
"fmt"
12+
"io"
13+
"strings"
14+
"testing"
15+
"time"
16+
17+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components/components_testutils"
18+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
19+
"github.com/microsoft/azure-linux-dev-tools/internal/providers/rpmprovider/rpmprovider_test"
20+
"github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders"
21+
"github.com/microsoft/azure-linux-dev-tools/internal/rpm/rpm_test"
22+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
23+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
24+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/git/git_test"
25+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/retry"
26+
"github.com/spf13/afero"
27+
"github.com/stretchr/testify/assert"
28+
"github.com/stretchr/testify/require"
29+
"go.uber.org/mock/gomock"
30+
)
31+
32+
// --- ResolveLocalSourceIdentity tests ---
33+
34+
func TestResolveLocalSourceIdentity_EmptyDir(t *testing.T) {
35+
identity, err := sourceproviders.ResolveLocalSourceIdentity(afero.NewMemMapFs(), "")
36+
require.NoError(t, err)
37+
assert.Empty(t, identity)
38+
}
39+
40+
func TestResolveLocalSourceIdentity_Deterministic(t *testing.T) {
41+
filesystem := afero.NewMemMapFs()
42+
require.NoError(t, fileutils.WriteFile(filesystem, "/specs/test.spec",
43+
[]byte("Name: test\nVersion: 1.0"), fileperms.PublicFile))
44+
45+
identity1, err := sourceproviders.ResolveLocalSourceIdentity(filesystem, "/specs")
46+
require.NoError(t, err)
47+
48+
identity2, err := sourceproviders.ResolveLocalSourceIdentity(filesystem, "/specs")
49+
require.NoError(t, err)
50+
51+
assert.Equal(t, identity1, identity2)
52+
assert.NotEmpty(t, identity1)
53+
assert.Contains(t, identity1, "sha256:", "identity should have sha256: prefix")
54+
}
55+
56+
func TestResolveLocalSourceIdentity_ContentChange(t *testing.T) {
57+
fs1 := afero.NewMemMapFs()
58+
require.NoError(t, fileutils.WriteFile(fs1, "/specs/test.spec", []byte("Version: 1.0"), fileperms.PublicFile))
59+
60+
fs2 := afero.NewMemMapFs()
61+
require.NoError(t, fileutils.WriteFile(fs2, "/specs/test.spec", []byte("Version: 2.0"), fileperms.PublicFile))
62+
63+
identity1, err := sourceproviders.ResolveLocalSourceIdentity(fs1, "/specs")
64+
require.NoError(t, err)
65+
66+
identity2, err := sourceproviders.ResolveLocalSourceIdentity(fs2, "/specs")
67+
require.NoError(t, err)
68+
69+
assert.NotEqual(t, identity1, identity2)
70+
}
71+
72+
func TestResolveLocalSourceIdentity_SidecarFileChangesIdentity(t *testing.T) {
73+
fsSpecOnly := afero.NewMemMapFs()
74+
require.NoError(t, fileutils.WriteFile(fsSpecOnly, "/specs/test.spec", []byte("spec"), fileperms.PublicFile))
75+
76+
fsWithPatch := afero.NewMemMapFs()
77+
require.NoError(t, fileutils.WriteFile(fsWithPatch, "/specs/test.spec", []byte("spec"), fileperms.PublicFile))
78+
require.NoError(t, fileutils.WriteFile(fsWithPatch, "/specs/fix.patch", []byte("patch"), fileperms.PublicFile))
79+
80+
identity1, err := sourceproviders.ResolveLocalSourceIdentity(fsSpecOnly, "/specs")
81+
require.NoError(t, err)
82+
83+
identity2, err := sourceproviders.ResolveLocalSourceIdentity(fsWithPatch, "/specs")
84+
require.NoError(t, err)
85+
86+
assert.NotEqual(t, identity1, identity2, "adding a sidecar file must change identity")
87+
}
88+
89+
// --- FedoraSourcesProviderImpl.ResolveSourceIdentity tests ---
90+
91+
func TestFedoraProvider_ResolveSourceIdentity(t *testing.T) {
92+
ctrl := gomock.NewController(t)
93+
mockGitProvider := git_test.NewMockGitProvider(ctrl)
94+
95+
provider, err := sourceproviders.NewFedoraSourcesProviderImpl(
96+
afero.NewMemMapFs(),
97+
newNoOpDryRunnable(),
98+
mockGitProvider,
99+
newNoOpDownloader(),
100+
testResolvedDistro(),
101+
retry.Disabled(),
102+
)
103+
require.NoError(t, err)
104+
105+
t.Run("resolves commit via clone", func(t *testing.T) {
106+
expectedCommit := "abc123def456"
107+
108+
// Expect: metadata-only clone, then GetCurrentCommit.
109+
mockGitProvider.EXPECT().
110+
Clone(gomock.Any(), repoURL, gomock.Any(), gomock.Any()).
111+
Return(nil)
112+
mockGitProvider.EXPECT().
113+
GetCurrentCommit(gomock.Any(), gomock.Any()).
114+
Return(expectedCommit, nil)
115+
116+
comp := newMockComp(ctrl, testPackageName)
117+
identity, resolveErr := provider.ResolveSourceIdentity(t.Context(), comp)
118+
require.NoError(t, resolveErr)
119+
assert.Equal(t, expectedCommit, identity)
120+
})
121+
122+
t.Run("returns error on clone failure", func(t *testing.T) {
123+
mockGitProvider.EXPECT().
124+
Clone(gomock.Any(), repoURL, gomock.Any(), gomock.Any()).
125+
Return(errors.New("network error"))
126+
127+
comp := newMockComp(ctrl, testPackageName)
128+
_, resolveErr := provider.ResolveSourceIdentity(t.Context(), comp)
129+
require.Error(t, resolveErr)
130+
assert.Contains(t, resolveErr.Error(), testPackageName)
131+
})
132+
133+
t.Run("returns pinned commit without network call", func(t *testing.T) {
134+
pinnedCommit := "deadbeef12345678"
135+
comp := newMockCompWithConfig(ctrl, testPackageName, &projectconfig.ComponentConfig{
136+
Name: testPackageName,
137+
Spec: projectconfig.SpecSource{
138+
SourceType: projectconfig.SpecSourceTypeUpstream,
139+
UpstreamCommit: pinnedCommit,
140+
},
141+
})
142+
143+
// No LsRemoteHead expectation — the pinned commit should be returned directly.
144+
identity, resolveErr := provider.ResolveSourceIdentity(t.Context(), comp)
145+
require.NoError(t, resolveErr)
146+
assert.Equal(t, pinnedCommit, identity)
147+
})
148+
}
149+
150+
func TestFedoraProvider_ResolveSourceIdentity_Snapshot(t *testing.T) {
151+
ctrl := gomock.NewController(t)
152+
mockGitProvider := git_test.NewMockGitProvider(ctrl)
153+
154+
snapshotTimeStr := "2025-06-15T00:00:00Z"
155+
snapshotTime, _ := time.Parse(time.RFC3339, snapshotTimeStr)
156+
157+
provider, err := sourceproviders.NewFedoraSourcesProviderImpl(
158+
afero.NewMemMapFs(),
159+
newNoOpDryRunnable(),
160+
mockGitProvider,
161+
newNoOpDownloader(),
162+
testResolvedDistroWithSnapshot(snapshotTimeStr),
163+
retry.Disabled(),
164+
)
165+
require.NoError(t, err)
166+
167+
t.Run("resolves commit via clone for snapshot", func(t *testing.T) {
168+
expectedCommit := "snapshot123abc"
169+
170+
// Expect: full single-branch clone, then rev-list --before.
171+
mockGitProvider.EXPECT().
172+
Clone(gomock.Any(), repoURL, gomock.Any(),
173+
gomock.Any()). // branch option
174+
Return(nil)
175+
mockGitProvider.EXPECT().
176+
GetCommitHashBeforeDate(gomock.Any(), gomock.Any(), snapshotTime).
177+
Return(expectedCommit, nil)
178+
179+
comp := newMockComp(ctrl, testPackageName)
180+
identity, resolveErr := provider.ResolveSourceIdentity(t.Context(), comp)
181+
require.NoError(t, resolveErr)
182+
assert.Equal(t, expectedCommit, identity)
183+
})
184+
185+
t.Run("pinned commit takes priority over snapshot", func(t *testing.T) {
186+
pinnedCommit := "pinned999"
187+
comp := newMockCompWithConfig(ctrl, testPackageName, &projectconfig.ComponentConfig{
188+
Name: testPackageName,
189+
Spec: projectconfig.SpecSource{
190+
SourceType: projectconfig.SpecSourceTypeUpstream,
191+
UpstreamCommit: pinnedCommit,
192+
},
193+
})
194+
195+
// No Clone/Deepen/GetCommitHashBeforeDate expectations — pinned commit is returned directly.
196+
identity, resolveErr := provider.ResolveSourceIdentity(t.Context(), comp)
197+
require.NoError(t, resolveErr)
198+
assert.Equal(t, pinnedCommit, identity)
199+
})
200+
}
201+
202+
// --- RPMContentsProviderImpl.ResolveSourceIdentity tests ---
203+
204+
func TestRPMProvider_ResolveSourceIdentity(t *testing.T) {
205+
ctrl := gomock.NewController(t)
206+
207+
t.Run("hashes downloaded RPM", func(t *testing.T) {
208+
rpmContent := "test-rpm-file-content"
209+
mockRPMProvider := rpmprovider_test.NewMockRPMProvider(ctrl)
210+
mockRPMProvider.EXPECT().
211+
GetRPM(gomock.Any(), "test-pkg", nil).
212+
Return(io.NopCloser(strings.NewReader(rpmContent)), nil)
213+
214+
provider, provErr := sourceproviders.NewRPMContentsProviderImpl(
215+
rpm_test.NewMockRPMExtractor(ctrl), mockRPMProvider)
216+
require.NoError(t, provErr)
217+
218+
comp := newMockComp(ctrl, "test-pkg")
219+
identity, resolveErr := provider.ResolveSourceIdentity(t.Context(), comp)
220+
require.NoError(t, resolveErr)
221+
assert.Equal(t, "sha256:"+sha256Hex(rpmContent), identity)
222+
})
223+
224+
t.Run("returns error on RPM download failure", func(t *testing.T) {
225+
mockRPMProvider := rpmprovider_test.NewMockRPMProvider(ctrl)
226+
mockRPMProvider.EXPECT().
227+
GetRPM(gomock.Any(), "test-pkg", nil).
228+
Return(nil, errors.New("download failed"))
229+
230+
provider, provErr := sourceproviders.NewRPMContentsProviderImpl(
231+
rpm_test.NewMockRPMExtractor(ctrl), mockRPMProvider)
232+
require.NoError(t, provErr)
233+
234+
comp := newMockComp(ctrl, "test-pkg")
235+
_, resolveErr := provider.ResolveSourceIdentity(t.Context(), comp)
236+
require.Error(t, resolveErr)
237+
assert.Contains(t, resolveErr.Error(), "test-pkg")
238+
})
239+
}
240+
241+
// --- Helpers ---
242+
243+
// newMockComp creates a mock component with the given name and an empty upstream config.
244+
func newMockComp(ctrl *gomock.Controller, name string) *components_testutils.MockComponent {
245+
return newMockCompWithConfig(ctrl, name, &projectconfig.ComponentConfig{
246+
Name: name,
247+
Spec: projectconfig.SpecSource{},
248+
})
249+
}
250+
251+
// newMockCompWithConfig creates a mock component with the given name and a custom config.
252+
func newMockCompWithConfig(
253+
ctrl *gomock.Controller, name string, config *projectconfig.ComponentConfig,
254+
) *components_testutils.MockComponent {
255+
comp := components_testutils.NewMockComponent(ctrl)
256+
comp.EXPECT().GetName().AnyTimes().Return(name)
257+
comp.EXPECT().GetConfig().AnyTimes().Return(config)
258+
259+
return comp
260+
}
261+
262+
func sha256Hex(content string) string {
263+
hasher := sha256.New()
264+
fmt.Fprint(hasher, content)
265+
266+
return hex.EncodeToString(hasher.Sum(nil))
267+
}
268+
269+
// newNoOpDryRunnable returns a mock that reports dry-run as false.
270+
func newNoOpDryRunnable() *opctxNoOpDryRunnable {
271+
return &opctxNoOpDryRunnable{}
272+
}
273+
274+
type opctxNoOpDryRunnable struct{}
275+
276+
func (d *opctxNoOpDryRunnable) DryRun() bool { return false }
277+
278+
// newNoOpDownloader returns a stub FedoraSourceDownloader that does nothing.
279+
func newNoOpDownloader() *noOpDownloader {
280+
return &noOpDownloader{}
281+
}
282+
283+
type noOpDownloader struct{}
284+
285+
func (d *noOpDownloader) ExtractSourcesFromRepo(
286+
_ context.Context, _, _, _ string, _ []string,
287+
) error {
288+
return nil
289+
}

0 commit comments

Comments
 (0)