Skip to content
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ looks stale.

## Naming

- **`meshstack`** — the binary, so every invocation reads `meshstack auth login`.
- **`meshstack`** — the binary, so every invocation reads `meshstack login`.
- **meshStack CLI** — the product name, used in prose and docs.
- `github.com/meshcloud/meshstack-cli` — the repository and Go module.

Expand Down
52 changes: 0 additions & 52 deletions client/openapi/authentication.go

This file was deleted.

55 changes: 0 additions & 55 deletions client/openapi/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package openapi

import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"slices"
)

// object keeps the order of its members, which a map would lose, so that a part of the document
Expand Down Expand Up @@ -65,56 +63,3 @@ func (o object) get(name string) (jsontext.Value, bool) {
}
return nil, false
}

func (o object) without(name string) object {
return slices.DeleteFunc(o, func(m member) bool { return m.name == name })
}

// update replaces the object at name, where there is one.
func (o object) update(name string, f func(object) (object, error)) error {
for i, m := range o {
if m.name != name {
continue
}
var value object
if err := json.Unmarshal(m.value, &value); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
updated, err := f(value)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
if o[i].value, err = json.Marshal(updated); err != nil {
return err
}
}
return nil
}

// updateAll replaces each member that is an object, and leaves any other value as it is.
func (o object) updateAll(f func(object) (object, error)) error {
for _, m := range o {
if m.value.Kind() != '{' {
continue
}
if err := o.update(m.name, f); err != nil {
return err
}
}
return nil
}

// updateString replaces the string at name, where there is one.
func (o object) updateString(name string, f func(string) string) error {
for i, m := range o {
var value string
if m.name != name || json.Unmarshal(m.value, &value) != nil {
continue
}
var err error
if o[i].value, err = json.Marshal(f(value)); err != nil {
return err
}
}
return nil
}
7 changes: 1 addition & 6 deletions client/openapi/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,11 @@ func (c component) ref() string {
return "#/components/" + c.kind + "/" + c.name
}

// Parse leaves out what the document says about authentication, see withoutAuthentication.
func Parse(r io.Reader) (Spec, error) {
read, err := io.ReadAll(r)
document, err := io.ReadAll(r)
if err != nil {
return Spec{}, err
}
document, err := withoutAuthentication(read)
if err != nil {
return Spec{}, fmt.Errorf("cannot parse the OpenAPI document: %w", err)
}
var parsed struct {
Paths object `json:"paths"`
Components map[string]object `json:"components"`
Expand Down
24 changes: 0 additions & 24 deletions client/openapi/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json/jsontext"
"encoding/json/v2"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -190,29 +189,6 @@ func TestSpec(t *testing.T) {
})
}

func TestParseLeavesOutAuthentication(t *testing.T) {
spec, err := openapi.Parse(strings.NewReader(`{
"security": [{"oauth2": []}],
"paths": {
"/api/login": {"post": {"operationId": "apiKeyLoginResponse"}},
"/api/meshobjects": {"put": {
"operationId": "importInJson",
"description": "Imports meshObjects.\n\nIt therefore requires\nBasic Authentication with an API User.\n\n**Authentication:** This endpoint supports API User authentication.",
"security": [{"basic": []}]
}}
},
"components": {"schemas": {}, "securitySchemes": {"basic": {"type": "http", "scheme": "basic"}}}
}`))
require.NoError(t, err)

out, err := json.Marshal(spec)
require.NoError(t, err)
assert.JSONEq(t, `{
"paths": {"/api/meshobjects": {"put": {"operationId": "importInJson", "description": "Imports meshObjects."}}},
"components": {"schemas": {}}
}`, string(out))
}

