agent

package
v0.21.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 9, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

Documentation

Overview

Package agent provides Kubernetes Job deployment for automated snapshot capture.

The agent package deploys a Kubernetes Job that runs aicr snapshot on GPU nodes and writes output to ConfigMap storage. It handles RBAC setup, Job lifecycle management, and snapshot retrieval.

Run Scoping

Every deployment belongs to a single run identified by Config.RunID (generate one with runid.Generate). The run ID is suffixed onto every object this package creates — Job, ServiceAccount, Role, RoleBinding, ClusterRole, ClusterRoleBinding, and the staging ConfigMap — so concurrent runs never share an object. Config.JobName is a prefix, not an exact name; Config.ServiceAccountName is a prefix only when no ServiceAccount of that exact name exists (see Existing ServiceAccounts below). When empty they fall back to Config.NameBase (default "aicr"). See ADR-020 (docs/design/020-snapshot-agent-run-isolation.md).

Because a run-scoped name cannot already belong to another run, creates are plain creates: there is no delete-and-recreate of the Job, and no create-or-update of the RBAC objects. An AlreadyExists implies a duplicate RunID and is returned as an error rather than adopted or overwritten.

Existing ServiceAccounts

Config.ServiceAccountName is exact-if-exists. When a ServiceAccount of exactly that name already exists in the namespace, the agent pod runs as it verbatim and the run creates NO ServiceAccount, Role, RoleBinding, ClusterRole or ClusterRoleBinding — aicr adds and removes no permissions on an identity it did not create. Nothing of those kinds enters the created-set, so Cleanup has nothing of those kinds to delete and the operator's grants outlive the run.

This exists because IRSA (eks.amazonaws.com/role-arn) and GKE Workload Identity (iam.gke.io/gcp-service-account) both pin trust to the ServiceAccount NAME — IRSA's trust policy conditions on system:serviceaccount:<ns>/<name>, GKE's IAM binding names PROJECT.svc.id.goog[<ns>/<name>] and accepts no wildcard — so a per-run name can never be trusted by either, and copying the annotations onto a run-scoped ServiceAccount would not help.

Render the RBAC that grants such a ServiceAccount the agent's permissions with BuildServiceAccountRoleManifests (CLI: aicr snapshot --add-roles-to-service-account). That path APPLIES NOTHING and contacts no cluster: it writes manifests the operator reviews and applies themselves, so the decision to grant cluster-scoped -- and, under DiscoverNetwork, mutating -- permissions is an informed one. What they then apply sits outside every run: no run-ID label, never in a created-set, never deleted by run cleanup, and removed with kubectl delete -f.

The trade-off is deliberate and opt-in: an adopted ServiceAccount waives per-run permission isolation. Concurrent runs sharing it share its grants, and a DiscoverNetwork grant leaves cluster-scoped mutating permissions in place permanently rather than for one run's lifetime.

Two objects are deliberately NOT run-scoped:

  • The Namespace is ensured, never deleted: it is created if absent and labeled "app.kubernetes.io/managed-by=aicr", patching the label onto a pre-existing namespace rather than silently dropping intent.
  • A caller-supplied "cm://namespace/name" Output is the caller's delivered artifact. It is written on purpose and never deleted (Config.OwnsOutputConfigMap is false for it).

Every object this package itself creates — the Job, ServiceAccount, Role, RoleBinding, ClusterRole, and ClusterRoleBinding — carries app.kubernetes.io/name=aicr, app.kubernetes.io/managed-by=aicr, app.kubernetes.io/component=snapshot-agent, aicr.run/run-id=<RunID>, and aicr.run/invocation-id, on the Job's pod template as well as the Job itself. Select agent pods across runs with the component label; the Job name changes every run.

aicr.run/invocation-id identifies the one Deployer that created the object, which the first four labels cannot: Config.RunID is caller-settable and sharing it is a supported scenario, so two invocations stamp identical values for all four. The invocation ID is generated inside NewDeployer and settable through no Config field, and it is what Cleanup requires before adopting an object whose creation it never had confirmed. Do not select on it — its value changes every invocation.

The staging ConfigMap is the exception: it is written from inside the pod by pkg/serializer's ConfigMap writer, which stamps app.kubernetes.io/name=aicr, app.kubernetes.io/component=<snapshot kind> and app.kubernetes.io/version — not managed-by, not the run-ID label, and not the invocation-ID label. That writer also produces the user's delivered cm:// artifact, so it deliberately does not stamp the run-ID sweep key onto an object this package must never delete. Run scoping for the staging ConfigMap comes from its name (see StagingConfigMapName), which is what both Cleanup paths key on.

