Skip to content

Commit b54eeb7

Browse files
authored
Add kubectl-grafana Plugin (#53)
1 parent c72bd6b commit b54eeb7

File tree

18 files changed

+809
-31
lines changed

18 files changed

+809
-31
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
name: Continuous Delivery
3+
4+
on:
5+
pull_request:
6+
release:
7+
types:
8+
- published
9+
10+
jobs:
11+
kubectl-grafana:
12+
name: kubectl-grafana
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: write
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0
21+
22+
- name: Setup Go
23+
uses: actions/setup-go@v5
24+
with:
25+
go-version-file: go.mod
26+
cache: true
27+
cache-dependency-path: go.sum
28+
29+
- name: Install Dependencies
30+
run: |
31+
go mod download
32+
33+
- name: Build
34+
run: |
35+
go tool mage -v buildAllKubectl
36+
37+
cd ./dist/kubectl-grafana
38+
tar -czf ../../kubectl-grafana.tar.gz *
39+
40+
- name: Upload Artifact (PR)
41+
if: ${{ github.event_name == 'pull_request' }}
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: kubectl-grafana.tar.gz
45+
path: kubectl-grafana.tar.gz
46+
if-no-files-found: error
47+
48+
- name: Upload Artifact (Release)
49+
uses: shogo82148/actions-upload-release-asset@v1
50+
if:
51+
${{ github.event_name == 'release' && github.event.action ==
52+
'published' }}
53+
with:
54+
upload_url: ${{ github.event.release.upload_url }}
55+
asset_path: kubectl-grafana.tar.gz

Magefile.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,129 @@
44
package main
55

66
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"os"
11+
"os/exec"
12+
"os/user"
13+
"path/filepath"
14+
"time"
15+
716
// mage:import
817
build "github.com/grafana/grafana-plugin-sdk-go/build"
18+
"github.com/magefile/mage/mg"
19+
"github.com/magefile/mage/sh"
920
)
1021

