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:
|
// 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:
|
// 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:
|
// 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
- Start two
CollectSnapshot calls against the same cluster and namespace.
- Use different
DiscoverNetwork values or let one run complete cleanup while the other is active.
- 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
Bug description
Client.CollectSnapshotdocuments concurrent calls as safe and independent, but snapshot agent runs share several Kubernetes resources and selectors:aicr-node-readerClusterRole and ClusterRoleBinding.aicr-snapshotConfigMap in the namespace.app.kubernetes.io/name=aicrand selects the youngest matching pod, rather than a pod owned by the current Job.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.
DiscoverNetworkmakes the shared ClusterRole rules configuration-dependent, increasing the race surface.Evidence
aicr/pkg/client/v1/aicr.go
Lines 1440 to 1442 in 6cb3ab9
aicr/pkg/k8s/agent/types.go
Lines 22 to 23 in 6cb3ab9
aicr/pkg/k8s/agent/rbac.go
Lines 205 to 264 in 6cb3ab9
aicr/pkg/k8s/agent/deployer.go
Lines 100 to 121 in 6cb3ab9
aicr/pkg/snapshotter/agent.go
Lines 501 to 529 in 6cb3ab9
aicr/pkg/k8s/agent/wait.go
Lines 111 to 149 in 6cb3ab9
Reproduction
CollectSnapshotcalls against the same cluster and namespace.DiscoverNetworkvalues or let one run complete cleanup while the other is active.Expected behavior
Each documented independent run must own and select only its Kubernetes resources and result data.
Acceptance criteria
controller-uidor equivalent), never the global application label alone.