Skip to content

Commit 91678e9

Browse files
committed
feat(sdk): expose snapshot diff facade
Signed-off-by: Tjark Gunnar Rasche <trasche@nvidia.com>
1 parent 90a7f41 commit 91678e9

12 files changed

Lines changed: 1369 additions & 114 deletions

File tree

docs/integrator/go-library.md

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ than in yours.
3939
| `Example_criteriaDimensions` | The coverage dimensions | yes |
4040
| `Example_committedConfig` | `AICRConfig` → source → catalog → criteria, in the required order | no |
4141
| `Example_resolveFromSnapshot` | `LoadSnapshot` plus snapshot criteria relaxation | no |
42+
| `ExampleClient_DiffSnapshots` | In-memory drift detection between two loaded snapshots | no |
4243
| `ExampleClient_LoadRecipe` | Reading a previously emitted recipe | no |
4344
| `ExampleClient_CollectSnapshot` | Capturing cluster state via the snapshotter Job | no |
4445
| `ExampleClient_ValidateState` | Selecting validation phases, and `--no-cluster` mode | no |
@@ -128,10 +129,10 @@ func main() {
128129
## Snapshotting and validation
129130

130131
Beyond recipe resolution, the facade exposes the rest of the
131-
Snapshot → Validate workflow. Both methods are stateless w.r.t. the
132-
Client's recipe source; they are surfaced through the Client only to
133-
keep the facade uniform and leave room for future per-Client
134-
telemetry hooks.
132+
Snapshot → Validate workflow, including comparison of two snapshots for
133+
configuration drift. These operations are stateless w.r.t. the Client's recipe
134+
source; they are surfaced through the Client to keep the facade uniform and
135+
leave room for future per-Client telemetry hooks.
135136

136137
### Loading a snapshot you already have
137138

@@ -174,6 +175,46 @@ identity with the loaded snapshot matters, such as hashing what you
174175
validated, capture the source contents yourself and load from that
175176
capture instead of re-reading afterwards.
176177

178+
### Comparing snapshots for drift
179+
180+
`DiffSnapshots` compares the measurement payloads already held by two facade
181+
snapshots. The comparison is in memory: it does not read a cluster or revisit
182+
the file, URL, or ConfigMap the snapshots came from.
183+
184+
```go
185+
baseline, err := client.LoadSnapshot(ctx, "before.yaml", "")
186+
if err != nil {
187+
log.Fatalf("load baseline: %v", err)
188+
}
189+
target, err := client.LoadSnapshot(ctx, "after.yaml", "")
190+
if err != nil {
191+
log.Fatalf("load target: %v", err)
192+
}
193+
194+
result, err := client.DiffSnapshots(ctx, baseline, target, aicr.SnapshotDiffOptions{
195+
BaselineSource: "before.yaml",
196+
TargetSource: "after.yaml",
197+
})
198+
if err != nil {
199+
log.Fatalf("diff snapshots: %v", err)
200+
}
201+
if result.HasDrift() {
202+
log.Printf("detected %d change(s)", result.Summary.Total)
203+
}
204+
```
205+
206+
Drift is returned as data, not as an error. `SnapshotDiff.Changes` preserves
207+
added, removed, and modified values, while `Summary` provides aggregate counts.
208+
The source labels are optional output metadata and do not affect comparison.
209+
Use `aicr.WriteSnapshotDiffTable` for the same human-readable table format as
210+
`aicr diff`; JSON and YAML serializers can consume the facade-owned result
211+
directly.
212+
213+
Inputs must retain at least one typed measurement through `LoadSnapshot`,
214+
`CollectSnapshot`, or `WrapSnapshot`. A hand-constructed `&aicr.Snapshot{}` or
215+
a wrapped snapshot with no usable measurement is rejected instead of being
216+
reported as no drift.
217+
177218
### Capturing a snapshot from a live cluster
178219

179220
```go
@@ -1069,6 +1110,8 @@ Per-operation caps:
10691110
load whatever the source: a local file read, an HTTP(S) fetch, or a
10701111
`cm://` ConfigMap read against the Kubernetes API. Distinct from
10711112
`SnapshotOperationTimeout` below, which bounds deploying an agent Job.
1113+
- `DiffSnapshots`: **no facade cap** — comparison is in memory and the caller's
1114+
context governs unchanged.
10721115
- `CollectSnapshot`: caller-controlled via `AgentConfig.Timeout` (falling
10731116
back to `defaults.SnapshotOperationTimeout` when unset), plus
10741117
`defaults.SnapshotOperationGrace`. The grace exists because

docs/integrator/public-api.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ in the [Go library integration guide](./go-library.md).
3131
| `pkg/bom` | Internal | Bill-of-materials / image inventory generation. |
3232
| `pkg/config` | Internal | Config-file loading and flag/spec resolution. |
3333
| `pkg/corroborate` | Internal | Cross-source corroboration of observed state. |
34-
| `pkg/diff` | Internal | Structural diff between two snapshots. |
34+
| `pkg/diff` | Internal | Structural snapshot comparison implementation. External consumers use `Client.DiffSnapshots` and `aicr.WriteSnapshotDiffTable`. |
3535
| `pkg/fingerprint` | Internal | Cluster/provider fingerprint detection. |
3636
| `pkg/health` | Internal | Health-check orchestration. |
3737
| `pkg/helm` | Internal | Helm chart rendering helpers. |
@@ -65,6 +65,10 @@ unrelated exports in their evolving packages remain free to change.
6565
| Facade symbol | Translates to/from | Notes |
6666
|---|---|---|
6767
| `aicr.Snapshot` | `pkg/snapshotter.Snapshot` | **Facade-owned struct**. Public fields are identifying metadata; full measurement payload is preserved in an unexported field for round-trip through `ValidateState`. Obtain one from `Client.LoadSnapshot` (file, URL, or `cm://` ConfigMap) or `Client.CollectSnapshot` (live capture) — neither requires importing `pkg/snapshotter`. `aicr.WrapSnapshot` remains for the narrower case of lifting a `*snapshotter.Snapshot` you already hold from a direct `pkg/snapshotter` call. |
68+
| `aicr.SnapshotDiff`, `aicr.SnapshotChange`, `aicr.SnapshotDiffSummary` | `pkg/diff` result shapes | **Facade-owned structs** returned by `Client.DiffSnapshots`. They preserve the CLI's JSON/YAML schema without exposing `pkg/diff` types. Drift is data (`SnapshotDiff.HasDrift`), while invalid or payload-less inputs and context cancellation are errors. |
69+
| `aicr.SnapshotDiffOptions` | `Client.DiffSnapshots` input | **Facade-owned input struct** carrying optional baseline and target source labels. The labels are copied to output metadata and do not affect comparison semantics. |
70+
| `aicr.SnapshotChangeKind` and its constants | `pkg/diff.ChangeKind` | **Facade-owned string enum** whose values describe added, removed, and modified readings. |
71+
| `aicr.SnapshotChangeSeverity` and `aicr.SnapshotChangeSeverityInfo` | `pkg/diff.Severity` | **Facade-owned string enum** classifying change impact; informational is the currently defined severity. |
6872
| `aicr.AgentConfig` | `pkg/snapshotter.AgentConfig` | **Facade-owned struct** covering the deployment-time agent fields. `Tolerations` keeps `k8s.io/api/core/v1.Toleration` since `k8s.io` is itself a stable contract. It does **not** mirror every `pkg/snapshotter.AgentConfig` field — the network-collector fields `ClusterConfigPath` and `DiscoverNetwork` are not surfaced on the facade type. `AKSGPUPoolsPath` **is** surfaced (controller-side pool projection input, required for AKS profile-qualified resolution from a collected snapshot). |
6973
| `aicr.PhaseResult` | `pkg/validator.PhaseResult` | **Facade-owned struct**. Exposes `Summary` (CTRF counts) and `RawReport` (CTRF JSON bytes); `Report *ctrf.Report` is retained for in-tree consumers that merge per-phase reports. |
7074
| `aicr.Phase`, `aicr.PhaseDeployment` / `PhasePerformance` / `PhaseConformance` | string consts | **Facade-owned**. Values match `pkg/validator/v1` constants verbatim for byte-identical wire round-trip. |

pkg/cli/diff.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import (
2424

2525
"github.com/urfave/cli/v3"
2626

27+
aicr "github.com/NVIDIA/aicr/pkg/client/v1"
2728
"github.com/NVIDIA/aicr/pkg/defaults"
28-
"github.com/NVIDIA/aicr/pkg/diff"
2929
"github.com/NVIDIA/aicr/pkg/errors"
3030
"github.com/NVIDIA/aicr/pkg/serializer"
3131
)
@@ -122,13 +122,13 @@ func runDiffCmd(ctx context.Context, cmd *cli.Command) error {
122122
return err
123123
}
124124

125-
// Unwrap to reach pkg/diff, which still takes the internal shape. This is
126-
// the last direct hop left in this command; exposing diff on the facade
127-
// (#2025) is what removes it, and it was deliberately sequenced after
128-
// LoadSnapshot so it can take facade snapshots rather than paths.
129-
result := diff.Snapshots(baseline.Unwrap(), target.Unwrap())
130-
result.BaselineSource = baselinePath
131-
result.TargetSource = targetPath
125+
result, err := client.DiffSnapshots(ctx, baseline, target, aicr.SnapshotDiffOptions{
126+
BaselineSource: baselinePath,
127+
TargetSource: targetPath,
128+
})
129+
if err != nil {
130+
return err
131+
}
132132

133133
slog.Info("snapshot diff complete",
134134
slog.Int("added", result.Summary.Added),
@@ -154,7 +154,7 @@ func runDiffCmd(ctx context.Context, cmd *cli.Command) error {
154154
//
155155
// kubeconfig is propagated through to ConfigMap writers so multi-cluster
156156
// workflows write back to the same cluster the snapshots were read from.
157-
func writeDiffResult(ctx context.Context, cmd *cli.Command, outFormat serializer.Format, kubeconfig string, result *diff.Result) (err error) {
157+
func writeDiffResult(ctx context.Context, cmd *cli.Command, outFormat serializer.Format, kubeconfig string, result *aicr.SnapshotDiff) (err error) {
158158
output := cmd.String("output")
159159

160160
// Use custom table writer for human-readable output
@@ -176,7 +176,7 @@ func writeDiffResult(ctx context.Context, cmd *cli.Command, outFormat serializer
176176
}()
177177
w = f
178178
}
179-
return diff.WriteTable(w, result)
179+
return aicr.WriteSnapshotDiffTable(w, result)
180180
}
181181

182182
// JSON/YAML use standard serializer; thread kubeconfig so ConfigMap

pkg/cli/diff_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323

2424
"github.com/urfave/cli/v3"
2525

26-
"github.com/NVIDIA/aicr/pkg/diff"
26+
aicr "github.com/NVIDIA/aicr/pkg/client/v1"
2727
"github.com/NVIDIA/aicr/pkg/serializer"
2828
)
2929

@@ -111,13 +111,13 @@ func TestWriteTable_ToFile(t *testing.T) {
111111
tmpDir := t.TempDir()
112112
outFile := filepath.Join(tmpDir, "out.txt")
113113

114-
result := &diff.Result{Changes: make([]diff.Change, 0)}
114+
result := &aicr.SnapshotDiff{Changes: make([]aicr.SnapshotChange, 0)}
115115
f, err := os.Create(outFile)
116116
if err != nil {
117117
t.Fatalf("failed to create output file: %v", err)
118118
}
119119

120-
err = diff.WriteTable(f, result)
120+
err = aicr.WriteSnapshotDiffTable(f, result)
121121
if closeErr := f.Close(); closeErr != nil && err == nil {
122122
err = closeErr
123123
}
@@ -135,10 +135,10 @@ func TestWriteTable_ToFile(t *testing.T) {
135135
}
136136

137137
func TestWriteTable_ToStdout(t *testing.T) {
138-
result := &diff.Result{Changes: make([]diff.Change, 0)}
138+
result := &aicr.SnapshotDiff{Changes: make([]aicr.SnapshotChange, 0)}
139139

140140
// WriteTable to stdout should not error.
141-
err := diff.WriteTable(os.Stdout, result)
141+
err := aicr.WriteSnapshotDiffTable(os.Stdout, result)
142142
if err != nil {
143143
t.Errorf("WriteTable to stdout failed: %v", err)
144144
}
@@ -336,7 +336,7 @@ func TestWriteDiffResult_TableToFile(t *testing.T) {
336336
outFile := filepath.Join(tmpDir, "out.txt")
337337

338338
cmd := buildDiffCommandWithOutput(t, outFile)
339-
result := &diff.Result{Changes: make([]diff.Change, 0)}
339+
result := &aicr.SnapshotDiff{Changes: make([]aicr.SnapshotChange, 0)}
340340

341341
if err := writeDiffResult(t.Context(), cmd, serializer.FormatTable, "", result); err != nil {
342342
t.Fatalf("writeDiffResult failed: %v", err)
@@ -358,7 +358,7 @@ func TestWriteDiffResult_CreateFails(t *testing.T) {
358358
bogusPath := filepath.Join(t.TempDir(), "does-not-exist", "out.txt")
359359

360360
cmd := buildDiffCommandWithOutput(t, bogusPath)
361-
result := &diff.Result{Changes: make([]diff.Change, 0)}
361+
result := &aicr.SnapshotDiff{Changes: make([]aicr.SnapshotChange, 0)}
362362

363363
err := writeDiffResult(t.Context(), cmd, serializer.FormatTable, "", result)
364364
if err == nil {
@@ -380,7 +380,7 @@ func TestWriteDiffResult_KubeconfigPropagatesToConfigMap(t *testing.T) {
380380
bogusKubeconfig := filepath.Join(tmpDir, "missing-kubeconfig.yaml")
381381

382382
cmd := buildDiffCommandWithOutput(t, "cm://aicr/test")
383-
result := &diff.Result{Changes: make([]diff.Change, 0)}
383+
result := &aicr.SnapshotDiff{Changes: make([]aicr.SnapshotChange, 0)}
384384

385385
err := writeDiffResult(t.Context(), cmd, serializer.FormatJSON, bogusKubeconfig, result)
386386
if err == nil {

pkg/client/v1/aicr.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
// - LoadSnapshot — read a previously captured *Snapshot from a file,
3333
// URL, or cm:// ConfigMap, for the common case where the snapshot
3434
// already exists and no cluster is needed.
35+
// - DiffSnapshots — compare two loaded or collected snapshots in memory and
36+
// return facade-owned field-level changes for drift detection.
3537
// - ValidateState — evaluate a resolved recipe against a snapshot,
3638
// running deployment / conformance / performance phases.
3739
// - LoadConfig — read and validate the AICRConfig a team commits, from a
@@ -62,10 +64,10 @@
6264
// - VerifyBinaryAttestation — package-level; prove an aicr binary was
6365
// built by NVIDIA CI.
6466
//
65-
// All facade types (Snapshot, AgentConfig, Criteria, RecipeRequest,
66-
// RecipeResult, ComponentBundle, ComponentRef, PhaseResult, and AllowLists)
67-
// are facade-owned structs translated to and from the upstream pkg/*
68-
// shapes, so internal field renames don't churn external callers.
67+
// All facade types (Snapshot, SnapshotDiff, SnapshotChange, AgentConfig,
68+
// Criteria, RecipeRequest, RecipeResult, ComponentBundle, ComponentRef,
69+
// PhaseResult, AllowLists) are facade-owned structs translated to and from the
70+
// upstream pkg/* shapes, so internal field renames don't churn external callers.
6971
//
7072
// Seven types remain deliberate transparent aliases: BundleConfig,
7173
// BundleAttester, BundleArtifact, OIDCResolveOptions, CriteriaRegistry,

0 commit comments

Comments
 (0)