Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ linters:
- dupl
- lll
path: internal/*
- linters:
- dupl
- goconst
path: _test\.go$
paths:
- third_party$
- builtin$
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
KUSTOMIZE_VERSION ?= v5.5.0
CONTROLLER_TOOLS_VERSION ?= v0.18.0
ENVTEST_VERSION := $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}')
GOLANGCI_LINT_VERSION ?= v2.1.0
GOLANGCI_LINT_VERSION ?= v2.12.2

.PHONY: kustomize
kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,23 @@ spec:
labels: {} # (optional) map of labels for managed `Secret`
name: bar # (optional) override name for managed `Secret` (default: .metadata.name)
namespace: default # (required, ClusterToken-only) set the target namespace for managed `Secret`
extraData: # (optional) list of additional keys to project into managed `Secret`
- inline: {} # static key/value pairs, merged verbatim
- configMap: # project keys from a ConfigMap
name: foo
namespace: bar # (ClusterToken only: defaults to `secret.namespace`; a Token ref has no namespace field and always reads its own namespace)
keys: [] # (optional) allowlist of keys to project (default: all keys)
optional: false # (optional) if true, a missing source or key is skipped (per-key) rather than blocking
- secret: # project keys from a Secret; same fields as configMap
name: baz
```

`spec.secret.extraData` projects additional keys into the managed `Secret` alongside the generated credentials, from inline values and/or referenced ConfigMaps/Secrets. Entries are merged in order, with later entries overriding earlier ones on key collision (logged as a Warning event). Operator-managed keys are always authoritative: inline entries that set a reserved key (`username`/`password` when `basicAuth: true`, or `token` otherwise) are rejected at admission, while the same keys from a ConfigMap/Secret source are silently dropped at reconcile (also a Warning event) since their contents aren't known until read.

Token validity always takes priority over extraData. A required source (`optional: false`) that cannot be resolved only blocks *creation* of the managed `Secret` (surfaced as `Ready=False`/`SourceUnavailable`); once the `Secret` exists, an unresolvable source never destroys it — the credential keeps refreshing, the last-known-good extraData is retained, and the degradation is surfaced via the abnormal-true `ExtraDataDegraded` condition (`True`/`SourceUnavailable`, plus a Warning event) while the source is retried on the retry interval. With `optional: true`, a missing object or a missing listed key is skipped per-key (present keys still project) and reported via `ExtraDataDegraded=True`/`KeysMissing`. When extraData resolves in full, the condition is absent.

Sources are re-read on the refresh/retry interval; there is no separate watch on the referenced ConfigMap/Secret. Because last-known-good data is retained, deleting a source object does not remove its keys from the managed `Secret` — remove the entry from `spec.secret.extraData` instead.

### Multiple GitHub Apps (`App` CRD)

Deployments that need multiple GitHub App configurations — different orgs, per-tenant Apps, or installations with different key providers — can declare `App` resources as the sole credential source, alongside, or instead of the startup `Secret/gtm-config`. `Token.spec.appRef` and `ClusterToken.spec.appRef` then select which App to use; when `appRef` is omitted, the startup config remains the fallback so **existing deployments need no changes**.
Expand Down
38 changes: 37 additions & 1 deletion api/v1/clustertoken_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package v1
import (
"time"

"github.com/google/go-github/v84/github"
"github.com/google/go-github/v88/github"
"github.com/isometry/github-token-manager/internal/ghapp"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -72,6 +72,9 @@ type ClusterTokenSpec struct {
RepositoryIDs []int64 `json:"repositoryIDs,omitempty"`
}

// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('username' in e.inline || 'password' in e.inline))",message="extraData inline must not contain 'username' or 'password' when basicAuth is true"
// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))",message="extraData inline must not contain 'token' when basicAuth is false"
// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || self.extraData.all(e, !has(e.inline) || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))",message="extraData inline keys must consist of alphanumerics, '-', '_' or '.'"
type ClusterTokenSecretSpec struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:MaxLength:=253
Expand All @@ -95,6 +98,15 @@ type ClusterTokenSecretSpec struct {
// +optional
// Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token'
BasicAuth bool `json:"basicAuth,omitempty"`

// +optional
// +kubebuilder:validation:MaxItems:=16
// Additional keys to project into the managed Secret, from inline values
// and/or referenced ConfigMaps/Secrets. A configMap/secret ref's namespace
// defaults to the target Secret's namespace when unset. Reserved keys
// ('username'/'password' when basicAuth is true, 'token' otherwise) are
// always overridden by the operator-managed values.
ExtraData []SecretDataSource `json:"extraData,omitempty"`
}

// ClusterTokenStatus defines the observed state of ClusterToken
Expand Down Expand Up @@ -173,6 +185,26 @@ func (t *ClusterToken) GetSecretBasicAuth() bool {
return t.Spec.Secret.BasicAuth
}

// GetSecretDataSources returns the extraData sources for this ClusterToken,
// defaulting any configMap/secret ref's empty namespace to the target
// Secret's namespace. Sources are deep-copied so the defaulting never
// mutates the caller's spec.
func (t *ClusterToken) GetSecretDataSources() []SecretDataSource {
if len(t.Spec.Secret.ExtraData) == 0 {
return nil
}
sources := make([]SecretDataSource, len(t.Spec.Secret.ExtraData))
for i, source := range t.Spec.Secret.ExtraData {
sources[i] = *source.DeepCopy()
for _, ref := range []*SecretDataSourceRef{sources[i].ConfigMap, sources[i].Secret} {
if ref != nil && ref.Namespace == "" {
ref.Namespace = t.Spec.Secret.Namespace
}
}
}
return sources
}

func (t *ClusterToken) GetInstallationTokenOptions() *github.InstallationTokenOptions {
return &github.InstallationTokenOptions{
Permissions: t.Spec.Permissions.ToInstallationPermissions(),
Expand Down Expand Up @@ -214,6 +246,10 @@ func (t *ClusterToken) SetStatusCondition(condition metav1.Condition) (changed b
return meta.SetStatusCondition(&t.Status.Conditions, condition)
}

func (t *ClusterToken) RemoveStatusCondition(conditionType string) (changed bool) {
return meta.RemoveStatusCondition(&t.Status.Conditions, conditionType)
}

// +kubebuilder:object:root=true

// ClusterTokenList contains a list of ClusterToken
Expand Down
35 changes: 34 additions & 1 deletion api/v1/clustertoken_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"testing"
"time"

"github.com/google/go-github/v84/github"
"github.com/google/go-github/v88/github"
v1 "github.com/isometry/github-token-manager/api/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
Expand Down Expand Up @@ -356,3 +356,36 @@ func TestClusterToken_SetStatusTimestamps(t *testing.T) {
t.Error("CreatedAt should be before ExpiresAt")
}
}

func TestClusterToken_GetSecretDataSources(t *testing.T) {
token := &v1.ClusterToken{
Spec: v1.ClusterTokenSpec{
Secret: v1.ClusterTokenSecretSpec{
Namespace: "target-namespace",
ExtraData: []v1.SecretDataSource{
{Inline: map[string]string{"ca.crt": "PEM"}},
{ConfigMap: &v1.SecretDataSourceRef{Name: "ca-bundle"}},
{ConfigMap: &v1.SecretDataSourceRef{Name: "ca-bundle", Namespace: "shared-ns"}},
},
},
},
}

got := token.GetSecretDataSources()
if len(got) != 3 {
t.Fatalf("GetSecretDataSources() returned %d entries, want 3", len(got))
}
if got[0].Inline["ca.crt"] != "PEM" {
t.Errorf("inline entry = %v, want ca.crt=PEM", got[0].Inline)
}
if got[1].ConfigMap.Namespace != "target-namespace" {
t.Errorf("unset ref namespace = %q, want defaulted to target Secret namespace %q", got[1].ConfigMap.Namespace, "target-namespace")
}
if got[2].ConfigMap.Namespace != "shared-ns" {
t.Errorf("explicit ref namespace = %q, want preserved as %q", got[2].ConfigMap.Namespace, "shared-ns")
}

if got := (&v1.ClusterToken{}).GetSecretDataSources(); got != nil {
t.Errorf("GetSecretDataSources() on empty ClusterToken = %v, want nil", got)
}
}
19 changes: 19 additions & 0 deletions api/v1/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const (
// spec.validateKey is false.
ConditionTypeKeyValid = "KeyValid"

// ConditionTypeExtraDataDegraded is an abnormal-true (kstatus-style)
// condition: set True when spec.secret.extraData did not resolve in full
// on the last reconcile — reason KeysMissing when optional listed keys
// are absent, or reason SourceUnavailable while the managed Secret
// serves last-known-good extraData because a required source cannot be
// read. Absent when extraData resolves cleanly or none is configured.
ConditionTypeExtraDataDegraded = "ExtraDataDegraded"

// Condition reasons used by the Token and ClusterToken controllers when
// resolving spec.appRef.

Expand All @@ -30,4 +38,15 @@ const (
// ReasonInvalidKey indicates the resolved key material is missing,
// empty, or not a usable PEM-encoded RSA private key.
ReasonInvalidKey = "InvalidKey"

// Condition reasons used with ConditionTypeExtraDataDegraded (and, for
// SourceUnavailable, with ConditionTypeReady when Secret creation is
// blocked).

// ReasonKeysMissing indicates optional extraData keys were absent from
// their source and skipped.
ReasonKeysMissing = "KeysMissing"
// ReasonSourceUnavailable indicates a required extraData source could not
// be resolved.
ReasonSourceUnavailable = "SourceUnavailable"
)
1 change: 1 addition & 0 deletions api/v1/groupversion_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var (
GroupVersion = schema.GroupVersion{Group: "github.as-code.io", Version: "v1"}

// SchemeBuilder is used to add go types to the GroupVersionKind scheme
//nolint:staticcheck // standard kubebuilder scaffolding; scheme.Builder deprecation has no drop-in replacement yet
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}

// AddToScheme adds the types in this group-version to the given scheme.
Expand Down
2 changes: 1 addition & 1 deletion api/v1/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package v1_test
import (
"testing"

"github.com/google/go-github/v84/github"
"github.com/google/go-github/v88/github"
v1 "github.com/isometry/github-token-manager/api/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
Expand Down
2 changes: 1 addition & 1 deletion api/v1/permissions.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package v1

import (
"github.com/google/go-github/v84/github"
"github.com/google/go-github/v88/github"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand Down
3 changes: 0 additions & 3 deletions api/v1/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,6 @@ func TestPermissions_ToInstallationPermissions_FieldMapping(t *testing.T) {
}
}

//go:fix inline
func ptr(s string) *string { return new(s) }

func TestPermissions_ToInstallationPermissions_AllPermissions(t *testing.T) {
p := &v1.Permissions{
Actions: new("actions"),
Expand Down
140 changes: 140 additions & 0 deletions api/v1/secretdatasource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
Copyright 2024 Robin Breathe.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1

// +kubebuilder:validation:XValidation:rule="(has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) == 1",message="exactly one of inline, configMap or secret must be set"

// SecretDataSource projects additional keys into a managed Secret from
// exactly one of an inline map, a ConfigMap, or another Secret. Used by the
// cluster-scoped ClusterToken kind. Entries are merged in list order; later
// entries win on key collision, and keys reserved by the token type (e.g.
// 'token', or 'username'/'password' under basicAuth) are always overridden
// by the operator-managed values.
type SecretDataSource struct {
// +optional
// +kubebuilder:validation:MaxProperties:=16
// Static key/value pairs to merge in verbatim.
Inline map[string]string `json:"inline,omitempty"`

// +optional
// Project keys from a ConfigMap.
ConfigMap *SecretDataSourceRef `json:"configMap,omitempty"`

// +optional
// Project keys from a Secret.
Secret *SecretDataSourceRef `json:"secret,omitempty"`
}

// SecretDataSourceRef references a ConfigMap or Secret, optionally in a
// different namespace, to project keys from.
type SecretDataSourceRef struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:MaxLength:=253
// Name of the referenced ConfigMap or Secret.
Name string `json:"name"`

// +optional
// +kubebuilder:validation:MaxLength:=253
// Namespace of the referenced ConfigMap or Secret. If empty, defaults to
// the target Secret's namespace.
Namespace string `json:"namespace,omitempty"`

// +optional
// +kubebuilder:validation:MaxItems:=64
// +kubebuilder:validation:items:MaxLength:=253
// Restrict projection to these keys. When empty, every key in the
// referenced object is projected.
Keys []string `json:"keys,omitempty"`

// +optional
// When false (the default), an unresolvable reference — a missing or
// unreadable object, or a listed key absent from it — blocks creation of
// the managed Secret; once the Secret exists, its last-known-good
// extraData is retained (while the credential keeps refreshing) and the
// failure is surfaced via the ExtraDataDegraded condition. When true, a
// missing object or key is skipped instead and reported via the same
// condition.
Optional bool `json:"optional,omitempty"`
}

// +kubebuilder:validation:XValidation:rule="(has(self.inline)?1:0)+(has(self.configMap)?1:0)+(has(self.secret)?1:0) == 1",message="exactly one of inline, configMap or secret must be set"

// LocalSecretDataSource is the same-namespace form of SecretDataSource used
// by the namespaced Token kind. A Token may only reference ConfigMaps and
// Secrets in its own namespace.
type LocalSecretDataSource struct {
// +optional
// +kubebuilder:validation:MaxProperties:=16
// Static key/value pairs to merge in verbatim.
Inline map[string]string `json:"inline,omitempty"`

// +optional
// Project keys from a ConfigMap in the same namespace as the Token.
ConfigMap *LocalSecretDataSourceRef `json:"configMap,omitempty"`

// +optional
// Project keys from a Secret in the same namespace as the Token.
Secret *LocalSecretDataSourceRef `json:"secret,omitempty"`
}

// LocalSecretDataSourceRef references a ConfigMap or Secret in the same
// namespace as the referring Token to project keys from.
type LocalSecretDataSourceRef struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:MaxLength:=253
// Name of the referenced ConfigMap or Secret.
Name string `json:"name"`

// +optional
// +kubebuilder:validation:MaxItems:=64
// +kubebuilder:validation:items:MaxLength:=253
// Restrict projection to these keys. When empty, every key in the
// referenced object is projected.
Keys []string `json:"keys,omitempty"`

// +optional
// When false (the default), an unresolvable reference — a missing or
// unreadable object, or a listed key absent from it — blocks creation of
// the managed Secret; once the Secret exists, its last-known-good
// extraData is retained (while the credential keeps refreshing) and the
// failure is surfaced via the ExtraDataDegraded condition. When true, a
// missing object or key is skipped instead and reported via the same
// condition.
Optional bool `json:"optional,omitempty"`
}

// toSecretDataSource converts to the common SecretDataSource shape, placing
// any referenced ConfigMap/Secret in namespace (the referring Token's own).
func (s LocalSecretDataSource) toSecretDataSource(namespace string) SecretDataSource {
out := SecretDataSource{Inline: s.Inline}
if s.ConfigMap != nil {
out.ConfigMap = s.ConfigMap.toSecretDataSourceRef(namespace)
}
if s.Secret != nil {
out.Secret = s.Secret.toSecretDataSourceRef(namespace)
}
return out
}

func (r LocalSecretDataSourceRef) toSecretDataSourceRef(namespace string) *SecretDataSourceRef {
return &SecretDataSourceRef{
Name: r.Name,
Namespace: namespace,
Keys: r.Keys,
Optional: r.Optional,
}
}
Loading