Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9a1d3d9
Add secretreader-plugin
kahirokunn Sep 5, 2025
736e81b
Update controller example with kubeconfig and script
kahirokunn Sep 5, 2025
ee37286
Add GitHub Action to validate README controller example
kahirokunn Sep 5, 2025
1938fb0
Update cmd/secretreader-plugin/README.md
kahirokunn Sep 5, 2025
f7d579f
Update cmd/secretreader-plugin/README.md
kahirokunn Sep 5, 2025
d579b87
secretreader: Fix indentation in core.go file
kahirokunn Sep 6, 2025
14b72bb
secretreader: Remove unnecessary TrimSpace call on namespace
kahirokunn Sep 6, 2025
18410aa
secretreader: make Secret.data 'token' key a const
kahirokunn Sep 6, 2025
2575954
secretreader: replace dynamic client with typed clients (kubernetes, …
kahirokunn Sep 6, 2025
47074d2
secretreader: handle stdin/stdout in library, switch Provider to GetT…
kahirokunn Sep 6, 2025
cd10336
Simplify examples/controller-example/down.sh
kahirokunn Sep 6, 2025
0e63f30
secretreader: Bump k8s.io/client-go clientauthentication to v1
kahirokunn Sep 6, 2025
a166677
secretreader: Use client-go authexec.LoadExecCredentialFromEnv instea…
kahirokunn Sep 6, 2025
6417581
secretreader: Refactor secretreader into single file
kahirokunn Sep 6, 2025
82c0ed2
credentials: propagate exec extension to ExecCredential.Cluster.Config
kahirokunn Sep 7, 2025
aaf033f
secretreader: use per-cluster exec extension for token selection
kahirokunn Sep 7, 2025
9381b97
Unify shell interpreter to /bin/bash
kahirokunn Sep 8, 2025
e38d37c
secretreader: Rename clusterProfile.name to clusterName
kahirokunn Sep 8, 2025
18b707d
secretreader: Remove unused ClusterInventoryAPI client
kahirokunn Sep 9, 2025
787f991
secretreader: Remove redundant namespace determination logic
kahirokunn Sep 9, 2025
4887a7f
secretreader: Use early returns in GetToken for cluster config valida…
kahirokunn Sep 9, 2025
3045ae2
credentials: align exec plugin extension handling with client-go/clie…
kahirokunn Sep 9, 2025
7fabd65
credentials: populate ExecProvider.Config via scheme-based v1→interna…
kahirokunn Sep 22, 2025
2853612
Add clusterExtensionKey with reference link
kahirokunn Oct 9, 2025
aace2af
Update README for ClusterProfile extensions usage
kahirokunn Oct 9, 2025
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
66 changes: 66 additions & 0 deletions .github/workflows/readme-controller-example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Validate controller example in README

on:
pull_request:
push:

permissions:
contents: read

concurrency:
group: readme-controller-example-${{ github.ref }}
cancel-in-progress: true

jobs:
validate-controller-example:
name: Run README controller example
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false

- name: Setup kind (install only)
uses: helm/kind-action@v1
with:
install_only: true

- name: Tool versions
run: |
kind version
kubectl version --client=true --output=yaml
go version

- name: Run setup script (create clusters, secrets, and ClusterProfile)
run: |
bash ./examples/controller-example/setup-kind-demo.sh

- name: Build Secret Reader plugin
run: |
go build -o ./bin/secretreader-plugin ./cmd/secretreader-plugin

- name: Build controller example
run: |
go build -o ./examples/controller-example/controller-example.bin ./examples/controller-example

- name: Execute controller example
env:
KUBECONFIG: ./examples/controller-example/hub.kubeconfig
run: |
./examples/controller-example/controller-example.bin \
-clusterprofile-provider-file ./examples/controller-example/cp-creds.json \
-namespace default \
-clusterprofile spoke-1

- name: Cleanup kind clusters
if: always()
run: |
bash ./examples/controller-example/down.sh

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ go.work
*.swp
*.swo
*~
*.kubeconfig
*.bin
79 changes: 79 additions & 0 deletions cmd/secretreader-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Secret Reader plugin

When executed by a controller, this plugin reads the `token` from the Kubernetes Secret `<CONSUMER_NAMESPACE>/<CLUSTER_PROFILE_NAME>` and writes an ExecCredential (JSON) to stdout.

The specification follows the Secret Reader plugin KEP.

## Required RBAC

```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: secretreader
namespace: <CONSUMER_NAMESPACE>
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: secretreader
namespace: <CONSUMER_NAMESPACE>
subjects:
- kind: ServiceAccount
name: <CONSUMER_SERVICE_ACCOUNT_NAME>
namespace: <CONSUMER_NAMESPACE>
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: secretreader
```

## Build

```bash
go build -o ./bin/secretreader-plugin ./cmd/secretreader-plugin
```

## Usage in a controller

Use the following provider config to exec the secret-reader plugin.

```jsonc
{
"providers": [
{
"name": "secretreader",
"execConfig": {
"apiVersion": "client.authentication.k8s.io/v1",
"command": "./bin/secretreader-plugin",
"provideClusterInfo": true
}
}
]
}
```

### Note: `ClusterProfile.status.credentialProviders[].cluster.extensions`

- Required: set `extensions[].name` to `client.authentication.k8s.io/exec`.
- The library reads only the `extension` field of that entry and passes it through to `ExecCredential.Spec.Cluster.Config`.
- The `secretreader` plugin uses `clusterName` inside that Config.

Example:

```yaml
status:
credentialProviders:
- name: secretreader
cluster:
server: https://<spoke-server>
certificate-authority-data: <BASE64_CA>
extensions:
- name: client.authentication.k8s.io/exec
extension:
clusterName: spoke-1
```
111 changes: 111 additions & 0 deletions cmd/secretreader-plugin/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kubernetes "k8s.io/client-go/kubernetes"
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/cluster-inventory-api/pkg/credentialplugin"
)

