From e1d02533b46993a8554a5837095f6f28050cb4df Mon Sep 17 00:00:00 2001 From: Simon Mayer Date: Fri, 21 Aug 2026 17:31:31 +0200 Subject: [PATCH 1/4] Add commands for viewer and admin kubeconfig --- cmd/api/v1/cluster.go | 45 ++++++++++++++++++++------------ docs/metal_cluster_kubeconfig.md | 1 + go.mod | 2 +- go.sum | 4 +-- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/cmd/api/v1/cluster.go b/cmd/api/v1/cluster.go index 9773626..323dd2e 100644 --- a/cmd/api/v1/cluster.go +++ b/cmd/api/v1/cluster.go @@ -118,6 +118,7 @@ func newClusterCmd(c *config.Config) *cobra.Command { kubeconfigCmd.Flags().DurationP("expiration", "", 8*time.Hour, "kubeconfig will expire after given time") kubeconfigCmd.Flags().Bool("merge", true, "merges the kubeconfig into the current kubeconfig") kubeconfigCmd.Flags().Bool("print-only", false, "only prints the kubeconfig to the console instead of writing it") + kubeconfigCmd.Flags().String("role", "viewer", `role for the kubeconfig: "admin" or "viewer"`) kubeconfigCmd.Flags().String("auth-type", string(kubernetes.AuthTypeExec), `the way how the resulting kubeconfig authenticates at the api server. can be "exec" or "certs". "exec" injects an exec config into the kubeconfig, which uses this CLI to automatically renew certificates when they expire. "certs" simply adds the client certificates to the kubeconfig, there is no automatic renewal once the certificates have expired, the CLI is not called automatically.`) @@ -554,27 +555,39 @@ func (c *cluster) kubeconfig(args []string) error { return err } - req := &apiv1.ClusterServiceGetCredentialsRequest{ - Uuid: id, - Project: c.c.GetProject(), - Expiration: durationpb.New(viper.GetDuration("expiration")), + role := viper.GetString("role") + if role != "admin" && role != "viewer" { + return fmt.Errorf("role must be \"admin\" or \"viewer\"") } - resp, err := c.c.Client.Apiv1().Cluster().GetCredentials(ctx, connect.NewRequest(req)) - if err != nil { - return fmt.Errorf("failed to get cluster credentials: %w", err) - } + expiration := durationpb.New(viper.GetDuration("expiration")) + project := c.c.GetProject() - projectResp, err := c.c.Client.Apiv1().Project().Get(ctx, connect.NewRequest(&apiv1.ProjectServiceGetRequest{Project: c.c.GetProject()})) - if err != nil { - return err + var rawKubeconfig []byte + switch role { + case "admin": + resp, err := c.c.Client.Apiv1().Cluster().GetAdminKubeconfig(ctx, connect.NewRequest(&apiv1.ClusterServiceGetAdminKubeconfigRequest{ + Uuid: id, + Project: project, + Expiration: expiration, + })) + if err != nil { + return fmt.Errorf("failed to get admin kubeconfig: %w", err) + } + rawKubeconfig = []byte(resp.Msg.Kubeconfig) + case "viewer": + resp, err := c.c.Client.Apiv1().Cluster().GetViewerKubeconfig(ctx, connect.NewRequest(&apiv1.ClusterServiceGetViewerKubeconfigRequest{ + Uuid: id, + Project: project, + Expiration: expiration, + })) + if err != nil { + return fmt.Errorf("failed to get viewer kubeconfig: %w", err) + } + rawKubeconfig = []byte(resp.Msg.Kubeconfig) } - var ( - projectName = helpers.TrimProvider(projectResp.Msg.Project.Name) - ) - - kubeconfig, err := kubernetes.NewKubeconfigFromRaw(c.c.Fs, c.c.In, c.c.Out, []byte(resp.Msg.Kubeconfig), &projectName, projectResp.Msg.Project.Uuid, id) + kubeconfig, err := kubernetes.NewKubeconfigFromRaw(c.c.Fs, c.c.In, c.c.Out, rawKubeconfig, &project, project, id) if err != nil { return err } diff --git a/docs/metal_cluster_kubeconfig.md b/docs/metal_cluster_kubeconfig.md index 3b92410..2008857 100644 --- a/docs/metal_cluster_kubeconfig.md +++ b/docs/metal_cluster_kubeconfig.md @@ -18,6 +18,7 @@ metal cluster kubeconfig [flags] --merge merges the kubeconfig into the current kubeconfig (default true) --print-only only prints the kubeconfig to the console instead of writing it -p, --project string the project in which the cluster resides for which to get the kubeconfig for + --role string role for the kubeconfig: "admin" or "viewer" (default "viewer") ``` ### Options inherited from parent commands diff --git a/go.mod b/go.mod index acb82f7..8c6eed1 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/fatih/color v1.19.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 - github.com/metal-stack-cloud/api v0.16.8 + github.com/metal-stack-cloud/api v0.16.9-0.20260805110923-6fcb2327b097 github.com/metal-stack/metal-lib v0.26.1 github.com/metal-stack/v v1.0.3 github.com/spf13/afero v1.15.0 diff --git a/go.sum b/go.sum index 7e6a3d6..c473ff1 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,8 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metal-stack-cloud/api v0.16.8 h1:ntXPUkqp1XQRyALWBJxx4zSLhFK/Fa5/HQINQ5iH78I= -github.com/metal-stack-cloud/api v0.16.8/go.mod h1:fct2KsojPKyGgB7bZun42Teb80Pb4LvnMmAOfcco0N0= +github.com/metal-stack-cloud/api v0.16.9-0.20260805110923-6fcb2327b097 h1:cr1YYSrBKiNl4JOyi/XJS2iFJvom7OtpxvhYVuxFLd8= +github.com/metal-stack-cloud/api v0.16.9-0.20260805110923-6fcb2327b097/go.mod h1:fct2KsojPKyGgB7bZun42Teb80Pb4LvnMmAOfcco0N0= github.com/metal-stack/metal-lib v0.26.1 h1:wMrhENm/HPq0t7cjCkhzepGD6dyxmX/4IMsaJZea874= github.com/metal-stack/metal-lib v0.26.1/go.mod h1:tnx4MM5oml10EMN6Nq6oFSbjOKB9B27WUZQUyZ4MywA= github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= From 6a618b8933714aa0f5fcca2dee87d326c62e5922 Mon Sep 17 00:00:00 2001 From: Simon Mayer Date: Mon, 24 Aug 2026 07:00:47 +0200 Subject: [PATCH 2/4] Add admin commands for viewer and admin kubeconfig --- cmd/admin/v1/cluster.go | 35 +++++++++++++++++++++++++++-------- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/cmd/admin/v1/cluster.go b/cmd/admin/v1/cluster.go index f21b196..32096bc 100644 --- a/cmd/admin/v1/cluster.go +++ b/cmd/admin/v1/cluster.go @@ -74,6 +74,7 @@ func newClusterCmd(c *config.Config) *cobra.Command { kubeconfigCmd.Flags().DurationP("expiration", "", 8*time.Hour, "kubeconfig will expire after given time") kubeconfigCmd.Flags().Bool("merge", true, "merges the kubeconfig into the current kubeconfig") kubeconfigCmd.Flags().Bool("print-only", false, "only prints the kubeconfig to the console instead of writing it") + kubeconfigCmd.Flags().String("role", "viewer", `role for the kubeconfig: "admin" or "viewer"`) kubeconfigCmd.Flags().String("auth-type", string(kubernetes.AuthTypeExec), `the way how the resulting kubeconfig authenticates at the api server. can be "exec" or "certs". "exec" injects an exec config into the kubeconfig, which uses this CLI to automatically renew certificates when they expire. "certs" simply adds the client certificates to the kubeconfig, there is no automatic renewal once the certificates have expired, the CLI is not called automatically.`) @@ -190,18 +191,36 @@ func (c *cluster) kubeconfig(args []string) error { return err } - expiration := viper.GetDuration("expiration") - req := &adminv1.ClusterServiceCredentialsRequest{ - Uuid: id, - Expiration: durationpb.New(expiration), + role := viper.GetString("role") + if role != "admin" && role != "viewer" { + return fmt.Errorf("role must be \"admin\" or \"viewer\"") } - resp, err := c.c.Client.Adminv1().Cluster().Credentials(ctx, connect.NewRequest(req)) - if err != nil { - return fmt.Errorf("failed to get cluster credentials: %w", err) + expiration := durationpb.New(viper.GetDuration("expiration")) + + var rawKubeconfig []byte + switch role { + case "admin": + resp, err := c.c.Client.Adminv1().Cluster().GetAdminKubeconfig(ctx, connect.NewRequest(&adminv1.ClusterServiceGetAdminKubeconfigRequest{ + Uuid: id, + Expiration: expiration, + })) + if err != nil { + return fmt.Errorf("failed to get admin kubeconfig: %w", err) + } + rawKubeconfig = []byte(resp.Msg.Kubeconfig) + case "viewer": + resp, err := c.c.Client.Adminv1().Cluster().GetViewerKubeconfig(ctx, connect.NewRequest(&adminv1.ClusterServiceGetViewerKubeconfigRequest{ + Uuid: id, + Expiration: expiration, + })) + if err != nil { + return fmt.Errorf("failed to get viewer kubeconfig: %w", err) + } + rawKubeconfig = []byte(resp.Msg.Kubeconfig) } - kubeconfig, err := kubernetes.NewKubeconfigFromRaw(c.c.Fs, c.c.In, c.c.Out, []byte(resp.Msg.Kubeconfig), nil, c.c.GetProject(), id) // FIXME: reverse lookup project name + kubeconfig, err := kubernetes.NewKubeconfigFromRaw(c.c.Fs, c.c.In, c.c.Out, rawKubeconfig, nil, c.c.GetProject(), id) // FIXME: reverse lookup project name if err != nil { return err } diff --git a/go.mod b/go.mod index 8c6eed1..4e497fc 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/fatih/color v1.19.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 - github.com/metal-stack-cloud/api v0.16.9-0.20260805110923-6fcb2327b097 + github.com/metal-stack-cloud/api v0.16.9-0.20260824044448-9aa96a4ae2e4 github.com/metal-stack/metal-lib v0.26.1 github.com/metal-stack/v v1.0.3 github.com/spf13/afero v1.15.0 diff --git a/go.sum b/go.sum index c473ff1..384e33e 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,8 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metal-stack-cloud/api v0.16.9-0.20260805110923-6fcb2327b097 h1:cr1YYSrBKiNl4JOyi/XJS2iFJvom7OtpxvhYVuxFLd8= -github.com/metal-stack-cloud/api v0.16.9-0.20260805110923-6fcb2327b097/go.mod h1:fct2KsojPKyGgB7bZun42Teb80Pb4LvnMmAOfcco0N0= +github.com/metal-stack-cloud/api v0.16.9-0.20260824044448-9aa96a4ae2e4 h1:UMWCmD/X/9/XoDCEFxuCGWwOnWtG77GUjYvgI7eBPq0= +github.com/metal-stack-cloud/api v0.16.9-0.20260824044448-9aa96a4ae2e4/go.mod h1:fct2KsojPKyGgB7bZun42Teb80Pb4LvnMmAOfcco0N0= github.com/metal-stack/metal-lib v0.26.1 h1:wMrhENm/HPq0t7cjCkhzepGD6dyxmX/4IMsaJZea874= github.com/metal-stack/metal-lib v0.26.1/go.mod h1:tnx4MM5oml10EMN6Nq6oFSbjOKB9B27WUZQUyZ4MywA= github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= From 9644e9211cd46088e63fa5bae9d31ad6a030f812 Mon Sep 17 00:00:00 2001 From: Simon Mayer Date: Tue, 25 Aug 2026 13:17:17 +0200 Subject: [PATCH 3/4] Use access-level flag --- cmd/admin/v1/cluster.go | 16 +++++++++------- cmd/api/v1/cluster.go | 16 +++++++++------- cmd/config/constants.go | 13 +++++++++++++ docs/metal_cluster_kubeconfig.md | 2 +- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/cmd/admin/v1/cluster.go b/cmd/admin/v1/cluster.go index 32096bc..e145b37 100644 --- a/cmd/admin/v1/cluster.go +++ b/cmd/admin/v1/cluster.go @@ -2,6 +2,7 @@ package v1 import ( "fmt" + "strings" "time" "connectrpc.com/connect" @@ -74,13 +75,14 @@ func newClusterCmd(c *config.Config) *cobra.Command { kubeconfigCmd.Flags().DurationP("expiration", "", 8*time.Hour, "kubeconfig will expire after given time") kubeconfigCmd.Flags().Bool("merge", true, "merges the kubeconfig into the current kubeconfig") kubeconfigCmd.Flags().Bool("print-only", false, "only prints the kubeconfig to the console instead of writing it") - kubeconfigCmd.Flags().String("role", "viewer", `role for the kubeconfig: "admin" or "viewer"`) + kubeconfigCmd.Flags().String("access-level", config.AccessLevelViewer, `access level for the kubeconfig. One of "admin" or "viewer"`) kubeconfigCmd.Flags().String("auth-type", string(kubernetes.AuthTypeExec), `the way how the resulting kubeconfig authenticates at the api server. can be "exec" or "certs". "exec" injects an exec config into the kubeconfig, which uses this CLI to automatically renew certificates when they expire. "certs" simply adds the client certificates to the kubeconfig, there is no automatic renewal once the certificates have expired, the CLI is not called automatically.`) kubeconfigCmd.Flags().String("kubeconfig", "", "specify an explicit path for the merged kubeconfig to be written, defaults to default kubeconfig paths if not provided") genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("auth-type", c.Completion.ClusterKubeconfigAuthType)) + genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("access-level", cobra.FixedCompletions(config.AccessLevels, cobra.ShellCompDirectiveNoFileComp))) // metal admin cluster machine list @@ -191,16 +193,16 @@ func (c *cluster) kubeconfig(args []string) error { return err } - role := viper.GetString("role") - if role != "admin" && role != "viewer" { - return fmt.Errorf("role must be \"admin\" or \"viewer\"") + accessLevel := viper.GetString("access-level") + if !config.IsValidAccessLevel(accessLevel) { + return fmt.Errorf("access-level must be one of: %s", strings.Join(config.AccessLevels, ", ")) } expiration := durationpb.New(viper.GetDuration("expiration")) var rawKubeconfig []byte - switch role { - case "admin": + switch accessLevel { + case config.AccessLevelAdmin: resp, err := c.c.Client.Adminv1().Cluster().GetAdminKubeconfig(ctx, connect.NewRequest(&adminv1.ClusterServiceGetAdminKubeconfigRequest{ Uuid: id, Expiration: expiration, @@ -209,7 +211,7 @@ func (c *cluster) kubeconfig(args []string) error { return fmt.Errorf("failed to get admin kubeconfig: %w", err) } rawKubeconfig = []byte(resp.Msg.Kubeconfig) - case "viewer": + case config.AccessLevelViewer: resp, err := c.c.Client.Adminv1().Cluster().GetViewerKubeconfig(ctx, connect.NewRequest(&adminv1.ClusterServiceGetViewerKubeconfigRequest{ Uuid: id, Expiration: expiration, diff --git a/cmd/api/v1/cluster.go b/cmd/api/v1/cluster.go index 323dd2e..e87c0fa 100644 --- a/cmd/api/v1/cluster.go +++ b/cmd/api/v1/cluster.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "slices" + "strings" "time" "connectrpc.com/connect" @@ -118,7 +119,7 @@ func newClusterCmd(c *config.Config) *cobra.Command { kubeconfigCmd.Flags().DurationP("expiration", "", 8*time.Hour, "kubeconfig will expire after given time") kubeconfigCmd.Flags().Bool("merge", true, "merges the kubeconfig into the current kubeconfig") kubeconfigCmd.Flags().Bool("print-only", false, "only prints the kubeconfig to the console instead of writing it") - kubeconfigCmd.Flags().String("role", "viewer", `role for the kubeconfig: "admin" or "viewer"`) + kubeconfigCmd.Flags().String("access-level", config.AccessLevelViewer, `access level for the kubeconfig. One of "admin" or "viewer"`) kubeconfigCmd.Flags().String("auth-type", string(kubernetes.AuthTypeExec), `the way how the resulting kubeconfig authenticates at the api server. can be "exec" or "certs". "exec" injects an exec config into the kubeconfig, which uses this CLI to automatically renew certificates when they expire. "certs" simply adds the client certificates to the kubeconfig, there is no automatic renewal once the certificates have expired, the CLI is not called automatically.`) @@ -126,6 +127,7 @@ func newClusterCmd(c *config.Config) *cobra.Command { genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("project", c.Completion.ProjectListCompletion)) genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("auth-type", c.Completion.ClusterKubeconfigAuthType)) + // genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("access-level", cobra.FixedCompletions(config.AccessLevels, cobra.ShellCompDirectiveNoFileComp))) execConfigCmd := &cobra.Command{ Use: "exec-config", @@ -555,17 +557,17 @@ func (c *cluster) kubeconfig(args []string) error { return err } - role := viper.GetString("role") - if role != "admin" && role != "viewer" { - return fmt.Errorf("role must be \"admin\" or \"viewer\"") + accessLevel := viper.GetString("access-level") + if !config.IsValidAccessLevel(accessLevel) { + return fmt.Errorf("access-level must be one of: %s", strings.Join(config.AccessLevels, ", ")) } expiration := durationpb.New(viper.GetDuration("expiration")) project := c.c.GetProject() var rawKubeconfig []byte - switch role { - case "admin": + switch accessLevel { + case config.AccessLevelAdmin: resp, err := c.c.Client.Apiv1().Cluster().GetAdminKubeconfig(ctx, connect.NewRequest(&apiv1.ClusterServiceGetAdminKubeconfigRequest{ Uuid: id, Project: project, @@ -575,7 +577,7 @@ func (c *cluster) kubeconfig(args []string) error { return fmt.Errorf("failed to get admin kubeconfig: %w", err) } rawKubeconfig = []byte(resp.Msg.Kubeconfig) - case "viewer": + case config.AccessLevelViewer: resp, err := c.c.Client.Apiv1().Cluster().GetViewerKubeconfig(ctx, connect.NewRequest(&apiv1.ClusterServiceGetViewerKubeconfigRequest{ Uuid: id, Project: project, diff --git a/cmd/config/constants.go b/cmd/config/constants.go index 6a5767a..779db61 100644 --- a/cmd/config/constants.go +++ b/cmd/config/constants.go @@ -1,7 +1,20 @@ package config +import "slices" + const ( DefaultApiURL = "https://api.metalstack.cloud" DefaultAfterLoginPage = "https://metalstack.cloud" DefaultConsoleURL = "https://console.metalstack.cloud" + + // Access level admin used for shoot kubeconfig generation + AccessLevelAdmin = "admin" + // Access level viewer used for shoot kubeconfig generation + AccessLevelViewer = "viewer" ) + +var AccessLevels = []string{AccessLevelAdmin, AccessLevelViewer} + +func IsValidAccessLevel(level string) bool { + return slices.Contains(AccessLevels, level) +} diff --git a/docs/metal_cluster_kubeconfig.md b/docs/metal_cluster_kubeconfig.md index 2008857..71e52bc 100644 --- a/docs/metal_cluster_kubeconfig.md +++ b/docs/metal_cluster_kubeconfig.md @@ -9,6 +9,7 @@ metal cluster kubeconfig [flags] ### Options ``` + --access-level string access level for the kubeconfig. One of "admin" or "viewer" (default "viewer") --auth-type string the way how the resulting kubeconfig authenticates at the api server. can be "exec" or "certs". "exec" injects an exec config into the kubeconfig, which uses this CLI to automatically renew certificates when they expire. "certs" simply adds the client certificates to the kubeconfig, there is no automatic renewal once the certificates have expired, the CLI is not called automatically. (default "exec") @@ -18,7 +19,6 @@ metal cluster kubeconfig [flags] --merge merges the kubeconfig into the current kubeconfig (default true) --print-only only prints the kubeconfig to the console instead of writing it -p, --project string the project in which the cluster resides for which to get the kubeconfig for - --role string role for the kubeconfig: "admin" or "viewer" (default "viewer") ``` ### Options inherited from parent commands From a604fd004bf82c8203a8a614ce4827949fc6e7c6 Mon Sep 17 00:00:00 2001 From: Simon Mayer Date: Tue, 25 Aug 2026 15:35:17 +0200 Subject: [PATCH 4/4] Remove comment --- cmd/api/v1/cluster.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api/v1/cluster.go b/cmd/api/v1/cluster.go index e87c0fa..f3129fe 100644 --- a/cmd/api/v1/cluster.go +++ b/cmd/api/v1/cluster.go @@ -127,7 +127,7 @@ func newClusterCmd(c *config.Config) *cobra.Command { genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("project", c.Completion.ProjectListCompletion)) genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("auth-type", c.Completion.ClusterKubeconfigAuthType)) - // genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("access-level", cobra.FixedCompletions(config.AccessLevels, cobra.ShellCompDirectiveNoFileComp))) + genericcli.Must(kubeconfigCmd.RegisterFlagCompletionFunc("access-level", cobra.FixedCompletions(config.AccessLevels, cobra.ShellCompDirectiveNoFileComp))) execConfigCmd := &cobra.Command{ Use: "exec-config",