feat: support standard and access token kubeconfigs

This commit is contained in:
franklin 2023-02-14 09:33:17 +01:00
parent ce2ddb5320
commit 76e169ea9b
10 changed files with 310 additions and 257 deletions

View File

@ -1,17 +1,96 @@
# POSH gcloud provider
## Usage
## Configuration:
```yaml
gcloud:
project: myproject-123456
configPath: .posh/config/gcloud
clusters:
environments:
- name: prod
region: europe-west6
- name: stage
region: europe-west6
project: myproject-123456
clusters:
- name: default
role: admin
region: europe-west6
```
Using service account access tokens retrieved by OnePassword:
```yaml
gcloud:
configPath: .posh/config/gcloud
accessTokenPath: .posh/config/gcloud/access_tokens
environments:
- name: prod
project: myproject-123456
clusters:
- name: default
role: admin
region: europe-west6
accessToken:
field: 1234564dxtuty3vaaxezex4c7ey
item: 1234564dxtuty3vaaxezex4c7ey
vault: 1234564dxtuty3vaaxezex4c7ey
account: foomo
```
## Usage
```go
func New(l log.Logger) (plugin.Plugin, error) {
inst := &Plugin{}
// ...
// create provider
provider, err := gcloud.New(l, inst.cache)
if err != nil {
return nil
}
// add command
inst.commands.Add(
gcloud.NewCommand(l, provider, inst.kubectl, gcloud.CommandWithOnePassword(inst.op)),
)
// ...
}
```
Using service account access tokens retrieved by OnePassword:
```go
func New(l log.Logger) (plugin.Plugin, error) {
inst := &Plugin{}
// ...
// create provider
provider, err := gcloud.New(l, inst.cache)
if err != nil {
return nil
}
// add command
inst.commands.Add(
gcloud.NewCommand(l, provider, inst.kubectl, gcloud.CommandWithOnePassword(inst.op)),
)
// ...
}
```
## Ownbrew
```yaml
require:
packages:
- name: gcloud
version: '>=409'
command: gcloud --version 2>&1 | grep "Google Cloud SDK" | awk '{print $4}'
help: |
Please ensure you have 'gcloud' installed in a recent version: %s!
$ brew update
$ brew install google-cloud-sdk
```

View File

@ -1,8 +0,0 @@
package gcloud
type Account struct {
Role string
Environment string
Cluster string
Path string
}

View File

@ -1,7 +1,32 @@
package gcloud
import (
"github.com/foomo/posh-providers/onepassword"
)
const (
DefaultRole string = "default"
DefaultCluster string = "default"
)
type Cluster struct {
Project string `json:"project" yaml:"project"`
Region string `json:"region" yaml:"region"`
Name string `json:"name" yaml:"name"`
Name string `json:"name" yaml:"name"`
FullName string `json:"fullName" yaml:"fullName"`
Region string `json:"region" yaml:"region"`
Role string `json:"role" yaml:"role"`
AccessToken *onepassword.Secret `json:"accessToken" yaml:"accessToken"`
}
func (c Cluster) DefaultFullName() string {
if c.FullName != "" {
return c.FullName
}
return c.Name
}
func (c Cluster) DefaultRole() string {
if c.Role != "" {
return c.Role
}
return DefaultRole
}

View File

