diff --git a/.golangci.yml b/.golangci.yml index e5b21b0..6c4be88 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -36,6 +36,10 @@ linters: - dupl - lll path: internal/* + - linters: + - dupl + - goconst + path: _test\.go$ paths: - third_party$ - builtin$ diff --git a/Makefile b/Makefile index a8d81c2..03b50d8 100644 --- a/Makefile +++ b/Makefile @@ -260,7 +260,7 @@ GOLANGCI_LINT = $(LOCALBIN)/golangci-lint KUSTOMIZE_VERSION ?= v5.5.0 CONTROLLER_TOOLS_VERSION ?= v0.18.0 ENVTEST_VERSION := $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') -GOLANGCI_LINT_VERSION ?= v2.1.0 +GOLANGCI_LINT_VERSION ?= v2.12.2 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. diff --git a/README.md b/README.md index 3fffc11..4794d0e 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,23 @@ spec: labels: {} # (optional) map of labels for managed `Secret` name: bar # (optional) override name for managed `Secret` (default: .metadata.name) namespace: default # (required, ClusterToken-only) set the target namespace for managed `Secret` + extraData: # (optional) list of additional keys to project into managed `Secret` + - inline: {} # static key/value pairs, merged verbatim + - configMap: # project keys from a ConfigMap + name: foo + namespace: bar # (ClusterToken only: defaults to `secret.namespace`; a Token ref has no namespace field and always reads its own namespace) + keys: [] # (optional) allowlist of keys to project (default: all keys) + optional: false # (optional) if true, a missing source or key is skipped (per-key) rather than blocking + - secret: # project keys from a Secret; same fields as configMap + name: baz ``` +`spec.secret.extraData` projects additional keys into the managed `Secret` alongside the generated credentials, from inline values and/or referenced ConfigMaps/Secrets. Entries are merged in order, with later entries overriding earlier ones on key collision (logged as a Warning event). Operator-managed keys are always authoritative: inline entries that set a reserved key (`username`/`password` when `basicAuth: true`, or `token` otherwise) are rejected at admission, while the same keys from a ConfigMap/Secret source are silently dropped at reconcile (also a Warning event) since their contents aren't known until read. + +Token validity always takes priority over extraData. A required source (`optional: false`) that cannot be resolved only blocks *creation* of the managed `Secret` (surfaced as `Ready=False`/`SourceUnavailable`); once the `Secret` exists, an unresolvable source never destroys it — the credential keeps refreshing, the last-known-good extraData is retained, and the degradation is surfaced via the abnormal-true `ExtraDataDegraded` condition (`True`/`SourceUnavailable`, plus a Warning event) while the source is retried on the retry interval. With `optional: true`, a missing object or a missing listed key is skipped per-key (present keys still project) and reported via `ExtraDataDegraded=True`/`KeysMissing`. When extraData resolves in full, the condition is absent. + +Sources are re-read on the refresh/retry interval; there is no separate watch on the referenced ConfigMap/Secret. Because last-known-good data is retained, deleting a source object does not remove its keys from the managed `Secret` — remove the entry from `spec.secret.extraData` instead. + ### Multiple GitHub Apps (`App` CRD) Deployments that need multiple GitHub App configurations — different orgs, per-tenant Apps, or installations with different key providers — can declare `App` resources as the sole credential source, alongside, or instead of the startup `Secret/gtm-config`. `Token.spec.appRef` and `ClusterToken.spec.appRef` then select which App to use; when `appRef` is omitted, the startup config remains the fallback so **existing deployments need no changes**. diff --git a/api/v1/clustertoken_types.go b/api/v1/clustertoken_types.go index 13ffe7f..88ccbac 100644 --- a/api/v1/clustertoken_types.go +++ b/api/v1/clustertoken_types.go @@ -19,7 +19,7 @@ package v1 import ( "time" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" "github.com/isometry/github-token-manager/internal/ghapp" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -72,6 +72,9 @@ type ClusterTokenSpec struct { RepositoryIDs []int64 `json:"repositoryIDs,omitempty"` } +// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('username' in e.inline || 'password' in e.inline))",message="extraData inline must not contain 'username' or 'password' when basicAuth is true" +// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))",message="extraData inline must not contain 'token' when basicAuth is false" +// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || self.extraData.all(e, !has(e.inline) || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))",message="extraData inline keys must consist of alphanumerics, '-', '_' or '.'" type ClusterTokenSecretSpec struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MaxLength:=253 @@ -95,6 +98,15 @@ type ClusterTokenSecretSpec struct { // +optional // Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token' BasicAuth bool `json:"basicAuth,omitempty"` + + // +optional + // +kubebuilder:validation:MaxItems:=16 + // Additional keys to project into the managed Secret, from inline values + // and/or referenced ConfigMaps/Secrets. A configMap/secret ref's namespace + // defaults to the target Secret's namespace when unset. Reserved keys + // ('username'/'password' when basicAuth is true, 'token' otherwise) are + // always overridden by the operator-managed values. + ExtraData []SecretDataSource `json:"extraData,omitempty"` } // ClusterTokenStatus defines the observed state of ClusterToken @@ -173,6 +185,26 @@ func (t *ClusterToken) GetSecretBasicAuth() bool { return t.Spec.Secret.BasicAuth } +// GetSecretDataSources returns the extraData sources for this ClusterToken, +// defaulting any configMap/secret ref's empty namespace to the target +// Secret's namespace. Sources are deep-copied so the defaulting never +// mutates the caller's spec. +func (t *ClusterToken) GetSecretDataSources() []SecretDataSource { + if len(t.Spec.Secret.ExtraData) == 0 { + return nil + } + sources := make([]SecretDataSource, len(t.Spec.Secret.ExtraData)) + for i, source := range t.Spec.Secret.ExtraData { + sources[i] = *source.DeepCopy() + for _, ref := range []*SecretDataSourceRef{sources[i].ConfigMap, sources[i].Secret} { + if ref != nil && ref.Namespace == "" { + ref.Namespace = t.Spec.Secret.Namespace + } + } + } + return sources +} + func (t *ClusterToken) GetInstallationTokenOptions() *github.InstallationTokenOptions { return &github.InstallationTokenOptions{ Permissions: t.Spec.Permissions.ToInstallationPermissions(), @@ -214,6 +246,10 @@ func (t *ClusterToken) SetStatusCondition(condition metav1.Condition) (changed b return meta.SetStatusCondition(&t.Status.Conditions, condition) } +func (t *ClusterToken) RemoveStatusCondition(conditionType string) (changed bool) { + return meta.RemoveStatusCondition(&t.Status.Conditions, conditionType) +} + // +kubebuilder:object:root=true // ClusterTokenList contains a list of ClusterToken diff --git a/api/v1/clustertoken_types_test.go b/api/v1/clustertoken_types_test.go index d4e2bac..ccdd4ca 100644 --- a/api/v1/clustertoken_types_test.go +++ b/api/v1/clustertoken_types_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" v1 "github.com/isometry/github-token-manager/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -356,3 +356,36 @@ func TestClusterToken_SetStatusTimestamps(t *testing.T) { t.Error("CreatedAt should be before ExpiresAt") } } + +func TestClusterToken_GetSecretDataSources(t *testing.T) { + token := &v1.ClusterToken{ + Spec: v1.ClusterTokenSpec{ + Secret: v1.ClusterTokenSecretSpec{ + Namespace: "target-namespace", + ExtraData: []v1.SecretDataSource{ + {Inline: map[string]string{"ca.crt": "PEM"}}, + {ConfigMap: &v1.SecretDataSourceRef{Name: "ca-bundle"}}, + {ConfigMap: &v1.SecretDataSourceRef{Name: "ca-bundle", Namespace: "shared-ns"}}, + }, + }, + }, + } + + got := token.GetSecretDataSources() + if len(got) != 3 { + t.Fatalf("GetSecretDataSources() returned %d entries, want 3", len(got)) + } + if got[0].Inline["ca.crt"] != "PEM" { + t.Errorf("inline entry = %v, want ca.crt=PEM", got[0].Inline) + } + if got[1].ConfigMap.Namespace != "target-namespace" { + t.Errorf("unset ref namespace = %q, want defaulted to target Secret namespace %q", got[1].ConfigMap.Namespace, "target-namespace") + } + if got[2].ConfigMap.Namespace != "shared-ns" { + t.Errorf("explicit ref namespace = %q, want preserved as %q", got[2].ConfigMap.Namespace, "shared-ns") + } + + if got := (&v1.ClusterToken{}).GetSecretDataSources(); got != nil { + t.Errorf("GetSecretDataSources() on empty ClusterToken = %v, want nil", got) + } +} diff --git a/api/v1/conditions.go b/api/v1/conditions.go index 53a639a..6995a61 100644 --- a/api/v1/conditions.go +++ b/api/v1/conditions.go @@ -9,6 +9,14 @@ const ( // spec.validateKey is false. ConditionTypeKeyValid = "KeyValid" + // ConditionTypeExtraDataDegraded is an abnormal-true (kstatus-style) + // condition: set True when spec.secret.extraData did not resolve in full + // on the last reconcile — reason KeysMissing when optional listed keys + // are absent, or reason SourceUnavailable while the managed Secret + // serves last-known-good extraData because a required source cannot be + // read. Absent when extraData resolves cleanly or none is configured. + ConditionTypeExtraDataDegraded = "ExtraDataDegraded" + // Condition reasons used by the Token and ClusterToken controllers when // resolving spec.appRef. @@ -30,4 +38,15 @@ const ( // ReasonInvalidKey indicates the resolved key material is missing, // empty, or not a usable PEM-encoded RSA private key. ReasonInvalidKey = "InvalidKey" + + // Condition reasons used with ConditionTypeExtraDataDegraded (and, for + // SourceUnavailable, with ConditionTypeReady when Secret creation is + // blocked). + + // ReasonKeysMissing indicates optional extraData keys were absent from + // their source and skipped. + ReasonKeysMissing = "KeysMissing" + // ReasonSourceUnavailable indicates a required extraData source could not + // be resolved. + ReasonSourceUnavailable = "SourceUnavailable" ) diff --git a/api/v1/groupversion_info.go b/api/v1/groupversion_info.go index 4f8e6e8..732bbca 100644 --- a/api/v1/groupversion_info.go +++ b/api/v1/groupversion_info.go @@ -29,6 +29,7 @@ var ( GroupVersion = schema.GroupVersion{Group: "github.as-code.io", Version: "v1"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme + //nolint:staticcheck // standard kubebuilder scaffolding; scheme.Builder deprecation has no drop-in replacement yet SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} // AddToScheme adds the types in this group-version to the given scheme. diff --git a/api/v1/helpers_test.go b/api/v1/helpers_test.go index cf4bed0..0f93842 100644 --- a/api/v1/helpers_test.go +++ b/api/v1/helpers_test.go @@ -3,7 +3,7 @@ package v1_test import ( "testing" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" v1 "github.com/isometry/github-token-manager/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/api/v1/permissions.go b/api/v1/permissions.go index 82f355c..c2e1c6c 100644 --- a/api/v1/permissions.go +++ b/api/v1/permissions.go @@ -1,7 +1,7 @@ package v1 import ( - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/api/v1/permissions_test.go b/api/v1/permissions_test.go index a67d38b..bfe34ad 100644 --- a/api/v1/permissions_test.go +++ b/api/v1/permissions_test.go @@ -73,9 +73,6 @@ func TestPermissions_ToInstallationPermissions_FieldMapping(t *testing.T) { } } -//go:fix inline -func ptr(s string) *string { return new(s) } - func TestPermissions_ToInstallationPermissions_AllPermissions(t *testing.T) { p := &v1.Permissions{ Actions: new("actions"), diff --git a/api/v1/secretdatasource.go b/api/v1/secretdatasource.go new file mode 100644 index 0000000..3bd50a8 --- /dev/null +++ b/api/v1/secretdatasource.go @@ -0,0 +1,140 @@ +/* +Copyright 2024 Robin Breathe. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +// +kubebuilder:validation:XValidation:rule="(has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) == 1",message="exactly one of inline, configMap or secret must be set" + +// SecretDataSource projects additional keys into a managed Secret from +// exactly one of an inline map, a ConfigMap, or another Secret. Used by the +// cluster-scoped ClusterToken kind. Entries are merged in list order; later +// entries win on key collision, and keys reserved by the token type (e.g. +// 'token', or 'username'/'password' under basicAuth) are always overridden +// by the operator-managed values. +type SecretDataSource struct { + // +optional + // +kubebuilder:validation:MaxProperties:=16 + // Static key/value pairs to merge in verbatim. + Inline map[string]string `json:"inline,omitempty"` + + // +optional + // Project keys from a ConfigMap. + ConfigMap *SecretDataSourceRef `json:"configMap,omitempty"` + + // +optional + // Project keys from a Secret. + Secret *SecretDataSourceRef `json:"secret,omitempty"` +} + +// SecretDataSourceRef references a ConfigMap or Secret, optionally in a +// different namespace, to project keys from. +type SecretDataSourceRef struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength:=253 + // Name of the referenced ConfigMap or Secret. + Name string `json:"name"` + + // +optional + // +kubebuilder:validation:MaxLength:=253 + // Namespace of the referenced ConfigMap or Secret. If empty, defaults to + // the target Secret's namespace. + Namespace string `json:"namespace,omitempty"` + + // +optional + // +kubebuilder:validation:MaxItems:=64 + // +kubebuilder:validation:items:MaxLength:=253 + // Restrict projection to these keys. When empty, every key in the + // referenced object is projected. + Keys []string `json:"keys,omitempty"` + + // +optional + // When false (the default), an unresolvable reference — a missing or + // unreadable object, or a listed key absent from it — blocks creation of + // the managed Secret; once the Secret exists, its last-known-good + // extraData is retained (while the credential keeps refreshing) and the + // failure is surfaced via the ExtraDataDegraded condition. When true, a + // missing object or key is skipped instead and reported via the same + // condition. + Optional bool `json:"optional,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="(has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) == 1",message="exactly one of inline, configMap or secret must be set" + +// LocalSecretDataSource is the same-namespace form of SecretDataSource used +// by the namespaced Token kind. A Token may only reference ConfigMaps and +// Secrets in its own namespace. +type LocalSecretDataSource struct { + // +optional + // +kubebuilder:validation:MaxProperties:=16 + // Static key/value pairs to merge in verbatim. + Inline map[string]string `json:"inline,omitempty"` + + // +optional + // Project keys from a ConfigMap in the same namespace as the Token. + ConfigMap *LocalSecretDataSourceRef `json:"configMap,omitempty"` + + // +optional + // Project keys from a Secret in the same namespace as the Token. + Secret *LocalSecretDataSourceRef `json:"secret,omitempty"` +} + +// LocalSecretDataSourceRef references a ConfigMap or Secret in the same +// namespace as the referring Token to project keys from. +type LocalSecretDataSourceRef struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength:=253 + // Name of the referenced ConfigMap or Secret. + Name string `json:"name"` + + // +optional + // +kubebuilder:validation:MaxItems:=64 + // +kubebuilder:validation:items:MaxLength:=253 + // Restrict projection to these keys. When empty, every key in the + // referenced object is projected. + Keys []string `json:"keys,omitempty"` + + // +optional + // When false (the default), an unresolvable reference — a missing or + // unreadable object, or a listed key absent from it — blocks creation of + // the managed Secret; once the Secret exists, its last-known-good + // extraData is retained (while the credential keeps refreshing) and the + // failure is surfaced via the ExtraDataDegraded condition. When true, a + // missing object or key is skipped instead and reported via the same + // condition. + Optional bool `json:"optional,omitempty"` +} + +// toSecretDataSource converts to the common SecretDataSource shape, placing +// any referenced ConfigMap/Secret in namespace (the referring Token's own). +func (s LocalSecretDataSource) toSecretDataSource(namespace string) SecretDataSource { + out := SecretDataSource{Inline: s.Inline} + if s.ConfigMap != nil { + out.ConfigMap = s.ConfigMap.toSecretDataSourceRef(namespace) + } + if s.Secret != nil { + out.Secret = s.Secret.toSecretDataSourceRef(namespace) + } + return out +} + +func (r LocalSecretDataSourceRef) toSecretDataSourceRef(namespace string) *SecretDataSourceRef { + return &SecretDataSourceRef{ + Name: r.Name, + Namespace: namespace, + Keys: r.Keys, + Optional: r.Optional, + } +} diff --git a/api/v1/token_types.go b/api/v1/token_types.go index f6419d9..0876d38 100644 --- a/api/v1/token_types.go +++ b/api/v1/token_types.go @@ -19,7 +19,7 @@ package v1 import ( "time" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" "github.com/isometry/github-token-manager/internal/ghapp" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -72,6 +72,9 @@ type TokenSpec struct { RepositoryIDs []int64 `json:"repositoryIDs,omitempty"` } +// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('username' in e.inline || 'password' in e.inline))",message="extraData inline must not contain 'username' or 'password' when basicAuth is true" +// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))",message="extraData inline must not contain 'token' when basicAuth is false" +// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || self.extraData.all(e, !has(e.inline) || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))",message="extraData inline keys must consist of alphanumerics, '-', '_' or '.'" type TokenSecretSpec struct { // +optional // +kubebuilder:validation:MaxLength:=253 @@ -89,6 +92,14 @@ type TokenSecretSpec struct { // +optional // Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token' BasicAuth bool `json:"basicAuth,omitempty"` + + // +optional + // +kubebuilder:validation:MaxItems:=16 + // Additional keys to project into the managed Secret, from inline values + // and/or referenced ConfigMaps/Secrets in this Token's own namespace. + // Reserved keys ('username'/'password' when basicAuth is true, 'token' + // otherwise) are always overridden by the operator-managed values. + ExtraData []LocalSecretDataSource `json:"extraData,omitempty"` } // TokenStatus defines the observed state of Token @@ -167,6 +178,21 @@ func (t *Token) GetSecretBasicAuth() bool { return t.Spec.Secret.BasicAuth } +// GetSecretDataSources returns the extraData sources for this Token, +// converted to the common SecretDataSource shape with every configMap/secret +// ref placed in the Token's own namespace (the LocalSecretDataSourceRef +// schema has no namespace field, so no other namespace is expressible). +func (t *Token) GetSecretDataSources() []SecretDataSource { + if len(t.Spec.Secret.ExtraData) == 0 { + return nil + } + sources := make([]SecretDataSource, len(t.Spec.Secret.ExtraData)) + for i, source := range t.Spec.Secret.ExtraData { + sources[i] = source.toSecretDataSource(t.Namespace) + } + return sources +} + func (t *Token) GetInstallationTokenOptions() *github.InstallationTokenOptions { return &github.InstallationTokenOptions{ Permissions: t.Spec.Permissions.ToInstallationPermissions(), @@ -208,6 +234,10 @@ func (t *Token) SetStatusCondition(condition metav1.Condition) (changed bool) { return meta.SetStatusCondition(&t.Status.Conditions, condition) } +func (t *Token) RemoveStatusCondition(conditionType string) (changed bool) { + return meta.RemoveStatusCondition(&t.Status.Conditions, conditionType) +} + // +kubebuilder:object:root=true // TokenList contains a list of Token diff --git a/api/v1/token_types_test.go b/api/v1/token_types_test.go index 12434df..4dd4e93 100644 --- a/api/v1/token_types_test.go +++ b/api/v1/token_types_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" v1 "github.com/isometry/github-token-manager/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -343,3 +343,39 @@ func TestToken_SetStatusTimestamps(t *testing.T) { t.Error("CreatedAt should be before ExpiresAt") } } + +func TestToken_GetSecretDataSources(t *testing.T) { + token := &v1.Token{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}, + Spec: v1.TokenSpec{ + Secret: v1.TokenSecretSpec{ + ExtraData: []v1.LocalSecretDataSource{ + {Inline: map[string]string{"ca.crt": "PEM"}}, + {ConfigMap: &v1.LocalSecretDataSourceRef{Name: "ca-bundle", Keys: []string{"ca.crt"}, Optional: true}}, + {Secret: &v1.LocalSecretDataSourceRef{Name: "ca-key"}}, + }, + }, + }, + } + + got := token.GetSecretDataSources() + if len(got) != 3 { + t.Fatalf("GetSecretDataSources() returned %d entries, want 3", len(got)) + } + if got[0].Inline["ca.crt"] != "PEM" { + t.Errorf("inline entry = %v, want ca.crt=PEM", got[0].Inline) + } + if got[1].ConfigMap.Namespace != "team-a" { + t.Errorf("configMap ref namespace = %q, want the Token's own namespace %q", got[1].ConfigMap.Namespace, "team-a") + } + if len(got[1].ConfigMap.Keys) != 1 || got[1].ConfigMap.Keys[0] != "ca.crt" || !got[1].ConfigMap.Optional { + t.Errorf("configMap ref = %+v, want Keys and Optional carried over", got[1].ConfigMap) + } + if got[2].Secret.Namespace != "team-a" { + t.Errorf("secret ref namespace = %q, want the Token's own namespace %q", got[2].Secret.Namespace, "team-a") + } + + if got := (&v1.Token{}).GetSecretDataSources(); got != nil { + t.Errorf("GetSecretDataSources() on empty Token = %v, want nil", got) + } +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 4d38f6e..fbc6500 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -217,6 +217,13 @@ func (in *ClusterTokenSecretSpec) DeepCopyInto(out *ClusterTokenSecretSpec) { (*out)[key] = val } } + if in.ExtraData != nil { + in, out := &in.ExtraData, &out.ExtraData + *out = make([]SecretDataSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTokenSecretSpec. @@ -338,6 +345,58 @@ func (in *LocalAppReference) DeepCopy() *LocalAppReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalSecretDataSource) DeepCopyInto(out *LocalSecretDataSource) { + *out = *in + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ConfigMap != nil { + in, out := &in.ConfigMap, &out.ConfigMap + *out = new(LocalSecretDataSourceRef) + (*in).DeepCopyInto(*out) + } + if in.Secret != nil { + in, out := &in.Secret, &out.Secret + *out = new(LocalSecretDataSourceRef) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalSecretDataSource. +func (in *LocalSecretDataSource) DeepCopy() *LocalSecretDataSource { + if in == nil { + return nil + } + out := new(LocalSecretDataSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalSecretDataSourceRef) DeepCopyInto(out *LocalSecretDataSourceRef) { + *out = *in + if in.Keys != nil { + in, out := &in.Keys, &out.Keys + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalSecretDataSourceRef. +func (in *LocalSecretDataSourceRef) DeepCopy() *LocalSecretDataSourceRef { + if in == nil { + return nil + } + out := new(LocalSecretDataSourceRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedSecret) DeepCopyInto(out *ManagedSecret) { *out = *in @@ -548,6 +607,58 @@ func (in *Permissions) DeepCopy() *Permissions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretDataSource) DeepCopyInto(out *SecretDataSource) { + *out = *in + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ConfigMap != nil { + in, out := &in.ConfigMap, &out.ConfigMap + *out = new(SecretDataSourceRef) + (*in).DeepCopyInto(*out) + } + if in.Secret != nil { + in, out := &in.Secret, &out.Secret + *out = new(SecretDataSourceRef) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretDataSource. +func (in *SecretDataSource) DeepCopy() *SecretDataSource { + if in == nil { + return nil + } + out := new(SecretDataSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretDataSourceRef) DeepCopyInto(out *SecretDataSourceRef) { + *out = *in + if in.Keys != nil { + in, out := &in.Keys, &out.Keys + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretDataSourceRef. +func (in *SecretDataSourceRef) DeepCopy() *SecretDataSourceRef { + if in == nil { + return nil + } + out := new(SecretDataSourceRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Token) DeepCopyInto(out *Token) { *out = *in @@ -624,6 +735,13 @@ func (in *TokenSecretSpec) DeepCopyInto(out *TokenSecretSpec) { (*out)[key] = val } } + if in.ExtraData != nil { + in, out := &in.ExtraData, &out.ExtraData + *out = make([]LocalSecretDataSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenSecretSpec. diff --git a/cmd/manager/main.go b/cmd/manager/main.go index fa2809d..ef8bad7 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -20,9 +20,11 @@ import ( "context" "crypto/tls" "flag" + "fmt" "os" "path/filepath" "strings" + "time" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -206,7 +208,11 @@ func main() { os.Exit(1) } defer func() { - if err := metricsRecorder.Shutdown(context.Background()); err != nil { + // The signal context is already cancelled by the time this runs; + // derive an uncancelled-but-bounded context for the final flush. + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := metricsRecorder.Shutdown(shutdownCtx); err != nil { setupLog.Error(err, "shutting down meter provider") } }() @@ -229,45 +235,16 @@ func main() { registry := ghapp.NewRegistry(operatorNamespace, startupCfg) - if err := mgr.GetFieldIndexer().IndexField(ctx, &githubv1.Token{}, controller.TokenAppRefIndex, func(obj client.Object) []string { - t := obj.(*githubv1.Token) - if t.Spec.AppRef == nil { - return nil - } - return []string{t.Spec.AppRef.Name} - }); err != nil { - setupLog.Error(err, "unable to create field indexer", "field", controller.TokenAppRefIndex) - os.Exit(1) - } - - if err := mgr.GetFieldIndexer().IndexField(ctx, &githubv1.ClusterToken{}, controller.ClusterTokenAppRefIndex, func(obj client.Object) []string { - ct := obj.(*githubv1.ClusterToken) - if ct.Spec.AppRef == nil { - return nil - } - ns := ct.Spec.AppRef.Namespace - if ns == "" { - ns = operatorNamespace - } - return []string{ns + "/" + ct.Spec.AppRef.Name} - }); err != nil { - setupLog.Error(err, "unable to create field indexer", "field", controller.ClusterTokenAppRefIndex) - os.Exit(1) - } - - if err := mgr.GetFieldIndexer().IndexField(ctx, &githubv1.App{}, controller.AppKeyRefIndex, func(obj client.Object) []string { - a := obj.(*githubv1.App) - if a.Spec.KeyRef == nil { - return nil - } - return []string{a.Spec.KeyRef.Name} - }); err != nil { - setupLog.Error(err, "unable to create field indexer", "field", controller.AppKeyRefIndex) + if err := setupFieldIndexes(ctx, mgr, operatorNamespace); err != nil { + setupLog.Error(err, "unable to create field indexer") os.Exit(1) } tokenBase := controller.TokenReconcilerBase{ - Client: mgr.GetClient(), + Client: mgr.GetClient(), + Reader: mgr.GetAPIReader(), + //nolint:staticcheck // migrating to the structured events API changes the recorder type end-to-end; deferred + Recorder: mgr.GetEventRecorderFor("github-token-manager"), Metrics: metricsRecorder, Registry: registry, } @@ -321,6 +298,50 @@ func main() { } } +// setupFieldIndexes registers the field indexes that map App changes to the +// Tokens/ClusterTokens referencing them, and Secret changes to the Apps +// keyed on them. +func setupFieldIndexes(ctx context.Context, mgr ctrl.Manager, operatorNamespace string) error { + if err := mgr.GetFieldIndexer().IndexField(ctx, &githubv1.Token{}, controller.TokenAppRefIndex, + func(obj client.Object) []string { + t := obj.(*githubv1.Token) + if t.Spec.AppRef == nil { + return nil + } + return []string{t.Spec.AppRef.Name} + }); err != nil { + return fmt.Errorf("field %s: %w", controller.TokenAppRefIndex, err) + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &githubv1.ClusterToken{}, controller.ClusterTokenAppRefIndex, + func(obj client.Object) []string { + ct := obj.(*githubv1.ClusterToken) + if ct.Spec.AppRef == nil { + return nil + } + ns := ct.Spec.AppRef.Namespace + if ns == "" { + ns = operatorNamespace + } + return []string{ns + "/" + ct.Spec.AppRef.Name} + }); err != nil { + return fmt.Errorf("field %s: %w", controller.ClusterTokenAppRefIndex, err) + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &githubv1.App{}, controller.AppKeyRefIndex, + func(obj client.Object) []string { + a := obj.(*githubv1.App) + if a.Spec.KeyRef == nil { + return nil + } + return []string{a.Spec.KeyRef.Name} + }); err != nil { + return fmt.Errorf("field %s: %w", controller.AppKeyRefIndex, err) + } + + return nil +} + // getOperatorNamespace returns the namespace this operator Pod runs in. It // prefers the POD_NAMESPACE env var (typically supplied via the downward API) // and falls back to the in-cluster ServiceAccount namespace file. Returns "" diff --git a/config/crd/bases/github.as-code.io_clustertokens.yaml b/config/crd/bases/github.as-code.io_clustertokens.yaml index 2447713..adcea06 100644 --- a/config/crd/bases/github.as-code.io_clustertokens.yaml +++ b/config/crd/bases/github.as-code.io_clustertokens.yaml @@ -300,6 +300,108 @@ spec: Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token' type: boolean + extraData: + description: |- + Additional keys to project into the managed Secret, from inline values + and/or referenced ConfigMaps/Secrets. A configMap/secret ref's namespace + defaults to the target Secret's namespace when unset. Reserved keys + ('username'/'password' when basicAuth is true, 'token' otherwise) are + always overridden by the operator-managed values. + items: + description: |- + SecretDataSource projects additional keys into a managed Secret from + exactly one of an inline map, a ConfigMap, or another Secret. Used by the + cluster-scoped ClusterToken kind. Entries are merged in list order; later + entries win on key collision, and keys reserved by the token type (e.g. + 'token', or 'username'/'password' under basicAuth) are always overridden + by the operator-managed values. + properties: + configMap: + description: Project keys from a ConfigMap. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. If empty, defaults to + the target Secret's namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + inline: + additionalProperties: + type: string + description: Static key/value pairs to merge in verbatim. + maxProperties: 16 + type: object + secret: + description: Project keys from a Secret. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. If empty, defaults to + the target Secret's namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + type: object + x-kubernetes-validations: + - message: + exactly one of inline, configMap or secret must be + set + rule: + (has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) + == 1 + maxItems: 16 + type: array labels: additionalProperties: type: string @@ -319,6 +421,26 @@ spec: required: - namespace type: object + x-kubernetes-validations: + - message: + extraData inline must not contain 'username' or 'password' + when basicAuth is true + rule: + "!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('username' in e.inline + || 'password' in e.inline))" + - message: + extraData inline must not contain 'token' when basicAuth + is false + rule: + "!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))" + - message: + extraData inline keys must consist of alphanumerics, '-', + '_' or '.' + rule: + "!has(self.extraData) || self.extraData.all(e, !has(e.inline) + || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))" required: - secret type: object diff --git a/config/crd/bases/github.as-code.io_tokens.yaml b/config/crd/bases/github.as-code.io_tokens.yaml index d40f8c5..1200d5f 100644 --- a/config/crd/bases/github.as-code.io_tokens.yaml +++ b/config/crd/bases/github.as-code.io_tokens.yaml @@ -296,6 +296,96 @@ spec: Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token' type: boolean + extraData: + description: |- + Additional keys to project into the managed Secret, from inline values + and/or referenced ConfigMaps/Secrets in this Token's own namespace. + Reserved keys ('username'/'password' when basicAuth is true, 'token' + otherwise) are always overridden by the operator-managed values. + items: + description: |- + LocalSecretDataSource is the same-namespace form of SecretDataSource used + by the namespaced Token kind. A Token may only reference ConfigMaps and + Secrets in its own namespace. + properties: + configMap: + description: + Project keys from a ConfigMap in the same namespace + as the Token. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + inline: + additionalProperties: + type: string + description: Static key/value pairs to merge in verbatim. + maxProperties: 16 + type: object + secret: + description: + Project keys from a Secret in the same namespace + as the Token. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + type: object + x-kubernetes-validations: + - message: + exactly one of inline, configMap or secret must be + set + rule: + (has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) + == 1 + maxItems: 16 + type: array labels: additionalProperties: type: string @@ -308,6 +398,26 @@ spec: maxLength: 253 type: string type: object + x-kubernetes-validations: + - message: + extraData inline must not contain 'username' or 'password' + when basicAuth is true + rule: + "!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('username' in e.inline + || 'password' in e.inline))" + - message: + extraData inline must not contain 'token' when basicAuth + is false + rule: + "!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))" + - message: + extraData inline keys must consist of alphanumerics, '-', + '_' or '.' + rule: + "!has(self.extraData) || self.extraData.all(e, !has(e.inline) + || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))" type: object status: description: TokenStatus defines the observed state of Token diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f4ce651..8ad6fc7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,12 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get - apiGroups: - "" resources: diff --git a/deploy/charts/github-token-manager/templates/crds.yaml b/deploy/charts/github-token-manager/templates/crds.yaml index 3956f7e..346683f 100644 --- a/deploy/charts/github-token-manager/templates/crds.yaml +++ b/deploy/charts/github-token-manager/templates/crds.yaml @@ -308,6 +308,108 @@ spec: Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token' type: boolean + extraData: + description: |- + Additional keys to project into the managed Secret, from inline values + and/or referenced ConfigMaps/Secrets. A configMap/secret ref's namespace + defaults to the target Secret's namespace when unset. Reserved keys + ('username'/'password' when basicAuth is true, 'token' otherwise) are + always overridden by the operator-managed values. + items: + description: |- + SecretDataSource projects additional keys into a managed Secret from + exactly one of an inline map, a ConfigMap, or another Secret. Used by the + cluster-scoped ClusterToken kind. Entries are merged in list order; later + entries win on key collision, and keys reserved by the token type (e.g. + 'token', or 'username'/'password' under basicAuth) are always overridden + by the operator-managed values. + properties: + configMap: + description: Project keys from a ConfigMap. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. If empty, defaults to + the target Secret's namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + inline: + additionalProperties: + type: string + description: Static key/value pairs to merge in verbatim. + maxProperties: 16 + type: object + secret: + description: Project keys from a Secret. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. If empty, defaults to + the target Secret's namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + type: object + x-kubernetes-validations: + - message: + exactly one of inline, configMap or secret must be + set + rule: + (has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) + == 1 + maxItems: 16 + type: array labels: additionalProperties: type: string @@ -327,6 +429,26 @@ spec: required: - namespace type: object + x-kubernetes-validations: + - message: + extraData inline must not contain 'username' or 'password' + when basicAuth is true + rule: + "!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('username' in e.inline + || 'password' in e.inline))" + - message: + extraData inline must not contain 'token' when basicAuth + is false + rule: + "!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))" + - message: + extraData inline keys must consist of alphanumerics, '-', + '_' or '.' + rule: + "!has(self.extraData) || self.extraData.all(e, !has(e.inline) + || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))" required: - secret type: object @@ -721,6 +843,96 @@ spec: Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token' type: boolean + extraData: + description: |- + Additional keys to project into the managed Secret, from inline values + and/or referenced ConfigMaps/Secrets in this Token's own namespace. + Reserved keys ('username'/'password' when basicAuth is true, 'token' + otherwise) are always overridden by the operator-managed values. + items: + description: |- + LocalSecretDataSource is the same-namespace form of SecretDataSource used + by the namespaced Token kind. A Token may only reference ConfigMaps and + Secrets in its own namespace. + properties: + configMap: + description: + Project keys from a ConfigMap in the same namespace + as the Token. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + inline: + additionalProperties: + type: string + description: Static key/value pairs to merge in verbatim. + maxProperties: 16 + type: object + secret: + description: + Project keys from a Secret in the same namespace + as the Token. + properties: + keys: + description: |- + Restrict projection to these keys. When empty, every key in the + referenced object is projected. + items: + maxLength: 253 + type: string + maxItems: 64 + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), an unresolvable reference — a missing or + unreadable object, or a listed key absent from it — blocks creation of + the managed Secret; once the Secret exists, its last-known-good + extraData is retained (while the credential keeps refreshing) and the + failure is surfaced via the ExtraDataDegraded condition. When true, a + missing object or key is skipped instead and reported via the same + condition. + type: boolean + required: + - name + type: object + type: object + x-kubernetes-validations: + - message: + exactly one of inline, configMap or secret must be + set + rule: + (has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) + == 1 + maxItems: 16 + type: array labels: additionalProperties: type: string @@ -733,6 +945,26 @@ spec: maxLength: 253 type: string type: object + x-kubernetes-validations: + - message: + extraData inline must not contain 'username' or 'password' + when basicAuth is true + rule: + "!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('username' in e.inline + || 'password' in e.inline))" + - message: + extraData inline must not contain 'token' when basicAuth + is false + rule: + "!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) + || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))" + - message: + extraData inline keys must consist of alphanumerics, '-', + '_' or '.' + rule: + "!has(self.extraData) || self.extraData.all(e, !has(e.inline) + || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))" type: object status: description: TokenStatus defines the observed state of Token diff --git a/deploy/charts/github-token-manager/templates/rbac.yaml b/deploy/charts/github-token-manager/templates/rbac.yaml index cb30fe1..b7b0ff5 100644 --- a/deploy/charts/github-token-manager/templates/rbac.yaml +++ b/deploy/charts/github-token-manager/templates/rbac.yaml @@ -96,6 +96,12 @@ metadata: component: rbac {{- include "labels" . | nindent 4 }} rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get - apiGroups: - "" resources: diff --git a/go.mod b/go.mod index 120b639..05cf912 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/isometry/github-token-manager -go 1.26.3 +go 1.26.4 require ( github.com/go-logr/logr v1.4.3 - github.com/google/go-github/v84 v84.0.0 - github.com/isometry/ghait/v84 v84.2.0 + github.com/google/go-github/v88 v88.0.0 + github.com/isometry/ghait/v88 v88.0.0 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 github.com/spf13/viper v1.21.0 @@ -14,7 +14,6 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 - golang.org/x/oauth2 v0.36.0 k8s.io/api v0.36.1 k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 @@ -31,39 +30,39 @@ require ( cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/kms v1.31.0 // indirect cloud.google.com/go/longrunning v1.0.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect - github.com/aws/aws-sdk-go-v2/service/kms v1.53.0 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect - github.com/aws/smithy-go v1.26.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.53.4 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 // indirect + github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect @@ -116,7 +115,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/pelletier/go-toml/v2 v2.4.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -143,21 +142,22 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.45.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/api v0.283.0 // indirect - google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/api v0.285.0 // indirect + google.golang.org/genproto v0.0.0-20260615183401-62b3387ff324 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect diff --git a/go.sum b/go.sum index 27fd20c..5f3ecc0 100644 --- a/go.sum +++ b/go.sum @@ -14,12 +14,12 @@ cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= @@ -34,42 +34,42 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= -github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= -github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= -github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= -github.com/aws/aws-sdk-go-v2/service/kms v1.53.0 h1:d/qhv0TFUtqeaLWmX5rJlKG+qBr/gQnsNPR66bYtnAU= -github.com/aws/aws-sdk-go-v2/service/kms v1.53.0/go.mod h1:oqZYP0JN0ih1JTsoiT10Un/Ivg8LeVOMTK+UDNBq3sU= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= -github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= -github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/kms v1.53.4 h1:PEgVSsWtR8NNxsDxFL2Ywisi7R+1EFQARGsT4q3mWwI= +github.com/aws/aws-sdk-go-v2/service/kms v1.53.4/go.mod h1:3EeKyDGPGSCEphG2OolwNGNF45RvQIfm27AYYpfEWrw= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk= +github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 h1:WPqnN6NS9XvYlOgZQAIseN7Z1uAiE+UxgDKlW7FvFuU= -github.com/bradleyfalzon/ghinstallation/v2 v2.18.0/go.mod h1:gpoSwwWc4biE49F7n+roCcpkEkZ1Qr9soZ2ESvMiouU= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -96,8 +96,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= @@ -176,8 +176,8 @@ github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA= -github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -220,8 +220,8 @@ github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4 github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/isometry/ghait/v84 v84.2.0 h1:LrkIALEsOc7KoSeRXCvgwJmnqIBFr9vpL5u1a0S1nes= -github.com/isometry/ghait/v84 v84.2.0/go.mod h1:eQKiAsT00YGQCs1jQtTiyIJXf85xyesuk1oZBqpFELM= +github.com/isometry/ghait/v88 v88.0.0 h1:FTGwv6yaVuPPIS822Bh2NI7BRSxcSlEffFQHyF37vX0= +github.com/isometry/ghait/v88 v88.0.0/go.mod h1:tk4aQNylZDAYRdYMzTnfSjX1bWdn9L767iMwiAcpums= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -260,8 +260,8 @@ github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row= +github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -353,25 +353,25 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM= golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= @@ -380,14 +380,14 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= -google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= -google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE= -google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/api v0.285.0 h1:B7eHHoKGAX/LrPkQvhQqnGwjgWxofbdGwCTQvpm8FkM= +google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= +google.golang.org/genproto v0.0.0-20260615183401-62b3387ff324 h1:r7/+bt4yKglJiN8eUY8enbRjglCvFm1eh8ezYdYoKTM= +google.golang.org/genproto v0.0.0-20260615183401-62b3387ff324/go.mod h1:V5M1lxGXNUICs0aOqAMsK6HtmLnCyuzY031uOQS9rJE= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/internal/controller/app_controller.go b/internal/controller/app_controller.go index 1c41a81..1d59a8a 100644 --- a/internal/controller/app_controller.go +++ b/internal/controller/app_controller.go @@ -179,27 +179,6 @@ func (r *AppReconciler) mapSecretToApps(ctx context.Context, obj client.Object) return out } -// secretReferencedByApp reports whether at least one App in the Secret's -// namespace references it via spec.keyRef.name. Used to gate the Secret -// watch so unrelated cluster Secret churn doesn't drive mapper invocations. -// On a transient cache error the event is allowed through; the mapper will -// log and short-circuit if the index is still empty. -func (r *AppReconciler) secretReferencedByApp(obj client.Object) bool { - secret, ok := obj.(*corev1.Secret) - if !ok { - return false - } - var apps githubv1.AppList - if err := r.List(context.Background(), &apps, - client.InNamespace(secret.Namespace), - client.MatchingFields{AppKeyRefIndex: secret.Name}, - client.Limit(1), - ); err != nil { - return true - } - return len(apps.Items) > 0 -} - // SetupWithManager sets up the controller with the Manager. func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). @@ -207,10 +186,7 @@ func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error { Named(ControllerNameApp). Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.mapSecretToApps), - builder.WithPredicates( - predicate.ResourceVersionChangedPredicate{}, - predicate.NewPredicateFuncs(r.secretReferencedByApp), - ), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). WithOptions(controller.Options{MaxConcurrentReconciles: 3}). Complete(r) diff --git a/internal/controller/appconfig.go b/internal/controller/appconfig.go index 30a4eab..0605329 100644 --- a/internal/controller/appconfig.go +++ b/internal/controller/appconfig.go @@ -74,14 +74,14 @@ func resolveAppConfig(ctx context.Context, c client.Reader, app *githubv1.App) ( nn := types.NamespacedName{Namespace: app.Namespace, Name: app.Spec.KeyRef.Name} if err := c.Get(ctx, nn, &secret); err != nil { if apierrors.IsNotFound(err) { - return nil, "", githubv1.ReasonSecretNotFound, fmt.Errorf("Secret %s not found: %w", nn, err) + return nil, "", githubv1.ReasonSecretNotFound, fmt.Errorf("referenced Secret %s not found: %w", nn, err) } return nil, "", githubv1.ReasonSetupFailed, fmt.Errorf("fetch Secret %s: %w", nn, err) } pemBytes, ok := secret.Data[dataKey] if !ok || len(pemBytes) == 0 { - return nil, "", githubv1.ReasonInvalidKey, fmt.Errorf("Secret %s has no data under key %q", nn, dataKey) + return nil, "", githubv1.ReasonInvalidKey, fmt.Errorf("referenced Secret %s has no data under key %q", nn, dataKey) } cfg.Provider = "file" diff --git a/internal/controller/appresolver.go b/internal/controller/appresolver.go index 053564f..2d62ee4 100644 --- a/internal/controller/appresolver.go +++ b/internal/controller/appresolver.go @@ -21,7 +21,7 @@ import ( "fmt" "time" - "github.com/isometry/ghait/v84" + "github.com/isometry/ghait/v88" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/internal/controller/clustertoken_controller.go b/internal/controller/clustertoken_controller.go index 435d300..644c10b 100644 --- a/internal/controller/clustertoken_controller.go +++ b/internal/controller/clustertoken_controller.go @@ -41,6 +41,7 @@ type ClusterTokenReconciler struct { // +kubebuilder:rbac:groups=github.as-code.io,resources=apps,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. diff --git a/internal/controller/reconcile_token.go b/internal/controller/reconcile_token.go index 58e63d2..dae373d 100644 --- a/internal/controller/reconcile_token.go +++ b/internal/controller/reconcile_token.go @@ -19,6 +19,7 @@ package controller import ( "context" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -33,6 +34,11 @@ import ( // reconcile helper can take a single receiver value. type TokenReconcilerBase struct { client.Client + // Reader is an uncached client used to resolve extraData ConfigMap/Secret + // sources live on every reconcile, rather than caching every such object + // cluster-wide. + Reader client.Reader + Recorder record.EventRecorder Metrics *metrics.Recorder Registry *ghapp.Registry } @@ -76,6 +82,8 @@ func reconcileTokenLike[T any, PT interface { options := []tm.Option{ tm.WithClient(r.Client), + tm.WithAPIReader(r.Reader), + tm.WithEventRecorder(r.Recorder), tm.WithGHApp(resolution.Client), tm.WithLogger(logger), tm.WithMetrics(r.Metrics), diff --git a/internal/controller/token_controller.go b/internal/controller/token_controller.go index cbbd520..9cb1b5a 100644 --- a/internal/controller/token_controller.go +++ b/internal/controller/token_controller.go @@ -41,6 +41,7 @@ type TokenReconciler struct { // +kubebuilder:rbac:groups=github.as-code.io,resources=apps,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. diff --git a/internal/ghapp/providers.go b/internal/ghapp/providers.go index 82a4e85..9e24d68 100644 --- a/internal/ghapp/providers.go +++ b/internal/ghapp/providers.go @@ -11,8 +11,8 @@ package ghapp // The file provider needs no import here: ghait registers it by default // unless built with ghait.no_file. import ( - _ "github.com/isometry/ghait/v84/provider/aws" - _ "github.com/isometry/ghait/v84/provider/azure" - _ "github.com/isometry/ghait/v84/provider/gcp" - _ "github.com/isometry/ghait/v84/provider/vault" + _ "github.com/isometry/ghait/v88/provider/aws" + _ "github.com/isometry/ghait/v88/provider/azure" + _ "github.com/isometry/ghait/v88/provider/gcp" + _ "github.com/isometry/ghait/v88/provider/vault" ) diff --git a/internal/ghapp/providers_test.go b/internal/ghapp/providers_test.go index 0b231a3..23e6d79 100644 --- a/internal/ghapp/providers_test.go +++ b/internal/ghapp/providers_test.go @@ -4,7 +4,7 @@ import ( "slices" "testing" - "github.com/isometry/ghait/v84/provider" + "github.com/isometry/ghait/v88/provider" ) // TestDefaultBuildRegistersAllProviders guards against the default (tagless) diff --git a/internal/ghapp/registry.go b/internal/ghapp/registry.go index 2c795c7..c655192 100644 --- a/internal/ghapp/registry.go +++ b/internal/ghapp/registry.go @@ -22,7 +22,7 @@ import ( "fmt" "sync" - "github.com/isometry/ghait/v84" + "github.com/isometry/ghait/v88" ) // Key identifies an App in the registry. The zero value is reserved for the @@ -143,7 +143,7 @@ func (r *Registry) ForApp(ctx context.Context, key Key, version string, cfg ghai } client, err := r.factory(ctx, cfg) if err != nil { - return nil, fmt.Errorf("App %s/%s: %w", key.Namespace, key.Name, err) + return nil, fmt.Errorf("build client for App %s/%s: %w", key.Namespace, key.Name, err) } r.clients[key] = cachedClient{client: client, version: version} return client, nil diff --git a/internal/ghapp/registry_test.go b/internal/ghapp/registry_test.go index 3a277f8..5ac7f75 100644 --- a/internal/ghapp/registry_test.go +++ b/internal/ghapp/registry_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - "github.com/google/go-github/v84/github" - "github.com/isometry/ghait/v84" + "github.com/google/go-github/v88/github" + "github.com/isometry/ghait/v88" ) // fakeGHAIT is a minimal implementation of [ghait.GHAIT] used only to @@ -35,7 +35,7 @@ func countingFactory() (FactoryFunc, *int) { func TestRegistry_Startup_NoConfig(t *testing.T) { r := NewRegistry("gtm-system", nil) - _, err := r.Startup(context.Background()) + _, err := r.Startup(t.Context()) if !errors.Is(err, ErrNoStartupConfig) { t.Fatalf("Startup() err = %v, want ErrNoStartupConfig", err) } @@ -46,11 +46,11 @@ func TestRegistry_Startup_CachesAcrossCalls(t *testing.T) { fac, calls := countingFactory() r := NewRegistry("gtm-system", cfg, WithFactory(fac)) - c1, err := r.Startup(context.Background()) + c1, err := r.Startup(t.Context()) if err != nil { t.Fatalf("Startup() err = %v", err) } - c2, err := r.Startup(context.Background()) + c2, err := r.Startup(t.Context()) if err != nil { t.Fatalf("Startup() 2nd call err = %v", err) } @@ -68,7 +68,7 @@ func TestRegistry_ForApp_CachesByVersion(t *testing.T) { key := Key{Namespace: "team-a", Name: "prod"} cfg := &OperatorConfig{AppID: 42, InstallationID: 7, Provider: "file", Key: "inline"} - ctx := context.Background() + ctx := t.Context() c1, err := r.ForApp(ctx, key, "1", cfg) if err != nil { @@ -96,7 +96,7 @@ func TestRegistry_ForApp_CachesByVersion(t *testing.T) { func TestRegistry_ForApp_RejectsStartupKey(t *testing.T) { r := NewRegistry("gtm-system", nil) - _, err := r.ForApp(context.Background(), StartupKey, "", &OperatorConfig{}) + _, err := r.ForApp(t.Context(), StartupKey, "", &OperatorConfig{}) if err == nil { t.Fatalf("ForApp(StartupKey) returned no error") } @@ -108,7 +108,7 @@ func TestRegistry_Invalidate_EvictsEntry(t *testing.T) { key := Key{Namespace: "team-a", Name: "prod"} cfg := &OperatorConfig{AppID: 42, Provider: "file", Key: "inline"} - ctx := context.Background() + ctx := t.Context() if _, err := r.ForApp(ctx, key, "5", cfg); err != nil { t.Fatalf("ForApp() err = %v", err) @@ -130,7 +130,7 @@ func TestRegistry_FactoryError_Propagates(t *testing.T) { return nil, sentinel }), ) - _, err := r.Startup(context.Background()) + _, err := r.Startup(t.Context()) if !errors.Is(err, sentinel) { t.Fatalf("err = %v, want to wrap %v", err, sentinel) } diff --git a/internal/metrics/recorder.go b/internal/metrics/recorder.go index deec46c..9f321ee 100644 --- a/internal/metrics/recorder.go +++ b/internal/metrics/recorder.go @@ -26,6 +26,7 @@ const ( ReasonSecretCreate = "secret_create" ReasonSecretUpdate = "secret_update" ReasonStatusUpdate = "status_update" + ReasonExtraData = "extra_data" ) // Recorder holds all custom OTEL metric instruments for the operator. diff --git a/internal/metrics/recorder_test.go b/internal/metrics/recorder_test.go index ecab57f..33cbf89 100644 --- a/internal/metrics/recorder_test.go +++ b/internal/metrics/recorder_test.go @@ -13,7 +13,7 @@ import ( func TestNilRecorderSafety(t *testing.T) { var r *Recorder - ctx := context.Background() + ctx := t.Context() // All methods must be callable on a nil receiver without panic. r.RecordTokenRefresh(ctx, "github-token", ResultSuccess) @@ -41,7 +41,7 @@ func TestRecorderInstruments(t *testing.T) { t.Fatalf("newRecorder: %v", err) } - ctx := context.Background() + ctx := t.Context() // Record some values. r.RecordTokenRefresh(ctx, "github-token", ResultSuccess) @@ -134,7 +134,7 @@ func TestActiveTokenIdempotency(t *testing.T) { t.Fatalf("newRecorder: %v", err) } - ctx := context.Background() + ctx := t.Context() // First call increments. r.EnsureTokenActive(ctx, "github-token", "default/tok-a") diff --git a/internal/metrics/setup_test.go b/internal/metrics/setup_test.go index 84e16ce..776dffd 100644 --- a/internal/metrics/setup_test.go +++ b/internal/metrics/setup_test.go @@ -17,5 +17,7 @@ func TestSetup(t *testing.T) { if rec == nil { t.Fatal("Setup returned nil recorder") } - t.Cleanup(func() { _ = rec.Shutdown(context.Background()) }) + // t.Context() is already cancelled when cleanups run; derive an + // uncancelled context for the final flush. + t.Cleanup(func() { _ = rec.Shutdown(context.WithoutCancel(t.Context())) }) } diff --git a/internal/tokenmanager/extradata.go b/internal/tokenmanager/extradata.go new file mode 100644 index 0000000..05cb973 --- /dev/null +++ b/internal/tokenmanager/extradata.go @@ -0,0 +1,170 @@ +package tokenmanager + +import ( + "context" + "fmt" + "maps" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + + githubv1 "github.com/isometry/github-token-manager/api/v1" +) + +// resolveExtraData projects spec.secret.extraData into a flat key/value map, +// in list order: inline entries copy verbatim; configMap/secret entries are +// read live (uncached) and filtered by their optional key allowlist. +// Optional refs tolerate absence: a missing object or listed key is skipped +// and reported in missing. A non-optional ref that is unreadable, or names +// an absent key, fails the whole resolution; the caller decides whether to +// retain the managed Secret's last-known-good data or block its creation. +// Keys reserved for the operator-managed credential (per GetSecretBasicAuth) +// are dropped with a Warning event; duplicate destination keys across +// sources let the later source win, also with a Warning event. +func (s *tokenSecret) resolveExtraData(ctx context.Context) (data map[string][]byte, missing []string, err error) { + reserved := reservedKeys(s.owner.GetSecretBasicAuth()) + data = make(map[string][]byte) + + for _, source := range s.owner.GetSecretDataSources() { + projected, absent, err := s.resolveSource(ctx, source) + if err != nil { + return nil, nil, err + } + missing = append(missing, absent...) + + for k, v := range projected { + if reserved.Has(k) { + s.recordWarning("ReservedKeyIgnored", "extraData key %q is reserved by the managed credential and was ignored", k) + continue + } + if _, exists := data[k]; exists { + s.recordWarning("ExtraDataKeyShadowed", "extraData key %q was overridden by a later source", k) + } + data[k] = v + } + } + + return data, missing, nil +} + +// resolveSource resolves a single extraData entry to its projected keys, +// also reporting any keys an optional ref tolerated as absent. +func (s *tokenSecret) resolveSource(ctx context.Context, source githubv1.SecretDataSource) (map[string][]byte, []string, error) { + var kind string + var ref *githubv1.SecretDataSourceRef + var read func(context.Context, *githubv1.SecretDataSourceRef) (map[string][]byte, error) + + switch { + case source.Inline != nil: + projected := make(map[string][]byte, len(source.Inline)) + for k, v := range source.Inline { + projected[k] = []byte(v) + } + return projected, nil, nil + + case source.ConfigMap != nil: + kind, ref, read = "configMap", source.ConfigMap, s.readConfigMap + + case source.Secret != nil: + kind, ref, read = "secret", source.Secret, s.readSecret + + default: + // Unreachable: CEL admission requires exactly one of inline/configMap/secret. + return nil, nil, nil + } + + desc := fmt.Sprintf("%s %s/%s", kind, ref.Namespace, ref.Name) + all, err := read(ctx, ref) + if err != nil { + if ref.Optional && apierrors.IsNotFound(err) { + return nil, []string{desc}, nil + } + return nil, nil, fmt.Errorf("required extraData source %s: %w", desc, err) + } + return applyAllowlist(desc, ref, all) +} + +// applyAllowlist narrows all down to ref.Keys, or returns all keys when the +// allowlist is empty. A listed key absent from the source is fatal unless +// ref.Optional, in which case just that key is skipped and reported in +// missing. +func applyAllowlist(desc string, ref *githubv1.SecretDataSourceRef, all map[string][]byte) (selected map[string][]byte, missing []string, err error) { + if len(ref.Keys) == 0 { + return all, nil, nil + } + + selected = make(map[string][]byte, len(ref.Keys)) + for _, k := range ref.Keys { + v, ok := all[k] + if !ok { + if !ref.Optional { + return nil, nil, fmt.Errorf("required extraData source %s: key %q not found", desc, k) + } + missing = append(missing, fmt.Sprintf("%s: %s", desc, k)) + continue + } + selected[k] = v + } + return selected, missing, nil +} + +func (s *tokenSecret) readConfigMap(ctx context.Context, ref *githubv1.SecretDataSourceRef) (map[string][]byte, error) { + cm := &corev1.ConfigMap{} + key := types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name} + if err := s.reader.Get(ctx, key, cm); err != nil { + return nil, err + } + all := make(map[string][]byte, len(cm.Data)+len(cm.BinaryData)) + for k, v := range cm.Data { + all[k] = []byte(v) + } + maps.Copy(all, cm.BinaryData) + return all, nil +} + +func (s *tokenSecret) readSecret(ctx context.Context, ref *githubv1.SecretDataSourceRef) (map[string][]byte, error) { + secret := &corev1.Secret{} + key := types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name} + if err := s.reader.Get(ctx, key, secret); err != nil { + return nil, err + } + all := make(map[string][]byte, len(secret.Data)) + maps.Copy(all, secret.Data) + return all, nil +} + +// lastKnownGoodExtraData recovers the extraData most recently projected into +// the managed Secret: everything in its Data except the operator-managed +// credential keys. Used to retain the projection while a required source is +// unresolvable, so a valid credential is never sacrificed to an auxiliary +// failure. +func lastKnownGoodExtraData(data map[string][]byte, basicAuth bool) map[string][]byte { + reserved := reservedKeys(basicAuth) + retained := make(map[string][]byte, len(data)) + for k, v := range data { + if !reserved.Has(k) { + retained[k] = v + } + } + return retained +} + +// reservedKeys returns the set of Secret data keys the operator manages +// itself for the given basicAuth mode; extraData may never set them. +func reservedKeys(basicAuth bool) sets.Set[string] { + if basicAuth { + return sets.New("username", "password") + } + return sets.New("token") +} + +// recordWarning emits a Warning event against the owner, when a recorder is +// configured (it is nil-safe so tests may omit it). +func (s *tokenSecret) recordWarning(reason, messageFmt string, args ...any) { + if s.recorder == nil { + return + } + s.recorder.Eventf(s.owner, corev1.EventTypeWarning, reason, messageFmt, args...) +} diff --git a/internal/tokenmanager/extradata_test.go b/internal/tokenmanager/extradata_test.go new file mode 100644 index 0000000..31d0e73 --- /dev/null +++ b/internal/tokenmanager/extradata_test.go @@ -0,0 +1,287 @@ +package tokenmanager + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + githubv1 "github.com/isometry/github-token-manager/api/v1" +) + +func newFakeReader(objs ...runtime.Object) *fake.ClientBuilder { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + return fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...) +} + +// newTestOwner builds a Token in namespace "ns"; its extraData refs resolve +// against that namespace (Tokens may only reference same-namespace sources). +func newTestOwner(basicAuth bool, sources ...githubv1.LocalSecretDataSource) TokenManager { + return &githubv1.Token{ + ObjectMeta: metav1.ObjectMeta{Name: "test-token", Namespace: "ns"}, + Spec: githubv1.TokenSpec{ + Secret: githubv1.TokenSecretSpec{ + BasicAuth: basicAuth, + ExtraData: sources, + }, + }, + } +} + +func TestResolveExtraData_Inline(t *testing.T) { + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + Inline: map[string]string{"ca.crt": "PEM"}, + }) + s := &tokenSecret{owner: owner} + + got, missing, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + if len(missing) != 0 { + t.Errorf("missing = %v, want none", missing) + } + if string(got["ca.crt"]) != "PEM" { + t.Errorf("got %v, want ca.crt=PEM", got) + } +} + +func TestResolveExtraData_ConfigMap_AllKeys(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "ca-bundle", Namespace: "ns"}, + Data: map[string]string{"ca.crt": "PEM", "other.txt": "ignored-not"}, + } + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "ca-bundle"}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build()} + + got, _, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + if string(got["ca.crt"]) != "PEM" || string(got["other.txt"]) != "ignored-not" { + t.Errorf("got %v, want all configMap keys projected", got) + } +} + +func TestResolveExtraData_ConfigMap_Allowlist(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "ca-bundle", Namespace: "ns"}, + Data: map[string]string{"ca.crt": "PEM", "other.txt": "excluded"}, + } + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "ca-bundle", Keys: []string{"ca.crt"}}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build()} + + got, _, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + if len(got) != 1 || string(got["ca.crt"]) != "PEM" { + t.Errorf("got %v, want only allowlisted ca.crt=PEM", got) + } +} + +func TestResolveExtraData_Secret_AllKeys(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "ca-key", Namespace: "ns"}, + Data: map[string][]byte{"tls.key": []byte("KEY")}, + } + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + Secret: &githubv1.LocalSecretDataSourceRef{Name: "ca-key"}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(secret).Build()} + + got, _, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + if string(got["tls.key"]) != "KEY" { + t.Errorf("got %v, want tls.key=KEY", got) + } +} + +func TestResolveExtraData_OptionalMissingSource_SkipsAndReports(t *testing.T) { + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "missing", Optional: true}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader().Build(), recorder: record.NewFakeRecorder(10)} + + got, missing, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v, want nil (optional source skipped)", err) + } + if len(got) != 0 { + t.Errorf("got %v, want empty map", got) + } + if len(missing) != 1 || missing[0] != "configMap ns/missing" { + t.Errorf("missing = %v, want the absent optional source reported", missing) + } +} + +func TestResolveExtraData_RequiredMissingSource_Fails(t *testing.T) { + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "missing"}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader().Build()} + + _, _, err := s.resolveExtraData(t.Context()) + if err == nil { + t.Fatal("resolveExtraData() error = nil, want required-source error") + } + if !strings.Contains(err.Error(), "required extraData source configMap ns/missing") { + t.Errorf("resolveExtraData() error = %v, want it to identify the required source", err) + } +} + +func TestResolveExtraData_RequiredAllowlistKeyMissing_Fails(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "ca-bundle", Namespace: "ns"}, + Data: map[string]string{"ca.crt": "PEM"}, + } + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "ca-bundle", Keys: []string{"missing.key"}}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build()} + + _, _, err := s.resolveExtraData(t.Context()) + if err == nil { + t.Fatal("resolveExtraData() error = nil, want required-key error") + } + if !strings.Contains(err.Error(), `key "missing.key" not found`) { + t.Errorf("resolveExtraData() error = %v, want it to identify the missing key", err) + } +} + +func TestResolveExtraData_OptionalAllowlistKeyMissing_SkipsPerKey(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "ca-bundle", Namespace: "ns"}, + Data: map[string]string{"ca.crt": "PEM"}, + } + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "ca-bundle", Keys: []string{"ca.crt", "missing.key"}, Optional: true}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build(), recorder: record.NewFakeRecorder(10)} + + got, missing, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v, want nil (optional key skipped)", err) + } + if len(got) != 1 || string(got["ca.crt"]) != "PEM" { + t.Errorf("got %v, want present key ca.crt=PEM projected despite the absent sibling", got) + } + if len(missing) != 1 || missing[0] != "configMap ns/ca-bundle: missing.key" { + t.Errorf("missing = %v, want just the absent key reported", missing) + } +} + +func TestResolveExtraData_ReservedKeyDropped_EmitsWarningEvent(t *testing.T) { + owner := newTestOwner(false, githubv1.LocalSecretDataSource{ + ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "malicious"}, + }) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "malicious", Namespace: "ns"}, + Data: map[string]string{"token": "spoofed", "ca.crt": "PEM"}, + } + rec := record.NewFakeRecorder(10) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build(), recorder: rec} + + got, _, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + if _, exists := got["token"]; exists { + t.Errorf("got %v, want reserved key 'token' dropped", got) + } + if string(got["ca.crt"]) != "PEM" { + t.Errorf("got %v, want ca.crt=PEM to survive", got) + } + assertWarningEventEmitted(t, rec) +} + +func TestResolveExtraData_DuplicateKey_LastWins_EmitsWarningEvent(t *testing.T) { + owner := newTestOwner(false, + githubv1.LocalSecretDataSource{Inline: map[string]string{"ca.crt": "first"}}, + githubv1.LocalSecretDataSource{Inline: map[string]string{"ca.crt": "second"}}, + ) + rec := record.NewFakeRecorder(10) + s := &tokenSecret{owner: owner, recorder: rec} + + got, _, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + if string(got["ca.crt"]) != "second" { + t.Errorf("got ca.crt=%q, want last-listed value 'second' to win", got["ca.crt"]) + } + assertWarningEventEmitted(t, rec) +} + +func TestResolveExtraData_BasicAuthReservedKeysDropped(t *testing.T) { + owner := newTestOwner(true, githubv1.LocalSecretDataSource{ + Inline: map[string]string{"username": "spoofed", "password": "spoofed", "ca.crt": "PEM"}, + }) + rec := record.NewFakeRecorder(10) + s := &tokenSecret{owner: owner, recorder: rec} + + got, _, err := s.resolveExtraData(t.Context()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + if _, exists := got["username"]; exists { + t.Errorf("got %v, want reserved key 'username' dropped under basicAuth", got) + } + if _, exists := got["password"]; exists { + t.Errorf("got %v, want reserved key 'password' dropped under basicAuth", got) + } + if string(got["ca.crt"]) != "PEM" { + t.Errorf("got %v, want ca.crt=PEM to survive", got) + } +} + +func TestLastKnownGoodExtraData(t *testing.T) { + data := map[string][]byte{ + "token": []byte("ghs_live"), + "username": []byte("x-access-token"), + "password": []byte("ghs_live"), + "ca.crt": []byte("PEM"), + } + + got := lastKnownGoodExtraData(data, false) + if _, exists := got["token"]; exists { + t.Errorf("got %v, want credential key 'token' excluded", got) + } + if string(got["ca.crt"]) != "PEM" || string(got["username"]) != "x-access-token" { + t.Errorf("got %v, want non-reserved keys retained", got) + } + + got = lastKnownGoodExtraData(data, true) + if _, exists := got["username"]; exists { + t.Errorf("got %v, want credential key 'username' excluded under basicAuth", got) + } + if _, exists := got["password"]; exists { + t.Errorf("got %v, want credential key 'password' excluded under basicAuth", got) + } + if string(got["token"]) != "ghs_live" { + t.Errorf("got %v, want 'token' retained under basicAuth (not reserved there)", got) + } +} + +func assertWarningEventEmitted(t *testing.T, rec *record.FakeRecorder) { + t.Helper() + select { + case e := <-rec.Events: + if e == "" { + t.Error("expected a non-empty Warning event") + } + default: + t.Error("expected a Warning event to be recorded, got none") + } +} diff --git a/internal/tokenmanager/token_manager.go b/internal/tokenmanager/token_manager.go index e105117..ccba7a1 100644 --- a/internal/tokenmanager/token_manager.go +++ b/internal/tokenmanager/token_manager.go @@ -3,7 +3,7 @@ package tokenmanager import ( "time" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -17,6 +17,7 @@ type TokenManager interface { GetType() string GetAppRef() *githubv1.AppReference GetSecretBasicAuth() bool + GetSecretDataSources() []githubv1.SecretDataSource GetInstallationID() int64 GetRefreshInterval() time.Duration GetRetryInterval() time.Duration @@ -31,4 +32,5 @@ type TokenManager interface { SetStatusTimestamps(expiresAt time.Time) GetStatusConditions() []metav1.Condition SetStatusCondition(condition metav1.Condition) (changed bool) + RemoveStatusCondition(conditionType string) (changed bool) } diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 014b94c..4dc3d98 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -3,21 +3,24 @@ package tokenmanager import ( "context" "errors" + "fmt" "maps" + "strings" "time" "github.com/go-logr/logr" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/isometry/ghait/v84" + "github.com/isometry/ghait/v88" githubv1 "github.com/isometry/github-token-manager/api/v1" "github.com/isometry/github-token-manager/internal/metrics" ) @@ -31,6 +34,8 @@ const ( type tokenSecret struct { log logr.Logger client client.Client + reader client.Reader + recorder record.EventRecorder key types.NamespacedName owner TokenManager controllerName string @@ -47,6 +52,23 @@ func WithClient(c client.Client) Option { } } +// WithAPIReader supplies an uncached reader used to resolve extraData +// ConfigMap/Secret sources live on every reconcile, rather than starting a +// cluster-wide watch/cache for objects the operator otherwise never touches. +func WithAPIReader(r client.Reader) Option { + return func(s *tokenSecret) { + s.reader = r + } +} + +// WithEventRecorder supplies the recorder used to surface non-fatal +// extraData issues (reserved/shadowed keys) as Warning events on the owner. +func WithEventRecorder(r record.EventRecorder) Option { + return func(s *tokenSecret) { + s.recorder = r + } +} + func WithGHApp(g ghait.GHAIT) Option { return func(s *tokenSecret) { s.ghait = g @@ -122,10 +144,65 @@ func (s *tokenSecret) Reconcile(ctx context.Context) (result reconcile.Result, e log.Error(err, "failed to get secret") return result, err } + secretNotFound := apierrors.IsNotFound(err) + + extraData, missing, resolveErr := s.resolveExtraData(ctx) + // degraded is the abnormal-true ExtraDataDegraded condition to surface; + // nil means extraData resolved cleanly (or none is configured) and any + // stale condition is removed on the next status write. + var degraded *metav1.Condition + switch { + case resolveErr != nil: + degraded = &metav1.Condition{ + Type: githubv1.ConditionTypeExtraDataDegraded, + Status: metav1.ConditionTrue, + Reason: githubv1.ReasonSourceUnavailable, + Message: resolveErr.Error(), + } + s.metrics.RecordReconcileError(ctx, s.controllerName, metrics.ReasonExtraData) + + if secretNotFound { + // Fail closed on creation only: there is no last-known-good + // projection yet, and a partial Secret would mislead consumers. + log.Info("extraData source unavailable, deferring secret creation", "reason", resolveErr.Error()) + ready := metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: githubv1.ReasonSourceUnavailable, + Message: resolveErr.Error(), + } + if statusErr := s.UpdateTokenStatus(ctx, func() bool { + changed := s.owner.SetStatusCondition(ready) + if s.applyExtraDataDegraded(degraded) { + changed = true + } + return changed + }); statusErr != nil { + log.Error(statusErr, "failed to update token status") + return result, statusErr + } + return reconcile.Result{RequeueAfter: s.owner.GetRetryInterval()}, nil + } + + // The Secret already exists: token validity outranks auxiliary data, + // so keep the credential fresh and retain the last-known-good + // extraData until the source resolves again. + log.Info("extraData source unavailable, retaining last-known-good extraData", "reason", resolveErr.Error()) + s.recordWarning("ExtraDataSourceUnavailable", "retaining last-known-good extraData: %v", resolveErr) + extraData = lastKnownGoodExtraData(secret.Data, s.owner.GetSecretBasicAuth()) + + case len(missing) > 0: + degraded = &metav1.Condition{ + Type: githubv1.ConditionTypeExtraDataDegraded, + Status: metav1.ConditionTrue, + Reason: githubv1.ReasonKeysMissing, + Message: fmt.Sprintf("optional extraData keys missing: %s", strings.Join(missing, ", ")), + } + } - if apierrors.IsNotFound(err) { + if secretNotFound { start := time.Now() - if err := s.CreateSecret(ctx); err != nil { + if err := s.CreateSecret(ctx, extraData, degraded); err != nil { s.metrics.RecordTokenRefresh(ctx, s.controllerName, metrics.ResultError) s.metrics.RecordTokenRefreshDuration(ctx, s.controllerName, metrics.OperationCreate, time.Since(start)) if errors.Is(err, ghait.TransientError{}) { @@ -155,7 +232,9 @@ func (s *tokenSecret) Reconcile(ctx context.Context) (result reconcile.Result, e Reason: "Failed", Message: "Secret already exists", } - if err := s.UpdateTokenStatus(ctx, &condition, nil, false); err != nil { + if err := s.UpdateTokenStatus(ctx, func() bool { + return s.owner.SetStatusCondition(condition) + }); err != nil { log.Error(err, "failed to update token status") return result, err } @@ -168,7 +247,7 @@ func (s *tokenSecret) Reconcile(ctx context.Context) (result reconcile.Result, e s.Secret = secret start := time.Now() - if err := s.UpdateSecret(ctx); err != nil { + if err := s.UpdateSecret(ctx, extraData, degraded); err != nil { s.metrics.RecordTokenRefresh(ctx, s.controllerName, metrics.ResultError) s.metrics.RecordTokenRefreshDuration(ctx, s.controllerName, metrics.OperationUpdate, time.Since(start)) if errors.Is(err, ghait.TransientError{}) { @@ -188,10 +267,15 @@ func (s *tokenSecret) Reconcile(ctx context.Context) (result reconcile.Result, e s.metrics.EnsureTokenActive(ctx, s.controllerName, s.key.String()) s.recordExpiry(ctx) + if resolveErr != nil { + // Serving last-known-good extraData: re-resolve the failed source + // promptly rather than waiting out a full refresh cycle. + return reconcile.Result{RequeueAfter: s.owner.GetRetryInterval()}, nil + } return reconcile.Result{RequeueAfter: s.owner.GetRefreshInterval()}, nil } -func (s *tokenSecret) CreateSecret(ctx context.Context) error { +func (s *tokenSecret) CreateSecret(ctx context.Context, extraData map[string][]byte, degraded *metav1.Condition) error { log := s.log.WithValues("func", "CreateSecret") log.Info("creating secret") @@ -214,7 +298,7 @@ func (s *tokenSecret) CreateSecret(ctx context.Context) error { Labels: s.SecretLabels(), Annotations: s.owner.GetSecretAnnotations(), }, - Data: s.SecretData(installationToken.GetToken()), + Data: s.SecretData(installationToken.GetToken(), extraData), Type: secretType, } @@ -239,7 +323,7 @@ func (s *tokenSecret) CreateSecret(ctx context.Context) error { Message: "Created Secret", } expiresAt := installationToken.ExpiresAt.Time - if err := s.UpdateTokenStatus(ctx, &condition, &expiresAt, true); err != nil { + if err := s.UpdateTokenStatus(ctx, s.refreshStatusMutation(condition, degraded, expiresAt)); err != nil { log.Error(err, "failed to update token status") return err } @@ -247,7 +331,7 @@ func (s *tokenSecret) CreateSecret(ctx context.Context) error { return nil } -func (s *tokenSecret) UpdateSecret(ctx context.Context) error { +func (s *tokenSecret) UpdateSecret(ctx context.Context, extraData map[string][]byte, degraded *metav1.Condition) error { log := s.log.WithValues("func", "UpdateSecret") log.Info("updating secret") @@ -258,7 +342,7 @@ func (s *tokenSecret) UpdateSecret(ctx context.Context) error { return err } - s.Data = s.SecretData(installationToken.GetToken()) + s.Data = s.SecretData(installationToken.GetToken(), extraData) if err := s.client.Update(ctx, s.Secret); err != nil { log.Error(err, "failed to update secret") @@ -273,7 +357,7 @@ func (s *tokenSecret) UpdateSecret(ctx context.Context) error { Message: "Updated Secret", } expiresAt := installationToken.ExpiresAt.Time - if err := s.UpdateTokenStatus(ctx, &condition, &expiresAt, true); err != nil { + if err := s.UpdateTokenStatus(ctx, s.refreshStatusMutation(condition, degraded, expiresAt)); err != nil { log.Error(err, "failed to update token status") return err } @@ -315,7 +399,9 @@ func (s *tokenSecret) DeleteSecret(ctx context.Context, key types.NamespacedName Reason: "Reconciling", Message: "Deleted old Secret", } - if err := s.UpdateTokenStatus(ctx, &condition, nil, false); err != nil { + if err := s.UpdateTokenStatus(ctx, func() bool { + return s.owner.SetStatusCondition(condition) + }); err != nil { log.Error(err, "failed to update token status") return err } @@ -323,31 +409,16 @@ func (s *tokenSecret) DeleteSecret(ctx context.Context, key types.NamespacedName return nil } -// UpdateTokenStatus refreshes the owner, applies the given mutations, and -// writes status if anything changed, retrying on conflict. Pass nil for -// condition or expiresAt to leave them untouched; updateManaged toggles the -// ManagedSecret refresh. -func (s *tokenSecret) UpdateTokenStatus(ctx context.Context, condition *metav1.Condition, expiresAt *time.Time, updateManaged bool) error { +// UpdateTokenStatus refreshes the owner, applies mutate to its status, and +// writes the result when mutate reports a change, retrying on conflict. +func (s *tokenSecret) UpdateTokenStatus(ctx context.Context, mutate func() bool) error { log := s.log.WithValues("func", "UpdateTokenStatus") err := retry.RetryOnConflict(retry.DefaultRetry, func() error { if err := s.RefreshOwner(ctx); err != nil { return err } - - var changed bool - if condition != nil && s.owner.SetStatusCondition(*condition) { - changed = true - } - if expiresAt != nil { - s.owner.SetStatusTimestamps(*expiresAt) - changed = true - } - if updateManaged && s.owner.UpdateManagedSecret() { - changed = true - } - - if !changed { + if !mutate() { return nil } return s.client.Status().Update(ctx, s.owner) @@ -359,6 +430,30 @@ func (s *tokenSecret) UpdateTokenStatus(ctx context.Context, condition *metav1.C return nil } +// refreshStatusMutation is the status mutation shared by the create/update +// success paths: Ready, the ExtraDataDegraded set-or-clear, fresh token +// timestamps, and the ManagedSecret record. Always reports a change (the +// timestamps move on every refresh). +func (s *tokenSecret) refreshStatusMutation(ready metav1.Condition, degraded *metav1.Condition, expiresAt time.Time) func() bool { + return func() bool { + s.owner.SetStatusCondition(ready) + s.applyExtraDataDegraded(degraded) + s.owner.SetStatusTimestamps(expiresAt) + s.owner.UpdateManagedSecret() + return true + } +} + +// applyExtraDataDegraded sets the abnormal-true ExtraDataDegraded condition, +// or removes it when degraded is nil (extraData resolved cleanly, or none is +// configured). +func (s *tokenSecret) applyExtraDataDegraded(degraded *metav1.Condition) bool { + if degraded != nil { + return s.owner.SetStatusCondition(*degraded) + } + return s.owner.RemoveStatusCondition(githubv1.ConditionTypeExtraDataDegraded) +} + func (s *tokenSecret) SecretLabels() map[string]string { secretLabels := map[string]string{ "app.kubernetes.io/name": s.owner.GetType(), @@ -370,14 +465,19 @@ func (s *tokenSecret) SecretLabels() map[string]string { return secretLabels } -func (s *tokenSecret) SecretData(installationToken string) map[string][]byte { +// SecretData builds the final Secret payload from the resolved extraData +// plus the operator-managed credential keys, which always win over any +// extraData overlap. +func (s *tokenSecret) SecretData(installationToken string, extraData map[string][]byte) map[string][]byte { + data := make(map[string][]byte, len(extraData)+2) + maps.Copy(data, extraData) + if s.owner.GetSecretBasicAuth() { - return map[string][]byte{ - "username": []byte(BasicAuthUsername), - "password": []byte(installationToken), - } - } - return map[string][]byte{ - "token": []byte(installationToken), + data["username"] = []byte(BasicAuthUsername) + data["password"] = []byte(installationToken) + } else { + data["token"] = []byte(installationToken) } + + return data } diff --git a/internal/tokenmanager/token_secret_test.go b/internal/tokenmanager/token_secret_test.go new file mode 100644 index 0000000..dc27763 --- /dev/null +++ b/internal/tokenmanager/token_secret_test.go @@ -0,0 +1,343 @@ +package tokenmanager + +import ( + "context" + "errors" + "maps" + "slices" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/google/go-github/v88/github" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + githubv1 "github.com/isometry/github-token-manager/api/v1" +) + +func TestSecretData(t *testing.T) { + const token = "ghs_installationtoken" + + tests := []struct { + name string + basicAuth bool + extraData map[string][]byte + want map[string]string + }{ + { + name: "token only", + want: map[string]string{"token": token}, + }, + { + name: "basic auth only", + basicAuth: true, + want: map[string]string{"username": BasicAuthUsername, "password": token}, + }, + { + name: "token with resolved extraData", + extraData: map[string][]byte{"ca.crt": []byte("PEM")}, + want: map[string]string{"token": token, "ca.crt": "PEM"}, + }, + { + name: "basic auth with resolved extraData", + basicAuth: true, + extraData: map[string][]byte{"ca.crt": []byte("PEM")}, + want: map[string]string{"username": BasicAuthUsername, "password": token, "ca.crt": "PEM"}, + }, + { + name: "managed token key wins over resolved extraData", + extraData: map[string][]byte{"token": []byte("spoofed")}, + want: map[string]string{"token": token}, + }, + { + name: "managed basic-auth keys win over resolved extraData", + basicAuth: true, + extraData: map[string][]byte{"username": []byte("spoofed"), "password": []byte("spoofed")}, + want: map[string]string{"username": BasicAuthUsername, "password": token}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := &githubv1.Token{ + Spec: githubv1.TokenSpec{ + Secret: githubv1.TokenSecretSpec{ + BasicAuth: tt.basicAuth, + }, + }, + } + s := &tokenSecret{owner: owner} + + got := s.SecretData(token, tt.extraData) + + if len(got) != len(tt.want) { + t.Fatalf("got %d keys %v, want %d keys %v", len(got), slices.Collect(maps.Keys(got)), len(tt.want), slices.Collect(maps.Keys(tt.want))) + } + for k, want := range tt.want { + if string(got[k]) != want { + t.Errorf("key %q: got %q, want %q", k, string(got[k]), want) + } + } + }) + } +} + +// fakeGHAIT mints a fixed fresh token so Reconcile-level tests can run +// without GitHub. +type fakeGHAIT struct{} + +func (fakeGHAIT) GetAppID() int64 { return 1 } +func (fakeGHAIT) GetInstallationID() int64 { return 1 } + +func (fakeGHAIT) NewInstallationToken(context.Context, int64, *github.InstallationTokenOptions) (*github.InstallationToken, error) { + return &github.InstallationToken{ + Token: new("ghs_fresh"), + ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour)}, + }, nil +} + +func (g fakeGHAIT) NewToken(ctx context.Context) (*github.InstallationToken, error) { + return g.NewInstallationToken(ctx, 0, nil) +} + +func (g fakeGHAIT) NewTokenWithOptions(ctx context.Context, options *github.InstallationTokenOptions) (*github.InstallationToken, error) { + return g.NewInstallationToken(ctx, 0, options) +} + +func newReconcileScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := githubv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + return scheme +} + +func newReconcileToken() *githubv1.Token { + return &githubv1.Token{ + ObjectMeta: metav1.ObjectMeta{Name: "test-token", Namespace: "ns", UID: "token-uid"}, + Spec: githubv1.TokenSpec{ + RefreshInterval: metav1.Duration{Duration: 30 * time.Minute}, + RetryInterval: metav1.Duration{Duration: 5 * time.Minute}, + Secret: githubv1.TokenSecretSpec{ + ExtraData: []githubv1.LocalSecretDataSource{ + {ConfigMap: &githubv1.LocalSecretDataSourceRef{Name: "ca-bundle"}}, + }, + }, + }, + } +} + +// managedSecretFor returns a Secret as previously created for token, holding +// a stale credential and last-good extraData. +func managedSecretFor(token *githubv1.Token) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: token.Name, + Namespace: token.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: githubv1.GroupVersion.String(), + Kind: "Token", + Name: token.Name, + UID: token.UID, + Controller: new(true), + }}, + }, + Type: SecretTypeToken, + Data: map[string][]byte{ + "token": []byte("ghs_stale"), + "ca.crt": []byte("LAST-GOOD-PEM"), + }, + } +} + +func newReconcileTokenSecret(token *githubv1.Token, c client.Client, reader client.Reader) *tokenSecret { + return NewTokenSecret( + types.NamespacedName{Namespace: token.Namespace, Name: token.Name}, + token, + "token", + WithClient(c), + WithAPIReader(reader), + WithEventRecorder(record.NewFakeRecorder(10)), + WithGHApp(fakeGHAIT{}), + WithLogger(logr.Discard()), + ) +} + +// TestReconcile_RetainsExtraDataWhenSourceUnavailable covers the retention +// model: a required source that cannot be resolved (whether deleted or hit +// by a transient read error) must never destroy the managed Secret — the +// token is refreshed, the last-known-good extraData is retained, and the +// failure is surfaced via the abnormal-true ExtraDataDegraded condition. +func TestReconcile_RetainsExtraDataWhenSourceUnavailable(t *testing.T) { + tests := []struct { + name string + readerErr error // nil means the source object is genuinely absent + wantContains string + }{ + {name: "source deleted (NotFound)"}, + {name: "transient read error", readerErr: errors.New("apiserver timeout")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := newReconcileScheme(t) + token := newReconcileToken() + secret := managedSecretFor(token) + + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(token, secret). + WithStatusSubresource(&githubv1.Token{}). + Build() + + readerBuilder := fake.NewClientBuilder().WithScheme(scheme) + if tt.readerErr != nil { + readerBuilder = readerBuilder.WithInterceptorFuncs(interceptor.Funcs{ + Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error { + return tt.readerErr + }, + }) + } + + s := newReconcileTokenSecret(token, c, readerBuilder.Build()) + + result, err := s.Reconcile(t.Context()) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.RequeueAfter != token.GetRetryInterval() { + t.Errorf("RequeueAfter = %v, want RetryInterval %v for prompt re-resolution", result.RequeueAfter, token.GetRetryInterval()) + } + + got := &corev1.Secret{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(secret), got); err != nil { + t.Fatalf("managed Secret must survive an unresolvable source: %v", err) + } + if string(got.Data["token"]) != "ghs_fresh" { + t.Errorf("token = %q, want refreshed 'ghs_fresh' despite the source failure", got.Data["token"]) + } + if string(got.Data["ca.crt"]) != "LAST-GOOD-PEM" { + t.Errorf("ca.crt = %q, want last-known-good value retained", got.Data["ca.crt"]) + } + + refreshed := &githubv1.Token{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(token), refreshed); err != nil { + t.Fatal(err) + } + ready := meta.FindStatusCondition(refreshed.Status.Conditions, githubv1.ConditionTypeReady) + if ready == nil || ready.Status != metav1.ConditionTrue { + t.Errorf("Ready = %+v, want True (credential is valid)", ready) + } + degraded := meta.FindStatusCondition(refreshed.Status.Conditions, githubv1.ConditionTypeExtraDataDegraded) + if degraded == nil || degraded.Status != metav1.ConditionTrue || degraded.Reason != githubv1.ReasonSourceUnavailable { + t.Errorf("ExtraDataDegraded = %+v, want True/SourceUnavailable", degraded) + } + }) + } +} + +// TestReconcile_BlocksCreationWhenSourceUnavailable covers the fail-closed +// creation case: with no last-known-good projection, a partial Secret must +// not be created. +func TestReconcile_BlocksCreationWhenSourceUnavailable(t *testing.T) { + scheme := newReconcileScheme(t) + token := newReconcileToken() + + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(token). + WithStatusSubresource(&githubv1.Token{}). + Build() + + s := newReconcileTokenSecret(token, c, fake.NewClientBuilder().WithScheme(scheme).Build()) + + result, err := s.Reconcile(t.Context()) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.RequeueAfter != token.GetRetryInterval() { + t.Errorf("RequeueAfter = %v, want RetryInterval %v", result.RequeueAfter, token.GetRetryInterval()) + } + + got := &corev1.Secret{} + err = c.Get(t.Context(), types.NamespacedName{Namespace: token.Namespace, Name: token.Name}, got) + if err == nil { + t.Fatalf("managed Secret was created despite an unresolvable required source: %v", got.Data) + } + + refreshed := &githubv1.Token{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(token), refreshed); err != nil { + t.Fatal(err) + } + ready := meta.FindStatusCondition(refreshed.Status.Conditions, githubv1.ConditionTypeReady) + if ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != githubv1.ReasonSourceUnavailable { + t.Errorf("Ready = %+v, want False/SourceUnavailable", ready) + } + degraded := meta.FindStatusCondition(refreshed.Status.Conditions, githubv1.ConditionTypeExtraDataDegraded) + if degraded == nil || degraded.Status != metav1.ConditionTrue || degraded.Reason != githubv1.ReasonSourceUnavailable { + t.Errorf("ExtraDataDegraded = %+v, want True/SourceUnavailable", degraded) + } +} + +// TestReconcile_ResolvedExtraDataReplacesLastGood covers the recovery path: +// once the source resolves again, its fresh content replaces the retained +// values and the abnormal-true ExtraDataDegraded condition is removed. +func TestReconcile_ResolvedExtraDataReplacesLastGood(t *testing.T) { + scheme := newReconcileScheme(t) + token := newReconcileToken() + // Seed the degraded condition from a prior failed resolution so the test + // proves recovery removes it. + token.Status.Conditions = []metav1.Condition{{ + Type: githubv1.ConditionTypeExtraDataDegraded, + Status: metav1.ConditionTrue, + Reason: githubv1.ReasonSourceUnavailable, + Message: "seeded", + LastTransitionTime: metav1.Now(), + }} + secret := managedSecretFor(token) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "ca-bundle", Namespace: "ns"}, + Data: map[string]string{"ca.crt": "FRESH-PEM"}, + } + + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(token, secret). + WithStatusSubresource(&githubv1.Token{}). + Build() + + s := newReconcileTokenSecret(token, c, fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build()) + + result, err := s.Reconcile(t.Context()) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.RequeueAfter != token.GetRefreshInterval() { + t.Errorf("RequeueAfter = %v, want RefreshInterval %v", result.RequeueAfter, token.GetRefreshInterval()) + } + + got := &corev1.Secret{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(secret), got); err != nil { + t.Fatal(err) + } + if string(got.Data["ca.crt"]) != "FRESH-PEM" { + t.Errorf("ca.crt = %q, want freshly resolved 'FRESH-PEM'", got.Data["ca.crt"]) + } + + refreshed := &githubv1.Token{} + if err := c.Get(t.Context(), client.ObjectKeyFromObject(token), refreshed); err != nil { + t.Fatal(err) + } + if degraded := meta.FindStatusCondition(refreshed.Status.Conditions, githubv1.ConditionTypeExtraDataDegraded); degraded != nil { + t.Errorf("ExtraDataDegraded = %+v, want the condition removed after clean resolution", degraded) + } +} diff --git a/test/e2e/e2e_helpers_test.go b/test/e2e/e2e_helpers_test.go index cb0ce81..b168cc6 100644 --- a/test/e2e/e2e_helpers_test.go +++ b/test/e2e/e2e_helpers_test.go @@ -36,10 +36,9 @@ import ( "strings" "time" - "github.com/google/go-github/v84/github" + "github.com/google/go-github/v88/github" "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" //nolint:staticcheck - "golang.org/x/oauth2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -88,9 +87,9 @@ func (c *clientContext) waitForTokenReconciliation(name, namespace string) { Namespace: namespace, }, tokenObj), ).NotTo(HaveOccurred()) - g.Expect(tokenObj.Status.Conditions).To(HaveLen(1)) - g.Expect(tokenObj.Status.Conditions[0].Type).To(Equal(gtmv1.ConditionTypeReady)) - g.Expect(tokenObj.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + ready := meta.FindStatusCondition(tokenObj.Status.Conditions, gtmv1.ConditionTypeReady) + g.Expect(ready).NotTo(BeNil()) + g.Expect(ready.Status).To(Equal(metav1.ConditionTrue)) }).Within(reconciliationTimeout).Should(Succeed()) } @@ -103,9 +102,9 @@ func (c *clientContext) waitForClusterTokenReconciliation(name string) { Name: name, }, clusterTokenObj), ).NotTo(HaveOccurred()) - g.Expect(clusterTokenObj.Status.Conditions).To(HaveLen(1)) - g.Expect(clusterTokenObj.Status.Conditions[0].Type).To(Equal(gtmv1.ConditionTypeReady)) - g.Expect(clusterTokenObj.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + ready := meta.FindStatusCondition(clusterTokenObj.Status.Conditions, gtmv1.ConditionTypeReady) + g.Expect(ready).NotTo(BeNil()) + g.Expect(ready.Status).To(Equal(metav1.ConditionTrue)) }).Within(reconciliationTimeout).Should(Succeed()) } @@ -125,6 +124,28 @@ func (c *clientContext) waitForAppReconciliation(name, namespace string) { }).Within(reconciliationTimeout).Should(Succeed()) } +// waitForTokenCondition waits for a Token condition of the given type to +// reach the given status and reason. Unlike waitForTokenReconciliation +// (which hard-asserts Ready=True), this also covers not-ready and degraded +// states such as an unresolvable extraData source. The window allows for a +// full refresh cycle, since extraData sources are only re-read on the +// refresh/retry cadence. +func (c *clientContext) waitForTokenCondition(name, namespace, conditionType string, status metav1.ConditionStatus, reason string) { + Eventually(func(g Gomega) { + tokenObj := >mv1.Token{} + g.Expect( + c.client.Get(c.context, client.ObjectKey{ + Name: name, + Namespace: namespace, + }, tokenObj), + ).NotTo(HaveOccurred()) + condition := meta.FindStatusCondition(tokenObj.Status.Conditions, conditionType) + g.Expect(condition).NotTo(BeNil()) + g.Expect(condition.Status).To(Equal(status)) + g.Expect(condition.Reason).To(Equal(reason)) + }).Within(reconciliationTimeout + tokenRefreshInterval).Should(Succeed()) +} + // checkManagedSecret waits for a secret to be created and returns its initial token value func (c *clientContext) checkManagedSecret( name, namespace string, //nolint:unparam @@ -159,6 +180,37 @@ func (c *clientContext) checkManagedSecret( return secretValue } +// getSecret fetches and returns a Secret by name, failing the spec if it +// cannot be retrieved. Use this (rather than checkManagedSecret) to assert +// arbitrary extraData keys that aren't part of the fixed credential shape. +func (c *clientContext) getSecret(name, namespace string) *corev1.Secret { + secret := &corev1.Secret{} + Expect( + c.client.Get(c.context, client.ObjectKey{ + Name: name, + Namespace: namespace, + }, secret), + ).NotTo(HaveOccurred()) + return secret +} + +// waitForWarningEvent waits for a Warning Event with the given reason to be +// recorded against the named object in namespace. +func (c *clientContext) waitForWarningEvent(objName, namespace, reason string) { + Eventually(func(g Gomega) { + events := &corev1.EventList{} + g.Expect(c.client.List(c.context, events, client.InNamespace(namespace))).NotTo(HaveOccurred()) + found := false + for _, e := range events.Items { + if e.InvolvedObject.Name == objName && e.Type == corev1.EventTypeWarning && e.Reason == reason { + found = true + break + } + } + g.Expect(found).To(BeTrue(), "expected a Warning event with reason %q for %q", reason, objName) + }).Within(reconciliationTimeout).Should(Succeed()) +} + // checkManagedSecretRotation waits for a token to be refreshed and returns the refreshed token func (c *clientContext) checkManagedSecretRotation( secretName, namespace string, //nolint:unparam @@ -218,12 +270,14 @@ func (c *clientContext) createToken( name, namespace, secretName, appRefName string, isBasicAuth bool, refreshInterval time.Duration, + extraData ...gtmv1.LocalSecretDataSource, ) error { spec := gtmv1.TokenSpec{ RefreshInterval: metav1.Duration{Duration: refreshInterval}, Secret: gtmv1.TokenSecretSpec{ Name: secretName, BasicAuth: isBasicAuth, + ExtraData: extraData, }, Repositories: []string{ testRepositoryName, @@ -267,6 +321,7 @@ func (c *clientContext) createClusterToken( name, secretName, targetNamespace string, isBasicAuth bool, refreshInterval time.Duration, + extraData ...gtmv1.SecretDataSource, ) error { clusterToken := >mv1.ClusterToken{ TypeMeta: metav1.TypeMeta{ @@ -282,6 +337,7 @@ func (c *clientContext) createClusterToken( Name: secretName, Namespace: targetNamespace, BasicAuth: isBasicAuth, + ExtraData: extraData, }, Repositories: []string{ testRepositoryName, @@ -355,6 +411,29 @@ func (c *clientContext) deleteSecret(name, namespace string) error { return c.client.Delete(c.context, secret) } +// createConfigMap creates a ConfigMap with the supplied data map. +func (c *clientContext) createConfigMap(name, namespace string, data map[string]string) error { + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: data, + } + return c.client.Create(c.context, configMap) +} + +// deleteConfigMap deletes a ConfigMap by name. +func (c *clientContext) deleteConfigMap(name, namespace string) error { + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + return c.client.Delete(c.context, configMap) +} + // runCommand executes the provided command within this context func runCommand(cmd *exec.Cmd) ([]byte, error) { dir, _ := getProjectDir() @@ -459,14 +538,11 @@ func generateTestKey() (string, error) { func checkToken(repository, token string) error { ctx := context.Background() - // Create OAuth2 token source - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) - tc := oauth2.NewClient(ctx, ts) - // Create GitHub client - client := github.NewClient(tc) + client, err := github.NewClient(github.WithAuthToken(token)) + if err != nil { + return fmt.Errorf("failed to create GitHub client: %w", err) + } // Parse repository string (format: "owner/repo") parts := strings.Split(repository, "/") @@ -476,7 +552,7 @@ func checkToken(repository, token string) error { owner, repo := parts[0], parts[1] // Test the /repos/OWNER/REPO/readme endpoint to validate content read permissions - _, _, err := client.Repositories.GetReadme(ctx, owner, repo, nil) + _, _, err = client.Repositories.GetReadme(ctx, owner, repo, nil) if err != nil { return fmt.Errorf("failed to validate token for repository %s (readme endpoint): %w", repository, err) } diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index ba92096..52ac21a 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -33,6 +33,9 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/clientcmd" @@ -64,8 +67,12 @@ const ( testToken1 = "token-1" testToken2 = "token-2" testToken3 = "token-3" + testToken4 = "token-4" + testToken5 = "token-5" + testToken6 = "token-6" testClusterToken1 = "cluster-token-1" testClusterToken2 = "cluster-token-2" + testClusterToken3 = "cluster-token-3" testApp = "test-app" // Secret names @@ -74,8 +81,17 @@ const ( testSecret3 = "secret-3" testSecret4 = "secret-4" testSecret5 = "secret-5" + testSecret6 = "secret-6" + testSecret7 = "secret-7" + testSecret8 = "secret-8" + testSecret9 = "secret-9" testAppKeySecret = "test-app-key" + + // extraData fixtures + testExtraDataConfigMap1 = "extra-data-configmap-1" + testExtraDataSecret1 = "extra-data-secret-1" + testExtraDataConfigMap2 = "extra-data-configmap-2" ) // gtmConfig holds the GitHub App credentials captured from the Helm install @@ -103,7 +119,7 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { var hasAppCredentials bool var capturedConfig *gtmConfig var k8sClient client.Client - ctx := context.Background() + ctx := context.Background() // suite-scoped; Ginkgo has no suite-lifetime context var clientCtx *clientContext checkToken := newTokenValidator(testRepository) @@ -160,8 +176,6 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { Context("Helm Chart", func() { It("installs cleanly", func() { - ctx = context.Background() - By("checking for valid GitHub App credentials") projectDir, err := getProjectDir() Expect(err).NotTo(HaveOccurred()) @@ -329,6 +343,116 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { }) }) + Context("Token CR extraData", func() { + It("merges inline, configMap and secret extraData sources", func() { + if !hasAppCredentials { + Skip("skipping tests - no valid GitHub App configuration provided") + } + + By("creating a ConfigMap fixture with a projected key and a reserved key") + Expect(clientCtx.createConfigMap(testExtraDataConfigMap1, targetNamespace, map[string]string{ + "ca.crt": "PEM-DATA", + "token": "spoofed-token", + })).To(Succeed()) + + By("creating a Secret fixture with an allowlisted key and an excluded key") + Expect(clientCtx.createOpaqueSecret(testExtraDataSecret1, targetNamespace, map[string][]byte{ + "tls.key": []byte("KEY-DATA"), + "unused.txt": []byte("excluded"), + })).To(Succeed()) + + By("creating a Token resource with inline, configMap and secret extraData") + Expect(clientCtx.createToken(testToken4, targetNamespace, testSecret6, "", false, tokenRefreshInterval, + gtmv1.LocalSecretDataSource{Inline: map[string]string{"app": "demo"}}, + gtmv1.LocalSecretDataSource{ConfigMap: >mv1.LocalSecretDataSourceRef{Name: testExtraDataConfigMap1}}, + gtmv1.LocalSecretDataSource{Secret: >mv1.LocalSecretDataSourceRef{Name: testExtraDataSecret1, Keys: []string{"tls.key"}}}, + )).To(Succeed()) + + By("waiting for Token reconciliation") + clientCtx.waitForTokenReconciliation(testToken4, targetNamespace) + + By("checking the managed Secret contains the merged extraData and the real credential") + secret := clientCtx.getSecret(testSecret6, targetNamespace) + Expect(secret.Data).To(HaveKeyWithValue("app", []byte("demo"))) + Expect(secret.Data).To(HaveKeyWithValue("ca.crt", []byte("PEM-DATA"))) + Expect(secret.Data).To(HaveKeyWithValue("tls.key", []byte("KEY-DATA"))) + Expect(secret.Data).NotTo(HaveKey("unused.txt")) + Expect(secret.Data).To(HaveKey("token")) + Expect(string(secret.Data["token"])).NotTo(Equal("spoofed-token")) + + By("checking that the managed token value is valid") + Expect(checkToken(string(secret.Data["token"]))).To(Succeed()) + + By("checking a Warning event was recorded for the reserved key") + clientCtx.waitForWarningEvent(testToken4, targetNamespace, "ReservedKeyIgnored") + + By("deleting the source ConfigMap and verifying the degraded condition") + Expect(clientCtx.deleteConfigMap(testExtraDataConfigMap1, targetNamespace)).To(Succeed()) + clientCtx.waitForTokenCondition(testToken4, targetNamespace, + gtmv1.ConditionTypeExtraDataDegraded, metav1.ConditionTrue, gtmv1.ReasonSourceUnavailable) + + By("checking the managed Secret retains the last-known-good extraData alongside a valid credential") + secret = clientCtx.getSecret(testSecret6, targetNamespace) + Expect(secret.Data).To(HaveKeyWithValue("ca.crt", []byte("PEM-DATA"))) + Expect(secret.Data).To(HaveKey("token")) + Expect(checkToken(string(secret.Data["token"]))).To(Succeed()) + + By("cleaning up") + Expect(clientCtx.deleteToken(testToken4, targetNamespace)).To(Succeed()) + Expect(clientCtx.deleteSecret(testExtraDataSecret1, targetNamespace)).To(Succeed()) + }) + + It("skips an optional missing extraData source", func() { + if !hasAppCredentials { + Skip("skipping tests - no valid GitHub App configuration provided") + } + + By("creating a Token resource with an optional reference to a nonexistent ConfigMap") + Expect(clientCtx.createToken(testToken5, targetNamespace, testSecret7, "", false, tokenRefreshInterval, + gtmv1.LocalSecretDataSource{ConfigMap: >mv1.LocalSecretDataSourceRef{Name: "does-not-exist", Optional: true}}, + )).To(Succeed()) + + By("waiting for Token reconciliation") + clientCtx.waitForTokenReconciliation(testToken5, targetNamespace) + + By("checking the managed Secret only contains the managed credential") + secret := clientCtx.getSecret(testSecret7, targetNamespace) + Expect(secret.Data).To(HaveKey("token")) + Expect(secret.Data).To(HaveLen(1)) + + By("checking the skipped optional source is surfaced on the ExtraDataDegraded condition") + clientCtx.waitForTokenCondition(testToken5, targetNamespace, + gtmv1.ConditionTypeExtraDataDegraded, metav1.ConditionTrue, gtmv1.ReasonKeysMissing) + + By("deleting the Token resource") + Expect(clientCtx.deleteToken(testToken5, targetNamespace)).To(Succeed()) + }) + + It("blocks Secret creation when a required extraData source is missing", func() { + By("creating a Token resource with a required reference to a nonexistent ConfigMap") + Expect(clientCtx.createToken(testToken6, targetNamespace, testSecret8, "", false, tokenRefreshInterval, + gtmv1.LocalSecretDataSource{ConfigMap: >mv1.LocalSecretDataSourceRef{Name: "does-not-exist"}}, + )).To(Succeed()) + + By("checking the Token is marked not-ready due to the unavailable source") + clientCtx.waitForTokenCondition(testToken6, targetNamespace, + gtmv1.ConditionTypeReady, metav1.ConditionFalse, gtmv1.ReasonSourceUnavailable) + + By("checking the managed Secret was never created") + Consistently(func(g Gomega) { + secret := &corev1.Secret{} + err := clientCtx.client.Get(clientCtx.context, client.ObjectKey{ + Name: testSecret8, + Namespace: targetNamespace, + }, secret) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }).Within(5 * time.Second).Should(Succeed()) + + By("deleting the Token resource") + Expect(clientCtx.deleteToken(testToken6, targetNamespace)).To(Succeed()) + }) + }) + Context("ClusterToken CR", func() { It("manages Secrets of type github.as-code.io/token", func() { if !hasAppCredentials { @@ -405,6 +529,33 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { By("deleting the ClusterToken resource") Expect(clientCtx.deleteClusterToken(testClusterToken2)).To(Succeed()) }) + + It("defaults an extraData configMap ref's namespace to the target Secret's namespace", func() { + if !hasAppCredentials { + Skip("skipping tests - no valid GitHub App configuration provided") + } + + By("creating a ConfigMap fixture in the target namespace") + Expect(clientCtx.createConfigMap(testExtraDataConfigMap2, targetNamespace, map[string]string{ + "ca.crt": "PEM-DATA", + })).To(Succeed()) + + By("creating a ClusterToken resource with a configMap ref that omits namespace") + Expect(clientCtx.createClusterToken(testClusterToken3, testSecret9, targetNamespace, false, tokenRefreshInterval, + gtmv1.SecretDataSource{ConfigMap: >mv1.SecretDataSourceRef{Name: testExtraDataConfigMap2}}, + )).To(Succeed()) + + By("waiting for ClusterToken reconciliation") + clientCtx.waitForClusterTokenReconciliation(testClusterToken3) + + By("checking the managed Secret contains the key projected from the defaulted namespace") + secret := clientCtx.getSecret(testSecret9, targetNamespace) + Expect(secret.Data).To(HaveKeyWithValue("ca.crt", []byte("PEM-DATA"))) + + By("cleaning up") + Expect(clientCtx.deleteClusterToken(testClusterToken3)).To(Succeed()) + Expect(clientCtx.deleteConfigMap(testExtraDataConfigMap2, targetNamespace)).To(Succeed()) + }) }) Context("App CR", Ordered, func() {