1122
// Default configures the default target.
1223
var Default = build.BuildAll
24+
25+
// Config holds the setup variables required for a build
26+
type Config struct {
27+
OS string // GOOS
28+
Arch string // GOOS
29+
}
30+
31+
// BeforeBuildCallback hooks into the build process
32+
type BeforeBuildCallback func(cfg Config) (Config, error)
33+
34+
func newBuildConfig(os string, arch string) Config {
35+
return Config{
36+
OS: os,
37+
Arch: arch,
38+
}
39+
}
40+
41+
func getVersion() string {
42+
packageJson := make(map[string]any)
43+
44+
packageJsonFile, err := os.Open("package.json")
45+
if err != nil {
46+
return ""
47+
}
48+
49+
jsonParser := json.NewDecoder(packageJsonFile)
50+
if err = jsonParser.Decode(&packageJson); err != nil {
51+
return ""
52+
}
53+
54+
return packageJson["version"].(string)
55+
}
56+
57+
func getGitInfo(args ...string) string {
58+
out, err := exec.CommandContext(context.Background(), "git", args...).Output()
59+
if err != nil {
60+
return ""
61+
}
62+
63+
return string(out)
64+
}
65+
66+
func buildBackend(cfg Config) error {
67+
env := map[string]string{
68+
"GOARCH": cfg.Arch,
69+
"GOOS": cfg.OS,
70+
"CGO_ENABLED": "0",
71+
}
72+
73+
version := getVersion()
74+
revision := getGitInfo("rev-parse", "HEAD")
75+
branch := getGitInfo("rev-parse", "--abbrev-ref", "HEAD")
76+
buildDate := time.Now().Format(time.RFC3339)
77+
78+
buildUser := ""
79+
user, _ := user.Current()
80+
if user != nil {
81+
buildUser = user.Username
82+
}
83+
84+
args := []string{
85+
"build",
86+
"-o",
87+
filepath.Join("dist", "kubectl-grafana", fmt.Sprintf("kubectl-grafana-%s-%s", cfg.OS, cfg.Arch)),
88+
"-ldflags",
89+
fmt.Sprintf(`-w -s -extldflags "-static" -X github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/version.Version=%s -X github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/version.Revision=%s -X github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/version.Branch=%s -X github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/version.BuildUser=%s -X github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/version.BuildDate=%s`, version, revision, branch, buildUser, buildDate),
90+
"./cmd/kubectl-grafana",
91+
}
92+
93+
return sh.RunWithV(env, "go", args...)
94+
}
95+
96+
// BuildKubectl is a namespace.
97+
type BuildKubectl mg.Namespace
98+
99+
// Linux builds the kubectl plugin for Linux.
100+
func (BuildKubectl) Linux() error {
101+
return buildBackend(newBuildConfig("linux", "amd64"))
102+
}
103+
104+
// LinuxARM builds the kubectl plugin for Linux on ARM.
105+
func (BuildKubectl) LinuxARM() error {
106+
return buildBackend(newBuildConfig("linux", "arm"))
107+
}
108+
109+
// LinuxARM64 builds the kubectl plugin for Linux on ARM64.
110+
func (BuildKubectl) LinuxARM64() error {
111+
return buildBackend(newBuildConfig("linux", "arm64"))
112+
}
113+
114+
// Windows builds the kubectl plugin for Windows.
115+
func (BuildKubectl) Windows() error {
116+
return buildBackend(newBuildConfig("windows", "amd64"))
117+
}
118+
119+
// Darwin builds the kubectl plugin for OSX on AMD64.
120+
func (BuildKubectl) Darwin() error {
121+
return buildBackend(newBuildConfig("darwin", "amd64"))
122+
}
123+
124+
// DarwinARM64 builds the kubectl plugin for OSX on ARM (M1/M2).
125+
func (BuildKubectl) DarwinARM64() error {
126+
return buildBackend(newBuildConfig("darwin", "arm64"))
127+
}
128+
129+
func BuildAllKubectl() {
130+
b := BuildKubectl{}
131+
mg.Deps(b.Linux, b.Windows, b.Darwin, b.DarwinARM64, b.LinuxARM64, b.LinuxARM)
132+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package kubeconfig
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/url"
7+
"os"
8+
9+
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/utils"
10+
11+
k8sruntime "k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/client-go/tools/clientcmd"
13+
clientcmdlatest "k8s.io/client-go/tools/clientcmd/api/latest"
14+
"sigs.k8s.io/yaml"
15+
)
16+
17+
type Cmd struct {
18+
Url string `default:"" help:"Url of the Grafana instance, e.g. \"https://grafana.ricoberger.de/\"."`
19+
Datasource string `default:"kubernetes" help:"Uid of the Kubernetes datasource."`
20+
Kubeconfig string `default:"$HOME/.kube/config" help:"The file to which the Kubeconfig should be written."`
21+
}
22+
23+
func (r *Cmd) Run() error {
24+
// Validate that all required command-line flags are set. If a flag is
25+
// missing return an error.
26+
if r.Url == "" {
27+
return fmt.Errorf("url is required")
28+
}
29+
if r.Datasource == "" {
30+
return fmt.Errorf("datasource is required")
31+
}
32+
if r.Kubeconfig == "" {
33+
return fmt.Errorf("kubeconfig is required")
34+
}
35+
36+
// Create the url, which can be used to download the Kubeconfig from the
37+
// Grafana instance. It is important to set the "type=exec" and
38+
// "redirect=http://localhost:11716" query parameters, so that Grafana
39+
// redirects the Kubeconfig to our local HTTP server.
40+
kubeconfigUrl := fmt.Sprintf("%sapi/datasources/uid/%s/resources/kubernetes/kubeconfig?type=exec&redirect=%s", r.Url, r.Datasource, url.QueryEscape("http://localhost:11716"))
41+
kubeconfigFile := utils.ExpandEnv(r.Kubeconfig)
42+
doneChannel := make(chan error)
43+
44+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
45+
// When the function is done, send the error (if any) to the
46+
// "doneChannel", so that the main function can exit.
47+
var err error
48+
49+
defer func() {
50+
doneChannel <- err
51+
}()
52+
53+
// Extrace the "kubeconfig" query parameter from the redirect request
54+
// and return an error if it is missing.
55+
kubeconfigParam := r.URL.Query().Get("kubeconfig")
56+
if kubeconfigParam == "" {
57+
err = fmt.Errorf("kubeconfig parameter is missing in redirect")
58+
return
59+
}
60+
61+
// Create a temporary file, which contains the Kubeconfig downloaded
62+
// from Grafana in YAML format.
63+
tmpKubeconfig, err := yaml.JSONToYAML([]byte(kubeconfigParam))
64+
if err != nil {
65+
return
66+
}
67+
68+
f, err := os.CreateTemp("", "tmp-kubeconfig.yaml")
69+
if err != nil {
70+
return
71+
}
72+
defer os.Remove(f.Name())
73+
74+
if _, err := f.Write(tmpKubeconfig); err != nil {
75+
return
76+
}
77+
defer f.Close()
78+
79+
// Load the temporary Kubeconfig file and merge it with the existing
80+
// Kubeconfig file (if it exists). The merged Kubeconfig is then
81+
// written back to the original Kubeconfig file.
82+
loadingRules := clientcmd.ClientConfigLoadingRules{
83+
Precedence: []string{f.Name(), kubeconfigFile},
84+
}
85+
mergedConfig, err := loadingRules.Load()
86+
if err != nil {
87+
return
88+
}
89+
90+
json, err := k8sruntime.Encode(clientcmdlatest.Codec, mergedConfig)
91+
if err != nil {
92+
return
93+
}
94+
output, err := yaml.JSONToYAML(json)
95+
if err != nil {
96+
return
97+
}
98+
99+
if err := os.WriteFile(kubeconfigFile, output, 0600); err != nil {
100+
return
101+
}
102+
103+
fmt.Fprintf(w, "Kubeconfig was saved to %s", kubeconfigFile)
104+
})
105+
106+
// Start the HTTP server, which listens for the redirect from Grafana on
107+
// port 11716.
108+
// #nosec G114
109+
go http.ListenAndServe(":11716", nil)
110+
111+
// Open the url in the users default browser and wait until Grafana
112+
// redirects the user to our local HTTP server.
113+
utils.OpenUrl(kubeconfigUrl)
114+
err := <-doneChannel
115+
return err
116+
}