Job and Pod lifecycle waits use the Kubernetes watch API (not polling) for efficiency. Pod selection narrows by label and then authorizes the candidate against the controlling ownerReference carrying the recorded Job UID, since pod labels are writable by anything that can update pods in the namespace.

Cleanup

The Deployer records (kind, name) immediately before each Create and writes the returned UID onto that entry on success. Cleanup deletes exactly that set, passing the recorded UID as a metav1.Preconditions so a same-named object belonging to another run is never collected; a UID mismatch (Conflict) and a NotFound are both treated as success. Cleanup also runs on the Deploy failure path, which is why it is scoped to what was created rather than to configured names.

Recording before the Create is what keeps a lost Create response from orphaning an object forever: if the apiserver commits the create but the response never arrives, the entry is already in the set. That entry carries no UID, and its (run-unique) name is not evidence of ownership — it says what this run WOULD have created, not what is standing there now — so Cleanup never deletes it by bare name. It Gets the live object and re-verifies it: the delete is issued only when that object carries the full label set this INVOCATION stamps at creation time — aicr.run/invocation-id included — AND a non-empty UID, and it is pinned to the UID that Get observed. A label mismatch or a missing UID fails closed — no delete at all, and a warning names the object left behind for an operator to judge — while a NotFound means there is nothing to reclaim. The one response that proves the object is not ours — AlreadyExists — discards the entry again.

The invocation ID is what makes that re-verification mean anything. Pinning the delete to the UID this Get returned protects only against a replacement made after the Get; a replacement standing there before it is simply what the Get returns. Without a per-invocation label, an object another invocation created under the same RunID and the same name would pass every check and be deleted.

The staging ConfigMap is written by the in-pod agent, so its UID is observed when GetSnapshot reads it. When the run owns that ConfigMap and failed before it could be observed, Cleanup Gets it by its run-scoped name and deletes it pinned to the UID that Get returned. That object cannot carry the invocation ID — the agent image writing it may be a different aicr version — so the sweep takes its ownership evidence from the Job instead: it runs only while the Job this invocation created is still the live Job at its name, which is the one thing that rules out a second invocation's agent having written the ConfigMap at the shared staging name.

Usage Example

package main

import (
	"context"
	"time"

	"github.com/NVIDIA/aicr/pkg/k8s/agent"
	"github.com/NVIDIA/aicr/pkg/k8s/client"
	"github.com/NVIDIA/aicr/pkg/runid"
)

func main() {
	ctx := context.Background()

	// Get Kubernetes client
	clientset, _, err := client.GetKubeClient()
	if err != nil {
		panic(err)
	}

	// One run ID scopes every object this deployment creates.
	runID := runid.Generate()

	// Configure deployer
	config := agent.Config{
		Namespace: "gpu-operator",
		RunID:     runID,
		Image:     "ghcr.io/nvidia/aicr-validator:latest",
		Output:    "cm://gpu-operator/" + agent.StagingConfigMapName(runID),
		// Output is owned by this run, so Cleanup may delete it. Point
		// Output at a ConfigMap of your own and leave this false: an
		// artifact you named is never deleted here.
		OwnsOutputConfigMap: true,
		NodeSelector: map[string]string{
			"nodeGroup": "customer-gpu",
		},
	}

	// Create deployer
	deployer := agent.NewDeployer(clientset, config)

	// Always clean up this run's objects, including on the failure path.
	defer func() {
		_ = deployer.Cleanup(context.Background(), agent.CleanupOptions{Enabled: true})
	}()

	// Deploy RBAC and Job
	if err := deployer.Deploy(ctx); err != nil {
		panic(err)
	}

	// Wait for completion (deployer.JobName() is the run-scoped name)
	if err := deployer.WaitForCompletion(ctx, 5*time.Minute); err != nil {
		panic(err)
	}

	// Get snapshot
	snapshot, err := deployer.GetSnapshot(ctx)
	if err != nil {
		panic(err)
	}

	// Use snapshot...
}

Testing

The package is designed for testability with Kubernetes fake clients:

import (
	"testing"
	"k8s.io/client-go/kubernetes/fake"
)

func TestDeployer(t *testing.T) {
	clientset := fake.NewClientset()
	deployer := agent.NewDeployer(clientset, agent.Config{
		Namespace: "test",
		RunID:     "20260821-142233-9f3a1c0b7e2d4a55",
		Image:     "test:latest",
	})
	// Test deployment logic...
}

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func StagingConfigMapName added in v0.21.0

