Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 97 additions & 34 deletions docs/integrator/go-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,21 +346,71 @@ are created. Other facade options

## Recipe sources

AICR exposes one production recipe source today; pick it via
AICR exposes three production recipe sources; pick one via
`aicr.WithRecipeSource`:

| Source | Constructor | Status |
|--------|-------------|--------|
| Embedded | `aicr.EmbeddedSource()` | Production. Uses only AICR's built-in recipe data with no external overlay. |
| Local filesystem | `aicr.FilesystemSource(path)` | Production. Use a directory containing a `registry.yaml` (layered over the embedded recipe data). |
| OCI registry | `aicr.OCISource(registry, tag)` | **Reserved — not yet implemented.** `NewClient` returns `ErrCodeUnavailable` when this source is selected. |
| OCI registry | `aicr.OCISource(repository, digest)` | Production. Pulls one immutable, digest-pinned recipe catalog into a private per-Client workspace. |

`EmbeddedSource` resolves against the recipe data compiled into the
AICR binary — no filesystem path required. Use it when you want AICR's
bundled recipe data and no local overrides. `FilesystemSource`
layers an external directory over that same embedded data, so files in
the directory override their embedded equivalents.

### Digest-pinned OCI recipe sources

`OCISource` keeps the repository and immutable selector separate. The
repository may start with `oci://`, but must not contain a tag or digest.
The selector must be a complete `sha256:<64-hex-character>` manifest
digest obtained through trusted configuration; tags and implicit `latest`
are rejected.

The accepted artifact is one OCI image manifest with the AICR artifact type,
the canonical empty config, and exactly one gzip-compressed layer. Downloads
and extraction are bounded, content digests are checked while streaming, and
archive traversal, links, devices, oversized content, and malformed catalogs
fail closed before the provider is activated.

Use `NewClientContext` so caller cancellation and tighter deadlines
propagate through registry authentication, download, extraction, and catalog
validation:

```go
import (
"context"
"errors"

aicr "github.com/NVIDIA/aicr/pkg/client/v1"
)

func useOCIRecipes(ctx context.Context, repository, manifestDigest, tempDir string) (retErr error) {
client, err := aicr.NewClientContext(ctx,
aicr.WithRecipeSource(aicr.OCISource(repository, manifestDigest)),
aicr.WithOCISourceTempDir(tempDir),
)
if err != nil {
return err
}
defer func() { retErr = errors.Join(retErr, client.Close()) }()

return client.LoadCatalog(ctx)
}
```
Comment thread
tjrasche marked this conversation as resolved.

`NewClient` remains a bounded compatibility wrapper. OCI construction
never exceeds `defaults.OCIRecipeConstructionTimeout` (eight minutes), while
`NewClientContext` also honors any shorter caller deadline. Registry staging
and materialization each retain the five-minute
`defaults.OCIRecipePullTimeout` phase ceiling; the larger construction
envelope reserves more than three minutes for materialization and catalog
validation after maximum-jitter pull retries.
`Client.Close` waits for in-flight reads, evicts provider-scoped caches,
and removes only the unique child workspace it owns.

## Client options