cmd/kubectl-grafana/main.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package main
2+
3+
import (
4+
"log/slog"
5+
"os"
6+
7+
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/kubeconfig"
8+
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/token"
9+
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/version"
10+
11+
"github.com/alecthomas/kong"
12+
_ "github.com/joho/godotenv/autoload"
13+
)
14+
15+
var cli struct {
16+
Kubeconfig kubeconfig.Cmd `cmd:"kubeconfig" help:"Download a Kubeconfig from a Grafana instance with the \"Grafana Kubernetes Plugin\" installed."`
17+
Token token.Cmd `cmd:"token" help:"Generate \"ExecCredential\" for a Kubeconfig downloaded via the \"kubeconfig\" command."`
18+
Version version.Cmd `cmd:"version" help:"Show version information."`
19+
}
20+
21+
func main() {
22+
ctx := kong.Parse(&cli, kong.Name("kubectl grafana"))
23+
err := ctx.Run()
24+
if err != nil {
25+
slog.Error("Failed to run command", slog.Any("error", err))
26+
os.Exit(1)
27+
}
28+
}

cmd/kubectl-grafana/token/cache.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package token
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"time"
8+
9+
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
10+
)
11+
12+
type Cache struct {
13+
cacheFile string
14+
}
15+
16+
func NewCache(name string) (*Cache, error) {
17+
homeDir, err := os.UserHomeDir()
18+
if err != nil {
19+
return nil, err
20+
}
21+
22+
cacheDir := filepath.Join(homeDir, ".kube", "cache", "kubectl-grafana")
23+
if err := os.MkdirAll(cacheDir, 0700); err != nil {
24+
return nil, err
25+
}
26+
27+
return &Cache{
28+
cacheFile: filepath.Join(cacheDir, name),
29+
}, nil
30+
}
31+
32+
func (c *Cache) Get() (*clientauthenticationv1.ExecCredential, error) {
33+
data, err := os.ReadFile(c.cacheFile)
34+
if err != nil {
35+
if os.IsNotExist(err) {
36+
return nil, nil
37+
}
38+
return nil, err
39+
}
40+
41+
var execCredential clientauthenticationv1.ExecCredential
42+
if err := json.Unmarshal(data, &execCredential); err != nil {
43+
return nil, err
44+
}
45+
46+
if execCredential.Status != nil && execCredential.Status.ExpirationTimestamp != nil {
47+
if time.Now().After(execCredential.Status.ExpirationTimestamp.Time) {
48+
return nil, nil
49+
}
50+
}
51+
52+
return &execCredential, nil
53+
}
54+
55+
func (c *Cache) Set(credentials string) error {
56+
if err := os.WriteFile(c.cacheFile, []byte(credentials), 0600); err != nil {
57+
return err
58+
}
59+
60+
return nil
61+
}

0 commit comments

Comments
 (0)