Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC: allow retrieving kubeconfig from BTP #2124

Merged
merged 6 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions internal/btp/cis/kubeconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cis

import (
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/kyma-project/cli.v3/internal/clierror"
)

const environmentsEndpoint = "provisioning/v1/environments"

type Labels struct {
APIServerURL string `json:"APIServerURL"`
KubeconfigURL string `json:"KubeconfigURL"`
Name string `json:"Name"`
}

type environmentInstances struct {
EnvironmentInstances []ProvisionResponse `json:"environmentInstances"`
}

func (c *LocalClient) GetKymaKubeconfig() (string, clierror.Error) {
provisionURL := fmt.Sprintf("%s/%s", c.credentials.Endpoints.ProvisioningServiceURL, environmentsEndpoint)

response, err := c.cis.get(provisionURL, requestOptions{})
if err != nil {
// TODO: finish - error codes?
return "", clierror.New(err.Error())
}

defer response.Body.Close()

return decodeResponse(response)

}

func decodeResponse(response *http.Response) (string, clierror.Error) {
envInstances := environmentInstances{}
err := json.NewDecoder(response.Body).Decode(&envInstances)
if err != nil {
return "", clierror.Wrap(err, clierror.New("failed to decode response"))
}

// we assume there can be only one Kyma environment in the BTP subaccount
for _, env := range envInstances.EnvironmentInstances {
if env.EnvironmentType == "kyma" {
// parse labels to get kubeconfig URL
labels := Labels{}
err := json.Unmarshal([]byte(env.Labels), &labels)
if err != nil {
return "", clierror.Wrap(err, clierror.New("failed to unmarshal labels"))
}

kubeconfig, err := downloadKubeconfig(labels.KubeconfigURL)
if err != nil {
return "", clierror.Wrap(err, clierror.New("failed to get kubeconfig"))
}
return kubeconfig, nil
}
}

return "", clierror.New("no Kyma environment found")
}

func downloadKubeconfig(url string) (string, error) {
response, err := http.Get(url)
if err != nil {
return "", err
}
defer response.Body.Close()

kubeconfig, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}

return string(kubeconfig), nil
}
138 changes: 84 additions & 54 deletions internal/cmd/oidc/oidc.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
package oidc

import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"

"github.com/kyma-project/cli.v3/internal/btp/auth"
"github.com/kyma-project/cli.v3/internal/btp/cis"
"github.com/kyma-project/cli.v3/internal/clierror"
"github.com/kyma-project/cli.v3/internal/cmdcommon"
"github.com/kyma-project/cli.v3/internal/kube"
"github.com/spf13/cobra"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)

type oidcConfig struct {
*cmdcommon.KymaConfig
cmdcommon.KubeClientConfig

cisCredentialsPath string
output string
caCertificate string
clusterServer string
audience string
token string
idTokenRequestURL string
Expand Down Expand Up @@ -53,17 +55,15 @@ func NewOIDCCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command {

cfg.KubeClientConfig.AddFlag(cmd)

cmd.Flags().StringVar(&cfg.cisCredentialsPath, "credentials-path", "", "Path to the CIS credentials file.")
cmd.Flags().StringVar(&cfg.output, "output", "", "Path to the output kubeconfig file")
cmd.Flags().StringVar(&cfg.caCertificate, "ca-certificate", "", "Path to the CA certificate file")
cmd.Flags().StringVar(&cfg.clusterServer, "cluster-server", "", "URL of the cluster server")

cmd.Flags().StringVar(&cfg.token, "token", "", "Token used in the kubeconfig")
cmd.Flags().StringVar(&cfg.audience, "audience", "", "Audience of the token")
cmd.Flags().StringVar(&cfg.idTokenRequestURL, "id-token-request-url", "", "URL to request the ID token, defaults to ACTIONS_ID_TOKEN_REQUEST_URL env variable")

cmd.MarkFlagsOneRequired("kubeconfig", "ca-certificate")
cmd.MarkFlagsRequiredTogether("ca-certificate", "cluster-server")
cmd.MarkFlagsMutuallyExclusive("kubeconfig", "ca-certificate")
cmd.MarkFlagsOneRequired("kubeconfig", "credentials-path")
cmd.MarkFlagsMutuallyExclusive("kubeconfig", "credentials-path")

cmd.MarkFlagsMutuallyExclusive("token", "id-token-request-url")
cmd.MarkFlagsMutuallyExclusive("token", "audience")
Expand All @@ -77,7 +77,7 @@ func (cfg *oidcConfig) complete() clierror.Error {
}
cfg.idTokenRequestToken = os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")

if cfg.KubeClientConfig.Kubeconfig != "" {
if cfg.cisCredentialsPath == "" {
return cfg.KubeClientConfig.Complete()
}
return nil
Expand All @@ -91,58 +91,101 @@ func (cfg *oidcConfig) validate() clierror.Error {

if cfg.idTokenRequestURL == "" {
return clierror.New(
"ID token request URL is required",
"ID token request URL is required if --token is not provided",
"make sure you're running the command in Github Actions environment",
"provide id-token-request-url flag or ACTIONS_ID_TOKEN_REQUEST_URL env variable",
)
}

if cfg.idTokenRequestToken == "" {
return clierror.New(
"ACTIONS_ID_TOKEN_REQUEST_TOKEN env variable is required",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN env variable is required if --token is not provided",
"make sure you're running the command in Github Actions environment",
)
}
return nil
}

func runOIDC(cfg *oidcConfig) clierror.Error {
var err error
var clierr clierror.Error
token := cfg.token
if cfg.token != "" {
if cfg.token == "" {
// get Github token
token, err = getGithubToken(cfg.idTokenRequestURL, cfg.idTokenRequestToken, cfg.audience)
if err != nil {
return clierror.Wrap(err, clierror.New("failed to get token"))
token, clierr = getGithubToken(cfg.idTokenRequestURL, cfg.idTokenRequestToken, cfg.audience)
if clierr != nil {
return clierror.WrapE(clierr, clierror.New("failed to get token"))
}
}
caCertificate := cfg.caCertificate
clusterServer := cfg.clusterServer
if cfg.KubeClientConfig.Kubeconfig != "" {
currentServer := cfg.KubeClient.ApiConfig().Clusters[cfg.KubeClient.ApiConfig().CurrentContext]
caCertificate = string(currentServer.CertificateAuthorityData)
clusterServer = currentServer.Server
}

enrichedKubeconfig, err := createKubeconfig(caCertificate, clusterServer, token)
if err != nil {
return clierror.Wrap(err, clierror.New("failed to create kubeconfig"))
var kubeconfig *api.Config

if cfg.cisCredentialsPath != "" {
kubeconfig, clierr = getKubeconfigFromCIS(cfg)
if clierr != nil {
return clierror.WrapE(clierr, clierror.New("failed to get kubeconfig from CIS"))
}
} else {
kubeconfig = cfg.KubeClient.ApiConfig()
}

err = kube.SaveConfig(enrichedKubeconfig, cfg.output)
enrichedKubeconfig := createKubeconfig(kubeconfig, token)

err := kube.SaveConfig(enrichedKubeconfig, cfg.output)
if err != nil {
return clierror.Wrap(err, clierror.New("failed to save kubeconfig"))
}

return nil
}

func getGithubToken(url, requestToken, audience string) (string, error) {
func getKubeconfigFromCIS(cfg *oidcConfig) (*api.Config, clierror.Error) {
// TODO: maybe refactor with provision command to not duplicate localCISClient provisioning
credentials, err := auth.LoadCISCredentials(cfg.cisCredentialsPath)
if err != nil {
return nil, err
}
token, err := auth.GetOAuthToken(
credentials.GrantType,
credentials.UAA.URL,
credentials.UAA.ClientID,
credentials.UAA.ClientSecret,
)
if err != nil {
var hints []string
if strings.Contains(err.String(), "Internal Server Error") {
hints = append(hints, "check if CIS grant type is set to client credentials")
}

return nil, clierror.WrapE(err, clierror.New("failed to get access token", hints...))
}

localCISClient := cis.NewLocalClient(credentials, token)
kubeconfigString, err := localCISClient.GetKymaKubeconfig()
if err != nil {
return nil, clierror.WrapE(err, clierror.New("failed to get kubeconfig"))
}

kubeconfig, err := parseKubeconfig(kubeconfigString)
if err != nil {
return nil, clierror.WrapE(err, clierror.New("failed to parse kubeconfig"))
}
return kubeconfig, nil
}

func parseKubeconfig(kubeconfigString string) (*api.Config, clierror.Error) {
kubeconfig, err := clientcmd.Load([]byte(kubeconfigString))
if err != nil {
return nil, clierror.Wrap(err, clierror.New("failed to parse kubeconfig string"))
}
return kubeconfig, nil
}

func getGithubToken(url, requestToken, audience string) (string, clierror.Error) {
// create http client

request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
return "", clierror.Wrap(err, clierror.New("failed to create request"))
}
if audience != "" {
q := request.URL.Query()
Expand All @@ -155,51 +198,38 @@ func getGithubToken(url, requestToken, audience string) (string, error) {

response, err := http.DefaultClient.Do(request)
if err != nil {
return "", err
return "", clierror.Wrap(err, clierror.New("failed to get token from Github"))
}
defer response.Body.Close()

if response.StatusCode != 200 {
return "", fmt.Errorf("failed to get token from server: %s", response.Status)
return "", clierror.New(fmt.Sprintf("Invalid server response: %d", response.StatusCode))
}

tokenData := TokenData{}
err = json.NewDecoder(response.Body).Decode(&tokenData)
if err != nil {
return "", err
return "", clierror.Wrap(err, clierror.New("failed to decode token response"))
}
return tokenData.Value, nil
}

func createKubeconfig(caCertificate, clusterServer, token string) (*api.Config, error) {
certificate, err := base64.StdEncoding.DecodeString(caCertificate)
if err != nil {
return nil, err
}

func createKubeconfig(kubeconfig *api.Config, token string) *api.Config {
currentUser := kubeconfig.Contexts[kubeconfig.CurrentContext].AuthInfo
config := &api.Config{
Kind: "Config",
APIVersion: "v1",
Clusters: map[string]*api.Cluster{
"cluster": {
Server: clusterServer,
CertificateAuthorityData: certificate,
},
},
Clusters: kubeconfig.Clusters,
AuthInfos: map[string]*api.AuthInfo{
"user": {
currentUser: {
Token: token,
},
},
Contexts: map[string]*api.Context{
"default": {
Cluster: "cluster",
AuthInfo: "user",
},
},
CurrentContext: "default",
Extensions: nil,
Contexts: kubeconfig.Contexts,
CurrentContext: kubeconfig.CurrentContext,
Extensions: kubeconfig.Extensions,
Preferences: kubeconfig.Preferences,
}

return config, nil
return config
}
Loading