Skip to content

Commit 7887ef9

Browse files
committed
Add file provider
1 parent 79d0407 commit 7887ef9

File tree

11 files changed

+1369
-0
lines changed

11 files changed

+1369
-0
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ jobs:
2727
- providers/cluster-api
2828
- examples/cluster-inventory-api
2929
- providers/cluster-inventory-api
30+
- examples/file
31+
- providers/file
3032
name: golangci-lint [${{ matrix.working-directory }}]
3133
steps:
3234
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # tag=v5.0.0

examples/file/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# file
2+
3+
The file provider reads kubeconfig files from the local filesystem.
4+
5+
It provides clusters named after the context name in the kubeconfig file.
6+
7+
This example reads kubeconfig files at the specified paths and lists the
8+
collected clusters.
9+
10+
## Example
11+
12+
With a single kind cluster defined in `~/.kube/config`:
13+
14+
```bash
15+
$ go run . -paths ~/.kube/config
16+
Clusters:
17+
- kind-kind
18+
```

examples/file/go.mod

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
module sigs.k8s.io/multicluster-runtime/examples/file
2+
3+
go 1.24.0
4+
5+
replace (
6+
sigs.k8s.io/multicluster-runtime => ../..
7+
sigs.k8s.io/multicluster-runtime/providers/file => ../../providers/file
8+
)
9+
10+
require (
11+
sigs.k8s.io/multicluster-runtime v0.0.0-00010101000000-000000000000 // indirect
12+
sigs.k8s.io/multicluster-runtime/providers/file v0.0.0-00010101000000-000000000000
13+
)
14+
15+
require (
16+
github.com/beorn7/perks v1.0.1 // indirect
17+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
18+
github.com/davecgh/go-spew v1.1.1 // indirect
19+
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
20+
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
21+
github.com/fsnotify/fsnotify v1.7.0 // indirect
22+
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
23+
github.com/go-logr/logr v1.4.3 // indirect
24+
github.com/go-openapi/jsonpointer v0.21.0 // indirect
25+
github.com/go-openapi/jsonreference v0.20.2 // indirect
26+
github.com/go-openapi/swag v0.23.0 // indirect
27+
github.com/gogo/protobuf v1.3.2 // indirect
28+
github.com/google/gnostic-models v0.6.9 // indirect
29+
github.com/google/go-cmp v0.7.0 // indirect
30+
github.com/google/uuid v1.6.0 // indirect
31+
github.com/josharian/intern v1.0.0 // indirect
32+
github.com/json-iterator/go v1.1.12 // indirect
33+
github.com/mailru/easyjson v0.7.7 // indirect
34+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
35+
github.com/modern-go/reflect2 v1.0.2 // indirect
36+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
37+
github.com/pkg/errors v0.9.1 // indirect
38+
github.com/prometheus/client_golang v1.22.0 // indirect
39+
github.com/prometheus/client_model v0.6.1 // indirect
40+
github.com/prometheus/common v0.62.0 // indirect
41+
github.com/prometheus/procfs v0.15.1 // indirect
42+
github.com/spf13/pflag v1.0.5 // indirect
43+
github.com/x448/float16 v0.8.4 // indirect
44+
golang.org/x/net v0.41.0 // indirect
45+
golang.org/x/oauth2 v0.27.0 // indirect
46+
golang.org/x/sys v0.33.0 // indirect
47+
golang.org/x/term v0.32.0 // indirect
48+
golang.org/x/text v0.26.0 // indirect
49+
golang.org/x/time v0.9.0 // indirect
50+
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
51+
google.golang.org/protobuf v1.36.6 // indirect
52+
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
53+
gopkg.in/fsnotify.v1 v1.4.7 // indirect
54+
gopkg.in/inf.v0 v0.9.1 // indirect
55+
gopkg.in/yaml.v3 v3.0.1 // indirect
56+
k8s.io/api v0.33.3 // indirect
57+
k8s.io/apimachinery v0.33.3 // indirect
58+
k8s.io/client-go v0.33.3 // indirect
59+
k8s.io/klog/v2 v2.130.1 // indirect
60+
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
61+
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
62+
sigs.k8s.io/controller-runtime v0.21.0 // indirect
63+
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
64+
sigs.k8s.io/randfill v1.0.0 // indirect
65+
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
66+
sigs.k8s.io/yaml v1.4.0 // indirect
67+
)

