diff --git a/docs/integrator/go-library.md b/docs/integrator/go-library.md index c6db01b1d..d097c9105 100644 --- a/docs/integrator/go-library.md +++ b/docs/integrator/go-library.md @@ -22,6 +22,44 @@ You _may_ also import `pkg/*` subpackages directly, but their APIs are not covered by the same stability guarantees — see the [public API surface](./public-api.md) for the details. +## Runnable examples + +Each facade entry point below has a compiled counterpart in +[`pkg/client/v1`](https://pkg.go.dev/github.com/NVIDIA/aicr/pkg/client/v1#pkg-examples). +They are ordinary Go example functions, so `go test` builds them on every +change — a facade change that breaks one of these fails in AICR's tree rather +than in yours. + +| Example | Covers | Runs | +|---|---|---| +| `Example` | Quick start: client, resolve from criteria | yes | +| `Example_errorCodes` | Matching structured error codes | yes | +| `Example_bundleAndVerify` | Resolve → bundle → verify, hermetically | yes | +| `Example_trustLevels` | The accepted trust levels, and their ordering trap | yes | +| `Example_criteriaDimensions` | The coverage dimensions | yes | +| `Example_committedConfig` | `AICRConfig` → source → catalog → criteria, in the required order | no | +| `Example_resolveFromSnapshot` | `LoadSnapshot` plus snapshot criteria relaxation | no | +| `ExampleClient_LoadRecipe` | Reading a previously emitted recipe | no | +| `ExampleClient_CollectSnapshot` | Capturing cluster state via the snapshotter Job | no | +| `ExampleClient_ValidateState` | Selecting validation phases, and `--no-cluster` mode | no | +| `ExampleClient_RecipeDigest` | The digest a CI staleness gate compares | no | +| `ExampleClient_VerifyEvidence` | Evidence verification and exit classes | no | +| `ExampleClient_VerifyCatalog` / `ExampleClient_SignCatalog` | Checking and producing the catalog signature | no | +| `ExampleClient_PublishEvidence` | Signing and pushing an evidence bundle | no | +| `ExampleVerifyBinaryAttestation` | Proving a binary came from NVIDIA CI | no | + +**What "runs" means, and what it does not.** Examples marked *yes* print an +`Output:` block, so `go test` executes them and asserts the output. The rest +are **compiled but not executed** — they need a cluster, a registry, a signing +identity, or files that belong to your environment. Compilation still pins +every signature, field name, and option they touch, so a renamed method or a +dropped field breaks the build; it does not prove those flows behave +correctly at runtime. + +The guarantee covers the examples, not this page. Prose here can still drift, +and short illustrative snippets outside the table are not compiled — prefer +copying from the examples, which are complete and known to build. + ## Installing ```bash @@ -168,9 +206,15 @@ capture instead of re-reading afterwards. snapCtx, cancelSnap := context.WithTimeout(context.Background(), 10*time.Minute) defer cancelSnap() snap, err := client.CollectSnapshot(snapCtx, &aicr.AgentConfig{ - Kubeconfig: "/path/to/target-kubeconfig", + Kubeconfig: "/path/to/target-kubeconfig", + // Namespace, Image, JobName, and ServiceAccountName are all required on + // the SDK path. Only Namespace is validated; the rest are copied straight + // into the Job and RBAC objects, so an empty value becomes an empty + // metadata.name or container image that the API server rejects. The CLI + // defaults them from its own flags, which the facade does not share. Namespace: "aicr-snapshot", Image: "ghcr.io/nvidia/aicr:v0.11.1", + JobName: "aicr-snapshot", ServiceAccountName: "aicr-agent", Timeout: 5 * time.Minute, Cleanup: true, @@ -906,7 +950,9 @@ concern the caller owns, so both can run unattended from a server. ## Errors All errors returned by the facade are `*pkg/errors.StructuredError` -values carrying an `ErrorCode`. Use `errors.As` to inspect: +values carrying an `ErrorCode`. Match on the code with `errors.Is` — +`StructuredError.Is` reports a match when the target is a `StructuredError` +with the same code, so this works through wrap chains: ```go import ( @@ -916,9 +962,25 @@ import ( ) _, err := client.ResolveRecipe(ctx, req) -var se *aicrerrors.StructuredError -if stderrors.As(err, &se) && se.Code == aicrerrors.ErrCodeInvalidRequest { +switch { +case stderrors.Is(err, aicrerrors.New(aicrerrors.ErrCodeInvalidRequest, "")): // handle invalid input +case stderrors.Is(err, aicrerrors.New(aicrerrors.ErrCodeNotFound, "")): + // handle missing recipe +} +``` + +Runnable version: [`Example_errorCodes`](https://pkg.go.dev/github.com/NVIDIA/aicr/pkg/client/v1#example-package-ErrorCodes). + +Reach for `errors.As` only when you need the error's *payload* rather than +its class — `se.Context`, which carries structured detail such as a coverage +failure's `uncovered` dimensions: + +```go +var se *aicrerrors.StructuredError +if stderrors.As(err, &se) { + uncovered := se.Context["uncovered"] + _ = uncovered } ``` @@ -966,6 +1028,38 @@ Per-operation caps: Passing a `nil` `context.Context` returns `ErrCodeInvalidRequest`. Use `context.Background()` (or a deadline-bounded child) for unbounded callers. +## The integrator contract + +Four commitments, stated plainly, so you know what you are depending on. + +**Import `pkg/client/v1`. That is the contract.** Everything else under +`pkg/*` stays importable, but only this package is compatibility-reviewed, and +only its exported surface is checked by the API-diff gate on every PR. The +[stability matrix](./public-api.md#stability-tiers) tiers each package; +`Internal` packages will break you on upgrade. + +**When the facade is missing something, tell us instead of routing around +it.** [Open an issue](https://github.com/NVIDIA/aicr/issues/new/choose) +describing the capability. Reaching into an evolving subpackage works today +and is the thing most likely to break you later, and we would rather extend +the facade — that is how `LoadSnapshot`, `LoadConfig`, and the verification +surface all arrived. Where this guide shows a deliberate escape hatch (the +fingerprint step under [Criteria relaxation](#criteria-relaxation-on-the-snapshot-path)), +it says so and explains the coupling you are accepting. + +**Breaking changes are detected, not merely intended.** `tools/api-diff` +compares the facade and its transparent-alias targets against the last release +on every PR; an incompatible change fails CI and requires a recorded, reviewed +exception. That is a mechanical guarantee, not a policy promise — but note +what it does *not* cover: behavior. A function keeping its signature while +changing what it does passes the gate. + +**The examples are compiled.** Every entry in the [examples +table](#runnable-examples) builds in AICR's own test suite, so a facade change +that invalidates one fails here first. Scope that honestly: it covers those +examples, not this page's prose or its shorter inline snippets, and for the +majority it proves compilation rather than runtime behavior. + ## Compatibility Today AICR is pre-1.0. Under Go module versioning, a v0 minor release may diff --git a/docs/integrator/public-api.md b/docs/integrator/public-api.md index 623d861da..935d43dc9 100644 --- a/docs/integrator/public-api.md +++ b/docs/integrator/public-api.md @@ -78,6 +78,10 @@ unrelated exports in their evolving packages remain free to change. | `aicr.BundleArtifact` | `*pkg/bundler/result.Output` | Deliberate transparent alias. Callers receive the complete bundler result, including `HasErrors`, without a lossy projection. | | `aicr.OIDCResolveOptions` | `pkg/bundler/attestation.ResolveOptions` | Deliberate transparent alias. CLI and server callers can pass the same late-bound signing inputs used by the attestation resolver. | | `aicr.CriteriaRegistry` | `pkg/recipe.CriteriaRegistry` | Documented transparent alias. Kept as an alias intentionally because the registry is behavior-rich (`ParseService`, `SetStrict`, `Values`, ...) and carries mutable per-`DataProvider` state — wrapping would either break the per-Client identity coupling (copy) or add no isolation win over the alias (pointer). | +| `aicr.BundleVerifyReport` | `pkg/bundler/verifier.VerifyResult` | Deliberate transparent alias. Callers receive the verifier's complete report (`TrustLevel`, `TrustReason`, `Errors`, per-stage booleans) rather than a projection that would have to grow with every new check. | +| `aicr.EvidenceVerification` | `pkg/evidence/verifier.VerifyResult` | Deliberate transparent alias, for the same reason, and so `aicr.RenderEvidenceJSON` / `RenderEvidenceMarkdown` render the identical document `aicr evidence verify` emits. | +| `aicr.Config` | `pkg/config.AICRConfig` | **Facade-owned wrapper**, not an alias: Go cannot attach methods to another package's type through an alias, and the config document's ~30 nested types would otherwise freeze under the API-diff gate. Obtain one from `aicr.LoadConfig` (file or HTTP(S) URL) or `aicr.WrapConfig`. Its methods DERIVE options (`BundleVerifyOptions`, `RecipeSource`, `RecipeCriteria`, `RecipeResolveOptions`, ...) rather than applying them, so caller overrides stay explicit; `Unwrap()` reaches the raw document for fields the facade does not project. | +| `aicr.CriteriaDimension`, `aicr.DimensionService` / `DimensionAccelerator` / `DimensionIntent` / `DimensionOS` / `DimensionPlatform` | string consts | **Facade-owned.** The criteria dimensions subject to the coverage post-condition, and the values `WithSnapshotCriteriaRelaxation` accepts. Values match `pkg/recipe.CoverageDimensionNames` exactly, which a test asserts. `nodes` is absent: no overlay gates on it. | ## Recommended consumption pattern diff --git a/pkg/client/v1/aicr.go b/pkg/client/v1/aicr.go index d2398f814..003aedbaf 100644 --- a/pkg/client/v1/aicr.go +++ b/pkg/client/v1/aicr.go @@ -34,6 +34,18 @@ // already exists and no cluster is needed. // - ValidateState — evaluate a resolved recipe against a snapshot, // running deployment / conformance / performance phases. +// - LoadConfig — read and validate the AICRConfig a team commits, from a +// file or an HTTP(S) URL. WrapConfig lifts one already parsed elsewhere; +// it does no parsing itself. Either way the resulting Config DERIVES +// options (Config.BundleVerifyOptions, Config.RecipeSource, +// Config.RecipeCriteria, ...) rather than applying them: a Config never +// attaches to a Client and is never consulted implicitly, so caller +// precedence stays one readable line at the call site. +// +// Resolution behavior is tuned per call with RecipeResolveOption — +// WithProfile, WithAccountingMode, and WithSnapshotCriteriaRelaxation (the +// relax-and-retry policy behind `aicr recipe --snapshot`, which takes the +// criteria dimensions the caller stated explicitly and may clear the rest). // // The supply-chain half covers both producing and checking artifacts: // diff --git a/pkg/client/v1/example_test.go b/pkg/client/v1/example_test.go new file mode 100644 index 000000000..8fc30b84c --- /dev/null +++ b/pkg/client/v1/example_test.go @@ -0,0 +1,653 @@ +// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Runnable examples for the integrator surface (issue #2029). +// +// These are the canonical form of the flows in docs/integrator/go-library.md. +// `go test` compiles every one of them, so a facade change that breaks a +// documented flow fails in this tree rather than in a consumer's — the guide's +// prose can still drift, but its code cannot. +// +// Two kinds live here: +// +// - Examples with an "Output:" comment RUN. Keep their output stable: print +// criteria strings and error codes, never component counts or versions, +// which change as the catalog evolves and would fail unrelated PRs. +// - Examples without one are COMPILE-ONLY. Those use realistic paths +// ("aicr-config.yaml") that read correctly in godoc but do not exist here. +// They still pin every signature, field name, and option they touch. +// +// Errors are handled with log.Print + return rather than log.Fatal: these +// examples hold a Client whose Close is deferred, and log.Fatal exits the +// process without running deferred functions. Copying the wrong idiom out of +// a godoc example is how it spreads. +package aicr_test + +import ( + "context" + stderrors "errors" + "fmt" + "log" + "os" + + aicr "github.com/NVIDIA/aicr/pkg/client/v1" + aicrerrors "github.com/NVIDIA/aicr/pkg/errors" +) + +// Example is the quick start: build a Client over the embedded recipe data and +// resolve a recipe from explicit criteria. +func Example() { + ctx := context.Background() + + client, err := aicr.NewClient( + aicr.WithRecipeSource(aicr.EmbeddedSource()), + aicr.WithVersion("v0.19.0"), + ) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + result, err := client.ResolveRecipeFromCriteria(ctx, &aicr.Criteria{ + Service: "eks", + Accelerator: "h100", + Intent: "training", + }) + if err != nil { + log.Print(err) + return + } + + // Name is the resolved criteria's canonical string. Unstated dimensions + // still render, with an empty value. + fmt.Println(result.Name) + // Output: criteria(service=eks, accelerator=h100, intent=training, os=, platform=) +} + +// Example_errorCodes shows the error-handling contract. Every facade error is a +// *pkg/errors.StructuredError carrying an ErrorCode, and StructuredError.Is +// matches on that code — so errors.Is works through wrap chains without +// unwrapping by hand. +func Example_errorCodes() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + // A service no catalog defines. Membership is checked against this + // Client's CriteriaRegistry, so the request is rejected rather than + // silently resolving something broader. + _, err = client.ResolveRecipeFromCriteria(ctx, &aicr.Criteria{Service: "no-such-service"}) + + switch { + case err == nil: + fmt.Println("resolved") + case stderrors.Is(err, aicrerrors.New(aicrerrors.ErrCodeInvalidRequest, "")): + fmt.Println("invalid request") + case stderrors.Is(err, aicrerrors.New(aicrerrors.ErrCodeNotFound, "")): + fmt.Println("not found") + default: + fmt.Println("other") + } + // Output: invalid request +} + +// Example_committedConfig resolves from an AICRConfig a team commits alongside +// their code, so snapshot / recipe / bundle / verify settings are not retyped +// on each invocation. +// +// The ORDER matters and is the reason this example exists. Criteria membership +// is validated against a CriteriaRegistry, which is per-DataProvider — so the +// Client must exist and its catalog must be loaded before RecipeCriteria can +// resolve a value an external --data overlay contributed. Calling +// RecipeCriteria first works only for values in the embedded catalog. +func Example_committedConfig() { + ctx := context.Background() + + cfg, err := aicr.LoadConfig(ctx, "aicr-config.yaml") + if err != nil { + log.Print(err) + return + } + + // spec.recipe.data, when the document sets one. + source, ok := cfg.RecipeSource() + if !ok { + source = aicr.EmbeddedSource() + } + + client, err := aicr.NewClient(aicr.WithRecipeSource(source)) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + // Seeds the registry RecipeCriteria validates against. + if err = client.LoadCatalog(ctx); err != nil { + log.Print(err) + return + } + + criteria, err := cfg.RecipeCriteria(client.CriteriaRegistry()) + if err != nil { + log.Print(err) + return + } + + opts, err := cfg.RecipeResolveOptions() + if err != nil { + log.Print(err) + return + } + + result, err := client.ResolveRecipeFromCriteriaWithOptions(ctx, criteria, opts...) + if err != nil { + log.Print(err) + return + } + fmt.Println(result.Name) +} + +// Example_resolveFromSnapshot approximates `aicr recipe --snapshot`: resolve +// against a captured snapshot with the relax-and-retry policy the CLI applies. +// +// # The facade does not derive criteria from the snapshot +// +// ResolveRecipeFromSnapshotWithOptions takes your Criteria verbatim and uses +// the snapshot only to evaluate constraints and drive snapshot-aware +// post-processing. Producing criteria from a snapshot's measurements is the +// CLI's job, and there is no facade entry point for it yet — the integration +// guide shows the pkg/fingerprint escape hatch and the coupling it costs. +// +// So SUPPLY every dimension yourself, then use WithSnapshotCriteriaRelaxation +// to say which ones the user actually typed. Below, intent was typed and +// service and os were derived, so only those two may be relaxed. +// +// These values are chosen so relaxation genuinely fires: no kind overlay +// states an os, so the derived os comes back uncovered and is cleared, while +// the stated intent is protected. Two ways to make the policy inert, both +// silent: name every dimension you supplied (a specified-and-stated dimension +// is never cleared), or leave dimensions unset (the coverage post-condition +// only reports dimensions you SPECIFIED, so an unset one is never uncovered). +func Example_resolveFromSnapshot() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + // File path, HTTP(S) URL, or cm://namespace/name ConfigMap. + snap, err := client.LoadSnapshot(ctx, "snapshot.yaml", "") + if err != nil { + log.Print(err) + return + } + + criteria := &aicr.Criteria{ + Service: "kind", // derived: read off the snapshot + OS: "ubuntu", // derived, and uncovered by every kind overlay + Intent: "inference", // stated: the user asked for this + } + + result, err := client.ResolveRecipeFromSnapshotWithOptions(ctx, criteria, snap, + aicr.WithSnapshotCriteriaRelaxation(aicr.DimensionIntent)) + if err != nil { + log.Print(err) + return + } + + // Prints "relaxed os" for the criteria above: no kind overlay distinguishes + // ubuntu, so the derived os is cleared and the retry succeeds. Intent can + // never appear here — it was declared stated. + for _, dim := range result.RelaxedDimensions { + fmt.Printf("relaxed %s; resolved recipe is broader than requested\n", dim) + } +} + +// Example_bundleAndVerify is the integrator path end to end: resolve a recipe, +// render its deployment bundle, then check what was written. +// +// It runs hermetically against the embedded catalog, into a temporary +// directory, with no signing and no network — which is why it can assert its +// output, and why that output is "unverified". +// +// # Reading the result +// +// Failure arrives on THREE independent channels, and checking one is not +// enough: +// +// - the returned error — the bundle could not be produced at all; +// - BundleArtifact.HasErrors() — per-bundler failures that did not abort +// the run, so files exist but the set is incomplete; +// - on verification, BundleVerification.PolicyFailure (the trust floor was +// not met) AND Report.Errors (a check itself failed, e.g. a bad checksum). +// +// # Why "unverified" +// +// BundleOptions.Attester is nil here, so MakeBundle uses the no-op attester — +// the same default as `aicr bundle` without --attest. An unsigned bundle can +// reach "unverified" (checksums valid, no attestation) and no higher, so +// demanding MinTrustLevel "verified" would fail every time. Leaving it empty +// selects the "max" default: verify against the highest level this bundle can +// actually achieve. To reach "verified", pass an Attester and a binary +// attestation. +func Example_bundleAndVerify() { + ctx := context.Background() + + client, err := aicr.NewClient( + aicr.WithRecipeSource(aicr.EmbeddedSource()), + aicr.WithVersion("v0.19.0"), + ) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + result, err := client.ResolveRecipeFromCriteria(ctx, &aicr.Criteria{ + Service: "eks", + Accelerator: "h100", + Intent: "training", + }) + if err != nil { + log.Print(err) + return + } + + // Per-component Helm values and stitched manifests, without touching disk. + bundles, err := client.BundleComponents(ctx, result) + if err != nil { + log.Print(err) + return + } + for _, b := range bundles { + _ = b.Component.Name + _ = b.HelmValues + _ = b.Manifests + } + + // Or write a full bundle directory. + outputDir, err := os.MkdirTemp("", "aicr-bundle-") + if err != nil { + log.Print(err) + return + } + defer func() { _ = os.RemoveAll(outputDir) }() + + artifact, err := client.MakeBundle(ctx, result, aicr.BundleOptions{ + OutputDir: outputDir, + }) + if err != nil { + log.Print(err) + return + } + // Non-fatal per-bundler failures: files were written, but not all of them. + if artifact.HasErrors() { + log.Printf("bundle completed with %d errors", len(artifact.Errors)) + return + } + + verification, err := client.VerifyBundle(ctx, outputDir, aicr.BundleVerifyOptions{ + // Empty means "max": verify against the highest level achievable. + MinTrustLevel: "", + }) + if err != nil { + log.Print(err) + return + } + if verification.PolicyFailure != "" { + log.Printf("policy: %s", verification.PolicyFailure) + return + } + if len(verification.Report.Errors) > 0 { + log.Printf("verification: %s", verification.Report.Errors[0]) + return + } + + fmt.Println(verification.Report.TrustLevel) + // Output: unverified +} + +// ExampleClient_LoadRecipe reads a recipe emitted earlier by `aicr recipe -o`, +// instead of resolving a new one. The result is interchangeable with a +// resolved one: bundle it, or validate it against a snapshot. +func ExampleClient_LoadRecipe() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + // A local file path, an HTTP(S) URL, or a cm://namespace/name ConfigMap + // URI. The kubeconfig argument is consulted only for the cm:// form. + result, err := client.LoadRecipe(ctx, "recipe.yaml", "") + if err != nil { + log.Print(err) + return + } + fmt.Println(result.Name) +} + +// ExampleClient_CollectSnapshot captures cluster state by deploying the +// snapshotter Job, for callers that do not already have a snapshot file. +// Requires a reachable cluster and RBAC to create the Job. +// +// # Image, JobName, and ServiceAccountName are required here +// +// DeployAndCollect validates only Namespace; the rest are copied straight into +// the Job and RBAC objects. The CLI supplies defaults from its own flags, +// which the facade does not share — so leaving these empty produces an empty +// ServiceAccount name and an empty container image, and the API server rejects +// the ServiceAccount before the Job is ever created. Set all three. +func ExampleClient_CollectSnapshot() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + snap, err := client.CollectSnapshot(ctx, &aicr.AgentConfig{ + Namespace: "aicr-system", + Image: "ghcr.io/nvidia/aicr:v0.19.0", + JobName: "aicr", + ServiceAccountName: "aicr", + Cleanup: true, + }) + if err != nil { + log.Print(err) + return + } + + // Persist Raw rather than re-serializing: a newer agent image can emit + // fields this binary's Snapshot type does not model, and a typed round + // trip silently drops them. + if err = os.WriteFile("snapshot.yaml", snap.Raw, 0o600); err != nil { + log.Print(err) + return + } +} + +// ExampleClient_ValidateState evaluates a resolved recipe against observed +// cluster state. +// +// Validation comprises three phases, executed in order: deployment, +// conformance, performance. This example narrows to the first two with +// WithValidationPhases; omitting that option runs all three. +// +// WithValidationNoCluster(true) keeps constraint evaluation but skips +// everything needing a cluster — the mode CI uses to check a recipe against a +// captured snapshot without provisioning hardware. +func ExampleClient_ValidateState() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + recipe, err := client.LoadRecipe(ctx, "recipe.yaml", "") + if err != nil { + log.Print(err) + return + } + snap, err := client.LoadSnapshot(ctx, "snapshot.yaml", "") + if err != nil { + log.Print(err) + return + } + + phases, err := client.ValidateState(ctx, recipe, snap, + aicr.WithValidationNoCluster(true), + aicr.WithValidationPhases(aicr.PhaseDeployment, aicr.PhaseConformance), + ) + if err != nil { + log.Print(err) + return + } + for _, p := range phases { + fmt.Printf("%s: %d passed, %d failed\n", p.Phase, p.Summary.Passed, p.Summary.Failed) + } +} + +// ExampleClient_RecipeDigest computes the canonical digest an evidence +// predicate records. A CI gate compares this against the digest inside a +// published evidence bundle to detect evidence that has gone stale relative to +// the recipe it claims to describe. +func ExampleClient_RecipeDigest() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + digest, err := client.RecipeDigest(ctx, aicr.RecipeDigestOptions{ + Path: "recipe.yaml", + }) + if err != nil { + log.Print(err) + return + } + fmt.Println(digest) +} + +// ExampleClient_VerifyCatalog checks the Sigstore signature over this Client's +// recipe catalog — that the recipe data resolution is about to use was +// published by NVIDIA CI and has not been altered. The bundle ships as the +// recipe-catalog.sigstore.json release asset. +// +// The digest is computed over THIS Client's DataProvider. A Client layering +// external data over the embedded tree is verifying different content, so it +// will not match the released signature — that is the correct answer to "is +// the catalog I am resolving against the signed one", not a failure to +// work around. +func ExampleClient_VerifyCatalog() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.FilesystemSource("/etc/aicr/recipes"))) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + verification, err := client.VerifyCatalog(ctx, "recipe-catalog.sigstore.json", aicr.CatalogVerifyOptions{}) + if err != nil { + log.Print(err) + return + } + fmt.Printf("signed by %s over %s\n", verification.Identity, verification.Digest) +} + +// ExampleClient_SignCatalog signs a recipe catalog with keyless Sigstore. +// +// # This is a release-CI flow, not a local one +// +// VerifyCatalog pins the certificate identity to this repository's tag-release +// workflow. A catalog signed anywhere else verifies against nothing, so this +// is only useful from that workflow, with ambient credentials supplied. +// +// Leaving OIDCResolve zero-valued is the trap: SelectOIDCSource then falls +// through to the interactive BROWSER flow, which blocks on a human and mints a +// certificate issued by oauth2.sigstore.dev. SignCatalog succeeds and emits a +// bundle VerifyCatalog rejects — it fails the issuer pin before identity +// matching is even reached. +// +// SignCatalog does reject settings that break verifiability — a signing key, a +// private Fulcio or Rekor, or a disabled transparency-log upload — but it does +// NOT police the identity SOURCE, which is the asymmetry an SDK caller is most +// likely to hit. +// +// Signing is also deliberately not bounded by a facade timeout, because keyless +// OIDC can block on a human. +func ExampleClient_SignCatalog() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.FilesystemSource("/etc/aicr/recipes"))) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + signed, err := client.SignCatalog(ctx, aicr.CatalogSignOptions{ + Output: "recipe-catalog.sigstore.json", + OIDCResolve: aicr.OIDCResolveOptions{ + // Ambient workload credentials. Both must be set, or resolution + // falls through to the browser flow described above. + AmbientURL: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"), + AmbientToken: os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), + }, + }) + if err != nil { + log.Print(err) + return + } + fmt.Printf("signed catalog digest %s (%d bytes)\n", signed.Digest, len(signed.BundleJSON)) +} + +// ExampleClient_PublishEvidence signs a recipe-evidence bundle and pushes it to +// an OCI registry, the producing half of ExampleClient_VerifyEvidence. +func ExampleClient_PublishEvidence() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + if err = client.PublishEvidence(ctx, aicr.EvidencePublishOptions{ + BundleDir: "./evidence", + Push: "ghcr.io/example/aicr-evidence:v1", + }); err != nil { + log.Print(err) + return + } +} + +// ExampleClient_VerifyEvidence checks a recipe-evidence bundle's signature and +// hash chain. Input accepts a pointer file, a directory, or an OCI reference. +func ExampleClient_VerifyEvidence() { + ctx := context.Background() + + client, err := aicr.NewClient(aicr.WithRecipeSource(aicr.EmbeddedSource())) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close() }() + + verification, err := client.VerifyEvidence(ctx, aicr.EvidenceVerifyOptions{ + Input: "evidence.json", + }) + if err != nil { + log.Print(err) + return + } + + switch verification.Exit { + case aicr.EvidenceExitValidPassed: + fmt.Println("valid, all phases passed") + case aicr.EvidenceExitValidPhaseFailures: + fmt.Println("valid, but phases failed") + case aicr.EvidenceExitInvalid: + fmt.Println("invalid") + case aicr.EvidenceExitIncomplete: + fmt.Println("incomplete") + } + + fmt.Println(aicr.RenderEvidenceMarkdown(verification)) +} + +// ExampleVerifyBinaryAttestation proves an aicr binary was built by NVIDIA CI. +// It is package-level rather than a Client method: verifying a binary needs no +// recipe data, so it requires no Client. +func ExampleVerifyBinaryAttestation() { + ctx := context.Background() + + builder, err := aicr.VerifyBinaryAttestation(ctx, aicr.BinaryAttestationVerifyOptions{ + Attestation: []byte(`{}`), // the .intoto.jsonl bundle shipped with the release + BinaryDigest: []byte{ + 0x00, 0x01, 0x02, 0x03, + }, + // Defaults to the release workflow on tag refs. An override must still + // begin with the NVIDIA/aicr repository prefix; ValidateIdentityPattern + // reports whether a candidate is acceptable before you use it. + IdentityRegexp: aicr.TrustedIdentityPattern, + }) + if err != nil { + log.Print(err) + return + } + fmt.Println(builder) +} + +// Example_trustLevels enumerates the bundle trust levels +// BundleVerifyOptions.MinTrustLevel accepts. The CLI's --min-trust-level +// completion is generated from this same list. +// +// Two properties to note before validating input against it. The order is +// ALPHABETICAL, not by rank — do not treat position as severity. And the list +// is not the full accepted set: the default "max" (auto-detect the highest +// achievable level) and the empty string are both valid and both absent here, +// so a membership check built from this list alone rejects the option's own +// default. +func Example_trustLevels() { + for _, level := range aicr.TrustLevels() { + fmt.Println(level) + } + // Output: + // attested + // unknown + // unverified + // verified +} + +// Example_criteriaDimensions lists the criteria dimensions subject to the +// coverage post-condition — the values WithSnapshotCriteriaRelaxation accepts. +// +// nodes is deliberately absent: no overlay gates on it, so it never +// participates in overlay selection or coverage. +func Example_criteriaDimensions() { + for _, dim := range aicr.AllCriteriaDimensions() { + fmt.Println(dim) + } + // Output: + // service + // accelerator + // intent + // os + // platform +}