type Provider struct {
// KubeClient is the typed client for core Kubernetes resources (e.g. Secret).
KubeClient kubernetes.Interface
// Namespace, if set, overrides namespace inference.
Namespace string
}

// NewDefault constructs a Provider with pre-initialized typed clientsets and an inferred namespace.
func NewDefault() (*Provider, error) {
// Build Kubernetes rest.Config via in-cluster first, then fallback to kubeconfig
cfg, err := rest.InClusterConfig()
if err != nil {
kubeconfig := os.Getenv("KUBECONFIG")
cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
return nil, fmt.Errorf("failed to build kube client config: %w", err)
}
}

kubeClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create kubernetes clientset: %w", err)
}

return &Provider{KubeClient: kubeClient, Namespace: inferNamespace()}, nil
}

// ProviderName is the name of the credential provider.
const ProviderName = "secretreader"

// SecretTokenKey is the `Secret.data` key.
const SecretTokenKey = "token"

func (Provider) Name() string { return ProviderName }

func (p Provider) GetToken(ctx context.Context, info clientauthenticationv1.ExecCredential) (clientauthenticationv1.ExecCredentialStatus, error) {
// Require pre-initialized typed clients
if p.KubeClient == nil {
return clientauthenticationv1.ExecCredentialStatus{}, errors.New("provider clients are not initialized; construct with NewDefault or set clients")
}

// Require clusterName to be present in extensions config
type execClusterConfig struct {
ClusterName string `json:"clusterName"`
}
// Validate presence of cluster config
if info.Spec.Cluster == nil || len(info.Spec.Cluster.Config.Raw) == 0 {
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("missing ExecCredential.Spec.Cluster.Config")
}
var cfg execClusterConfig
if err := json.Unmarshal(info.Spec.Cluster.Config.Raw, &cfg); err != nil {
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("invalid ExecCredential.Spec.Cluster.Config: %w", err)
}
if cfg.ClusterName == "" {
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("missing clusterName in ExecCredential.Spec.Cluster.Config")
}
clusterName := cfg.ClusterName

// Read Secret <namespace>/<clusterName> via typed client and return token
sec, err := p.KubeClient.CoreV1().Secrets(p.Namespace).Get(ctx, clusterName, metav1.GetOptions{})
if err != nil {
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("failed to get secret %s/%s: %w", p.Namespace, clusterName, err)
}
data, ok := sec.Data[SecretTokenKey]
if !ok || len(data) == 0 {
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("secret %s/%s missing %q key", p.Namespace, clusterName, SecretTokenKey)
}

return clientauthenticationv1.ExecCredentialStatus{Token: string(data)}, nil
}