func StagingConfigMapName(runID string) string

StagingConfigMapName returns the run-scoped name of the internal staging ConfigMap the agent Job writes its snapshot result to for the given run ID. It is exported so the one caller that builds the Job's `cm://` output URI (pkg/snapshotter's agentConfigMapTarget) derives that name from the same place Cleanup deletes it, instead of repeating the format string.

Types

type CleanupOptions

type CleanupOptions struct {
	Enabled bool // If true, removes Job and all RBAC resources
}

CleanupOptions controls what resources to remove during cleanup.

type Config

type Config struct {
	Namespace string

	// ServiceAccountName is exact-if-exists, and therefore carries two
	// meanings resolved once per Deploy (see resolveServiceAccount):
	//
	//   - A ServiceAccount of exactly this name already exists in
	//     Namespace: the agent pod runs as it verbatim and this run
	//     creates NO ServiceAccount, Role, RoleBinding, ClusterRole or
	//     ClusterRoleBinding. aicr adds and removes no permissions on an
	//     identity it did not create, and cleanup deletes none of them.
	//     This is what keeps a ServiceAccount carrying IRSA or GKE
	//     Workload Identity annotations usable: both providers pin trust
	//     to the ServiceAccount NAME, which a run-scoped name can never
	//     satisfy. Generate the RBAC that grants such a ServiceAccount
	//     the agent's permissions with
	//     BuildServiceAccountRoleManifests, then apply it out of band.
	//   - Otherwise: a prefix. The run creates "<prefix>-<RunID>" and
	//     the full run-scoped RBAC set, and deletes them at cleanup.
	//
	// Empty falls back to NameBase and is never probed for existence —
	// the fallback base is aicr's own default, not a name the caller
	// asked for, so a stray ServiceAccount sitting at it must not
	// silently capture the run.
	//
	// Exact mode waives per-run permission isolation: concurrent runs
	// sharing the ServiceAccount share its grants, and grants provisioned
	// for DiscoverNetwork persist beyond any one run.
	ServiceAccountName string

	JobName string

	// RunID scopes every resource this Deployer creates to a single run,
	// so concurrent snapshot-agent runs never collide on a shared resource
	// name. Callers generate it with runid.Generate() before deploying.
	//
	// Required, and validated by Deploy before any object is created: it
	// is folded into every run-owned name, so it must be a DNS-1123 label
	// (lowercase alphanumerics and "-", starting and ending alphanumeric,
	// at most 63 characters). Anything else fails with
	// errors.ErrCodeInvalidRequest.
	RunID string

	// NameBase prefixes generated resource names. It applies per name,
	// not all-or-nothing: jobName() falls back to it when JobName is
	// empty, and saName() (which also names the Role and RoleBinding)
	// falls back to it when ServiceAccountName is empty — so setting only
	// one of the two leaves NameBase governing the other. Defaults to
	// "aicr" when empty.
	NameBase string

	Image            string
	ImagePullSecrets []string
	NodeSelector     map[string]string
	Tolerations      []corev1.Toleration
	Output           string
	Debug            bool
	Privileged       bool   // If true, run with privileged security context (required for GPU/SystemD collectors)
	RequireGPU       bool   // If true, request nvidia.com/gpu resource (required for CDI environments)
	RuntimeClassName string // If set, use this runtimeClassName on the pod and inject NVIDIA_VISIBLE_DEVICES=all (alternative to RequireGPU)
	MaxNodesPerEntry int    // Max node names per topology entry (0 = unlimited)
	OS               string // Recipe OS criteria value. When set to oskind.Talos, systemd hostPath mounts are skipped and the in-pod agent uses the Talos service backend.

	// ClusterConfigPath, when set, forwards to the in-pod network
	// collector via AICR_CLUSTER_CONFIG_PATH so it ingests an existing
	// l8k cluster-config.yaml. The path must resolve inside the pod —
	// today's Job mode does NOT auto-mount the caller's host file, so
	// the snapshotter's deployAndWaitForResult rejects a Job-mode call
	// with ClusterConfigPath set (returns ErrCodeInvalidRequest).
	// ConfigMap-backed mounting is tracked as a follow-up; until then
	// file ingestion is local-mode-only (developer runs the CLI with
	// AICR_AGENT_MODE=true), and Job mode is best used with
	// DiscoverNetwork for live cluster discovery.
	ClusterConfigPath string

	// DiscoverNetwork, when true, forwards via AICR_DISCOVER_NETWORK to
	// enable the in-pod network collector's live l8k discovery path.
	// Discovery is NOT read-only — it patches NicClusterPolicy and writes
	// nvidia.kubernetes-launch-kit.* node labels.
	DiscoverNetwork bool

	// Requests overrides the per-resource container requests on the agent pod.
	// When nil, the privileged/restricted defaults in job.go are used. Keys
	// must match standard Kubernetes resource names (cpu, memory,
	// ephemeral-storage); unknown keys are passed through unchanged.
	Requests corev1.ResourceList

	// Limits overrides the per-resource container limits on the agent pod.
	// When nil, the privileged/restricted defaults in job.go are used.
	// RequireGPU adds nvidia.com/gpu=1 to the merged limits ONLY when the
	// caller did not already supply that key — so a caller can request
	// e.g. nvidia.com/gpu=4 alongside RequireGPU and keep their value.
	Limits corev1.ResourceList

	// OwnsOutputConfigMap is true when Output names the staging ConfigMap
	// this Deployer's own Job writes (the default run-scoped
	// `cm://<namespace>/<generated-name>` URI), rather than a ConfigMap
	// the caller supplied out of band via a hand-written `cm://` Output
	// URI. GetSnapshot enters the ConfigMap into the created-set for
	// Cleanup only when this is true — a caller-supplied ConfigMap is
	// the caller's artifact and must never be deleted by this Deployer.
	OwnsOutputConfigMap bool
}