Beyond `WithRecipeSource`, `NewClient` accepts these functional options:
Expand Down Expand Up @@ -391,6 +441,9 @@ client, err := aicr.NewClient(
It returns `nil` when none are set — `WithAllowLists` treats a `nil`
`AllowLists` as allow-all, so the result is always safe to pass straight
to `WithAllowLists`.
- **`WithOCISourceTempDir(parent string)`** selects an existing writable
parent for an OCI-backed Client's private workspace. It is rejected for
embedded and filesystem sources.

`AllowLists` is a facade-owned struct whose `Accelerators`, `Services`,
`Intents`, and `OSTypes` fields are plain `[]string` slices, so callers
Expand Down Expand Up @@ -630,42 +683,52 @@ tooling, so the CLI and an embedding runtime agree on the settings by
construction rather than by convention.

```go
cfg, err := aicr.LoadConfig(ctx, "aicr-config.yaml") // path or HTTP(S) URL
if err != nil {
log.Fatal(err)
}
import (
"context"
"errors"

// spec.recipe.data decides how the Client is constructed.
source := aicr.EmbeddedSource()
if configured, ok := cfg.RecipeSource(); ok {
source = configured
}
client, err := aicr.NewClient(aicr.WithRecipeSource(source))
if err != nil {
log.Fatal(err)
}
defer client.Close()
aicr "github.com/NVIDIA/aicr/pkg/client/v1"
)

// REQUIRED before deriving criteria: loading the catalog is what seeds this
// Client's registry with the values its overlays contribute. Skip it and a
// value defined only by spec.recipe.data is still unknown, so the derivation
// below rejects it.
if err = client.LoadCatalog(ctx); err != nil {
log.Fatal(err)
}
func resolveCommittedConfig(ctx context.Context) (retErr error) {
cfg, err := aicr.LoadConfig(ctx, "aicr-config.yaml") // path or HTTP(S) URL
if err != nil {
return err
}

// spec.recipe.criteria, parsed against this Client's registry so a value
// contributed by a --data overlay validates against the same catalog.
criteria, err := cfg.RecipeCriteria(client.CriteriaRegistry())
if err != nil {
log.Fatal(err)
}
opts, err := cfg.RecipeResolveOptions() // spec.recipe.profile + accounting mode
if err != nil {
log.Fatal(err)
}
// spec.recipe.data decides how the Client is constructed.
source := aicr.EmbeddedSource()
if configured, ok := cfg.RecipeSource(); ok {
source = configured
}
client, err := aicr.NewClientContext(ctx, aicr.WithRecipeSource(source))
if err != nil {
return err
}
defer func() { retErr = errors.Join(retErr, client.Close()) }()

// REQUIRED before deriving criteria: loading the catalog is what seeds this
// Client's registry with the values its overlays contribute. Skip it and a
// value defined only by spec.recipe.data is still unknown, so the derivation
// below rejects it.
if err = client.LoadCatalog(ctx); err != nil {
return err
}

result, err := client.ResolveRecipeFromCriteriaWithOptions(ctx, criteria, opts...)
// spec.recipe.criteria, parsed against this Client's registry so a value
// contributed by a --data overlay validates against the same catalog.
criteria, err := cfg.RecipeCriteria(client.CriteriaRegistry())
if err != nil {
return err
}
opts, err := cfg.RecipeResolveOptions() // spec.recipe.profile + accounting mode
if err != nil {
return err
}

_, retErr = client.ResolveRecipeFromCriteriaWithOptions(ctx, criteria, opts...)
return retErr
}
```

**Config derives options; it never applies them.** A `Config` does not attach
Expand Down
2 changes: 1 addition & 1 deletion docs/integrator/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ in the [Go library integration guide](./go-library.md).

| Package | Tier | Purpose |
|---------|------|---------|
| `github.com/NVIDIA/aicr/pkg/client/v1` | **Public (stable)** | Facade: `Client`, `NewClient`, request/result types, source constructors. |
| `github.com/NVIDIA/aicr/pkg/client/v1` | **Public (stable)** | Facade: `Client`, `NewClient`, `NewClientContext`, request/result types, source constructors. |
| `pkg/recipe` | Public (evolving) | Recipe resolution, criteria, overlay system, component registry. |
| `pkg/bundler` | Public (evolving) | Per-component Helm/Kustomize bundle generation. |
| `pkg/validator` | Public (evolving) | Constraint evaluation, three-phase validation (executed in order: Deployment, Conformance, Performance). |
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,7 @@ func runBundleCmdWithDependencies(
// otherwise. The Client owns its DataProvider — LoadRecipe and
// MakeBundle thread it through, replacing the old process-global
// data provider.
client, err := recipeClientFromCmd(cmd, cfg)
client, err := recipeClientFromCmd(ctx, cmd, cfg)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/bundle_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ func runBundleVerifyCmd(ctx context.Context, cmd *cli.Command) error {
"--insecure-ignore-tlog requires --key: offline verification is key-based (verify a bundle signed with `bundle --signing-key ... --tlog-upload=false`)")
}

client, err := embeddedClient()
client, err := embeddedClient(ctx)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func runDiffCmd(ctx context.Context, cmd *cli.Command) error {

slog.Debug("snapshot diff", slog.String("baseline", baselinePath), slog.String("target", targetPath))

client, err := embeddedClient()
client, err := embeddedClient(ctx)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/evidence_digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func runEvidenceDigestCmd(ctx context.Context, cmd *cli.Command) error {
"--recipe is required: aicr evidence digest -r <recipe-or-overlay>")
}

client, err := embeddedClient()
client, err := embeddedClient(ctx)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/evidence_publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func runEvidencePublishCmd(ctx context.Context, cmd *cli.Command) error {
}
}

client, err := embeddedClient()
client, err := embeddedClient(ctx)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/evidence_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func runEvidenceVerifyCmd(ctx context.Context, cmd *cli.Command) (err error) {
return errors.New(errors.ErrCodeInvalidRequest, "invalid --format: must be text or json")
}