// inferNamespace determines the namespace to read Secrets from, preferring kubeconfig current-context
func inferNamespace() string {
// kubeconfig current-context namespace
rules := clientcmd.NewDefaultClientConfigLoadingRules()
if path := os.Getenv("KUBECONFIG"); strings.TrimSpace(path) != "" {
rules.ExplicitPath = path
}
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{})
if n, _, err := cc.Namespace(); err == nil && strings.TrimSpace(n) != "" {
return n
}
// in-cluster kubeconfig is unavailable; library returns default namespace
return "default"
}

func main() {
p, err := NewDefault()
if err != nil {
panic(err)
}
credentialplugin.Run(*p)
}
65 changes: 65 additions & 0 deletions examples/controller-example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Controller Example

This example automatically sets up the following, stores the spoke cluster token in a Secret using the `secretreader` plugin, and lists spoke Pods from the `ClusterProfile`.

- Create a hub cluster and a spoke cluster with kind
- On the spoke, create a ServiceAccount and ClusterRole/Binding that can list Pods and issue a token
- On the hub, create a Secret with the token in `data.token`
- On the hub, create a `ClusterProfile` with spoke information (set `secretreader` in `status.credentialProviders`)

## Prerequisites

- `kind`, `kubectl`, and `go` are available
- Working directory is the repository root

## 1. Run the setup script

Hub and spoke clusters will be created.

```bash
bash ./examples/controller-example/setup-kind-demo.sh
```

## 2. Build the Secret Reader plugin

```bash
go build -o ./bin/secretreader-plugin ./cmd/secretreader-plugin
```

## 3. Build the controller

```bash
go build -o ./examples/controller-example/controller-example.bin ./examples/controller-example
```

## 4. Run

```bash
KUBECONFIG=./examples/controller-example/hub.kubeconfig ./examples/controller-example/controller-example.bin \
-clusterprofile-provider-file ./examples/controller-example/cp-creds.json \
-namespace default \
-clusterprofile spoke-1
```

## Note: ClusterProfile extensions

- Required: set `status.credentialProviders[].cluster.extensions[].name` to `client.authentication.k8s.io/exec`.
- The library reads only the `extension` field of that entry (arbitrary JSON). Other `extensions` entries are ignored.
- That `extension` is passed through to `ExecCredential.Spec.Cluster.Config`. The `secretreader` plugin uses `clusterName` in that object.

Example (to be merged into `ClusterProfile.status`):

```yaml
status:
credentialProviders:
- name: secretreader
cluster:
server: https://<spoke-server>
certificate-authority-data: <BASE64_CA>
extensions:
- name: client.authentication.k8s.io/exec
extension:
clusterName: spoke-1
```

Note: `client.authentication.k8s.io/exec` is a reserved key in the Kubernetes client authentication API. See the official documentation ("client.authentication.k8s.io").
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"providers": [
{
"name": "gkeFleet",
"name": "secretreader",
"execConfig": {
"apiVersion": "client.authentication.k8s.io/v1beta1",
"apiVersion": "client.authentication.k8s.io/v1",
"args": null,
"command": "gke-gcloud-auth-plugin",
"command": "./bin/secretreader-plugin",
"env": null,
"provideClusterInfo": true
}
Expand Down
4 changes: 4 additions & 0 deletions examples/controller-example/down.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/bash

kind delete cluster --name "hub" || true
kind delete cluster --name "spoke" || true
Loading