@ -3,69 +3,125 @@ package gcloud
import (
"context"
"fmt"
"path/filepath"
"strings"
"os"
"path"
"github.com/foomo/posh-providers/kubernets/kubectl"
"github.com/foomo/posh-providers/onepassword"
"github.com/foomo/posh/pkg/command/tree"
env2 "github.com/foomo/posh/pkg/env"
"github.com/foomo/posh/pkg/log"
"github.com/foomo/posh/pkg/prompt/goprompt"
"github.com/foomo/posh/pkg/readline"
"github.com/foomo/posh/pkg/shell"
"github.com/foomo/posh/pkg/util/files"
"github.com/foomo/posh/pkg/util/suggests"
"github.com/pkg/errors"
)
type Command struct {
l log.Logger
gcloud *GCloud
kubectl *kubectl.Kubectl
commandTree *tree.Root
type (
Command struct {
l log.Logger
name string
op *onepassword.OnePassword
gcloud *GCloud
kubectl *kubectl.Kubectl
commandTree *tree.Root
clusterNameFn ClusterNameFn
}
ClusterNameFn func(environment Environment, cluster Cluster) string
CommandOption func(command *Command)
)
// ------------------------------------------------------------------------------------------------
// ~ Options
// ------------------------------------------------------------------------------------------------
func CommandWithName(v string) CommandOption {
return func(o *Command) {
o.name = v
}
}
func CommandWithOnePassword(v *onepassword.OnePassword) CommandOption {
return func(o *Command) {
o.op = v
}
}
func CommandWithClusterNameFn(v ClusterNameFn) CommandOption {
return func(o *Command) {
o.clusterNameFn = v
}
}
// ------------------------------------------------------------------------------------------------
// ~ Constructor
// ------------------------------------------------------------------------------------------------
func NewCommand(l log.Logger, gcloud *GCloud, kubectl *kubectl.Kubectl) *Command {
func NewCommand(l log.Logger, gcloud *GCloud, kubectl *kubectl.Kubectl, opts ...CommandOption) *Command {
inst := &Command{
l: l.Named("gcloud"),
name: "gcloud",
gcloud: gcloud,
kubectl: kubectl,
clusterNameFn: func(environment Environment, cluster Cluster) string {
ret := environment.Name
if cluster.Name != DefaultCluster {
ret = ret + "-" + cluster.Name
}
if cluster.DefaultRole() != DefaultRole {
ret = cluster.DefaultRole() + "@" + ret
}
return ret
},
}
for _, opt := range opts {
if opt != nil {
opt(inst)
}
}
inst.commandTree = &tree.Root{
Name: "gcloud",
Name: inst.name,
Description: "Run google cloud sdk commands",
Node: &tree.Node{
Execute: inst.execute,
},
Nodes: tree.Nodes{
{
Name: "environment",
Description: "Environments to access",
Values: inst.completeAccounts,
Name: "login",
Description: "Login to gcloud",
Execute: inst.authLogin,
},
{
Name: "docker",
Description: "Configure docker access",
Execute: inst.authConfigureDocker,
},
{
Name: "kubeconfig",
Description: "Retrieve kube config",
Nodes: tree.Nodes{
{
Name: "login",
Description: "Login to gcloud",
Execute: inst.authLogin,
},
{
Name: "docker",
Description: "Configure docker access",
Execute: inst.authConfigureDocker,
},
{
Name: "kubeconfig",
Description: "Retrieve kube config",
Args: tree.Args{
Name: "environment",
Description: "Name of the environment",
Values: func(ctx context.Context, r *readline.Readline) []goprompt.Suggest {
return suggests.List(inst.gcloud.cfg.EnvironmentNames())
},
Nodes: tree.Nodes{
{
Name: "cluster",
Repeat: true,
Optional: true,
Suggest: inst.completeClusters,
Name: "cluster",
Description: "Name of the cluster",
Values: func(ctx context.Context, r *readline.Readline) []goprompt.Suggest {
account, err := inst.gcloud.cfg.Environment(r.Args().At(1))
if err != nil {
return nil
}
return suggests.List(account.ClusterNames())
},
Execute: inst.containerClustersGetCredentials,
},
},
Execute: inst.containerClustersGetCredentials,
},
},
},
@ -102,9 +158,9 @@ Usage:
gcloud [cmd]
Available commands:
login Login into your google cloud account
docker Configure docker access
kubeconfig <cluster> Retrieve kube config
login Login into your google cloud account
docker Configure docker access
kubeconfig [env] [cluster] Retrieve kube config for the given cluster
`
}
@ -121,39 +177,6 @@ func (c *Command) execute(ctx context.Context, r *readline.Readline) error {
Run()
}
// Auto-complete clusters based on selected account
func (c *Command) completeClusters(ctx context.Context, t *tree.Root, r *readline.Readline) []goprompt.Suggest {
var ret []goprompt.Suggest
account := r.Args().At(0)
for _, acc := range c.gcloud.cfg.Environments {
if strings.Contains(account, acc.Name) {
for _, cluster := range acc.Clusters {
ret = append(ret, goprompt.Suggest{Text: cluster.Name})
}
}
}
return ret
}
func (c *Command) completeAccounts(ctx context.Context, r *readline.Readline) []goprompt.Suggest {
accounts, err := c.gcloud.ParseAccounts(ctx)
if err != nil {
c.l.Debug("failed to walk files", err.Error())
return nil
}
var suggestions []goprompt.Suggest
for _, acc := range accounts {
suggestions = append(suggestions, goprompt.Suggest{
Text: acc.Environment,
Description: fmt.Sprintf("%q cluster with role %q", acc.Cluster, acc.Role),
})
}
return suggestions
}
func (c *Command) authLogin(ctx context.Context, r *readline.Readline) error {
if err := shell.New(ctx, c.l, "gcloud", "auth", "login").
Args(r.AdditionalArgs()...).
@ -173,45 +196,51 @@ func (c *Command) authConfigureDocker(ctx context.Context, r *readline.Readline)
}
func (c *Command) containerClustersGetCredentials(ctx context.Context, r *readline.Readline) error {
environment := r.Args().At(0)
clusterName := r.Args().At(2)
clusters := c.gcloud.cfg.ClusterNamesForEnv(environment)
if clusterName != "" {
clusters = []string{clusterName}
var env []string
environment, err := c.gcloud.cfg.Environment(r.Args().At(1))
if err != nil {
return errors.Errorf("failed to retrieve environment for: %s", r.Args().At(1))
}
for _, cluster := range clusters {
serviceAccounts, err := c.gcloud.FindAccounts(ctx, environment, cluster)
if err != nil {
return err
}
if len(serviceAccounts) > 1 {
c.l.Warnf("multiple accounts found for env %q and cluster %q", environment, cluster)
}
accountPath, _ := filepath.Abs(serviceAccounts[0].Path)
kubectlCluster := c.kubectl.Cluster(environment + "-" + cluster)
gcloudCluster, ok := c.gcloud.cfg.FindCluster(environment, cluster)
if !ok {
return fmt.Errorf("could not find configuration for env %q and cluster %q", environment, cluster)
}
sh := shell.New(ctx, c.l, "gcloud", "container", "clusters", "get-credentials",
"--project", gcloudCluster.Project,
"--region", gcloudCluster.Region,
cluster,
).
Args(r.AdditionalArgs()...).
Env("GOOGLE_APPLICATION_CREDENTIALS=" + accountPath).
Env("CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=" + accountPath).
Env("GOOGLE_CREDENTIALS=" + accountPath).
Env(kubectlCluster.Env())
if err := sh.Run(); err != nil {
return err
}
cluster, err := environment.Cluster(r.Args().At(2))
if err != nil {
return errors.Errorf("failed to retrieve cluster for: %s", r.Args().At(2))
}
return nil
// resolve or retrieve service account access token
accessTokenFilename := path.Join(
os.Getenv(env2.ProjectRoot),
c.gcloud.cfg.AccessTokenPath,
fmt.Sprintf("%s@%s-%s.json", cluster.DefaultRole(), environment.Name, cluster.Name),
)
if stat, err := os.Stat(accessTokenFilename); err == nil && !stat.IsDir() {
c.l.Debug("using existing access token file:", accessTokenFilename)
env = c.gcloud.EnvWithAccessToken(env, accessTokenFilename)
} else if cluster.AccessToken != nil {
if c.op == nil {
return errors.New("missing OnePassword provider to retrieve configured access token")
}
// retrieve token and write to file
if value, err := c.op.GetDocument(ctx, *cluster.AccessToken); err != nil {
return errors.Wrap(err, "failed to retrieve access token")
} else if err := files.MkdirAll(c.gcloud.cfg.AccessTokenPath); err != nil {
return errors.Wrap(err, "failed to create access token path")
} else if err := os.WriteFile(accessTokenFilename, []byte(value), 0600); err != nil {
return errors.Wrap(err, "failed to write access token")
}
c.l.Debug("retrieved and store access token file:", accessTokenFilename)
env = c.gcloud.EnvWithAccessToken(env, accessTokenFilename)
}
kubectlCluster := c.kubectl.Cluster(c.clusterNameFn(environment, cluster))
return shell.New(ctx, c.l, "gcloud", "container", "clusters", "get-credentials",
"--project", environment.Project,
"--region", cluster.Region,
cluster.DefaultFullName(),
).
Args(r.AdditionalArgs()...).
Env(kubectlCluster.Env()).
Env(env...).
Run()
}

View File

@ -1,44 +1,36 @@
package gcloud
import (
"github.com/pkg/errors"
)
type Config struct {
ConfigDir string `json:"configDir" yaml:"configDir"`
Environments []Environment `json:"environments" yaml:"environments"`
ConfigPath string `json:"configPath" yaml:"configPath"`
AccessTokenPath string `json:"accessTokenPath" yaml:"accessTokenPath"`
Environments []Environment `json:"environments" yaml:"environments"`
}
func (c Config) FindCluster(envName, clusterName string) (Cluster, bool) {
for _, env := range c.Environments {
if env.Name != envName {
continue
}
for _, cluster := range env.Clusters {
if cluster.Name == clusterName {
return cluster, true
}
func (c Config) Environment(name string) (Environment, error) {
for _, environment := range c.Environments {
if environment.Name == name {
return environment, nil
}
}
return Cluster{}, false
return Environment{}, errors.Errorf("given environment not found: %s", name)
}
func (c Config) ClusterNames() []string {
var ret []string
for _, account := range c.Environments {
for _, cluster := range account.Clusters {
ret = append(ret, cluster.Name)
}
func (c Config) EnvironmentNames() []string {
ret := make([]string, len(c.Environments))
for i, environment := range c.Environments {
ret[i] = environment.Name
}
return ret
}
func (c Config) ClusterNamesForEnv(envName string) []string {
for _, env := range c.Environments {
if env.Name == envName {
names := make([]string, len(env.Clusters))
for idx, cluster := range env.Clusters {
names[idx] = cluster.Name
}
return names
}
func (c Config) AllEnvironmentsClusterNames() []string {
var ret []string
for _, environment := range c.Environments {
ret = append(ret, environment.ClusterNames()...)
}
return nil
return ret
}

View File

@ -1,6 +1,28 @@
package gcloud
import (
"github.com/pkg/errors"
)
type Environment struct {
Name string `json:"name" yaml:"name"`
Project string `json:"project" yaml:"project"`
Clusters []Cluster `json:"clusters" yaml:"clusters"`
}
func (e Environment) Cluster(name string) (Cluster, error) {
for _, cluster := range e.Clusters {
if cluster.Name == name {
return cluster, nil
}
}
return Cluster{}, errors.Errorf("given cluster not found: %s", name)
}
func (e Environment) ClusterNames() []string {
ret := make([]string, len(e.Clusters))
for i, cluster := range e.Clusters {
ret[i] = cluster.Name
}
return ret
}

View File

@ -1,17 +1,17 @@
package gcloud
import (
"context"
"fmt"
"path/filepath"
"os"
"path"
"regexp"
"github.com/foomo/posh/pkg/shell"
"github.com/foomo/posh/pkg/env"
"github.com/pkg/errors"
"github.com/foomo/posh/pkg/cache"
"github.com/foomo/posh/pkg/log"
"github.com/foomo/posh/pkg/util/files"
"github.com/pkg/errors"
"github.com/spf13/viper"
)
@ -71,8 +71,14 @@ func New(l log.Logger, cache cache.Cache, opts ...Option) (*GCloud, error) {
}
}
if err := files.MkdirAll(inst.cfg.ConfigDir); err != nil {
return nil, errors.Wrapf(err, "failed to create directory %q", inst.cfg.ConfigDir)
// ensure config path
if err := files.MkdirAll(inst.cfg.ConfigPath); err != nil {
return nil, errors.Wrapf(err, "failed to create directory %q", inst.cfg.ConfigPath)
}
// set config path to encapsuplte any mishaps global gcloud usage!
if err := os.Setenv("CLOUDSDK_CONFIG", path.Join(os.Getenv(env.ProjectRoot), inst.cfg.ConfigPath)); err != nil {
return nil, err
}
return inst, nil
@ -82,68 +88,10 @@ func New(l log.Logger, cache cache.Cache, opts ...Option) (*GCloud, error) {
// ~ Public methods
// ------------------------------------------------------------------------------------------------
func (gc *GCloud) ParseAccounts(ctx context.Context) ([]Account, error) {
accountFiles, err := files.Find(ctx, gc.cfg.ConfigDir, "*.json")
if err != nil {
return nil, err
}
var accounts []Account
for _, f := range accountFiles {
matchString := gc.accountFileNameRegex.FindAllStringSubmatch(filepath.Base(f), 1)
if len(matchString) == 0 {
continue
}
match := matchString[0]
acc := Account{
Role: match[1],
Environment: match[2],
Cluster: match[3],
Path: f,
}
accounts = append(accounts, acc)
}
return accounts, err
}
func (gc *GCloud) FindAccounts(ctx context.Context, env, cluster string) ([]Account, error) {
accounts, err := gc.ParseAccounts(ctx)
if err != nil {
return nil, err
}
filtered := accounts[:0]
for _, acc := range accounts {
if acc.Environment == env && acc.Cluster == cluster {
filtered = append(filtered, acc)
}
}
if len(filtered) == 0 {
return nil, fmt.Errorf("account not found for cluster %q and env %q", cluster, env)
}
return filtered, nil
}
func (gc *GCloud) GenerateToken(ctx context.Context, env, cluster string) (string, error) {
accounts, err := gc.FindAccounts(ctx, env, cluster)
if err != nil {
return "", err
}
if len(accounts) > 1 {
gc.l.Warnf("multiple accounts found for env %q and cluster %q", env, cluster)
}
account := accounts[0]
out, err := shell.New(ctx, gc.l,
"gcloud", "auth", "application-default", "print-access-token").
Env("CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=" + account.Path).
Env("GOOGLE_APPLICATION_CREDENTIALS=" + account.Path).
Output()
if err != nil {
return "", err
}
return string(out), nil
func (p *GCloud) EnvWithAccessToken(env []string, accessTokenFilename string) []string {
return append(env,
fmt.Sprintf("GOOGLE_CREDENTIALS=%s", accessTokenFilename),
fmt.Sprintf("GOOGLE_APPLICATION_CREDENTIALS=%s", accessTokenFilename),
fmt.Sprintf("CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=%s", accessTokenFilename),
)
}

View File

@ -1,33 +0,0 @@
package gcloud_test
import (
"context"
"testing"
"github.com/foomo/posh-providers/google/gcloud"
"github.com/foomo/posh/pkg/cache"
"github.com/foomo/posh/pkg/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGCloud_ParseAccounts(t *testing.T) {
l := log.NewTest(t)
c := &cache.MemoryCache{}
inst, err := gcloud.New(l, c, gcloud.WithConfig(
&gcloud.Config{
ConfigDir: "testdata/accounts",
Environments: nil,
},
))
require.NoError(t, err)
accounts, err := inst.ParseAccounts(context.Background())
require.NoError(t, err)
require.Len(t, accounts, 1)
assert.Equal(t, "testdata/accounts/admin@prod-default.json", accounts[0].Path)
assert.Equal(t, "admin", accounts[0].Role)
assert.Equal(t, "prod", accounts[0].Environment)
assert.Equal(t, "default", accounts[0].Cluster)
}

View File

@ -1 +0,0 @@
chore