Skip to content

Commit

Permalink
feat: nctl auth login in non-interactive env and without set token sh…
Browse files Browse the repository at this point in the history
…ould not block

If you execute `nctl auth login` in a non-interactive environment (e.g. CI/CD)
and forget to set `--api-token`, the browser should not try to open - you
should see an error message instead.
  • Loading branch information
pawelkuc committed Dec 16, 2024
1 parent b2bb0f2 commit c1b8813
Show file tree
Hide file tree
Showing 15 changed files with 147 additions and 66 deletions.
7 changes: 7 additions & 0 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"context"
"errors"
"fmt"
"net/url"
"os"
Expand All @@ -24,6 +25,8 @@ type LoginCmd struct {
ClientID string `help:"Client ID is the OIDC client ID of the API." default:"nineapis.ch-f178254"`
}

const ErrNonInteractiveEnvironmentEptyToken = "a static API token is required in non-interactive envirtonments"

func (l *LoginCmd) Run(ctx context.Context, command string, tk api.TokenGetter) error {
apiURL, err := url.Parse(l.APIURL)
if err != nil {
Expand Down Expand Up @@ -58,6 +61,10 @@ func (l *LoginCmd) Run(ctx context.Context, command string, tk api.TokenGetter)
return login(ctx, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(l.Organization))
}

if !format.IsInteractiveEnvironment(ctx, os.Stdout) {
return errors.New(ErrNonInteractiveEnvironmentEptyToken)
}

usePKCE := true

token, err := tk.GetTokenString(ctx, l.IssuerURL, l.ClientID, usePKCE)
Expand Down
124 changes: 88 additions & 36 deletions auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import (
"path"
"testing"

"github.com/ninech/nctl/api"
"github.com/ninech/nctl/internal/format"
"github.com/ninech/nctl/internal/test"
"github.com/stretchr/testify/require"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)
Expand All @@ -19,7 +22,19 @@ func (f *fakeTokenGetter) GetTokenString(ctx context.Context, issuerURL, clientI
return test.FakeJWTToken, nil
}

func checkErrorRequire(t *testing.T, err error, expectError bool, expectedErrMsg string) {
t.Helper()
if expectError {
require.Error(t, err, "expected an error but got none")
require.EqualError(t, err, expectedErrMsg, "unexpected error message")
} else {
require.NoError(t, err, "expected no error but got one")
}
}

func TestLoginCmd(t *testing.T) {
ctx := format.WithForceInteractiveEnvironment(context.Background(), true)

// write our "existing" kubeconfig to a temp kubeconfig
kubeconfig, err := os.CreateTemp("", "*-kubeconfig.yaml")
if err != nil {
Expand All @@ -39,7 +54,7 @@ func TestLoginCmd(t *testing.T) {
APIURL: "https://" + apiHost,
IssuerURL: "https://auth.example.org",
}
if err := cmd.Run(context.Background(), "", tk); err != nil {
if err := cmd.Run(ctx, "", tk); err != nil {
t.Fatal(err)
}

Expand All @@ -58,46 +73,83 @@ func TestLoginCmd(t *testing.T) {
}

func TestLoginStaticToken(t *testing.T) {
kubeconfig, err := os.CreateTemp("", "*-kubeconfig.yaml")
if err != nil {
log.Fatal(err)
}
defer os.Remove(kubeconfig.Name())

os.Setenv(clientcmd.RecommendedConfigPathEnvVar, kubeconfig.Name())

apiHost := "api.example.org"
token := test.FakeJWTToken

cmd := &LoginCmd{APIURL: "https://" + apiHost, APIToken: token, Organization: "test"}
tk := &fakeTokenGetter{}
if err := cmd.Run(context.Background(), "", tk); err != nil {
t.Fatal(err)
}

// read out the kubeconfig again to test the contents
b, err := io.ReadAll(kubeconfig)
if err != nil {
t.Fatal(err)
}

kc, err := clientcmd.Load(b)
if err != nil {
t.Fatal(err)
}

checkConfig(t, kc, 1, "")

if token != kc.AuthInfos[apiHost].Token {
t.Fatalf("expected token to be %s, got %s", token, kc.AuthInfos[apiHost].Token)
}

if kc.AuthInfos[apiHost].Exec != nil {
t.Fatalf("expected execConfig to be empty, got %v", kc.AuthInfos[apiHost].Exec)
tests := []struct {
name string
ctx context.Context
cmd *LoginCmd
tk api.TokenGetter
wantToken string
wantErr bool
wantErrMessage string
}{
{
name: "interactive environment with token",
ctx: format.WithForceInteractiveEnvironment(context.Background(), true),
cmd: &LoginCmd{APIURL: "https://" + apiHost, APIToken: test.FakeJWTToken, Organization: "test"},
tk: &fakeTokenGetter{},
wantToken: test.FakeJWTToken,
},
{
name: "non-interactive environment with token",
ctx: context.Background(),
cmd: &LoginCmd{APIURL: "https://" + apiHost, APIToken: test.FakeJWTToken, Organization: "test"},
tk: &fakeTokenGetter{},
wantToken: test.FakeJWTToken,
},
{
name: "non-interactive environment with empty token",
ctx: context.Background(),
cmd: &LoginCmd{APIURL: "https://" + apiHost, APIToken: "", Organization: "test"},
wantErr: true,
wantErrMessage: ErrNonInteractiveEnvironmentEptyToken,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
kubeconfig, err := os.CreateTemp("", "*-kubeconfig.yaml")
if err != nil {
log.Fatal(err)
}
defer os.Remove(kubeconfig.Name())
os.Setenv(clientcmd.RecommendedConfigPathEnvVar, kubeconfig.Name())

err = tt.cmd.Run(tt.ctx, "", tt.tk)

Check failure on line 119 in auth/login_test.go

View workflow job for this annotation

GitHub Actions / lint

loopclosure: loop variable tt captured by func literal (govet)
checkErrorRequire(t, err, tt.wantErr, tt.wantErrMessage)

Check failure on line 120 in auth/login_test.go

View workflow job for this annotation

GitHub Actions / lint

loopclosure: loop variable tt captured by func literal (govet)

if tt.wantErr {

Check failure on line 122 in auth/login_test.go

View workflow job for this annotation

GitHub Actions / lint

loopclosure: loop variable tt captured by func literal (govet)
return
}

// read out the kubeconfig again to test the contents
b, err := io.ReadAll(kubeconfig)
if err != nil {
t.Fatal(err)
}

kc, err := clientcmd.Load(b)
if err != nil {
t.Fatal(err)
}

checkConfig(t, kc, 1, "")

if tt.wantToken != kc.AuthInfos[apiHost].Token {
t.Fatalf("expected token to be %s, got %s", tt.wantToken, kc.AuthInfos[apiHost].Token)
}

if kc.AuthInfos[apiHost].Exec != nil {
t.Fatalf("expected execConfig to be empty, got %v", kc.AuthInfos[apiHost].Exec)
}
})
}
}

func TestLoginCmdWithoutExistingKubeconfig(t *testing.T) {
ctx := format.WithForceInteractiveEnvironment(context.Background(), true)

dir, err := os.MkdirTemp("", "nctl-test-*")
if err != nil {
t.Fatal(err)
Expand All @@ -113,7 +165,7 @@ func TestLoginCmdWithoutExistingKubeconfig(t *testing.T) {
IssuerURL: "https://auth.example.org",
}
tk := &fakeTokenGetter{}
if err := cmd.Run(context.Background(), "", tk); err != nil {
if err := cmd.Run(ctx, "", tk); err != nil {
t.Fatal(err)
}

Expand Down
2 changes: 1 addition & 1 deletion get/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (cmd *allCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error
case noHeader:
return printItems(items, *get, defaultOut(cmd.out), false)
case yamlOut:
return format.PrettyPrintObjects(items, format.PrintOpts{Out: cmd.out})
return format.PrettyPrintObjects(ctx, items, format.PrintOpts{Out: cmd.out})
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion get/apiserviceaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (asa *apiServiceAccountsCmd) Run(ctx context.Context, client *api.Client, g
case noHeader:
return asa.print(asaList.Items, get, false)
case yamlOut:
return format.PrettyPrintObjects(asaList.GetItems(), format.PrintOpts{})
return format.PrettyPrintObjects(ctx, asaList.GetItems(), format.PrintOpts{})
}

return nil
Expand Down
14 changes: 7 additions & 7 deletions get/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ func (cmd *applicationsCmd) Run(ctx context.Context, client *api.Client, get *Cm
fmt.Fprintf(defaultOut(cmd.out), "no application with basic auth enabled found\n")
return err
}
if printErr := printCredentials(creds, get, defaultOut(cmd.out)); printErr != nil {
if printErr := printCredentials(ctx, creds, get, defaultOut(cmd.out)); printErr != nil {
err = multierror.Append(err, printErr)
}
return err
}

if cmd.DNS {
return printDNSDetails(util.GatherDNSDetails(appList.Items), get, defaultOut(cmd.out))
return printDNSDetails(ctx, util.GatherDNSDetails(appList.Items), get, defaultOut(cmd.out))
}

switch get.Output {
Expand All @@ -59,7 +59,7 @@ func (cmd *applicationsCmd) Run(ctx context.Context, client *api.Client, get *Cm
case noHeader:
return printApplication(appList.Items, get, defaultOut(cmd.out), false)
case yamlOut:
return format.PrettyPrintObjects(appList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
return format.PrettyPrintObjects(ctx, appList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
case stats:
return cmd.printStats(ctx, client, appList.Items, get, defaultOut(cmd.out))
}
Expand Down Expand Up @@ -101,9 +101,9 @@ func printApplication(apps []apps.Application, get *Cmd, out io.Writer, header b
return w.Flush()
}

func printCredentials(creds []appCredentials, get *Cmd, out io.Writer) error {
func printCredentials(ctx context.Context, creds []appCredentials, get *Cmd, out io.Writer) error {
if get.Output == yamlOut {
return format.PrettyPrintObjects(creds, format.PrintOpts{Out: out})
return format.PrettyPrintObjects(ctx, creds, format.PrintOpts{Out: out})
}
return printCredentialsTabRow(creds, get, out)
}
Expand Down Expand Up @@ -162,9 +162,9 @@ func join(list []string) string {
return strings.Join(list, ",")
}

func printDNSDetails(items []util.DNSDetail, get *Cmd, out io.Writer) error {
func printDNSDetails(ctx context.Context, items []util.DNSDetail, get *Cmd, out io.Writer) error {
if get.Output == yamlOut {
return format.PrettyPrintObjects(items, format.PrintOpts{Out: out})
return format.PrettyPrintObjects(ctx, items, format.PrintOpts{Out: out})
}
return printDNSDetailsTabRow(items, get, out)
}
Expand Down
2 changes: 1 addition & 1 deletion get/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func (cmd *buildCmd) Run(ctx context.Context, client *api.Client, get *Cmd) erro
case noHeader:
return printBuild(buildList.Items, get, defaultOut(cmd.out), false)
case yamlOut:
return format.PrettyPrintObjects(buildList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
return format.PrettyPrintObjects(ctx, buildList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion get/cloudvm.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (cmd *cloudVMCmd) Run(ctx context.Context, client *api.Client, get *Cmd) er
case noHeader:
return cmd.printCloudVirtualMachineInstances(cloudVMList.Items, get, false)
case yamlOut:
return format.PrettyPrintObjects(cloudVMList.GetItems(), format.PrintOpts{})
return format.PrettyPrintObjects(ctx, cloudVMList.GetItems(), format.PrintOpts{})
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion get/clusters.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (l *clustersCmd) Run(ctx context.Context, client *api.Client, get *Cmd) err
case noHeader:
return printClusters(clusterList.Items, get, false)
case yamlOut:
return format.PrettyPrintObjects(clusterList.GetItems(), format.PrintOpts{})
return format.PrettyPrintObjects(ctx, clusterList.GetItems(), format.PrintOpts{})
case contexts:
for _, cluster := range clusterList.Items {
fmt.Printf("%s\n", config.ContextName(&cluster))
Expand Down
2 changes: 1 addition & 1 deletion get/keyvaluestore.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client, get *C
case noHeader:
return cmd.printKeyValueStoreInstances(keyValueStoreList.Items, get, false)
case yamlOut:
return format.PrettyPrintObjects(keyValueStoreList.GetItems(), format.PrintOpts{})
return format.PrettyPrintObjects(ctx, keyValueStoreList.GetItems(), format.PrintOpts{})
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion get/mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client, get *Cmd) erro
case noHeader:
return cmd.printMySQLInstances(mysqlList.Items, get, false)
case yamlOut:
return format.PrettyPrintObjects(mysqlList.GetItems(), format.PrintOpts{})
return format.PrettyPrintObjects(ctx, mysqlList.GetItems(), format.PrintOpts{})
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion get/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client, get *Cmd) e
case noHeader:
return cmd.printPostgresInstances(postgresList.Items, get, false)
case yamlOut:
return format.PrettyPrintObjects(postgresList.GetItems(), format.PrintOpts{})
return format.PrettyPrintObjects(ctx, postgresList.GetItems(), format.PrintOpts{})
}

return nil
Expand Down
1 change: 1 addition & 0 deletions get/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func (proj *projectCmd) Run(ctx context.Context, client *api.Client, get *Cmd) e
return printProject(projectList, *get, defaultOut(proj.out), false)
case yamlOut:
return format.PrettyPrintObjects(
ctx,
(&management.ProjectList{Items: projectList}).GetItems(),
format.PrintOpts{
Out: proj.out,
Expand Down
2 changes: 1 addition & 1 deletion get/project_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (cmd *configsCmd) Run(ctx context.Context, client *api.Client, get *Cmd) er
case noHeader:
return printProjectConfigs(projectConfigList.Items, get, defaultOut(cmd.out), false)
case yamlOut:
return format.PrettyPrintObjects(projectConfigList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
return format.PrettyPrintObjects(ctx, projectConfigList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion get/releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (cmd *releasesCmd) Run(ctx context.Context, client *api.Client, get *Cmd) e
case noHeader:
return cmd.printReleases(releaseList.Items, get, false)
case yamlOut:
return format.PrettyPrintObjects(releaseList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
return format.PrettyPrintObjects(ctx, releaseList.GetItems(), format.PrintOpts{Out: defaultOut(cmd.out)})
}

return nil
Expand Down
Loading

0 comments on commit c1b8813

Please sign in to comment.