From bc8f4273984cb46a2bd0c889785b710a1ebc4258 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Fri, 3 Jul 2026 11:10:18 +0200 Subject: [PATCH 1/2] feat: support extra static and projected fields in managed Secrets Add spec.secret.extraData to Token and ClusterToken as an ordered list of inline, configMap, or secret projection sources merged into the generated Secret. Refs support an optional key allowlist and an optional flag controlling whether a missing/unreadable source fails the reconcile (deleting the managed Secret) or is skipped. Operator-managed keys (token, or username/password under basicAuth) always win; collisions and dropped reserved keys are surfaced as Warning events. Inline reserved-key and key-name violations are rejected at admission via CEL. Adds e2e coverage for the merge, optional-skip, and fail-closed paths. Also bumps golangci-lint v2.1.0 -> v2.12.2 (the old binary is built with Go 1.25 and refuses go 1.26.4) and resolves the findings its newer linters surfaced. --- .golangci.yml | 4 + Makefile | 2 +- README.md | 13 + api/v1/clustertoken_types.go | 24 ++ api/v1/clustertoken_types_test.go | 33 +++ api/v1/groupversion_info.go | 1 + api/v1/permissions_test.go | 3 - api/v1/secretdatasource.go | 90 +++++++ api/v1/token_types.go | 22 ++ api/v1/token_types_test.go | 33 +++ api/v1/zz_generated.deepcopy.go | 66 +++++ cmd/manager/main.go | 86 +++--- .../github.as-code.io_clustertokens.yaml | 113 ++++++++ .../crd/bases/github.as-code.io_tokens.yaml | 118 +++++++++ config/rbac/role.yaml | 6 + .../github-token-manager/templates/crds.yaml | 231 ++++++++++++++++ .../github-token-manager/templates/rbac.yaml | 6 + go.mod | 2 +- internal/controller/appconfig.go | 4 +- .../controller/clustertoken_controller.go | 1 + internal/controller/reconcile_token.go | 8 + internal/controller/token_controller.go | 1 + internal/ghapp/registry.go | 2 +- internal/metrics/recorder.go | 1 + internal/tokenmanager/extradata.go | 182 +++++++++++++ internal/tokenmanager/extradata_test.go | 249 ++++++++++++++++++ internal/tokenmanager/token_manager.go | 1 + internal/tokenmanager/token_secret.go | 62 ++++- internal/tokenmanager/token_secret_test.go | 76 ++++++ test/e2e/e2e_helpers_test.go | 78 ++++++ test/e2e/e2e_test.go | 138 ++++++++++ 31 files changed, 1606 insertions(+), 50 deletions(-) create mode 100644 api/v1/secretdatasource.go create mode 100644 internal/tokenmanager/extradata.go create mode 100644 internal/tokenmanager/extradata_test.go create mode 100644 internal/tokenmanager/token_secret_test.go 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..a50dd10 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,21 @@ 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: defaults to `secret.namespace`; Token: not permitted, always own namespace) + keys: [] # (optional) allowlist of keys to project (default: all keys) + optional: false # (optional) if true, a missing/unreadable source or key is skipped rather than failing + - 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. + +By default (`optional: false`), a required source that cannot be read, or a listed key missing from it, fails the reconcile: the managed `Secret` is deleted (or never created) and the failure is surfaced on the `Token`/`ClusterToken` status. Set `optional: true` on a ref to skip it instead. Sources are re-read on the existing refresh/retry interval; there is no separate watch on the referenced ConfigMap/Secret. + ### 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..ec6a092 100644 --- a/api/v1/clustertoken_types.go +++ b/api/v1/clustertoken_types.go @@ -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,18 @@ 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. +func (t *ClusterToken) GetSecretDataSources() []SecretDataSource { + return normalizeDataSources(t.Spec.Secret.ExtraData, func(existing string) string { + if existing == "" { + return t.Spec.Secret.Namespace + } + return existing + }) +} + func (t *ClusterToken) GetInstallationTokenOptions() *github.InstallationTokenOptions { return &github.InstallationTokenOptions{ Permissions: t.Spec.Permissions.ToInstallationPermissions(), diff --git a/api/v1/clustertoken_types_test.go b/api/v1/clustertoken_types_test.go index d4e2bac..66507fd 100644 --- a/api/v1/clustertoken_types_test.go +++ b/api/v1/clustertoken_types_test.go @@ -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/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/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..d7148e7 --- /dev/null +++ b/api/v1/secretdatasource.go @@ -0,0 +1,90 @@ +/* +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. 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 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. ClusterToken defaults + // this to the target Secret's namespace when empty. Token may not set + // this field: Tokens may only reference sources in their own namespace. + Namespace string `json:"namespace,omitempty"` + + // +optional + // 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), a missing or unreadable object, or a listed + // key absent from it, fails the reconcile: the managed Secret is deleted + // (or never created) and the (Cluster)Token is marked not-ready. When + // true, the reference is skipped instead. + Optional bool `json:"optional,omitempty"` +} + +// normalizeDataSources returns a copy of sources with every configMap/secret +// ref's namespace rewritten by resolveNamespace. Refs are deep-copied so the +// result never aliases the caller's spec. +func normalizeDataSources(sources []SecretDataSource, resolveNamespace func(existing string) string) []SecretDataSource { + if len(sources) == 0 { + return sources + } + normalized := make([]SecretDataSource, len(sources)) + for i, source := range sources { + normalized[i] = source + if source.ConfigMap != nil { + ref := *source.ConfigMap + ref.Namespace = resolveNamespace(ref.Namespace) + normalized[i].ConfigMap = &ref + } + if source.Secret != nil { + ref := *source.Secret + ref.Namespace = resolveNamespace(ref.Namespace) + normalized[i].Secret = &ref + } + } + return normalized +} diff --git a/api/v1/token_types.go b/api/v1/token_types.go index f6419d9..6d8efdd 100644 --- a/api/v1/token_types.go +++ b/api/v1/token_types.go @@ -72,6 +72,10 @@ 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 '.'" +// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || self.extraData.all(e, (!has(e.configMap) || !has(e.configMap.namespace)) && (!has(e.secret) || !has(e.secret.namespace)))",message="extraData configMap/secret sources may not set namespace on a Token; Tokens may only reference sources in their own namespace" type TokenSecretSpec struct { // +optional // +kubebuilder:validation:MaxLength:=253 @@ -89,6 +93,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 []SecretDataSource `json:"extraData,omitempty"` } // TokenStatus defines the observed state of Token @@ -167,6 +179,16 @@ func (t *Token) GetSecretBasicAuth() bool { return t.Spec.Secret.BasicAuth } +// GetSecretDataSources returns the extraData sources for this Token, with +// every configMap/secret ref's namespace forced to the Token's own namespace +// (Tokens may only reference sources in their own namespace; admission +// already rejects an explicit namespace on a Token ref). +func (t *Token) GetSecretDataSources() []SecretDataSource { + return normalizeDataSources(t.Spec.Secret.ExtraData, func(string) string { + return t.Namespace + }) +} + func (t *Token) GetInstallationTokenOptions() *github.InstallationTokenOptions { return &github.InstallationTokenOptions{ Permissions: t.Spec.Permissions.ToInstallationPermissions(), diff --git a/api/v1/token_types_test.go b/api/v1/token_types_test.go index 12434df..5ae7b1a 100644 --- a/api/v1/token_types_test.go +++ b/api/v1/token_types_test.go @@ -343,3 +343,36 @@ 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.SecretDataSource{ + {Inline: map[string]string{"ca.crt": "PEM"}}, + {ConfigMap: &v1.SecretDataSourceRef{Name: "ca-bundle"}}, + {Secret: &v1.SecretDataSourceRef{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 forced to Token's own namespace %q", got[1].ConfigMap.Namespace, "team-a") + } + if got[2].Secret.Namespace != "team-a" { + t.Errorf("secret ref namespace = %q, want forced to 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..d151b31 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. @@ -548,6 +555,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 +683,13 @@ func (in *TokenSecretSpec) DeepCopyInto(out *TokenSecretSpec) { (*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 TokenSecretSpec. diff --git a/cmd/manager/main.go b/cmd/manager/main.go index fa2809d..1f55387 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -20,6 +20,7 @@ import ( "context" "crypto/tls" "flag" + "fmt" "os" "path/filepath" "strings" @@ -229,45 +230,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 +293,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..0a36089 100644 --- a/config/crd/bases/github.as-code.io_clustertokens.yaml +++ b/config/crd/bases/github.as-code.io_clustertokens.yaml @@ -300,6 +300,99 @@ 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. 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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 +412,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..80e1d39 100644 --- a/config/crd/bases/github.as-code.io_tokens.yaml +++ b/config/crd/bases/github.as-code.io_tokens.yaml @@ -296,6 +296,98 @@ 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: |- + SecretDataSource projects additional keys into a managed Secret from + exactly one of an inline map, a ConfigMap, or another Secret. 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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 +400,32 @@ 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]+$')))" + - message: + extraData configMap/secret sources may not set namespace + on a Token; Tokens may only reference sources in their own namespace + rule: + "!has(self.extraData) || self.extraData.all(e, (!has(e.configMap) + || !has(e.configMap.namespace)) && (!has(e.secret) || !has(e.secret.namespace)))" 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..18b6c88 100644 --- a/deploy/charts/github-token-manager/templates/crds.yaml +++ b/deploy/charts/github-token-manager/templates/crds.yaml @@ -308,6 +308,99 @@ 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. 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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 +420,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 +834,98 @@ 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: |- + SecretDataSource projects additional keys into a managed Secret from + exactly one of an inline map, a ConfigMap, or another Secret. 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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: + type: string + type: array + name: + description: Name of the referenced ConfigMap or Secret. + maxLength: 253 + type: string + namespace: + description: |- + Namespace of the referenced ConfigMap or Secret. ClusterToken defaults + this to the target Secret's namespace when empty. Token may not set + this field: Tokens may only reference sources in their own namespace. + maxLength: 253 + type: string + optional: + description: |- + When false (the default), a missing or unreadable object, or a listed + key absent from it, fails the reconcile: the managed Secret is deleted + (or never created) and the (Cluster)Token is marked not-ready. When + true, the reference is skipped instead. + 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 +938,32 @@ 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]+$')))" + - message: + extraData configMap/secret sources may not set namespace + on a Token; Tokens may only reference sources in their own namespace + rule: + "!has(self.extraData) || self.extraData.all(e, (!has(e.configMap) + || !has(e.configMap.namespace)) && (!has(e.secret) || !has(e.secret.namespace)))" 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..9a455f9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/isometry/github-token-manager -go 1.26.3 +go 1.26.4 require ( github.com/go-logr/logr v1.4.3 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/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/registry.go b/internal/ghapp/registry.go index 2c795c7..aa1f31c 100644 --- a/internal/ghapp/registry.go +++ b/internal/ghapp/registry.go @@ -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/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/tokenmanager/extradata.go b/internal/tokenmanager/extradata.go new file mode 100644 index 0000000..a48c774 --- /dev/null +++ b/internal/tokenmanager/extradata.go @@ -0,0 +1,182 @@ +package tokenmanager + +import ( + "context" + "fmt" + "maps" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + + githubv1 "github.com/isometry/github-token-manager/api/v1" +) + +// requiredSourceError wraps a failure to resolve a required (non-optional) +// extraData source: an unreadable ConfigMap/Secret, or an allowlisted key +// absent from it. Reconcile treats it as fatal to the managed Secret. +type requiredSourceError struct { + desc string + err error +} + +func (e *requiredSourceError) Error() string { + return fmt.Sprintf("required extraData source %s: %v", e.desc, e.err) +} + +func (e *requiredSourceError) Unwrap() error { + return e.err +} + +// 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. A +// non-optional ref that is unreadable, or names an absent key, fails the +// whole resolution. 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) (map[string][]byte, error) { + reserved := reservedKeys(s.owner.GetSecretBasicAuth()) + data := make(map[string][]byte) + reads := make(map[readKey]map[string][]byte) + + for _, source := range s.owner.GetSecretDataSources() { + projected, err := s.resolveSource(ctx, source, reads) + if err != nil { + return nil, err + } + + 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, nil +} + +// readKey identifies a source object within one resolveExtraData pass, so +// multiple entries referencing the same object share a single live read. +type readKey struct { + kind string + namespace string + name string +} + +// resolveSource resolves a single extraData entry to its projected keys. +// Successful ConfigMap/Secret reads are memoized in reads. +func (s *tokenSecret) resolveSource(ctx context.Context, source githubv1.SecretDataSource, reads map[readKey]map[string][]byte) (map[string][]byte, 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 + + 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 + } + + key := readKey{kind: kind, namespace: ref.Namespace, name: ref.Name} + all, cached := reads[key] + var err error + if !cached { + if all, err = read(ctx, ref); err == nil { + reads[key] = all + } + } + return s.applyAllowlist(fmt.Sprintf("%s %s/%s", kind, ref.Namespace, ref.Name), ref, all, err) +} + +// applyAllowlist narrows `all` (nil if the source object itself could not be +// read; readErr carries the reason) down to ref.Keys, or returns all keys +// when the allowlist is empty. A failure to read the source, or an +// allowlisted key absent from it, is fatal unless ref.Optional, in which +// case the source is skipped by projecting nothing. +func (s *tokenSecret) applyAllowlist(desc string, ref *githubv1.SecretDataSourceRef, all map[string][]byte, readErr error) (map[string][]byte, error) { + if readErr != nil { + if ref.Optional { + return nil, nil + } + return nil, &requiredSourceError{desc: desc, err: readErr} + } + + if len(ref.Keys) == 0 { + return all, 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 + } + return nil, &requiredSourceError{desc: desc, err: fmt.Errorf("key %q not found", k)} + } + selected[k] = v + } + return selected, 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 +} + +// 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..3ca0b74 --- /dev/null +++ b/internal/tokenmanager/extradata_test.go @@ -0,0 +1,249 @@ +package tokenmanager + +import ( + "context" + "errors" + "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...) +} + +func newTestOwner(basicAuth bool, sources ...githubv1.SecretDataSource) 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.SecretDataSource{ + Inline: map[string]string{"ca.crt": "PEM"}, + }) + s := &tokenSecret{owner: owner} + + got, err := s.resolveExtraData(context.Background()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v", err) + } + 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.SecretDataSource{ + ConfigMap: &githubv1.SecretDataSourceRef{Name: "ca-bundle", Namespace: "ns"}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build()} + + got, err := s.resolveExtraData(context.Background()) + 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.SecretDataSource{ + ConfigMap: &githubv1.SecretDataSourceRef{Name: "ca-bundle", Namespace: "ns", Keys: []string{"ca.crt"}}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build()} + + got, err := s.resolveExtraData(context.Background()) + 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.SecretDataSource{ + Secret: &githubv1.SecretDataSourceRef{Name: "ca-key", Namespace: "ns"}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(secret).Build()} + + got, err := s.resolveExtraData(context.Background()) + 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_Skips(t *testing.T) { + owner := newTestOwner(false, githubv1.SecretDataSource{ + ConfigMap: &githubv1.SecretDataSourceRef{Name: "missing", Namespace: "ns", Optional: true}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader().Build(), recorder: record.NewFakeRecorder(10)} + + got, err := s.resolveExtraData(context.Background()) + 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) + } +} + +func TestResolveExtraData_RequiredMissingSource_Fails(t *testing.T) { + owner := newTestOwner(false, githubv1.SecretDataSource{ + ConfigMap: &githubv1.SecretDataSourceRef{Name: "missing", Namespace: "ns"}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader().Build()} + + _, err := s.resolveExtraData(context.Background()) + if err == nil { + t.Fatal("resolveExtraData() error = nil, want required-source error") + } + if !errors.As(err, new(*requiredSourceError)) { + t.Errorf("resolveExtraData() error = %v, want *requiredSourceError", 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.SecretDataSource{ + ConfigMap: &githubv1.SecretDataSourceRef{Name: "ca-bundle", Namespace: "ns", Keys: []string{"missing.key"}}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build()} + + _, err := s.resolveExtraData(context.Background()) + if err == nil { + t.Fatal("resolveExtraData() error = nil, want required-source error") + } + if !errors.As(err, new(*requiredSourceError)) { + t.Errorf("resolveExtraData() error = %v, want *requiredSourceError", err) + } +} + +func TestResolveExtraData_OptionalAllowlistKeyMissing_Skips(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.SecretDataSource{ + ConfigMap: &githubv1.SecretDataSourceRef{Name: "ca-bundle", Namespace: "ns", Keys: []string{"missing.key"}, Optional: true}, + }) + s := &tokenSecret{owner: owner, reader: newFakeReader(cm).Build(), recorder: record.NewFakeRecorder(10)} + + got, err := s.resolveExtraData(context.Background()) + if err != nil { + t.Fatalf("resolveExtraData() error = %v, want nil (optional ref skipped)", err) + } + if len(got) != 0 { + t.Errorf("got %v, want empty map", got) + } +} + +func TestResolveExtraData_ReservedKeyDropped_EmitsWarningEvent(t *testing.T) { + owner := newTestOwner(false, githubv1.SecretDataSource{ + ConfigMap: &githubv1.SecretDataSourceRef{Name: "malicious", Namespace: "ns"}, + }) + 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(context.Background()) + 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.SecretDataSource{Inline: map[string]string{"ca.crt": "first"}}, + githubv1.SecretDataSource{Inline: map[string]string{"ca.crt": "second"}}, + ) + rec := record.NewFakeRecorder(10) + s := &tokenSecret{owner: owner, recorder: rec} + + got, err := s.resolveExtraData(context.Background()) + 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.SecretDataSource{ + 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(context.Background()) + 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 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..cd405ac 100644 --- a/internal/tokenmanager/token_manager.go +++ b/internal/tokenmanager/token_manager.go @@ -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 diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 014b94c..7c3f045 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -12,6 +12,7 @@ import ( 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" @@ -31,11 +32,14 @@ const ( type tokenSecret struct { log logr.Logger client client.Client + reader client.Reader + recorder record.EventRecorder key types.NamespacedName owner TokenManager controllerName string ghait ghait.GHAIT metrics *metrics.Recorder + extraData map[string][]byte *corev1.Secret } @@ -47,6 +51,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 @@ -115,6 +136,28 @@ func (s *tokenSecret) Reconcile(ctx context.Context) (result reconcile.Result, e Name: s.owner.GetSecretName(), } + extraData, err := s.resolveExtraData(ctx) + if err != nil { + log.Info("required extraData source unavailable, deleting managed secret", "reason", err.Error()) + if delErr := s.DeleteSecret(ctx, secretKey); delErr != nil { + log.Error(delErr, "failed to delete secret after extraData resolution failure") + return result, delErr + } + condition := metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: "SourceUnavailable", + Message: err.Error(), + } + if statusErr := s.UpdateTokenStatus(ctx, &condition, nil, false); statusErr != nil { + log.Error(statusErr, "failed to update token status") + return result, statusErr + } + s.metrics.RecordReconcileError(ctx, s.controllerName, metrics.ReasonExtraData) + return reconcile.Result{RequeueAfter: s.owner.GetRetryInterval()}, nil + } + s.extraData = extraData + secret := &corev1.Secret{} err = s.client.Get(ctx, secretKey, secret) @@ -370,14 +413,19 @@ func (s *tokenSecret) SecretLabels() map[string]string { return secretLabels } +// SecretData builds the final Secret payload from the extraData already +// resolved by resolveExtraData (see Reconcile) plus the operator-managed +// credential keys, which always win over any extraData overlap. func (s *tokenSecret) SecretData(installationToken string) map[string][]byte { + data := make(map[string][]byte, len(s.extraData)+2) + maps.Copy(data, s.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..6363961 --- /dev/null +++ b/internal/tokenmanager/token_secret_test.go @@ -0,0 +1,76 @@ +package tokenmanager + +import ( + "maps" + "slices" + "testing" + + 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, extraData: tt.extraData} + + got := s.SecretData(token) + + 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) + } + } + }) + } +} diff --git a/test/e2e/e2e_helpers_test.go b/test/e2e/e2e_helpers_test.go index cb0ce81..a130e14 100644 --- a/test/e2e/e2e_helpers_test.go +++ b/test/e2e/e2e_helpers_test.go @@ -125,6 +125,26 @@ func (c *clientContext) waitForAppReconciliation(name, namespace string) { }).Within(reconciliationTimeout).Should(Succeed()) } +// waitForTokenCondition waits for a Token's Ready condition to reach the given +// status and reason. Unlike waitForTokenReconciliation (which hard-asserts +// Ready=True), this also covers not-ready terminal states such as a +// fail-closed extraData source. +func (c *clientContext) waitForTokenCondition(name, namespace 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()) + ready := meta.FindStatusCondition(tokenObj.Status.Conditions, gtmv1.ConditionTypeReady) + g.Expect(ready).NotTo(BeNil()) + g.Expect(ready.Status).To(Equal(status)) + g.Expect(ready.Reason).To(Equal(reason)) + }).Within(reconciliationTimeout).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 +179,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 +269,14 @@ func (c *clientContext) createToken( name, namespace, secretName, appRefName string, isBasicAuth bool, refreshInterval time.Duration, + extraData ...gtmv1.SecretDataSource, ) error { spec := gtmv1.TokenSpec{ RefreshInterval: metav1.Duration{Duration: refreshInterval}, Secret: gtmv1.TokenSecretSpec{ Name: secretName, BasicAuth: isBasicAuth, + ExtraData: extraData, }, Repositories: []string{ testRepositoryName, @@ -267,6 +320,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 +336,7 @@ func (c *clientContext) createClusterToken( Name: secretName, Namespace: targetNamespace, BasicAuth: isBasicAuth, + ExtraData: extraData, }, Repositories: []string{ testRepositoryName, @@ -355,6 +410,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() diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index ba92096..981072a 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 @@ -329,6 +345,101 @@ 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.SecretDataSource{Inline: map[string]string{"app": "demo"}}, + gtmv1.SecretDataSource{ConfigMap: >mv1.SecretDataSourceRef{Name: testExtraDataConfigMap1}}, + gtmv1.SecretDataSource{Secret: >mv1.SecretDataSourceRef{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("cleaning up") + Expect(clientCtx.deleteToken(testToken4, targetNamespace)).To(Succeed()) + Expect(clientCtx.deleteConfigMap(testExtraDataConfigMap1, 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.SecretDataSource{ConfigMap: >mv1.SecretDataSourceRef{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("deleting the Token resource") + Expect(clientCtx.deleteToken(testToken5, targetNamespace)).To(Succeed()) + }) + + It("fails closed 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.SecretDataSource{ConfigMap: >mv1.SecretDataSourceRef{Name: "does-not-exist"}}, + )).To(Succeed()) + + By("checking the Token is marked not-ready due to the unavailable source") + clientCtx.waitForTokenCondition(testToken6, targetNamespace, metav1.ConditionFalse, "SourceUnavailable") + + 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 +516,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() { From 6afb4eb9ea9e3bc63439c91bc83afc7676e225df Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Fri, 3 Jul 2026 12:04:36 +0200 Subject: [PATCH 2/2] chore(deps): bump go-github and ghait to v88 Rewrite import paths from v84 to v88 and adapt to the new github.NewClient options-pattern constructor, replacing the oauth2 static-token client in the e2e helpers with WithAuthToken. --- api/v1/clustertoken_types.go | 2 +- api/v1/clustertoken_types_test.go | 2 +- api/v1/helpers_test.go | 2 +- api/v1/permissions.go | 2 +- api/v1/secretdatasource.go | 5 +- api/v1/token_types.go | 2 +- api/v1/token_types_test.go | 2 +- go.mod | 66 ++++++------- go.sum | 132 ++++++++++++------------- internal/controller/appresolver.go | 2 +- internal/ghapp/providers.go | 8 +- internal/ghapp/providers_test.go | 2 +- internal/ghapp/registry.go | 2 +- internal/ghapp/registry_test.go | 4 +- internal/tokenmanager/token_manager.go | 2 +- internal/tokenmanager/token_secret.go | 4 +- test/e2e/e2e_helpers_test.go | 16 ++- 17 files changed, 126 insertions(+), 129 deletions(-) diff --git a/api/v1/clustertoken_types.go b/api/v1/clustertoken_types.go index ec6a092..9ea25be 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" diff --git a/api/v1/clustertoken_types_test.go b/api/v1/clustertoken_types_test.go index 66507fd..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" ) 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/secretdatasource.go b/api/v1/secretdatasource.go index d7148e7..fe2b456 100644 --- a/api/v1/secretdatasource.go +++ b/api/v1/secretdatasource.go @@ -66,8 +66,9 @@ type SecretDataSourceRef struct { } // normalizeDataSources returns a copy of sources with every configMap/secret -// ref's namespace rewritten by resolveNamespace. Refs are deep-copied so the -// result never aliases the caller's spec. +// ref's namespace rewritten by resolveNamespace. Each ref struct is copied so +// the rewrite never mutates the caller's spec; nested slices/maps (Keys, +// Inline) remain shared and are treated as read-only downstream. func normalizeDataSources(sources []SecretDataSource, resolveNamespace func(existing string) string) []SecretDataSource { if len(sources) == 0 { return sources diff --git a/api/v1/token_types.go b/api/v1/token_types.go index 6d8efdd..12d522a 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" diff --git a/api/v1/token_types_test.go b/api/v1/token_types_test.go index 5ae7b1a..eae9510 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" ) diff --git a/go.mod b/go.mod index 9a455f9..05cf912 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ 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/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/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 aa1f31c..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 diff --git a/internal/ghapp/registry_test.go b/internal/ghapp/registry_test.go index 3a277f8..e7eb25e 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 diff --git a/internal/tokenmanager/token_manager.go b/internal/tokenmanager/token_manager.go index cd405ac..ce1e0f9 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" diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 7c3f045..c360a17 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -7,7 +7,7 @@ import ( "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" @@ -18,7 +18,7 @@ import ( "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" ) diff --git a/test/e2e/e2e_helpers_test.go b/test/e2e/e2e_helpers_test.go index a130e14..6c829f8 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" @@ -537,14 +536,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, "/") @@ -554,7 +550,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) }