Skip to content

Isolate concurrent snapshot agent runs in Kubernetes #2120

Description

@mchmarny

Bug description

Client.CollectSnapshot documents concurrent calls as safe and independent, but snapshot agent runs share several Kubernetes resources and selectors:

  • Every run creates or updates the cluster-scoped aicr-node-reader ClusterRole and ClusterRoleBinding.
  • Cleanup from either run deletes those shared objects.
  • Non-ConfigMap outputs stage through the same aicr-snapshot ConfigMap in the namespace.
  • Pod discovery uses only app.kubernetes.io/name=aicr and selects the youngest matching pod, rather than a pod owned by the current Job.
  • Callers commonly use the same default Job and ServiceAccount names.

Impact

Overlapping SDK or CLI calls can overwrite RBAC subjects/rules, delete permissions while another Job is running, delete/recreate another run's Job, stream logs from the wrong pod, or retrieve another run's snapshot. DiscoverNetwork makes the shared ClusterRole rules configuration-dependent, increasing the race surface.

Evidence

  • Concurrent-safety contract:

    aicr/pkg/client/v1/aicr.go

    Lines 1440 to 1442 in 6cb3ab9

    // Concurrent CollectSnapshot calls are safe; each call constructs an
    // independent run.
    func (c *Client) CollectSnapshot(ctx context.Context, cfg *AgentConfig) (*Snapshot, error) {
  • Fixed cluster-scoped resource name:
    // clusterRoleName is the name used for the ClusterRole and ClusterRoleBinding.
    const clusterRoleName = "aicr-node-reader"
  • Shared ClusterRole and binding are updated:

    aicr/pkg/k8s/agent/rbac.go

    Lines 205 to 264 in 6cb3ab9

    // nodes, and patches mellanox.com NicClusterPolicy via server-side
    // apply. Grant the extra cluster-scoped rules only when the snapshot
    // opted into discovery so non-network snapshots stay minimal-priv.
    if d.config.DiscoverNetwork {
    rules = append(rules, discoverNetworkClusterRules()...)
    }
    cr := &rbacv1.ClusterRole{
    ObjectMeta: metav1.ObjectMeta{
    Name: clusterRoleName,
    },
    Rules: rules,
    }
    _, err := d.clientset.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{})
    if apierrors.IsAlreadyExists(err) {
    _, err = d.clientset.RbacV1().ClusterRoles().Update(ctx, cr, metav1.UpdateOptions{})
    if err != nil {
    return errors.Wrap(errors.ErrCodeInternal, "failed to update ClusterRole", err)
    }
    return nil
    }
    if err != nil {
    return errors.Wrap(errors.ErrCodeInternal, "failed to create ClusterRole", err)
    }
    return nil
    }
    // ensureClusterRoleBinding creates or updates the ClusterRoleBinding to bind the ClusterRole to the ServiceAccount.
    func (d *Deployer) ensureClusterRoleBinding(ctx context.Context) error {
    crb := &rbacv1.ClusterRoleBinding{
    ObjectMeta: metav1.ObjectMeta{
    Name: clusterRoleName,
    },
    Subjects: []rbacv1.Subject{
    {
    Kind: "ServiceAccount",
    Name: d.config.ServiceAccountName,
    Namespace: d.config.Namespace,
    },
    },
    RoleRef: rbacv1.RoleRef{
    APIGroup: rbacAPIGroup,
    Kind: "ClusterRole",
    Name: clusterRoleName,
    },
    }
    _, err := d.clientset.RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{})
    if apierrors.IsAlreadyExists(err) {
    _, err = d.clientset.RbacV1().ClusterRoleBindings().Update(ctx, crb, metav1.UpdateOptions{})
    if err != nil {
    return errors.Wrap(errors.ErrCodeInternal, "failed to update ClusterRoleBinding", err)
    }
    return nil
    }
    if err != nil {
    return errors.Wrap(errors.ErrCodeInternal, "failed to create ClusterRoleBinding", err)
    }
    return nil
  • Cleanup deletes shared RBAC:
    // Deletions are fanned out concurrently so a slow apiserver does not serialize the wall clock.
    func (d *Deployer) Cleanup(ctx context.Context, opts CleanupOptions) error {
    if !opts.Enabled {
    return nil
    }
    type result struct {
    label string
    err error
    }
    tasks := []struct {
    label string
    op func(context.Context) error
    }{
    {fmt.Sprintf("Job %q", d.config.JobName), d.deleteJob},
    {fmt.Sprintf("ServiceAccount %q", d.config.ServiceAccountName), d.deleteServiceAccount},
    {fmt.Sprintf("Role %q", d.config.ServiceAccountName), d.deleteRole},
    {fmt.Sprintf("RoleBinding %q", d.config.ServiceAccountName), d.deleteRoleBinding},
    {fmt.Sprintf("ClusterRole %q", clusterRoleName), d.deleteClusterRole},
    {fmt.Sprintf("ClusterRoleBinding %q", clusterRoleName), d.deleteClusterRoleBinding},
    }
  • Fixed staging ConfigMap:
    // agentConfigMapTarget resolves where the agent Job stages its result and
    // whether that ConfigMap is the user's delivery vehicle.
    //
    // The Job always writes to a ConfigMap. When config.Output is a cm:// URI the
    // user asked for that exact ConfigMap, so the Job targets it directly and
    // deliverViaConfigMap is true — which makes a failed AKS-pool-merge rewrite
    // fatal rather than a warning, because the bytes the user will read live
    // there. Any other Output (file, stdout, template, or unset) stages to an
    // internal ConfigMap in config.Namespace that the caller never sees.
    //
    // A cm:// Output is fully parsed here, not merely prefix-matched. The
    // namespace/name only has to be well-formed for the in-pod writer much later,
    // so a typo like "cm://aicr-snapshot" (no namespace) would otherwise surface
    // as a Job failure — after RBAC and the Job exist, and with Cleanup false
    // (the zero value) they stay behind. Returns ErrCodeInvalidRequest instead.
    func agentConfigMapTarget(config *AgentConfig) (uri string, deliverViaConfigMap bool, err error) {
    if strings.HasPrefix(config.Output, serializer.ConfigMapURIScheme) {
    if _, _, parseErr := pod.ParseConfigMapURI(config.Output); parseErr != nil {
    // Wrap with the same code rather than PropagateOrWrap: the inner
    // error says "invalid configmap URI", which does not tell the
    // caller WHICH input was wrong. Naming the field is the point.
    return "", false, errors.Wrap(errors.ErrCodeInvalidRequest,
    fmt.Sprintf("invalid ConfigMap output URI %q (expected cm://namespace/name)", config.Output),
    parseErr)
    }
    return config.Output, true, nil
    }
    return fmt.Sprintf("%s%s/aicr-snapshot", serializer.ConfigMapURIScheme, config.Namespace), false, nil
    }
  • Global pod selector:

    aicr/pkg/k8s/agent/wait.go

    Lines 111 to 149 in 6cb3ab9

    // findPodName finds the pod name by label selector for this Job.
    // One-shot: returns ErrCodeNotFound if no pod is currently labeled.
    // Skips pods that are being deleted or have already failed so an
    // orphaned pod from a prior run is not selected.
    func (d *Deployer) findPodName(ctx context.Context) (string, error) {
    pods, err := d.clientset.CoreV1().Pods(d.config.Namespace).List(ctx, metav1.ListOptions{
    LabelSelector: agentLabelSelector,
    })
    if err != nil {
    return "", errors.Wrap(errors.ErrCodeInternal, "failed to list Pods", err)
    }
    name := pickLivePod(pods.Items)
    if name == "" {
    return "", errors.New(errors.ErrCodeNotFound, fmt.Sprintf("no Pods found for Job %s", d.config.JobName))
    }
    return name, nil
    }
    // pickLivePod returns the name of the youngest pod that is neither being
    // deleted nor in a Failed phase. Returns "" if no usable pod exists.
    func pickLivePod(pods []corev1.Pod) string {
    var best *corev1.Pod
    for i := range pods {
    p := &pods[i]
    if p.DeletionTimestamp != nil {
    continue
    }
    if p.Status.Phase == corev1.PodFailed {
    continue
    }
    if best == nil || p.CreationTimestamp.After(best.CreationTimestamp.Time) {
    best = p
    }
    }
    if best == nil {
    return ""
    }
    return best.Name

Reproduction

  1. Start two CollectSnapshot calls against the same cluster and namespace.
  2. Use different DiscoverNetwork values or let one run complete cleanup while the other is active.
  3. Observe shared RBAC updates/deletion and ambiguous pod/ConfigMap selection.

Expected behavior

Each documented independent run must own and select only its Kubernetes resources and result data.

Acceptance criteria

  • Generate a run ID for every snapshot operation and apply it to Job, ServiceAccount, namespaced RBAC, cluster-scoped RBAC, staging ConfigMap, labels, and selectors.
  • Select pods by current Job ownership (controller-uid or equivalent), never the global application label alone.
  • Cleanup deletes only resources owned by that run.
  • Concurrent runs with different network-discovery settings cannot change one another's permissions.
  • A concurrency test overlaps two runs and proves correct logs, result bytes, and cleanup ownership.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions