Skip to content
Open
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
5 changes: 4 additions & 1 deletion args.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ const (
ArgKubernetesSSOIssuerURL = "sso-issuer-url"
// ArgKubernetesSSOClientID is the OIDC client ID for cluster SSO configuration.
ArgKubernetesSSOClientID = "sso-client-id"
// ArgKubernetesSSOLocalServerPort is the port to use for the local server which handles SSO authentication flow.
ArgKubernetesSSOLocalServerPort = "sso-local-server-port"
// ArgSurgeUpgrade is a cluster's surge-upgrade argument.
ArgSurgeUpgrade = "surge-upgrade"
// ArgCommandUpsert is an upsert for a resource to be created or updated argument.
Expand Down Expand Up @@ -328,7 +330,8 @@ const (
// ArgTriggerDeployment indicates whether to trigger a deployment
ArgTriggerDeployment = "trigger-deployment"
// ArgVersion is the version of the command to use
ArgVersion = "version"
ArgVersion = "version"
ArgKubernetesSSOURL = "sso-url"
// ArgVerbose enables verbose output
ArgVerbose = "verbose"

Expand Down
2 changes: 1 addition & 1 deletion commands/doit.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func initConfig() {
}

// in case we ever want to change this, or let folks configure it...
func defaultConfigHome() string {
var defaultConfigHome = func() string {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that it can be stubbed in tests.

cfgDir, err := os.UserConfigDir()
checkErr(err)

Expand Down
112 changes: 89 additions & 23 deletions commands/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
Expand All @@ -28,12 +29,14 @@ import (
"github.com/blang/semver"
"github.com/digitalocean/godo"
"github.com/google/uuid"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/digitalocean/doctl"
"github.com/digitalocean/doctl/commands/displayers"
"github.com/digitalocean/doctl/do"
"github.com/digitalocean/doctl/internal/kubernetes/sso"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kubeerrors "k8s.io/apimachinery/pkg/util/errors"
Expand Down Expand Up @@ -61,6 +64,14 @@ A typical workflow is to use ` + "`" + `doctl kubernetes cluster create` + "`" +
The commands under ` + "`" + `doctl kubernetes options` + "`" + ` retrieve values used while creating clusters, such as the list of regions where cluster creation is supported.`
)

func init() {
// Borrowed from https://github.com/int128/kubelogin:
// In credential plugin mode, some browser launcher writes a message to stdout
// and it may break the credential json for client-go.
// This prevents the browser launcher from breaking the credential json.
browser.Stdout = os.Stderr
}

var getCurrentAuthContextFn = defaultGetCurrentAuthContextFn

func defaultGetCurrentAuthContextFn() string {
Expand Down Expand Up @@ -207,13 +218,17 @@ func (p *kubeconfigProvider) ConfigPath() string {
// KubernetesCommandService is used to execute Kubernetes commands.
type KubernetesCommandService struct {
KubeconfigProvider KubeconfigProvider

// to be used for stubbing in testss
ssoLogin func(ctx context.Context, clientID, issuerURL string, opts ...sso.LocalOIDCLoginOption) (string, time.Time, error)
}

func kubernetesCommandService() *KubernetesCommandService {
return &KubernetesCommandService{
KubeconfigProvider: &kubeconfigProvider{
pathOptions: clientcmd.NewDefaultPathOptions(),
},
ssoLogin: sso.GetIDToken,
}
}

Expand Down Expand Up @@ -473,8 +488,11 @@ Returns the raw YAML for the specified cluster's kubeconfig.`, Writer, aliasOpt(
cmdShowConfig.Example = `The following example shows the kubeconfig YAML for a cluster named ` + "`" + `example-cluster` + "`" + `: doctl kubernetes cluster kubeconfig show example-cluster`

execCredDesc := "INTERNAL: This hidden command is for printing a cluster's exec credential"
cmdExecCredential := CmdBuilder(cmd, k8sCmdService.RunKubernetesKubeconfigExecCredential, "exec-credential <cluster-id>", execCredDesc, execCredDesc, Writer, hiddenCmd())
cmdExecCredential := CmdBuilder(cmd, k8sCmdService.RunKubernetesKubeconfigExecCredential, "exec-credential <cluster-id>", execCredDesc, execCredDesc, Writer) //, hiddenCmd())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we’re making this command non-hidden, should we also update the execCredDesc accordingly?

AddStringFlag(cmdExecCredential, doctl.ArgVersion, "", "", "")
AddStringFlag(cmdExecCredential, doctl.ArgKubernetesSSOIssuerURL, "", "", "")
AddStringFlag(cmdExecCredential, doctl.ArgKubernetesSSOClientID, "", "", "")
AddIntFlag(cmdExecCredential, doctl.ArgKubernetesSSOLocalServerPort, "", 8080, "")

cmdSaveConfig := CmdBuilder(cmd, k8sCmdService.RunKubernetesKubeconfigSave, "save <cluster-id|cluster-name>", "Save a cluster's credentials to your local kubeconfig", `
Adds the credentials for the specified cluster to your local kubeconfig. After this, your kubectl installation can directly manage the specified cluster.
Expand Down Expand Up @@ -1264,10 +1282,13 @@ func cachedExecCredentialPath(id string) string {
return filepath.Join(kubeconfigCachePath(), id+".json")
}

func cachedSSOExecCredentialPath(id string) string {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate files for SSO tokens

return filepath.Join(kubeconfigCachePath(), id+"_sso.json")
}

// loadCachedExecCredential attempts to load the cached exec credential from disk. Never errors
// Returns nil if there's no credential, if it failed to load it, or if it's expired.
func loadCachedExecCredential(id string) (*clientauthentication.ExecCredential, error) {
path := cachedExecCredentialPath(id)
func loadCachedExecCredential(path string) (*clientauthentication.ExecCredential, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
Expand Down Expand Up @@ -1300,7 +1321,7 @@ func loadCachedExecCredential(id string) (*clientauthentication.ExecCredential,
}

// cacheExecCredential caches an ExecCredential to the doctl cache directory
func cacheExecCredential(id string, execCredential *clientauthentication.ExecCredential) error {
func cacheExecCredential(path string, execCredential *clientauthentication.ExecCredential) error {
// Don't bother caching if there's no expiration set
if execCredential.Status.ExpirationTimestamp.IsZero() {
return nil
Expand All @@ -1311,7 +1332,6 @@ func cacheExecCredential(id string, execCredential *clientauthentication.ExecCre
return err
}

path := cachedExecCredentialPath(id)
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(0600))
if err != nil {
return err
Expand All @@ -1337,43 +1357,85 @@ func (s *KubernetesCommandService) RunKubernetesKubeconfigExecCredential(c *CmdC
return fmt.Errorf("Invalid version %q, expected 'v1beta1'", version)
}

var isSSO bool
ssoIssuerURL, err := c.Doit.GetString(c.NS, doctl.ArgKubernetesSSOIssuerURL)
if err != nil {
return fmt.Errorf("Checking %s flag: %v", doctl.ArgKubernetesSSOIssuerURL, err)
}
ssoClientID, err := c.Doit.GetString(c.NS, doctl.ArgKubernetesSSOClientID)
if err != nil {
return fmt.Errorf("Checking %s flag: %v", doctl.ArgKubernetesSSOClientID, err)
}
if (ssoIssuerURL != "" && ssoClientID == "") || (ssoIssuerURL == "" && ssoClientID != "") {
return fmt.Errorf("Invalid SSO configuration: issuer URL and client ID must be provided together")
}
isSSO = (ssoIssuerURL != "" && ssoClientID != "")

kube := c.Kubernetes()
// it's important that we don't print anything to stdout since this command
// is used by kubectl which relies on stdout to contain _only_ the credential
logger := log.New(os.Stderr, "doctl: ", log.LstdFlags)

clusterID := c.Args[0]
execCredential, err := loadCachedExecCredential(clusterID)
cachePath := cachedExecCredentialPath(clusterID)
if isSSO {
// store SSO credentials separately so that, if a user switches from SSO to token auth (or vice versa),
// we stop using the cached credentials and instead fetch new ones
cachePath = cachedSSOExecCredentialPath(clusterID)
}
execCredential, err := loadCachedExecCredential(cachePath)
if err != nil && Verbose {
warn("%v", err)
}

if execCredential != nil {
logger.Println("Using cached credential")
return json.NewEncoder(c.Out).Encode(execCredential)
}

credentials, err := kube.GetCredentials(clusterID)
if err != nil {
if errResponse, ok := err.(*godo.ErrorResponse); ok {
return fmt.Errorf("Failed to fetch credentials for cluster %q: %v", clusterID, errResponse.Message)
var token string
var expiry time.Time
if isSSO {
logger.Println("SSO login")
ssoLocalServerPort, err := c.Doit.GetInt(c.NS, doctl.ArgKubernetesSSOLocalServerPort)
if err != nil {
return fmt.Errorf("Checking %s flag: %v", doctl.ArgKubernetesSSOLocalServerPort, err)
}

if ssoLocalServerPort <= 1024 || ssoLocalServerPort > 65535 {
return fmt.Errorf("Invalid %s flag: %d", doctl.ArgKubernetesSSOLocalServerPort, ssoLocalServerPort)
}
return err
}

status := &clientauthentication.ExecCredentialStatus{
ClientCertificateData: string(credentials.ClientCertificateData),
ClientKeyData: string(credentials.ClientKeyData),
ExpirationTimestamp: &metav1.Time{Time: credentials.ExpiresAt},
Token: credentials.Token,
token, expiry, err = s.ssoLogin(context.Background(), ssoClientID, ssoIssuerURL, sso.WithLocalServerPort(uint16(ssoLocalServerPort)), sso.WithLogger(logger))
if err != nil {
return fmt.Errorf("Failed to get ID token: %w", err)
}
} else {
logger.Println("DO PAT login")
credentials, err := kube.GetCredentials(clusterID)
if err != nil {
if errResponse, ok := err.(*godo.ErrorResponse); ok {
return fmt.Errorf("Failed to fetch credentials for cluster %q: %v", clusterID, errResponse.Message)
}
return err
}
expiry = credentials.ExpiresAt
token = credentials.Token
}

execCredential = &clientauthentication.ExecCredential{
TypeMeta: metav1.TypeMeta{
Kind: execCredentialKind,
APIVersion: clientauthentication.SchemeGroupVersion.String(),
},
Status: status,
Status: &clientauthentication.ExecCredentialStatus{
Token: token,
ExpirationTimestamp: &metav1.Time{Time: expiry},
},
}

// Don't error out when caching credentials, just print it if we're being verbose
if err := cacheExecCredential(clusterID, execCredential); err != nil && Verbose {
if err := cacheExecCredential(cachePath, execCredential); err != nil && Verbose {
warn("%v", err)
}

Expand Down Expand Up @@ -1408,10 +1470,14 @@ func (s *KubernetesCommandService) RunKubernetesKubeconfigSave(c *CmdConfig) err
return err
}

path := cachedExecCredentialPath(kubeconfigParams.clusterID)
_, err = os.Stat(path)
if err == nil {
os.Remove(path)
for _, path := range []string{
cachedExecCredentialPath(kubeconfigParams.clusterID),
cachedSSOExecCredentialPath(kubeconfigParams.clusterID),
} {
_, err = os.Stat(path)
if err == nil {
os.Remove(path)
}
}

return s.writeOrAddToKubeconfig(kubeconfigParams, remoteKubeconfig, setCurrentContext)
Expand Down
Loading
Loading