Skip to content

Commit 3dac902

Browse files
authored
Use Frontend for Redirect in kubectl-grafana (#58)
Instead of directly calling the API of the Kubernetes datasource to get a Kubeconfig / credentials, we are now opening the Grafana frontend, which calls the API to get the data and then redirects to the `kubectl-grafana` command. This is done, because when we directly call the API and the user is not authenticated, Grafana will return an error. When we open the Grafana frontend, the user is redirect to the authentication page first. This commit also renames the `token` command to `credentials`.
1 parent 55c2c91 commit 3dac902

File tree

13 files changed

+168
-57
lines changed

13 files changed

+168
-57
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ contains a Kubeconfig it will be merged, with the downloaded Kubeconfig having a
169169
higher priority.
170170

171171
```bash
172-
kubectl grafana kubeconfig --url <GRAFANA-INSTANCE-URL> --datasource <DATASOURCE-UID> --kubeconfig <PATH-TO-KUBECONFIG-FILE>
172+
kubectl grafana kubeconfig --grafana-url <GRAFANA-INSTANCE-URL> --grafana-datasource <DATASOURCE-UID> --kubeconfig <PATH-TO-KUBECONFIG-FILE>
173173
```
174174

175175
### Integrations

cmd/kubectl-grafana/token/token.go renamed to cmd/kubectl-grafana/credentials/credentials.go

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package token
1+
package credentials
22

33
import (
44
"encoding/json"
@@ -12,27 +12,23 @@ import (
1212
)
1313

1414
type Cmd struct {
15-
Name string `default:"grafana" help:"Name of the Kubernetes cluster, which used for the cache file."`
16-
Url string `default:"" help:"Url of the Grafana instance, e.g. \"https://grafana.ricoberger.de/\"."`
17-
Datasource string `default:"kubernetes" help:"Uid of the Kubernetes datasource."`
15+
GrafanaUrl string `default:"" help:"Url of the Grafana instance, e.g. \"https://play.grafana.org/\"."`
16+
GrafanaDatasource string `default:"kubernetes" help:"Uid of the Kubernetes datasource."`
1817
}
1918

2019
func (r *Cmd) Run() error {
2120
// Validate that all required command-line flags are set. If a flag is
2221
// missing return an error.
23-
if r.Url == "" {
24-
return fmt.Errorf("url is required")
22+
if r.GrafanaUrl == "" {
23+
return fmt.Errorf("grafana url is required")
2524
}
26-
if r.Datasource == "" {
27-
return fmt.Errorf("datasource is required")
28-
}
29-
if r.Name == "" {
30-
return fmt.Errorf("name is required")
25+
if r.GrafanaDatasource == "" {
26+
return fmt.Errorf("grafana datasource is required")
3127
}
3228

3329
// Initialize the cache and check if the cache contains valid credentials.
3430
// If valid credentials are found, print them to stdout and return.
35-
cache, err := NewCache(r.Name)
31+
cache, err := utils.NewCache(r.GrafanaUrl, r.GrafanaDatasource)
3632
if err != nil {
3733
return err
3834
}
@@ -54,7 +50,7 @@ func (r *Cmd) Run() error {
5450
// Grafana instance. It is important to set the
5551
// "redirect=http://localhost:11716" query parameter, so that Grafana
5652
// redirects the credentials to our local HTTP server.
57-
credentialsUrl := fmt.Sprintf("%sapi/datasources/uid/%s/resources/kubernetes/kubeconfig/credentials?redirect=%s", r.Url, r.Datasource, url.QueryEscape("http://localhost:11716"))
53+
credentialsUrl := fmt.Sprintf("%sa/ricoberger-kubernetes-app/kubectl?type=credentials&datasource=%s&redirect=%s", r.GrafanaUrl, r.GrafanaDatasource, url.QueryEscape("http://localhost:11716"))
5854
credentials := ""
5955
doneChannel := make(chan error)
6056

cmd/kubectl-grafana/kubeconfig/kubeconfig.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,29 @@ import (
1616
)
1717

1818
type Cmd struct {
19-
Url string `default:"" help:"Url of the Grafana instance, e.g. \"https://grafana.ricoberger.de/\"."`
20-
Datasource string `default:"kubernetes" help:"Uid of the Kubernetes datasource."`
21-
Kubeconfig string `default:"$HOME/.kube/config" help:"The file to which the Kubeconfig should be written."`
19+
GrafanaUrl string `default:"" help:"Url of the Grafana instance, e.g. \"https://play.grafana.org/\"."`
20+
GrafanaDatasource string `default:"kubernetes" help:"Uid of the Kubernetes datasource."`
21+
Kubeconfig string `default:"$HOME/.kube/config" help:"Path to the Kubeconfig file."`
2222
}
2323

2424
func (r *Cmd) Run() error {
2525
// Validate that all required command-line flags are set. If a flag is
2626
// missing return an error.
27-
if r.Url == "" {
28-
return fmt.Errorf("url is required")
27+
if r.GrafanaUrl == "" {
28+
return fmt.Errorf("grafana url is required")
2929
}
30-
if r.Datasource == "" {
31-
return fmt.Errorf("datasource is required")
30+
if r.GrafanaDatasource == "" {
31+
return fmt.Errorf("grafana datasource is required")
3232
}
3333
if r.Kubeconfig == "" {
3434
return fmt.Errorf("kubeconfig is required")
3535
}
3636

3737
// Create the url, which can be used to download the Kubeconfig from the
38-
// Grafana instance. It is important to set the "type=exec" and
38+
// Grafana instance. It is important to set the
3939
// "redirect=http://localhost:11716" query parameters, so that Grafana
4040
// redirects the Kubeconfig to our local HTTP server.
41-
kubeconfigUrl := fmt.Sprintf("%sapi/datasources/uid/%s/resources/kubernetes/kubeconfig?type=exec&redirect=%s", r.Url, r.Datasource, url.QueryEscape("http://localhost:11716"))
41+
kubeconfigUrl := fmt.Sprintf("%sa/ricoberger-kubernetes-app/kubectl?type=kubeconfig&datasource=%s&redirect=%s", r.GrafanaUrl, r.GrafanaDatasource, url.QueryEscape("http://localhost:11716"))
4242
kubeconfigFile := utils.ExpandEnv(r.Kubeconfig)
4343
doneChannel := make(chan error)
4444

cmd/kubectl-grafana/main.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ import (
44
"log/slog"
55
"os"
66

7+
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/credentials"
78
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/kubeconfig"
8-
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/token"
99
"github.com/ricoberger/grafana-kubernetes-plugin/cmd/kubectl-grafana/version"
1010

1111
"github.com/alecthomas/kong"
1212
_ "github.com/joho/godotenv/autoload"
1313
)
1414

1515
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."`
16+
Kubeconfig kubeconfig.Cmd `cmd:"kubeconfig" help:"Download a Kubeconfig from a Grafana instance with the \"Grafana Kubernetes Plugin\" installed."`
17+
Credentials credentials.Cmd `cmd:"credentials" help:"Generate \"ExecCredential\" for a Kubeconfig downloaded via the \"kubeconfig\" command."`
18+
Version version.Cmd `cmd:"version" help:"Show version information."`
1919
}
2020

2121
func main() {
Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
package token
1+
package utils
22

33
import (
44
"encoding/json"
5+
"net/url"
56
"os"
67
"path/filepath"
8+
"strings"
79
"time"
810

911
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
@@ -13,7 +15,7 @@ type Cache struct {
1315
cacheFile string
1416
}
1517

16-
func NewCache(name string) (*Cache, error) {
18+
func NewCache(grafanaUrl, grafanaDatasource string) (*Cache, error) {
1719
homeDir, err := os.UserHomeDir()
1820
if err != nil {
1921
return nil, err
@@ -24,11 +26,25 @@ func NewCache(name string) (*Cache, error) {
2426
return nil, err
2527
}
2628

29+
fileName, err := getFileName(grafanaUrl, grafanaDatasource)
30+
if err != nil {
31+
return nil, err
32+
}
33+
2734
return &Cache{
28-
cacheFile: filepath.Join(cacheDir, name),
35+
cacheFile: filepath.Join(cacheDir, fileName),
2936
}, nil
3037
}
3138

39+
func getFileName(grafanaUrl, grafanaDatasource string) (string, error) {
40+
parsedUrl, err := url.Parse(grafanaUrl)
41+
if err != nil {
42+
return "", err
43+
}
44+
45+
return strings.ReplaceAll(parsedUrl.Host, ":", "_") + "_" + grafanaDatasource + ".json", nil
46+
}
47+
3248
func (c *Cache) Get() (*clientauthenticationv1.ExecCredential, error) {
3349
data, err := os.ReadFile(c.cacheFile)
3450
if err != nil {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/grafana/grafana-plugin-sdk-go v0.281.0
1212
github.com/hashicorp/golang-lru/v2 v2.0.7
1313
github.com/joho/godotenv v1.5.1
14+
github.com/magefile/mage v1.15.0
1415
github.com/stretchr/testify v1.11.1
1516
github.com/testcontainers/testcontainers-go v0.39.0
1617
github.com/testcontainers/testcontainers-go/modules/k3s v0.39.0
@@ -122,7 +123,6 @@ require (
122123
github.com/lib/pq v1.10.9 // indirect
123124
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
124125
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
125-
github.com/magefile/mage v1.15.0 // indirect
126126
github.com/magiconair/properties v1.8.10 // indirect
127127
github.com/mailru/easyjson v0.9.0 // indirect
128128
github.com/mattetti/filebuffer v1.0.1 // indirect

pkg/plugin/kubernetes.go

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8-
"net/url"
98
"slices"
109
"time"
1110

@@ -269,6 +268,13 @@ func (d *Datasource) handleKubernetesKubeconfig(w http.ResponseWriter, r *http.R
269268
return
270269
}
271270

271+
if r.URL.Query().Get("redirect") != "" {
272+
if !slices.Contains(d.generateKubeconfigRedirectUrls, r.URL.Query().Get("redirect")) {
273+
http.Error(w, "invalid redirect url", http.StatusForbidden)
274+
return
275+
}
276+
}
277+
272278
// Get the user which makes the request. The "GetImpersonateUser" function
273279
// only returns a user when the impersonate user feature is enabled. So that
274280
// we have to set a user name when an empty string is returned.
@@ -295,10 +301,9 @@ func (d *Datasource) handleKubernetesKubeconfig(w http.ResponseWriter, r *http.R
295301
Command: "kubectl",
296302
Args: []string{
297303
"grafana",
298-
"token",
299-
fmt.Sprintf("--name=%s", d.generateKubeconfigName),
300-
fmt.Sprintf("--url=%s", d.grafanaClient.GetUrl().String()),
301-
fmt.Sprintf("--datasource=%s", backend.PluginConfigFromContext(ctx).DataSourceInstanceSettings.UID),
304+
"credentials",
305+
fmt.Sprintf("--grafana-url=%s", d.grafanaClient.GetUrl().String()),
306+
fmt.Sprintf("--grafana-datasource=%s", backend.PluginConfigFromContext(ctx).DataSourceInstanceSettings.UID),
302307
},
303308
InteractiveMode: clientcmdapiv1.NeverExecInteractiveMode,
304309
}
@@ -356,16 +361,6 @@ func (d *Datasource) handleKubernetesKubeconfig(w http.ResponseWriter, r *http.R
356361
return
357362
}
358363

359-
if r.URL.Query().Get("redirect") != "" {
360-
if !slices.Contains(d.generateKubeconfigRedirectUrls, r.URL.Query().Get("redirect")) {
361-
http.Error(w, "invalid redirect url", http.StatusForbidden)
362-
return
363-
}
364-
365-
http.Redirect(w, r, fmt.Sprintf("%s?kubeconfig=%s", r.URL.Query().Get("redirect"), url.QueryEscape(string(data))), http.StatusTemporaryRedirect)
366-
return
367-
}
368-
369364
w.Header().Set("Content-Type", "application/json")
370365
w.Write(data)
371366
}
@@ -379,6 +374,13 @@ func (d *Datasource) handleKubernetesKubeconfigCredentials(w http.ResponseWriter
379374
return
380375
}
381376

377+
if r.URL.Query().Get("redirect") != "" {
378+
if !slices.Contains(d.generateKubeconfigRedirectUrls, r.URL.Query().Get("redirect")) {
379+
http.Error(w, "invalid redirect url", http.StatusForbidden)
380+
return
381+
}
382+
}
383+
382384
// Get the user which makes the request. The "GetImpersonateUser" function
383385
// only returns a user when the impersonate user feature is enabled. So that
384386
// we have to set a user name when an empty string is returned.
@@ -427,16 +429,6 @@ func (d *Datasource) handleKubernetesKubeconfigCredentials(w http.ResponseWriter
427429
return
428430
}
429431

430-
if r.URL.Query().Get("redirect") != "" {
431-
if !slices.Contains(d.generateKubeconfigRedirectUrls, r.URL.Query().Get("redirect")) {
432-
http.Error(w, "invalid redirect url", http.StatusForbidden)
433-
return
434-
}
435-
436-
http.Redirect(w, r, fmt.Sprintf("%s?credentials=%s", r.URL.Query().Get("redirect"), url.QueryEscape(string(data))), http.StatusTemporaryRedirect)
437-
return
438-
}
439-
440432
w.Header().Set("Content-Type", "application/json")
441433
w.Write(data)
442434
}

src/app/App.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { initPluginTranslations } from '@grafana/i18n';
66
import { PluginPropsContext } from '../utils/utils.plugin';
77
import { HomePage } from '../pages/home/HomePage';
88
import { KubeconfigPage } from '../pages/kubeconfig/KubeconfigPage';
9+
import { KubectlPage } from '../pages/kubectl/KubectlPage';
910
import { ResourcesPage } from '../pages/resources/ResourcesPage';
1011
import { HelmPage } from '../pages/helm/HelmPage';
1112
import { FluxPage } from '../pages/flux/FluxPage';
@@ -20,7 +21,14 @@ initPluginTranslations('ricoberger-kubernetes-app');
2021

2122
function getSceneApp() {
2223
return new SceneApp({
23-
pages: [HomePage, ResourcesPage, HelmPage, FluxPage, KubeconfigPage],
24+
pages: [
25+
HomePage,
26+
ResourcesPage,
27+
HelmPage,
28+
FluxPage,
29+
KubeconfigPage,
30+
KubectlPage,
31+
],
2432
urlSyncOptions: {
2533
updateUrlOnInit: true,
2634
createBrowserHistorySteps: true,

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export enum ROUTES {
88
Helm = 'helm',
99
Flux = 'flux',
1010
Kubeconfig = 'kubeconfig',
11+
Kubectl = 'kubectl',
1112
}

src/pages/kubectl/Kubectl.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from 'react';
2+
import {
3+
SceneComponentProps,
4+
SceneObjectBase,
5+
SceneObjectState,
6+
} from '@grafana/scenes';
7+
import { Alert, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
8+
import { css } from '@emotion/css';
9+
import { useAsync } from 'react-use';
10+
11+
interface KubectlState extends SceneObjectState { }
12+
13+
export class Kubectl extends SceneObjectBase<KubectlState> {
14+
static Component = KubectlRenderer;
15+
}
16+
17+
function KubectlRenderer({ }: SceneComponentProps<Kubectl>) {
18+
const styles = useStyles2(() => {
19+
return {
20+
container: css({
21+
width: '100%',
22+
}),
23+
};
24+
});
25+
26+
const urlParams = new URLSearchParams(window.location.search);
27+
const type = urlParams.get('type');
28+
const datasource = urlParams.get('datasource');
29+
const redirect = urlParams.get('redirect');
30+
31+
const state = useAsync(async (): Promise<void> => {
32+
let url = '';
33+
if (type === 'kubeconfig') {
34+
url = `/api/datasources/uid/${datasource}/resources/kubernetes/kubeconfig?type=exec&redirect=${redirect}`;
35+
} else if (type === 'credentials') {
36+
url = `/api/datasources/uid/${datasource}/resources/kubernetes/kubeconfig/credentials?redirect=${redirect}`;
37+
}
38+
39+
const response = await fetch(url, { method: 'get' });
40+
if (!response.ok) {
41+
throw new Error(await response.text());
42+
}
43+
44+
const result = await response.json();
45+
46+
window.location.replace(
47+
`${redirect}?${type}=${encodeURIComponent(JSON.stringify(result))}`,
48+
);
49+
}, []);
50+
51+
return (
52+
<>
53+
<div className={styles.container}>
54+
{state.loading ? (
55+
<LoadingPlaceholder text={`Loading ${type}...`} />
56+
) : state.error ? (
57+
<Alert severity="error" title={`Failed to load ${type}`}>
58+
{state.error.message}
59+
</Alert>
60+
) : (
61+
<LoadingPlaceholder text={'Redirecting...'} />
62+
)}
63+
</div>
64+
</>
65+
);
66+
}

0 commit comments

Comments
 (0)