Config holds the configuration for deploying the agent.

type Deployer

type Deployer struct {
	// contains filtered or unexported fields
}

Deployer manages the deployment and lifecycle of the agent Job.

func NewDeployer

func NewDeployer(clientset kubernetes.Interface, config Config) *Deployer

NewDeployer creates a new agent Deployer with the given configuration.

func (*Deployer) CheckPermissions

func (d *Deployer) CheckPermissions(ctx context.Context) ([]permissionCheck, error)

CheckPermissions is the authoritative pre-flight gate for an entire agent run. It verifies every permission the run will actually exercise, for both identities involved — the caller (the kubeconfig identity running aicr) and the ServiceAccount the agent pod runs as — and fails before anything is written to the cluster when any of them is missing.

Ordering, and why it is still fail-before-mutate

The required verb set depends on which ServiceAccount mode this run is in (see resolveServiceAccount), and the mode cannot be known without reading ServiceAccounts. The gate therefore runs in two phases:

  1. Check the caller permissions every run needs in either mode, `serviceaccounts: get` among them.
  2. Resolve the ServiceAccount (a read-only Get), then check the mode-specific set.

Every step up to the point the gate closes is a read: a Self/SubjectAccessReview is a non-persisted authorization query, and the resolution Get creates nothing. The fail-before-mutate guarantee is about not WRITING before validation, and no write is issued until Deploy's ensure* chain, which runs only after this returns nil.

Mode-specific verbs

Prefix mode (aicr creates its own run-scoped ServiceAccount) needs create AND delete on all five RBAC kinds: the deferred Cleanup is registered before Deploy and always runs, so an identity that can create but not delete would pass a green pre-flight and then leak a full run-scoped RBAC set — cluster-scoped objects included — on every run.

Exact-ServiceAccount mode creates and deletes no RBAC at all, so demanding those verbs would block operators who legitimately hold none. It instead verifies that the operator actually provisioned the ServiceAccount, which aicr does not do for them.

Reporting

Every check is evaluated before any failure is reported, so an operator fixing permissions gets the complete list in one run. Each failure names the verb, the resource, the scope, and which subject lacked it.

func (*Deployer) Cleanup

func (d *Deployer) Cleanup(ctx context.Context, opts CleanupOptions) error

Cleanup removes exactly the objects this Deployer created: the Job, the RBAC resources, and — when this Deployer owns the output ConfigMap — the staging ConfigMap. If opts.Enabled is false, no cleanup is performed (resources are kept for debugging). All resources are attempted for deletion even if some fail, and a combined error is returned. Deletions are fanned out concurrently so a slow apiserver does not serialize the wall clock.

func (*Deployer) Deploy

func (d *Deployer) Deploy(ctx context.Context) error

Deploy deploys the agent with all required resources (RBAC + Job). This is the main entry point that orchestrates the deployment.

func (*Deployer) GetPodLogs

func (d *Deployer) GetPodLogs(ctx context.Context) (string, error)

GetPodLogs retrieves logs from the Job's Pod.

func (*Deployer) GetSnapshot

func (d *Deployer) GetSnapshot(ctx context.Context) ([]byte, error)

GetSnapshot retrieves the snapshot data from the ConfigMap created by the agent. Returns the snapshot YAML content.

