forked from openshift/cloud-credential-operator
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: per-component vSphere credential distribution with graceful fallback (story #38) #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
splatypus-bot
wants to merge
4
commits into
master
Choose a base branch
from
story-38-credential-distribution
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c1d959a
feat: implement per-component vCenter privilege validation (story #37)
splatypus-bot 787864a
feat: implement per-component vSphere credential distribution with gr…
splatypus-bot a3bcc3f
refactor: remove privilege validation per review feedback
splatypus-bot 7fa354d
Remove privilege validation from credential distribution
splatypus-bot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package actuator | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| minterv1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" | ||
| corev1 "k8s.io/api/core/v1" | ||
| ) | ||
|
|
||
| const vsphereComponentAnnotationKey = "cloudcredential.openshift.io/vsphere-component" | ||
|
|
||
| // ComponentSecretReader looks up a named secret from kube-system. | ||
| type ComponentSecretReader interface { | ||
| GetComponentSecret(ctx context.Context, name string) (*corev1.Secret, error) | ||
| } | ||
|
|
||
| // componentSecretName maps an annotation value to the kube-system secret name. | ||
| // Returns "" for unrecognized components. | ||
| func componentSecretName(component string) string { | ||
| switch component { | ||
| case "machineAPI": | ||
| return "vsphere-machine-api-creds" | ||
| case "csiDriver": | ||
| return "vsphere-storage-creds" | ||
| case "cloudController": | ||
| return "vsphere-cloud-controller-creds" | ||
| case "vsphereProblemDetector": | ||
| return "vsphere-problem-detector-creds" | ||
| default: | ||
| return "" | ||
| } | ||
| } | ||
|
|
||
| // resolveVSphereCredentials routes a CredentialsRequest to per-component or shared credentials. | ||
| // | ||
| // If the CR has a vsphere-component annotation and a matching kube-system secret with non-empty | ||
| // data, the per-component credential is returned. If the component secret is absent or empty, | ||
| // shared credentials are returned and a warning is set. A CR without the annotation is a | ||
| // passthrough — shared credentials, no warning. | ||
| func resolveVSphereCredentials( | ||
| ctx context.Context, | ||
| cr *minterv1.CredentialsRequest, | ||
| componentSecretReader ComponentSecretReader, | ||
| sharedSecretData map[string][]byte, | ||
| ) (secretData map[string][]byte, warning string, err error) { | ||
| component := cr.Annotations[vsphereComponentAnnotationKey] | ||
| if component == "" { | ||
| return sharedSecretData, "", nil | ||
| } | ||
|
|
||
| secretName := componentSecretName(component) | ||
| if secretName != "" { | ||
| secret, err := componentSecretReader.GetComponentSecret(ctx, secretName) | ||
| if err != nil { | ||
| return nil, "", err | ||
| } | ||
|
|
||
| if secret != nil && len(secret.Data) > 0 { | ||
| return secret.Data, "", nil | ||
| } | ||
| } | ||
|
|
||
| // No component secret (or unrecognized component): fall back to shared credentials. | ||
| return sharedSecretData, fmt.Sprintf("Component '%s' using fallback shared credentials", component), nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| // credential_distribution_test.go tests Story #38: | ||
| // CCO Per-Component Credential Distribution with Graceful Fallback. | ||
| // | ||
| // Implementation contract expected by these tests: | ||
| // | ||
| // func resolveVSphereCredentials( | ||
| // ctx context.Context, | ||
| // cr *minterv1.CredentialsRequest, | ||
| // componentSecretReader ComponentSecretReader, | ||
| // sharedSecretData map[string][]byte, | ||
| // ) (secretData map[string][]byte, warning string, err error) | ||
| // | ||
| // ComponentSecretReader is an interface (or func) that looks up a named secret from kube-system. | ||
| // The component annotation key is: cloudcredential.openshift.io/vsphere-component | ||
| // Annotation values: machineAPI, csiDriver, cloudController, vsphereProblemDetector | ||
| // Component secret names: vsphere-machine-api-creds, vsphere-storage-creds, | ||
| // | ||
| // vsphere-cloud-controller-creds, vsphere-problem-detector-creds | ||
| // | ||
| // Per-component secret key format: {vcenter.fqdn}.username / {vcenter.fqdn}.password | ||
| // Warning format: Component '{component}' using fallback shared credentials | ||
| package actuator | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| minterv1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" | ||
| corev1 "k8s.io/api/core/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| ) | ||
|
|
||
| // vsphereComponentAnnotation is the annotation key used to identify per-component CredentialsRequests. | ||
| const vsphereComponentAnnotation = "cloudcredential.openshift.io/vsphere-component" | ||
|
|
||
| // stubComponentSecretReader is a test double for looking up kube-system component secrets. | ||
| type stubComponentSecretReader struct { | ||
| secrets map[string]*corev1.Secret // secretName → secret (nil means NotFound) | ||
| } | ||
|
|
||
| func (s *stubComponentSecretReader) GetComponentSecret(_ context.Context, name string) (*corev1.Secret, error) { | ||
| if secret, ok := s.secrets[name]; ok { | ||
| return secret, nil // nil value means secret was explicitly absent | ||
| } | ||
| return nil, nil // not configured = absent | ||
| } | ||
|
|
||
| // makeCredCR builds a minimal CredentialsRequest with the given component annotation value. | ||
| func makeCredCR(component string) *minterv1.CredentialsRequest { | ||
| cr := &minterv1.CredentialsRequest{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-cr", | ||
| Namespace: "openshift-cloud-credential-operator", | ||
| }, | ||
| } | ||
| if component != "" { | ||
| cr.Annotations = map[string]string{vsphereComponentAnnotation: component} | ||
| } | ||
| return cr | ||
| } | ||
|
|
||
| // makeComponentSecret builds a kube-system Secret with the given vCenter credentials. | ||
| func makeComponentSecret(name string, creds map[string]string) *corev1.Secret { | ||
| data := make(map[string][]byte, len(creds)) | ||
| for k, v := range creds { | ||
| data[k] = []byte(v) | ||
| } | ||
| return &corev1.Secret{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "kube-system"}, | ||
| Data: data, | ||
| } | ||
| } | ||
|
|
||
| var sharedSecretData = map[string][]byte{ | ||
| "vcenter.example.com.username": []byte("shared@vsphere.local"), | ||
| "vcenter.example.com.password": []byte("shared-password"), | ||
| } | ||
|
|
||
| // ── AC1: Per-component routing and shared-credential fallback ───────────────── | ||
|
|
||
| // TestResolveComponentCredential_AnnotatedCRWithSecret_UsesPerComponentCred verifies AC1 (first | ||
| // branch): a CredentialsRequest annotated machineAPI, whose component secret exists, receives the | ||
| // per-component credential — not the shared credential. | ||
| func TestResolveComponentCredential_AnnotatedCRWithSecret_UsesPerComponentCred(t *testing.T) { | ||
| cr := makeCredCR("machineAPI") | ||
|
|
||
| componentSecret := makeComponentSecret("vsphere-machine-api-creds", map[string]string{ | ||
| "vcenter.example.com.username": "machine-api@vsphere.local", | ||
| "vcenter.example.com.password": "machine-api-password", | ||
| }) | ||
|
|
||
| reader := &stubComponentSecretReader{ | ||
| secrets: map[string]*corev1.Secret{ | ||
| "vsphere-machine-api-creds": componentSecret, | ||
| }, | ||
| } | ||
|
|
||
| data, warning, err := resolveVSphereCredentials( | ||
| context.Background(), cr, reader, sharedSecretData, | ||
| ) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("expected no error, got: %v", err) | ||
| } | ||
| if warning != "" { | ||
| t.Errorf("expected no warning for annotated CR with existing component secret, got: %q", warning) | ||
| } | ||
| if string(data["vcenter.example.com.username"]) != "machine-api@vsphere.local" { | ||
| t.Errorf("expected per-component username, got: %q", data["vcenter.example.com.username"]) | ||
| } | ||
| } | ||
|
|
||
| // TestResolveComponentCredential_AnnotatedCRWithoutSecret_FallsBackToShared verifies AC1 (fallback | ||
| // branch): a CredentialsRequest annotated csiDriver, with no component secret present, receives the | ||
| // shared credential and a warning is returned. | ||
| func TestResolveComponentCredential_AnnotatedCRWithoutSecret_FallsBackToShared(t *testing.T) { | ||
| cr := makeCredCR("csiDriver") | ||
|
|
||
| // No component secret registered for csiDriver | ||
| reader := &stubComponentSecretReader{secrets: map[string]*corev1.Secret{}} | ||
|
|
||
| data, warning, err := resolveVSphereCredentials( | ||
| context.Background(), cr, reader, sharedSecretData, | ||
| ) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("expected no error during fallback, got: %v", err) | ||
| } | ||
| expectedWarning := "Component 'csiDriver' using fallback shared credentials" | ||
| if warning != expectedWarning { | ||
| t.Errorf("expected warning %q, got %q", expectedWarning, warning) | ||
| } | ||
| if string(data["vcenter.example.com.username"]) != "shared@vsphere.local" { | ||
| t.Errorf("expected shared username in fallback data, got: %q", data["vcenter.example.com.username"]) | ||
| } | ||
| } | ||
|
|
||
| // ── AC2: Multi-vCenter credential distribution ──────────────────────────────── | ||
|
|
||
| // TestResolveComponentCredential_MultiVCenter_BothVCentersPopulated verifies AC2: when a | ||
| // component secret contains credentials for two vCenters, both sets of keyed entries appear | ||
| // in the provisioned secret data. | ||
| func TestResolveComponentCredential_MultiVCenter_BothVCentersPopulated(t *testing.T) { | ||
| cr := makeCredCR("machineAPI") | ||
|
|
||
| componentSecret := makeComponentSecret("vsphere-machine-api-creds", map[string]string{ | ||
| "vcenter1.example.com.username": "machine-api@vc1.local", | ||
| "vcenter1.example.com.password": "password1", | ||
| "vcenter2.example.com.username": "machine-api@vc2.local", | ||
| "vcenter2.example.com.password": "password2", | ||
| }) | ||
|
|
||
| reader := &stubComponentSecretReader{ | ||
| secrets: map[string]*corev1.Secret{"vsphere-machine-api-creds": componentSecret}, | ||
| } | ||
|
|
||
| data, _, err := resolveVSphereCredentials( | ||
| context.Background(), cr, reader, sharedSecretData, | ||
| ) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("expected no error, got: %v", err) | ||
| } | ||
| if string(data["vcenter1.example.com.username"]) != "machine-api@vc1.local" { | ||
| t.Errorf("vcenter1 username missing or wrong: %q", data["vcenter1.example.com.username"]) | ||
| } | ||
| if string(data["vcenter2.example.com.username"]) != "machine-api@vc2.local" { | ||
| t.Errorf("vcenter2 username missing or wrong: %q", data["vcenter2.example.com.username"]) | ||
| } | ||
| } | ||
|
|
||
| // ── Adversarial cases ───────────────────────────────────────────────────────── | ||
|
|
||
| // TestResolveComponentCredential_NoAnnotation_Passthrough verifies that a CredentialsRequest | ||
| // with no vsphere-component annotation is not routed to per-component logic (passthrough). | ||
| func TestResolveComponentCredential_NoAnnotation_Passthrough(t *testing.T) { | ||
| cr := makeCredCR("") // no annotation | ||
|
|
||
| reader := &stubComponentSecretReader{secrets: map[string]*corev1.Secret{}} | ||
|
|
||
| data, warning, err := resolveVSphereCredentials( | ||
| context.Background(), cr, reader, sharedSecretData, | ||
| ) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("expected no error for passthrough CR, got: %v", err) | ||
| } | ||
| if warning != "" { | ||
| t.Errorf("expected no warning for passthrough CR, got: %q", warning) | ||
| } | ||
| // Passthrough returns the shared secret data unchanged | ||
| if string(data["vcenter.example.com.username"]) != "shared@vsphere.local" { | ||
| t.Errorf("expected shared credential in passthrough, got: %q", data["vcenter.example.com.username"]) | ||
| } | ||
| } | ||
|
|
||
| // TestResolveComponentCredential_NilAnnotations_Passthrough is an edge case ensuring nil | ||
| // annotations map (not just absent key) is handled without panic. | ||
| func TestResolveComponentCredential_NilAnnotations_Passthrough(t *testing.T) { | ||
| cr := &minterv1.CredentialsRequest{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "test-cr", | ||
| Namespace: "openshift-cloud-credential-operator", | ||
| Annotations: nil, // explicitly nil | ||
| }, | ||
| } | ||
|
|
||
| reader := &stubComponentSecretReader{secrets: map[string]*corev1.Secret{}} | ||
|
|
||
| _, _, err := resolveVSphereCredentials( | ||
| context.Background(), cr, reader, sharedSecretData, | ||
| ) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("nil annotations must not panic or error, got: %v", err) | ||
| } | ||
| } | ||
|
|
||
| // TestResolveComponentCredential_UnknownComponentAnnotation_FallsBackToShared verifies that an | ||
| // unrecognized annotation value (e.g. "diagnostics") does not panic and falls back to shared. | ||
| func TestResolveComponentCredential_UnknownComponentAnnotation_FallsBackToShared(t *testing.T) { | ||
| cr := makeCredCR("diagnostics") // not a known component | ||
|
|
||
| reader := &stubComponentSecretReader{secrets: map[string]*corev1.Secret{}} | ||
|
|
||
| _, warning, err := resolveVSphereCredentials( | ||
| context.Background(), cr, reader, sharedSecretData, | ||
| ) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("unknown component should fall back gracefully, got error: %v", err) | ||
| } | ||
| if warning == "" { | ||
| t.Error("expected a fallback warning for unknown component") | ||
| } | ||
| } | ||
|
|
||
| // TestResolveComponentCredential_EmptyComponentSecret_FallsBackToShared verifies that a component | ||
| // secret with no keys (empty Data) is treated equivalently to a missing secret — fallback occurs. | ||
| func TestResolveComponentCredential_EmptyComponentSecret_FallsBackToShared(t *testing.T) { | ||
| cr := makeCredCR("machineAPI") | ||
|
|
||
| emptySecret := makeComponentSecret("vsphere-machine-api-creds", map[string]string{}) // no keys | ||
|
|
||
| reader := &stubComponentSecretReader{ | ||
| secrets: map[string]*corev1.Secret{"vsphere-machine-api-creds": emptySecret}, | ||
| } | ||
|
|
||
| _, warning, err := resolveVSphereCredentials( | ||
| context.Background(), cr, reader, sharedSecretData, | ||
| ) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("empty component secret should fall back, not error: %v", err) | ||
| } | ||
| if warning == "" { | ||
| t.Error("expected fallback warning for empty component secret") | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing hard validation for multi-vCenter credential completeness.
At Line 59, any non-empty component secret is accepted. That allows partial credentials (for example only one vCenter, or missing
.password) to pass, which breaks the expected “error on missing vCenter entry” behavior.Proposed direction
🤖 Prompt for AI Agents