Skip to content

Commit f3130bd

Browse files
committed
feat(cli): Add component identity command
1 parent 115fa91 commit f3130bd

File tree

4 files changed

+370
-0
lines changed

4 files changed

+370
-0
lines changed

docs/user/reference/cli/azldev_component.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/user/reference/cli/azldev_component_identity.md

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/app/azldev/cmds/component/component.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ components defined in the project configuration.`,
2626
addOnAppInit(app, cmd)
2727
buildOnAppInit(app, cmd)
2828
diffSourcesOnAppInit(app, cmd)
29+
identityOnAppInit(app, cmd)
2930
listOnAppInit(app, cmd)
3031
prepareOnAppInit(app, cmd)
3132
queryOnAppInit(app, cmd)
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component
5+
6+
import (
7+
"fmt"
8+
"log/slog"
9+
"sync"
10+
11+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
12+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
13+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources"
14+
"github.com/microsoft/azure-linux-dev-tools/internal/fingerprint"
15+
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
16+
"github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
// Options for computing component identity fingerprints.
21+
type IdentityComponentOptions struct {
22+
// Standard filter for selecting components.
23+
ComponentFilter components.ComponentFilter
24+
}
25+
26+
func identityOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
27+
parentCmd.AddCommand(NewComponentIdentityCommand())
28+
}
29+
30+
// NewComponentIdentityCommand constructs a [cobra.Command] for "component identity" CLI subcommand.
31+
func NewComponentIdentityCommand() *cobra.Command {
32+
options := &IdentityComponentOptions{}
33+
34+
cmd := &cobra.Command{
35+
Use: "identity",
36+
Short: "Compute identity fingerprints for components",
37+
Long: `Compute a deterministic identity fingerprint for each selected component.
38+
39+
The fingerprint captures all resolved build inputs (config fields, spec file
40+
content, overlay source files, distro context, and Affects commit count).
41+
A change to any input produces a different fingerprint.
42+
43+
Use this with 'component diff-identity' to determine which components need
44+
rebuilding between two commits.`,
45+
Example: ` # All components, JSON output for CI
46+
azldev component identity -a -O json > identity.json
47+
48+
# Single component, table output for dev
49+
azldev component identity -p curl
50+
51+
# Components in a group
52+
azldev component identity -g core`,
53+
RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) {
54+
options.ComponentFilter.ComponentNamePatterns = append(
55+
args, options.ComponentFilter.ComponentNamePatterns...,
56+
)
57+
58+
return ComputeComponentIdentities(env, options)
59+
}),
60+
ValidArgsFunction: components.GenerateComponentNameCompletions,
61+
}
62+
63+
components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)
64+
65+
return cmd
66+
}
67+
68+
// ComponentIdentityResult is the per-component output for the identity command.
69+
type ComponentIdentityResult struct {
70+
// Component is the component name.
71+
Component string `json:"component" table:",sortkey"`
72+
// Fingerprint is the overall identity hash string.
73+
Fingerprint string `json:"fingerprint"`
74+
// Inputs provides the individual input hashes (shown in JSON output).
75+
Inputs fingerprint.ComponentInputs `json:"inputs" table:"-"`
76+
}
77+
78+
// ComputeComponentIdentities computes fingerprints for all selected components.
79+
func ComputeComponentIdentities(
80+
env *azldev.Env, options *IdentityComponentOptions,
81+
) ([]ComponentIdentityResult, error) {
82+
resolver := components.NewResolver(env)
83+
84+
comps, err := resolver.FindComponents(&options.ComponentFilter)
85+
if err != nil {
86+
return nil, fmt.Errorf("failed to resolve components:\n%w", err)
87+
}
88+
89+
distroRef := env.Config().Project.DefaultDistro
90+
91+
// Resolve the distro definition (fills in default version for the fingerprint).
92+
distroRef, err = resolveDistroForIdentity(env, distroRef)
93+
if err != nil {
94+
slog.Debug("Could not resolve distro", "error", err)
95+
}
96+
97+
return computeIdentitiesParallel(
98+
env, comps.Components(), distroRef,
99+
)
100+
}
101+
102+
// maxConcurrentIdentity limits the number of concurrent identity computations.
103+
// This bounds both git ls-remote calls and file I/O.
104+
const maxConcurrentIdentity = 32
105+
106+
// computeIdentitiesParallel computes fingerprints for all components concurrently,
107+
// including source identity resolution, affects count, and overlay file hashing.
108+
func computeIdentitiesParallel(
109+
env *azldev.Env,
110+
comps []components.Component,
111+
distroRef projectconfig.DistroReference,
112+
) ([]ComponentIdentityResult, error) {
113+
progressEvent := env.StartEvent("Computing component identities",
114+
"count", len(comps))
115+
defer progressEvent.End()
116+
117+
// Create a cancellable child env so we can stop remaining goroutines on first error.
118+
workerEnv, cancel := env.WithCancel()
119+
defer cancel()
120+
121+
type indexedResult struct {
122+
index int
123+
result ComponentIdentityResult
124+
err error
125+
}
126+
127+
resultsChan := make(chan indexedResult, len(comps))
128+
semaphore := make(chan struct{}, maxConcurrentIdentity)
129+
130+
var waitGroup sync.WaitGroup
131+
132+
for compIdx, comp := range comps {
133+
waitGroup.Add(1)
134+
135+
go func() {
136+
defer waitGroup.Done()
137+
138+
// Context-aware semaphore acquisition.
139+
select {
140+
case semaphore <- struct{}{}:
141+
defer func() { <-semaphore }()
142+
case <-workerEnv.Done():
143+
resultsChan <- indexedResult{index: compIdx, err: workerEnv.Err()}
144+
145+
return
146+
}
147+
148+
result, computeErr := computeSingleIdentity(
149+
workerEnv, comp, distroRef,
150+
)
151+
152+
resultsChan <- indexedResult{index: compIdx, result: result, err: computeErr}
153+
}()
154+
}
155+
156+
// Close channel when all goroutines complete.
157+
go func() { waitGroup.Wait(); close(resultsChan) }()
158+
159+
// Collect results in order.
160+
results := make([]ComponentIdentityResult, len(comps))
161+
total := int64(len(comps))
162+
163+
var (
164+
completed int64
165+
firstErr error
166+
)
167+
168+
for indexed := range resultsChan {
169+
if indexed.err != nil {
170+
if firstErr == nil {
171+
firstErr = indexed.err
172+
173+
cancel()
174+
}
175+
176+
// Drain remaining results so the closer goroutine can finish.
177+
continue
178+
}
179+
180+
if firstErr == nil {
181+
results[indexed.index] = indexed.result
182+
completed++
183+
progressEvent.SetProgress(completed, total)
184+
}
185+
}
186+
187+
if firstErr != nil {
188+
return nil, firstErr
189+
}
190+
191+
return results, nil
192+
}
193+
194+
// computeSingleIdentity computes the identity for a single component, including
195+
// source identity resolution, affects commit counting, and overlay file hashing.
196+
func computeSingleIdentity(
197+
env *azldev.Env,
198+
comp components.Component,
199+
distroRef projectconfig.DistroReference,
200+
) (ComponentIdentityResult, error) {
201+
config := comp.GetConfig()
202+
componentName := comp.GetName()
203+
204+
identityOpts := fingerprint.IdentityOptions{
205+
AffectsCommitCount: countAffectsCommits(config, componentName),
206+
}
207+
208+
// Resolve source identity, selecting the appropriate method based on source type (local vs. upstream etc.).
209+
sourceIdentity, err := resolveSourceIdentityForComponent(env, comp)
210+
if err != nil {
211+
return ComponentIdentityResult{}, fmt.Errorf(
212+
"source identity resolution failed for %#q:\n%w",
213+
componentName, err)
214+
}
215+
216+
identityOpts.SourceIdentity = sourceIdentity
217+
218+
identity, err := fingerprint.ComputeIdentity(env.FS(), *config, distroRef, identityOpts)
219+
if err != nil {
220+
return ComponentIdentityResult{}, fmt.Errorf("computing identity for component %#q:\n%w",
221+
componentName, err)
222+
}
223+
224+
return ComponentIdentityResult{
225+
Component: componentName,
226+
Fingerprint: identity.Fingerprint,
227+
Inputs: identity.Inputs,
228+
}, nil
229+
}
230+
231+
// resolveDistroForIdentity resolves the default distro reference, filling in the
232+
// default version when unspecified.
233+
func resolveDistroForIdentity(
234+
env *azldev.Env, distroRef projectconfig.DistroReference,
235+
) (projectconfig.DistroReference, error) {
236+
distroDef, _, err := env.ResolveDistroRef(distroRef)
237+
if err != nil {
238+
return distroRef,
239+
fmt.Errorf("resolving distro %#q:\n%w", distroRef.Name, err)
240+
}
241+
242+
// Fill in the resolved version if the ref didn't specify one.
243+
if distroRef.Version == "" {
244+
distroRef.Version = distroDef.DefaultVersion
245+
}
246+
247+
return distroRef, nil
248+
}
249+
250+
// countAffectsCommits counts the number of "Affects: <componentName>" commits in the
251+
// project repo. Returns 0 if the count cannot be determined (e.g., no git repo).
252+
func countAffectsCommits(config *projectconfig.ComponentConfig, componentName string,
253+
) int {
254+
configFile := config.SourceConfigFile
255+
if configFile == nil || configFile.SourcePath() == "" {
256+
return 0
257+
}
258+
259+
repo, err := sources.OpenProjectRepo(configFile.SourcePath())
260+
if err != nil {
261+
slog.Debug("Could not open project repo for Affects commits; defaulting to 0",
262+
"component", componentName, "error", err)
263+
264+
return 0
265+
}
266+
267+
commits, err := sources.FindAffectsCommits(repo, componentName)
268+
if err != nil {
269+
slog.Debug("Could not count Affects commits; defaulting to 0",
270+
"component", componentName, "error", err)
271+
272+
return 0
273+
}
274+
275+
return len(commits)
276+
}
277+
278+
// resolveSourceIdentityForComponent returns a deterministic identity string for the
279+
// component's source by delegating to [sourceproviders.SourceManager.ResolveSourceIdentity].
280+
func resolveSourceIdentityForComponent(
281+
env *azldev.Env, comp components.Component,
282+
) (string, error) {
283+
distro, err := sourceproviders.ResolveDistro(env, comp)
284+
if err != nil {
285+
return "", fmt.Errorf("resolving distro for component %#q:\n%w",
286+
comp.GetName(), err)
287+
}
288+
289+
// A new source manager is created per component because each may reference a different
290+
// upstream distro.
291+
srcManager, err := sourceproviders.NewSourceManager(env, distro)
292+
if err != nil {
293+
return "", fmt.Errorf("creating source manager for component %#q:\n%w",
294+
comp.GetName(), err)
295+
}
296+
297+
identity, err := srcManager.ResolveSourceIdentity(env.Context(), comp)
298+
if err != nil {
299+
return "", fmt.Errorf("resolving source identity for %#q:\n%w",
300+
comp.GetName(), err)
301+
}
302+
303+
return identity, nil
304+
}

0 commit comments

Comments
 (0)