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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ See the [Installation Guide](https://www.datum.net/docs/quickstart/datumctl/) fo
```bash
datumctl login
```
Opens your browser for authentication, then fetches your organizations and projects and prompts you to pick a default context. If you only have a single project, the picker is skipped.
Opens your browser for authentication, then fetches your organizations and projects and prompts you to pick a default context. If you only have a single project, the picker is skipped. To skip it yourself, name the context: `datumctl login --project my-org/my-project`.

2. **Work with resources:**
```bash
Expand Down Expand Up @@ -75,6 +75,8 @@ datumctl login --no-browser

It prints a short code and a URL to open on any device.

Coding agents and other tools that run `datumctl` without a terminal can sign a person in too: `datumctl login --project <project>` (or with `DATUM_PROJECT` set) chooses the context without the picker. With no context named and no terminal, login still signs in and saves the session, and leaves the context for `datumctl ctx use <org>/<project>`.

To run one command as another signed-in account without switching the active one, name its session with `--session` or `DATUM_SESSION`. The value is a session name (`email@api-host`) or an email signed in on only one endpoint. The command uses that session's last context unless you pass a scope:

```bash
Expand Down
19 changes: 18 additions & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ keyring.
To authenticate with Datum Cloud, use the `login` command:

```
datumctl login [--hostname <auth-hostname>] [--no-browser] [--credentials <file>] [-v]
datumctl login [--hostname <auth-hostname>] [--no-browser] [--credentials <file>] [--project <project> | --organization <org>] [-v]
```

* `--hostname <auth-hostname>`: (Optional) Specify the hostname of the Datum
Expand All @@ -37,6 +37,10 @@ datumctl login [--hostname <auth-hostname>] [--no-browser] [--credentials <file>
* `--credentials <file>`: (Optional) Sign in as a service account using its
credentials JSON file. Non-interactive; see
[Service accounts](#service-accounts-ci-and-automation).
* `--project <project>` / `--organization <org>`: (Optional) The context to
switch to after signing in, instead of choosing one in the picker. A
project can be given as `<org>/<project>`. `DATUM_PROJECT` and
`DATUM_ORGANIZATION` work the same way when neither flag is set.
* `-v, --verbose`: (Optional) Print the full ID token claims after successful
login.

Expand All @@ -54,6 +58,19 @@ Running this command will:
Your credentials (including refresh tokens) are stored securely in the system
keyring, associated with your user identifier (typically your email address).

### Choosing a context

After signing in, `datumctl login` switches to a default context:

* With `--project`, `--organization`, `DATUM_PROJECT` or `DATUM_ORGANIZATION`,
it switches to that context. If none of your organizations or projects
match, login keeps the session and exits with an error naming the value.
* With a single organization and no projects, it switches to that one.
* Otherwise it shows a picker. The picker needs an interactive terminal.
Without one (a coding agent's shell, a script), login still signs you in
and saves the session, but leaves the context unset. Choose one afterwards
with `datumctl ctx use <org>/<project>`; `datumctl ctx` lists them.

## Service accounts (CI and automation)

A service account is a non-human identity for CI pipelines, scheduled jobs,
Expand Down
15 changes: 15 additions & 0 deletions docs/developer/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,28 @@ Plugins listed there can be installed by name:

```sh
datumctl plugin install compute
datumctl plugin install --all-available
datumctl plugin search
datumctl plugin search --index datum -o json
datumctl plugin list
datumctl plugin upgrade compute
datumctl plugin upgrade --all
datumctl plugin remove compute
```

`plugin install --all-available` installs every plugin in the official `datum`
catalog, or in the catalog named by `--index`. It upgrades plugins that are
behind the catalog, leaves up-to-date ones alone, and skips a plugin whose name
is already installed from another catalog or from GitHub rather than replace
it. A plugin that fails is reported at the end without stopping the rest.
With a third-party catalog it first lists the plugins it would install and asks
to confirm; pass `--yes` to skip the prompt. Without a terminal and without
`--yes`, it exits with an error rather than install anything.

`plugin search -o json` prints an array of `{name, index, version, trust,
description}` objects, and `-o name` prints one plugin name per line, for
scripts and agents.

`plugin upgrade` reinstalls a plugin from the catalog or GitHub repository it
was installed from, skipping any plugin already at its latest version. With
`--all`, every installed plugin is upgraded; a plugin that fails is reported
Expand Down
96 changes: 95 additions & 1 deletion internal/cmd/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package login

import (
"fmt"
"os"
"strings"

"github.com/pkg/browser"
Expand Down Expand Up @@ -36,6 +37,12 @@ After authentication you will be prompted to select a default context (org
or project) so subsequent commands do not require --project or --organization
flags.

Pass --project or --organization (or set DATUM_PROJECT / DATUM_ORGANIZATION)
to choose the context without the prompt. Without a terminal and without
either, login still signs you in but leaves the context unset; pick one
afterwards with 'datumctl ctx use <org>/<project>'. Coding agents and other
tools that run datumctl without a terminal can use either route.

By default, opens your browser for OAuth2 PKCE authentication. Use
--no-browser on a machine without a browser (SSH, containers) to sign in
with a device code from another device.
Expand All @@ -54,6 +61,10 @@ and ignores DATUM_SESSION.`,
Example: ` # Log in (opens browser, then picks a context)
datumctl login

# Log in straight into a project, with no context prompt
datumctl login --project my-project
datumctl login --project my-org/my-project

# CI / automation: log in as a service account, then scope each command
datumctl login --credentials ./key.json
DATUM_PROJECT=my-project datumctl get dnszones
Expand Down Expand Up @@ -162,10 +173,24 @@ func runLogin(cmd *cobra.Command, _ []string) error {
}
fmt.Println()

selected, err := picker.SelectContext(dir, cfg.CurrentContextName())
selected, err := chooseContext(dir, cfg.CurrentContextName(), requestedContext(cmd, os.Getenv), picker.IsTerminal())
if err != nil {
// The sign-in itself worked, so keep the session even though no
// context was chosen.
if saveErr := saveWithoutContext(cfg, sessionName); saveErr != nil {
return saveErr
}
return err
}
if selected == nil {
if err := saveWithoutContext(cfg, sessionName); err != nil {
return err
}
fmt.Println("Signed in. No context selected: choosing one needs an interactive terminal.")
fmt.Println("Run 'datumctl ctx use <org>/<project>' to pick one ('datumctl ctx' lists them),")
fmt.Println("or sign in with 'datumctl login --project <project>' next time.")
return nil
}

onboardingResult, err := onboarding.CheckOrg(
ctx,
Expand Down Expand Up @@ -199,6 +224,75 @@ func runLogin(cmd *cobra.Command, _ []string) error {
return nil
}

// requestedContext returns the organization or project the caller asked to
// work in: --project or --organization, or, when neither flag is set,
// DATUM_PROJECT and DATUM_ORGANIZATION. With both environment variables set it
// returns "org/project". It returns "" when nothing was requested.
func requestedContext(cmd *cobra.Command, getenv func(string) string) string {
flagValue := func(name string) string {
if f := cmd.Flag(name); f != nil {
return f.Value.String()
}
return ""
}
project, org := flagValue("project"), flagValue("organization")
if project == "" && org == "" {
project, org = getenv("DATUM_PROJECT"), getenv("DATUM_ORGANIZATION")
}
switch {
case org != "" && project != "":
return org + "/" + project
case project != "":
return project
default:
return org
}
}

// chooseContext picks the context login switches to. A requested context must
// match one the user can reach. Otherwise a lone context is chosen
// automatically, and with several the user picks one, if there is a terminal
// to show the picker in. Without one it returns nil and no error, so login can
// keep the session and tell the user how to choose a context.
func chooseContext(dir *discovery.Directory, current, requested string, interactive bool) (*datumconfig.DiscoveredContext, error) {
if requested != "" {
if c := dir.Resolve(requested); c != nil {
return c, nil
}
return nil, customerrors.NewUserErrorWithHint(
fmt.Sprintf("Signed in, but no organization or project matches %q.", requested),
"Run 'datumctl ctx' to list the ones you can use, then 'datumctl ctx use <org>/<project>'.",
)
}
if contexts := dir.Contexts(); len(contexts) == 1 {
return &contexts[0], nil
}
if !interactive {
return nil, nil
}
return picker.SelectContext(dir, current)
}

// saveWithoutContext saves the config after a sign-in that chose no context.
// A current context from another session is cleared, because the current
// context decides which session is active and the new session should be.
func saveWithoutContext(cfg *datumconfig.ConfigV1Beta1, sessionName string) error {
clearOtherSessionContext(cfg, sessionName)
if err := datumconfig.SaveV1Beta1(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}

// clearOtherSessionContext unsets the current context when it belongs to a
// session other than sessionName. A context of the same session (signing in
// again) is kept.
func clearOtherSessionContext(cfg *datumconfig.ConfigV1Beta1, sessionName string) {
if cur := cfg.ContextByName(cfg.CurrentContext); cur != nil && cur.Session != sessionName {
cfg.CurrentContext = ""
}
}

func deriveAuthHostname(apiHostname string) (string, error) {
if rest, ok := strings.CutPrefix(apiHostname, "api."); ok {
return "auth." + rest, nil
Expand Down
160 changes: 160 additions & 0 deletions internal/cmd/login/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package login

import (
"testing"

"github.com/spf13/cobra"

"go.datum.net/datumctl/internal/datumconfig"
"go.datum.net/datumctl/internal/discovery"
)

func testDirectory() *discovery.Directory {
return &discovery.Directory{
Session: "alice@example.com@api.datum.net",
Orgs: []discovery.DiscoveredOrg{
{Name: "acme", DisplayName: "Acme"},
{Name: "globex", DisplayName: "Globex"},
},
Projects: []discovery.DiscoveredProject{
{Name: "acme-web", DisplayName: "Web", OrgName: "acme"},
{Name: "acme-api", DisplayName: "API", OrgName: "acme"},
{Name: "globex-web", DisplayName: "Web", OrgName: "globex"},
},
}
}

func TestChooseContext(t *testing.T) {
t.Parallel()

tests := []struct {
name string
dir *discovery.Directory
requested string
interactive bool
wantRef string
wantErr bool
}{
{name: "project by id", dir: testDirectory(), requested: "acme-api", wantRef: "acme/acme-api"},
{name: "org/project", dir: testDirectory(), requested: "globex/globex-web", wantRef: "globex/globex-web"},
{name: "org only", dir: testDirectory(), requested: "acme", wantRef: "acme"},
{name: "requested works without a terminal", dir: testDirectory(), requested: "acme/acme-web", interactive: false, wantRef: "acme/acme-web"},
{name: "unknown requested context errors", dir: testDirectory(), requested: "nope", wantErr: true},
{name: "ambiguous display name errors", dir: testDirectory(), requested: "Web", wantErr: true},
{name: "no terminal and nothing requested selects nothing", dir: testDirectory(), interactive: false, wantRef: ""},
{
name: "a lone context is chosen without a terminal",
dir: &discovery.Directory{
Session: "bob@example.com@api.datum.net",
Orgs: []discovery.DiscoveredOrg{{Name: "solo"}},
},
interactive: false,
wantRef: "solo",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := chooseContext(tt.dir, "", tt.requested, tt.interactive)
if tt.wantErr {
if err == nil {
t.Fatalf("expected an error, got context %+v", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
gotRef := ""
if got != nil {
gotRef = got.Ref()
}
if gotRef != tt.wantRef {
t.Fatalf("chooseContext() = %q, want %q", gotRef, tt.wantRef)
}
})
}
}

// loginCmdWithScopeFlags mirrors how login is mounted: --project and
// --organization are persistent flags on the root command.
func loginCmdWithScopeFlags(t *testing.T, args ...string) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "datumctl"}
root.PersistentFlags().String("project", "", "")
root.PersistentFlags().String("organization", "", "")
login := &cobra.Command{Use: "login", RunE: func(*cobra.Command, []string) error { return nil }}
root.AddCommand(login)
root.SetArgs(append([]string{"login"}, args...))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
return login
}

func TestRequestedContext(t *testing.T) {
t.Parallel()

env := func(vars map[string]string) func(string) string {
return func(k string) string { return vars[k] }
}

tests := []struct {
name string
args []string
env map[string]string
want string
}{
{name: "nothing", want: ""},
{name: "project flag", args: []string{"--project", "acme-web"}, want: "acme-web"},
{name: "project flag with org prefix", args: []string{"--project", "acme/acme-web"}, want: "acme/acme-web"},
{name: "organization flag", args: []string{"--organization", "acme"}, want: "acme"},
{name: "project env", env: map[string]string{"DATUM_PROJECT": "acme-web"}, want: "acme-web"},
{name: "organization env", env: map[string]string{"DATUM_ORGANIZATION": "acme"}, want: "acme"},
{name: "both env", env: map[string]string{"DATUM_ORGANIZATION": "acme", "DATUM_PROJECT": "acme-web"}, want: "acme/acme-web"},
{name: "flag beats env", args: []string{"--project", "acme-api"}, env: map[string]string{"DATUM_PROJECT": "acme-web"}, want: "acme-api"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := loginCmdWithScopeFlags(t, tt.args...)
if got := requestedContext(cmd, env(tt.env)); got != tt.want {
t.Fatalf("requestedContext() = %q, want %q", got, tt.want)
}
})
}
}

func TestClearOtherSessionContext(t *testing.T) {
t.Parallel()

newCfg := func() *datumconfig.ConfigV1Beta1 {
cfg := &datumconfig.ConfigV1Beta1{}
ctx := datumconfig.DiscoveredContext{Session: "old@example.com@api.datum.net", OrganizationID: "acme"}
ctx.Name = ctx.QualifiedName()
cfg.UpsertContext(ctx)
cfg.CurrentContext = ctx.Name
return cfg
}

t.Run("another session's context is cleared", func(t *testing.T) {
t.Parallel()
cfg := newCfg()
clearOtherSessionContext(cfg, "new@example.com@api.datum.net")
if cfg.CurrentContext != "" {
t.Fatalf("CurrentContext = %q, want it cleared", cfg.CurrentContext)
}
})

t.Run("the same session's context is kept", func(t *testing.T) {
t.Parallel()
cfg := newCfg()
want := cfg.CurrentContext
clearOtherSessionContext(cfg, "old@example.com@api.datum.net")
if cfg.CurrentContext != want {
t.Fatalf("CurrentContext = %q, want %q", cfg.CurrentContext, want)
}
})
}
Loading
Loading