Repository navigation
Conversation
7c63f4b to
a918936
Compare
a918936 to
d18b058
Compare
HarshwardhanPatil07
left a comment
There was a problem hiding this comment.
I will let @cheesesashimi have the final look here, need to head at other place, thank you!
| @@ -0,0 +1,179 @@ | |||
| // SPDX-License-Identifier: Apache-2.0 | |||
There was a problem hiding this comment.
nit: Is it possible to place the aggregator in its own module? I'm mostly thinking about separation of concerns. For example, we could relocate it under internal/pullsecret. Or, since it's effectively a new controller, we could do something like this to keep things more logically grouped:
internal/controller/bootcnodepool/<all current bootcnode controller code>
internal/controller/pullsecret/<all pull secret aggregator code>
| if !ok { | ||
| return false | ||
| } | ||
| return secret.Type == corev1.SecretTypeDockerConfigJson || |
There was a problem hiding this comment.
issue: Here are a few edge-cases we may want to consider:
- Legacy pull secrets (e.g.,
corev1.DockerConfigKey/corev1.SecretTypeDockercfg. We should detect these and normalize them though I'll put my comments around normalization elsewhere. - Opqaue secrets with a
.dockerconfigjsonor.dockercfgkey. - Transitions from Opaque -> {DockerConfigJson, DockerConfig} (and vice-versa). Although there is an immutable bit on secrets, we can't always assume that it is set to true, especially if another operator is what created the secret.
Something like the following should cover all of these edge-cases:
func isPullSecretCandidate(obj client.Object) bool {
secret, ok := obj.(*corev1.Secret)
if !ok {
return false
}
_, hasJSON := secret.Data[corev1.DockerConfigJsonKey]
_, hasLegacy := secret.Data[corev1.DockerConfigKey]
return hasJSON || hasLegacy ||
secret.Type == corev1.SecretTypeDockerConfigJson ||
secret.Type == corev1.SecretTypeDockercfg
}
func (r *PullSecretAggregator) secretPredicate() predicate.Predicate {
isAggregate := func(obj client.Object) bool {
return client.ObjectKeyFromObject(obj) == r.secretKey()
}
isMatch := func(obj client.Object) bool {
return isAggregate(obj) || isPullSecretCandidate(obj)
}
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
return isMatch(e.Object)
},
UpdateFunc: func(e event.UpdateEvent) bool {
return isMatch(e.ObjectOld) || isMatch(e.ObjectNew)
},
DeleteFunc: func(e event.DeleteEvent) bool {
return isMatch(e.Object)
},
}
}Additionally, we may want to consider even tighter scoping in the future where we can give a list of secret names to watch so that we don't have to reconcile whenever any secret in a given namespace changes, regardless of whether we're consuming it or not.
There was a problem hiding this comment.
Additionally, we may want to consider even tighter scoping in the future where we can give a list of secret names to watch so that we don't have to reconcile whenever any secret in a given namespace changes, regardless of whether we're consuming it or not.
Agree, but if you don't mind I would postpone this since right now we are passing the allowed namespace statically. If something changes the operator needs to be restarted. So, I would introduce this after #257 and #92. We want also to restrict the RBAC to that secrets and we want to adjust them dynamically
| if err != nil { | ||
| return nil, fmt.Errorf("fetching pull secret %s/%s: %w", ref.Namespace, ref.Name, err) | ||
| } | ||
| if data, ok := secret.Data[corev1.DockerConfigJsonKey]; ok { |
There was a problem hiding this comment.
issue: We'll need to normalize legacy secrets (e.g., corev1.DockerConfigKey). This is a problem I solved for the MCO team, which I did like this: https://github.com/openshift/machine-config-operator/blob/main/pkg/secrets/secrets.go. We might not want something that sophisticated here, but its an idea.
Also, since we're also doing a similar operation in the BootcNodePool controller, we should consider making it a common function that both controllers can use.
| // atomically (temp file + rename). | ||
| func (r *BootcNodeReconciler) writeAuthFile(data []byte) error { | ||
| // mountedPullSecret returns the mounted pull secret entry for ref, or nil if | ||
| // ref is nil or the entry does not exist. |
There was a problem hiding this comment.
thought: Since we now have the pull secret mounted as a projected volume, we may want to consider using fsnotify to proactively update auth.json whenever the secrets change. It's also possible that the secrets could be changed while an update is in progress, so we may want to consider how to handle that too.
There was a problem hiding this comment.
I have considered the case where the secret change while we are updating. I think, it can be reduced to 2 cases, the old credentials are still valid and the pull will be successful or it will fail because it require new credentials. In the last case, we will reconcile and retry until the credentials are updated and correct. I think it is a good mechanism for keeping the logic relatively simple
There was a problem hiding this comment.
we may want to consider using fsnotify to proactively update auth.json whenever the secrets change
we aren't using fsnotify because we simply reread the secret every time we need to pull and we don't cache it. So, there is no need to watch for fsnotify event. We could review this mechanism if necessary but if you don't mind I would tackle this in a separate PR since it is already en existing mechanism
The tag resolver looked up credentials with an exact match of the registry host in the auths map. Entries written by kubectl create secret docker-registry and docker login use other forms, most commonly https://index.docker.io/v1/ for Docker Hub. Those missed, the resolver silently fell back to anonymous access and tag resolution failed with 401, while the node's container runtime accepted the same secret. Compare keys the way containers/image does for auth files: strip the http(s):// scheme, drop the path when a scheme was present, and map docker.io and registry-1.docker.io to index.docker.io. An exact match still wins, and normalized keys are tried in sorted order so the result is deterministic. Scoped keys without a scheme, such as quay.io/org, are still not matched; the resolver looks up registries only. Assisted-by: AI Signed-off-by: Alice Frosi <afrosi@redhat.com>
The daemon will read pull secrets from a single Secret maintained by the controller and mounted into the DaemonSet, with one key per source secret. The new internal/pullsecret package holds the Secret name, the daemon mount directory and the key function, "<namespace>_<name>", so the controller and the daemon cannot drift. A namespace and a Secret name together can exceed the 253-character Secret key limit, and one invalid key would make the API server reject the whole aggregated Secret. Key returns an error in that case, and fetchPullSecretData reports it on the pool as SecretError. Assisted-by: AI Signed-off-by: Alice Frosi <afrosi@redhat.com>
The pool reconciler required a .dockerconfigjson key and passed its
bytes unchanged to the tag resolver and, via the aggregated Secret, to
bootc. Both expect the {"auths": ...} wrapper. A legacy .dockercfg
Secret, which holds the bare auths map and which OpenShift generates for
service accounts, was rejected as missing a key; had it been passed
through, it would have meant silently no credentials.
Add pullsecret.AuthData, which returns the credentials in
.dockerconfigjson format. It goes by the Secret's keys rather than its
type, so an Opaque Secret works too. .dockerconfigjson is returned as
is and wins when both keys are set. .dockercfg is wrapped in
{"auths": ...} without re-encoding its entries, so fields such as
identitytoken and the credential bytes are preserved. Data that is not
a JSON object, including an empty .dockerconfigjson, is reported with
the key name but never with its content. fetchPullSecretData uses it, so
the reconciler normalizes once and the daemon keeps copying bytes
verbatim.
Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The pool reconciler decided which Secret events to act on by type, accepting only kubernetes.io/dockerconfigjson. That ignored events for the other forms of pull secret that fetchPullSecretData accepts, such as legacy .dockercfg and Opaque Secrets carrying a .dockerconfigjson key, so rotating them was never noticed. Matching on type is also not meaningful for a Secret that is not referenced by any pool, and the mapper listed every pool on each Secret event to find out. Index BootcNodePool by spec.pullSecretRef and map a Secret event to the pools in that index. A referenced Secret now triggers a reconcile whatever its type or content, so data that becomes valid or invalid is always seen, and Secrets no pool references enqueue nothing. The Secret type is immutable, so type changes need no handling. The field index is registered once per manager, in main and in the envtest suite, since a manager rejects duplicate registrations. Assisted-by: AI Signed-off-by: Alice Frosi <afrosi@redhat.com>
The daemon will stop reading pull secrets through the API and read them from a Secret mounted into the DaemonSet instead. The new PullSecretAggregator maintains that Secret, bootc-pull-secrets in the operator namespace, with one key per source secret referenced by a pool, holding its credentials normalized to .dockerconfigjson format. It lives in internal/pullsecret, next to the key function and the constants it shares with the daemon, so the reconciler and the key format cannot drift apart. It is the Secret's only writer, so pool reconciles cannot conflict on it. Pool, referenced source secret and bootc-pull-secrets events all map to one request, found through the pool index, and each reconcile recomputes the data from scratch, so stale keys disappear and a deleted or edited Secret is repaired. References the pool reconciler reports as SecretError are skipped. The aggregator also reconciles once at startup, so the Secret exists whenever the controller runs; it is kept, with no keys, when no pool references a pull secret. The controller needs create on secrets in its namespace, and update and patch on bootc-pull-secrets only. It now requires the operator namespace to be known, since that is where the Secret lives. Assisted-by: AI Signed-off-by: Alice Frosi <afrosi@redhat.com>
The DaemonSet now mounts bootc-pull-secrets, maintained by the controller, at /var/run/bootc-pull-secrets. The volume is optional, as the daemon may start before the Secret exists, and uses no subPath, so the kubelet keeps it up to date. While an update is pending, before staging, syncPullSecret copies the entry for the node's pullSecretRef to /run/ostree/auth.json instead of fetching the Secret through the API. Without a reference or a readable entry, it now removes the auth file instead of leaving stale credentials behind, and bootc falls back to /etc or /usr/lib. A missing entry needs no special handling: the kubelet may take a minute to sync a new or rotated Secret, and a failed stage is retried with backoff and re-reads the file. The daemon tests now use temporary directories for the host root and the mount, so they never touch the real /run/ostree. Assisted-by: AI Signed-off-by: Alice Frosi <afrosi@redhat.com>
The daemon reads pull secrets from the mounted bootc-pull-secrets Secret, so it no longer needs to get Secrets through the API. Remove the rule from its ClusterRole, which allowed every node to read any Secret in the cluster, and the now unused APIReader. The kubelet still reads the mounted Secret: the node authorizer allows a node to read Secrets used by pods scheduled on it. make helm-test now checks that no ClusterRole, the daemon's included, grants access to secrets. Assisted-by: AI Signed-off-by: Alice Frosi <afrosi@redhat.com>
The controller now creates bootc-pull-secrets in the release namespace. Helm does not know about it, and helm uninstall keeps the namespace created by --create-namespace, so the Secret is left behind. Document deleting the namespace to remove it. Assisted-by: AI Signed-off-by: Alice Frosi <afrosi@redhat.com>
d18b058 to
4ae038c
Compare
The controller now keeps one Secret,
bootc-pull-secrets, with one key (<namespace>_<name>) per pull secret a pool references, and mounts it into the daemon.The daemon copies that mounted entry to
/run/ostree/auth.jsonbefore staging and deletes the file when there is none, instead of reading Secrets through the API.This removes the daemon's cluster-wide
secretspermission, so nodes can only read the mounted Secret.Based on #273
Partially Fixes: ##272