Skip to content

Commit c926054

Browse files
authored
Merge pull request #21 from kahirokunn/add-credentials-plugin
Add Secret Reader plugin with controller example
2 parents dd9e2b6 + aace2af commit c926054

File tree

13 files changed

+663
-52
lines changed

13 files changed

+663
-52
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Validate controller example in README
2+
3+
on:
4+
pull_request:
5+
push:
6+
7+
permissions:
8+
contents: read
9+
10+
concurrency:
11+
group: readme-controller-example-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
validate-controller-example:
16+
name: Run README controller example
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 30
19+
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v4
23+
24+
- name: Setup Go
25+
uses: actions/setup-go@v5
26+
with:
27+
go-version-file: go.mod
28+
cache: false
29+
30+
- name: Setup kind (install only)
31+
uses: helm/kind-action@v1
32+
with:
33+
install_only: true
34+
35+
- name: Tool versions
36+
run: |
37+
kind version
38+
kubectl version --client=true --output=yaml
39+
go version
40+
41+
- name: Run setup script (create clusters, secrets, and ClusterProfile)
42+
run: |
43+
bash ./examples/controller-example/setup-kind-demo.sh
44+
45+
- name: Build Secret Reader plugin
46+
run: |
47+
go build -o ./bin/secretreader-plugin ./cmd/secretreader-plugin
48+
49+
- name: Build controller example
50+
run: |
51+
go build -o ./examples/controller-example/controller-example.bin ./examples/controller-example
52+
53+
- name: Execute controller example
54+
env:
55+
KUBECONFIG: ./examples/controller-example/hub.kubeconfig
56+
run: |
57+
./examples/controller-example/controller-example.bin \
58+
-clusterprofile-provider-file ./examples/controller-example/cp-creds.json \
59+
-namespace default \
60+
-clusterprofile spoke-1
61+
62+
- name: Cleanup kind clusters
63+
if: always()
64+
run: |
65+
bash ./examples/controller-example/down.sh
66+

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ go.work
2626
*.swp
2727
*.swo
2828
*~
29+
*.kubeconfig
30+
*.bin

cmd/secretreader-plugin/README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Secret Reader plugin
2+
3+
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.
4+
5+
The specification follows the Secret Reader plugin KEP.
6+
7+
## Required RBAC
8+
9+
```yaml
10+
apiVersion: rbac.authorization.k8s.io/v1
11+
kind: Role
12+
metadata:
13+
name: secretreader
14+
namespace: <CONSUMER_NAMESPACE>
15+
rules:
16+
- apiGroups: [""]
17+
resources: ["secrets"]
18+
verbs: ["get"]
19+
---
20+
apiVersion: rbac.authorization.k8s.io/v1
21+
kind: RoleBinding
22+
metadata:
23+
name: secretreader
24+
namespace: <CONSUMER_NAMESPACE>
25+
subjects:
26+
- kind: ServiceAccount
27+
name: <CONSUMER_SERVICE_ACCOUNT_NAME>
28+
namespace: <CONSUMER_NAMESPACE>
29+
roleRef:
30+
apiGroup: rbac.authorization.k8s.io
31+
kind: Role
32+
name: secretreader
33+
```
34+
35+
## Build
36+
37+
```bash
38+
go build -o ./bin/secretreader-plugin ./cmd/secretreader-plugin
39+
```
40+
41+
## Usage in a controller
42+
43+
Use the following provider config to exec the secret-reader plugin.
44+
45+
```jsonc
46+
{
47+
"providers": [
48+
{
49+
"name": "secretreader",
50+
"execConfig": {
51+
"apiVersion": "client.authentication.k8s.io/v1",
52+
"command": "./bin/secretreader-plugin",
53+
"provideClusterInfo": true
54+
}
55+
}
56+
]
57+
}
58+
```
59+
60+
### Note: `ClusterProfile.status.credentialProviders[].cluster.extensions`
61+
62+
- Required: set `extensions[].name` to `client.authentication.k8s.io/exec`.
63+
- The library reads only the `extension` field of that entry and passes it through to `ExecCredential.Spec.Cluster.Config`.
64+
- The `secretreader` plugin uses `clusterName` inside that Config.
65+
66+
Example:
67+
68+
```yaml
69+
status:
70+
credentialProviders:
71+
- name: secretreader
72+
cluster:
73+
server: https://<spoke-server>
74+
certificate-authority-data: <BASE64_CA>
75+
extensions:
76+
- name: client.authentication.k8s.io/exec
77+
extension:
78+
clusterName: spoke-1
79+
```