client, clientErr := embeddedClient()
client, clientErr := embeddedClient(ctx)
if clientErr != nil {
return clientErr
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/mirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func runMirrorListCmd(ctx context.Context, cmd *cli.Command) (err error) {
// Build ONE per-command Client bound to the resolved data source. Both
// recipe-resolution paths (--recipe load and criteria resolve) run through
// it, replacing the old process-global data provider.
client, err := recipeClientFromCmd(cmd, cfg)
client, err := recipeClientFromCmd(ctx, cmd, cfg)
if err != nil {
return err
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/cli/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Use in shell scripts:
// registry; it now explicitly seeds its OWN provider via
// LoadCatalog before parsing criteria, fixing a latent ordering
// bug where the first parse could run against an empty registry.
client, err := recipeClientFromCmd(cmd, cfg)
client, err := recipeClientFromCmd(ctx, cmd, cfg)
if err != nil {
return err
}
Expand All @@ -119,6 +119,7 @@ Use in shell scripts:
if err = client.LoadCatalog(ctx); err != nil {
return err
}
applyClientCriteriaStrictMode(cmd, cfg, client)

outFormat, err := parseRecipeOutputFormat(cmd, cfg)
if err != nil {
Expand Down
103 changes: 103 additions & 0 deletions pkg/cli/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ package cli

import (
"bytes"
stderrors "errors"
"os"
"path/filepath"
"strings"
"testing"

"github.com/urfave/cli/v3"

"github.com/NVIDIA/aicr/pkg/errors"
"github.com/NVIDIA/aicr/pkg/serializer"
)

Expand Down Expand Up @@ -188,3 +192,102 @@ func TestRecipeAndQueryCommandsRejectExplicitEmptySlurmAccountingMode(t *testing
})
}
}

func TestQueryCmdCriteriaStrictRejectsExternalCriteria(t *testing.T) {
t.Setenv("AICR_CRITERIA_STRICT", "")
dataDir := writeQueryExternalCriteriaCatalog(t)
configPath := filepath.Join(t.TempDir(), "aicr-config.yaml")
config := `apiVersion: aicr.run/v1alpha2
kind: AICRConfig
metadata:
name: query-strict-test
spec:
recipe:
criteriaStrict: true
`
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}

tests := []struct {
name string
extraArgs []string
wantErrCode errors.ErrorCode
wantOutput string
}{
{
name: "non-strict query accepts external criterion",
wantOutput: "external-query-service",
},
{
name: "CLI flag rejects external criterion",
extraArgs: []string{"--criteria-strict"},
wantErrCode: errors.ErrCodeInvalidRequest,
},
{
name: "config rejects external criterion",
extraArgs: []string{"--config", configPath},
wantErrCode: errors.ErrCodeInvalidRequest,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var output bytes.Buffer
parent := &cli.Command{
Name: "aicr",
Commands: []*cli.Command{queryCmd()},
Writer: &output,
}
args := []string{
"aicr", "query",
"--data", dataDir,
"--service", "external-query-service",
"--selector", "criteria.service",
}
args = append(args, tt.extraArgs...)
err := parent.Run(t.Context(), args)
if tt.wantErrCode != "" {
if !stderrors.Is(err, errors.New(tt.wantErrCode, "")) {
t.Fatalf("query error = %v, want code %s", err, tt.wantErrCode)
}
return
}
if err != nil {
t.Fatalf("query error = %v", err)
}
if !strings.Contains(output.String(), tt.wantOutput) {
t.Fatalf("query output = %q, want %q", output.String(), tt.wantOutput)
}
})
}
}

func writeQueryExternalCriteriaCatalog(t *testing.T) string {
t.Helper()
dir := t.TempDir()
registry := `apiVersion: aicr.run/v1alpha2
kind: ComponentRegistry
components: []
`
if err := os.WriteFile(filepath.Join(dir, "registry.yaml"), []byte(registry), 0o600); err != nil {
t.Fatalf("write registry: %v", err)
}
overlaysDir := filepath.Join(dir, "overlays")
if err := os.MkdirAll(overlaysDir, 0o755); err != nil {
t.Fatalf("create overlays directory: %v", err)
}
overlay := `apiVersion: aicr.run/v1alpha2
kind: RecipeMetadata
metadata:
name: external-query
spec:
criteria:
service: external-query-service
componentRefs: []
`
if err := os.WriteFile(filepath.Join(overlaysDir, "external-query.yaml"), []byte(overlay), 0o600); err != nil {
t.Fatalf("write overlay: %v", err)
}
return dir
}
Loading
Loading