examples/file/go.sum

Lines changed: 201 additions & 0 deletions
Large diffs are not rendered by default.

examples/file/main.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"flag"
22+
"fmt"
23+
"os/signal"
24+
"strings"
25+
"syscall"
26+
"time"
27+
28+
"sigs.k8s.io/multicluster-runtime/providers/file"
29+
)
30+
31+
var (
32+
fKubeconfigFiles = flag.String("kubeconfigs", "", "Comma-separated list of kubeconfig file paths to process")
33+
fKubeconfigDirs = flag.String("kubeconfig-dirs", "", "Comma-separated list of directories to search for kubeconfig files")
34+
fGlobs = flag.String("globs", "", "Comma-separated list of glob patterns to match files")
35+
fContinue = flag.Bool("continue", false, "Continue processing and listing files until cancelled")
36+
)
37+
38+
func printClusters(clusters []string) {
39+
if len(clusters) == 0 {
40+
fmt.Println("No clusters found.")
41+
return
42+
}
43+
fmt.Println("Clusters:")
44+
for _, cluster := range clusters {
45+
fmt.Printf("- %s\n", cluster)
46+
}
47+
}
48+
49+
func main() {
50+
flag.Parse()
51+
52+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
53+
defer cancel()
54+
55+
kubeconfigFiles := strings.Split(*fKubeconfigFiles, ",")
56+
if len(kubeconfigFiles) == 1 && kubeconfigFiles[0] == "" {
57+
kubeconfigFiles = []string{}
58+
}
59+
60+
kubeconfigDirs := strings.Split(*fKubeconfigDirs, ",")
61+
if len(kubeconfigDirs) == 1 && kubeconfigDirs[0] == "" {
62+
kubeconfigDirs = []string{}
63+
}
64+
65+
provider, err := file.New(file.Options{
66+
KubeconfigFiles: kubeconfigFiles,
67+
KubeconfigDirs: kubeconfigDirs,
68+
KubeconfigGlobs: strings.Split(*fGlobs, ","),
69+
})
70+
if err != nil {
71+
fmt.Printf("Error creating provider: %v\n", err)
72+
return
73+
}
74+
75+
if err := provider.RunOnce(ctx, nil); err != nil {
76+
fmt.Printf("Error running provider once: %v\n", err)
77+
return
78+
}
79+
80+
printClusters(provider.ClusterNames())
81+
82+
if *fContinue {
83+
go func() {
84+
if err := provider.Run(ctx, nil); err != nil {
85+
fmt.Printf("Error running provider continuously: %v\n", err)
86+
return
87+
}
88+
}()
89+
90+
for range time.Tick(1 * time.Second) {
91+
if ctx.Err() != nil {
92+
fmt.Println("Context cancelled, stopping...")
93+
break
94+
}
95+
96+
printClusters(provider.ClusterNames())
97+
}
98+
}
99+
}

