From e9b6c6c257e88ee2cbde34911396499e58c886c7 Mon Sep 17 00:00:00 2001 From: Daniel McIlvaney Date: Mon, 30 Mar 2026 16:15:03 -0700 Subject: [PATCH] feat(cli): Add component identity-diff command, scenario test --- .../developer/reference/component-identity.md | 56 +++++ docs/user/reference/cli/azldev_component.md | 1 + .../cli/azldev_component_diff-identity.md | 54 ++++ .../app/azldev/cmds/component/component.go | 1 + .../app/azldev/cmds/component/diffidentity.go | 197 +++++++++++++++ .../cmds/component/diffidentity_test.go | 235 ++++++++++++++++++ ...tySnapshots_diff-identity_help_1.snap.json | 3 + ...Snapshots_diff-identity_help_stderr_1.snap | 1 + ...Snapshots_diff-identity_help_stdout_1.snap | 32 +++ ...dentitySnapshots_identity_help_1.snap.json | 3 + ...ntitySnapshots_identity_help_stderr_1.snap | 1 + ...ntitySnapshots_identity_help_stdout_1.snap | 42 ++++ scenario/component_identity_test.go | 168 +++++++++++++ 13 files changed, 794 insertions(+) create mode 100644 docs/developer/reference/component-identity.md create mode 100644 docs/user/reference/cli/azldev_component_diff-identity.md create mode 100644 internal/app/azldev/cmds/component/diffidentity.go create mode 100644 internal/app/azldev/cmds/component/diffidentity_test.go create mode 100755 scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_1.snap.json create mode 100755 scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stderr_1.snap create mode 100755 scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stdout_1.snap create mode 100755 scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_1.snap.json create mode 100755 scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stderr_1.snap create mode 100755 scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stdout_1.snap create mode 100644 scenario/component_identity_test.go diff --git a/docs/developer/reference/component-identity.md b/docs/developer/reference/component-identity.md new file mode 100644 index 0000000..07ee91f --- /dev/null +++ b/docs/developer/reference/component-identity.md @@ -0,0 +1,56 @@ +# Component Identity & Change Detection + +The `component identity` and `component diff-identity` subcommands compute deterministic fingerprints of component build inputs. For example, CI can compute fingerprints for the base and head commits of a PR, then diff them to determine exactly which components have changed and need to be rebuilt/tested. + +```bash +# Typical CI workflow +git checkout $BASE_REF && azldev component identity -a -O json > base.json +git checkout $HEAD_REF && azldev component identity -a -O json > head.json +azldev component diff-identity base.json head.json -O json -c +# → {"changed": ["curl"], "added": ["wget"], "removed": [], "unchanged": []} +``` + +## Fingerprint Inputs + +A component's fingerprint is a SHA256 combining: + +1. **Config hash** — `hashstructure.Hash()` of the resolved `ComponentConfig` (after all merging). Fields tagged `fingerprint:"-"` are excluded. +2. **Source identity** — content hash for local specs (all files in the spec directory), commit hash for upstream. +3. **Overlay file hashes** — SHA256 of each file referenced by overlay `Source` fields. +4. **Distro name + version** +5. **Affects commit count** — number of `Affects: ` commits in the project repo. + +Global change propagation works automatically: the fingerprint operates on the fully-merged config, so a change to a distro or group default changes the resolved config of every inheriting component. + +## `fingerprint:"-"` Tag System + +The `hashstructure` library uses `TagName: "fingerprint"`. Untagged fields are **included by default** (safe default: false positive > false negative). + +A guard test (`TestAllFingerprintedFieldsHaveDecision`) reflects over all fingerprinted structs and maintains a bi-directional allowlist of exclusions. It fails if a `fingerprint:"-"` tag is added without registering it, or if a registered exclusion's tag is removed. + +### Adding a New Config Field + +1. Add the field to the struct in `internal/projectconfig/`. +2. **If NOT a build input**: add `fingerprint:"-"` to the struct tag and register it in `expectedExclusions` in `internal/projectconfig/fingerprint_test.go`. +3. **If a build input**: do nothing — included by default. +4. Run `mage unit`. + +### Adding a New Source Type + +1. Implement `SourceIdentityProvider` on your provider (see `ResolveLocalSourceIdentity` in `localidentity.go` for a simple example). +2. Add a case to `sourceManager.ResolveSourceIdentity()` in `sourcemanager.go`. +3. Add tests in `identityprovider_test.go`. + +## CLI + +### `azldev component identity` + +Compute fingerprints. Uses standard component filter flags (`-a`, `-p`, `-g`, `-s`). Exposed as an MCP tool. + +### `azldev component diff-identity` + +Compare two identity JSON files. The `--changed-only` / `-c` flag filters to only changed and added components (the build queue). Applies to both table and JSON output. + +## Known Limitations + +- It is difficult to determine WHY a diff occurred (e.g., which specific field changed) since the fingerprint is a single opaque hash. The JSON output includes an `inputs` breakdown (`configHash`, `sourceIdentity`, `overlayFileHashes`, etc.) that can help narrow it down by comparing the two identity files manually. diff --git a/docs/user/reference/cli/azldev_component.md b/docs/user/reference/cli/azldev_component.md index c1a8d4e..1695515 100644 --- a/docs/user/reference/cli/azldev_component.md +++ b/docs/user/reference/cli/azldev_component.md @@ -40,6 +40,7 @@ components defined in the project configuration. * [azldev](azldev.md) - 🐧 Azure Linux Dev Tool * [azldev component add](azldev_component_add.md) - Add component(s) to this project * [azldev component build](azldev_component_build.md) - Build packages for components +* [azldev component diff-identity](azldev_component_diff-identity.md) - Compare two identity files and report changed components * [azldev component diff-sources](azldev_component_diff-sources.md) - Show the diff that overlays apply to a component's sources * [azldev component list](azldev_component_list.md) - List components in this project * [azldev component prepare-sources](azldev_component_prepare-sources.md) - Prepare buildable sources for components diff --git a/docs/user/reference/cli/azldev_component_diff-identity.md b/docs/user/reference/cli/azldev_component_diff-identity.md new file mode 100644 index 0000000..59afbfb --- /dev/null +++ b/docs/user/reference/cli/azldev_component_diff-identity.md @@ -0,0 +1,54 @@ + + +## azldev component diff-identity + +Compare two identity files and report changed components + +### Synopsis + +Compare two component identity JSON files (produced by 'component identity -a -O json') +and report which components have changed, been added, or been removed. + +CI uses the 'changed' and 'added' lists to determine the build queue. + +``` +azldev component diff-identity [flags] +``` + +### Examples + +``` + # Compare base and head identity files + azldev component diff-identity base-identity.json head-identity.json + + # JSON output for CI + azldev component diff-identity base.json head.json -O json +``` + +### Options + +``` + -c, --changed-only Only show changed and added components (the build queue) + -h, --help help for diff-identity +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev component](azldev_component.md) - Manage components + diff --git a/internal/app/azldev/cmds/component/component.go b/internal/app/azldev/cmds/component/component.go index 6713b64..34dcc25 100644 --- a/internal/app/azldev/cmds/component/component.go +++ b/internal/app/azldev/cmds/component/component.go @@ -25,6 +25,7 @@ components defined in the project configuration.`, app.AddTopLevelCommand(cmd) addOnAppInit(app, cmd) buildOnAppInit(app, cmd) + diffIdentityOnAppInit(app, cmd) diffSourcesOnAppInit(app, cmd) listOnAppInit(app, cmd) prepareOnAppInit(app, cmd) diff --git a/internal/app/azldev/cmds/component/diffidentity.go b/internal/app/azldev/cmds/component/diffidentity.go new file mode 100644 index 0000000..cfbb381 --- /dev/null +++ b/internal/app/azldev/cmds/component/diffidentity.go @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/spf13/cobra" +) + +func diffIdentityOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { + parentCmd.AddCommand(NewDiffIdentityCommand()) +} + +// diffIdentityArgCount is the number of positional arguments required by the diff-identity command. +const diffIdentityArgCount = 2 + +// NewDiffIdentityCommand constructs a [cobra.Command] for "component diff-identity". +func NewDiffIdentityCommand() *cobra.Command { + var options struct { + ChangedOnly bool + } + + cmd := &cobra.Command{ + Use: "diff-identity ", + Short: "Compare two identity files and report changed components", + Long: `Compare two component identity JSON files (produced by 'component identity -a -O json') +and report which components have changed, been added, or been removed. + +CI uses the 'changed' and 'added' lists to determine the build queue.`, + Example: ` # Compare base and head identity files + azldev component diff-identity base-identity.json head-identity.json + + # JSON output for CI + azldev component diff-identity base.json head.json -O json`, + Args: cobra.ExactArgs(diffIdentityArgCount), + RunE: azldev.RunFuncWithoutRequiredConfigWithExtraArgs( + func(env *azldev.Env, args []string) (interface{}, error) { + return DiffIdentities(env, args[0], args[1], options.ChangedOnly) + }, + ), + } + + cmd.Flags().BoolVarP(&options.ChangedOnly, "changed-only", "c", false, + "Only show changed and added components (the build queue)") + + return cmd +} + +// IdentityDiffStatus represents the change status of a component. +type IdentityDiffStatus string + +const ( + // IdentityDiffChanged indicates the component's fingerprint changed. + IdentityDiffChanged IdentityDiffStatus = "changed" + // IdentityDiffAdded indicates the component is new in the head. + IdentityDiffAdded IdentityDiffStatus = "added" + // IdentityDiffRemoved indicates the component was removed in the head. + IdentityDiffRemoved IdentityDiffStatus = "removed" + // IdentityDiffUnchanged indicates the component's fingerprint is identical. + IdentityDiffUnchanged IdentityDiffStatus = "unchanged" +) + +// IdentityDiffResult is the per-component row in table output. +type IdentityDiffResult struct { + Component string `json:"component" table:",sortkey"` + Status IdentityDiffStatus `json:"status"` +} + +// IdentityDiffReport is the structured output for JSON format. +type IdentityDiffReport struct { + Changed []string `json:"changed"` + Added []string `json:"added"` + Removed []string `json:"removed"` + Unchanged []string `json:"unchanged"` +} + +// DiffIdentities reads two identity JSON files and computes the diff. +func DiffIdentities(env *azldev.Env, basePath string, headPath string, changedOnly bool) (interface{}, error) { + baseIdentities, err := readIdentityFile(env, basePath) + if err != nil { + return nil, fmt.Errorf("reading base identity file %#q:\n%w", basePath, err) + } + + headIdentities, err := readIdentityFile(env, headPath) + if err != nil { + return nil, fmt.Errorf("reading head identity file %#q:\n%w", headPath, err) + } + + report := ComputeDiff(baseIdentities, headIdentities, changedOnly) + + // Return table-friendly results for table/CSV format, or the report for JSON. + if env.DefaultReportFormat() == azldev.ReportFormatJSON { + return report, nil + } + + return buildTableResults(report), nil +} + +// readIdentityFile reads and parses a component identity JSON file into a map of +// component name to fingerprint. +func readIdentityFile( + env *azldev.Env, filePath string, +) (map[string]string, error) { + data, err := fileutils.ReadFile(env.FS(), filePath) + if err != nil { + return nil, fmt.Errorf("reading file:\n%w", err) + } + + var entries []ComponentIdentityResult + + err = json.Unmarshal(data, &entries) + if err != nil { + return nil, fmt.Errorf("parsing JSON:\n%w", err) + } + + result := make(map[string]string, len(entries)) + for _, entry := range entries { + result[entry.Component] = entry.Fingerprint + } + + return result, nil +} + +// ComputeDiff compares base and head identity maps and produces a diff report. +// When changedOnly is true, the Removed and Unchanged lists are left empty. +func ComputeDiff(base map[string]string, head map[string]string, changedOnly bool) *IdentityDiffReport { + // Initialize all slices so JSON serialization produces [] instead of null. + report := &IdentityDiffReport{ + Changed: make([]string, 0), + Added: make([]string, 0), + Removed: make([]string, 0), + Unchanged: make([]string, 0), + } + + // Check base components against head. + for name, baseFP := range base { + headFP, exists := head[name] + + switch { + case !exists: + if !changedOnly { + report.Removed = append(report.Removed, name) + } + case baseFP != headFP: + report.Changed = append(report.Changed, name) + default: + if !changedOnly { + report.Unchanged = append(report.Unchanged, name) + } + } + } + + // Check for new components in head. + for name := range head { + if _, exists := base[name]; !exists { + report.Added = append(report.Added, name) + } + } + + // Sort all lists for deterministic output. + sort.Strings(report.Changed) + sort.Strings(report.Added) + sort.Strings(report.Removed) + sort.Strings(report.Unchanged) + + return report +} + +// buildTableResults converts the diff report into a slice for table output. +func buildTableResults(report *IdentityDiffReport) []IdentityDiffResult { + results := make([]IdentityDiffResult, 0, + len(report.Changed)+len(report.Added)+len(report.Removed)+len(report.Unchanged)) + + for _, name := range report.Changed { + results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffChanged}) + } + + for _, name := range report.Added { + results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffAdded}) + } + + for _, name := range report.Removed { + results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffRemoved}) + } + + for _, name := range report.Unchanged { + results = append(results, IdentityDiffResult{Component: name, Status: IdentityDiffUnchanged}) + } + + return results +} diff --git a/internal/app/azldev/cmds/component/diffidentity_test.go b/internal/app/azldev/cmds/component/diffidentity_test.go new file mode 100644 index 0000000..7c26fc3 --- /dev/null +++ b/internal/app/azldev/cmds/component/diffidentity_test.go @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component_test + +import ( + "encoding/json" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/component" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "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" +) + +func TestComputeDiff(t *testing.T) { + t.Run("all categories", func(t *testing.T) { + base := map[string]string{ + "curl": "sha256:aaa", + "wget": "sha256:bbb", + "openssl": "sha256:ccc", + "libold": "sha256:fff", + } + head := map[string]string{ + "curl": "sha256:aaa", + "wget": "sha256:ddd", + "libfoo": "sha256:eee", + "openssl": "sha256:ccc", + } + + report := component.ComputeDiff(base, head, false) + + assert.Equal(t, []string{"wget"}, report.Changed) + assert.Equal(t, []string{"libfoo"}, report.Added) + assert.Equal(t, []string{"libold"}, report.Removed) + assert.Equal(t, []string{"curl", "openssl"}, report.Unchanged) + }) + + t.Run("removed component", func(t *testing.T) { + base := map[string]string{ + "curl": "sha256:aaa", + "libfoo": "sha256:bbb", + } + head := map[string]string{ + "curl": "sha256:aaa", + } + + report := component.ComputeDiff(base, head, false) + + assert.Empty(t, report.Changed) + assert.Empty(t, report.Added) + assert.Equal(t, []string{"libfoo"}, report.Removed) + assert.Equal(t, []string{"curl"}, report.Unchanged) + }) + + t.Run("empty base", func(t *testing.T) { + base := map[string]string{} + head := map[string]string{ + "curl": "sha256:aaa", + "wget": "sha256:bbb", + } + + report := component.ComputeDiff(base, head, false) + + assert.Empty(t, report.Changed) + assert.Equal(t, []string{"curl", "wget"}, report.Added) + assert.Empty(t, report.Removed) + assert.Empty(t, report.Unchanged) + }) + + t.Run("empty head", func(t *testing.T) { + base := map[string]string{ + "curl": "sha256:aaa", + } + head := map[string]string{} + + report := component.ComputeDiff(base, head, false) + + assert.Empty(t, report.Changed) + assert.Empty(t, report.Added) + assert.Equal(t, []string{"curl"}, report.Removed) + assert.Empty(t, report.Unchanged) + }) + + t.Run("both empty", func(t *testing.T) { + report := component.ComputeDiff(map[string]string{}, map[string]string{}, false) + + assert.Empty(t, report.Changed) + assert.Empty(t, report.Added) + assert.Empty(t, report.Removed) + assert.Empty(t, report.Unchanged) + }) + + t.Run("identical", func(t *testing.T) { + both := map[string]string{ + "curl": "sha256:aaa", + "openssl": "sha256:bbb", + } + + report := component.ComputeDiff(both, both, false) + + assert.Empty(t, report.Changed) + assert.Empty(t, report.Added) + assert.Empty(t, report.Removed) + assert.Equal(t, []string{"curl", "openssl"}, report.Unchanged) + }) + + t.Run("sorted output", func(t *testing.T) { + base := map[string]string{ + "zlib": "sha256:aaa", + "curl": "sha256:bbb", + "openssl": "sha256:ccc", + } + head := map[string]string{ + "zlib": "sha256:xxx", + "curl": "sha256:yyy", + "openssl": "sha256:ccc", + } + + report := component.ComputeDiff(base, head, false) + + assert.Equal(t, []string{"curl", "zlib"}, report.Changed, "changed list should be sorted") + }) + + t.Run("changed only", func(t *testing.T) { + base := map[string]string{ + "curl": "sha256:aaa", + "wget": "sha256:bbb", + "openssl": "sha256:ccc", + "libold": "sha256:fff", + } + head := map[string]string{ + "curl": "sha256:aaa", + "wget": "sha256:ddd", + "libfoo": "sha256:eee", + "openssl": "sha256:ccc", + } + + report := component.ComputeDiff(base, head, true) + + assert.Equal(t, []string{"wget"}, report.Changed) + assert.Equal(t, []string{"libfoo"}, report.Added) + assert.Empty(t, report.Removed, "removed should be empty with changedOnly") + assert.Empty(t, report.Unchanged, "unchanged should be empty with changedOnly") + }) +} + +func TestDiffIdentities_MissingFile(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + _, err := component.DiffIdentities(testEnv.Env, "/nonexistent/base.json", "/nonexistent/head.json", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "base identity file") +} + +func TestDiffIdentities_MalformedJSON(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/base.json", + []byte("not valid json"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/head.json", + []byte(`[{"component":"a","fingerprint":"sha256:aaa"}]`), fileperms.PublicFile)) + + _, err := component.DiffIdentities(testEnv.Env, "/base.json", "/head.json", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "base identity file") +} + +func TestDiffIdentities_ValidFiles(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/base.json", + []byte(`[{"component":"curl","fingerprint":"sha256:aaa"}]`), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/head.json", + []byte(`[{"component":"curl","fingerprint":"sha256:bbb"},{"component":"wget","fingerprint":"sha256:ccc"}]`), + fileperms.PublicFile)) + + result, err := component.DiffIdentities(testEnv.Env, "/base.json", "/head.json", false) + require.NoError(t, err) + + // Default format is table, so we get []IdentityDiffResult. + tableResults, ok := result.([]component.IdentityDiffResult) + require.True(t, ok, "expected table results for default report format") + require.Len(t, tableResults, 2) +} + +func TestDiffIdentities_EmptyArray(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/base.json", + []byte(`[]`), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/head.json", + []byte(`[]`), fileperms.PublicFile)) + + result, err := component.DiffIdentities(testEnv.Env, "/base.json", "/head.json", false) + require.NoError(t, err) + + tableResults, ok := result.([]component.IdentityDiffResult) + require.True(t, ok) + assert.Empty(t, tableResults) +} + +func TestDiffIdentities_JSONFormat(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.Env.SetDefaultReportFormat(azldev.ReportFormatJSON) + + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/base.json", + []byte(`[{"component":"curl","fingerprint":"sha256:aaa"}]`), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, "/head.json", + []byte(`[{"component":"curl","fingerprint":"sha256:bbb"},{"component":"wget","fingerprint":"sha256:ccc"}]`), + fileperms.PublicFile)) + + result, err := component.DiffIdentities(testEnv.Env, "/base.json", "/head.json", false) + require.NoError(t, err) + + report, ok := result.(*component.IdentityDiffReport) + require.True(t, ok, "expected IdentityDiffReport for JSON format") + + assert.Equal(t, []string{"curl"}, report.Changed) + assert.Equal(t, []string{"wget"}, report.Added) + assert.Empty(t, report.Removed) + assert.Empty(t, report.Unchanged) + + // Verify JSON serialization produces [] not null for empty arrays. + jsonBytes, err := json.Marshal(report) + require.NoError(t, err) + + jsonStr := string(jsonBytes) + assert.Contains(t, jsonStr, `"removed":[]`) + assert.Contains(t, jsonStr, `"unchanged":[]`) + assert.NotContains(t, jsonStr, "null") +} diff --git a/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_1.snap.json b/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_1.snap.json new file mode 100755 index 0000000..dc0a9bb --- /dev/null +++ b/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_1.snap.json @@ -0,0 +1,3 @@ +{ + "ExitCode": 0 +} \ No newline at end of file diff --git a/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stderr_1.snap b/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stderr_1.snap new file mode 100755 index 0000000..b77b53d --- /dev/null +++ b/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stderr_1.snap @@ -0,0 +1 @@ +##:##:## INF No Azure Linux project found; some commands will not be available. diff --git a/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stdout_1.snap b/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stdout_1.snap new file mode 100755 index 0000000..e0585ad --- /dev/null +++ b/scenario/__snapshots__/TestComponentIdentitySnapshots_diff-identity_help_stdout_1.snap @@ -0,0 +1,32 @@ +Compare two component identity JSON files (produced by 'component identity -a -O json') +and report which components have changed, been added, or been removed. + +CI uses the 'changed' and 'added' lists to determine the build queue. + +Usage: + azldev component diff-identity [flags] + +Examples: + # Compare base and head identity files + azldev component diff-identity base-identity.json head-identity.json + + # JSON output for CI + azldev component diff-identity base.json head.json -O json + +Flags: + -c, --changed-only Only show changed and added components (the build queue) + -h, --help help for diff-identity + +Global Flags: + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output + diff --git a/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_1.snap.json b/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_1.snap.json new file mode 100755 index 0000000..dc0a9bb --- /dev/null +++ b/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_1.snap.json @@ -0,0 +1,3 @@ +{ + "ExitCode": 0 +} \ No newline at end of file diff --git a/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stderr_1.snap b/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stderr_1.snap new file mode 100755 index 0000000..b77b53d --- /dev/null +++ b/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stderr_1.snap @@ -0,0 +1 @@ +##:##:## INF No Azure Linux project found; some commands will not be available. diff --git a/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stdout_1.snap b/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stdout_1.snap new file mode 100755 index 0000000..6c3dae5 --- /dev/null +++ b/scenario/__snapshots__/TestComponentIdentitySnapshots_identity_help_stdout_1.snap @@ -0,0 +1,42 @@ +Compute a deterministic identity fingerprint for each selected component. + +The fingerprint captures all resolved build inputs (config fields, spec file +content, overlay source files, distro context, and Affects commit count). +A change to any input produces a different fingerprint. + +Use this with 'component diff-identity' to determine which components need +rebuilding between two commits. + +Usage: + azldev component identity [flags] + +Examples: + # All components, JSON output for CI + azldev component identity -a -O json > identity.json + + # Single component, table output for dev + azldev component identity -p curl + + # Components in a group + azldev component identity -g core + +Flags: + -a, --all-components Include all components + -p, --component stringArray Component name pattern + -g, --component-group stringArray Component group name + -h, --help help for identity + -s, --spec-path stringArray Spec path + +Global Flags: + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output + diff --git a/scenario/component_identity_test.go b/scenario/component_identity_test.go new file mode 100644 index 0000000..d32b098 --- /dev/null +++ b/scenario/component_identity_test.go @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//go:build scenario + +package scenario_tests + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/scenario/internal/cmdtest" + "github.com/microsoft/azure-linux-dev-tools/scenario/internal/projecttest" + "github.com/microsoft/azure-linux-dev-tools/scenario/internal/snapshot" + "github.com/microsoft/azure-linux-dev-tools/scenario/internal/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestComponentIdentitySnapshots tests basic CLI output snapshots for the identity commands. +func TestComponentIdentitySnapshots(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long test") + } + + tests := map[string]testhelpers.ScenarioTest{ + "identity help": cmdtest.NewScenarioTest("component", "identity", "--help").Locally(), + "diff-identity help": cmdtest.NewScenarioTest("component", "diff-identity", "--help").Locally(), + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + snapshot.TestSnapshottableCmd(t, test) + }) + } +} + +// TestComponentIdentityInContainer runs the full identity pipeline in a container: +// creates a project with two components, computes identity, modifies one component, +// recomputes identity, and diffs the two. +func TestComponentIdentityInContainer(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long test") + } + + // Create two specs for the project. + specA := projecttest.NewSpec( + projecttest.WithName("component-a"), + projecttest.WithVersion("1.0.0"), + ) + specB := projecttest.NewSpec( + projecttest.WithName("component-b"), + projecttest.WithVersion("2.0.0"), + ) + + project := projecttest.NewDynamicTestProject( + projecttest.AddSpec(specA), + projecttest.AddSpec(specB), + projecttest.UseTestDefaultConfigs(), + ) + + // Script that: + // 1. Computes identity for all components → base.json + // 2. Modifies component-a's spec file (changes version) + // 3. Recomputes identity → head.json + // 4. Diffs the two → diff.json + testScript := ` +set -ex + +rm -rf project/build +ln -s /var/lib/mock project/build + +# Compute base identity +azldev -C project -v component identity -a --output-format json > base.json + +# Modify component-a's spec (change version) +sed -i 's/Version: 1.0.0/Version: 1.1.0/' project/specs/component-a/component-a.spec + +# Compute head identity +azldev -C project -v component identity -a --output-format json > head.json + +# Diff the two +azldev -v component diff-identity base.json head.json --output-format json > diff.json +` + + scenarioTest := cmdtest.NewScenarioTest(). + WithScript(strings.NewReader(testScript)) + + // Serialize the project and add it to the container. + projectStagingDir := t.TempDir() + project.Serialize(t, projectStagingDir) + scenarioTest.AddDirRecursive(t, "project", projectStagingDir) + + // Add test default configs. + scenarioTest.AddDirRecursive(t, projecttest.TestDefaultConfigsSubdir, projecttest.TestDefaultConfigsDir()) + + results, err := scenarioTest. + InContainer(). + WithPrivilege(). + WithNetwork(). + Run(t) + + require.NoError(t, err) + results.AssertZeroExitCode(t) + + t.Logf("stdout:\n%s", results.Stdout) + t.Logf("stderr:\n%s", results.Stderr) + + // Parse base identity. + baseBytes, err := os.ReadFile(filepath.Join(results.Workdir, "base.json")) + require.NoError(t, err, "base.json should exist") + + var baseIdentities []map[string]interface{} + require.NoError(t, json.Unmarshal(baseBytes, &baseIdentities)) + require.Len(t, baseIdentities, 2, "should have 2 components in base identity") + + // Parse head identity. + headBytes, err := os.ReadFile(filepath.Join(results.Workdir, "head.json")) + require.NoError(t, err, "head.json should exist") + + var headIdentities []map[string]interface{} + require.NoError(t, json.Unmarshal(headBytes, &headIdentities)) + require.Len(t, headIdentities, 2, "should have 2 components in head identity") + + // Verify fingerprints differ for the modified component. + baseFPs := identityMap(baseIdentities) + headFPs := identityMap(headIdentities) + + assert.NotEqual(t, baseFPs["component-a"], headFPs["component-a"], + "component-a fingerprint should change after spec modification") + assert.Equal(t, baseFPs["component-b"], headFPs["component-b"], + "component-b fingerprint should NOT change") + + // Parse and validate the diff output. + diffBytes, err := os.ReadFile(filepath.Join(results.Workdir, "diff.json")) + require.NoError(t, err, "diff.json should exist") + + var diffReport map[string][]string + require.NoError(t, json.Unmarshal(diffBytes, &diffReport)) + + assert.Contains(t, diffReport["changed"], "component-a", + "diff should report component-a as changed") + assert.Contains(t, diffReport["unchanged"], "component-b", + "diff should report component-b as unchanged") + assert.Empty(t, diffReport["added"], "no components should be added") + assert.Empty(t, diffReport["removed"], "no components should be removed") +} + +// identityMap converts the JSON identity array to a map of component name → fingerprint. +func identityMap(identities []map[string]interface{}) map[string]string { + result := make(map[string]string, len(identities)) + + for _, entry := range identities { + name, _ := entry["component"].(string) + fingerprint, _ := entry["fingerprint"].(string) + result[name] = fingerprint + } + + return result +}