Skip to content

Commit 8db810c

Browse files
committed
feat(cli): Add component diff sub-command
1 parent aae5c84 commit 8db810c

File tree

3 files changed

+301
-0
lines changed

3 files changed

+301
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ components defined in the project configuration.`,
2525
app.AddTopLevelCommand(cmd)
2626
addOnAppInit(app, cmd)
2727
buildOnAppInit(app, cmd)
28+
diffIdentityOnAppInit(app, cmd)
2829
diffSourcesOnAppInit(app, cmd)
2930
identityOnAppInit(app, cmd)
3031
listOnAppInit(app, cmd)
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"sort"
10+
11+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
12+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func diffIdentityOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
17+
parentCmd.AddCommand(NewDiffIdentityCommand())
18+
}
19+
20+
// diffIdentityArgCount is the number of positional arguments required by the diff-identity command.
21+
const diffIdentityArgCount = 2
22+
23+
// NewDiffIdentityCommand constructs a [cobra.Command] for "component diff-identity".
24+
func NewDiffIdentityCommand() *cobra.Command {
25+
cmd := &cobra.Command{
26+
Use: "diff-identity <base.json> <head.json>",
27+
Short: "Compare two identity files and report changed components",
28+
Long: `Compare two component identity JSON files (produced by 'component identity -a -O json')
29+
and report which components have changed, been added, or been removed.
30+
31+
CI uses the 'changed' and 'added' lists to determine the build queue.`,
32+
Example: ` # Compare base and head identity files
33+
azldev component diff-identity base-identity.json head-identity.json
34+
35+
# JSON output for CI
36+
azldev component diff-identity base.json head.json -O json`,
37+
Args: cobra.ExactArgs(diffIdentityArgCount),
38+
RunE: azldev.RunFuncWithoutRequiredConfigWithExtraArgs(
39+
func(env *azldev.Env, args []string) (interface{}, error) {
40+
return DiffIdentities(env, args[0], args[1])
41+
},
42+
),
43+
}
44+
45+
return cmd
46+
}
47+
48+
// IdentityDiffStatus represents the change status of a component.
49+
type IdentityDiffStatus string
50+
51+
const (
52+
// IdentityDiffChanged indicates the component's fingerprint changed.
53+
IdentityDiffChanged IdentityDiffStatus = "changed"
54+
// IdentityDiffAdded indicates the component is new in the head.
55+
IdentityDiffAdded IdentityDiffStatus = "added"
56+
// IdentityDiffRemoved indicates the component was removed in the head.
57+
IdentityDiffRemoved IdentityDiffStatus = "removed"
58+
// IdentityDiffUnchanged indicates the component's fingerprint is identical.
59+
IdentityDiffUnchanged IdentityDiffStatus = "unchanged"
60+
)
61+
62+
// IdentityDiffResult is the per-component row in table output.
63+
type IdentityDiffResult struct {
64+
Component string `json:"component" table:",sortkey"`
65+
Status IdentityDiffStatus `json:"status"`
66+
}
67+
68+
// IdentityDiffReport is the structured output for JSON format.
69+
type IdentityDiffReport struct {
70+
Changed []string `json:"changed"`
71+
Added []string `json:"added"`
72+
Removed []string `json:"removed"`
73+
Unchanged []string `json:"unchanged"`
74+
}
75+
76+
// DiffIdentities reads two identity JSON files and computes the diff.
77+
func DiffIdentities(env *azldev.Env, basePath string, headPath string) (interface{}, error) {
78+
baseIdentities, err := readIdentityFile(env, basePath)
79+
if err != nil {
80+
return nil, fmt.Errorf("reading base identity file %#q:\n%w", basePath, err)
81+
}
82+
83+
headIdentities, err := readIdentityFile(env, headPath)
84+
if err != nil {
85+
return nil, fmt.Errorf("reading head identity file %#q:\n%w", headPath, err)
86+
}
87+
88+
report := ComputeDiff(baseIdentities, headIdentities)
89+
90+
// Return table-friendly results for table/CSV format, or the report for JSON.
91+
if env.DefaultReportFormat() == azldev.ReportFormatJSON {
92+
return report, nil
93+
}
94+
95+
return buildTableResults(report), nil
96+
}
97+
98+
// readIdentityFile reads and parses a component identity JSON file into a map of
99+
// component name to fingerprint.
100+
func readIdentityFile(
101+
env *azldev.Env, filePath string,
102+
) (map[string]string, error) {
103+
data, err := fileutils.ReadFile(env.FS(), filePath)
104+
if err != nil {
105+
return nil, fmt.Errorf("reading file:\n%w", err)
106+
}
107+
108+
var entries []ComponentIdentityResult
109+
110+
err = json.Unmarshal(data, &entries)
111+
if err != nil {
112+
return nil, fmt.Errorf("parsing JSON:\n%w", err)
113+
}
114+
115+
result := make(map[string]string, len(entries))
116+
for _, entry := range entries {
117+
result[entry.Component] = entry.Fingerprint
118+
}
119+
120+
return result, nil
121+
}
122+
123+
// ComputeDiff compares base and head identity maps and produces a diff report.
124+
func ComputeDiff(base map[string]string, head map[string]string) *IdentityDiffReport {
125+
report := &IdentityDiffReport{}
126+
127+
// Check base components against head.
128+
for name, baseFP := range base {
129+
headFP, exists := head[name]
130+
131+
switch {
132+
case !exists:
133+
report.Removed = append(report.Removed, name)
134+
case baseFP != headFP:
135+
report.Changed = append(report.Changed, name)
136+
default:
137+
report.Unchanged = append(report.Unchanged, name)
138+
}
139+
}
140+
141+
// Check for new components in head.
142+
for name := range head {
143+
if _, exists := base[name]; !exists {
144+
report.Added = append(report.Added, name)
145+
}
146+
}
147+
148+
// Sort all lists for deterministic output.
149+
sort.Strings(report.Changed)
150+
sort.Strings(report.Added)
151+
sort.Strings(report.Removed)
152+
sort.Strings(report.Unchanged)
153+
154+
return report
155+
}
156+
157+
// buildTableResults converts the diff report into a slice for table output.
158+
func buildTableResults(report *IdentityDiffReport) []IdentityDiffResult {
159+
results := make([]IdentityDiffResult, 0,
160+
len(report.Changed)+len(report.Added)+len(report.Removed)+len(report.Unchanged))
161+
162+
for _, name := range report.Changed {
163+
results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffChanged})
164+
}
165+
166+
for _, name := range report.Added {
167+
results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffAdded})
168+
}
169+
170+
for _, name := range report.Removed {
171+
results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffRemoved})
172+
}
173+
174+
for _, name := range report.Unchanged {
175+
results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffUnchanged})
176+
}
177+
178+
return results
179+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component_test
5+
6+
import (
7+
"testing"
8+
9+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/component"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestComputeDiff(t *testing.T) {
14+
t.Run("all categories", func(t *testing.T) {
15+
base := map[string]string{
16+
"curl": "sha256:aaa",
17+
"wget": "sha256:bbb",
18+
"openssl": "sha256:ccc",
19+
"libold": "sha256:fff",
20+
}
21+
head := map[string]string{
22+
"curl": "sha256:aaa",
23+
"wget": "sha256:ddd",
24+
"libfoo": "sha256:eee",
25+
"openssl": "sha256:ccc",
26+
}
27+
28+
report := component.ComputeDiff(base, head)
29+
30+
assert.Equal(t, []string{"wget"}, report.Changed)
31+
assert.Equal(t, []string{"libfoo"}, report.Added)
32+
assert.Equal(t, []string{"libold"}, report.Removed)
33+
assert.Equal(t, []string{"curl", "openssl"}, report.Unchanged)
34+
})
35+
36+
t.Run("removed component", func(t *testing.T) {
37+
base := map[string]string{
38+
"curl": "sha256:aaa",
39+
"libfoo": "sha256:bbb",
40+
}
41+
head := map[string]string{
42+
"curl": "sha256:aaa",
43+
}
44+
45+
report := component.ComputeDiff(base, head)
46+
47+
assert.Empty(t, report.Changed)
48+
assert.Empty(t, report.Added)
49+
assert.Equal(t, []string{"libfoo"}, report.Removed)
50+
assert.Equal(t, []string{"curl"}, report.Unchanged)
51+
})
52+
53+
t.Run("empty base", func(t *testing.T) {
54+
base := map[string]string{}
55+
head := map[string]string{
56+
"curl": "sha256:aaa",
57+
"wget": "sha256:bbb",
58+
}
59+
60+
report := component.ComputeDiff(base, head)
61+
62+
assert.Empty(t, report.Changed)
63+
assert.Equal(t, []string{"curl", "wget"}, report.Added)
64+
assert.Empty(t, report.Removed)
65+
assert.Empty(t, report.Unchanged)
66+
})
67+
68+
t.Run("empty head", func(t *testing.T) {
69+
base := map[string]string{
70+
"curl": "sha256:aaa",
71+
}
72+
head := map[string]string{}
73+
74+
report := component.ComputeDiff(base, head)
75+
76+
assert.Empty(t, report.Changed)
77+
assert.Empty(t, report.Added)
78+
assert.Equal(t, []string{"curl"}, report.Removed)
79+
assert.Empty(t, report.Unchanged)
80+
})
81+
82+
t.Run("both empty", func(t *testing.T) {
83+
report := component.ComputeDiff(map[string]string{}, map[string]string{})
84+
85+
assert.Empty(t, report.Changed)
86+
assert.Empty(t, report.Added)
87+
assert.Empty(t, report.Removed)
88+
assert.Empty(t, report.Unchanged)
89+
})
90+
91+
t.Run("identical", func(t *testing.T) {
92+
both := map[string]string{
93+
"curl": "sha256:aaa",
94+
"openssl": "sha256:bbb",
95+
}
96+
97+
report := component.ComputeDiff(both, both)
98+
99+
assert.Empty(t, report.Changed)
100+
assert.Empty(t, report.Added)
101+
assert.Empty(t, report.Removed)
102+
assert.Equal(t, []string{"curl", "openssl"}, report.Unchanged)
103+
})
104+
105+
t.Run("sorted output", func(t *testing.T) {
106+
base := map[string]string{
107+
"zlib": "sha256:aaa",
108+
"curl": "sha256:bbb",
109+
"openssl": "sha256:ccc",
110+
}
111+
head := map[string]string{
112+
"zlib": "sha256:xxx",
113+
"curl": "sha256:yyy",
114+
"openssl": "sha256:ccc",
115+
}
116+
117+
report := component.ComputeDiff(base, head)
118+
119+
assert.Equal(t, []string{"curl", "zlib"}, report.Changed, "changed list should be sorted")
120+
})
121+
}

0 commit comments

Comments
 (0)