providers/file/clusters.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package file
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
24+
"k8s.io/client-go/rest"
25+
"k8s.io/client-go/tools/clientcmd"
26+
27+
"sigs.k8s.io/controller-runtime/pkg/cluster"
28+
)
29+
30+
type clusters map[string]cluster.Cluster
31+
32+
func (p *Provider) loadClusters() (clusters, error) {
33+
filepaths, err := p.collectPaths()
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
kubeCtxs := map[string]*rest.Config{}
39+
for _, filepath := range filepaths {
40+
fileKubeCtxs, err := readFile(filepath)
41+
if err != nil {
42+
p.log.Error(err, "failed to read kubeconfig file", "file", filepath)
43+
continue
44+
}
45+
for name, kubeCtx := range fileKubeCtxs {
46+
clusterName := filepath + p.opts.Separator + name
47+
if _, exists := kubeCtxs[clusterName]; exists {
48+
p.log.Error(nil, "duplicate context name found", "file", filepath, "context", name, "clusterName", clusterName)
49+
continue
50+
}
51+
kubeCtxs[clusterName] = kubeCtx
52+
}
53+
}
54+
55+
return p.fromContexts(kubeCtxs), nil
56+
}
57+
58+
func (p *Provider) collectPaths() ([]string, error) {
59+
filepaths := []string{}
60+
61+
for _, file := range p.opts.KubeconfigFiles {
62+
if _, err := os.Stat(file); err != nil {
63+
if os.IsNotExist(err) {
64+
p.log.Info("kubeconfig file does not exist, skipping", "file", file)
65+
continue
66+
}
67+
return nil, fmt.Errorf("failed to stat kubeconfig file %q: %w", file, err)
68+
}
69+
filepaths = append(filepaths, file)
70+
}
71+
72+
for _, dir := range p.opts.KubeconfigDirs {
73+
if _, err := os.Stat(dir); err != nil {
74+
if os.IsNotExist(err) {
75+
p.log.Info("directory does not exist, skipping", "dir", dir)
76+
continue
77+
}
78+
return nil, fmt.Errorf("failed to stat directory %q: %w", dir, err)
79+
}
80+
81+
filepaths = append(filepaths, p.matchKubeconfigGlobs(dir)...)
82+
}
83+
84+
return filepaths, nil
85+
}
86+
87+
func (p *Provider) matchKubeconfigGlobs(dirpath string) []string {
88+
matches := []string{}
89+
90+
for _, glob := range p.opts.KubeconfigGlobs {
91+
globMatches, err := filepath.Glob(filepath.Join(dirpath, glob))
92+
if err != nil {
93+
p.log.Error(err, "failed to glob files", "dirpath", dirpath, "glob", glob)
94+
continue
95+
}
96+
matches = append(matches, globMatches...)
97+
}
98+
99+
return matches
100+
}
101+
102+
func readFile(filepath string) (map[string]*rest.Config, error) {
103+
config, err := clientcmd.LoadFromFile(filepath)
104+
if err != nil {
105+
return nil, fmt.Errorf("failed to load kubeconfig from file %q: %w", filepath, err)
106+
}
107+
108+
ret := make(map[string]*rest.Config, len(config.Contexts))
109+
for name := range config.Contexts {
110+
restConfig, err := clientcmd.NewNonInteractiveClientConfig(*config, name, &clientcmd.ConfigOverrides{}, nil).ClientConfig()
111+
if err != nil {
112+
return nil, fmt.Errorf("failed to create rest config for context %q: %w", name, err)
113+
}
114+
ret[name] = restConfig
115+
}
116+
117+
return ret, nil
118+
}
119+
120+
func (p *Provider) fromContexts(kubeCtxs map[string]*rest.Config) clusters {
121+
c := make(map[string]cluster.Cluster, len(kubeCtxs))
122+
123+
for name, kubeCtx := range kubeCtxs {
124+
cl, err := cluster.New(kubeCtx)
125+
if err != nil {
126+
p.log.Error(err, "failed to create cluster", "context", name)
127+
continue
128+
}
129+
if _, ok := c[name]; ok {
130+
p.log.Error(nil, "duplicate context name found", "context", name)
131+
continue
132+
}
133+
c[name] = cl
134+
}
135+
136+
return c
137+
}

providers/file/file_suite_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package file
18+
19+
import (
20+
"testing"
21+
22+
logf "sigs.k8s.io/controller-runtime/pkg/log"
23+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
24+
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
25+
26+
. "github.com/onsi/ginkgo/v2"
27+
. "github.com/onsi/gomega"
28+
)
29+
30+
func TestBuilder(t *testing.T) {
31+
RegisterFailHandler(Fail)
32+
RunSpecs(t, "File Provider Suite")
33+
}
34+
35+
var _ = BeforeSuite(func() {
36+
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
37+
38+
// The file provider is designed to work with local files and
39+
// directories, so we don't need envtest.
40+
41+
// Prevent the metrics listener being created
42+
metricsserver.DefaultBindAddress = "0"
43+
})
44+
45+
var _ = AfterSuite(func() {
46+
metricsserver.DefaultBindAddress = ":8080"
47+
})

0 commit comments

Comments
 (0)