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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,21 @@ spec:
labels: {} # (optional) map of labels for managed `Secret`
name: bar # (optional) override name for managed `Secret` (default: .metadata.name)
namespace: default # (required, ClusterToken-only) set the target namespace for managed `Secret`
extraData: # (optional) list of additional keys to project into managed `Secret`
- inline: {} # static key/value pairs, merged verbatim
- configMap: # project keys from a ConfigMap
name: foo
namespace: bar # (ClusterToken: defaults to `secret.namespace`; Token: not permitted, always own namespace)
keys: [] # (optional) allowlist of keys to project (default: all keys)
optional: false # (optional) if true, a missing/unreadable source or key is skipped rather than failing
- secret: # project keys from a Secret; same fields as configMap
name: baz
```

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

By default (`optional: false`), a required source that cannot be read, or a listed key missing from it, fails the reconcile: the managed `Secret` is deleted (or never created) and the failure is surfaced on the `Token`/`ClusterToken` status. Set `optional: true` on a ref to skip it instead. Sources are re-read on the existing refresh/retry interval; there is no separate watch on the referenced ConfigMap/Secret.

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

Deployments that need multiple GitHub App configurations — different orgs, per-tenant Apps, or installations with different key providers — can declare `App` resources as the sole credential source, alongside, or instead of the startup `Secret/gtm-config`. `Token.spec.appRef` and `ClusterToken.spec.appRef` then select which App to use; when `appRef` is omitted, the startup config remains the fallback so **existing deployments need no changes**.
Expand Down
26 changes: 25 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,18 @@ func (t *ClusterToken) GetSecretBasicAuth() bool {
return t.Spec.Secret.BasicAuth
}

// GetSecretDataSources returns the extraData sources for this ClusterToken,
// defaulting any configMap/secret ref's empty namespace to the target
// Secret's namespace.
func (t *ClusterToken) GetSecretDataSources() []SecretDataSource {
return normalizeDataSources(t.Spec.Secret.ExtraData, func(existing string) string {
if existing == "" {
return t.Spec.Secret.Namespace
}
return existing
})
}

func (t *ClusterToken) GetInstallationTokenOptions() *github.InstallationTokenOptions {
return &github.InstallationTokenOptions{
Permissions: t.Spec.Permissions.ToInstallationPermissions(),
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)
}
}
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
91 changes: 91 additions & 0 deletions api/v1/secretdatasource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
Copyright 2024 Robin Breathe.

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

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

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

package v1

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

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

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

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

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

// +optional
// +kubebuilder:validation:MaxLength:=253
// Namespace of the referenced ConfigMap or Secret. ClusterToken defaults
// this to the target Secret's namespace when empty. Token may not set
// this field: Tokens may only reference sources in their own namespace.
Namespace string `json:"namespace,omitempty"`

// +optional
// Restrict projection to these keys. When empty, every key in the
// referenced object is projected.
Keys []string `json:"keys,omitempty"`

// +optional
// When false (the default), a missing or unreadable object, or a listed
// key absent from it, fails the reconcile: the managed Secret is deleted
// (or never created) and the (Cluster)Token is marked not-ready. When
// true, the reference is skipped instead.
Optional bool `json:"optional,omitempty"`
}

// normalizeDataSources returns a copy of sources with every configMap/secret
// ref's namespace rewritten by resolveNamespace. Each ref struct is copied so
// the rewrite never mutates the caller's spec; nested slices/maps (Keys,
// Inline) remain shared and are treated as read-only downstream.
func normalizeDataSources(sources []SecretDataSource, resolveNamespace func(existing string) string) []SecretDataSource {
if len(sources) == 0 {
return sources
}
normalized := make([]SecretDataSource, len(sources))
for i, source := range sources {
normalized[i] = source
if source.ConfigMap != nil {
ref := *source.ConfigMap
ref.Namespace = resolveNamespace(ref.Namespace)
normalized[i].ConfigMap = &ref
}
if source.Secret != nil {
ref := *source.Secret
ref.Namespace = resolveNamespace(ref.Namespace)
normalized[i].Secret = &ref
}
}
return normalized
}
24 changes: 23 additions & 1 deletion api/v1/token_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,10 @@ type TokenSpec struct {
RepositoryIDs []int64 `json:"repositoryIDs,omitempty"`
}

// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || !(has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('username' in e.inline || 'password' in e.inline))",message="extraData inline must not contain 'username' or 'password' when basicAuth is true"
// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || (has(self.basicAuth) && self.basicAuth) || self.extraData.all(e, !has(e.inline) || !('token' in e.inline))",message="extraData inline must not contain 'token' when basicAuth is false"
// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || self.extraData.all(e, !has(e.inline) || e.inline.all(k, k.matches('^[-._a-zA-Z0-9]+$')))",message="extraData inline keys must consist of alphanumerics, '-', '_' or '.'"
// +kubebuilder:validation:XValidation:rule="!has(self.extraData) || self.extraData.all(e, (!has(e.configMap) || !has(e.configMap.namespace)) && (!has(e.secret) || !has(e.secret.namespace)))",message="extraData configMap/secret sources may not set namespace on a Token; Tokens may only reference sources in their own namespace"
type TokenSecretSpec struct {
// +optional
// +kubebuilder:validation:MaxLength:=253
Expand All @@ -89,6 +93,14 @@ type TokenSecretSpec struct {
// +optional
// Create a secret with 'username' and 'password' fields for HTTP Basic Auth rather than simply 'token'
BasicAuth bool `json:"basicAuth,omitempty"`

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

// TokenStatus defines the observed state of Token
Expand Down Expand Up @@ -167,6 +179,16 @@ func (t *Token) GetSecretBasicAuth() bool {
return t.Spec.Secret.BasicAuth
}

// GetSecretDataSources returns the extraData sources for this Token, with
// every configMap/secret ref's namespace forced to the Token's own namespace
// (Tokens may only reference sources in their own namespace; admission
// already rejects an explicit namespace on a Token ref).
func (t *Token) GetSecretDataSources() []SecretDataSource {
return normalizeDataSources(t.Spec.Secret.ExtraData, func(string) string {
return t.Namespace
})
}

func (t *Token) GetInstallationTokenOptions() *github.InstallationTokenOptions {
return &github.InstallationTokenOptions{
Permissions: t.Spec.Permissions.ToInstallationPermissions(),
Expand Down
35 changes: 34 additions & 1 deletion api/v1/token_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 @@ -343,3 +343,36 @@ func TestToken_SetStatusTimestamps(t *testing.T) {
t.Error("CreatedAt should be before ExpiresAt")
}
}

func TestToken_GetSecretDataSources(t *testing.T) {
token := &v1.Token{
ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"},
Spec: v1.TokenSpec{
Secret: v1.TokenSecretSpec{
ExtraData: []v1.SecretDataSource{
{Inline: map[string]string{"ca.crt": "PEM"}},
{ConfigMap: &v1.SecretDataSourceRef{Name: "ca-bundle"}},
{Secret: &v1.SecretDataSourceRef{Name: "ca-key"}},
},
},
},
}

got := token.GetSecretDataSources()
if len(got) != 3 {
t.Fatalf("GetSecretDataSources() returned %d entries, want 3", len(got))
}
if got[0].Inline["ca.crt"] != "PEM" {
t.Errorf("inline entry = %v, want ca.crt=PEM", got[0].Inline)
}
if got[1].ConfigMap.Namespace != "team-a" {
t.Errorf("configMap ref namespace = %q, want forced to Token's own namespace %q", got[1].ConfigMap.Namespace, "team-a")
}
if got[2].Secret.Namespace != "team-a" {
t.Errorf("secret ref namespace = %q, want forced to Token's own namespace %q", got[2].Secret.Namespace, "team-a")
}

if got := (&v1.Token{}).GetSecretDataSources(); got != nil {
t.Errorf("GetSecretDataSources() on empty Token = %v, want nil", got)
}
}
Loading