From 6952cca2cea78137a60352217cff869963438884 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Fri, 24 Apr 2026 18:49:25 +0200 Subject: [PATCH 1/3] feat: support multiple GitHub Apps - Introduce `App` CRD (api/v1/app_types.go) for namespaced GitHub App config - Add `appRef` to Token and ClusterToken CRDs/specs for referencing App - Implement App controller and registry for per-App ghait client caching - Update RBAC, Helm chart, and CRD manifests for App support - Add tests for App logic and registry caching - Bump chart version to 0.4.0 - Enhance metrics setup with OTEL resource attributes - Update docs for multi-tenancy and observability --- Makefile | 2 +- README.md | 105 ++++++- api/v1/app_types.go | 149 +++++++++ api/v1/app_types_test.go | 165 ++++++++++ api/v1/appref.go | 43 +++ api/v1/clustertoken_types.go | 20 ++ api/v1/conditions.go | 27 ++ api/v1/permissions_test.go | 75 ++--- api/v1/token_types.go | 20 ++ api/v1/zz_generated.deepcopy.go | 156 ++++++++++ cmd/manager/main.go | 101 +++++- config/crd/bases/github.as-code.io_apps.yaml | 210 +++++++++++++ .../github.as-code.io_clustertokens.yaml | 20 ++ .../crd/bases/github.as-code.io_tokens.yaml | 15 + config/manager/manager.yaml | 1 + config/rbac/role.yaml | 13 +- deploy/charts/github-token-manager/Chart.yaml | 2 +- deploy/charts/github-token-manager/README.md | 65 +++- .../templates/config.yaml | 2 +- .../github-token-manager/templates/crds.yaml | 208 +++++++++++++ .../templates/deployment.yaml | 11 +- .../github-token-manager/templates/rbac.yaml | 30 +- .../charts/github-token-manager/values.yaml | 6 + go.mod | 140 ++++----- go.sum | 287 +++++++++--------- internal/controller/app_controller.go | 216 +++++++++++++ internal/controller/appconfig.go | 90 ++++++ internal/controller/appresolver.go | 144 +++++++++ .../controller/clustertoken_controller.go | 94 +++--- internal/controller/shared.go | 9 - internal/controller/token_controller.go | 109 ++++--- internal/ghapp/registry.go | 171 +++++++++++ internal/ghapp/registry_test.go | 148 +++++++++ internal/metrics/recorder.go | 74 ++--- internal/metrics/recorder_test.go | 107 ++++--- internal/metrics/setup.go | 40 ++- internal/tokenmanager/token_secret.go | 107 +++---- test/e2e/e2e_helpers_test.go | 135 ++++++++ test/e2e/e2e_test.go | 209 +++++++++++-- 39 files changed, 2936 insertions(+), 590 deletions(-) create mode 100644 api/v1/app_types.go create mode 100644 api/v1/app_types_test.go create mode 100644 api/v1/appref.go create mode 100644 config/crd/bases/github.as-code.io_apps.yaml create mode 100644 internal/controller/app_controller.go create mode 100644 internal/controller/appconfig.go create mode 100644 internal/controller/appresolver.go delete mode 100644 internal/controller/shared.go create mode 100644 internal/ghapp/registry.go create mode 100644 internal/ghapp/registry_test.go diff --git a/Makefile b/Makefile index 7c18376..f1377fc 100644 --- a/Makefile +++ b/Makefile @@ -174,7 +174,7 @@ build: manifests generate fmt vet ## Build manager binary. .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/manager + POD_NAMESPACE=$${POD_NAMESPACE:-github-token-manager} go run ./cmd/manager # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. diff --git a/README.md b/README.md index cc5da8e..3fffc11 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This operator solves the problem by functioning like cert-manager for GitHub tok - **🔐 Zero-Trust Security**: Never store GitHub App private keys in-cluster - integrates with AWS KMS, Azure Key Vault, Google Cloud KMS, and HashiCorp Vault - **⏰ Ephemeral & Auto-Rotating**: Tokens expire in 1 hour and refresh automatically before expiration - **🎯 Fine-Grained Permissions**: Each token can have different scopes, down to specific repositories and permissions -- **🏢 Multi-Tenancy**: Namespace isolation with `Token` CRD, cluster-wide control with `ClusterToken` +- **🏢 Multi-Tenancy**: Namespace isolation with `Token` CRD, cluster-wide control with `ClusterToken`, and optional per-tenant `App` credentials - **🚀 GitOps-Ready**: Native FluxCD integration with HTTP Basic Auth secret generation - **📊 Production-Ready**: Prometheus metrics, health probes, intelligent retry logic with exponential backoff @@ -136,7 +136,9 @@ kind: ClusterToken # or Token metadata: name: foo spec: - installationID: 321 # (optional) override GitHub App Installation ID configured for the operator + appRef: # (optional) reference an App CR for per-tenant credentials; see "Multiple GitHub Apps" + name: prod-app + installationID: 321 # (optional) override GitHub App Installation ID configured for the operator or App permissions: {} # (optional) map of token permissions, default: all permissions assigned to the GitHub App refreshInterval: 45m # (optional) token refresh interval, default 30m retryInterval: 1m # (optional) token retry interval on ephemeral failure; default: 5m @@ -150,6 +152,100 @@ spec: namespace: default # (required, ClusterToken-only) set the target namespace for managed `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**. + +The startup `Secret/gtm-config` is optional: the operator's `/config` volume is mounted with `optional: true`, so an App-CR-only install does not require the Secret to exist. The Helm chart only renders the Secret when `config.app_id` is non-zero, and the Secret name is overridable via `config.secretName` (default `gtm-config`) for users who manage it externally (e.g. ESO, Sealed Secrets). + +**Cloud KMS-backed App:** + +```yaml +apiVersion: github.as-code.io/v1 +kind: App +metadata: + name: prod-app + namespace: team-platform +spec: + appID: 12345 + installationID: 67890 + provider: aws # aws | azure | gcp | vault + key: alias/prod-gh-app # provider-specific key reference + validateKey: true # optional: test-sign the key at reconcile time +``` + +**Secret-backed App** (PEM-encoded RSA private key in a same-namespace Secret): + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: prod-app-key + namespace: team-platform +type: Opaque +stringData: + private-key.pem: | + -----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY----- +--- +apiVersion: github.as-code.io/v1 +kind: App +metadata: + name: prod-app + namespace: team-platform +spec: + appID: 12345 + installationID: 67890 + provider: secret + keyRef: + name: prod-app-key + # key: private-key.pem # default; matches GitHub's downloaded filename + validateKey: true +``` + +The spec fields mirror the startup configuration with one deliberate divergence: `provider: file` is **not** accepted on an `App`. Because an `App` is namespaced, allowing a filesystem path would let any namespace owner reference key material mounted on the controller Pod for unrelated tenants. Inline keys go through `provider: secret` + a same-namespace Secret instead; tenant isolation is then enforced by Kubernetes RBAC on Secrets in that namespace, and the Secret can be managed by ESO, Sealed Secrets, Vault CSI, or `kubectl create secret`. The `App` reconciler watches its keyRef Secret and rebuilds the signer client on rotation. It surfaces a `Ready` condition on the resource; when `validateKey: true`, it also surfaces a `KeyValid` condition. + +**Token references (same-namespace only):** + +```yaml +apiVersion: github.as-code.io/v1 +kind: Token +metadata: + name: ci-token + namespace: team-platform +spec: + appRef: + name: prod-app # must live in the Token's own namespace +``` + +**ClusterToken references (cross-namespace):** + +```yaml +apiVersion: github.as-code.io/v1 +kind: ClusterToken +metadata: + name: shared-token +spec: + appRef: + name: prod-app + namespace: team-platform # optional; defaults to the operator's own namespace + secret: + namespace: flux-system +``` + +When a referenced `App` is missing or not yet `Ready`, the Token surfaces a `Ready=False` condition with reason `AppNotFound`, `AppNotReady`, or `SetupFailed`. The controller watches `App` resources so Tokens automatically re-reconcile once the App becomes ready or its spec is corrected. + +**Migration note:** No changes are required when upgrading — existing Tokens and ClusterTokens without `spec.appRef` continue to use the startup `Secret/gtm-config`. Adopting the `App` CRD per workload is entirely opt-in. + +#### Security model: ClusterToken and App references + +`ClusterToken` is cluster-scoped and `spec.appRef.namespace` accepts any namespace. The operator runs with cluster-wide read on `App` resources, so there is no Kubernetes RBAC barrier between a `ClusterToken` creator and the `App`s they may reference. + +**Granting `create` or `update` on `ClusterToken` is therefore equivalent to granting use of every `App` in every namespace** — including any `App` in the operator's own namespace. + +In multi-tenant clusters, restrict `ClusterToken` write permissions to cluster administrators, or enforce a `spec.appRef.namespace` allow-list with an admission policy (Kyverno, OPA Gatekeeper, or `ValidatingAdmissionPolicy`). The namespaced `Token` does not have this concern: it can only reference `App`s in its own namespace. + ### Examples **FluxCD Git Repository Access:** @@ -202,6 +298,9 @@ Available opt-out tags: `ghait.no_aws`, `ghait.no_azure`, `ghait.no_gcp`, `ghait # Build and test make build test lint +# Run the controller from your host (defaults POD_NAMESPACE=github-token-manager) +make run + # Deploy locally make ko-build IMG=/github-token-manager:tag make deploy IMG=/github-token-manager:tag @@ -210,6 +309,8 @@ make deploy IMG=/github-token-manager:tag make undeploy uninstall ``` +The manager requires `POD_NAMESPACE` to know its own namespace (in-cluster, the chart injects it via the downward API). `make run` defaults it to `github-token-manager`, matching the kustomize deploy namespace at `config/default/kustomization.yaml`; override by exporting `POD_NAMESPACE` before invoking. If you run `go run ./cmd/manager` directly, set it yourself. + Run `make help` for all available targets. See [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) for details. ## Contributing diff --git a/api/v1/app_types.go b/api/v1/app_types.go new file mode 100644 index 0000000..65e607b --- /dev/null +++ b/api/v1/app_types.go @@ -0,0 +1,149 @@ +/* +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 + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AppSpec defines the desired state of an App. +// +// Provider selects how the App's RSA private key is materialised. For cloud +// KMS (aws/azure/gcp/vault) the key reference is supplied inline via Key. +// For "secret" the key bytes live in a same-namespace Secret named by +// KeyRef; this is the only supported way to use an inline PEM with an App, +// because allowing arbitrary filesystem paths from a namespaced resource +// would let any namespace owner read key material mounted on the controller +// Pod. +// +// +kubebuilder:validation:XValidation:rule="(self.provider == 'secret') == has(self.keyRef)",message="keyRef must be set if and only if provider is 'secret'" +// +kubebuilder:validation:XValidation:rule="(self.provider != 'secret') == has(self.key)",message="key must be set if and only if provider is not 'secret'" +type AppSpec struct { + // Important: Run "make" to regenerate code after modifying this file + + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:example:=12345 + // The AppID of the GitHub App. + AppID int64 `json:"appID"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:example:=123456789 + // The default InstallationID of the GitHub App; Tokens/ClusterTokens may + // override this via spec.installationID to target a different installation. + InstallationID int64 `json:"installationID"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=secret;aws;azure;gcp;vault + // Private key provider. One of "secret" (PEM material in a same-namespace + // Secret, referenced by keyRef), "aws" (AWS KMS), "azure" (Azure Key + // Vault), "gcp" (Google Cloud KMS), or "vault" (HashiCorp Vault transit). + // The "file" provider is intentionally not supported on an App; use the + // operator's startup configuration for file-based keys. + Provider string `json:"provider"` + + // +optional + // Cloud-KMS key reference. Required when provider is "aws", "azure", + // "gcp", or "vault"; forbidden when provider is "secret". The exact + // shape depends on the provider: KMS key alias/ID/ARN (aws), Azure Key + // Vault key URL (azure), GCP KMS resource name (gcp), or Vault transit + // sign path (vault). + Key string `json:"key,omitempty"` + + // +optional + // Same-namespace Secret reference holding the PEM-encoded RSA private + // key. Required when provider is "secret"; forbidden otherwise. + KeyRef *KeySecretReference `json:"keyRef,omitempty"` + + // +optional + // +kubebuilder:default:=false + // If true, the operator validates the private key at reconcile time by + // attempting a test sign. Failures surface as a KeyValid=False condition. + ValidateKey bool `json:"validateKey,omitempty"` +} + +// KeySecretReference identifies a same-namespace Secret holding a +// PEM-encoded RSA private key for a GitHub App. +type KeySecretReference struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength:=253 + // Name of the Secret in the App's namespace. + Name string `json:"name"` + + // +optional + // +kubebuilder:default:="private-key.pem" + // Key within the Secret's data map containing the PEM-encoded RSA + // private key. Defaults to "private-key.pem", which matches the + // filename GitHub uses when downloading App keys. + Key string `json:"key,omitempty"` +} + +// AppStatus defines the observed state of an App. +type AppStatus struct { + // Important: Run "make" to regenerate code after modifying this file + + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=app,path=apps +// +kubebuilder:printcolumn:name="App ID",type=integer,JSONPath=`.spec.appID` +// +kubebuilder:printcolumn:name="Installation ID",type=integer,JSONPath=`.spec.installationID` +// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.provider` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// App is the Schema for the apps API; it encapsulates a GitHub App +// configuration that Tokens and ClusterTokens may reference via spec.appRef. +type App struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AppSpec `json:"spec,omitempty"` + Status AppStatus `json:"status,omitempty"` +} + +// GetStatusConditions returns the App's status conditions slice. +func (a *App) GetStatusConditions() []metav1.Condition { + return a.Status.Conditions +} + +// SetStatusCondition updates the App's status conditions in place, +// returning true when the resulting slice differs from the prior value. +func (a *App) SetStatusCondition(condition metav1.Condition) (changed bool) { + return meta.SetStatusCondition(&a.Status.Conditions, condition) +} + +// +kubebuilder:object:root=true + +// AppList contains a list of App. +type AppList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []App `json:"items"` +} + +func init() { + SchemeBuilder.Register(&App{}, &AppList{}) +} diff --git a/api/v1/app_types_test.go b/api/v1/app_types_test.go new file mode 100644 index 0000000..5cb3de0 --- /dev/null +++ b/api/v1/app_types_test.go @@ -0,0 +1,165 @@ +package v1_test + +import ( + "testing" + + v1 "github.com/isometry/github-token-manager/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAppSpec_CloudProviderShape(t *testing.T) { + app := &v1.App{ + Spec: v1.AppSpec{ + AppID: 12345, + InstallationID: 67890, + Provider: "aws", + Key: "alias/github-token-manager", + ValidateKey: true, + }, + } + if app.Spec.AppID != 12345 || app.Spec.InstallationID != 67890 { + t.Errorf("ID round-trip failed: %+v", app.Spec) + } + if app.Spec.Provider != "aws" || app.Spec.Key != "alias/github-token-manager" { + t.Errorf("provider/key round-trip failed: %+v", app.Spec) + } + if !app.Spec.ValidateKey { + t.Errorf("ValidateKey round-trip failed") + } + if app.Spec.KeyRef != nil { + t.Errorf("cloud-provider App should not carry KeyRef, got %+v", app.Spec.KeyRef) + } +} + +func TestAppSpec_SecretProviderShape(t *testing.T) { + app := &v1.App{ + Spec: v1.AppSpec{ + AppID: 1, + InstallationID: 2, + Provider: "secret", + KeyRef: &v1.KeySecretReference{ + Name: "gh-app-key", + Key: "private-key.pem", + }, + }, + } + if app.Spec.Provider != "secret" { + t.Fatalf("provider = %q, want secret", app.Spec.Provider) + } + if app.Spec.Key != "" { + t.Errorf("secret-provider App should leave Key empty, got %q", app.Spec.Key) + } + if app.Spec.KeyRef == nil || app.Spec.KeyRef.Name != "gh-app-key" || app.Spec.KeyRef.Key != "private-key.pem" { + t.Errorf("KeyRef round-trip failed: %+v", app.Spec.KeyRef) + } +} + +func TestApp_SetStatusCondition(t *testing.T) { + app := &v1.App{} + + changed := app.SetStatusCondition(metav1.Condition{ + Type: v1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: v1.ReasonReconciled, + Message: "ready", + }) + if !changed { + t.Errorf("SetStatusCondition() on fresh App reported no change") + } + + // Setting the same condition again should not report a change. + changed = app.SetStatusCondition(metav1.Condition{ + Type: v1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: v1.ReasonReconciled, + Message: "ready", + }) + if changed { + t.Errorf("SetStatusCondition() with identical value reported a change") + } + + // Transition to False with a different reason. + changed = app.SetStatusCondition(metav1.Condition{ + Type: v1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: v1.ReasonSetupFailed, + Message: "boom", + }) + if !changed { + t.Errorf("SetStatusCondition() on status transition reported no change") + } + if got := app.GetStatusConditions(); len(got) != 1 || got[0].Reason != v1.ReasonSetupFailed { + t.Errorf("conditions = %v, want one with reason=%s", got, v1.ReasonSetupFailed) + } +} + +func TestToken_GetAppRef(t *testing.T) { + t.Run("nil ref returns nil", func(t *testing.T) { + tok := &v1.Token{ + ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "team-a"}, + } + if got := tok.GetAppRef(); got != nil { + t.Errorf("GetAppRef() = %v, want nil", got) + } + }) + + t.Run("ref uses Token namespace", func(t *testing.T) { + tok := &v1.Token{ + ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "team-a"}, + Spec: v1.TokenSpec{ + AppRef: &v1.LocalAppReference{Name: "prod-app"}, + }, + } + got := tok.GetAppRef() + if got == nil { + t.Fatalf("GetAppRef() = nil, want non-nil") + } + if got.Name != "prod-app" { + t.Errorf("Name = %v, want prod-app", got.Name) + } + if got.Namespace != "team-a" { + t.Errorf("Namespace = %v, want team-a (Token's namespace)", got.Namespace) + } + }) +} + +func TestClusterToken_GetAppRef(t *testing.T) { + t.Run("nil ref returns nil", func(t *testing.T) { + ct := &v1.ClusterToken{ObjectMeta: metav1.ObjectMeta{Name: "ct"}} + if got := ct.GetAppRef(); got != nil { + t.Errorf("GetAppRef() = %v, want nil", got) + } + }) + + t.Run("preserves explicit namespace", func(t *testing.T) { + ct := &v1.ClusterToken{ + ObjectMeta: metav1.ObjectMeta{Name: "ct"}, + Spec: v1.ClusterTokenSpec{ + AppRef: &v1.AppReference{Name: "prod-app", Namespace: "shared"}, + }, + } + got := ct.GetAppRef() + if got == nil { + t.Fatalf("GetAppRef() = nil, want non-nil") + } + if got.Namespace != "shared" { + t.Errorf("Namespace = %v, want shared", got.Namespace) + } + }) + + t.Run("leaves empty namespace for caller to default", func(t *testing.T) { + ct := &v1.ClusterToken{ + ObjectMeta: metav1.ObjectMeta{Name: "ct"}, + Spec: v1.ClusterTokenSpec{ + AppRef: &v1.AppReference{Name: "prod-app"}, + }, + } + got := ct.GetAppRef() + if got == nil { + t.Fatalf("GetAppRef() = nil, want non-nil") + } + if got.Namespace != "" { + t.Errorf("Namespace = %q, want empty (caller defaults)", got.Namespace) + } + }) +} diff --git a/api/v1/appref.go b/api/v1/appref.go new file mode 100644 index 0000000..fec0f12 --- /dev/null +++ b/api/v1/appref.go @@ -0,0 +1,43 @@ +/* +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 + +// LocalAppReference is a same-namespace reference to an App resource used +// by the namespaced Token kind. A Token may only reference an App in its +// own namespace. +type LocalAppReference struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength:=253 + // Name of the App resource in the same namespace as the referring Token. + Name string `json:"name"` +} + +// AppReference identifies an App resource, optionally in a different +// namespace. Used by the cluster-scoped ClusterToken kind. When Namespace is +// empty the controller resolves it to the operator's own namespace. +type AppReference struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength:=253 + // Name of the App resource. + Name string `json:"name"` + + // +optional + // +kubebuilder:validation:MaxLength:=253 + // Namespace containing the App resource. If empty, defaults to the + // operator's own namespace. + Namespace string `json:"namespace,omitempty"` +} diff --git a/api/v1/clustertoken_types.go b/api/v1/clustertoken_types.go index f462f40..a121877 100644 --- a/api/v1/clustertoken_types.go +++ b/api/v1/clustertoken_types.go @@ -32,6 +32,13 @@ import ( type ClusterTokenSpec struct { // Important: Run "make" to regenerate code after modifying this file + // +optional + // Reference to the App that provides the GitHub App credentials for this + // ClusterToken. When spec.appRef.namespace is empty, the operator resolves + // the reference in its own namespace. When unset, the operator's startup + // configuration is used. + AppRef *AppReference `json:"appRef,omitempty"` + // +kubebuilder:validation:Required Secret ClusterTokenSecretSpec `json:"secret"` @@ -131,6 +138,19 @@ func (t *ClusterToken) GetInstallationID() int64 { return t.Spec.InstallationID } +// GetAppRef returns the raw *AppReference set on the ClusterToken, or nil if +// unset. The Namespace field may be empty; the caller (registry) defaults it +// to the operator's own namespace. +func (t *ClusterToken) GetAppRef() *AppReference { + if t.Spec.AppRef == nil { + return nil + } + return &AppReference{ + Name: t.Spec.AppRef.Name, + Namespace: t.Spec.AppRef.Namespace, + } +} + func (t *ClusterToken) GetRefreshInterval() time.Duration { return t.Spec.RefreshInterval.Duration } diff --git a/api/v1/conditions.go b/api/v1/conditions.go index 82762c7..53a639a 100644 --- a/api/v1/conditions.go +++ b/api/v1/conditions.go @@ -3,4 +3,31 @@ package v1 const ( // ConditionTypeReady is used to signal whether a reconciliation has completed successfully. ConditionTypeReady = "Ready" + + // ConditionTypeKeyValid is set on an App when spec.validateKey is true and + // reports the outcome of the signer's key validation. Absent when + // spec.validateKey is false. + ConditionTypeKeyValid = "KeyValid" + + // Condition reasons used by the Token and ClusterToken controllers when + // resolving spec.appRef. + + // ReasonAppNotFound indicates the referenced App does not exist. + ReasonAppNotFound = "AppNotFound" + // ReasonAppNotReady indicates the referenced App exists but its Ready + // condition is not True. + ReasonAppNotReady = "AppNotReady" + // ReasonNoStartupConfig indicates no spec.appRef was set and the operator + // has no startup GitHub App configuration. + ReasonNoStartupConfig = "NoStartupConfig" + // ReasonReconciled indicates a successful reconciliation. + ReasonReconciled = "Reconciled" + // ReasonSetupFailed indicates construction of the GitHub App client failed. + ReasonSetupFailed = "SetupFailed" + // ReasonSecretNotFound indicates the Secret named by App.spec.keyRef + // could not be fetched (typically NotFound). + ReasonSecretNotFound = "SecretNotFound" + // ReasonInvalidKey indicates the resolved key material is missing, + // empty, or not a usable PEM-encoded RSA private key. + ReasonInvalidKey = "InvalidKey" ) diff --git a/api/v1/permissions_test.go b/api/v1/permissions_test.go index 2c89f10..a67d38b 100644 --- a/api/v1/permissions_test.go +++ b/api/v1/permissions_test.go @@ -73,46 +73,47 @@ func TestPermissions_ToInstallationPermissions_FieldMapping(t *testing.T) { } } -func ptr(s string) *string { return &s } +//go:fix inline +func ptr(s string) *string { return new(s) } func TestPermissions_ToInstallationPermissions_AllPermissions(t *testing.T) { p := &v1.Permissions{ - Actions: ptr("actions"), - Administration: ptr("administration"), - Checks: ptr("checks"), - Codespaces: ptr("codespaces"), - Contents: ptr("contents"), - DependabotSecrets: ptr("dependabot_secrets"), - Deployments: ptr("deployments"), - EmailAddresses: ptr("email_addresses"), - Environments: ptr("environments"), - Followers: ptr("followers"), - Issues: ptr("issues"), - Metadata: ptr("metadata"), - Members: ptr("members"), - OrganizationAdministration: ptr("organization_administration"), - OrganizationCustomRoles: ptr("organization_custom_roles"), - OrganizationHooks: ptr("organization_hooks"), - OrganizationPackages: ptr("organization_packages"), - OrganizationPlan: ptr("organization_plan"), - OrganizationProjects: ptr("organization_projects"), - OrganizationSecrets: ptr("organization_secrets"), - OrganizationSelfHostedRunners: ptr("organization_self_hosted_runners"), - OrganizationUserBlocking: ptr("organization_user_blocking"), - Packages: ptr("packages"), - Pages: ptr("pages"), - PullRequests: ptr("pull_requests"), - RepositoryCustomProperties: ptr("repository_custom_properties"), - RepositoryHooks: ptr("repository_hooks"), - RepositoryProjects: ptr("repository_projects"), - Secrets: ptr("secrets"), - SecretScanningAlerts: ptr("secret_scanning_alerts"), - SecurityEvents: ptr("security_events"), - SingleFile: ptr("single_file"), - Statuses: ptr("statuses"), - TeamDiscussions: ptr("team_discussions"), - VulnerabilityAlerts: ptr("vulnerability_alerts"), - Workflows: ptr("workflows"), + Actions: new("actions"), + Administration: new("administration"), + Checks: new("checks"), + Codespaces: new("codespaces"), + Contents: new("contents"), + DependabotSecrets: new("dependabot_secrets"), + Deployments: new("deployments"), + EmailAddresses: new("email_addresses"), + Environments: new("environments"), + Followers: new("followers"), + Issues: new("issues"), + Metadata: new("metadata"), + Members: new("members"), + OrganizationAdministration: new("organization_administration"), + OrganizationCustomRoles: new("organization_custom_roles"), + OrganizationHooks: new("organization_hooks"), + OrganizationPackages: new("organization_packages"), + OrganizationPlan: new("organization_plan"), + OrganizationProjects: new("organization_projects"), + OrganizationSecrets: new("organization_secrets"), + OrganizationSelfHostedRunners: new("organization_self_hosted_runners"), + OrganizationUserBlocking: new("organization_user_blocking"), + Packages: new("packages"), + Pages: new("pages"), + PullRequests: new("pull_requests"), + RepositoryCustomProperties: new("repository_custom_properties"), + RepositoryHooks: new("repository_hooks"), + RepositoryProjects: new("repository_projects"), + Secrets: new("secrets"), + SecretScanningAlerts: new("secret_scanning_alerts"), + SecurityEvents: new("security_events"), + SingleFile: new("single_file"), + Statuses: new("statuses"), + TeamDiscussions: new("team_discussions"), + VulnerabilityAlerts: new("vulnerability_alerts"), + Workflows: new("workflows"), } got := p.ToInstallationPermissions() diff --git a/api/v1/token_types.go b/api/v1/token_types.go index cade4ea..ad30447 100644 --- a/api/v1/token_types.go +++ b/api/v1/token_types.go @@ -32,6 +32,12 @@ import ( type TokenSpec struct { // Important: Run "make" to regenerate code after modifying this file + // +optional + // Reference to the App that provides the GitHub App credentials for this + // Token. Must be in the same namespace as the Token. When unset, the + // operator's startup configuration is used. + AppRef *LocalAppReference `json:"appRef,omitempty"` + // +optional // Override the default token secret name and type Secret TokenSecretSpec `json:"secret,omitempty"` @@ -124,6 +130,20 @@ func (t *Token) GetInstallationID() int64 { return t.Spec.InstallationID } +// GetAppRef returns a normalized *AppReference for the App backing this Token, +// or nil when no AppRef is set (falling back to the startup config). The +// namespace is always the Token's own namespace, since Tokens cannot reference +// Apps cross-namespace. +func (t *Token) GetAppRef() *AppReference { + if t.Spec.AppRef == nil { + return nil + } + return &AppReference{ + Name: t.Spec.AppRef.Name, + Namespace: t.Namespace, + } +} + func (t *Token) GetRefreshInterval() time.Duration { return t.Spec.RefreshInterval.Duration } diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 15c081a..4d38f6e 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -25,6 +25,122 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *App) DeepCopyInto(out *App) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new App. +func (in *App) DeepCopy() *App { + if in == nil { + return nil + } + out := new(App) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *App) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppList) DeepCopyInto(out *AppList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]App, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppList. +func (in *AppList) DeepCopy() *AppList { + if in == nil { + return nil + } + out := new(AppList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AppList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppReference) DeepCopyInto(out *AppReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppReference. +func (in *AppReference) DeepCopy() *AppReference { + if in == nil { + return nil + } + out := new(AppReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppSpec) DeepCopyInto(out *AppSpec) { + *out = *in + if in.KeyRef != nil { + in, out := &in.KeyRef, &out.KeyRef + *out = new(KeySecretReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppSpec. +func (in *AppSpec) DeepCopy() *AppSpec { + if in == nil { + return nil + } + out := new(AppSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppStatus) DeepCopyInto(out *AppStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppStatus. +func (in *AppStatus) DeepCopy() *AppStatus { + if in == nil { + return nil + } + out := new(AppStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterToken) DeepCopyInto(out *ClusterToken) { *out = *in @@ -116,6 +232,11 @@ func (in *ClusterTokenSecretSpec) DeepCopy() *ClusterTokenSecretSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterTokenSpec) DeepCopyInto(out *ClusterTokenSpec) { *out = *in + if in.AppRef != nil { + in, out := &in.AppRef, &out.AppRef + *out = new(AppReference) + **out = **in + } in.Secret.DeepCopyInto(&out.Secret) out.RefreshInterval = in.RefreshInterval out.RetryInterval = in.RetryInterval @@ -187,6 +308,36 @@ func (in *InstallationAccessToken) DeepCopy() *InstallationAccessToken { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeySecretReference) DeepCopyInto(out *KeySecretReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeySecretReference. +func (in *KeySecretReference) DeepCopy() *KeySecretReference { + if in == nil { + return nil + } + out := new(KeySecretReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalAppReference) DeepCopyInto(out *LocalAppReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalAppReference. +func (in *LocalAppReference) DeepCopy() *LocalAppReference { + if in == nil { + return nil + } + out := new(LocalAppReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedSecret) DeepCopyInto(out *ManagedSecret) { *out = *in @@ -488,6 +639,11 @@ func (in *TokenSecretSpec) DeepCopy() *TokenSecretSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TokenSpec) DeepCopyInto(out *TokenSpec) { *out = *in + if in.AppRef != nil { + in, out := &in.AppRef, &out.AppRef + *out = new(LocalAppReference) + **out = **in + } in.Secret.DeepCopyInto(&out.Secret) out.RefreshInterval = in.RefreshInterval out.RetryInterval = in.RetryInterval diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 4e9dcc3..20a286e 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -22,6 +22,7 @@ import ( "flag" "os" "path/filepath" + "strings" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -32,6 +33,7 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" @@ -40,6 +42,7 @@ import ( githubv1 "github.com/isometry/github-token-manager/api/v1" "github.com/isometry/github-token-manager/internal/controller" + "github.com/isometry/github-token-manager/internal/ghapp" "github.com/isometry/github-token-manager/internal/metrics" // +kubebuilder:scaffold:imports ) @@ -47,6 +50,8 @@ import ( var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") + // version is stamped in via -ldflags "-X main.version=" at build time. + version = "dev" ) func init() { @@ -195,7 +200,7 @@ func main() { os.Exit(1) } - metricsRecorder, err := metrics.Setup() + metricsRecorder, err := metrics.Setup(version) if err != nil { setupLog.Error(err, "unable to set up metrics") os.Exit(1) @@ -206,24 +211,85 @@ func main() { } }() + operatorNamespace := getOperatorNamespace() + if operatorNamespace == "" { + setupLog.Error(nil, "operator namespace is unknown; set POD_NAMESPACE via the downward API") + os.Exit(1) + } + + startupCfg, err := ghapp.LoadConfig(ctx) + if err != nil { + setupLog.Error(err, "failed to load startup GitHub App configuration; continuing with App CRs only") + startupCfg = nil + } + if startupCfg != nil && startupCfg.GetAppID() == 0 { + setupLog.Info("no startup GitHub App configuration found; Tokens/ClusterTokens must set spec.appRef") + startupCfg = nil + } + + 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) + os.Exit(1) + } + if err = (&controller.TokenReconciler{ - Client: mgr.GetClient(), - Metrics: metricsRecorder, - // Scheme: mgr.GetScheme(), - // Recorder: mgr.GetEventRecorderFor("token-controller"), + Client: mgr.GetClient(), + Metrics: metricsRecorder, + Registry: registry, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Token") os.Exit(1) } if err = (&controller.ClusterTokenReconciler{ - Client: mgr.GetClient(), - Metrics: metricsRecorder, - // Scheme: mgr.GetScheme(), - // Recorder: mgr.GetEventRecorderFor("clustertoken-controller"), + Client: mgr.GetClient(), + Metrics: metricsRecorder, + Registry: registry, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterToken") os.Exit(1) } + if err = (&controller.AppReconciler{ + Client: mgr.GetClient(), + Metrics: metricsRecorder, + Registry: registry, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "App") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { @@ -257,3 +323,20 @@ func main() { os.Exit(1) } } + +// 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 "" +// when the operator runs outside a cluster and POD_NAMESPACE is unset. +func getOperatorNamespace() string { + if ns := os.Getenv("POD_NAMESPACE"); ns != "" { + return ns + } + const saNamespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + if data, err := os.ReadFile(saNamespaceFile); err == nil { + if ns := strings.TrimSpace(string(data)); ns != "" { + return ns + } + } + return "" +} diff --git a/config/crd/bases/github.as-code.io_apps.yaml b/config/crd/bases/github.as-code.io_apps.yaml new file mode 100644 index 0000000..67db53e --- /dev/null +++ b/config/crd/bases/github.as-code.io_apps.yaml @@ -0,0 +1,210 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: apps.github.as-code.io +spec: + group: github.as-code.io + names: + kind: App + listKind: AppList + plural: apps + shortNames: + - app + singular: app + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.appID + name: App ID + type: integer + - jsonPath: .spec.installationID + name: Installation ID + type: integer + - jsonPath: .spec.provider + name: Provider + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: |- + App is the Schema for the apps API; it encapsulates a GitHub App + configuration that Tokens and ClusterTokens may reference via spec.appRef. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + AppSpec defines the desired state of an App. + + Provider selects how the App's RSA private key is materialised. For cloud + KMS (aws/azure/gcp/vault) the key reference is supplied inline via Key. + For "secret" the key bytes live in a same-namespace Secret named by + KeyRef; this is the only supported way to use an inline PEM with an App, + because allowing arbitrary filesystem paths from a namespaced resource + would let any namespace owner read key material mounted on the controller + Pod. + properties: + appID: + description: The AppID of the GitHub App. + example: 12345 + format: int64 + minimum: 1 + type: integer + installationID: + description: |- + The default InstallationID of the GitHub App; Tokens/ClusterTokens may + override this via spec.installationID to target a different installation. + example: 123456789 + format: int64 + minimum: 1 + type: integer + key: + description: |- + Cloud-KMS key reference. Required when provider is "aws", "azure", + "gcp", or "vault"; forbidden when provider is "secret". The exact + shape depends on the provider: KMS key alias/ID/ARN (aws), Azure Key + Vault key URL (azure), GCP KMS resource name (gcp), or Vault transit + sign path (vault). + type: string + keyRef: + description: |- + Same-namespace Secret reference holding the PEM-encoded RSA private + key. Required when provider is "secret"; forbidden otherwise. + properties: + key: + default: private-key.pem + description: |- + Key within the Secret's data map containing the PEM-encoded RSA + private key. Defaults to "private-key.pem", which matches the + filename GitHub uses when downloading App keys. + type: string + name: + description: Name of the Secret in the App's namespace. + maxLength: 253 + type: string + required: + - name + type: object + provider: + description: |- + Private key provider. One of "secret" (PEM material in a same-namespace + Secret, referenced by keyRef), "aws" (AWS KMS), "azure" (Azure Key + Vault), "gcp" (Google Cloud KMS), or "vault" (HashiCorp Vault transit). + The "file" provider is intentionally not supported on an App; use the + operator's startup configuration for file-based keys. + enum: + - secret + - aws + - azure + - gcp + - vault + type: string + validateKey: + default: false + description: |- + If true, the operator validates the private key at reconcile time by + attempting a test sign. Failures surface as a KeyValid=False condition. + type: boolean + required: + - appID + - installationID + - provider + type: object + x-kubernetes-validations: + - message: keyRef must be set if and only if provider is 'secret' + rule: (self.provider == 'secret') == has(self.keyRef) + - message: key must be set if and only if provider is not 'secret' + rule: (self.provider != 'secret') == has(self.key) + status: + description: AppStatus defines the observed state of an App. + properties: + conditions: + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/github.as-code.io_clustertokens.yaml b/config/crd/bases/github.as-code.io_clustertokens.yaml index 3b1304e..2447713 100644 --- a/config/crd/bases/github.as-code.io_clustertokens.yaml +++ b/config/crd/bases/github.as-code.io_clustertokens.yaml @@ -39,6 +39,26 @@ spec: spec: description: ClusterTokenSpec defines the desired state of ClusterToken properties: + appRef: + description: |- + Reference to the App that provides the GitHub App credentials for this + ClusterToken. When spec.appRef.namespace is empty, the operator resolves + the reference in its own namespace. When unset, the operator's startup + configuration is used. + properties: + name: + description: Name of the App resource. + maxLength: 253 + type: string + namespace: + description: |- + Namespace containing the App resource. If empty, defaults to the + operator's own namespace. + maxLength: 253 + type: string + required: + - name + type: object installationID: description: Specify or override the InstallationID of the GitHub diff --git a/config/crd/bases/github.as-code.io_tokens.yaml b/config/crd/bases/github.as-code.io_tokens.yaml index 10850fa..9816ef4 100644 --- a/config/crd/bases/github.as-code.io_tokens.yaml +++ b/config/crd/bases/github.as-code.io_tokens.yaml @@ -39,6 +39,21 @@ spec: spec: description: TokenSpec defines the desired state of Token properties: + appRef: + description: |- + Reference to the App that provides the GitHub App credentials for this + Token. Must be in the same namespace as the Token. When unset, the + operator's startup configuration is used. + properties: + name: + description: + Name of the App resource in the same namespace as + the referring Token. + maxLength: 253 + type: string + required: + - name + type: object installationID: description: Specify or override the InstallationID of the GitHub diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index e458536..1af1d8c 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -97,4 +97,5 @@ spec: - name: config secret: defaultMode: 444 + optional: true secretName: gtm-config diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index c93169f..f4ce651 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -26,26 +26,17 @@ rules: - apiGroups: - github.as-code.io resources: + - apps - clustertokens - tokens verbs: - - create - - delete - get - list - - patch - - update - watch - apiGroups: - github.as-code.io resources: - - clustertokens/finalizers - - tokens/finalizers - verbs: - - update -- apiGroups: - - github.as-code.io - resources: + - apps/status - clustertokens/status - tokens/status verbs: diff --git a/deploy/charts/github-token-manager/Chart.yaml b/deploy/charts/github-token-manager/Chart.yaml index bf60fe4..06a6a33 100644 --- a/deploy/charts/github-token-manager/Chart.yaml +++ b/deploy/charts/github-token-manager/Chart.yaml @@ -4,4 +4,4 @@ name: github-token-manager description: A Helm chart for github-token-manager type: application -version: 0.3.0 +version: 0.4.0 diff --git a/deploy/charts/github-token-manager/README.md b/deploy/charts/github-token-manager/README.md index 3b886e0..71c70c4 100644 --- a/deploy/charts/github-token-manager/README.md +++ b/deploy/charts/github-token-manager/README.md @@ -24,7 +24,8 @@ The following table lists the most relevant configurable parameters of the GitHu | Parameter | Description | Default | | --- | --- |-----------------------| -config.app_id | GitHub App ID | `0` | +config.secretName | Name of the Secret mounted at `/config` (chart only creates it when `config.app_id` is non-zero) | `gtm-config` | +config.app_id | GitHub App ID (`0` = skip Secret creation; operator runs in App-CR-only mode if `config.secretName` is not mountable) | `0` | config.installation_id | GitHub App Installation ID | `0` | config.provider | GitHub App Private Key Provider | `aws` | config.key | GitHub App Private Key Path | `alias/github-token-manager` | @@ -58,3 +59,65 @@ serviceAccount: ``` The role used requires `kms:DescribeKey` and `kms:Sign` permission on the KMS key. + +### Optional startup config + +The startup `Secret/gtm-config` is no longer required — the operator's `/config` volume is mounted with `optional: true`. Two non-default modes are supported: + +- **App-CR-only**: leave `config.app_id` at its default `0`. The chart skips the Secret entirely and the manager Pod starts cleanly. Tokens / ClusterTokens that omit `spec.appRef` will surface a `Ready=False` condition with reason `NoStartupConfig` until they're pointed at an `App` resource. See the [Multiple GitHub Apps](../../../README.md#multiple-github-apps-app-crd) section. +- **Bring-your-own Secret**: set `config.secretName: my-creds` (and leave `config.app_id` at `0`). Pre-create a Secret of that name with a `gtm.yaml` key in the same shape the chart would render, e.g. via External Secrets Operator or Sealed Secrets. + +### Security model: ClusterToken and App references + +`ClusterToken` is cluster-scoped and `spec.appRef.namespace` accepts any namespace. The operator runs with cluster-wide read on `App` resources, so there is no Kubernetes RBAC barrier between a `ClusterToken` creator and the `App`s they may reference. + +**Granting `create` or `update` on `ClusterToken` is therefore equivalent to granting use of every `App` in every namespace** — including any `App` in the operator's own namespace. + +In multi-tenant clusters, restrict `ClusterToken` write permissions to cluster administrators, or enforce a `spec.appRef.namespace` allow-list with an admission policy (Kyverno, OPA Gatekeeper, or `ValidatingAdmissionPolicy`). The namespaced `Token` does not have this concern: it can only reference `App`s in its own namespace. + +## Observability + +The operator exposes a Prometheus `/metrics` endpoint served by controller-runtime. + +### Custom metrics + +Emitted via OpenTelemetry: + +| Metric | Type | Labels | +| --- | --- | --- | +| `token_refresh_total` | counter | `controller`, `result` | +| `token_refresh_duration_seconds` | histogram | `controller`, `operation` | +| `github_api_call_duration_seconds` | histogram | `controller`, `result` | +| `github_api_requests_total` | counter | `controller`, `result` | +| `token_expiry_timestamp_seconds` | gauge | `controller`, `namespace`, `name` | +| `token_reconcile_errors_total` | counter | `controller`, `reason` | +| `tokens_active` | gauge | `controller` | +| `kubernetes_secret_operations_total` | counter | `controller`, `operation`, `result` | +| `config_errors_total` | counter | `controller`, `source` | + +`controller` values are `github-token`, `github-clustertoken`, or `github-app` — matching controller-runtime's own `controller_runtime_*` and `workqueue_*` labels so the two can be joined. + +The `target_info` series carries the OTEL Resource attributes, including `service_name="github-token-manager"` and `service_version=`. + +### Datadog autodiscovery + +To avoid metric-name collisions with other operators in the same cluster (e.g. `go_goroutines`, `process_resident_memory_bytes`, `rest_client_requests_total`), configure the Datadog OpenMetrics check with a `namespace:` prefix. Every metric will then land as `github_token_manager.*` in Datadog. + +Example pod annotation (`podAnnotations` in `values.yaml`): + +```yaml +podAnnotations: + ad.datadoghq.com/manager.checks: | + { + "openmetrics": { + "init_config": {}, + "instances": [{ + "openmetrics_endpoint": "http://%%host%%:8080/metrics", + "namespace": "github_token_manager", + "metrics": [".*"] + }] + } + } +``` + +Adjust the endpoint scheme/port to match your `metrics-bind-address` / `metrics-secure` flags. diff --git a/deploy/charts/github-token-manager/templates/config.yaml b/deploy/charts/github-token-manager/templates/config.yaml index e738659..bbf9310 100644 --- a/deploy/charts/github-token-manager/templates/config.yaml +++ b/deploy/charts/github-token-manager/templates/config.yaml @@ -3,7 +3,7 @@ apiVersion: v1 kind: Secret metadata: - name: gtm-config + name: {{ .Values.config.secretName }} {{- with (default dict .Values.commonAnnotations) }} annotations: {{- range $key, $value := . }} diff --git a/deploy/charts/github-token-manager/templates/crds.yaml b/deploy/charts/github-token-manager/templates/crds.yaml index 1c82d5f..36e40a4 100644 --- a/deploy/charts/github-token-manager/templates/crds.yaml +++ b/deploy/charts/github-token-manager/templates/crds.yaml @@ -47,6 +47,26 @@ spec: spec: description: ClusterTokenSpec defines the desired state of ClusterToken properties: + appRef: + description: |- + Reference to the App that provides the GitHub App credentials + for this ClusterToken. When spec.appRef.namespace is empty, the + operator resolves the reference in its own namespace. When unset, + the operator's startup configuration is used. + properties: + name: + description: Name of the App resource. + maxLength: 253 + type: string + namespace: + description: |- + Namespace containing the App resource. If empty, defaults to + the operator's own namespace. + maxLength: 253 + type: string + required: + - name + type: object installationID: description: Specify or override the InstallationID of the GitHub @@ -444,6 +464,20 @@ spec: spec: description: TokenSpec defines the desired state of Token properties: + appRef: + description: |- + Reference to the App that provides the GitHub App credentials + for this Token. Must be in the same namespace as the Token. + When unset, the operator's startup configuration is used. + properties: + name: + description: Name of the App resource in the same namespace + as the referring Token. + maxLength: 253 + type: string + required: + - name + type: object installationID: description: Specify or override the InstallationID of the GitHub @@ -788,4 +822,178 @@ spec: storage: true subresources: status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: apps.github.as-code.io + {{- with mergeOverwrite (default dict .Values.commonAnnotations) (ternary (dict "helm.sh/resource-policy" "keep") (dict) .Values.crds.keep) }} + annotations: + {{- range $key, $value := . }} + {{ $key }}: {{ tpl $value $ | quote }} + {{- end }} + {{- end }} + labels: + component: crd + {{- include "labels" . | nindent 4 }} +spec: + group: github.as-code.io + names: + kind: App + listKind: AppList + plural: apps + shortNames: + - app + singular: app + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.appID + name: App ID + type: integer + - jsonPath: .spec.installationID + name: Installation ID + type: integer + - jsonPath: .spec.provider + name: Provider + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: |- + App is the Schema for the apps API; it encapsulates a GitHub App + configuration that Tokens and ClusterTokens may reference via spec.appRef. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + description: AppSpec defines the desired state of an App. + properties: + appID: + description: The AppID of the GitHub App. + example: 12345 + format: int64 + type: integer + installationID: + description: |- + The default InstallationID of the GitHub App; Tokens/ClusterTokens + may override this via spec.installationID to target a different + installation. + example: 123456789 + format: int64 + type: integer + key: + description: |- + Cloud-KMS key reference. Required when provider is "aws", "azure", + "gcp", or "vault"; forbidden when provider is "secret". + type: string + keyRef: + description: |- + Same-namespace Secret reference holding the PEM-encoded RSA + private key. Required when provider is "secret"; forbidden + otherwise. + properties: + name: + description: Name of the Secret in the App's namespace. + maxLength: 253 + type: string + key: + default: private-key.pem + description: |- + Key within the Secret's data map containing the PEM-encoded + RSA private key. Defaults to "private-key.pem". + type: string + required: + - name + type: object + provider: + description: |- + Private key provider. One of "secret" (PEM material in a + same-namespace Secret, referenced by keyRef), "aws" (AWS KMS), + "azure" (Azure Key Vault), "gcp" (Google Cloud KMS), or "vault" + (HashiCorp Vault transit). The "file" provider is intentionally + not supported on an App; use the operator's startup configuration + for file-based keys. + enum: + - secret + - aws + - azure + - gcp + - vault + type: string + validateKey: + default: false + description: |- + If true, the operator validates the private key at reconcile time + by attempting a test sign. + type: boolean + required: + - appID + - installationID + - provider + type: object + x-kubernetes-validations: + - rule: "(self.provider == 'secret') == has(self.keyRef)" + message: "keyRef must be set if and only if provider is 'secret'" + - rule: "(self.provider != 'secret') == has(self.key)" + message: "key must be set if and only if provider is not 'secret'" + status: + description: AppStatus defines the observed state of an App. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + format: date-time + type: string + message: + maxLength: 32768 + type: string + observedGeneration: + format: int64 + minimum: 0 + type: integer + reason: + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} {{- end }} diff --git a/deploy/charts/github-token-manager/templates/deployment.yaml b/deploy/charts/github-token-manager/templates/deployment.yaml index 597cbd6..680d73e 100644 --- a/deploy/charts/github-token-manager/templates/deployment.yaml +++ b/deploy/charts/github-token-manager/templates/deployment.yaml @@ -53,10 +53,14 @@ spec: - --{{ $key }}={{ $value }} {{- end }} {{- end }} - {{- with $manager.env }} env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- with $manager.env }} {{- toYaml . | nindent 12 }} - {{- end }} + {{- end }} image: {{ if (hasPrefix "sha256:" (default "" $manager.tag)) -}} {{- printf "%s@%s" (tpl $manager.repository .) $manager.tag -}} {{- else -}} @@ -107,4 +111,5 @@ spec: - name: config secret: defaultMode: 444 - secretName: gtm-config + optional: true + secretName: {{ .Values.config.secretName }} diff --git a/deploy/charts/github-token-manager/templates/rbac.yaml b/deploy/charts/github-token-manager/templates/rbac.yaml index 22cf1f0..cb30fe1 100644 --- a/deploy/charts/github-token-manager/templates/rbac.yaml +++ b/deploy/charts/github-token-manager/templates/rbac.yaml @@ -118,32 +118,8 @@ rules: - apiGroups: - github.as-code.io resources: + - apps - clustertokens - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - - apiGroups: - - github.as-code.io - resources: - - clustertokens/finalizers - verbs: - - update - - apiGroups: - - github.as-code.io - resources: - - clustertokens/status - verbs: - - get - - patch - - update - - apiGroups: - - github.as-code.io - resources: - tokens verbs: - create @@ -156,12 +132,16 @@ rules: - apiGroups: - github.as-code.io resources: + - apps/finalizers + - clustertokens/finalizers - tokens/finalizers verbs: - update - apiGroups: - github.as-code.io resources: + - apps/status + - clustertokens/status - tokens/status verbs: - get diff --git a/deploy/charts/github-token-manager/values.yaml b/deploy/charts/github-token-manager/values.yaml index 09dc294..e012fdb 100644 --- a/deploy/charts/github-token-manager/values.yaml +++ b/deploy/charts/github-token-manager/values.yaml @@ -1,5 +1,11 @@ --- config: + # secretName: name of the Secret mounted at /config containing the startup + # gtm.yaml. Defaults to 'gtm-config'. The chart only creates this Secret when + # config.app_id is non-zero; with config.app_id == 0 the operator starts in + # App-CR-only mode, or you can pre-create a Secret of this name out-of-band + # (e.g. via External Secrets Operator or Sealed Secrets). + secretName: gtm-config app_id: 0 installation_id: 0 provider: aws diff --git a/go.mod b/go.mod index 153966b..d46df94 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/isometry/github-token-manager -go 1.26.1 +go 1.26.2 require ( github.com/go-logr/logr v1.4.3 @@ -10,13 +10,14 @@ require ( github.com/onsi/gomega v1.38.2 github.com/spf13/viper v1.21.0 go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/prometheus v0.64.0 + go.opentelemetry.io/otel/exporters/prometheus v0.65.0 go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 golang.org/x/oauth2 v0.36.0 - k8s.io/api v0.35.3 - k8s.io/apimachinery v0.35.3 - k8s.io/client-go v0.35.3 + k8s.io/api v0.35.4 + k8s.io/apimachinery v0.35.4 + k8s.io/client-go v0.35.4 sigs.k8s.io/controller-runtime v0.23.3 sigs.k8s.io/yaml v1.6.0 ) @@ -24,35 +25,35 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.19.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.6.0 // indirect - cloud.google.com/go/kms v1.26.0 // indirect - cloud.google.com/go/longrunning v0.8.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect + cloud.google.com/go/iam v1.9.0 // indirect + cloud.google.com/go/kms v1.29.0 // indirect + cloud.google.com/go/longrunning v0.11.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/internal v1.11.2 // 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.4.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.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 // 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.5 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect - github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.50.5 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect + github.com/aws/smithy-go v1.25.1 // 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 @@ -64,40 +65,40 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/swag v0.25.5 // indirect - github.com/go-openapi/swag/cmdutils v0.25.5 // indirect - github.com/go-openapi/swag/conv v0.25.5 // indirect - github.com/go-openapi/swag/fileutils v0.25.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-openapi/swag/jsonutils v0.25.5 // indirect - github.com/go-openapi/swag/loading v0.25.5 // indirect - github.com/go-openapi/swag/mangling v0.25.5 // indirect - github.com/go-openapi/swag/netutils v0.25.5 // indirect - github.com/go-openapi/swag/stringutils v0.25.5 // indirect - github.com/go-openapi/swag/typeutils v0.25.5 // indirect - github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofri/go-github-ratelimit/v2 v2.0.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.27.0 // indirect + github.com/google/cel-go v0.28.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.20.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -118,7 +119,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -133,44 +134,43 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/api v0.273.0 // indirect - google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/api v0.276.0 // indirect + google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apiextensions-apiserver v0.35.3 // indirect - k8s.io/apiserver v0.35.3 // indirect - k8s.io/component-base v0.35.3 // indirect + k8s.io/apiextensions-apiserver v0.35.4 // indirect + k8s.io/apiserver v0.35.4 // indirect + k8s.io/component-base v0.35.4 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 // indirect + k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f // indirect k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect ) diff --git a/go.sum b/go.sum index 1fdfec0..5927063 100644 --- a/go.sum +++ b/go.sum @@ -2,68 +2,68 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ= -cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.6.0 h1:JiSIcEi38dWBKhB3BtfKCW+dMvCZJEhBA2BsaGJgoxs= -cloud.google.com/go/iam v1.6.0/go.mod h1:ZS6zEy7QHmcNO18mjO2viYv/n+wOUkhJqGNkPPGueGU= -cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= -cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= -cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +cloud.google.com/go/iam v1.9.0 h1:89wyjxT6DL4b5rk/Nk8eBC9DHqf+JiMstrn5IEYxFw4= +cloud.google.com/go/iam v1.9.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.29.0 h1:bAW1C5FQf+6GhPkywQzPlsULALCG7c16qpXLFGV9ivY= +cloud.google.com/go/kms v1.29.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v0.11.0 h1:fE4XVLJQj+gRnw1HrbDyQXXgC0aiqY3wxP7DDU4cWk0= +cloud.google.com/go/longrunning v0.11.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/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +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.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 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.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg= -github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= -github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 h1:PgD1y0ZagPokGIZPmejCBUySBzOFDN+leZxCOfb1OEQ= -github.com/aws/aws-sdk-go-v2/service/kms v1.50.4/go.mod h1:FfXDb5nXrsoGgxsBFxwxr3vdHXheC2tV+6lmuLghhjQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= +github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.5 h1:nEzwx/ZlpUZ2Y6WztsgYmfBh5Ixd3QiECawXMzvTMeo= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.5/go.mod h1:GBO/aaEi47QldDVoqw2CsM2UZQDoqDiFIMJD/ztHPs0= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/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= @@ -102,8 +102,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -119,40 +119,40 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= -github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= -github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= -github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= -github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= -github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= -github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= -github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= -github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= -github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= -github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= -github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= -github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= -github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= -github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= -github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= -github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= -github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= -github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= -github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= -github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= -github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -171,8 +171,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= -github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -191,12 +191,12 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs= -github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -270,8 +270,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -320,18 +321,18 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= -go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= -go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -352,45 +353,45 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= 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/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= 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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= -google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= +google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529 h1:QoMBg0moLIlB/eucPzc+ID5SgPZWuirtjAn3l8nW2Dg= +google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529/go.mod h1:EjLmDZ8liSLBrCTK5vP+bGIxRQHE3ovGvOI0CzGk1PI= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 h1:zUWMZsvo/IJcD1t6MNCPO/azZTwz0TvwCBqr5aifoVY= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529/go.mod h1:a5OGAgyRr4lqco7AG9hQM9Fwh0N2ZV4grR0eXFEsXQg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -400,22 +401,22 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= -k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= -k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqHD8FiU= -k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= -k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= -k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= -k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= -k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= -k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= -k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= -k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= +k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= +k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= +k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TCgtOUU= +k8s.io/apiextensions-apiserver v0.35.4/go.mod h1:ogQlk+stIE8mnoRthSYCwlOS12fVqgWFiErMwPaXA7c= +k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= +k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/apiserver v0.35.4 h1:vtuFqNFmF9bPRdHDL2lpK6qCTPWDreZJL4LRPwVM6ho= +k8s.io/apiserver v0.35.4/go.mod h1:JnBcb+J8kFXKpZkgcbcUnPBBHi4qgBii1I7dLxFY/oo= +k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= +k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= +k8s.io/component-base v0.35.4 h1:6n1tNJ87johN0Hif0Fs8K2GMthsaUwMqCebUDLYyv7U= +k8s.io/component-base v0.35.4/go.mod h1:qaDJgz5c1KYKla9occFmlJEfPpkuA55s90G509R+PeY= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 h1:Sztf7ESG9tAXRW/ACJZjrj5jhdOUqS2KFRQT+CTvu78= -k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f h1:4Qiq0YAoQATdgmHALJWz9rJ4fj20pB3xebpB4CFNhYM= +k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= @@ -426,7 +427,7 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/controller/app_controller.go b/internal/controller/app_controller.go new file mode 100644 index 0000000..5a10756 --- /dev/null +++ b/internal/controller/app_controller.go @@ -0,0 +1,216 @@ +/* +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 controller + +import ( + "context" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + githubv1 "github.com/isometry/github-token-manager/api/v1" + "github.com/isometry/github-token-manager/internal/ghapp" + "github.com/isometry/github-token-manager/internal/metrics" +) + +// appRetryInterval controls how often we requeue after a failed client build. +const appRetryInterval = time.Minute + +// AppReconciler reconciles an App resource by (re)building a cached ghait +// client in the shared [ghapp.Registry] and surfacing its readiness via +// status conditions. +type AppReconciler struct { + client.Client + Metrics *metrics.Recorder + Registry *ghapp.Registry +} + +// +kubebuilder:rbac:groups=github.as-code.io,resources=apps,verbs=get;list;watch +// +kubebuilder:rbac:groups=github.as-code.io,resources=apps/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch + +func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + key := ghapp.Key{Namespace: req.Namespace, Name: req.Name} + + app := &githubv1.App{} + if err := r.Get(ctx, req.NamespacedName, app); err != nil { + if apierrors.IsNotFound(err) { + r.Registry.Invalidate(key) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + cfg, version, reason, resolveErr := resolveAppConfig(ctx, r.Client, app) + var ( + buildErr error + failure string + ) + if resolveErr != nil { + buildErr = resolveErr + failure = reason + } else { + _, err := r.Registry.ForApp(ctx, key, version, cfg) + if err != nil { + buildErr = err + failure = githubv1.ReasonSetupFailed + } + } + if buildErr != nil { + logger.Error(buildErr, "failed to build GitHub App client", "app", req.NamespacedName) + if r.Metrics != nil { + r.Metrics.RecordConfigError(ctx, "github-app", "app") + } + r.Registry.Invalidate(key) + + changed := app.SetStatusCondition(metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: failure, + Message: buildErr.Error(), + }) + if app.Spec.ValidateKey { + if app.SetStatusCondition(metav1.Condition{ + Type: githubv1.ConditionTypeKeyValid, + Status: metav1.ConditionFalse, + Reason: failure, + Message: buildErr.Error(), + }) { + changed = true + } + } else if meta.RemoveStatusCondition(&app.Status.Conditions, githubv1.ConditionTypeKeyValid) { + changed = true + } + if app.Status.ObservedGeneration != app.Generation { + app.Status.ObservedGeneration = app.Generation + changed = true + } + if changed { + if err := r.Status().Update(ctx, app); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{RequeueAfter: appRetryInterval}, nil + } + + changed := app.SetStatusCondition(metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: githubv1.ReasonReconciled, + Message: "GitHub App client ready", + }) + if app.Spec.ValidateKey { + if app.SetStatusCondition(metav1.Condition{ + Type: githubv1.ConditionTypeKeyValid, + Status: metav1.ConditionTrue, + Reason: githubv1.ReasonReconciled, + Message: "signer key validated", + }) { + changed = true + } + } else if meta.RemoveStatusCondition(&app.Status.Conditions, githubv1.ConditionTypeKeyValid) { + changed = true + } + if app.Status.ObservedGeneration != app.Generation { + app.Status.ObservedGeneration = app.Generation + changed = true + } + if changed { + if err := r.Status().Update(ctx, app); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil +} + +// mapSecretToApps enqueues every App in the Secret's namespace whose +// spec.keyRef.name == secret.Name. Apps may only reference Secrets in their +// own namespace, so a cluster-wide Secret watch is fanned out per namespace +// here. +func (r *AppReconciler) mapSecretToApps(ctx context.Context, obj client.Object) []reconcile.Request { + secret, ok := obj.(*corev1.Secret) + if !ok { + return nil + } + var apps githubv1.AppList + if err := r.List(ctx, &apps, + client.InNamespace(secret.Namespace), + client.MatchingFields{AppKeyRefIndex: secret.Name}, + ); err != nil { + log.FromContext(ctx).Error(err, "failed to list Apps for Secret", "secret", client.ObjectKeyFromObject(secret)) + return nil + } + out := make([]reconcile.Request, 0, len(apps.Items)) + for i := range apps.Items { + out = append(out, reconcile.Request{NamespacedName: types.NamespacedName{ + Namespace: apps.Items[i].Namespace, + Name: apps.Items[i].Name, + }}) + } + return out +} + +// secretReferencedByApp reports whether at least one App in the Secret's +// namespace references it via spec.keyRef.name. Used to gate the Secret +// watch so unrelated cluster Secret churn doesn't drive mapper invocations. +// On a transient cache error the event is allowed through; the mapper will +// log and short-circuit if the index is still empty. +func (r *AppReconciler) secretReferencedByApp(obj client.Object) bool { + secret, ok := obj.(*corev1.Secret) + if !ok { + return false + } + var apps githubv1.AppList + if err := r.List(context.Background(), &apps, + client.InNamespace(secret.Namespace), + client.MatchingFields{AppKeyRefIndex: secret.Name}, + client.Limit(1), + ); err != nil { + return true + } + return len(apps.Items) > 0 +} + +// SetupWithManager sets up the controller with the Manager. +func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&githubv1.App{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Named("github-app"). + Watches(&corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.mapSecretToApps), + builder.WithPredicates( + predicate.ResourceVersionChangedPredicate{}, + predicate.NewPredicateFuncs(r.secretReferencedByApp), + ), + ). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + Complete(r) +} diff --git a/internal/controller/appconfig.go b/internal/controller/appconfig.go new file mode 100644 index 0000000..8ece500 --- /dev/null +++ b/internal/controller/appconfig.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 controller + +import ( + "context" + "fmt" + "strconv" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + githubv1 "github.com/isometry/github-token-manager/api/v1" + "github.com/isometry/github-token-manager/internal/ghapp" +) + +// AppKeyRefIndex is the field-indexer key used to watch Secrets and map them +// back to the Apps that reference them via spec.keyRef.name. +const AppKeyRefIndex = ".spec.keyRef.name" + +// defaultKeyRefDataKey matches the kubebuilder default on +// AppSpec.KeyRef.Key; mirrored here so the in-process Get path agrees with +// any object that bypassed defaulting (e.g. tests using the typed client). +const defaultKeyRefDataKey = "private-key.pem" + +// resolveAppConfig returns an [*ghapp.OperatorConfig] for the given App +// together with a version string capturing every input that affects client +// identity. +// +// Cloud-KMS configs pass through unchanged with the version set to the App's +// spec generation. provider:"secret" configs translate to ghait's file +// provider with the literal PEM bytes in Key (its os.Stat fallback handles +// literal PEM bytes), and the version composes the spec generation with the +// referenced Secret's ResourceVersion so cached clients invalidate on key +// rotation. +// +// reason is one of the v1.Reason* constants when err is non-nil, suitable +// for the caller to write into a status condition. +func resolveAppConfig(ctx context.Context, c client.Reader, app *githubv1.App) (cfg *ghapp.OperatorConfig, version, reason string, err error) { + cfg = &ghapp.OperatorConfig{ + AppID: app.Spec.AppID, + InstallationID: app.Spec.InstallationID, + Provider: app.Spec.Provider, + Key: app.Spec.Key, + ValidateKey: app.Spec.ValidateKey, + } + + if app.Spec.Provider != "secret" { + return cfg, strconv.FormatInt(app.Generation, 10), "", nil + } + + dataKey := app.Spec.KeyRef.Key + if dataKey == "" { + dataKey = defaultKeyRefDataKey + } + + var secret corev1.Secret + 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", nn) + } + 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) + } + + cfg.Provider = "file" + cfg.Key = string(pemBytes) + return cfg, fmt.Sprintf("%d:%s", app.Generation, secret.ResourceVersion), "", nil +} diff --git a/internal/controller/appresolver.go b/internal/controller/appresolver.go new file mode 100644 index 0000000..7448ae4 --- /dev/null +++ b/internal/controller/appresolver.go @@ -0,0 +1,144 @@ +/* +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 controller + +import ( + "context" + "fmt" + "time" + + "github.com/isometry/ghait/v84" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + githubv1 "github.com/isometry/github-token-manager/api/v1" + "github.com/isometry/github-token-manager/internal/ghapp" +) + +// Field-indexer keys used to watch App changes and map them back to the +// Tokens/ClusterTokens that reference them. +const ( + TokenAppRefIndex = ".spec.appRef.name" + ClusterTokenAppRefIndex = ".spec.appRef" +) + +// appRefRetryInterval is how long a Token/ClusterToken waits before retrying +// when its App reference is unavailable. +const appRefRetryInterval = 30 * time.Second + +// appResolution describes the outcome of looking up the ghait client for a +// Token/ClusterToken's spec.appRef (or its absence, which falls back to the +// startup configuration). Exactly one of Client or FailCondition is populated. +type appResolution struct { + // Client, if non-nil, is the ghait client to use for minting tokens. + Client ghait.GHAIT + + // FailCondition, if non-nil, should be written to the owner's status and + // surfaced to the user. The caller should also requeue after + // RequeueAfter. + FailCondition *metav1.Condition + + // RequeueAfter is the duration after which the Token/ClusterToken should + // be re-reconciled when resolution failed. Zero when Client is set. + RequeueAfter time.Duration +} + +// resolveApp returns the ghait client for the given *AppReference. A nil ref +// falls back to the startup configuration. When the ref points to an +// unresolvable or not-yet-Ready App, a condition describing the problem is +// returned instead; the App watch will re-enqueue the owner when the +// situation changes. +// +// For ClusterToken callers, an empty ref.Namespace is resolved against the +// operator's own namespace. +func resolveApp(ctx context.Context, c client.Client, reg *ghapp.Registry, ref *githubv1.AppReference) appResolution { + if ref == nil { + cli, err := reg.Startup(ctx) + if err != nil { + return appResolution{ + FailCondition: &metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: githubv1.ReasonNoStartupConfig, + Message: err.Error(), + }, + RequeueAfter: appRefRetryInterval, + } + } + return appResolution{Client: cli} + } + + namespace := ref.Namespace + if namespace == "" { + namespace = reg.OperatorNamespace() + } + nn := types.NamespacedName{Namespace: namespace, Name: ref.Name} + + var app githubv1.App + if err := c.Get(ctx, nn, &app); err != nil { + if apierrors.IsNotFound(err) { + return appResolution{ + FailCondition: &metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: githubv1.ReasonAppNotFound, + Message: fmt.Sprintf("App %s not found", nn), + }, + RequeueAfter: appRefRetryInterval, + } + } + return appResolution{ + FailCondition: &metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: githubv1.ReasonSetupFailed, + Message: fmt.Sprintf("fetch App %s: %v", nn, err), + }, + RequeueAfter: appRefRetryInterval, + } + } + + if !meta.IsStatusConditionTrue(app.Status.Conditions, githubv1.ConditionTypeReady) { + return appResolution{ + FailCondition: &metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: githubv1.ReasonAppNotReady, + Message: fmt.Sprintf("App %s is not Ready", nn), + }, + RequeueAfter: appRefRetryInterval, + } + } + + key := ghapp.Key{Namespace: app.Namespace, Name: app.Name} + cli, ok := reg.Lookup(key) + if !ok { + return appResolution{ + FailCondition: &metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: githubv1.ReasonAppNotReady, + Message: fmt.Sprintf("App %s client not yet cached", nn), + }, + RequeueAfter: appRefRetryInterval, + } + } + return appResolution{Client: cli} +} diff --git a/internal/controller/clustertoken_controller.go b/internal/controller/clustertoken_controller.go index 3fdbdbb..3713b37 100644 --- a/internal/controller/clustertoken_controller.go +++ b/internal/controller/clustertoken_controller.go @@ -18,14 +18,16 @@ package controller import ( "context" - "time" + apierrors "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" githubv1 "github.com/isometry/github-token-manager/api/v1" "github.com/isometry/github-token-manager/internal/ghapp" @@ -36,48 +38,57 @@ import ( // ClusterTokenReconciler reconciles a ClusterToken object type ClusterTokenReconciler struct { client.Client - Metrics *metrics.Recorder - // Scheme *runtime.Scheme - // Recorder record.EventRecorder + Metrics *metrics.Recorder + Registry *ghapp.Registry } -// +kubebuilder:rbac:groups=github.as-code.io,resources=clustertokens,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=github.as-code.io,resources=clustertokens,verbs=get;list;watch // +kubebuilder:rbac:groups=github.as-code.io,resources=clustertokens/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=github.as-code.io,resources=clustertokens/finalizers,verbs=update +// +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 // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the ClusterToken object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. // // For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile func (r *ClusterTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { logger := log.FromContext(ctx) + logger.V(1).Info("reconcile start") - if app == nil { - app, err = ghapp.NewGHApp(ctx) - if err != nil { - r.Metrics.RecordConfigError(ctx, "ghapp") - logger.Error(err, "failed to load GitHub App credentials") - return ctrl.Result{RequeueAfter: time.Minute}, err + token := &githubv1.ClusterToken{} + if err := r.Get(ctx, req.NamespacedName, token); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil } + return ctrl.Result{}, err + } + + resolution := resolveApp(ctx, r.Client, r.Registry, token.GetAppRef()) + if resolution.FailCondition != nil { + r.Metrics.RecordConfigError(ctx, "github-clustertoken", "ghapp") + logger.Info("App reference unavailable", + "reason", resolution.FailCondition.Reason, + "message", resolution.FailCondition.Message, + ) + if token.SetStatusCondition(*resolution.FailCondition) { + if err := r.Status().Update(ctx, token); err != nil { + logger.Error(err, "failed to update ClusterToken status with AppRef failure") + return ctrl.Result{}, err + } + } + return ctrl.Result{RequeueAfter: resolution.RequeueAfter}, nil } - // Fetch Token instance - token := &githubv1.ClusterToken{} options := []tm.Option{ tm.WithReconciler(r), - tm.WithGHApp(app), + tm.WithGHApp(resolution.Client), tm.WithLogger(logger), tm.WithMetrics(r.Metrics), } - tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, options...) + tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, "github-clustertoken", options...) if err != nil { logger.Error(err, "failed to create ClusterToken reconciler") return ctrl.Result{}, err @@ -97,27 +108,36 @@ func (r *ClusterTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request return result, nil } -func ignoreClusterTokenStatusUpdatePredicate() predicate.Predicate { - return predicate.Funcs{ - UpdateFunc: func(e event.UpdateEvent) bool { - oldToken, ok1 := e.ObjectOld.(*githubv1.ClusterToken) - newToken, ok2 := e.ObjectNew.(*githubv1.ClusterToken) - if ok1 && ok2 && oldToken.GetGeneration() == newToken.GetGeneration() { - // The generation has not changed, so ignore this update - return false - } - // The generation has changed, so handle this update - return true - }, +// mapAppToClusterTokens enqueues every ClusterToken whose spec.appRef +// resolves to this App. The field index already accounts for the operator +// namespace default, so a single lookup suffices. +func (r *ClusterTokenReconciler) mapAppToClusterTokens(ctx context.Context, obj client.Object) []reconcile.Request { + app, ok := obj.(*githubv1.App) + if !ok { + return nil + } + indexValue := app.Namespace + "/" + app.Name + var list githubv1.ClusterTokenList + if err := r.List(ctx, &list, client.MatchingFields{ClusterTokenAppRefIndex: indexValue}); err != nil { + log.FromContext(ctx).Error(err, "failed to list ClusterTokens for App", "app", client.ObjectKeyFromObject(app)) + return nil + } + requests := make([]reconcile.Request, 0, len(list.Items)) + for i := range list.Items { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])}) } + return requests } // SetupWithManager sets up the controller with the Manager. func (r *ClusterTokenReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&githubv1.ClusterToken{}). + For(&githubv1.ClusterToken{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Named("github-clustertoken"). - WithEventFilter(ignoreClusterTokenStatusUpdatePredicate()). - WithOptions(controller.Options{MaxConcurrentReconciles: 1}). // default + Watches(&githubv1.App{}, + handler.EnqueueRequestsFromMapFunc(r.mapAppToClusterTokens), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). Complete(r) } diff --git a/internal/controller/shared.go b/internal/controller/shared.go deleted file mode 100644 index c6236ce..0000000 --- a/internal/controller/shared.go +++ /dev/null @@ -1,9 +0,0 @@ -package controller - -import ( - "github.com/isometry/ghait/v84" -) - -var ( - app ghait.GHAIT // cached GHAIT instance -) diff --git a/internal/controller/token_controller.go b/internal/controller/token_controller.go index a5bcb1e..a35988c 100644 --- a/internal/controller/token_controller.go +++ b/internal/controller/token_controller.go @@ -18,14 +18,16 @@ package controller import ( "context" - "time" + apierrors "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" githubv1 "github.com/isometry/github-token-manager/api/v1" "github.com/isometry/github-token-manager/internal/ghapp" @@ -36,47 +38,57 @@ import ( // TokenReconciler reconciles a Token object type TokenReconciler struct { client.Client - Metrics *metrics.Recorder - // Scheme *runtime.Scheme - // Recorder record.EventRecorder + Metrics *metrics.Recorder + Registry *ghapp.Registry } -// +kubebuilder:rbac:groups=github.as-code.io,resources=tokens,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=github.as-code.io,resources=tokens,verbs=get;list;watch // +kubebuilder:rbac:groups=github.as-code.io,resources=tokens/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=github.as-code.io,resources=tokens/finalizers,verbs=update +// +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 // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the Token object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. // // For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile func (r *TokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { logger := log.FromContext(ctx) + logger.V(1).Info("reconcile start") - if app == nil { - app, err = ghapp.NewGHApp(ctx) - if err != nil { - r.Metrics.RecordConfigError(ctx, "ghapp") - logger.Error(err, "failed to load GitHub App credentials") - return ctrl.Result{RequeueAfter: time.Minute}, err + token := &githubv1.Token{} + if err := r.Get(ctx, req.NamespacedName, token); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil } + return ctrl.Result{}, err + } + + resolution := resolveApp(ctx, r.Client, r.Registry, token.GetAppRef()) + if resolution.FailCondition != nil { + r.Metrics.RecordConfigError(ctx, "github-token", "ghapp") + logger.Info("App reference unavailable", + "reason", resolution.FailCondition.Reason, + "message", resolution.FailCondition.Message, + ) + if token.SetStatusCondition(*resolution.FailCondition) { + if err := r.Status().Update(ctx, token); err != nil { + logger.Error(err, "failed to update Token status with AppRef failure") + return ctrl.Result{}, err + } + } + return ctrl.Result{RequeueAfter: resolution.RequeueAfter}, nil } - token := &githubv1.Token{} options := []tm.Option{ tm.WithReconciler(r), - tm.WithGHApp(app), + tm.WithGHApp(resolution.Client), tm.WithLogger(logger), tm.WithMetrics(r.Metrics), } - tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, options...) + tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, "github-token", options...) if err != nil { logger.Error(err, "failed to create Token reconciler") return ctrl.Result{}, err @@ -96,41 +108,38 @@ func (r *TokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (resu return result, nil } -func ignoreTokenStatusUpdatePredicate() predicate.Predicate { - return predicate.Funcs{ - UpdateFunc: func(e event.UpdateEvent) bool { - oldToken, ok1 := e.ObjectOld.(*githubv1.Token) - newToken, ok2 := e.ObjectNew.(*githubv1.Token) - if ok1 && ok2 && oldToken.GetGeneration() == newToken.GetGeneration() { - // The generation has not changed, so ignore this update - return false - } - // The generation has changed, so handle this update - return true - }, +// mapAppToTokens enqueues every Token in the App's namespace that references +// it via spec.appRef.name. Tokens are namespaced and may only reference Apps +// in their own namespace. +func (r *TokenReconciler) mapAppToTokens(ctx context.Context, obj client.Object) []reconcile.Request { + app, ok := obj.(*githubv1.App) + if !ok { + return nil + } + var list githubv1.TokenList + if err := r.List(ctx, &list, + client.InNamespace(app.Namespace), + client.MatchingFields{TokenAppRefIndex: app.Name}, + ); err != nil { + log.FromContext(ctx).Error(err, "failed to list Tokens for App", "app", client.ObjectKeyFromObject(app)) + return nil } + requests := make([]reconcile.Request, 0, len(list.Items)) + for i := range list.Items { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&list.Items[i])}) + } + return requests } -// func ignoreManagedSecretsPredicate() predicate.Predicate { -// return predicate.Funcs{ -// UpdateFunc: func(e event.UpdateEvent) bool { -// // Ignore updates to Secrets -// if _, isSecret := e.ObjectNew.(*corev1.Secret); isSecret { -// return false -// } -// return true -// }, -// } -// } - // SetupWithManager sets up the controller with the Manager. func (r *TokenReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&githubv1.Token{}). + For(&githubv1.Token{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Named("github-token"). - // Owns(&corev1.Secret{}). - WithEventFilter(ignoreTokenStatusUpdatePredicate()). - // WithEventFilter(ignoreManagedSecretsPredicate()). - WithOptions(controller.Options{MaxConcurrentReconciles: 1}). // default + Watches(&githubv1.App{}, + handler.EnqueueRequestsFromMapFunc(r.mapAppToTokens), + builder.WithPredicates(predicate.GenerationChangedPredicate{}), + ). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). Complete(r) } diff --git a/internal/ghapp/registry.go b/internal/ghapp/registry.go new file mode 100644 index 0000000..2d1067f --- /dev/null +++ b/internal/ghapp/registry.go @@ -0,0 +1,171 @@ +/* +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 ghapp + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/isometry/ghait/v84" +) + +// Key identifies an App in the registry. The zero value is reserved for the +// startup-config singleton (see [Registry.Startup]). +type Key struct { + Namespace string + Name string +} + +// StartupKey is the reserved key for the operator's startup-config client. +var StartupKey = Key{} + +// FactoryFunc constructs a [ghait.GHAIT] client from a [ghait.Config]. It is +// a variable on the [Registry] so tests can inject a fake without touching +// network or KMS providers. +type FactoryFunc func(ctx context.Context, cfg ghait.Config) (ghait.GHAIT, error) + +var defaultFactory FactoryFunc = func(ctx context.Context, cfg ghait.Config) (ghait.GHAIT, error) { + return ghait.NewGHAIT(ctx, cfg) +} + +// ErrNoStartupConfig is returned by [Registry.Startup] when the operator was +// started without a usable GitHub App config and a Token/ClusterToken without +// an explicit appRef attempts to reconcile. +var ErrNoStartupConfig = errors.New("no startup GitHub App configuration loaded; set spec.appRef") + +type cachedClient struct { + client ghait.GHAIT + version string +} + +// Registry caches [ghait.GHAIT] clients keyed by App identity. The startup +// config lives under [StartupKey]; each App CR gets its own entry keyed by +// {namespace, name} and invalidated on generation change. +type Registry struct { + mu sync.RWMutex + clients map[Key]cachedClient + startupCfg *OperatorConfig + operatorNS string + factory FactoryFunc +} + +// NewRegistry builds a Registry. Pass a nil startupCfg to require that every +// Token/ClusterToken sets spec.appRef explicitly. +func NewRegistry(operatorNS string, startupCfg *OperatorConfig) *Registry { + return &Registry{ + clients: make(map[Key]cachedClient), + startupCfg: startupCfg, + operatorNS: operatorNS, + factory: defaultFactory, + } +} + +// SetFactory replaces the internal client factory. Intended for tests. +func (r *Registry) SetFactory(f FactoryFunc) { + r.mu.Lock() + defer r.mu.Unlock() + r.factory = f +} + +// OperatorNamespace returns the operator's own namespace, used to default an +// unset ClusterToken.spec.appRef.namespace. +func (r *Registry) OperatorNamespace() string { + return r.operatorNS +} + +// HasStartupConfig reports whether a startup GitHub App config is available. +func (r *Registry) HasStartupConfig() bool { + return r.startupCfg != nil +} + +// Startup returns the cached startup-config client, building it on first use. +// Returns [ErrNoStartupConfig] if no startup config was loaded. +func (r *Registry) Startup(ctx context.Context) (ghait.GHAIT, error) { + if r.startupCfg == nil { + return nil, ErrNoStartupConfig + } + r.mu.RLock() + cached, ok := r.clients[StartupKey] + r.mu.RUnlock() + if ok { + return cached.client, nil + } + r.mu.Lock() + defer r.mu.Unlock() + if cached, ok := r.clients[StartupKey]; ok { + return cached.client, nil + } + client, err := r.factory(ctx, r.startupCfg) + if err != nil { + return nil, fmt.Errorf("startup GitHub App: %w", err) + } + r.clients[StartupKey] = cachedClient{client: client} + return client, nil +} + +// ForApp returns a cached client for the given App, building it when the +// cached entry's version differs from the supplied one. The version is an +// opaque string the caller derives from all inputs that affect client +// identity (typically the App's spec generation, plus any referenced +// Secret's ResourceVersion when the App is Secret-backed). +func (r *Registry) ForApp(ctx context.Context, key Key, version string, cfg ghait.Config) (ghait.GHAIT, error) { + if key == StartupKey { + return nil, errors.New("ForApp called with reserved startup key; use Startup()") + } + r.mu.RLock() + cached, ok := r.clients[key] + r.mu.RUnlock() + if ok && cached.version == version { + return cached.client, nil + } + r.mu.Lock() + defer r.mu.Unlock() + if cached, ok := r.clients[key]; ok && cached.version == version { + return cached.client, nil + } + client, err := r.factory(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("App %s/%s: %w", key.Namespace, key.Name, err) + } + r.clients[key] = cachedClient{client: client, version: version} + return client, nil +} + +// Lookup returns the cached client for key, if any. Unlike [Registry.ForApp] +// it never builds a new client — callers use this on the hot path +// (Token/ClusterToken reconcile) where the App reconciler is the authority +// for keeping the entry current. A miss means the App reconciler has not +// yet populated (or has invalidated) the entry; the caller should requeue +// and let the App watch re-trigger. +func (r *Registry) Lookup(key Key) (ghait.GHAIT, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + cached, ok := r.clients[key] + if !ok { + return nil, false + } + return cached.client, true +} + +// Invalidate drops the cache entry for key. Safe to call for unknown keys. +func (r *Registry) Invalidate(key Key) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.clients, key) +} diff --git a/internal/ghapp/registry_test.go b/internal/ghapp/registry_test.go new file mode 100644 index 0000000..1e59796 --- /dev/null +++ b/internal/ghapp/registry_test.go @@ -0,0 +1,148 @@ +package ghapp + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-github/v84/github" + "github.com/isometry/ghait/v84" +) + +// fakeGHAIT is a minimal implementation of [ghait.GHAIT] used only to +// distinguish identity in the registry tests. +type fakeGHAIT struct { + id int64 +} + +func (f *fakeGHAIT) GetAppID() int64 { return f.id } +func (f *fakeGHAIT) GetInstallationID() int64 { return 0 } +func (f *fakeGHAIT) NewInstallationToken(context.Context, int64, *github.InstallationTokenOptions) (*github.InstallationToken, error) { + return nil, nil +} +func (f *fakeGHAIT) NewToken(context.Context) (*github.InstallationToken, error) { return nil, nil } +func (f *fakeGHAIT) NewTokenWithOptions(context.Context, *github.InstallationTokenOptions) (*github.InstallationToken, error) { + return nil, nil +} + +func countingFactory() (FactoryFunc, *int) { + var calls int + return func(ctx context.Context, cfg ghait.Config) (ghait.GHAIT, error) { + calls++ + return &fakeGHAIT{id: cfg.GetAppID()}, nil + }, &calls +} + +func TestRegistry_Startup_NoConfig(t *testing.T) { + r := NewRegistry("gtm-system", nil) + if r.HasStartupConfig() { + t.Fatalf("HasStartupConfig() = true with nil cfg") + } + _, err := r.Startup(context.Background()) + if !errors.Is(err, ErrNoStartupConfig) { + t.Fatalf("Startup() err = %v, want ErrNoStartupConfig", err) + } +} + +func TestRegistry_Startup_CachesAcrossCalls(t *testing.T) { + cfg := &OperatorConfig{AppID: 1, InstallationID: 2, Provider: "file", Key: "inline"} + r := NewRegistry("gtm-system", cfg) + fac, calls := countingFactory() + r.SetFactory(fac) + + c1, err := r.Startup(context.Background()) + if err != nil { + t.Fatalf("Startup() err = %v", err) + } + c2, err := r.Startup(context.Background()) + if err != nil { + t.Fatalf("Startup() 2nd call err = %v", err) + } + if c1 != c2 { + t.Errorf("second Startup() returned a different client; want cached") + } + if *calls != 1 { + t.Errorf("factory calls = %d, want 1", *calls) + } +} + +func TestRegistry_ForApp_CachesByVersion(t *testing.T) { + r := NewRegistry("gtm-system", nil) + fac, calls := countingFactory() + r.SetFactory(fac) + + key := Key{Namespace: "team-a", Name: "prod"} + cfg := &OperatorConfig{AppID: 42, InstallationID: 7, Provider: "file", Key: "inline"} + ctx := context.Background() + + c1, err := r.ForApp(ctx, key, "1", cfg) + if err != nil { + t.Fatalf("ForApp() err = %v", err) + } + c2, err := r.ForApp(ctx, key, "1", cfg) + if err != nil { + t.Fatalf("ForApp() 2nd err = %v", err) + } + if c1 != c2 || *calls != 1 { + t.Errorf("same-version ForApp calls=%d, identity-equal=%v; want 1 build, identity-equal", *calls, c1 == c2) + } + + c3, err := r.ForApp(ctx, key, "2", cfg) + if err != nil { + t.Fatalf("ForApp() 3rd err = %v", err) + } + if c3 == c2 { + t.Errorf("version change did not rebuild client") + } + if *calls != 2 { + t.Errorf("factory calls after version change = %d, want 2", *calls) + } +} + +func TestRegistry_ForApp_RejectsStartupKey(t *testing.T) { + r := NewRegistry("gtm-system", nil) + _, err := r.ForApp(context.Background(), StartupKey, "", &OperatorConfig{}) + if err == nil { + t.Fatalf("ForApp(StartupKey) returned no error") + } +} + +func TestRegistry_Invalidate_EvictsEntry(t *testing.T) { + r := NewRegistry("gtm-system", nil) + fac, calls := countingFactory() + r.SetFactory(fac) + + key := Key{Namespace: "team-a", Name: "prod"} + cfg := &OperatorConfig{AppID: 42, Provider: "file", Key: "inline"} + ctx := context.Background() + + if _, err := r.ForApp(ctx, key, "5", cfg); err != nil { + t.Fatalf("ForApp() err = %v", err) + } + r.Invalidate(key) + if _, err := r.ForApp(ctx, key, "5", cfg); err != nil { + t.Fatalf("ForApp() after Invalidate err = %v", err) + } + if *calls != 2 { + t.Errorf("factory calls after Invalidate = %d, want 2", *calls) + } +} + +func TestRegistry_FactoryError_Propagates(t *testing.T) { + r := NewRegistry("gtm-system", &OperatorConfig{AppID: 1, Provider: "file", Key: "inline"}) + sentinel := errors.New("provider init failed") + r.SetFactory(func(context.Context, ghait.Config) (ghait.GHAIT, error) { + return nil, sentinel + }) + _, err := r.Startup(context.Background()) + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want to wrap %v", err, sentinel) + } +} + +func TestRegistry_OperatorNamespace(t *testing.T) { + r := NewRegistry("my-ns", nil) + if got := r.OperatorNamespace(); got != "my-ns" { + t.Errorf("OperatorNamespace() = %q, want my-ns", got) + } +} diff --git a/internal/metrics/recorder.go b/internal/metrics/recorder.go index c5f89fc..deec46c 100644 --- a/internal/metrics/recorder.go +++ b/internal/metrics/recorder.go @@ -36,12 +36,12 @@ type Recorder struct { tokenRefresh metric.Int64Counter tokenRefreshDuration metric.Float64Histogram githubAPIDuration metric.Float64Histogram + githubAPIRequests metric.Int64Counter tokenExpiry metric.Float64Gauge reconcileErrors metric.Int64Counter tokensActive metric.Int64UpDownCounter secretOperations metric.Int64Counter configErrors metric.Int64Counter - githubTokenRequests metric.Int64Counter activeTokens sync.Map } @@ -59,69 +59,69 @@ func newRecorder(meter metric.Meter) (*Recorder, error) { var r Recorder var err error - if r.tokenRefresh, err = meter.Int64Counter("gtm.token.refresh", + if r.tokenRefresh, err = meter.Int64Counter("token.refresh", metric.WithUnit("{refresh}"), metric.WithDescription("Total number of token refresh operations"), ); err != nil { return nil, err } - if r.tokenRefreshDuration, err = meter.Float64Histogram("gtm.token.refresh.duration", + if r.tokenRefreshDuration, err = meter.Float64Histogram("token.refresh.duration", metric.WithUnit("s"), metric.WithDescription("Duration of token refresh operations"), ); err != nil { return nil, err } - if r.githubAPIDuration, err = meter.Float64Histogram("gtm.github.api_call.duration", + if r.githubAPIDuration, err = meter.Float64Histogram("github.api.call.duration", metric.WithUnit("s"), metric.WithDescription("Duration of GitHub API calls"), ); err != nil { return nil, err } - if r.tokenExpiry, err = meter.Float64Gauge("gtm.token.expiry.timestamp", + if r.githubAPIRequests, err = meter.Int64Counter("github.api.requests", + metric.WithUnit("{request}"), + metric.WithDescription("Total number of GitHub API requests"), + ); err != nil { + return nil, err + } + + if r.tokenExpiry, err = meter.Float64Gauge("token.expiry.timestamp", metric.WithUnit("s"), metric.WithDescription("Unix timestamp when the token expires"), ); err != nil { return nil, err } - if r.reconcileErrors, err = meter.Int64Counter("gtm.reconcile.errors", + if r.reconcileErrors, err = meter.Int64Counter("token.reconcile.errors", metric.WithUnit("{error}"), - metric.WithDescription("Total number of reconciliation errors"), + metric.WithDescription("Total number of token-reconcile errors (by reason)"), ); err != nil { return nil, err } - if r.tokensActive, err = meter.Int64UpDownCounter("gtm.tokens.active", + if r.tokensActive, err = meter.Int64UpDownCounter("tokens.active", metric.WithUnit("{token}"), metric.WithDescription("Number of currently active tokens"), ); err != nil { return nil, err } - if r.secretOperations, err = meter.Int64Counter("gtm.secret.operations", + if r.secretOperations, err = meter.Int64Counter("kubernetes.secret.operations", metric.WithUnit("{operation}"), - metric.WithDescription("Total number of secret operations"), + metric.WithDescription("Total number of Kubernetes Secret operations performed by the operator"), ); err != nil { return nil, err } - if r.configErrors, err = meter.Int64Counter("gtm.config.errors", + if r.configErrors, err = meter.Int64Counter("config.errors", metric.WithUnit("{error}"), metric.WithDescription("Total number of configuration errors"), ); err != nil { return nil, err } - if r.githubTokenRequests, err = meter.Int64Counter("gtm.github.token.requests", - metric.WithUnit("{request}"), - metric.WithDescription("Total GitHub Installation Access Token requests"), - ); err != nil { - return nil, err - } - return &r, nil } @@ -151,8 +151,9 @@ func (r *Recorder) RecordTokenRefreshDuration(ctx context.Context, controllerNam ) } -// RecordGitHubAPIDuration records the duration of a GitHub API call. -func (r *Recorder) RecordGitHubAPIDuration(ctx context.Context, d time.Duration, err error) { +// RecordGitHubAPICall records a completed GitHub API call: increments the +// requests counter and observes the duration histogram with the same attributes. +func (r *Recorder) RecordGitHubAPICall(ctx context.Context, controllerName string, d time.Duration, err error) { if r == nil { return } @@ -160,11 +161,12 @@ func (r *Recorder) RecordGitHubAPIDuration(ctx context.Context, d time.Duration, if err != nil { result = ResultError } - r.githubAPIDuration.Record(ctx, d.Seconds(), - metric.WithAttributes( - attribute.String("result", result), - ), + attrs := metric.WithAttributes( + attribute.String("controller", controllerName), + attribute.String("result", result), ) + r.githubAPIRequests.Add(ctx, 1, attrs) + r.githubAPIDuration.Record(ctx, d.Seconds(), attrs) } // RecordTokenExpiry records the expiry timestamp for a token. @@ -227,7 +229,7 @@ func (r *Recorder) RemoveTokenActive(ctx context.Context, controllerName, tokenK ) } -// RecordSecretOperation records a secret create/update/delete operation. +// RecordSecretOperation records a Kubernetes Secret create/update/delete operation. func (r *Recorder) RecordSecretOperation(ctx context.Context, controllerName, operation, result string) { if r == nil { return @@ -241,30 +243,16 @@ func (r *Recorder) RecordSecretOperation(ctx context.Context, controllerName, op ) } -// RecordConfigError records a configuration loading error. -func (r *Recorder) RecordConfigError(ctx context.Context, source string) { +// RecordConfigError records a configuration loading error. source identifies the +// subsystem that failed (e.g. "ghapp", "app"). +func (r *Recorder) RecordConfigError(ctx context.Context, controllerName, source string) { if r == nil { return } r.configErrors.Add(ctx, 1, metric.WithAttributes( + attribute.String("controller", controllerName), attribute.String("source", source), ), ) } - -// RecordGitHubTokenRequest records a GitHub Installation Access Token request. -func (r *Recorder) RecordGitHubTokenRequest(ctx context.Context, err error) { - if r == nil { - return - } - result := ResultSuccess - if err != nil { - result = ResultError - } - r.githubTokenRequests.Add(ctx, 1, - metric.WithAttributes( - attribute.String("result", result), - ), - ) -} diff --git a/internal/metrics/recorder_test.go b/internal/metrics/recorder_test.go index b5971ab..ecab57f 100644 --- a/internal/metrics/recorder_test.go +++ b/internal/metrics/recorder_test.go @@ -16,18 +16,16 @@ func TestNilRecorderSafety(t *testing.T) { ctx := context.Background() // All methods must be callable on a nil receiver without panic. - r.RecordTokenRefresh(ctx, "token", ResultSuccess) - r.RecordTokenRefreshDuration(ctx, "token", OperationCreate, time.Second) - r.RecordGitHubAPIDuration(ctx, time.Second, nil) - r.RecordGitHubAPIDuration(ctx, time.Second, errors.New("test")) - r.RecordTokenExpiry(ctx, "token", "default", "my-token", time.Now()) - r.RecordReconcileError(ctx, "token", ReasonTransient) - r.EnsureTokenActive(ctx, "token", "default/my-token") - r.RemoveTokenActive(ctx, "token", "default/my-token") - r.RecordSecretOperation(ctx, "token", OperationCreate, ResultSuccess) - r.RecordConfigError(ctx, "file") - r.RecordGitHubTokenRequest(ctx, nil) - r.RecordGitHubTokenRequest(ctx, errors.New("x")) + r.RecordTokenRefresh(ctx, "github-token", ResultSuccess) + r.RecordTokenRefreshDuration(ctx, "github-token", OperationCreate, time.Second) + r.RecordGitHubAPICall(ctx, "github-token", time.Second, nil) + r.RecordGitHubAPICall(ctx, "github-token", time.Second, errors.New("test")) + r.RecordTokenExpiry(ctx, "github-token", "default", "my-token", time.Now()) + r.RecordReconcileError(ctx, "github-token", ReasonTransient) + r.EnsureTokenActive(ctx, "github-token", "default/my-token") + r.RemoveTokenActive(ctx, "github-token", "default/my-token") + r.RecordSecretOperation(ctx, "github-token", OperationCreate, ResultSuccess) + r.RecordConfigError(ctx, "github-token", "file") if err := r.Shutdown(ctx); err != nil { t.Errorf("Shutdown on nil receiver returned error: %v", err) } @@ -46,19 +44,17 @@ func TestRecorderInstruments(t *testing.T) { ctx := context.Background() // Record some values. - r.RecordTokenRefresh(ctx, "token", ResultSuccess) - r.RecordTokenRefresh(ctx, "token", ResultSuccess) - r.RecordTokenRefresh(ctx, "clustertoken", ResultError) - r.RecordTokenRefreshDuration(ctx, "token", OperationCreate, 500*time.Millisecond) - r.RecordGitHubAPIDuration(ctx, 200*time.Millisecond, nil) - r.RecordGitHubAPIDuration(ctx, 300*time.Millisecond, errors.New("rate limit")) - r.RecordTokenExpiry(ctx, "token", "default", "my-token", time.Unix(1700000000, 0)) - r.RecordReconcileError(ctx, "token", ReasonGitHubAPI) - r.EnsureTokenActive(ctx, "token", "default/my-token") - r.RecordSecretOperation(ctx, "token", OperationCreate, ResultSuccess) - r.RecordConfigError(ctx, "file") - r.RecordGitHubTokenRequest(ctx, nil) - r.RecordGitHubTokenRequest(ctx, errors.New("rate limit")) + r.RecordTokenRefresh(ctx, "github-token", ResultSuccess) + r.RecordTokenRefresh(ctx, "github-token", ResultSuccess) + r.RecordTokenRefresh(ctx, "github-clustertoken", ResultError) + r.RecordTokenRefreshDuration(ctx, "github-token", OperationCreate, 500*time.Millisecond) + r.RecordGitHubAPICall(ctx, "github-token", 200*time.Millisecond, nil) + r.RecordGitHubAPICall(ctx, "github-token", 300*time.Millisecond, errors.New("rate limit")) + r.RecordTokenExpiry(ctx, "github-token", "default", "my-token", time.Unix(1700000000, 0)) + r.RecordReconcileError(ctx, "github-token", ReasonGitHubAPI) + r.EnsureTokenActive(ctx, "github-token", "default/my-token") + r.RecordSecretOperation(ctx, "github-token", OperationCreate, ResultSuccess) + r.RecordConfigError(ctx, "github-app", "app") // Collect and verify. var rm metricdata.ResourceMetrics @@ -69,60 +65,63 @@ func TestRecorderInstruments(t *testing.T) { metrics := flattenMetrics(rm) // Verify token refresh counter. - assertCounterValue(t, metrics, "gtm.token.refresh", - attribute.String("controller", "token"), + assertCounterValue(t, metrics, "token.refresh", + attribute.String("controller", "github-token"), attribute.String("result", ResultSuccess), 2, ) - assertCounterValue(t, metrics, "gtm.token.refresh", - attribute.String("controller", "clustertoken"), + assertCounterValue(t, metrics, "token.refresh", + attribute.String("controller", "github-clustertoken"), attribute.String("result", ResultError), 1, ) // Verify reconcile errors counter. - assertCounterValue(t, metrics, "gtm.reconcile.errors", - attribute.String("controller", "token"), + assertCounterValue(t, metrics, "token.reconcile.errors", + attribute.String("controller", "github-token"), attribute.String("reason", ReasonGitHubAPI), 1, ) // Verify secret operations counter. - assertCounterValue(t, metrics, "gtm.secret.operations", - attribute.String("controller", "token"), + assertCounterValue(t, metrics, "kubernetes.secret.operations", + attribute.String("controller", "github-token"), attribute.String("operation", OperationCreate), attribute.String("result", ResultSuccess), 1, ) // Verify config errors counter. - assertCounterValue(t, metrics, "gtm.config.errors", - attribute.String("source", "file"), + assertCounterValue(t, metrics, "config.errors", + attribute.String("controller", "github-app"), + attribute.String("source", "app"), 1, ) // Verify tokens active up-down counter. - assertCounterValue(t, metrics, "gtm.tokens.active", - attribute.String("controller", "token"), + assertCounterValue(t, metrics, "tokens.active", + attribute.String("controller", "github-token"), 1, ) // Verify histogram has data points. - assertHistogramCount(t, metrics, "gtm.token.refresh.duration", 1) - assertHistogramCount(t, metrics, "gtm.github.api_call.duration", 2) + assertHistogramCount(t, metrics, "token.refresh.duration", 1) + assertHistogramCount(t, metrics, "github.api.call.duration", 2) - // Verify gauge value. - assertGaugeValue(t, metrics, "gtm.token.expiry.timestamp", 1700000000) - - // Verify GitHub token requests counter. - assertCounterValue(t, metrics, "gtm.github.token.requests", + // Verify github.api.requests counter ticks alongside the duration histogram. + assertCounterValue(t, metrics, "github.api.requests", + attribute.String("controller", "github-token"), attribute.String("result", ResultSuccess), 1, ) - assertCounterValue(t, metrics, "gtm.github.token.requests", + assertCounterValue(t, metrics, "github.api.requests", + attribute.String("controller", "github-token"), attribute.String("result", ResultError), 1, ) + + // Verify gauge value. + assertGaugeValue(t, metrics, "token.expiry.timestamp", 1700000000) } func TestActiveTokenIdempotency(t *testing.T) { @@ -138,27 +137,27 @@ func TestActiveTokenIdempotency(t *testing.T) { ctx := context.Background() // First call increments. - r.EnsureTokenActive(ctx, "token", "default/tok-a") + r.EnsureTokenActive(ctx, "github-token", "default/tok-a") assertActiveCount(t, reader, ctx, 1) // Duplicate call is a no-op. - r.EnsureTokenActive(ctx, "token", "default/tok-a") + r.EnsureTokenActive(ctx, "github-token", "default/tok-a") assertActiveCount(t, reader, ctx, 1) // Second distinct key increments again. - r.EnsureTokenActive(ctx, "token", "default/tok-b") + r.EnsureTokenActive(ctx, "github-token", "default/tok-b") assertActiveCount(t, reader, ctx, 2) // Remove one key. - r.RemoveTokenActive(ctx, "token", "default/tok-a") + r.RemoveTokenActive(ctx, "github-token", "default/tok-a") assertActiveCount(t, reader, ctx, 1) // Duplicate remove is a no-op. - r.RemoveTokenActive(ctx, "token", "default/tok-a") + r.RemoveTokenActive(ctx, "github-token", "default/tok-a") assertActiveCount(t, reader, ctx, 1) // Remove the other key. - r.RemoveTokenActive(ctx, "token", "default/tok-b") + r.RemoveTokenActive(ctx, "github-token", "default/tok-b") assertActiveCount(t, reader, ctx, 0) } @@ -169,9 +168,9 @@ func assertActiveCount(t *testing.T, reader *metric.ManualReader, ctx context.Co t.Fatalf("Collect: %v", err) } metrics := flattenMetrics(rm) - m, ok := metrics["gtm.tokens.active"] + m, ok := metrics["tokens.active"] if !ok { - t.Fatal("metric gtm.tokens.active not found") + t.Fatal("metric tokens.active not found") } data, ok := m.Data.(metricdata.Sum[int64]) if !ok { @@ -182,7 +181,7 @@ func assertActiveCount(t *testing.T, reader *metric.ManualReader, ctx context.Co total += dp.Value } if total != expected { - t.Errorf("gtm.tokens.active: got %d, want %d", total, expected) + t.Errorf("tokens.active: got %d, want %d", total, expected) } } diff --git a/internal/metrics/setup.go b/internal/metrics/setup.go index b99d325..7ba2248 100644 --- a/internal/metrics/setup.go +++ b/internal/metrics/setup.go @@ -2,24 +2,46 @@ package metrics import ( "fmt" + "os" promexporter "go.opentelemetry.io/otel/exporters/prometheus" "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.40.0" crmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" ) +const serviceName = "github-token-manager" + // Setup creates an OTEL Prometheus exporter registered with the controller-runtime // metrics registry, and returns a Recorder holding all custom instruments. -func Setup() (*Recorder, error) { +// +// version is surfaced as service.version on the OTEL Resource and therefore on +// the `target_info` metric. Pass the build-time version string (or "" if unknown). +func Setup(version string) (*Recorder, error) { exporter, err := promexporter.New( promexporter.WithRegisterer(crmetrics.Registry), + promexporter.WithoutScopeInfo(), ) if err != nil { return nil, fmt.Errorf("creating prometheus exporter: %w", err) } - provider := metric.NewMeterProvider(metric.WithReader(exporter)) - meter := provider.Meter("github.com/isometry/github-token-manager") + res, err := resource.Merge(resource.Default(), resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(serviceName), + semconv.ServiceVersion(version), + semconv.ServiceInstanceID(instanceID()), + )) + if err != nil { + return nil, fmt.Errorf("building metrics resource: %w", err) + } + + provider := metric.NewMeterProvider( + metric.WithReader(exporter), + metric.WithResource(res), + ) + meter := provider.Meter(serviceName) recorder, err := newRecorder(meter) if err != nil { @@ -29,3 +51,15 @@ func Setup() (*Recorder, error) { recorder.provider = provider return recorder, nil } + +// instanceID returns the pod name (via POD_NAME / HOSTNAME downward API) for +// use as service.instance.id, falling back to the hostname or an empty string. +func instanceID() string { + if v := os.Getenv("POD_NAME"); v != "" { + return v + } + if v, err := os.Hostname(); err == nil { + return v + } + return "" +} diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 42cec04..9e573b0 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -29,13 +29,14 @@ const ( ) type tokenSecret struct { - ctx context.Context - log logr.Logger - reconciler tokenReconciler - key types.NamespacedName - owner tokenManager - ghait ghait.GHAIT - metrics *metrics.Recorder + ctx context.Context + log logr.Logger + reconciler tokenReconciler + key types.NamespacedName + owner tokenManager + controllerName string + ghait ghait.GHAIT + metrics *metrics.Recorder *corev1.Secret } @@ -65,11 +66,12 @@ func WithMetrics(m *metrics.Recorder) Option { } } -func NewTokenSecret(ctx context.Context, key types.NamespacedName, owner tokenManager, options ...Option) (*tokenSecret, error) { +func NewTokenSecret(ctx context.Context, key types.NamespacedName, owner tokenManager, controllerName string, options ...Option) (*tokenSecret, error) { s := &tokenSecret{ - ctx: ctx, - key: key, - owner: owner, + ctx: ctx, + key: key, + owner: owner, + controllerName: controllerName, } for _, option := range options { @@ -81,7 +83,7 @@ func NewTokenSecret(ctx context.Context, key types.NamespacedName, owner tokenMa // If the custom resource is not found then, it usually means that it was deleted or not created // In this way, we will stop the reconciliation s.log.Info("token resource not found; ignoring since object must be deleted") - s.metrics.RemoveTokenActive(s.ctx, s.owner.GetType(), s.key.String()) + s.metrics.RemoveTokenActive(s.ctx, s.controllerName, s.key.String()) return nil, nil } // Error reading the object - requeue the request. @@ -114,8 +116,7 @@ func (s *tokenSecret) NewInstallationToken() (*github.InstallationToken, error) start := time.Now() token, err := s.ghait.NewInstallationToken(s.ctx, installationId, options) - s.metrics.RecordGitHubAPIDuration(s.ctx, time.Since(start), err) - s.metrics.RecordGitHubTokenRequest(s.ctx, err) + s.metrics.RecordGitHubAPICall(s.ctx, s.controllerName, time.Since(start), err) return token, err } @@ -123,14 +124,10 @@ func (s *tokenSecret) RefreshOwner() error { return s.reconciler.Get(s.ctx, s.key, s.owner) } -func (s *tokenSecret) controllerName() string { - return s.owner.GetType() -} - func (s *tokenSecret) recordExpiry() { _, expiresAt := s.owner.GetStatusTimestamps() if !expiresAt.IsZero() { - s.metrics.RecordTokenExpiry(s.ctx, s.controllerName(), s.owner.GetSecretNamespace(), s.owner.GetName(), expiresAt) + s.metrics.RecordTokenExpiry(s.ctx, s.controllerName, s.owner.GetSecretNamespace(), s.owner.GetName(), expiresAt) } } @@ -164,23 +161,23 @@ func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { // Secret not found, so create it start := time.Now() if err := s.CreateSecret(); err != nil { - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName(), metrics.ResultError) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName(), metrics.OperationCreate, time.Since(start)) + s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultError) + s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationCreate, time.Since(start)) if errors.Is(err, ghait.TransientError{}) { - s.metrics.RecordReconcileError(s.ctx, s.controllerName(), metrics.ReasonTransient) + s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonTransient) log.Error(err, "transient error creating secret") return reconcile.Result{RequeueAfter: s.owner.GetRetryInterval()}, nil } - s.metrics.RecordReconcileError(s.ctx, s.controllerName(), metrics.ReasonSecretCreate) + s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonSecretCreate) log.Error(err, "fatal error creating secret") return result, err } - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName(), metrics.ResultSuccess) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName(), metrics.OperationCreate, time.Since(start)) - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationCreate, metrics.ResultSuccess) - s.metrics.EnsureTokenActive(s.ctx, s.controllerName(), s.key.String()) + s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultSuccess) + s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationCreate, time.Since(start)) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultSuccess) + s.metrics.EnsureTokenActive(s.ctx, s.controllerName, s.key.String()) s.recordExpiry() return reconcile.Result{RequeueAfter: s.owner.GetRefreshInterval()}, nil @@ -198,7 +195,7 @@ func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { log.Error(err, "failed to update token status") return result, err } - s.metrics.RecordReconcileError(s.ctx, s.controllerName(), metrics.ReasonOwnership) + s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonOwnership) err := errors.New("existing secret not owned by token") log.Error(err, "ownership mismatch", "token", s.owner) return result, err @@ -208,23 +205,23 @@ func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { start := time.Now() if err := s.UpdateSecret(); err != nil { - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName(), metrics.ResultError) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName(), metrics.OperationUpdate, time.Since(start)) + s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultError) + s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationUpdate, time.Since(start)) if errors.Is(err, ghait.TransientError{}) { - s.metrics.RecordReconcileError(s.ctx, s.controllerName(), metrics.ReasonTransient) + s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonTransient) log.Error(err, "transient error updating secret") return reconcile.Result{RequeueAfter: s.owner.GetRetryInterval()}, nil } - s.metrics.RecordReconcileError(s.ctx, s.controllerName(), metrics.ReasonSecretUpdate) + s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonSecretUpdate) log.Error(err, "fatal error updating secret") return result, err } - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName(), metrics.ResultSuccess) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName(), metrics.OperationUpdate, time.Since(start)) - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationUpdate, metrics.ResultSuccess) - s.metrics.EnsureTokenActive(s.ctx, s.controllerName(), s.key.String()) + s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultSuccess) + s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationUpdate, time.Since(start)) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultSuccess) + s.metrics.EnsureTokenActive(s.ctx, s.controllerName, s.key.String()) s.recordExpiry() return reconcile.Result{RequeueAfter: s.owner.GetRefreshInterval()}, nil @@ -237,7 +234,7 @@ func (s *tokenSecret) CreateSecret() error { installationToken, err := s.NewInstallationToken() if err != nil { log.Error(err, "failed to get installation token") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationCreate, metrics.ResultError) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) return err } @@ -263,7 +260,7 @@ func (s *tokenSecret) CreateSecret() error { // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ if err := ctrl.SetControllerReference(s.owner, s.Secret, s.reconciler.Scheme()); err != nil { log.Error(err, "failed to set controller reference") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationCreate, metrics.ResultError) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) return err } @@ -281,7 +278,7 @@ func (s *tokenSecret) CreateSecret() error { if err := s.reconciler.Create(s.ctx, s.Secret); err != nil { log.Error(err, "failed to create secret") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationCreate, metrics.ResultError) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) return err } @@ -309,7 +306,7 @@ func (s *tokenSecret) UpdateSecret() error { installationToken, err := s.NewInstallationToken() if err != nil { log.Error(err, "failed to get installation token") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationUpdate, metrics.ResultError) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultError) return err } @@ -329,7 +326,7 @@ func (s *tokenSecret) UpdateSecret() error { if err := s.reconciler.Update(s.ctx, s.Secret); err != nil { log.Error(err, "failed to update secret") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationUpdate, metrics.ResultError) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultError) return err } @@ -384,12 +381,12 @@ func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { log.Info("deleting existing secret") if err := s.reconciler.Delete(s.ctx, secret); err != nil { log.Error(err, "failed to delete secret") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationDelete, metrics.ResultError) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationDelete, metrics.ResultError) return err } - s.metrics.RecordSecretOperation(s.ctx, s.controllerName(), metrics.OperationDelete, metrics.ResultSuccess) - s.metrics.RemoveTokenActive(s.ctx, s.controllerName(), s.key.String()) + s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationDelete, metrics.ResultSuccess) + s.metrics.RemoveTokenActive(s.ctx, s.controllerName, s.key.String()) condition.Message = "Deleted old Secret" @@ -472,26 +469,8 @@ func (s *tokenSecret) SecretData(installationToken string) map[string][]byte { "username": []byte(BasicAuthUsername), "password": []byte(installationToken), } - } else { - return map[string][]byte{ - "token": []byte(installationToken), - } } -} - -func (s *tokenSecret) RemoveOldSecret(key types.NamespacedName) error { - log := s.log.WithValues("func", "RemoveOldSecret") - - secret := &corev1.Secret{} - if err := s.reconciler.Get(s.ctx, key, secret); err != nil && apierrors.IsNotFound(err) { - log.Info("existing secret not found") - return nil - } else if err == nil && metav1.IsControlledBy(s.Secret, s.owner) { - // Delete the old Secret; failure to delete is fatal - log.Info("deleting existing secret") - return s.reconciler.Delete(s.ctx, s.Secret) - } else { - log.Error(err, "failed to get secret") - return err + return map[string][]byte{ + "token": []byte(installationToken), } } diff --git a/test/e2e/e2e_helpers_test.go b/test/e2e/e2e_helpers_test.go index 2c811dd..54a335a 100644 --- a/test/e2e/e2e_helpers_test.go +++ b/test/e2e/e2e_helpers_test.go @@ -41,8 +41,10 @@ import ( . "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" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" gtmv1 "github.com/isometry/github-token-manager/api/v1" "github.com/isometry/github-token-manager/internal/tokenmanager" @@ -107,6 +109,22 @@ func (c *clientContext) waitForClusterTokenReconciliation(name string) { }).Within(reconciliationTimeout).Should(Succeed()) } +// waitForAppReconciliation waits for an App resource to reach status Ready=True. +func (c *clientContext) waitForAppReconciliation(name, namespace string) { + Eventually(func(g Gomega) { + app := >mv1.App{} + g.Expect( + c.client.Get(c.context, client.ObjectKey{ + Name: name, + Namespace: namespace, + }, app), + ).NotTo(HaveOccurred()) + ready := meta.FindStatusCondition(app.Status.Conditions, gtmv1.ConditionTypeReady) + g.Expect(ready).NotTo(BeNil()) + g.Expect(ready.Status).To(Equal(metav1.ConditionTrue)) + }).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 @@ -227,6 +245,42 @@ func (c *clientContext) createToken( return c.client.Create(c.context, token) } +// createTokenWithAppRef creates a Token that resolves its GitHub App credentials +// via spec.appRef rather than the operator's startup configuration. +func (c *clientContext) createTokenWithAppRef( + name, namespace, secretName, appRefName string, + isBasicAuth bool, + refreshInterval time.Duration, +) error { + token := >mv1.Token{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "github.as-code.io/v1", + Kind: "Token", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: gtmv1.TokenSpec{ + AppRef: >mv1.LocalAppReference{Name: appRefName}, + RefreshInterval: metav1.Duration{Duration: refreshInterval}, + Secret: gtmv1.TokenSecretSpec{ + Name: secretName, + BasicAuth: isBasicAuth, + }, + Repositories: []string{ + testRepositoryName, + }, + Permissions: >mv1.Permissions{ + Contents: &readPermission, + Metadata: &readPermission, + }, + }, + } + + return c.client.Create(c.context, token) +} + // deleteToken deletes a Token resource func (c *clientContext) deleteToken(name, namespace string) error { token := >mv1.Token{ @@ -280,6 +334,57 @@ func (c *clientContext) deleteClusterToken(name string) error { return c.client.Delete(c.context, clusterToken) } +// createApp creates an App resource with the supplied AppSpec. +func (c *clientContext) createApp(name, namespace string, spec gtmv1.AppSpec) error { + app := >mv1.App{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "github.as-code.io/v1", + Kind: "App", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: spec, + } + return c.client.Create(c.context, app) +} + +// deleteApp deletes an App resource. +func (c *clientContext) deleteApp(name, namespace string) error { + app := >mv1.App{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + return c.client.Delete(c.context, app) +} + +// createOpaqueSecret creates an Opaque Secret with the supplied data map. +func (c *clientContext) createOpaqueSecret(name, namespace string, data map[string][]byte) error { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Type: corev1.SecretTypeOpaque, + Data: data, + } + return c.client.Create(c.context, secret) +} + +// deleteSecret deletes a Secret by name. +func (c *clientContext) deleteSecret(name, namespace string) error { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + return c.client.Delete(c.context, secret) +} + // runCommand executes the provided command within this context func runCommand(cmd *exec.Cmd) ([]byte, error) { dir, _ := getProjectDir() @@ -332,6 +437,36 @@ func getProjectDir() (string, error) { } } +// parseHelmValuesFile extracts the github-token-manager config block from a +// Helm values.yaml so the captured credentials can be reused by later specs. +func parseHelmValuesFile(path string) (*gtmConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading helm values file %q: %w", path, err) + } + var v struct { + Config struct { + AppID int64 `json:"app_id"` + InstallationID int64 `json:"installation_id"` + Provider string `json:"provider"` + Key string `json:"key"` + } `json:"config"` + } + if err := yaml.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("unmarshalling helm values file %q: %w", path, err) + } + provider := v.Config.Provider + if provider == "" { + provider = "file" + } + return >mConfig{ + appID: v.Config.AppID, + installationID: v.Config.InstallationID, + provider: provider, + key: v.Config.Key, + }, nil +} + // generateTestKey generates a new RSA private key and returns it as a PEM-encoded string func generateTestKey() (string, error) { privateKey, err := rsa.GenerateKey(rand.Reader, 2048) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index e1eb04c..24d6ffb 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -26,6 +26,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -61,22 +62,45 @@ const ( // Resource names testToken1 = "token-1" testToken2 = "token-2" + testToken3 = "token-3" testClusterToken1 = "cluster-token-1" testClusterToken2 = "cluster-token-2" + testApp = "test-app" // Secret names testSecret1 = "secret-1" testSecret2 = "secret-2" testSecret3 = "secret-3" testSecret4 = "secret-4" + testSecret5 = "secret-5" + + testAppKeySecret = "test-app-key" ) +// gtmConfig holds the GitHub App credentials captured from the Helm install +// step so that later specs (notably the App CR test) can reuse them without +// re-parsing env vars or values files. +type gtmConfig struct { + appID int64 + installationID int64 + provider string + key string +} + // Permission strings (var to allow taking address) var readPermission = "read" +// e2ePreserveState reports whether E2E_PRESERVE is set in the environment. +// When true, the suite skips the uninstall spec and the namespace teardown in +// AfterAll so that a developer can inspect cluster state after a run. +func e2ePreserveState() bool { + return os.Getenv("E2E_PRESERVE") != "" +} + var _ = Describe("GitHub Token Manager", Ordered, func() { var kubeContext, testImage, testRepo, testTag string var hasAppCredentials bool + var capturedConfig *gtmConfig var k8sClient client.Client ctx := context.Background() var clientCtx *clientContext @@ -142,6 +166,8 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { "helm", "upgrade", "--install", "github-token-manager", chartPath, "--namespace", operatorNamespace, "--create-namespace", + "--timeout=60s", + "--wait=watcher", fmt.Sprintf("--set=manager.repository=%s", testRepo), fmt.Sprintf("--set=manager.tag=%s", testTag), } @@ -157,6 +183,9 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { if _, err := os.Stat(valuesPath); err == nil { hasAppCredentials = true GinkgoWriter.Printf("Using GitHub App configuration values from %q\n", valuesPath) + cfg, err := parseHelmValuesFile(valuesPath) + Expect(err).NotTo(HaveOccurred()) + capturedConfig = cfg valuesArgs = append(valuesArgs, fmt.Sprintf("--values=%s", valuesPath), ) @@ -164,6 +193,17 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { hasAppCredentials = true GinkgoWriter.Println("Using GitHub App credentials from the environment") + appID, err := strconv.ParseInt(gtmAppId, 10, 64) + Expect(err).NotTo(HaveOccurred(), "parsing GTM_APP_ID") + installationID, err := strconv.ParseInt(gtmInstallationId, 10, 64) + Expect(err).NotTo(HaveOccurred(), "parsing GTM_INSTALLATION_ID") + capturedConfig = >mConfig{ + appID: appID, + installationID: installationID, + provider: gtmProvider, + key: gtmKey, + } + // Create temporary values file from environment variables envValuesPath := filepath.Join(projectDir, "test", "e2e", "values.env.yaml") @@ -361,6 +401,62 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { }) }) + Context("App CR", Ordered, func() { + AfterAll(func() { + if !hasAppCredentials { + return + } + _ = clientCtx.deleteApp(testApp, targetNamespace) + _ = clientCtx.deleteSecret(testAppKeySecret, targetNamespace) + }) + + It("reconciles an App resource to Ready=True", func() { + if !hasAppCredentials { + Skip("skipping App test - no valid GitHub App configuration provided") + } + + By("creating a Secret holding the GitHub App private key") + Expect(clientCtx.createOpaqueSecret(testAppKeySecret, targetNamespace, map[string][]byte{ + "private-key.pem": []byte(capturedConfig.key), + })).To(Succeed()) + + By("creating a Secret-backed App resource referencing it") + Expect(clientCtx.createApp(testApp, targetNamespace, gtmv1.AppSpec{ + AppID: capturedConfig.appID, + InstallationID: capturedConfig.installationID, + Provider: "secret", + KeyRef: >mv1.KeySecretReference{Name: testAppKeySecret}, + })).To(Succeed()) + + By("waiting for App reconciliation") + clientCtx.waitForAppReconciliation(testApp, targetNamespace) + }) + + It("manages a Token that references the App via spec.appRef", func() { + if !hasAppCredentials { + Skip("skipping App test - no valid GitHub App configuration provided") + } + + By("creating a Token with spec.appRef pointing at the App") + Expect(clientCtx.createTokenWithAppRef( + testToken3, targetNamespace, testSecret5, testApp, + false, tokenRefreshInterval, + )).To(Succeed()) + + By("waiting for Token reconciliation") + clientCtx.waitForTokenReconciliation(testToken3, targetNamespace) + + By("checking managed Secret is created correctly") + initialSecretToken := clientCtx.checkManagedSecret(testSecret5, targetNamespace, tokenmanager.SecretTypeToken) + + By("checking that the managed Secret token value is valid") + Expect(checkToken(initialSecretToken)).To(Succeed()) + + By("deleting the Token resource") + Expect(clientCtx.deleteToken(testToken3, targetNamespace)).To(Succeed()) + }) + }) + Context("Metrics", func() { var metricsBody string @@ -375,54 +471,111 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { metricsBody = scrapeMetrics(operatorNamespace, podName, 8080) }) - It("exposes Prometheus metrics on the /metrics endpoint", func() { - By("checking for standard Go runtime metric") + It("serves /metrics with OTEL-identified, scope-pruned payload", func() { + By("endpoint-up smoke check") Expect(metricsBody).To(ContainSubstring("go_goroutines")) - if hasAppCredentials { - By("checking for custom GTM metric TYPE lines (requires successful reconciliation)") - expectedTypes := []string{ - "gtm_token_refresh_total", - "gtm_token_refresh_duration_seconds", - "gtm_github_api_call_duration_seconds", - "gtm_token_expiry_timestamp_seconds", - "gtm_tokens_active", - "gtm_secret_operations_total", - "gtm_github_token_requests_total", - } - for _, metric := range expectedTypes { - Expect(metricsBody).To( - ContainSubstring(fmt.Sprintf("# TYPE %s ", metric)), - "missing TYPE line for %s", metric, - ) - } + By("target_info carries OTEL Resource identity") + Expect(metricsBody).To( + MatchRegexp(`target_info\{[^}]*service_name="github-token-manager"[^}]*\}`), + "target_info missing service_name=github-token-manager", + ) + Expect(metricsBody).To( + MatchRegexp(`target_info\{[^}]*service_version="[^"]+"[^}]*\}`), + "target_info missing non-empty service_version", + ) + Expect(metricsBody).To( + MatchRegexp(`target_info\{[^}]*service_instance_id="[^"]+"[^}]*\}`), + "target_info missing non-empty service_instance_id", + ) + + By("otel_scope_* labels have been stripped") + Expect(metricsBody).NotTo(ContainSubstring("otel_scope_")) + + By("no metric family starts with the retired gtm_ prefix") + Expect(metricsBody).NotTo( + MatchRegexp(`(?m)^gtm_`), + "found residual gtm_-prefixed metric family", + ) + + By("controller-runtime defaults carry the expected controller labels") + Expect(metricsBody).To( + MatchRegexp(`controller_runtime_reconcile_total\{[^}]*controller="github-token"[^}]*\}`), + ) + Expect(metricsBody).To( + MatchRegexp(`controller_runtime_reconcile_total\{[^}]*controller="github-clustertoken"[^}]*\}`), + ) + Expect(metricsBody).To( + MatchRegexp(`workqueue_adds_total\{[^}]*controller="github-token"[^}]*\}`), + ) + Expect(metricsBody).To( + MatchRegexp(`workqueue_adds_total\{[^}]*controller="github-clustertoken"[^}]*\}`), + ) + }) + + It("exposes custom Prometheus metric TYPE lines after reconciliation", func() { + if !hasAppCredentials { + Skip("skipping custom metric checks - no valid GitHub App configuration provided") + } + + // Only the happy-path instruments are asserted. The OTEL Prometheus + // exporter only emits a `# TYPE` line once an instrument has recorded + // at least one data point, so error-only counters (token_reconcile_errors_total, + // config_errors_total) are intentionally excluded from this list. + expectedTypes := []string{ + "token_refresh_total", + "token_refresh_duration_seconds", + "github_api_call_duration_seconds", + "github_api_requests_total", + "token_expiry_timestamp_seconds", + "tokens_active", + "kubernetes_secret_operations_total", + } + for _, metric := range expectedTypes { + Expect(metricsBody).To( + ContainSubstring(fmt.Sprintf("# TYPE %s ", metric)), + "missing TYPE line for %s", metric, + ) } }) - It("reports non-zero token counters after reconciliation", func() { + It("reports non-zero success counters after reconciliation", func() { if !hasAppCredentials { Skip("skipping metrics counter checks - no valid GitHub App configuration provided") } - By("checking gtm_token_refresh_total has success count >= 1") + By("checking token_refresh_total{controller=github-token,result=success} >= 1") + Expect(metricsBody).To( + MatchRegexp(`token_refresh_total\{[^}]*controller="github-token"[^}]*result="success"[^}]*\}\s+[1-9]`), + ) + + By("checking kubernetes_secret_operations_total{controller=github-token,operation=create,result=success} >= 1") Expect(metricsBody).To( - MatchRegexp(`gtm_token_refresh_total\{[^}]*result="success"[^}]*\}\s+[1-9]`), + MatchRegexp(`kubernetes_secret_operations_total\{[^}]*controller="github-token"[^}]*operation="create"[^}]*result="success"[^}]*\}\s+[1-9]`), ) - By("checking gtm_github_token_requests_total has success count >= 1") + By("checking github_api_call_duration_seconds_count{controller=github-token,result=success} >= 1") Expect(metricsBody).To( - MatchRegexp(`gtm_github_token_requests_total\{[^}]*result="success"[^}]*\}\s+[1-9]`), + MatchRegexp(`github_api_call_duration_seconds_count\{[^}]*controller="github-token"[^}]*result="success"[^}]*\}\s+[1-9]`), ) - By("checking gtm_secret_operations_total has success count >= 1") + By("checking github_api_requests_total{controller=github-token,result=success} >= 1") Expect(metricsBody).To( - MatchRegexp(`gtm_secret_operations_total\{[^}]*result="success"[^}]*\}\s+[1-9]`), + MatchRegexp(`github_api_requests_total\{[^}]*controller="github-token"[^}]*result="success"[^}]*\}\s+[1-9]`), + ) + + By("checking tokens_active{controller=github-token} >= 1") + Expect(metricsBody).To( + MatchRegexp(`tokens_active\{[^}]*controller="github-token"[^}]*\}\s+[1-9]`), ) }) }) Context("Helm Chart", func() { It("should uninstall without error", func() { + if e2ePreserveState() { + Skip("E2E_PRESERVE is set; leaving Helm release in place for inspection") + } By("uninstalling Helm chart") cmd := exec.Command("helm", "uninstall", "github-token-manager", "--namespace", operatorNamespace) _, err := runCommand(cmd) @@ -432,6 +585,10 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { }) AfterAll(func() { + if e2ePreserveState() { + fmt.Fprintln(GinkgoWriter, "E2E_PRESERVE is set; skipping namespace teardown") + return + } Expect(clientCtx.deleteNamespace(operatorNamespace)).To(Succeed()) Expect(clientCtx.deleteNamespace(targetNamespace)).To(Succeed()) }) From cb9ffb839b9ed1fbe4d0dd30896fd5e3c7bcbef4 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Mon, 27 Apr 2026 00:27:12 +0200 Subject: [PATCH 2/3] refactor: unify Token and ClusterToken reconciliation logic with generics - Extract shared reconciliation logic for Token and ClusterToken into generic helpers in internal/controller/reconcile_token.go - Introduce TokenReconcilerBase for shared dependencies - Add controller name constants in internal/controller/names.go - Refactor main.go to use TokenReconcilerBase - Remove unused ghapp/context.go and ghapp/ghapp.go - Refactor Registry to use functional options for testability - Simplify e2e test helpers for Token creation - Remove redundant TokenSpec tests --- api/v1/app_types_test.go | 47 -------- cmd/manager/main.go | 11 +- internal/controller/app_controller.go | 81 +++++++------ internal/controller/appconfig.go | 2 +- internal/controller/appresolver.go | 64 ++++------- .../controller/clustertoken_controller.go | 69 +---------- internal/controller/names.go | 23 ++++ internal/controller/reconcile_token.go | 107 ++++++++++++++++++ internal/controller/token_controller.go | 69 +---------- internal/ghapp/context.go | 38 ------- internal/ghapp/ghapp.go | 32 ------ internal/ghapp/registry.go | 25 ++-- internal/ghapp/registry_test.go | 19 ++-- internal/tokenmanager/token_manager.go | 4 +- internal/tokenmanager/token_secret.go | 66 ++++------- test/e2e/e2e_helpers_test.go | 66 +++-------- test/e2e/e2e_test.go | 6 +- 17 files changed, 272 insertions(+), 457 deletions(-) create mode 100644 internal/controller/names.go create mode 100644 internal/controller/reconcile_token.go delete mode 100644 internal/ghapp/context.go delete mode 100644 internal/ghapp/ghapp.go diff --git a/api/v1/app_types_test.go b/api/v1/app_types_test.go index 5cb3de0..bd896fd 100644 --- a/api/v1/app_types_test.go +++ b/api/v1/app_types_test.go @@ -7,53 +7,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func TestAppSpec_CloudProviderShape(t *testing.T) { - app := &v1.App{ - Spec: v1.AppSpec{ - AppID: 12345, - InstallationID: 67890, - Provider: "aws", - Key: "alias/github-token-manager", - ValidateKey: true, - }, - } - if app.Spec.AppID != 12345 || app.Spec.InstallationID != 67890 { - t.Errorf("ID round-trip failed: %+v", app.Spec) - } - if app.Spec.Provider != "aws" || app.Spec.Key != "alias/github-token-manager" { - t.Errorf("provider/key round-trip failed: %+v", app.Spec) - } - if !app.Spec.ValidateKey { - t.Errorf("ValidateKey round-trip failed") - } - if app.Spec.KeyRef != nil { - t.Errorf("cloud-provider App should not carry KeyRef, got %+v", app.Spec.KeyRef) - } -} - -func TestAppSpec_SecretProviderShape(t *testing.T) { - app := &v1.App{ - Spec: v1.AppSpec{ - AppID: 1, - InstallationID: 2, - Provider: "secret", - KeyRef: &v1.KeySecretReference{ - Name: "gh-app-key", - Key: "private-key.pem", - }, - }, - } - if app.Spec.Provider != "secret" { - t.Fatalf("provider = %q, want secret", app.Spec.Provider) - } - if app.Spec.Key != "" { - t.Errorf("secret-provider App should leave Key empty, got %q", app.Spec.Key) - } - if app.Spec.KeyRef == nil || app.Spec.KeyRef.Name != "gh-app-key" || app.Spec.KeyRef.Key != "private-key.pem" { - t.Errorf("KeyRef round-trip failed: %+v", app.Spec.KeyRef) - } -} - func TestApp_SetStatusCondition(t *testing.T) { app := &v1.App{} diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 20a286e..fa2809d 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -266,19 +266,16 @@ func main() { os.Exit(1) } - if err = (&controller.TokenReconciler{ + tokenBase := controller.TokenReconcilerBase{ Client: mgr.GetClient(), Metrics: metricsRecorder, Registry: registry, - }).SetupWithManager(mgr); err != nil { + } + if err = (&controller.TokenReconciler{TokenReconcilerBase: tokenBase}).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Token") os.Exit(1) } - if err = (&controller.ClusterTokenReconciler{ - Client: mgr.GetClient(), - Metrics: metricsRecorder, - Registry: registry, - }).SetupWithManager(mgr); err != nil { + if err = (&controller.ClusterTokenReconciler{TokenReconcilerBase: tokenBase}).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterToken") os.Exit(1) } diff --git a/internal/controller/app_controller.go b/internal/controller/app_controller.go index 5a10756..2934dca 100644 --- a/internal/controller/app_controller.go +++ b/internal/controller/app_controller.go @@ -77,63 +77,60 @@ func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R if resolveErr != nil { buildErr = resolveErr failure = reason - } else { - _, err := r.Registry.ForApp(ctx, key, version, cfg) - if err != nil { - buildErr = err - failure = githubv1.ReasonSetupFailed - } + } else if _, err := r.Registry.ForApp(ctx, key, version, cfg); err != nil { + buildErr = err + failure = githubv1.ReasonSetupFailed } + if buildErr != nil { logger.Error(buildErr, "failed to build GitHub App client", "app", req.NamespacedName) if r.Metrics != nil { - r.Metrics.RecordConfigError(ctx, "github-app", "app") + r.Metrics.RecordConfigError(ctx, ControllerNameApp, "app") } r.Registry.Invalidate(key) - - changed := app.SetStatusCondition(metav1.Condition{ + ready := metav1.Condition{ Type: githubv1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: failure, Message: buildErr.Error(), - }) - if app.Spec.ValidateKey { - if app.SetStatusCondition(metav1.Condition{ - Type: githubv1.ConditionTypeKeyValid, - Status: metav1.ConditionFalse, - Reason: failure, - Message: buildErr.Error(), - }) { - changed = true - } - } else if meta.RemoveStatusCondition(&app.Status.Conditions, githubv1.ConditionTypeKeyValid) { - changed = true } - if app.Status.ObservedGeneration != app.Generation { - app.Status.ObservedGeneration = app.Generation - changed = true + keyValid := metav1.Condition{ + Type: githubv1.ConditionTypeKeyValid, + Status: metav1.ConditionFalse, + Reason: failure, + Message: buildErr.Error(), } - if changed { - if err := r.Status().Update(ctx, app); err != nil { - return ctrl.Result{}, err - } + if err := r.writeAppStatus(ctx, app, ready, keyValid); err != nil { + return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: appRetryInterval}, nil } - changed := app.SetStatusCondition(metav1.Condition{ + ready := metav1.Condition{ Type: githubv1.ConditionTypeReady, Status: metav1.ConditionTrue, Reason: githubv1.ReasonReconciled, Message: "GitHub App client ready", - }) + } + keyValid := metav1.Condition{ + Type: githubv1.ConditionTypeKeyValid, + Status: metav1.ConditionTrue, + Reason: githubv1.ReasonReconciled, + Message: "signer key validated", + } + if err := r.writeAppStatus(ctx, app, ready, keyValid); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// writeAppStatus applies the Ready condition, applies or clears the KeyValid +// condition based on Spec.ValidateKey, bumps ObservedGeneration, and writes +// status only if anything actually changed. +func (r *AppReconciler) writeAppStatus(ctx context.Context, app *githubv1.App, ready, keyValid metav1.Condition) error { + changed := app.SetStatusCondition(ready) if app.Spec.ValidateKey { - if app.SetStatusCondition(metav1.Condition{ - Type: githubv1.ConditionTypeKeyValid, - Status: metav1.ConditionTrue, - Reason: githubv1.ReasonReconciled, - Message: "signer key validated", - }) { + if app.SetStatusCondition(keyValid) { changed = true } } else if meta.RemoveStatusCondition(&app.Status.Conditions, githubv1.ConditionTypeKeyValid) { @@ -143,12 +140,10 @@ func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R app.Status.ObservedGeneration = app.Generation changed = true } - if changed { - if err := r.Status().Update(ctx, app); err != nil { - return ctrl.Result{}, err - } + if !changed { + return nil } - return ctrl.Result{}, nil + return r.Status().Update(ctx, app) } // mapSecretToApps enqueues every App in the Secret's namespace whose @@ -203,7 +198,7 @@ func (r *AppReconciler) secretReferencedByApp(obj client.Object) bool { func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&githubv1.App{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - Named("github-app"). + Named(ControllerNameApp). Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.mapSecretToApps), builder.WithPredicates( @@ -211,6 +206,6 @@ func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error { predicate.NewPredicateFuncs(r.secretReferencedByApp), ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + WithOptions(controller.Options{MaxConcurrentReconciles: 3}). Complete(r) } diff --git a/internal/controller/appconfig.go b/internal/controller/appconfig.go index 8ece500..30a4eab 100644 --- a/internal/controller/appconfig.go +++ b/internal/controller/appconfig.go @@ -74,7 +74,7 @@ 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", nn) + return nil, "", githubv1.ReasonSecretNotFound, fmt.Errorf("Secret %s not found: %w", nn, err) } return nil, "", githubv1.ReasonSetupFailed, fmt.Errorf("fetch Secret %s: %w", nn, err) } diff --git a/internal/controller/appresolver.go b/internal/controller/appresolver.go index 7448ae4..053564f 100644 --- a/internal/controller/appresolver.go +++ b/internal/controller/appresolver.go @@ -60,6 +60,20 @@ type appResolution struct { RequeueAfter time.Duration } +// failResolution builds an appResolution carrying a not-Ready condition with +// the given reason/message and the standard retry interval. +func failResolution(reason, message string) appResolution { + return appResolution{ + FailCondition: &metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: message, + }, + RequeueAfter: appRefRetryInterval, + } +} + // resolveApp returns the ghait client for the given *AppReference. A nil ref // falls back to the startup configuration. When the ref points to an // unresolvable or not-yet-Ready App, a condition describing the problem is @@ -72,15 +86,7 @@ func resolveApp(ctx context.Context, c client.Client, reg *ghapp.Registry, ref * if ref == nil { cli, err := reg.Startup(ctx) if err != nil { - return appResolution{ - FailCondition: &metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: githubv1.ReasonNoStartupConfig, - Message: err.Error(), - }, - RequeueAfter: appRefRetryInterval, - } + return failResolution(githubv1.ReasonNoStartupConfig, err.Error()) } return appResolution{Client: cli} } @@ -94,51 +100,19 @@ func resolveApp(ctx context.Context, c client.Client, reg *ghapp.Registry, ref * var app githubv1.App if err := c.Get(ctx, nn, &app); err != nil { if apierrors.IsNotFound(err) { - return appResolution{ - FailCondition: &metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: githubv1.ReasonAppNotFound, - Message: fmt.Sprintf("App %s not found", nn), - }, - RequeueAfter: appRefRetryInterval, - } - } - return appResolution{ - FailCondition: &metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: githubv1.ReasonSetupFailed, - Message: fmt.Sprintf("fetch App %s: %v", nn, err), - }, - RequeueAfter: appRefRetryInterval, + return failResolution(githubv1.ReasonAppNotFound, fmt.Sprintf("App %s not found", nn)) } + return failResolution(githubv1.ReasonSetupFailed, fmt.Sprintf("fetch App %s: %v", nn, err)) } if !meta.IsStatusConditionTrue(app.Status.Conditions, githubv1.ConditionTypeReady) { - return appResolution{ - FailCondition: &metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: githubv1.ReasonAppNotReady, - Message: fmt.Sprintf("App %s is not Ready", nn), - }, - RequeueAfter: appRefRetryInterval, - } + return failResolution(githubv1.ReasonAppNotReady, fmt.Sprintf("App %s is not Ready", nn)) } key := ghapp.Key{Namespace: app.Namespace, Name: app.Name} cli, ok := reg.Lookup(key) if !ok { - return appResolution{ - FailCondition: &metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: githubv1.ReasonAppNotReady, - Message: fmt.Sprintf("App %s client not yet cached", nn), - }, - RequeueAfter: appRefRetryInterval, - } + return failResolution(githubv1.ReasonAppNotReady, fmt.Sprintf("App %s client not yet cached", nn)) } return appResolution{Client: cli} } diff --git a/internal/controller/clustertoken_controller.go b/internal/controller/clustertoken_controller.go index 3713b37..435d300 100644 --- a/internal/controller/clustertoken_controller.go +++ b/internal/controller/clustertoken_controller.go @@ -19,7 +19,6 @@ package controller import ( "context" - apierrors "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -30,16 +29,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" githubv1 "github.com/isometry/github-token-manager/api/v1" - "github.com/isometry/github-token-manager/internal/ghapp" - "github.com/isometry/github-token-manager/internal/metrics" - tm "github.com/isometry/github-token-manager/internal/tokenmanager" ) -// ClusterTokenReconciler reconciles a ClusterToken object +// ClusterTokenReconciler reconciles a ClusterToken object. type ClusterTokenReconciler struct { - client.Client - Metrics *metrics.Recorder - Registry *ghapp.Registry + TokenReconcilerBase } // +kubebuilder:rbac:groups=github.as-code.io,resources=clustertokens,verbs=get;list;watch @@ -53,59 +47,8 @@ type ClusterTokenReconciler struct { // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile -func (r *ClusterTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { - logger := log.FromContext(ctx) - logger.V(1).Info("reconcile start") - - token := &githubv1.ClusterToken{} - if err := r.Get(ctx, req.NamespacedName, token); err != nil { - if apierrors.IsNotFound(err) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err - } - - resolution := resolveApp(ctx, r.Client, r.Registry, token.GetAppRef()) - if resolution.FailCondition != nil { - r.Metrics.RecordConfigError(ctx, "github-clustertoken", "ghapp") - logger.Info("App reference unavailable", - "reason", resolution.FailCondition.Reason, - "message", resolution.FailCondition.Message, - ) - if token.SetStatusCondition(*resolution.FailCondition) { - if err := r.Status().Update(ctx, token); err != nil { - logger.Error(err, "failed to update ClusterToken status with AppRef failure") - return ctrl.Result{}, err - } - } - return ctrl.Result{RequeueAfter: resolution.RequeueAfter}, nil - } - - options := []tm.Option{ - tm.WithReconciler(r), - tm.WithGHApp(resolution.Client), - tm.WithLogger(logger), - tm.WithMetrics(r.Metrics), - } - - tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, "github-clustertoken", options...) - if err != nil { - logger.Error(err, "failed to create ClusterToken reconciler") - return ctrl.Result{}, err - } - - if tokenSecret == nil { - logger.Info("ClusterToken not found, skipping reconciliation") - return ctrl.Result{}, nil - } - - result, err = tokenSecret.Reconcile() - if err != nil { - logger.Error(err, "failed to reconcile ClusterToken") - return result, err - } - logger.Info("reconciled", "requeueAfter", result.RequeueAfter) - return result, nil +func (r *ClusterTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return reconcileTokenLike[githubv1.ClusterToken](ctx, &r.TokenReconcilerBase, req, ControllerNameClusterToken) } // mapAppToClusterTokens enqueues every ClusterToken whose spec.appRef @@ -133,11 +76,11 @@ func (r *ClusterTokenReconciler) mapAppToClusterTokens(ctx context.Context, obj func (r *ClusterTokenReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&githubv1.ClusterToken{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - Named("github-clustertoken"). + Named(ControllerNameClusterToken). Watches(&githubv1.App{}, handler.EnqueueRequestsFromMapFunc(r.mapAppToClusterTokens), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). - WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + WithOptions(controller.Options{MaxConcurrentReconciles: 5}). Complete(r) } diff --git a/internal/controller/names.go b/internal/controller/names.go new file mode 100644 index 0000000..0b15cde --- /dev/null +++ b/internal/controller/names.go @@ -0,0 +1,23 @@ +/* +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 controller + +const ( + ControllerNameToken = "github-token" + ControllerNameClusterToken = "github-clustertoken" + ControllerNameApp = "github-app" +) diff --git a/internal/controller/reconcile_token.go b/internal/controller/reconcile_token.go new file mode 100644 index 0000000..8b0bcd4 --- /dev/null +++ b/internal/controller/reconcile_token.go @@ -0,0 +1,107 @@ +/* +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 controller + +import ( + "context" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + githubv1 "github.com/isometry/github-token-manager/api/v1" + "github.com/isometry/github-token-manager/internal/ghapp" + "github.com/isometry/github-token-manager/internal/metrics" + tm "github.com/isometry/github-token-manager/internal/tokenmanager" +) + +// TokenReconcilerBase carries the dependencies shared by Token and +// ClusterToken reconcilers. Both reconcilers embed it so the generic +// reconcile helper can take a single receiver value. +type TokenReconcilerBase struct { + client.Client + Metrics *metrics.Recorder + Registry *ghapp.Registry +} + +// tokenObject is the generic constraint for a Token-like CR. PT is the +// pointer type (*Token or *ClusterToken); the interface lists the methods +// the reconcile helper needs that aren't already on tm.TokenManager. +type tokenObject[T any] interface { + tm.TokenManager + GetAppRef() *githubv1.AppReference + *T +} + +// reconcileTokenLike runs the post-Get reconcile body shared by Token and +// ClusterToken: fetch the typed object, resolve its App reference, surface +// any failure as a status condition, then hand off to tokenmanager to +// reconcile the managed Secret. +func reconcileTokenLike[T any, PT tokenObject[T]]( + ctx context.Context, + r *TokenReconcilerBase, + req ctrl.Request, + controllerName string, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + logger.V(1).Info("reconcile start") + + owner := PT(new(T)) + if err := r.Get(ctx, req.NamespacedName, owner); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + resolution := resolveApp(ctx, r.Client, r.Registry, owner.GetAppRef()) + if resolution.FailCondition != nil { + r.Metrics.RecordConfigError(ctx, controllerName, "ghapp") + logger.Info("App reference unavailable", + "reason", resolution.FailCondition.Reason, + "message", resolution.FailCondition.Message, + ) + if owner.SetStatusCondition(*resolution.FailCondition) { + if err := r.Status().Update(ctx, owner); err != nil { + logger.Error(err, "failed to update status with AppRef failure") + return ctrl.Result{}, err + } + } + return ctrl.Result{RequeueAfter: resolution.RequeueAfter}, nil + } + + options := []tm.Option{ + tm.WithReconciler(r.Client), + tm.WithGHApp(resolution.Client), + tm.WithLogger(logger), + tm.WithMetrics(r.Metrics), + } + + tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, owner, controllerName, options...) + if err != nil { + logger.Error(err, "failed to create token reconciler") + return ctrl.Result{}, err + } + if tokenSecret == nil { + return ctrl.Result{}, nil + } + + result, err := tokenSecret.Reconcile() + if err != nil { + logger.Error(err, "failed to reconcile token") + return result, err + } + logger.Info("reconciled", "requeueAfter", result.RequeueAfter) + return result, nil +} diff --git a/internal/controller/token_controller.go b/internal/controller/token_controller.go index a35988c..cbbd520 100644 --- a/internal/controller/token_controller.go +++ b/internal/controller/token_controller.go @@ -19,7 +19,6 @@ package controller import ( "context" - apierrors "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -30,16 +29,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" githubv1 "github.com/isometry/github-token-manager/api/v1" - "github.com/isometry/github-token-manager/internal/ghapp" - "github.com/isometry/github-token-manager/internal/metrics" - tm "github.com/isometry/github-token-manager/internal/tokenmanager" ) -// TokenReconciler reconciles a Token object +// TokenReconciler reconciles a Token object. type TokenReconciler struct { - client.Client - Metrics *metrics.Recorder - Registry *ghapp.Registry + TokenReconcilerBase } // +kubebuilder:rbac:groups=github.as-code.io,resources=tokens,verbs=get;list;watch @@ -53,59 +47,8 @@ type TokenReconciler struct { // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile -func (r *TokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { - logger := log.FromContext(ctx) - logger.V(1).Info("reconcile start") - - token := &githubv1.Token{} - if err := r.Get(ctx, req.NamespacedName, token); err != nil { - if apierrors.IsNotFound(err) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err - } - - resolution := resolveApp(ctx, r.Client, r.Registry, token.GetAppRef()) - if resolution.FailCondition != nil { - r.Metrics.RecordConfigError(ctx, "github-token", "ghapp") - logger.Info("App reference unavailable", - "reason", resolution.FailCondition.Reason, - "message", resolution.FailCondition.Message, - ) - if token.SetStatusCondition(*resolution.FailCondition) { - if err := r.Status().Update(ctx, token); err != nil { - logger.Error(err, "failed to update Token status with AppRef failure") - return ctrl.Result{}, err - } - } - return ctrl.Result{RequeueAfter: resolution.RequeueAfter}, nil - } - - options := []tm.Option{ - tm.WithReconciler(r), - tm.WithGHApp(resolution.Client), - tm.WithLogger(logger), - tm.WithMetrics(r.Metrics), - } - - tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, "github-token", options...) - if err != nil { - logger.Error(err, "failed to create Token reconciler") - return ctrl.Result{}, err - } - - if tokenSecret == nil { - logger.Info("Token not found, skipping reconciliation") - return ctrl.Result{}, nil - } - - result, err = tokenSecret.Reconcile() - if err != nil { - logger.Error(err, "failed to reconcile Token") - return result, err - } - logger.Info("reconciled", "requeueAfter", result.RequeueAfter) - return result, nil +func (r *TokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return reconcileTokenLike[githubv1.Token](ctx, &r.TokenReconcilerBase, req, ControllerNameToken) } // mapAppToTokens enqueues every Token in the App's namespace that references @@ -135,11 +78,11 @@ func (r *TokenReconciler) mapAppToTokens(ctx context.Context, obj client.Object) func (r *TokenReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&githubv1.Token{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - Named("github-token"). + Named(ControllerNameToken). Watches(&githubv1.App{}, handler.EnqueueRequestsFromMapFunc(r.mapAppToTokens), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). - WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + WithOptions(controller.Options{MaxConcurrentReconciles: 5}). Complete(r) } diff --git a/internal/ghapp/context.go b/internal/ghapp/context.go deleted file mode 100644 index e363d3f..0000000 --- a/internal/ghapp/context.go +++ /dev/null @@ -1,38 +0,0 @@ -package ghapp - -import ( - "context" - "fmt" - - "github.com/isometry/ghait/v84" -) - -type contextKey struct{} - -type notFoundError struct{} - -func (notFoundError) Error() string { - return "no GHApp was present" -} - -func (notFoundError) IsNotFound() bool { - return true -} - -func NewContext(ctx context.Context, ghapp ghait.GHAIT) context.Context { - return context.WithValue(ctx, contextKey{}, ghapp) -} - -func FromContext(ctx context.Context) (ghait.GHAIT, error) { - v := ctx.Value(contextKey{}) - if v == nil { - return nil, notFoundError{} - } - - switch v := v.(type) { - case ghait.GHAIT: - return v, nil - default: - return nil, fmt.Errorf("unexpected value type for ghapp context key: %T", v) - } -} diff --git a/internal/ghapp/ghapp.go b/internal/ghapp/ghapp.go deleted file mode 100644 index 114a863..0000000 --- a/internal/ghapp/ghapp.go +++ /dev/null @@ -1,32 +0,0 @@ -package ghapp - -import ( - "context" - "fmt" - - "sigs.k8s.io/controller-runtime/pkg/log" - - "github.com/isometry/ghait/v84" - _ "github.com/isometry/ghait/v84/provider/aws" // AWS KMS provider - _ "github.com/isometry/ghait/v84/provider/azure" // Azure Key Vault provider - _ "github.com/isometry/ghait/v84/provider/gcp" // GCP KMS provider - _ "github.com/isometry/ghait/v84/provider/vault" // HashiCorp Vault provider -) - -func NewGHApp(ctx context.Context) (ghait.GHAIT, error) { - logger := log.FromContext(ctx) - - cfg, err := LoadConfig(ctx) - if err != nil { - return nil, fmt.Errorf("configuration: %w", err) - } - - logger.Info("loaded configuration", "config", cfg) - - ghapp, err := ghait.NewGHAIT(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("ghait: %w", err) - } - - return ghapp, nil -} diff --git a/internal/ghapp/registry.go b/internal/ghapp/registry.go index 2d1067f..743f3e7 100644 --- a/internal/ghapp/registry.go +++ b/internal/ghapp/registry.go @@ -65,22 +65,29 @@ type Registry struct { factory FactoryFunc } +// Option configures a [Registry] at construction time. +type Option func(*Registry) + +// WithFactory replaces the default client factory. Intended for tests. +func WithFactory(f FactoryFunc) Option { + return func(r *Registry) { + r.factory = f + } +} + // NewRegistry builds a Registry. Pass a nil startupCfg to require that every // Token/ClusterToken sets spec.appRef explicitly. -func NewRegistry(operatorNS string, startupCfg *OperatorConfig) *Registry { - return &Registry{ +func NewRegistry(operatorNS string, startupCfg *OperatorConfig, opts ...Option) *Registry { + r := &Registry{ clients: make(map[Key]cachedClient), startupCfg: startupCfg, operatorNS: operatorNS, factory: defaultFactory, } -} - -// SetFactory replaces the internal client factory. Intended for tests. -func (r *Registry) SetFactory(f FactoryFunc) { - r.mu.Lock() - defer r.mu.Unlock() - r.factory = f + for _, opt := range opts { + opt(r) + } + return r } // OperatorNamespace returns the operator's own namespace, used to default an diff --git a/internal/ghapp/registry_test.go b/internal/ghapp/registry_test.go index 1e59796..336028d 100644 --- a/internal/ghapp/registry_test.go +++ b/internal/ghapp/registry_test.go @@ -46,9 +46,8 @@ func TestRegistry_Startup_NoConfig(t *testing.T) { func TestRegistry_Startup_CachesAcrossCalls(t *testing.T) { cfg := &OperatorConfig{AppID: 1, InstallationID: 2, Provider: "file", Key: "inline"} - r := NewRegistry("gtm-system", cfg) fac, calls := countingFactory() - r.SetFactory(fac) + r := NewRegistry("gtm-system", cfg, WithFactory(fac)) c1, err := r.Startup(context.Background()) if err != nil { @@ -67,9 +66,8 @@ func TestRegistry_Startup_CachesAcrossCalls(t *testing.T) { } func TestRegistry_ForApp_CachesByVersion(t *testing.T) { - r := NewRegistry("gtm-system", nil) fac, calls := countingFactory() - r.SetFactory(fac) + r := NewRegistry("gtm-system", nil, WithFactory(fac)) key := Key{Namespace: "team-a", Name: "prod"} cfg := &OperatorConfig{AppID: 42, InstallationID: 7, Provider: "file", Key: "inline"} @@ -108,9 +106,8 @@ func TestRegistry_ForApp_RejectsStartupKey(t *testing.T) { } func TestRegistry_Invalidate_EvictsEntry(t *testing.T) { - r := NewRegistry("gtm-system", nil) fac, calls := countingFactory() - r.SetFactory(fac) + r := NewRegistry("gtm-system", nil, WithFactory(fac)) key := Key{Namespace: "team-a", Name: "prod"} cfg := &OperatorConfig{AppID: 42, Provider: "file", Key: "inline"} @@ -129,11 +126,13 @@ func TestRegistry_Invalidate_EvictsEntry(t *testing.T) { } func TestRegistry_FactoryError_Propagates(t *testing.T) { - r := NewRegistry("gtm-system", &OperatorConfig{AppID: 1, Provider: "file", Key: "inline"}) sentinel := errors.New("provider init failed") - r.SetFactory(func(context.Context, ghait.Config) (ghait.GHAIT, error) { - return nil, sentinel - }) + r := NewRegistry("gtm-system", + &OperatorConfig{AppID: 1, Provider: "file", Key: "inline"}, + WithFactory(func(context.Context, ghait.Config) (ghait.GHAIT, error) { + return nil, sentinel + }), + ) _, err := r.Startup(context.Background()) if !errors.Is(err, sentinel) { t.Fatalf("err = %v, want to wrap %v", err, sentinel) diff --git a/internal/tokenmanager/token_manager.go b/internal/tokenmanager/token_manager.go index f0b297a..69e925c 100644 --- a/internal/tokenmanager/token_manager.go +++ b/internal/tokenmanager/token_manager.go @@ -14,8 +14,8 @@ type tokenReconciler interface { client.Client } -// tokenManager provides a common interface for both namespaced Tokens and ClusterTokens -type tokenManager interface { +// TokenManager provides a common interface for both namespaced Tokens and ClusterTokens. +type TokenManager interface { client.Object GetType() string diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 9e573b0..61caca7 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -33,7 +33,7 @@ type tokenSecret struct { log logr.Logger reconciler tokenReconciler key types.NamespacedName - owner tokenManager + owner TokenManager controllerName string ghait ghait.GHAIT metrics *metrics.Recorder @@ -66,7 +66,7 @@ func WithMetrics(m *metrics.Recorder) Option { } } -func NewTokenSecret(ctx context.Context, key types.NamespacedName, owner tokenManager, controllerName string, options ...Option) (*tokenSecret, error) { +func NewTokenSecret(ctx context.Context, key types.NamespacedName, owner TokenManager, controllerName string, options ...Option) (*tokenSecret, error) { s := &tokenSecret{ ctx: ctx, key: key, @@ -264,27 +264,18 @@ func (s *tokenSecret) CreateSecret() error { return err } - condition := metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: "Creating", - Message: "Creating Secret", - } - - if err := s.UpdateTokenStatus(s.withCondition(condition)); err != nil { - log.Error(err, "failed to update token status", "condition", condition) - return err - } - if err := s.reconciler.Create(s.ctx, s.Secret); err != nil { log.Error(err, "failed to create secret") s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) return err } - condition.Status = metav1.ConditionTrue - condition.Reason = "Created" - condition.Message = "Created Secret" + condition := metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Created", + Message: "Created Secret", + } options := []tokenStatusOptions{ s.withCondition(condition), @@ -312,27 +303,18 @@ func (s *tokenSecret) UpdateSecret() error { s.Data = s.SecretData(installationToken.GetToken()) - condition := metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionUnknown, - Reason: "Updating", - Message: "Updating Secret", - } - - if err := s.UpdateTokenStatus(s.withCondition(condition)); err != nil { - log.Error(err, "failed to update token status") - return err - } - if err := s.reconciler.Update(s.ctx, s.Secret); err != nil { log.Error(err, "failed to update secret") s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultError) return err } - condition.Status = metav1.ConditionTrue - condition.Reason = "Updated" - condition.Message = "Updated Secret" + condition := metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Updated", + Message: "Updated Secret", + } options := []tokenStatusOptions{ s.withCondition(condition), @@ -350,18 +332,6 @@ func (s *tokenSecret) UpdateSecret() error { func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { log := s.log.WithValues("func", "DeleteSecret") - condition := metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionFalse, - Reason: "Reconciling", - Message: "Deleting old Secret", - } - - if err := s.UpdateTokenStatus(s.withCondition(condition)); err != nil { - log.Error(err, "failed to update token status") - return err - } - secret := &corev1.Secret{} if err := s.reconciler.Get(s.ctx, key, secret); err != nil { if apierrors.IsNotFound(err) { @@ -388,8 +358,12 @@ func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationDelete, metrics.ResultSuccess) s.metrics.RemoveTokenActive(s.ctx, s.controllerName, s.key.String()) - condition.Message = "Deleted old Secret" - + condition := metav1.Condition{ + Type: githubv1.ConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: "Reconciling", + Message: "Deleted old Secret", + } if err := s.UpdateTokenStatus(s.withCondition(condition)); err != nil { log.Error(err, "failed to update token status") return err diff --git a/test/e2e/e2e_helpers_test.go b/test/e2e/e2e_helpers_test.go index 54a335a..cb0ce81 100644 --- a/test/e2e/e2e_helpers_test.go +++ b/test/e2e/e2e_helpers_test.go @@ -211,47 +211,31 @@ func (c *clientContext) deleteNamespace(name string) error { return c.client.Delete(c.context, ns) } -// createToken creates a standard Token resource for testing +// createToken creates a Token resource. An empty appRefName uses the +// operator's startup configuration; otherwise the Token resolves its GitHub +// App credentials via spec.appRef. func (c *clientContext) createToken( - name, namespace, secretName string, + name, namespace, secretName, appRefName string, isBasicAuth bool, refreshInterval time.Duration, ) error { - token := >mv1.Token{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "github.as-code.io/v1", - Kind: "Token", + spec := gtmv1.TokenSpec{ + RefreshInterval: metav1.Duration{Duration: refreshInterval}, + Secret: gtmv1.TokenSecretSpec{ + Name: secretName, + BasicAuth: isBasicAuth, }, - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, + Repositories: []string{ + testRepositoryName, }, - Spec: gtmv1.TokenSpec{ - RefreshInterval: metav1.Duration{Duration: refreshInterval}, - Secret: gtmv1.TokenSecretSpec{ - Name: secretName, - BasicAuth: isBasicAuth, - }, - Repositories: []string{ - testRepositoryName, - }, - Permissions: >mv1.Permissions{ - Contents: &readPermission, - Metadata: &readPermission, - }, + Permissions: >mv1.Permissions{ + Contents: &readPermission, + Metadata: &readPermission, }, } - - return c.client.Create(c.context, token) -} - -// createTokenWithAppRef creates a Token that resolves its GitHub App credentials -// via spec.appRef rather than the operator's startup configuration. -func (c *clientContext) createTokenWithAppRef( - name, namespace, secretName, appRefName string, - isBasicAuth bool, - refreshInterval time.Duration, -) error { + if appRefName != "" { + spec.AppRef = >mv1.LocalAppReference{Name: appRefName} + } token := >mv1.Token{ TypeMeta: metav1.TypeMeta{ APIVersion: "github.as-code.io/v1", @@ -261,21 +245,7 @@ func (c *clientContext) createTokenWithAppRef( Name: name, Namespace: namespace, }, - Spec: gtmv1.TokenSpec{ - AppRef: >mv1.LocalAppReference{Name: appRefName}, - RefreshInterval: metav1.Duration{Duration: refreshInterval}, - Secret: gtmv1.TokenSecretSpec{ - Name: secretName, - BasicAuth: isBasicAuth, - }, - Repositories: []string{ - testRepositoryName, - }, - Permissions: >mv1.Permissions{ - Contents: &readPermission, - Metadata: &readPermission, - }, - }, + Spec: spec, } return c.client.Create(c.context, token) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 24d6ffb..1c1c35b 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -264,7 +264,7 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { } By("creating a Token resource with basicAuth=false") - Expect(clientCtx.createToken(testToken1, targetNamespace, testSecret1, false, tokenRefreshInterval)).To(Succeed()) + Expect(clientCtx.createToken(testToken1, targetNamespace, testSecret1, "", false, tokenRefreshInterval)).To(Succeed()) By("waiting for Token reconciliation") clientCtx.waitForTokenReconciliation(testToken1, targetNamespace) @@ -296,7 +296,7 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { } By("creating a Token resource with basicAuth=true") - Expect(clientCtx.createToken(testToken2, targetNamespace, testSecret2, true, tokenRefreshInterval)).To(Succeed()) + Expect(clientCtx.createToken(testToken2, targetNamespace, testSecret2, "", true, tokenRefreshInterval)).To(Succeed()) By("waiting for Token reconciliation") clientCtx.waitForTokenReconciliation(testToken2, targetNamespace) @@ -438,7 +438,7 @@ var _ = Describe("GitHub Token Manager", Ordered, func() { } By("creating a Token with spec.appRef pointing at the App") - Expect(clientCtx.createTokenWithAppRef( + Expect(clientCtx.createToken( testToken3, targetNamespace, testSecret5, testApp, false, tokenRefreshInterval, )).To(Succeed()) From 7c1faf726c6161244e18f22d4ee3fe9d3bf8332f Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Mon, 27 Apr 2026 09:33:21 +0200 Subject: [PATCH 3/3] refactor: Remove unused methods and clean up CRD scaffolding - Remove unused GetName methods from Token and ClusterToken types - Remove HasStartupConfig from ghapp.Registry and related test - Remove commented and scaffolding code from CRDs and API types - Refactor tokenmanager to simplify tokenSecret construction and context usage - Update controller and tokenmanager to use new tokenSecret API --- api/v1/clustertoken_types.go | 11 - api/v1/managed_secret.go | 4 - api/v1/token_types.go | 10 - .../crd/bases/github.as-code.io_tokens.yaml | 3 - internal/controller/app_controller.go | 36 +-- internal/controller/reconcile_token.go | 29 +-- internal/ghapp/registry.go | 5 - internal/ghapp/registry_test.go | 3 - internal/tokenmanager/token_manager.go | 6 +- internal/tokenmanager/token_secret.go | 221 ++++++------------ 10 files changed, 106 insertions(+), 222 deletions(-) diff --git a/api/v1/clustertoken_types.go b/api/v1/clustertoken_types.go index a121877..13ffe7f 100644 --- a/api/v1/clustertoken_types.go +++ b/api/v1/clustertoken_types.go @@ -25,13 +25,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - // ClusterTokenSpec defines the desired state of ClusterToken type ClusterTokenSpec struct { - // Important: Run "make" to regenerate code after modifying this file - // +optional // Reference to the App that provides the GitHub App credentials for this // ClusterToken. When spec.appRef.namespace is empty, the operator resolves @@ -104,8 +99,6 @@ type ClusterTokenSecretSpec struct { // ClusterTokenStatus defines the observed state of ClusterToken type ClusterTokenStatus struct { - // Important: Run "make" to regenerate code after modifying this file - ManagedSecret ManagedSecret `json:"managedSecret,omitempty"` IAT InstallationAccessToken `json:"installationAccessToken,omitempty"` @@ -130,10 +123,6 @@ func (t *ClusterToken) GetType() string { return "ClusterToken" } -func (t *ClusterToken) GetName() string { - return t.Name -} - func (t *ClusterToken) GetInstallationID() int64 { return t.Spec.InstallationID } diff --git a/api/v1/managed_secret.go b/api/v1/managed_secret.go index 63fbf7c..601aac7 100644 --- a/api/v1/managed_secret.go +++ b/api/v1/managed_secret.go @@ -22,10 +22,6 @@ func (m ManagedSecret) MatchesSpec(owner secretOwner) bool { return m.Namespace == owner.GetSecretNamespace() && m.Name == owner.GetSecretName() && m.BasicAuth == owner.GetSecretBasicAuth() } -// func (m *ManagedSecret) MatchesKey(key types.NamespacedName) bool { -// return m.Namespace == key.Namespace && m.Name == key.Name -// } - func (m ManagedSecret) Key() types.NamespacedName { return types.NamespacedName{ Namespace: m.Namespace, diff --git a/api/v1/token_types.go b/api/v1/token_types.go index ad30447..f6419d9 100644 --- a/api/v1/token_types.go +++ b/api/v1/token_types.go @@ -25,13 +25,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - // TokenSpec defines the desired state of Token type TokenSpec struct { - // Important: Run "make" to regenerate code after modifying this file - // +optional // Reference to the App that provides the GitHub App credentials for this // Token. Must be in the same namespace as the Token. When unset, the @@ -98,7 +93,6 @@ type TokenSecretSpec struct { // TokenStatus defines the observed state of Token type TokenStatus struct { - // Important: Run "make" to regenerate code after modifying this file ManagedSecret ManagedSecret `json:"managedSecret,omitempty"` IAT InstallationAccessToken `json:"installationAccessToken,omitempty"` @@ -122,10 +116,6 @@ func (t *Token) GetType() string { return "Token" } -func (t *Token) GetName() string { - return t.Name -} - func (t *Token) GetInstallationID() int64 { return t.Spec.InstallationID } diff --git a/config/crd/bases/github.as-code.io_tokens.yaml b/config/crd/bases/github.as-code.io_tokens.yaml index 9816ef4..d40f8c5 100644 --- a/config/crd/bases/github.as-code.io_tokens.yaml +++ b/config/crd/bases/github.as-code.io_tokens.yaml @@ -379,9 +379,6 @@ spec: type: string type: object managedSecret: - description: - 'Important: Run "make" to regenerate code after modifying - this file' properties: basicAuth: type: boolean diff --git a/internal/controller/app_controller.go b/internal/controller/app_controller.go index 2934dca..1c41a81 100644 --- a/internal/controller/app_controller.go +++ b/internal/controller/app_controller.go @@ -94,11 +94,14 @@ func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R Reason: failure, Message: buildErr.Error(), } - keyValid := metav1.Condition{ - Type: githubv1.ConditionTypeKeyValid, - Status: metav1.ConditionFalse, - Reason: failure, - Message: buildErr.Error(), + var keyValid *metav1.Condition + if app.Spec.ValidateKey { + keyValid = &metav1.Condition{ + Type: githubv1.ConditionTypeKeyValid, + Status: metav1.ConditionFalse, + Reason: failure, + Message: buildErr.Error(), + } } if err := r.writeAppStatus(ctx, app, ready, keyValid); err != nil { return ctrl.Result{}, err @@ -112,11 +115,14 @@ func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R Reason: githubv1.ReasonReconciled, Message: "GitHub App client ready", } - keyValid := metav1.Condition{ - Type: githubv1.ConditionTypeKeyValid, - Status: metav1.ConditionTrue, - Reason: githubv1.ReasonReconciled, - Message: "signer key validated", + var keyValid *metav1.Condition + if app.Spec.ValidateKey { + keyValid = &metav1.Condition{ + Type: githubv1.ConditionTypeKeyValid, + Status: metav1.ConditionTrue, + Reason: githubv1.ReasonReconciled, + Message: "signer key validated", + } } if err := r.writeAppStatus(ctx, app, ready, keyValid); err != nil { return ctrl.Result{}, err @@ -125,12 +131,12 @@ func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R } // writeAppStatus applies the Ready condition, applies or clears the KeyValid -// condition based on Spec.ValidateKey, bumps ObservedGeneration, and writes -// status only if anything actually changed. -func (r *AppReconciler) writeAppStatus(ctx context.Context, app *githubv1.App, ready, keyValid metav1.Condition) error { +// condition (nil clears), bumps ObservedGeneration, and writes status only if +// anything actually changed. +func (r *AppReconciler) writeAppStatus(ctx context.Context, app *githubv1.App, ready metav1.Condition, keyValid *metav1.Condition) error { changed := app.SetStatusCondition(ready) - if app.Spec.ValidateKey { - if app.SetStatusCondition(keyValid) { + if keyValid != nil { + if app.SetStatusCondition(*keyValid) { changed = true } } else if meta.RemoveStatusCondition(&app.Status.Conditions, githubv1.ConditionTypeKeyValid) { diff --git a/internal/controller/reconcile_token.go b/internal/controller/reconcile_token.go index 8b0bcd4..58e63d2 100644 --- a/internal/controller/reconcile_token.go +++ b/internal/controller/reconcile_token.go @@ -23,7 +23,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - githubv1 "github.com/isometry/github-token-manager/api/v1" "github.com/isometry/github-token-manager/internal/ghapp" "github.com/isometry/github-token-manager/internal/metrics" tm "github.com/isometry/github-token-manager/internal/tokenmanager" @@ -38,20 +37,14 @@ type TokenReconcilerBase struct { Registry *ghapp.Registry } -// tokenObject is the generic constraint for a Token-like CR. PT is the -// pointer type (*Token or *ClusterToken); the interface lists the methods -// the reconcile helper needs that aren't already on tm.TokenManager. -type tokenObject[T any] interface { - tm.TokenManager - GetAppRef() *githubv1.AppReference - *T -} - // reconcileTokenLike runs the post-Get reconcile body shared by Token and // ClusterToken: fetch the typed object, resolve its App reference, surface // any failure as a status condition, then hand off to tokenmanager to // reconcile the managed Secret. -func reconcileTokenLike[T any, PT tokenObject[T]]( +func reconcileTokenLike[T any, PT interface { + tm.TokenManager + *T +}]( ctx context.Context, r *TokenReconcilerBase, req ctrl.Request, @@ -82,22 +75,14 @@ func reconcileTokenLike[T any, PT tokenObject[T]]( } options := []tm.Option{ - tm.WithReconciler(r.Client), + tm.WithClient(r.Client), tm.WithGHApp(resolution.Client), tm.WithLogger(logger), tm.WithMetrics(r.Metrics), } - tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, owner, controllerName, options...) - if err != nil { - logger.Error(err, "failed to create token reconciler") - return ctrl.Result{}, err - } - if tokenSecret == nil { - return ctrl.Result{}, nil - } - - result, err := tokenSecret.Reconcile() + tokenSecret := tm.NewTokenSecret(req.NamespacedName, owner, controllerName, options...) + result, err := tokenSecret.Reconcile(ctx) if err != nil { logger.Error(err, "failed to reconcile token") return result, err diff --git a/internal/ghapp/registry.go b/internal/ghapp/registry.go index 743f3e7..2c795c7 100644 --- a/internal/ghapp/registry.go +++ b/internal/ghapp/registry.go @@ -96,11 +96,6 @@ func (r *Registry) OperatorNamespace() string { return r.operatorNS } -// HasStartupConfig reports whether a startup GitHub App config is available. -func (r *Registry) HasStartupConfig() bool { - return r.startupCfg != nil -} - // Startup returns the cached startup-config client, building it on first use. // Returns [ErrNoStartupConfig] if no startup config was loaded. func (r *Registry) Startup(ctx context.Context) (ghait.GHAIT, error) { diff --git a/internal/ghapp/registry_test.go b/internal/ghapp/registry_test.go index 336028d..3a277f8 100644 --- a/internal/ghapp/registry_test.go +++ b/internal/ghapp/registry_test.go @@ -35,9 +35,6 @@ func countingFactory() (FactoryFunc, *int) { func TestRegistry_Startup_NoConfig(t *testing.T) { r := NewRegistry("gtm-system", nil) - if r.HasStartupConfig() { - t.Fatalf("HasStartupConfig() = true with nil cfg") - } _, err := r.Startup(context.Background()) if !errors.Is(err, ErrNoStartupConfig) { t.Fatalf("Startup() err = %v, want ErrNoStartupConfig", err) diff --git a/internal/tokenmanager/token_manager.go b/internal/tokenmanager/token_manager.go index 69e925c..e105117 100644 --- a/internal/tokenmanager/token_manager.go +++ b/internal/tokenmanager/token_manager.go @@ -10,16 +10,12 @@ import ( githubv1 "github.com/isometry/github-token-manager/api/v1" ) -type tokenReconciler interface { - client.Client -} - // TokenManager provides a common interface for both namespaced Tokens and ClusterTokens. type TokenManager interface { client.Object GetType() string - GetName() string + GetAppRef() *githubv1.AppReference GetSecretBasicAuth() bool GetInstallationID() int64 GetRefreshInterval() time.Duration diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 61caca7..014b94c 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -29,9 +29,8 @@ const ( ) type tokenSecret struct { - ctx context.Context log logr.Logger - reconciler tokenReconciler + client client.Client key types.NamespacedName owner TokenManager controllerName string @@ -42,9 +41,9 @@ type tokenSecret struct { type Option func(*tokenSecret) -func WithReconciler(reconciler tokenReconciler) Option { +func WithClient(c client.Client) Option { return func(s *tokenSecret) { - s.reconciler = reconciler + s.client = c } } @@ -66,79 +65,46 @@ func WithMetrics(m *metrics.Recorder) Option { } } -func NewTokenSecret(ctx context.Context, key types.NamespacedName, owner TokenManager, controllerName string, options ...Option) (*tokenSecret, error) { +func NewTokenSecret(key types.NamespacedName, owner TokenManager, controllerName string, options ...Option) *tokenSecret { s := &tokenSecret{ - ctx: ctx, key: key, owner: owner, controllerName: controllerName, } - for _, option := range options { option(s) } - - if err := s.RefreshOwner(); err != nil { - if apierrors.IsNotFound(err) { - // If the custom resource is not found then, it usually means that it was deleted or not created - // In this way, we will stop the reconciliation - s.log.Info("token resource not found; ignoring since object must be deleted") - s.metrics.RemoveTokenActive(s.ctx, s.controllerName, s.key.String()) - return nil, nil - } - // Error reading the object - requeue the request. - s.log.Error(err, "failed to get token") - return nil, err - } - - // Initialize Token status conditions - if len(s.owner.GetStatusConditions()) == 0 { - s.log.Info("initializing token status conditions") - - condition := metav1.Condition{ - Type: githubv1.ConditionTypeReady, - Status: metav1.ConditionUnknown, - Reason: "Reconciling", - Message: "Starting reconciliation", - } - if err := s.UpdateTokenStatus(s.withCondition(condition)); err != nil { - s.log.Error(err, "failed to update token status") - return nil, err - } - } - - return s, nil + return s } -func (s *tokenSecret) NewInstallationToken() (*github.InstallationToken, error) { +func (s *tokenSecret) NewInstallationToken(ctx context.Context) (*github.InstallationToken, error) { installationId := s.owner.GetInstallationID() options := s.owner.GetInstallationTokenOptions() start := time.Now() - token, err := s.ghait.NewInstallationToken(s.ctx, installationId, options) - s.metrics.RecordGitHubAPICall(s.ctx, s.controllerName, time.Since(start), err) + token, err := s.ghait.NewInstallationToken(ctx, installationId, options) + s.metrics.RecordGitHubAPICall(ctx, s.controllerName, time.Since(start), err) return token, err } -func (s *tokenSecret) RefreshOwner() error { - return s.reconciler.Get(s.ctx, s.key, s.owner) +func (s *tokenSecret) RefreshOwner(ctx context.Context) error { + return s.client.Get(ctx, s.key, s.owner) } -func (s *tokenSecret) recordExpiry() { +func (s *tokenSecret) recordExpiry(ctx context.Context) { _, expiresAt := s.owner.GetStatusTimestamps() if !expiresAt.IsZero() { - s.metrics.RecordTokenExpiry(s.ctx, s.controllerName, s.owner.GetSecretNamespace(), s.owner.GetName(), expiresAt) + s.metrics.RecordTokenExpiry(ctx, s.controllerName, s.owner.GetSecretNamespace(), s.owner.GetName(), expiresAt) } } -func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { +func (s *tokenSecret) Reconcile(ctx context.Context) (result reconcile.Result, err error) { log := s.log.WithValues("func", "Reconcile") managedSecret := s.owner.GetManagedSecret() if !managedSecret.IsUnset() && !managedSecret.MatchesSpec(s.owner) { - // The Secret key has changed, so delete the old Secret - if err := s.DeleteSecret(managedSecret.Key()); err != nil { + if err := s.DeleteSecret(ctx, managedSecret.Key()); err != nil { log.Error(err, "failed to delete managed secret") return result, err } @@ -151,39 +117,37 @@ func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { secret := &corev1.Secret{} - err = s.reconciler.Get(s.ctx, secretKey, secret) - if client.IgnoreNotFound(err) != nil { + err = s.client.Get(ctx, secretKey, secret) + if err != nil && !apierrors.IsNotFound(err) { log.Error(err, "failed to get secret") return result, err } if apierrors.IsNotFound(err) { - // Secret not found, so create it start := time.Now() - if err := s.CreateSecret(); err != nil { - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultError) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationCreate, time.Since(start)) + if err := s.CreateSecret(ctx); err != nil { + s.metrics.RecordTokenRefresh(ctx, s.controllerName, metrics.ResultError) + s.metrics.RecordTokenRefreshDuration(ctx, s.controllerName, metrics.OperationCreate, time.Since(start)) if errors.Is(err, ghait.TransientError{}) { - s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonTransient) + s.metrics.RecordReconcileError(ctx, s.controllerName, metrics.ReasonTransient) log.Error(err, "transient error creating secret") return reconcile.Result{RequeueAfter: s.owner.GetRetryInterval()}, nil } - s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonSecretCreate) + s.metrics.RecordReconcileError(ctx, s.controllerName, metrics.ReasonSecretCreate) log.Error(err, "fatal error creating secret") return result, err } - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultSuccess) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationCreate, time.Since(start)) - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultSuccess) - s.metrics.EnsureTokenActive(s.ctx, s.controllerName, s.key.String()) - s.recordExpiry() + s.metrics.RecordTokenRefresh(ctx, s.controllerName, metrics.ResultSuccess) + s.metrics.RecordTokenRefreshDuration(ctx, s.controllerName, metrics.OperationCreate, time.Since(start)) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationCreate, metrics.ResultSuccess) + s.metrics.EnsureTokenActive(ctx, s.controllerName, s.key.String()) + s.recordExpiry(ctx) return reconcile.Result{RequeueAfter: s.owner.GetRefreshInterval()}, nil } - // Secret was found, so update it if !metav1.IsControlledBy(secret, s.owner) { condition := metav1.Condition{ Type: githubv1.ConditionTypeReady, @@ -191,11 +155,11 @@ func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { Reason: "Failed", Message: "Secret already exists", } - if err := s.UpdateTokenStatus(s.withCondition(condition)); err != nil { + if err := s.UpdateTokenStatus(ctx, &condition, nil, false); err != nil { log.Error(err, "failed to update token status") return result, err } - s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonOwnership) + s.metrics.RecordReconcileError(ctx, s.controllerName, metrics.ReasonOwnership) err := errors.New("existing secret not owned by token") log.Error(err, "ownership mismatch", "token", s.owner) return result, err @@ -204,37 +168,37 @@ func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { s.Secret = secret start := time.Now() - if err := s.UpdateSecret(); err != nil { - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultError) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationUpdate, time.Since(start)) + if err := s.UpdateSecret(ctx); err != nil { + s.metrics.RecordTokenRefresh(ctx, s.controllerName, metrics.ResultError) + s.metrics.RecordTokenRefreshDuration(ctx, s.controllerName, metrics.OperationUpdate, time.Since(start)) if errors.Is(err, ghait.TransientError{}) { - s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonTransient) + s.metrics.RecordReconcileError(ctx, s.controllerName, metrics.ReasonTransient) log.Error(err, "transient error updating secret") return reconcile.Result{RequeueAfter: s.owner.GetRetryInterval()}, nil } - s.metrics.RecordReconcileError(s.ctx, s.controllerName, metrics.ReasonSecretUpdate) + s.metrics.RecordReconcileError(ctx, s.controllerName, metrics.ReasonSecretUpdate) log.Error(err, "fatal error updating secret") return result, err } - s.metrics.RecordTokenRefresh(s.ctx, s.controllerName, metrics.ResultSuccess) - s.metrics.RecordTokenRefreshDuration(s.ctx, s.controllerName, metrics.OperationUpdate, time.Since(start)) - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultSuccess) - s.metrics.EnsureTokenActive(s.ctx, s.controllerName, s.key.String()) - s.recordExpiry() + s.metrics.RecordTokenRefresh(ctx, s.controllerName, metrics.ResultSuccess) + s.metrics.RecordTokenRefreshDuration(ctx, s.controllerName, metrics.OperationUpdate, time.Since(start)) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultSuccess) + s.metrics.EnsureTokenActive(ctx, s.controllerName, s.key.String()) + s.recordExpiry(ctx) return reconcile.Result{RequeueAfter: s.owner.GetRefreshInterval()}, nil } -func (s *tokenSecret) CreateSecret() error { +func (s *tokenSecret) CreateSecret(ctx context.Context) error { log := s.log.WithValues("func", "CreateSecret") log.Info("creating secret") - installationToken, err := s.NewInstallationToken() + installationToken, err := s.NewInstallationToken(ctx) if err != nil { log.Error(err, "failed to get installation token") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) return err } @@ -256,17 +220,15 @@ func (s *tokenSecret) CreateSecret() error { s.Secret = secret - // Set the ownerRef for the Secret - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ - if err := ctrl.SetControllerReference(s.owner, s.Secret, s.reconciler.Scheme()); err != nil { + if err := ctrl.SetControllerReference(s.owner, s.Secret, s.client.Scheme()); err != nil { log.Error(err, "failed to set controller reference") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) return err } - if err := s.reconciler.Create(s.ctx, s.Secret); err != nil { + if err := s.client.Create(ctx, s.Secret); err != nil { log.Error(err, "failed to create secret") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationCreate, metrics.ResultError) return err } @@ -276,13 +238,8 @@ func (s *tokenSecret) CreateSecret() error { Reason: "Created", Message: "Created Secret", } - - options := []tokenStatusOptions{ - s.withCondition(condition), - s.withUpdateManagedSecret(), - s.withExpiresAt(installationToken.ExpiresAt.Time), - } - if err := s.UpdateTokenStatus(options...); err != nil { + expiresAt := installationToken.ExpiresAt.Time + if err := s.UpdateTokenStatus(ctx, &condition, &expiresAt, true); err != nil { log.Error(err, "failed to update token status") return err } @@ -290,22 +247,22 @@ func (s *tokenSecret) CreateSecret() error { return nil } -func (s *tokenSecret) UpdateSecret() error { +func (s *tokenSecret) UpdateSecret(ctx context.Context) error { log := s.log.WithValues("func", "UpdateSecret") log.Info("updating secret") - installationToken, err := s.NewInstallationToken() + installationToken, err := s.NewInstallationToken(ctx) if err != nil { log.Error(err, "failed to get installation token") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultError) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultError) return err } s.Data = s.SecretData(installationToken.GetToken()) - if err := s.reconciler.Update(s.ctx, s.Secret); err != nil { + if err := s.client.Update(ctx, s.Secret); err != nil { log.Error(err, "failed to update secret") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultError) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationUpdate, metrics.ResultError) return err } @@ -315,13 +272,8 @@ func (s *tokenSecret) UpdateSecret() error { Reason: "Updated", Message: "Updated Secret", } - - options := []tokenStatusOptions{ - s.withCondition(condition), - s.withUpdateManagedSecret(), - s.withExpiresAt(installationToken.ExpiresAt.Time), - } - if err := s.UpdateTokenStatus(options...); err != nil { + expiresAt := installationToken.ExpiresAt.Time + if err := s.UpdateTokenStatus(ctx, &condition, &expiresAt, true); err != nil { log.Error(err, "failed to update token status") return err } @@ -329,11 +281,11 @@ func (s *tokenSecret) UpdateSecret() error { return nil } -func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { +func (s *tokenSecret) DeleteSecret(ctx context.Context, key types.NamespacedName) error { log := s.log.WithValues("func", "DeleteSecret") secret := &corev1.Secret{} - if err := s.reconciler.Get(s.ctx, key, secret); err != nil { + if err := s.client.Get(ctx, key, secret); err != nil { if apierrors.IsNotFound(err) { log.Info("existing secret not found") return nil @@ -347,16 +299,15 @@ func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { return nil } - // Delete the old Secret; failure to delete is fatal log.Info("deleting existing secret") - if err := s.reconciler.Delete(s.ctx, secret); err != nil { + if err := s.client.Delete(ctx, secret); err != nil { log.Error(err, "failed to delete secret") - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationDelete, metrics.ResultError) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationDelete, metrics.ResultError) return err } - s.metrics.RecordSecretOperation(s.ctx, s.controllerName, metrics.OperationDelete, metrics.ResultSuccess) - s.metrics.RemoveTokenActive(s.ctx, s.controllerName, s.key.String()) + s.metrics.RecordSecretOperation(ctx, s.controllerName, metrics.OperationDelete, metrics.ResultSuccess) + s.metrics.RemoveTokenActive(ctx, s.controllerName, s.key.String()) condition := metav1.Condition{ Type: githubv1.ConditionTypeReady, @@ -364,7 +315,7 @@ func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { Reason: "Reconciling", Message: "Deleted old Secret", } - if err := s.UpdateTokenStatus(s.withCondition(condition)); err != nil { + if err := s.UpdateTokenStatus(ctx, &condition, nil, false); err != nil { log.Error(err, "failed to update token status") return err } @@ -372,57 +323,39 @@ func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { return nil } -type tokenStatusOptions func() (changed bool) - -func (s *tokenSecret) withCondition(condition metav1.Condition) tokenStatusOptions { - return func() (changed bool) { - return s.owner.SetStatusCondition(condition) - } -} - -func (s *tokenSecret) withExpiresAt(expiresAt time.Time) tokenStatusOptions { - return func() (changed bool) { - s.owner.SetStatusTimestamps(expiresAt) - return true - } -} - -func (s *tokenSecret) withUpdateManagedSecret() tokenStatusOptions { - return func() (changed bool) { - return s.owner.UpdateManagedSecret() - } -} - -func (s *tokenSecret) UpdateTokenStatus(options ...tokenStatusOptions) error { +// UpdateTokenStatus refreshes the owner, applies the given mutations, and +// writes status if anything changed, retrying on conflict. Pass nil for +// condition or expiresAt to leave them untouched; updateManaged toggles the +// ManagedSecret refresh. +func (s *tokenSecret) UpdateTokenStatus(ctx context.Context, condition *metav1.Condition, expiresAt *time.Time, updateManaged bool) error { log := s.log.WithValues("func", "UpdateTokenStatus") err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - if err := s.RefreshOwner(); err != nil { + if err := s.RefreshOwner(ctx); err != nil { return err } var changed bool - for _, option := range options { - changed = option() || changed + if condition != nil && s.owner.SetStatusCondition(*condition) { + changed = true + } + if expiresAt != nil { + s.owner.SetStatusTimestamps(*expiresAt) + changed = true + } + if updateManaged && s.owner.UpdateManagedSecret() { + changed = true } if !changed { return nil } - - return s.reconciler.Status().Update(s.ctx, s.owner) + return s.client.Status().Update(ctx, s.owner) }) - if err != nil { log.Error(err, "failed to update token status") return err } - - if err := s.RefreshOwner(); err != nil { - log.Error(err, "failed to refresh token after status update") - return err - } - return nil }