func TestApiVersion(t *testing.T) {
versions := []string{"v1", "v2-preview", "v2", "v10"}
for i := range len(versions) - 1 {
Expand Down
2 changes: 1 addition & 1 deletion client/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type MeshWorkspaceListFilter struct {

type MeshWorkspaceClient interface {
// List returns every workspace the credential can see. An unscoped user token reaches this and
// almost nothing else, which is why `meshstack auth login` prompts for a workspace from it.
// almost nothing else, which is why `meshstack login` prompts for a workspace from it.
List(ctx context.Context) ([]MeshWorkspace, error)
Read(ctx context.Context, name string) (*MeshWorkspace, error)
Create(ctx context.Context, workspace *MeshWorkspaceCreate) (*MeshWorkspace, error)
Expand Down
1 change: 1 addition & 0 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func New() *cobra.Command {
cmd.AddCommand(newLogin())
cmd.AddCommand(newLogout())
cmd.AddCommand(newStatus())
cmd.AddCommand(newToken())

return cmd
}
36 changes: 36 additions & 0 deletions cmd/auth/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package auth

import (
"fmt"

"github.com/spf13/cobra"

"github.com/meshcloud/meshstack-cli/cmd/internal"
"github.com/meshcloud/meshstack-cli/internal/auth"
)

func newToken() *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Print an access token for the meshStack API",
Long: `Print the access token a command run with the same flags and environment would send, renewed
where it is about to expire. Nothing else goes to stdout, so $(meshstack auth token) works.

Paste it as the Bearer token in the API docs to try a request against your meshStack. The token is
short-lived: run this again once the API answers 401. For scripts, meshstack api renews the token by
itself. A browser login prints a token for the workspace this run resolves.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
session, err := auth.ResolveSession(cmd.Context(), internal.ResolveClientOptions())
if err != nil {
return err
}
token, err := session.GetBearerToken(cmd.Context())
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), token)
return err
},
}
}
57 changes: 30 additions & 27 deletions cmd/internal/testacc/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func TestAccBrowserLogin(t *testing.T) {
require.NoErrorf(t, run.wait(), "the browser login did not finish:\n%s", run.output.String())
})
t.Run("every GET operation answers the organization admin", everyGetOperationAnswers(c, false))
t.Run("--apitoken logs in with the token auth token prints", apiTokenLogsIn(c))
t.Run("tfstate of a browser login says that it takes an API key", tfstateTakesAnApiKey(c, apiKey))
}

Expand Down Expand Up @@ -172,19 +173,7 @@ func TestAccApiKeyLogin(t *testing.T) {
requireAuthStatus(t, c, "API key")
})

// Reading the token the API key login cached back off disk is the only way to reach --apitoken
// without minting a token, and a configuration directory is writable in CI too.
t.Run("--apitoken logs in with the token the API key login cached", func(t *testing.T) {
withToken := newCLI(t, endpoint)
withToken.setEnv(setting.ApiToken.EnvKey(), cachedApiKeyToken(t, c))

output, err := withToken.run("", "login", "--apitoken")
require.NoErrorf(t, err, "the API token login did not finish:\n%s", output)
assert.Contains(t, output, "| meshStack | ", "the login shows the status, with the meshStack it reached")
require.FileExists(t, withToken.credentialsJson())
requireAuthStatus(t, withToken, "API token")
})

t.Run("--apitoken logs in with the token auth token prints", apiTokenLogsIn(c))
t.Run("a listing keeps to the profile's default workspace", listingKeepsToTheDefaultWorkspace(c))
if workspace := c.workspaceHoldingABuildingBlock(t); workspace != "" {
c.setEnv(envWorkspace, workspace)
Expand All @@ -207,6 +196,34 @@ func (c *cli) withApiKey() *cli {
return c
}

// apiTokenLogsIn sends the token of c's login as an API token, as someone does who pastes it into
// the API docs.
func apiTokenLogsIn(c *cli) func(*testing.T) {
return func(t *testing.T) {
token := c.authToken(t)
withToken := newCLI(t, c.endpoint)
withToken.setEnv(setting.ApiToken.EnvKey(), token)

output, err := withToken.run("", "login", "--apitoken")
require.NoErrorf(t, err, "the API token login did not finish:\n%s", output)
assert.Contains(t, output, "| meshStack | ", "the login shows the status, with the meshStack it reached")
require.FileExists(t, withToken.credentialsJson())
requireAuthStatus(t, withToken, "API token")
output, err = withToken.run("", "workspace", "list", "-o", "ndjson")
require.NoErrorf(t, err, "the API refused the token:\n%s", output)
assert.Equal(t, token, withToken.authToken(t), "an API token is printed as it was given")
}
}

func (c *cli) authToken(t *testing.T) string {
t.Helper()
run := c.start("", "auth", "token")
require.NoErrorf(t, run.wait(), "meshstack auth token failed:\n%s", run.output.String())
token, oneLine := strings.CutSuffix(run.stdout.String(), "\n")
require.Truef(t, oneLine && !strings.Contains(token, "\n"), "a script reads stdout as the token, but it holds:\n%s", run.stdout.String())
return token
}

// requireAuthStatus does not check whether this meshStack lets an API key read itself through
// /self yet, so it accepts the warnings about the key's details.
func requireAuthStatus(t *testing.T, c *cli, wantCredential string) {
Expand All @@ -231,20 +248,6 @@ func requireStoredLogin(t *testing.T, c *cli, output string) {
}
}

func cachedApiKeyToken(t *testing.T, c *cli) string {
t.Helper()
content, err := os.ReadFile(c.credentialsCacheJson("apiKey"))
require.NoError(t, err)
var cacheFile struct {
Cache struct {
Token string `json:"token"`
} `json:"cache"`
}
require.NoError(t, json.Unmarshal(content, &cacheFile))
require.NotEmpty(t, cacheFile.Cache.Token, "the API key login cached no token to log in with")
return cacheFile.Cache.Token
}

func startLogin(t *testing.T, c *cli, workspaceAnswer string) *cliRun {
t.Helper()
return c.start(workspaceAnswer+"\n", "login")
Expand Down
6 changes: 4 additions & 2 deletions cmd/internal/testacc/testacc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package testacc
import (
"context"
"encoding/json/v2"
goio "io"
"log/slog"
gohttp "net/http"
"os"
Expand Down Expand Up @@ -139,6 +140,7 @@ func newRootCommand() *cobra.Command {
// binary would write to stdout and stderr, and the log, in the order it was written.
type cliRun struct {
output *syncBuffer
stdout *syncBuffer
cancel context.CancelFunc
finished chan struct{}
err error
Expand All @@ -156,15 +158,15 @@ func (c *cli) start(stdin string, args ...string) *cliRun {
c.t.Helper()
c.applyEnv()
ctx, cancel := context.WithCancel(c.t.Context())
run := &cliRun{output: &syncBuffer{}, cancel: cancel, finished: make(chan struct{})}
run := &cliRun{output: &syncBuffer{}, stdout: &syncBuffer{}, cancel: cancel, finished: make(chan struct{})}
previous := slog.Default()
c.t.Cleanup(func() { slog.SetDefault(previous) })
slog.SetDefault(slog.New(slog.NewTextHandler(run.output, nil)))

cmd := newRootCommand()
cmd.SetArgs(args)
cmd.SetIn(strings.NewReader(stdin))
cmd.SetOut(run.output)
cmd.SetOut(goio.MultiWriter(run.output, run.stdout))
cmd.SetErr(run.output)
go func() {
defer close(run.finished)
Expand Down
2 changes: 1 addition & 1 deletion cmd/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func New() *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage the stored profiles",
Long: `Manage the profiles that meshstack auth login stores, each an endpoint with its credential and
Long: `Manage the profiles that meshstack login stores, each an endpoint with its credential and
default workspace.

On a terminal, this lists the profiles to add, edit, delete, or make the current one. Elsewhere it
Expand Down
4 changes: 2 additions & 2 deletions internal/profile/name.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ var NameSetting = setting.Setting[Name]{
Long: func(envKey string) string {
return fmt.Sprintf("The profile whose credentials and defaults this run uses, also read from `%s`.\n\n"+
"A profile is a named bundle of endpoint and credential, written by "+
"`meshstack auth login` into the meshStack CLI's configuration directory. It supplies each of those "+
"`meshstack login` into the meshStack CLI's configuration directory. It supplies each of those "+
"only where nothing above it did, so it is never an override.\n\n"+
"With no name given, the profile is the one whose endpoint matches the endpoint in use, else the one "+
"the last `meshstack auth login` selected, else `default`.", envKey)
"the last `meshstack login` selected, else `default`.", envKey)
},
Default: setting.StaticDefault("default"),
Parse: setting.ParseTextUnmarshaler[Name],
Expand Down
Loading