cmd/secretreader-plugin/main.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"strings"
10+
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
kubernetes "k8s.io/client-go/kubernetes"
13+
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
14+
"k8s.io/client-go/rest"
15+
"k8s.io/client-go/tools/clientcmd"
16+
"sigs.k8s.io/cluster-inventory-api/pkg/credentialplugin"
17+
)
18+
19+
type Provider struct {
20+
// KubeClient is the typed client for core Kubernetes resources (e.g. Secret).
21+
KubeClient kubernetes.Interface
22+
// Namespace, if set, overrides namespace inference.
23+
Namespace string
24+
}
25+
26+
// NewDefault constructs a Provider with pre-initialized typed clientsets and an inferred namespace.
27+
func NewDefault() (*Provider, error) {
28+
// Build Kubernetes rest.Config via in-cluster first, then fallback to kubeconfig
29+
cfg, err := rest.InClusterConfig()
30+
if err != nil {
31+
kubeconfig := os.Getenv("KUBECONFIG")
32+
cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
33+
if err != nil {
34+
return nil, fmt.Errorf("failed to build kube client config: %w", err)
35+
}
36+
}
37+
38+
kubeClient, err := kubernetes.NewForConfig(cfg)
39+
if err != nil {
40+
return nil, fmt.Errorf("failed to create kubernetes clientset: %w", err)
41+
}
42+
43+
return &Provider{KubeClient: kubeClient, Namespace: inferNamespace()}, nil
44+
}
45+
46+
// ProviderName is the name of the credential provider.
47+
const ProviderName = "secretreader"
48+
49+
// SecretTokenKey is the `Secret.data` key.
50+
const SecretTokenKey = "token"
51+
52+
func (Provider) Name() string { return ProviderName }
53+
54+
func (p Provider) GetToken(ctx context.Context, info clientauthenticationv1.ExecCredential) (clientauthenticationv1.ExecCredentialStatus, error) {
55+
// Require pre-initialized typed clients
56+
if p.KubeClient == nil {
57+
return clientauthenticationv1.ExecCredentialStatus{}, errors.New("provider clients are not initialized; construct with NewDefault or set clients")
58+
}
59+
60+
// Require clusterName to be present in extensions config
61+
type execClusterConfig struct {
62+
ClusterName string `json:"clusterName"`
63+
}
64+
// Validate presence of cluster config
65+
if info.Spec.Cluster == nil || len(info.Spec.Cluster.Config.Raw) == 0 {
66+
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("missing ExecCredential.Spec.Cluster.Config")
67+
}
68+
var cfg execClusterConfig
69+
if err := json.Unmarshal(info.Spec.Cluster.Config.Raw, &cfg); err != nil {
70+
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("invalid ExecCredential.Spec.Cluster.Config: %w", err)
71+
}
72+
if cfg.ClusterName == "" {
73+
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("missing clusterName in ExecCredential.Spec.Cluster.Config")
74+
}
75+
clusterName := cfg.ClusterName
76+
77+
// Read Secret <namespace>/<clusterName> via typed client and return token
78+
sec, err := p.KubeClient.CoreV1().Secrets(p.Namespace).Get(ctx, clusterName, metav1.GetOptions{})
79+
if err != nil {
80+
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("failed to get secret %s/%s: %w", p.Namespace, clusterName, err)
81+
}
82+
data, ok := sec.Data[SecretTokenKey]
83+
if !ok || len(data) == 0 {
84+
return clientauthenticationv1.ExecCredentialStatus{}, fmt.Errorf("secret %s/%s missing %q key", p.Namespace, clusterName, SecretTokenKey)
85+
}
86+
87+
return clientauthenticationv1.ExecCredentialStatus{Token: string(data)}, nil
88+
}
89+
90+
// inferNamespace determines the namespace to read Secrets from, preferring kubeconfig current-context
91+
func inferNamespace() string {
92+
// kubeconfig current-context namespace
93+
rules := clientcmd.NewDefaultClientConfigLoadingRules()
94+
if path := os.Getenv("KUBECONFIG"); strings.TrimSpace(path) != "" {
95+
rules.ExplicitPath = path
96+
}
97+
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{})
98+
if n, _, err := cc.Namespace(); err == nil && strings.TrimSpace(n) != "" {
99+
return n
100+
}
101+
// in-cluster kubeconfig is unavailable; library returns default namespace
102+
return "default"
103+
}
104+
105+
func main() {
106+
p, err := NewDefault()
107+
if err != nil {
108+
panic(err)
109+
}
110+
credentialplugin.Run(*p)
111+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Controller Example
2+
3+
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`.
4+
5+
- Create a hub cluster and a spoke cluster with kind
6+
- On the spoke, create a ServiceAccount and ClusterRole/Binding that can list Pods and issue a token
7+
- On the hub, create a Secret with the token in `data.token`
8+
- On the hub, create a `ClusterProfile` with spoke information (set `secretreader` in `status.credentialProviders`)
9+
10+
## Prerequisites
11+
12+
- `kind`, `kubectl`, and `go` are available
13+
- Working directory is the repository root
14+
15+
## 1. Run the setup script
16+
17+
Hub and spoke clusters will be created.
18+
19+
```bash
20+
bash ./examples/controller-example/setup-kind-demo.sh
21+
```
22+
23+
## 2. Build the Secret Reader plugin
24+
25+
```bash
26+
go build -o ./bin/secretreader-plugin ./cmd/secretreader-plugin
27+
```
28+
29+
## 3. Build the controller
30+
31+
```bash
32+
go build -o ./examples/controller-example/controller-example.bin ./examples/controller-example
33+
```
34+
35+
## 4. Run
36+
37+
```bash
38+
KUBECONFIG=./examples/controller-example/hub.kubeconfig ./examples/controller-example/controller-example.bin \
39+
-clusterprofile-provider-file ./examples/controller-example/cp-creds.json \
40+
-namespace default \
41+
-clusterprofile spoke-1
42+
```
43+
44+
## Note: ClusterProfile extensions
45+
46+
- Required: set `status.credentialProviders[].cluster.extensions[].name` to `client.authentication.k8s.io/exec`.
47+
- The library reads only the `extension` field of that entry (arbitrary JSON). Other `extensions` entries are ignored.
48+
- That `extension` is passed through to `ExecCredential.Spec.Cluster.Config`. The `secretreader` plugin uses `clusterName` in that object.
49+
50+
Example (to be merged into `ClusterProfile.status`):
51+
52+
```yaml
53+
status:
54+
credentialProviders:
55+
- name: secretreader
56+
cluster:
57+
server: https://<spoke-server>
58+
certificate-authority-data: <BASE64_CA>
59+
extensions:
60+
- name: client.authentication.k8s.io/exec
61+
extension:
62+
clusterName: spoke-1
63+
```
64+
65+
Note: `client.authentication.k8s.io/exec` is a reserved key in the Kubernetes client authentication API. See the official documentation ("client.authentication.k8s.io").

pkg/cp-creds.json renamed to examples/controller-example/cp-creds.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"providers": [
33
{
4-
"name": "gkeFleet",
4+
"name": "secretreader",
55
"execConfig": {
6-
"apiVersion": "client.authentication.k8s.io/v1beta1",
6+
"apiVersion": "client.authentication.k8s.io/v1",
77
"args": null,
8-
"command": "gke-gcloud-auth-plugin",
8+
"command": "./bin/secretreader-plugin",
99
"env": null,
1010
"provideClusterInfo": true
1111
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/bash
2+
3+
kind delete cluster --name "hub" || true
4+
kind delete cluster --name "spoke" || true

0 commit comments

Comments
 (0)