func (*Deployer) JobName added in v0.21.0

func (d *Deployer) JobName() string

JobName returns the run-scoped name of the Job this Deployer deploys — Config.JobName (or the name base) suffixed with Config.RunID. Callers that surface the Job to an operator (log lines, kubectl hints) must use this rather than Config.JobName, which is only the optional prefix and is empty by default.

func (*Deployer) StreamLogs

func (d *Deployer) StreamLogs(ctx context.Context, w io.Writer, prefix string) error

StreamLogs streams logs from the Job's Pod to the provided writer. It will follow the logs until the context is canceled. Returns when the context is canceled or an error occurs.

func (*Deployer) WaitForCompletion

func (d *Deployer) WaitForCompletion(ctx context.Context, timeout time.Duration) error

WaitForCompletion waits for the agent Job to complete successfully. Returns error if the Job fails or times out.

func (*Deployer) WaitForPodReady

func (d *Deployer) WaitForPodReady(ctx context.Context, timeout time.Duration) error

WaitForPodReady waits for the Job's Pod to be in Running state. This is useful for streaming logs before Job completes.

type Manifest added in v0.21.0

type Manifest struct {
	// FileName is the name of the file within the output directory. It is
	// a bare file name, never a path.
	FileName string

	// Kind is the Kubernetes kind ("Role", "RoleBinding", "ClusterRole",
	// "ClusterRoleBinding").
	Kind string

	// Name is the object's metadata.name.
	Name string

	// Content is the complete file body: a YAML comment header explaining
	// what the object grants and why the agent needs it, followed by the
	// object itself.
	Content []byte
}

Manifest is one rendered RBAC object: the bytes to write, the file name to write them under, and the object's kind and name so a caller can report what it wrote without re-deriving either.

func BuildServiceAccountRoleManifests added in v0.21.0

func BuildServiceAccountRoleManifests(opts ManifestOptions) ([]Manifest, error)

BuildServiceAccountRoleManifests renders the Role, RoleBinding, ClusterRole and ClusterRoleBinding that grant the snapshot agent's permissions to an operator-supplied ServiceAccount.

It APPLIES NOTHING and contacts no cluster. There is no clientset, no ServiceAccount lookup and no permission pre-flight on this path, so it works with no kubeconfig and no cluster privileges at all. Applying the manifests, and deleting them when the grant is no longer wanted, is the operator's decision and the operator's command.

That is deliberate. The rules being granted include, under DiscoverNetwork, cluster-scoped mutating permissions (nodes: patch, pods/exec: create, CRD create) that outlive any single run. An operator consenting to that should be able to read exactly what they are granting first, which a command that provisions on their behalf does not allow.

Every rule set comes from namespacedRules and clusterRules — the same definitions the run-scoped ensureRole and ensureClusterRole build from — so a rendered manifest can never drift from what a run-owned grant carries.

The objects are NOT run-scoped: they carry no run-ID label, never enter a Deployer's created-set, and no run's Cleanup deletes them. Teardown is `kubectl delete -f <dir>/`.

Trade-off the caller must surface to the operator: a shared ServiceAccount waives per-run permission isolation. Concurrent runs using it share its grants, and a DiscoverNetwork grant leaves mutating cluster permissions in place until the operator removes them.

type ManifestOptions added in v0.21.0

type ManifestOptions struct {
	// Namespace is the namespace of the ServiceAccount, and the namespace
	// the rendered Role and RoleBinding declare. Required.
	Namespace string

	// ServiceAccountName is the name of the ServiceAccount the rendered
	// RoleBinding and ClusterRoleBinding name as their subject. Required.
	//
	// It is NOT verified to exist: rendering contacts no cluster, by
	// design (see BuildServiceAccountRoleManifests). A name that does not
	// resolve produces manifests that grant nothing, which the operator
	// sees when they review the files or when the ServiceAccount they
	// meant to name still cannot snapshot.
	ServiceAccountName string

	// DiscoverNetwork also renders the cluster-scoped MUTATING rules that
	// `aicr snapshot --discover-network` needs — nodes: patch,
	// pods/exec: create, and CRD, namespace, DaemonSet and namespaced-RBAC
	// create/delete (see discoverNetworkClusterRules).
	//
	// The rendered ClusterRole carries an explicit warning header
	// enumerating each mutating rule and the discovery step it exists for,
	// because deciding whether to grant them is the whole reason these
	// manifests are written out instead of applied.
	DiscoverNetwork bool
}

ManifestOptions selects the ServiceAccount that BuildServiceAccountRoleManifests renders RBAC for.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL