From b7a94b1ccd8cc62dd1ab7842fdc177ba780ceb2f Mon Sep 17 00:00:00 2001 From: Gianluca Arbezzano Date: Mon, 9 Mar 2026 21:52:48 +0100 Subject: [PATCH] feat: add datumctl config, clusters and context This commit contains the foundation of being able to connect to multiple Datum clusters and to switch in between projects and orgs via contexts. --- README.md | 22 +- docs/authentication.md | 58 ++--- docs/developer/authentication_flow.md | 16 +- internal/authutil/cluster_credentials.go | 39 ++++ internal/authutil/context.go | 222 ++++++++++++++++++ internal/authutil/credentials.go | 59 ++++- internal/authutil/known_users.go | 46 ++++ internal/client/context_switch.go | 8 +- internal/client/factory.go | 135 +++++++++-- internal/client/factory_test.go | 112 ++++++++++ internal/client/user_context.go | 10 +- internal/cmd/auth/auth.go | 2 - internal/cmd/auth/get_token.go | 25 ++- internal/cmd/auth/list.go | 98 -------- internal/cmd/auth/login.go | 149 ++++++++----- internal/cmd/auth/logout.go | 5 +- internal/cmd/auth/switch.go | 81 ------- internal/cmd/auth/update-kubeconfig.go | 26 ++- internal/cmd/config/config.go | 29 +++ internal/cmd/config/get_contexts.go | 46 ++++ internal/cmd/config/set_cluster.go | 59 +++++ internal/cmd/config/set_context.go | 64 ++++++ internal/cmd/config/use_context.go | 48 ++++ internal/cmd/config/view.go | 28 +++ internal/cmd/docs/openapi.go | 8 +- internal/cmd/mcp/mcp.go | 42 +++- internal/cmd/root.go | 6 + internal/datumconfig/config.go | 273 +++++++++++++++++++++++ internal/datumconfig/config_test.go | 65 ++++++ 29 files changed, 1425 insertions(+), 356 deletions(-) create mode 100644 internal/authutil/cluster_credentials.go create mode 100644 internal/authutil/context.go create mode 100644 internal/authutil/known_users.go create mode 100644 internal/client/factory_test.go delete mode 100644 internal/cmd/auth/list.go delete mode 100644 internal/cmd/auth/switch.go create mode 100644 internal/cmd/config/config.go create mode 100644 internal/cmd/config/get_contexts.go create mode 100644 internal/cmd/config/set_cluster.go create mode 100644 internal/cmd/config/set_context.go create mode 100644 internal/cmd/config/use_context.go create mode 100644 internal/cmd/config/view.go create mode 100644 internal/datumconfig/config.go create mode 100644 internal/datumconfig/config_test.go diff --git a/README.md b/README.md index b3a2ec8..afcac1d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,26 @@ See the [Installation Guide](https://www.datum.net/docs/quickstart/datumctl/) fo ``` Now you can use `kubectl` to interact with your Datum Cloud control plane. +4. **Manage contexts and clusters (optional):** + ```bash + # Define a cluster (base API server URL) + datumctl config set-cluster prod --server https://api.datum.net + + # Create a context for a project and namespace + datumctl config set-context prod-project \ + --cluster prod \ + --project \ + --namespace default + + # Switch the current context + datumctl config use-context prod-project + + # View or list contexts + datumctl config view + datumctl config get-contexts + ``` + Contexts are stored in `~/.datumctl/config`. Credentials remain in the system keychain. + ### MCP Setup MCP can target either an **organization** or **project** control plane. For maximum flexibility, we recommend starting with an organization context. @@ -86,7 +106,7 @@ echo "Project ready: $PRJ_ID" Start the Model Context Protocol (MCP) server targeting a specific Datum Cloud context: ```bash -# Exactly one of --organization or --project is required. +# Exactly one of --organization or --project is required (unless a current context provides one). datumctl mcp --organization --namespace [--port 8080] # or datumctl mcp --project --namespace [--port 8080] diff --git a/docs/authentication.md b/docs/authentication.md index a313535..95e152e 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -11,11 +11,9 @@ API keys directly. Authentication involves the following commands: * `datumctl auth login` -* `datumctl auth list` * `datumctl auth logout` * `datumctl auth get-token` * `datumctl auth update-kubeconfig` -* `datumctl auth switch` Credentials and tokens are stored securely in your operating system's default keyring. @@ -47,6 +45,14 @@ 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). +On every successful login, `datumctl` also ensures a matching cluster/context +entry exists in `~/.datumctl/config` for the API host you authenticated against. +If a current context already exists, it remains unchanged. + +`datumctl` stores a list of users in `~/.datumctl/config` and links each context +to a user key (in the `subject@auth-hostname` format). The actual tokens are +stored in your OS keyring under the `datumctl-auth` service. + ## Updating kubeconfig Once logged in, you typically need to configure `kubectl` to authenticate to @@ -73,38 +79,8 @@ This command adds or updates the necessary cluster, user, and context entries in your kubeconfig file. The user entry will be configured to use `datumctl auth get-token --output=client.authentication.k8s.io/v1` as an `exec` credential plugin. This means `kubectl` commands targeting this cluster will -automatically use your active `datumctl` login session for authentication. - -## Listing logged-in users - -To see which users you have authenticated locally, use the `list` command: - -``` -datumctl auth list -# Alias: datumctl auth ls -``` - -This will output a table showing the Name, Email, and Status (Active or blank) -for each set of stored credentials. The user marked `Active` is the one whose -credentials will be used by default for other `datumctl` commands and -`kubectl` (if configured via `update-kubeconfig`). - -## Switching active user - -If you have logged in with multiple user accounts (visible via -`datumctl auth list`), you can switch which account is active using the -`switch` command: - -``` -datumctl auth switch -``` - -Replace `` with the email address of the user you want to make -active. This user must already be logged in. - -After switching, subsequent commands that require authentication (like -`datumctl organizations list` or `kubectl` operations configured via -`update-kubeconfig`) will use the credentials of the newly activated user. +automatically use the credentials associated with your current `datumctl` +context for authentication. ## Logging out @@ -113,11 +89,11 @@ To remove stored credentials, use the `logout` command. **Log out a specific user:** ``` -datumctl auth logout +datumctl auth logout ``` -Replace `` with the email address shown in the -`datumctl auth list` command. +Replace `` with the key shown in the `users` list in +`~/.datumctl/config`. Use `--all` to remove all credentials. **Log out all users:** @@ -129,12 +105,12 @@ This removes all Datum Cloud credentials stored by `datumctl` in your keyring. ## Getting tokens (advanced) -The `get-token` command retrieves the current access token for the *active* -authenticated user. This is primarily used internally by other tools (like +The `get-token` command retrieves the current access token for the credentials +associated with the current context. This is primarily used internally by other tools (like `kubectl`) but can be used directly if needed. ``` -datumctl auth get-token [-o ] +datumctl auth get-token [-o ] [--cluster ] ``` * `-o, --output `: (Optional) Specify the output format. Defaults to @@ -143,6 +119,8 @@ datumctl auth get-token [-o ] * `client.authentication.k8s.io/v1`: Prints a Kubernetes `ExecCredential` JSON object containing the ID token, suitable for `kubectl` authentication. +* `--cluster `: (Optional) Use credentials bound to the + specified datumctl cluster instead of the current context. If the stored access token is expired, `get-token` will attempt to use the refresh token to obtain a new one automatically. diff --git a/docs/developer/authentication_flow.md b/docs/developer/authentication_flow.md index ef9c5b7..f07d8bb 100644 --- a/docs/developer/authentication_flow.md +++ b/docs/developer/authentication_flow.md @@ -36,8 +36,9 @@ sequenceDiagram Datumctl->>DatumAuth: Verify ID Token signature & claims Datumctl->>Datumctl: Extract user info (email, name) from ID Token Datumctl->>OSKeyring: Store Credentials (Hostname, Tokens, User Info) keyed by user email - Datumctl->>OSKeyring: Update Active User pointer Datumctl->>OSKeyring: Update Known Users list + Datumctl->>OSKeyring: Bind credentials to active_user.cluster. + Datumctl->>Datumctl: Upsert cluster/context/user in ~/.datumctl/config (current-context unchanged if set) Datumctl-->>-User: Login Successful ``` @@ -81,10 +82,13 @@ sequenceDiagram endpoints, scopes, and user info, is marshalled to JSON and stored securely in the OS keyring. The key for this entry is the user's email address. -11. **Active User:** A pointer (`active_user`) is also stored in the keyring, - indicating which user's credentials should be used by default. -12. **Known Users:** The user's key (email) is added to a list (`known_users`) - in the keyring to facilitate listing and multi-user management. +11. **Cluster Mapping:** A cluster-specific pointer + (`active_user.cluster.`) is stored in the keyring, indicating which + user's credentials should be used for that cluster. +12. **Config Users:** The config file stores a `users` list keyed by + `subject@hostname` and each context references a user. +13. **Known Users:** The user's key (subject@hostname) is added to a list (`known_users`) + in the keyring for compatibility and multi-user management. ## Token refresh & usage @@ -112,7 +116,7 @@ When commands like `datumctl organizations list` or * The `internal/keyring` package provides a wrapper around `github.com/zalando/go-keyring`. * The `internal/authutil` package defines constants for the service name - (`datumctl-auth`) and keys (`active_user`, `known_users`, ``) + (`datumctl-auth`) and keys (`active_user.cluster.`, `known_users`, ``) used within the keyring. * `authutil.StoredCredentials` is the structure marshalled into JSON for storage. diff --git a/internal/authutil/cluster_credentials.go b/internal/authutil/cluster_credentials.go new file mode 100644 index 0000000..64f4831 --- /dev/null +++ b/internal/authutil/cluster_credentials.go @@ -0,0 +1,39 @@ +package authutil + +import ( + "errors" + "fmt" + + "go.datum.net/datumctl/internal/keyring" +) + +const clusterActiveUserKeyPrefix = "active_user.cluster." + +func clusterActiveUserKey(clusterName string) string { + return clusterActiveUserKeyPrefix + clusterName +} + +// GetActiveUserKeyForCluster returns the user key mapped to the cluster. +func GetActiveUserKeyForCluster(clusterName string) (string, error) { + if clusterName == "" { + return "", ErrNoCurrentContext + } + + userKey, err := keyring.Get(ServiceName, clusterActiveUserKey(clusterName)) + if err == nil && userKey != "" { + return userKey, nil + } + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return "", fmt.Errorf("failed to get active user for cluster %q: %w", clusterName, err) + } + + return "", ErrNoActiveUserForCluster +} + +// SetActiveUserKeyForCluster stores a cluster-specific mapping to a user key. +func SetActiveUserKeyForCluster(clusterName, userKey string) error { + if clusterName == "" || userKey == "" { + return fmt.Errorf("cluster name and user key are required") + } + return keyring.Set(ServiceName, clusterActiveUserKey(clusterName), userKey) +} diff --git a/internal/authutil/context.go b/internal/authutil/context.go new file mode 100644 index 0000000..e0b640e --- /dev/null +++ b/internal/authutil/context.go @@ -0,0 +1,222 @@ +package authutil + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/keyring" +) + +// GetUserKeyForCurrentContext resolves the user key bound to the current context. +// If the context does not yet define a user, it attempts a one-time migration +// from existing keyring mappings and persists the user reference into config. +func GetUserKeyForCurrentContext() (string, string, error) { + cfg, ctxEntry, clusterEntry, err := datumconfig.LoadCurrentContext() + if err != nil { + return "", "", err + } + if ctxEntry == nil || clusterEntry == nil { + return bootstrapUserFromKeyring(cfg) + } + + if ctxEntry.Context.User != "" { + if userEntry, ok := cfg.UserByName(ctxEntry.Context.User); ok && userEntry.User.Key != "" { + return userEntry.User.Key, clusterEntry.Name, nil + } + // If user reference points directly to a key, accept it and backfill users. + userKey := ctxEntry.Context.User + if err := ensureUserEntry(cfg, ctxEntry, userKey); err != nil { + return "", "", err + } + if err := datumconfig.Save(cfg); err != nil { + return "", "", err + } + return userKey, clusterEntry.Name, nil + } + + userKey, err := resolveUserKeyForCluster(clusterEntry.Name) + if err != nil { + return "", "", err + } + + if err := ensureUserEntry(cfg, ctxEntry, userKey); err != nil { + return "", "", err + } + if err := datumconfig.Save(cfg); err != nil { + return "", "", err + } + + return userKey, clusterEntry.Name, nil +} + +func ensureUserEntry(cfg *datumconfig.Config, ctxEntry *datumconfig.NamedContext, userKey string) error { + if cfg == nil || ctxEntry == nil { + return fmt.Errorf("invalid config or context") + } + userName := userKey + cfg.UpsertUser(datumconfig.NamedUser{ + Name: userName, + User: datumconfig.User{Key: userKey}, + }) + ctxEntry.Context.User = userName + cfg.UpsertContext(*ctxEntry) + return nil +} + +func resolveUserKeyForCluster(clusterName string) (string, error) { + if clusterName == "" { + return "", ErrNoCurrentContext + } + + userKey, err := keyring.Get(ServiceName, clusterActiveUserKey(clusterName)) + if err == nil && userKey != "" { + return migrateUserKeyIfNeeded(clusterName, userKey) + } + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return "", fmt.Errorf("failed to get active user for cluster %q: %w", clusterName, err) + } + + legacyUserKey, err := keyring.Get(ServiceName, ActiveUserKey) + if err == nil && legacyUserKey != "" { + return migrateUserKeyIfNeeded(clusterName, legacyUserKey) + } + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return "", fmt.Errorf("failed to get legacy active user: %w", err) + } + + return "", ErrNoActiveUserForCluster +} + +func migrateUserKeyIfNeeded(clusterName, userKey string) (string, error) { + creds, err := GetStoredCredentials(userKey) + if err != nil { + return "", err + } + if creds.Subject == "" || creds.Hostname == "" { + return "", fmt.Errorf("stored credentials missing subject or hostname for %q", userKey) + } + + newUserKey := fmt.Sprintf("%s@%s", creds.Subject, creds.Hostname) + if newUserKey != userKey { + credsJSON, err := json.Marshal(creds) + if err != nil { + return "", err + } + if err := keyring.Set(ServiceName, newUserKey, string(credsJSON)); err != nil { + return "", err + } + if err := keyring.Set(ServiceName, clusterActiveUserKey(clusterName), newUserKey); err != nil { + return "", err + } + if err := AddKnownUserKey(newUserKey); err != nil { + return "", err + } + return newUserKey, nil + } + + if err := keyring.Set(ServiceName, clusterActiveUserKey(clusterName), userKey); err != nil { + return "", err + } + return userKey, nil +} + +func bootstrapUserFromKeyring(cfg *datumconfig.Config) (string, string, error) { + if cfg == nil { + return "", "", ErrNoCurrentContext + } + + legacyKey, err := keyring.Get(ServiceName, ActiveUserKey) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return "", "", fmt.Errorf("failed to read legacy active user: %w", err) + } + + candidateKey := legacyKey + if candidateKey == "" { + knownUsersJSON, err := keyring.Get(ServiceName, KnownUsersKey) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return "", "", fmt.Errorf("failed to read known users: %w", err) + } + if knownUsersJSON != "" { + var knownUsers []string + if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { + return "", "", fmt.Errorf("failed to unmarshal known users: %w", err) + } + if len(knownUsers) == 1 { + candidateKey = knownUsers[0] + } + } + } + + if candidateKey == "" { + return "", "", ErrNoCurrentContext + } + + creds, err := GetStoredCredentials(candidateKey) + if err != nil { + return "", "", err + } + + apiHostname := creds.APIHostname + if apiHostname == "" { + apiHostname, err = DeriveAPIHostname(creds.Hostname) + if err != nil { + return "", "", err + } + } + + clusterName := "datum-" + sanitizeClusterName(apiHostname) + userKey, err := migrateUserKeyIfNeeded(clusterName, candidateKey) + if err != nil { + return "", "", err + } + + cluster := datumconfig.Cluster{ + Server: datumconfig.CleanBaseServer(datumconfig.EnsureScheme(apiHostname)), + } + if err := cfg.ValidateCluster(cluster); err != nil { + return "", "", err + } + + ctx := datumconfig.Context{ + Cluster: clusterName, + User: userKey, + } + cfg.EnsureContextDefaults(&ctx) + if err := cfg.ValidateContext(ctx); err != nil { + return "", "", err + } + + cfg.UpsertCluster(datumconfig.NamedCluster{ + Name: clusterName, + Cluster: cluster, + }) + cfg.UpsertUser(datumconfig.NamedUser{ + Name: userKey, + User: datumconfig.User{Key: userKey}, + }) + cfg.UpsertContext(datumconfig.NamedContext{ + Name: clusterName, + Context: ctx, + }) + if cfg.CurrentContext == "" { + cfg.CurrentContext = clusterName + } + + if err := datumconfig.Save(cfg); err != nil { + return "", "", err + } + + return userKey, clusterName, nil +} + +func sanitizeClusterName(apiHostname string) string { + name := strings.TrimSpace(apiHostname) + name = strings.TrimPrefix(name, "https://") + name = strings.TrimPrefix(name, "http://") + name = strings.TrimSuffix(name, "/") + name = strings.NewReplacer(":", "-", "/", "-", " ", "-").Replace(name) + return name +} diff --git a/internal/authutil/credentials.go b/internal/authutil/credentials.go index 3ccaa07..d242b21 100644 --- a/internal/authutil/credentials.go +++ b/internal/authutil/credentials.go @@ -21,7 +21,7 @@ const ServiceName = "datumctl-auth" // ActiveUserKey is the key used in the keyring to store the identifier of the currently active user credentials. const ActiveUserKey = "active_user" -// KnownUsersKey is the key used in the keyring to store a JSON list of known user identifiers (email@hostname). +// KnownUsersKey is the key used in the keyring to store a JSON list of known user identifiers (subject@hostname). const KnownUsersKey = "known_users" // ErrNoActiveUser indicates that no active user is set in the keyring. @@ -30,6 +30,18 @@ var ErrNoActiveUser = customerrors.NewUserErrorWithHint( "Please login first using: `datumctl auth login`", ) +// ErrNoCurrentContext indicates there is no datumctl current context set. +var ErrNoCurrentContext = customerrors.NewUserErrorWithHint( + "No current context set.", + "Set one with `datumctl config use-context` or login using `datumctl auth login`.", +) + +// ErrNoActiveUserForCluster indicates there are no credentials for the selected cluster. +var ErrNoActiveUserForCluster = customerrors.NewUserErrorWithHint( + "No credentials found for the current cluster.", + "Login for this cluster using: `datumctl auth login`", +) + // StoredCredentials holds all necessary information for a single authenticated session. type StoredCredentials struct { Hostname string `json:"hostname"` // The auth server hostname used (e.g., auth.datum.net) @@ -144,14 +156,26 @@ func (p *persistingTokenSource) Token() (*oauth2.Token, error) { return newToken, nil } -// GetTokenSource creates an oauth2.TokenSource for the active user. +// GetTokenSource creates an oauth2.TokenSource for the current context's user. // This source will automatically refresh the token if it's expired and persist updates to the keyring. func GetTokenSource(ctx context.Context) (oauth2.TokenSource, error) { - creds, userKey, err := GetActiveCredentials() + userKey, _, err := GetUserKeyForCurrentContext() if err != nil { return nil, err } + return GetTokenSourceForUser(ctx, userKey) +} +// GetTokenSourceForUser creates an oauth2.TokenSource for a specific user key. +func GetTokenSourceForUser(ctx context.Context, userKey string) (oauth2.TokenSource, error) { + creds, err := GetStoredCredentials(userKey) + if err != nil { + return nil, err + } + return tokenSourceForCreds(ctx, userKey, creds), nil +} + +func tokenSourceForCreds(ctx context.Context, userKey string, creds *StoredCredentials) oauth2.TokenSource { // Rebuild the oauth2.Config needed for refreshing conf := &oauth2.Config{ ClientID: creds.ClientID, @@ -172,12 +196,21 @@ func GetTokenSource(ctx context.Context) (oauth2.TokenSource, error) { source: baseSource, userKey: userKey, creds: creds, - }, nil + } } // GetUserIDFromToken extracts the user ID (sub claim) from the stored credentials. func GetUserIDFromToken(ctx context.Context) (string, error) { - creds, _, err := GetActiveCredentials() + userKey, _, err := GetUserKeyForCurrentContext() + if err != nil { + return "", err + } + return GetUserIDFromTokenForUser(userKey) +} + +// GetUserIDFromTokenForUser extracts the user ID (sub claim) for a specific user key. +func GetUserIDFromTokenForUser(userKey string) (string, error) { + creds, err := GetStoredCredentials(userKey) if err != nil { return "", err } @@ -189,7 +222,7 @@ func GetUserIDFromToken(ctx context.Context) (string, error) { return creds.Subject, nil } -// GetActiveUserKey retrieves the key for the currently active user (e.g., email@example.com). +// GetActiveUserKey retrieves the key for the currently active user (e.g., subject@hostname). func GetActiveUserKey() (string, error) { activeUserKey, err := keyring.Get(ServiceName, ActiveUserKey) if err != nil { @@ -209,17 +242,25 @@ func GetActiveUserKey() (string, error) { // GetAPIHostname returns the API hostname from stored credentials. // If no API hostname is stored, it attempts to derive it from the auth hostname. func GetAPIHostname() (string, error) { - creds, _, err := GetActiveCredentials() + userKey, _, err := GetUserKeyForCurrentContext() + if err != nil { + return "", err + } + return GetAPIHostnameForUser(userKey) +} + +// GetAPIHostnameForUser returns the API hostname from stored credentials for a specific user key. +// If no API hostname is stored, it attempts to derive it from the auth hostname. +func GetAPIHostnameForUser(userKey string) (string, error) { + creds, err := GetStoredCredentials(userKey) if err != nil { return "", err } - // If API hostname is explicitly stored, use it if creds.APIHostname != "" { return creds.APIHostname, nil } - // Fall back to deriving from auth hostname return DeriveAPIHostname(creds.Hostname) } diff --git a/internal/authutil/known_users.go b/internal/authutil/known_users.go new file mode 100644 index 0000000..2a4b7a9 --- /dev/null +++ b/internal/authutil/known_users.go @@ -0,0 +1,46 @@ +package authutil + +import ( + "encoding/json" + "errors" + "fmt" + + "go.datum.net/datumctl/internal/keyring" +) + +// AddKnownUserKey adds a userKey (subject@hostname) to the known_users list in the keyring. +func AddKnownUserKey(newUserKey string) error { + knownUsers := []string{} + + knownUsersJSON, err := keyring.Get(ServiceName, KnownUsersKey) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("failed to get known users list from keyring: %w", err) + } + + if err == nil && knownUsersJSON != "" { + if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { + return fmt.Errorf("failed to unmarshal known users list: %w", err) + } + } + + found := false + for _, key := range knownUsers { + if key == newUserKey { + found = true + break + } + } + + if !found { + knownUsers = append(knownUsers, newUserKey) + updatedJSON, err := json.Marshal(knownUsers) + if err != nil { + return fmt.Errorf("failed to marshal updated known users list: %w", err) + } + if err := keyring.Set(ServiceName, KnownUsersKey, string(updatedJSON)); err != nil { + return fmt.Errorf("failed to store updated known users list: %w", err) + } + } + + return nil +} diff --git a/internal/client/context_switch.go b/internal/client/context_switch.go index 87a6fcc..e321e12 100644 --- a/internal/client/context_switch.go +++ b/internal/client/context_switch.go @@ -38,11 +38,15 @@ func NewForOrg(ctx context.Context, orgID, defaultNamespace string) (*K8sClient, } func restConfigFor(ctx context.Context, organizationID, projectID string) (*rest.Config, error) { - tknSrc, err := authutil.GetTokenSource(ctx) + userKey, _, err := authutil.GetUserKeyForCurrentContext() + if err != nil { + return nil, fmt.Errorf("get user key: %w", err) + } + tknSrc, err := authutil.GetTokenSourceForUser(ctx, userKey) if err != nil { return nil, fmt.Errorf("get token source: %w", err) } - apiHostname, err := authutil.GetAPIHostname() + apiHostname, err := authutil.GetAPIHostnameForUser(userKey) if err != nil { return nil, fmt.Errorf("get API hostname: %w", err) } diff --git a/internal/client/factory.go b/internal/client/factory.go index da6e19d..8a529ef 100644 --- a/internal/client/factory.go +++ b/internal/client/factory.go @@ -2,11 +2,14 @@ package client import ( "context" + "encoding/base64" "fmt" "net/http" + "os" "github.com/spf13/pflag" "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" "golang.org/x/oauth2" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" @@ -44,6 +47,10 @@ func (c *CustomConfigFlags) ToRESTConfig() (*rest.Config, error) { if err != nil { return nil, err } + ctxEntry, clusterEntry, err := c.loadDatumContext() + if err != nil { + return nil, err + } if c.APIServer != nil && *c.APIServer != "" { config.Host = *c.APIServer } @@ -54,7 +61,11 @@ func (c *CustomConfigFlags) ToRESTConfig() (*rest.Config, error) { config.ServerName = *c.TLSServerName } - tknSrc, err := authutil.GetTokenSource(c.Context) + userKey, err := c.resolveUserKeyForCluster(clusterEntry) + if err != nil { + return nil, err + } + tknSrc, err := authutil.GetTokenSourceForUser(c.Context, userKey) if err != nil { return nil, err } @@ -63,35 +74,47 @@ func (c *CustomConfigFlags) ToRESTConfig() (*rest.Config, error) { return &oauth2.Transport{Source: tknSrc, Base: rt} } - apiHostname, err := authutil.GetAPIHostname() + baseServer, err := c.resolveBaseServer(userKey, clusterEntry) if err != nil { return nil, err } - // Handle platform-wide mode - isPlatformWide := c.PlatformWide != nil && *c.PlatformWide - hasProject := c.Project != nil && *c.Project != "" - hasOrganization := c.Organization != nil && *c.Organization != "" + projectID, organizationID, platformWide, err := c.resolveScope(ctxEntry) + if err != nil { + return nil, err + } switch { - case isPlatformWide: - // Platform-wide mode: access the root of the platform - if hasProject || hasOrganization { - return nil, fmt.Errorf("--platform-wide cannot be used with --project or --organization") - } - config.Host = fmt.Sprintf("https://%s", apiHostname) - case !hasProject && !hasOrganization: - // No context specified - default behavior - case hasOrganization && !hasProject: - // Organization context - config.Host = fmt.Sprintf("https://%s/apis/resourcemanager.miloapis.com/v1alpha1/organizations/%s/control-plane", - apiHostname, *c.Organization) - case hasProject && !hasOrganization: - // Project context - config.Host = fmt.Sprintf("https://%s/apis/resourcemanager.miloapis.com/v1alpha1/projects/%s/control-plane", - apiHostname, *c.Project) + case platformWide: + config.Host = baseServer + case organizationID != "": + config.Host = fmt.Sprintf("%s/apis/resourcemanager.miloapis.com/v1alpha1/organizations/%s/control-plane", + baseServer, organizationID) + case projectID != "": + config.Host = fmt.Sprintf("%s/apis/resourcemanager.miloapis.com/v1alpha1/projects/%s/control-plane", + baseServer, projectID) default: - return nil, fmt.Errorf("exactly one of organizationID or projectID must be provided") + userID, err := authutil.GetUserIDFromTokenForUser(userKey) + if err != nil { + return nil, fmt.Errorf("failed to get user ID from token: %w", err) + } + config.Host = fmt.Sprintf("%s/apis/iam.miloapis.com/v1alpha1/users/%s/control-plane", baseServer, userID) + } + + if clusterEntry != nil { + if (c.TLSServerName == nil || *c.TLSServerName == "") && clusterEntry.Cluster.TLSServerName != "" { + config.ServerName = clusterEntry.Cluster.TLSServerName + } + if (c.Insecure == nil || !*c.Insecure) && clusterEntry.Cluster.InsecureSkipTLSVerify { + config.Insecure = true + } + if len(config.CAData) == 0 && clusterEntry.Cluster.CertificateAuthorityData != "" { + decoded, err := base64.StdEncoding.DecodeString(clusterEntry.Cluster.CertificateAuthorityData) + if err != nil { + return nil, fmt.Errorf("decode certificate authority data for cluster %q: %w", clusterEntry.Name, err) + } + config.CAData = decoded + } } return config, nil @@ -132,6 +155,11 @@ func (c *CustomConfigFlags) ToRawKubeConfigLoader() clientcmd.ClientConfig { if c.ConfigFlags.Namespace != nil && *c.ConfigFlags.Namespace != "" { overrides.Context.Namespace = *c.ConfigFlags.Namespace + } else { + ctxEntry, _, err := c.loadDatumContext() + if err == nil && ctxEntry != nil && ctxEntry.Context.Namespace != "" { + overrides.Context.Namespace = ctxEntry.Context.Namespace + } } // Apply cluster overrides if set @@ -158,6 +186,67 @@ func (c *CustomConfigFlags) ToRawKubeConfigLoader() clientcmd.ClientConfig { return clientcmd.NewDefaultClientConfig(*kubeConfig, overrides) } +func (c *CustomConfigFlags) loadDatumContext() (*datumconfig.NamedContext, *datumconfig.NamedCluster, error) { + _, ctxEntry, clusterEntry, err := datumconfig.LoadCurrentContext() + if err != nil { + return nil, nil, err + } + return ctxEntry, clusterEntry, nil +} + +func (c *CustomConfigFlags) resolveUserKeyForCluster(clusterEntry *datumconfig.NamedCluster) (string, error) { + if clusterEntry != nil && clusterEntry.Name != "" { + return authutil.GetActiveUserKeyForCluster(clusterEntry.Name) + } + return "", authutil.ErrNoCurrentContext +} + +func (c *CustomConfigFlags) resolveBaseServer(userKey string, clusterEntry *datumconfig.NamedCluster) (string, error) { + if c.APIServer != nil && *c.APIServer != "" { + return datumconfig.CleanBaseServer(datumconfig.EnsureScheme(*c.APIServer)), nil + } + if clusterEntry != nil && clusterEntry.Cluster.Server != "" { + return datumconfig.CleanBaseServer(datumconfig.EnsureScheme(clusterEntry.Cluster.Server)), nil + } + apiHostname, err := authutil.GetAPIHostnameForUser(userKey) + if err != nil { + return "", err + } + return datumconfig.CleanBaseServer(datumconfig.EnsureScheme(apiHostname)), nil +} + +func (c *CustomConfigFlags) resolveScope(ctxEntry *datumconfig.NamedContext) (string, string, bool, error) { + platformWide := c.PlatformWide != nil && *c.PlatformWide + projectID := "" + organizationID := "" + + if c.Project != nil && *c.Project != "" { + projectID = *c.Project + } + if c.Organization != nil && *c.Organization != "" { + organizationID = *c.Organization + } + + if platformWide && (projectID != "" || organizationID != "") { + return "", "", false, fmt.Errorf("--platform-wide cannot be used with --project or --organization") + } + + if projectID == "" && organizationID == "" && !platformWide && ctxEntry != nil { + projectID = ctxEntry.Context.ProjectID + organizationID = ctxEntry.Context.OrganizationID + } + + if projectID != "" && organizationID != "" { + if c.Project != nil && *c.Project != "" || c.Organization != nil && *c.Organization != "" { + return "", "", false, fmt.Errorf("exactly one of organizationID or projectID must be provided") + } + fmt.Fprintf(os.Stderr, "Warning: context has both project_id and organization_id set; using project_id and ignoring organization_id.\n") + organizationID = "" + } + + return projectID, organizationID, platformWide, nil +} + func NewDatumFactory(ctx context.Context) (*DatumCloudFactory, error) { baseConfigFlags := genericclioptions.NewConfigFlags(true) baseConfigFlags = baseConfigFlags.WithWrapConfigFn(func(*rest.Config) *rest.Config { diff --git a/internal/client/factory_test.go b/internal/client/factory_test.go new file mode 100644 index 0000000..24d3299 --- /dev/null +++ b/internal/client/factory_test.go @@ -0,0 +1,112 @@ +package client + +import ( + "testing" + + "go.datum.net/datumctl/internal/datumconfig" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +func TestResolveScope_FlagsOverrideContext(t *testing.T) { + t.Parallel() + + ctxEntry := &datumconfig.NamedContext{ + Name: "ctx", + Context: datumconfig.Context{ + ProjectID: "ctx-project", + }, + } + + tests := []struct { + name string + projectFlag *string + orgFlag *string + platformWide *bool + wantProjectID string + wantOrgID string + wantPlatform bool + }{ + { + name: "project flag beats context project", + projectFlag: stringPtr("flag-project"), + orgFlag: stringPtr(""), + platformWide: boolPtr(false), + wantProjectID: "flag-project", + }, + { + name: "organization flag beats context project", + projectFlag: stringPtr(""), + orgFlag: stringPtr("flag-org"), + platformWide: boolPtr(false), + wantOrgID: "flag-org", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &CustomConfigFlags{ + Project: tt.projectFlag, + Organization: tt.orgFlag, + PlatformWide: tt.platformWide, + } + + projectID, organizationID, platformWide, err := c.resolveScope(ctxEntry) + if err != nil { + t.Fatalf("resolveScope returned error: %v", err) + } + if projectID != tt.wantProjectID { + t.Fatalf("projectID=%q, want %q", projectID, tt.wantProjectID) + } + if organizationID != tt.wantOrgID { + t.Fatalf("organizationID=%q, want %q", organizationID, tt.wantOrgID) + } + if platformWide != tt.wantPlatform { + t.Fatalf("platformWide=%v, want %v", platformWide, tt.wantPlatform) + } + }) + } +} + +func TestResolveBaseServer_PrefersFlagOverCluster(t *testing.T) { + t.Parallel() + + clusterEntry := &datumconfig.NamedCluster{ + Name: "cluster-1", + Cluster: datumconfig.Cluster{ + Server: "https://cluster.example.com/", + }, + } + + c := &CustomConfigFlags{ + ConfigFlags: &genericclioptions.ConfigFlags{ + APIServer: stringPtr("https://flag.example.com/"), + }, + } + + baseServer, err := c.resolveBaseServer("user-key", clusterEntry) + if err != nil { + t.Fatalf("resolveBaseServer returned error: %v", err) + } + if baseServer != "https://flag.example.com" { + t.Fatalf("baseServer=%q, want %q", baseServer, "https://flag.example.com") + } + + c.APIServer = stringPtr("") + baseServer, err = c.resolveBaseServer("user-key", clusterEntry) + if err != nil { + t.Fatalf("resolveBaseServer returned error with cluster fallback: %v", err) + } + if baseServer != "https://cluster.example.com" { + t.Fatalf("baseServer=%q, want %q", baseServer, "https://cluster.example.com") + } +} + +func stringPtr(val string) *string { + return &val +} + +func boolPtr(val bool) *bool { + return &val +} diff --git a/internal/client/user_context.go b/internal/client/user_context.go index 4ee79b2..1ec272b 100644 --- a/internal/client/user_context.go +++ b/internal/client/user_context.go @@ -30,18 +30,22 @@ func NewUserContextualClient(ctx context.Context) (client.Client, error) { } func NewRestConfig(ctx context.Context) (*rest.Config, error) { - tknSrc, err := authutil.GetTokenSource(ctx) + userKey, _, err := authutil.GetUserKeyForCurrentContext() + if err != nil { + return nil, fmt.Errorf("failed to get user key: %w", err) + } + tknSrc, err := authutil.GetTokenSourceForUser(ctx, userKey) if err != nil { return nil, fmt.Errorf("failed to get token source: %w", err) } // Get user ID from stored credentials - userID, err := authutil.GetUserIDFromToken(ctx) + userID, err := authutil.GetUserIDFromTokenForUser(userKey) if err != nil { return nil, fmt.Errorf("failed to get user ID from token: %w", err) } // Get API hostname from stored credentials - apiHostname, err := authutil.GetAPIHostname() + apiHostname, err := authutil.GetAPIHostnameForUser(userKey) if err != nil { return nil, fmt.Errorf("failed to get API hostname: %w", err) } diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 4b4710a..fe252c3 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -15,9 +15,7 @@ func Command() *cobra.Command { cmd.AddCommand( getTokenCmd, LoginCmd, - listCmd, logoutCmd, - switchCmd, updateKubeconfigCmd(), ) diff --git a/internal/cmd/auth/get_token.go b/internal/cmd/auth/get_token.go index e2d3c21..f3ec8b4 100644 --- a/internal/cmd/auth/get_token.go +++ b/internal/cmd/auth/get_token.go @@ -2,7 +2,6 @@ package auth import ( "encoding/json" - "errors" "fmt" "os" "time" @@ -22,8 +21,8 @@ const ( // getTokenCmd retrieves tokens based on the --output flag. var getTokenCmd = &cobra.Command{ Use: "get-token", - Short: "Retrieve access token for active user (raw or K8s format)", - Long: `Retrieves credentials for the currently active datumctl user. + Short: "Retrieve access token for current context (raw or K8s format)", + Long: `Retrieves credentials for the current datumctl context. Default behavior (--output=token) prints the raw access token to stdout. With --output=client.authentication.k8s.io/v1, prints a K8s ExecCredential JSON object @@ -35,24 +34,36 @@ suitable for use as a kubectl credential plugin.`, // Updated description func init() { // Add flags for direct execution mode getTokenCmd.Flags().StringP("output", "o", outputFormatToken, fmt.Sprintf("Output format. One of: %s|%s", outputFormatToken, outputFormatK8sV1Creds)) + getTokenCmd.Flags().String("cluster", "", "Datumctl cluster name to use (defaults to current context)") } // runGetToken implements the logic based on the --output flag. func runGetToken(cmd *cobra.Command, args []string) error { ctx := cmd.Context() outputFormat, _ := cmd.Flags().GetString("output") // Ignore error, handled by validation + clusterName, _ := cmd.Flags().GetString("cluster") if outputFormat != outputFormatToken && outputFormat != outputFormatK8sV1Creds { // Return error here so Cobra prints usage return fmt.Errorf("invalid --output format %q. Must be %s or %s", outputFormat, outputFormatToken, outputFormatK8sV1Creds) } + var ( + userKey string + err error + ) + if clusterName != "" { + userKey, err = authutil.GetActiveUserKeyForCluster(clusterName) + } else { + userKey, _, err = authutil.GetUserKeyForCurrentContext() + } + if err != nil { + return err + } + // Get the token source (which handles refresh and persistence automatically) - tokenSource, err := authutil.GetTokenSource(ctx) + tokenSource, err := authutil.GetTokenSourceForUser(ctx, userKey) if err != nil { - if errors.Is(err, authutil.ErrNoActiveUser) { - return errors.New("no active user found in keyring. Please login first using 'datumctl auth login'") - } return fmt.Errorf("failed to get token source: %w", err) } diff --git a/internal/cmd/auth/list.go b/internal/cmd/auth/list.go deleted file mode 100644 index b73987a..0000000 --- a/internal/cmd/auth/list.go +++ /dev/null @@ -1,98 +0,0 @@ -package auth - -import ( - "encoding/json" - "errors" - "fmt" - "os" - - "github.com/rodaine/table" - "github.com/spf13/cobra" - "go.datum.net/datumctl/internal/authutil" - "go.datum.net/datumctl/internal/keyring" -) - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List locally authenticated users", - Aliases: []string{"ls"}, - RunE: func(cmd *cobra.Command, args []string) error { - return runList() - }, -} - -func runList() error { - // Get the list of known user keys - knownUsers := []string{} - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) - if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - // No users known yet - fmt.Println("No users have been logged in yet.") - return nil - } - // Other error getting the list - return fmt.Errorf("failed to get known users list from keyring: %w", err) - } - - if knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - return fmt.Errorf("failed to unmarshal known users list: %w", err) - } - } - - if len(knownUsers) == 0 { - fmt.Println("No users have been logged in yet.") - return nil - } - - // Get the active user key - activeUserKey, err := keyring.Get(authutil.ServiceName, authutil.ActiveUserKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - // Don't fail if active user key is missing, just proceed without marking active - fmt.Printf("Warning: could not determine active user: %v\n", err) - activeUserKey = "" - } - - // Initialize table - tbl := table.New("Name", "Email", "Status").WithWriter(os.Stdout) - - for _, userKey := range knownUsers { - // Retrieve the stored credentials for this user to get name/email - credsJSON, err := keyring.Get(authutil.ServiceName, userKey) - if err != nil { - // Add row with error message if details retrieval fails - tbl.AddRow("", userKey, fmt.Sprintf("Error: %v", err)) - continue - } - - var creds authutil.StoredCredentials - if err := json.Unmarshal([]byte(credsJSON), &creds); err != nil { - // Add row with error message if unmarshal fails - tbl.AddRow("", userKey, fmt.Sprintf("Error parsing: %v", err)) - continue - } - - // Prepare display values - displayName := creds.UserName - if displayName == "" { - displayName = "" - } - displayEmail := creds.UserEmail - if displayEmail == "" { - displayEmail = "" - } - status := "" - if userKey == activeUserKey { - status = "Active" - } - - // Add row to table - tbl.AddRow(displayName, displayEmail, status) - } - - // Print the table - tbl.Print() - - return nil -} diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 03fec85..5237850 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -6,7 +6,6 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" - "errors" "fmt" "net" "net/http" @@ -21,6 +20,7 @@ import ( "golang.org/x/oauth2" "go.datum.net/datumctl/internal/authutil" // Import new authutil package + "go.datum.net/datumctl/internal/datumconfig" "go.datum.net/datumctl/internal/keyring" ) @@ -33,9 +33,12 @@ const ( ) var ( - hostname string // Variable to store hostname flag - apiHostname string // Variable to store api-hostname flag - clientIDFlag string // Variable to store client-id flag + hostname string // Variable to store hostname flag + apiHostname string // Variable to store api-hostname flag + clientIDFlag string // Variable to store client-id flag + skipConfigSetup bool + configClusterName string + configContextName string ) var LoginCmd = &cobra.Command{ @@ -53,7 +56,12 @@ var LoginCmd = &cobra.Command{ // Return an error if no client ID could be determined return fmt.Errorf("client ID not configured for hostname '%s'. Please specify one with the --client-id flag", hostname) } - return runLoginFlow(cmd.Context(), hostname, apiHostname, actualClientID, (kubectlcmd.GetLogVerbosity(os.Args) != "0")) + opts := configSetupOptions{ + skipConfigSetup: skipConfigSetup, + clusterName: configClusterName, + contextName: configContextName, + } + return runLoginFlow(cmd.Context(), hostname, apiHostname, actualClientID, (kubectlcmd.GetLogVerbosity(os.Args) != "0"), opts) }, } @@ -64,6 +72,9 @@ func init() { LoginCmd.Flags().StringVar(&apiHostname, "api-hostname", "", "Hostname of the Datum Cloud API server (if not specified, will be derived from auth hostname)") // Add the client-id flag LoginCmd.Flags().StringVar(&clientIDFlag, "client-id", "", "Override the OAuth2 Client ID") + LoginCmd.Flags().BoolVar(&skipConfigSetup, "skip-config-setup", false, "Do not prompt to create a default cluster/context") + LoginCmd.Flags().StringVar(&configClusterName, "cluster-name", "", "Cluster name to use when populating config (defaults to datum-)") + LoginCmd.Flags().StringVar(&configContextName, "context-name", "", "Context name to use when populating config (defaults to cluster name)") } // Generates a random PKCE code verifier @@ -94,7 +105,13 @@ func generateRandomState(length int) (string, error) { } // runLoginFlow now accepts context, hostname, apiHostname, clientID, and verbose flag -func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, clientID string, verbose bool) error { +type configSetupOptions struct { + skipConfigSetup bool + clusterName string + contextName string +} + +func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, clientID string, verbose bool, setupOpts configSetupOptions) error { fmt.Printf("Starting login process for %s ...\n", authHostname) // Determine the final API hostname to use @@ -294,8 +311,8 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, fmt.Printf("\nAuthenticated as: %s (%s)\n", claims.Name, claims.Email) - // Use email directly as the key, as it already contains the hostname from the claim - userKey := claims.Email + // Use subject+auth-hostname to avoid overwriting credentials across clusters. + userKey := fmt.Sprintf("%s@%s", claims.Subject, authHostname) creds := authutil.StoredCredentials{ Hostname: authHostname, @@ -320,29 +337,17 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, return fmt.Errorf("failed to store credentials in keyring for user %s: %w", userKey, err) } - activeUserKey := "" // Temp variable to check if active user was set - err = keyring.Set(authutil.ServiceName, authutil.ActiveUserKey, userKey) - if err != nil { - fmt.Printf("Warning: Failed to set '%s' as active user in keyring: %v\n", userKey, err) - fmt.Printf("Credentials for '%s' were stored successfully.\n", userKey) - } else { - // fmt.Printf("Credentials stored and set as active for user '%s'.\n", userKey) // Old message - activeUserKey = userKey // Mark success - } - - // Update confirmation messages - if activeUserKey == userKey { // Check if we successfully set the active user - fmt.Println("Authentication successful. Credentials stored and set as active.") - } else { - // This case handles if setting the active user key failed but creds were stored - fmt.Println("Authentication successful. Credentials stored.") - } + fmt.Println("Authentication successful. Credentials stored.") // Update the list of known users (using the new key format) - if err := addKnownUser(userKey); err != nil { + if err := authutil.AddKnownUserKey(userKey); err != nil { fmt.Printf("Warning: Failed to update list of known users: %v\n", err) } + if err := maybePopulateConfig(finalAPIHostname, userKey, setupOpts); err != nil { + return err + } + if verbose { var rawClaims map[string]interface{} if err := idToken.Claims(&rawClaims); err == nil { @@ -362,47 +367,77 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, return nil } -// addKnownUser adds a userKey (now email@hostname) to the known_users list in the keyring. -func addKnownUser(newUserKey string) error { - knownUsers := []string{} +func maybePopulateConfig(apiHostname string, userKey string, opts configSetupOptions) error { + if opts.skipConfigSetup { + return nil + } - // Get current list - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - // Only return error if it's not ErrNotFound - return fmt.Errorf("failed to get known users list from keyring: %w", err) + cfg, err := datumconfig.Load() + if err != nil { + return err } - if err == nil && knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - return fmt.Errorf("failed to unmarshal known users list: %w", err) - } + clusterName := strings.TrimSpace(opts.clusterName) + if clusterName == "" { + clusterName = "datum-" + sanitizeClusterName(apiHostname) + } + contextName := strings.TrimSpace(opts.contextName) + if contextName == "" { + contextName = clusterName } - // Check if user already exists - found := false - for _, key := range knownUsers { - if key == newUserKey { - found = true - break - } + cluster := datumconfig.Cluster{ + Server: datumconfig.CleanBaseServer(datumconfig.EnsureScheme(apiHostname)), + } + if err := cfg.ValidateCluster(cluster); err != nil { + return err } - // Add if not found - if !found { - knownUsers = append(knownUsers, newUserKey) + ctx := datumconfig.Context{ + Cluster: clusterName, + User: userKey, + } + cfg.EnsureContextDefaults(&ctx) + if err := cfg.ValidateContext(ctx); err != nil { + return err + } - // Marshal updated list - updatedJSON, err := json.Marshal(knownUsers) - if err != nil { - return fmt.Errorf("failed to marshal updated known users list: %w", err) - } + cfg.UpsertCluster(datumconfig.NamedCluster{ + Name: clusterName, + Cluster: cluster, + }) + cfg.UpsertContext(datumconfig.NamedContext{ + Name: contextName, + Context: ctx, + }) + cfg.UpsertUser(datumconfig.NamedUser{ + Name: userKey, + User: datumconfig.User{Key: userKey}, + }) + if cfg.CurrentContext == "" { + cfg.CurrentContext = contextName + } - // Store updated list - if err := keyring.Set(authutil.ServiceName, authutil.KnownUsersKey, string(updatedJSON)); err != nil { - return fmt.Errorf("failed to store updated known users list: %w", err) - } + if err := datumconfig.Save(cfg); err != nil { + return err + } + if err := authutil.SetActiveUserKeyForCluster(clusterName, userKey); err != nil { + fmt.Printf("Warning: Failed to bind active user to cluster %q: %v\n", clusterName, err) } + if cfg.CurrentContext == contextName { + fmt.Printf("Created default cluster %q and context %q in datumctl config.\n", clusterName, contextName) + } else { + fmt.Printf("Updated datumctl config with cluster %q and context %q (current-context unchanged).\n", clusterName, contextName) + } return nil } + +func sanitizeClusterName(apiHostname string) string { + name := strings.TrimSpace(apiHostname) + name = strings.TrimPrefix(name, "https://") + name = strings.TrimPrefix(name, "http://") + name = strings.TrimSuffix(name, "/") + name = strings.NewReplacer(":", "-", "/", "-", " ", "-").Replace(name) + return name +} diff --git a/internal/cmd/auth/logout.go b/internal/cmd/auth/logout.go index 3af0460..bf3f718 100644 --- a/internal/cmd/auth/logout.go +++ b/internal/cmd/auth/logout.go @@ -18,8 +18,7 @@ var logoutCmd = &cobra.Command{ Short: "Remove local authentication credentials for a specified user or all users", Long: `Remove local authentication credentials. -Specify a user in the format 'email@hostname' to log out only that user. -Use 'datumctl auth list' to see available users. +Specify a user in the format 'subject@hostname' to log out only that user. Use the --all flag to log out all known users.`, // Updated Long description Args: func(cmd *cobra.Command, args []string) error { // Custom args validation @@ -28,7 +27,7 @@ Use the --all flag to log out all known users.`, // Updated Long description return errors.New("cannot specify a user argument when using the --all flag") } if !all && len(args) != 1 { - return errors.New("must specify exactly one user (email@hostname) or use the --all flag") + return errors.New("must specify exactly one user (subject@hostname) or use the --all flag") } return nil }, diff --git a/internal/cmd/auth/switch.go b/internal/cmd/auth/switch.go deleted file mode 100644 index 47575a2..0000000 --- a/internal/cmd/auth/switch.go +++ /dev/null @@ -1,81 +0,0 @@ -package auth - -import ( - "encoding/json" - "errors" - "fmt" - - "github.com/spf13/cobra" - "go.datum.net/datumctl/internal/authutil" - "go.datum.net/datumctl/internal/keyring" -) - -var switchCmd = &cobra.Command{ - Use: "switch ", - Short: "Set the active authenticated user session", - Long: `Switches the active user context to the specified user email. - -The user email must correspond to an existing set of credentials previously -established via 'datumctl auth login'. Use 'datumctl auth list' to see available users.`, - Args: cobra.ExactArgs(1), // Requires exactly one argument: the user email - RunE: func(cmd *cobra.Command, args []string) error { - targetUserKey := args[0] - return runSwitch(targetUserKey) - }, -} - -func runSwitch(targetUserKey string) error { - // 1. Get the list of known users to validate the target user exists - knownUsers := []string{} - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - // Don't fail if list is missing, but we won't be able to validate. - // Print a warning? - fmt.Printf("Warning: could not retrieve known users list to validate target: %v\n", err) - } else if knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - // Also don't fail, but warn. - fmt.Printf("Warning: could not parse known users list to validate target: %v\n", err) - } - } - - // 2. Validate the target user key exists in the known list (if available) - found := false - if len(knownUsers) > 0 { - for _, key := range knownUsers { - if key == targetUserKey { - found = true - break - } - } - if !found { - return fmt.Errorf("user '%s' not found in the list of locally authenticated users. Use 'datumctl auth list' to see available users", targetUserKey) - } - } else { - // If known users list wasn't available or parseable, try to get the specific credential as a fallback validation - _, err := keyring.Get(authutil.ServiceName, targetUserKey) - if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - return fmt.Errorf("credentials for user '%s' not found. Use 'datumctl auth list' to see available users", targetUserKey) - } - return fmt.Errorf("failed to check credentials for user '%s': %w", targetUserKey, err) - } - } - - // 3. Get current active user (optional, for comparison message) - currentActiveUser, _ := keyring.Get(authutil.ServiceName, authutil.ActiveUserKey) - - if currentActiveUser == targetUserKey { - fmt.Printf("User '%s' is already the active user.\n", targetUserKey) - return nil - } - - // 4. Set the new active user - err = keyring.Set(authutil.ServiceName, authutil.ActiveUserKey, targetUserKey) - if err != nil { - return fmt.Errorf("failed to set '%s' as active user in keyring: %w", targetUserKey, err) - } - - fmt.Printf("Switched active user to '%s'\n", targetUserKey) - return nil -} diff --git a/internal/cmd/auth/update-kubeconfig.go b/internal/cmd/auth/update-kubeconfig.go index 0761c1f..c42bedf 100644 --- a/internal/cmd/auth/update-kubeconfig.go +++ b/internal/cmd/auth/update-kubeconfig.go @@ -30,26 +30,29 @@ func updateKubeconfigCmd() *cobra.Command { } var apiHostname string - var activeUserKey string + var userKey string + var datumClusterName string // Use override hostname if provided, otherwise get from stored credentials if hostname != "" { apiHostname = hostname } else { var err error - apiHostname, err = authutil.GetAPIHostname() + userKey, datumClusterName, err = authutil.GetUserKeyForCurrentContext() if err != nil { - return fmt.Errorf("failed to get API hostname: %w", err) + return err } - activeUserKey, err = authutil.GetActiveUserKey() + apiHostname, err = authutil.GetAPIHostnameForUser(userKey) + if err != nil { + return fmt.Errorf("failed to get API hostname: %w", err) + } + } + if datumClusterName == "" { + var err error + userKey, datumClusterName, err = authutil.GetUserKeyForCurrentContext() if err != nil { - // We only expect an error here if the user is not logged in. - if errors.Is(err, authutil.ErrNoActiveUser) { - return errors.New("no active user found. Please login using 'datumctl auth login'") - } - // For other errors, provide more context. - return fmt.Errorf("failed to get active user for kubeconfig message: %w", err) + return err } } @@ -100,6 +103,7 @@ func updateKubeconfigCmd() *cobra.Command { "auth", "get-token", "--output=client.authentication.k8s.io/v1", + "--cluster=" + datumClusterName, }, APIVersion: "client.authentication.k8s.io/v1", ProvideClusterInfo: false, @@ -113,7 +117,7 @@ func updateKubeconfigCmd() *cobra.Command { } // Construct success message - userInfo := activeUserKey + userInfo := userKey if userInfo == "" { userInfo = "custom hostname override" } diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go new file mode 100644 index 0000000..2281a91 --- /dev/null +++ b/internal/cmd/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage datumctl contexts and clusters", + } + + cmd.AddCommand(newGetContextsCmd()) + cmd.AddCommand(newUseContextCmd()) + cmd.AddCommand(newSetContextCmd()) + cmd.AddCommand(newSetClusterCmd()) + cmd.AddCommand(newViewCmd()) + + return cmd +} + +func requireArgs(cmd *cobra.Command, args []string, count int) (string, error) { + if len(args) < count { + return "", fmt.Errorf("requires %d argument(s)", count) + } + return args[0], nil +} diff --git a/internal/cmd/config/get_contexts.go b/internal/cmd/config/get_contexts.go new file mode 100644 index 0000000..a492ac8 --- /dev/null +++ b/internal/cmd/config/get_contexts.go @@ -0,0 +1,46 @@ +package config + +import ( + "os" + + "github.com/rodaine/table" + "github.com/spf13/cobra" + "go.datum.net/datumctl/internal/datumconfig" +) + +func newGetContextsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get-contexts", + Short: "List contexts", + Aliases: []string{"contexts", "gc"}, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := datumconfig.Load() + if err != nil { + return err + } + + if len(cfg.Contexts) == 0 { + cmd.Println("No contexts found.") + return nil + } + + tbl := table.New("Name", "Cluster", "User", "Namespace", "Project", "Organization", "Current").WithWriter(os.Stdout) + for _, ctx := range cfg.Contexts { + namespace := ctx.Context.Namespace + if namespace == "" { + namespace = datumconfig.DefaultNamespace + } + current := "" + if ctx.Name == cfg.CurrentContext { + current = "*" + } + tbl.AddRow(ctx.Name, ctx.Context.Cluster, ctx.Context.User, namespace, ctx.Context.ProjectID, ctx.Context.OrganizationID, current) + } + + tbl.Print() + return nil + }, + } + + return cmd +} diff --git a/internal/cmd/config/set_cluster.go b/internal/cmd/config/set_cluster.go new file mode 100644 index 0000000..9519b0f --- /dev/null +++ b/internal/cmd/config/set_cluster.go @@ -0,0 +1,59 @@ +package config + +import ( + "github.com/spf13/cobra" + "go.datum.net/datumctl/internal/datumconfig" +) + +func newSetClusterCmd() *cobra.Command { + var server string + var tlsServerName string + var insecureSkipTLSVerify bool + var caData string + + cmd := &cobra.Command{ + Use: "set-cluster NAME", + Short: "Create or update a cluster", + RunE: func(cmd *cobra.Command, args []string) error { + name, err := requireArgs(cmd, args, 1) + if err != nil { + return err + } + + cfg, err := datumconfig.Load() + if err != nil { + return err + } + + cluster := datumconfig.Cluster{ + Server: server, + TLSServerName: tlsServerName, + InsecureSkipTLSVerify: insecureSkipTLSVerify, + CertificateAuthorityData: caData, + } + if err := cfg.ValidateCluster(cluster); err != nil { + return err + } + + cfg.UpsertCluster(datumconfig.NamedCluster{ + Name: name, + Cluster: cluster, + }) + + if err := datumconfig.Save(cfg); err != nil { + return err + } + + cmd.Printf("Cluster %q set.\n", name) + return nil + }, + } + + cmd.Flags().StringVar(&server, "server", "", "API server base URL (e.g., https://api.example.com)") + cmd.Flags().StringVar(&tlsServerName, "tls-server-name", "", "TLS server name override") + cmd.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "Skip TLS verification") + cmd.Flags().StringVar(&caData, "certificate-authority-data", "", "Base64-encoded PEM certificate data") + cmd.MarkFlagRequired("server") + + return cmd +} diff --git a/internal/cmd/config/set_context.go b/internal/cmd/config/set_context.go new file mode 100644 index 0000000..9680124 --- /dev/null +++ b/internal/cmd/config/set_context.go @@ -0,0 +1,64 @@ +package config + +import ( + "github.com/spf13/cobra" + "go.datum.net/datumctl/internal/datumconfig" +) + +func newSetContextCmd() *cobra.Command { + var cluster string + var user string + var namespace string + var projectID string + var organizationID string + + cmd := &cobra.Command{ + Use: "set-context NAME", + Short: "Create or update a context", + RunE: func(cmd *cobra.Command, args []string) error { + name, err := requireArgs(cmd, args, 1) + if err != nil { + return err + } + + cfg, err := datumconfig.Load() + if err != nil { + return err + } + + ctx := datumconfig.Context{ + Cluster: cluster, + User: user, + Namespace: namespace, + ProjectID: projectID, + OrganizationID: organizationID, + } + cfg.EnsureContextDefaults(&ctx) + if err := cfg.ValidateContext(ctx); err != nil { + return err + } + + cfg.UpsertContext(datumconfig.NamedContext{ + Name: name, + Context: ctx, + }) + + if err := datumconfig.Save(cfg); err != nil { + return err + } + + cmd.Printf("Context %q set.\n", name) + return nil + }, + } + + cmd.Flags().StringVar(&cluster, "cluster", "", "Cluster name") + cmd.Flags().StringVar(&user, "user", "", "User name (from datumctl config users list)") + cmd.Flags().StringVar(&namespace, "namespace", "", "Namespace (defaults to 'default')") + cmd.Flags().StringVar(&projectID, "project", "", "Project ID") + cmd.Flags().StringVar(&organizationID, "organization", "", "Organization ID") + cmd.MarkFlagRequired("cluster") + cmd.MarkFlagsMutuallyExclusive("project", "organization") + + return cmd +} diff --git a/internal/cmd/config/use_context.go b/internal/cmd/config/use_context.go new file mode 100644 index 0000000..2d77e25 --- /dev/null +++ b/internal/cmd/config/use_context.go @@ -0,0 +1,48 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" +) + +func newUseContextCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "use-context NAME", + Short: "Set the current context", + RunE: func(cmd *cobra.Command, args []string) error { + name, err := requireArgs(cmd, args, 1) + if err != nil { + return err + } + + cfg, err := datumconfig.Load() + if err != nil { + return err + } + + ctx, ok := cfg.ContextByName(name) + if !ok { + return fmt.Errorf("context %q not found", name) + } + + cfg.CurrentContext = name + if err := datumconfig.Save(cfg); err != nil { + return err + } + + if ctx.Context.Cluster != "" { + if _, err := authutil.GetActiveUserKeyForCluster(ctx.Context.Cluster); err != nil { + fmt.Printf("Warning: No credentials found for cluster %q. Run `datumctl auth login` for this cluster.\n", ctx.Context.Cluster) + } + } + + cmd.Printf("Switched to context %q.\n", name) + return nil + }, + } + + return cmd +} diff --git a/internal/cmd/config/view.go b/internal/cmd/config/view.go new file mode 100644 index 0000000..c5e7709 --- /dev/null +++ b/internal/cmd/config/view.go @@ -0,0 +1,28 @@ +package config + +import ( + "github.com/spf13/cobra" + "go.datum.net/datumctl/internal/datumconfig" + "sigs.k8s.io/yaml" +) + +func newViewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "view", + Short: "Display the current configuration", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := datumconfig.Load() + if err != nil { + return err + } + cfgData, err := yaml.Marshal(cfg) + if err != nil { + return err + } + cmd.Print(string(cfgData)) + return nil + }, + } + + return cmd +} diff --git a/internal/cmd/docs/openapi.go b/internal/cmd/docs/openapi.go index 03945d3..b174f65 100644 --- a/internal/cmd/docs/openapi.go +++ b/internal/cmd/docs/openapi.go @@ -116,12 +116,16 @@ func runOpenAPI(ctx context.Context, cmd *cobra.Command, opts *openAPIOptions) e } func buildRESTConfig(ctx context.Context, opts *openAPIOptions) (*rest.Config, error) { - tknSrc, err := authutil.GetTokenSource(ctx) + userKey, _, err := authutil.GetUserKeyForCurrentContext() + if err != nil { + return nil, fmt.Errorf("get user key: %w", err) + } + tknSrc, err := authutil.GetTokenSourceForUser(ctx, userKey) if err != nil { return nil, fmt.Errorf("get token source: %w", err) } - apiHostname, err := authutil.GetAPIHostname() + apiHostname, err := authutil.GetAPIHostnameForUser(userKey) if err != nil { return nil, fmt.Errorf("get API hostname: %w", err) } diff --git a/internal/cmd/mcp/mcp.go b/internal/cmd/mcp/mcp.go index 8082187..51e5b23 100644 --- a/internal/cmd/mcp/mcp.go +++ b/internal/cmd/mcp/mcp.go @@ -13,6 +13,7 @@ import ( "go.datum.net/datumctl/internal/authutil" "go.datum.net/datumctl/internal/client" + "go.datum.net/datumctl/internal/datumconfig" serversvc "go.datum.net/datumctl/internal/mcp" ) @@ -37,13 +38,20 @@ MCP clients (e.g., Claude) connect over STDIO. Use --port to also expose a local HTTP debug API on 127.0.0.1:. Select a Datum context with exactly one of --organization or --project.`, RunE: func(cmd *cobra.Command, args []string) error { - // Exactly one of --organization or --project is required. - if (organization == "") == (project == "") { - return errors.New("exactly one of --organization or --project is required") + _, ctxEntry, clusterEntry, err := datumconfig.LoadCurrentContext() + if err != nil { + return err + } + if organization == "" && project == "" && ctxEntry != nil { + organization = ctxEntry.Context.OrganizationID + project = ctxEntry.Context.ProjectID + if namespace == "" { + namespace = ctxEntry.Context.Namespace + } } // Build *rest.Config from Datum context (no kubeconfig reliance). - cfg, err := restConfigFromFlags(cmd.Context(), organization, project) + cfg, err := restConfigFromFlags(cmd.Context(), organization, project, clusterEntry) if err != nil { return err } @@ -81,17 +89,29 @@ Select a Datum context with exactly one of --organization or --project.`, // restConfigFromFlags constructs a client-go *rest.Config using the same auth + host // pattern as internal/client/user_context.go, but scoped to an org OR a project. -func restConfigFromFlags(ctx context.Context, organizationID, projectID string) (*rest.Config, error) { +func restConfigFromFlags(ctx context.Context, organizationID, projectID string, clusterEntry *datumconfig.NamedCluster) (*rest.Config, error) { // OIDC token & API hostname from stored credentials - tknSrc, err := authutil.GetTokenSource(ctx) + if clusterEntry == nil { + return nil, authutil.ErrNoCurrentContext + } + userKey, err := authutil.GetActiveUserKeyForCluster(clusterEntry.Name) + if err != nil { + return nil, err + } + tknSrc, err := authutil.GetTokenSourceForUser(ctx, userKey) if err != nil { return nil, fmt.Errorf("get token source: %w", err) } - apiHostname, err := authutil.GetAPIHostname() + apiHostname, err := authutil.GetAPIHostnameForUser(userKey) if err != nil { return nil, fmt.Errorf("get API hostname: %w", err) } + baseServer := datumconfig.CleanBaseServer(datumconfig.EnsureScheme(apiHostname)) + if clusterEntry != nil && clusterEntry.Cluster.Server != "" { + baseServer = datumconfig.CleanBaseServer(datumconfig.EnsureScheme(clusterEntry.Cluster.Server)) + } + if (organizationID == "") == (projectID == "") { return nil, errors.New("exactly one of organizationID or projectID must be provided") } @@ -99,11 +119,11 @@ func restConfigFromFlags(ctx context.Context, organizationID, projectID string) // Build the control-plane endpoint similar to user_context.go var host string if organizationID != "" { - host = fmt.Sprintf("https://%s/apis/resourcemanager.miloapis.com/v1alpha1/organizations/%s/control-plane", - apiHostname, organizationID) + host = fmt.Sprintf("%s/apis/resourcemanager.miloapis.com/v1alpha1/organizations/%s/control-plane", + baseServer, organizationID) } else { - host = fmt.Sprintf("https://%s/apis/resourcemanager.miloapis.com/v1alpha1/projects/%s/control-plane", - apiHostname, projectID) + host = fmt.Sprintf("%s/apis/resourcemanager.miloapis.com/v1alpha1/projects/%s/control-plane", + baseServer, projectID) } return &rest.Config{ diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c5e8314..839dd2c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "go.datum.net/datumctl/internal/client" "go.datum.net/datumctl/internal/cmd/auth" + "go.datum.net/datumctl/internal/cmd/config" "go.datum.net/datumctl/internal/cmd/create" "go.datum.net/datumctl/internal/cmd/docs" "go.datum.net/datumctl/internal/cmd/mcp" @@ -45,6 +46,7 @@ func RootCmd() *cobra.Command { factory.AddFlagMutualExclusions(rootCmd) rootCmd.AddGroup(&cobra.Group{ID: "auth", Title: "Authentication"}) + rootCmd.AddGroup(&cobra.Group{ID: "config", Title: "Configuration"}) rootCmd.AddGroup(&cobra.Group{ID: "other", Title: "Other Commands"}) rootCmd.AddGroup(&cobra.Group{ID: "resource", Title: "Resource Management"}) @@ -56,6 +58,10 @@ func RootCmd() *cobra.Command { authCommand.GroupID = "auth" rootCmd.AddCommand(authCommand) + configCmd := config.Command() + configCmd.GroupID = "config" + rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(WrapResourceCommand(get.NewCmdGet("datumctl", factory, ioStreams))) rootCmd.AddCommand(WrapResourceCommand(delcmd.NewCmdDelete(factory, ioStreams))) diff --git a/internal/datumconfig/config.go b/internal/datumconfig/config.go new file mode 100644 index 0000000..ef3d57c --- /dev/null +++ b/internal/datumconfig/config.go @@ -0,0 +1,273 @@ +package datumconfig + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "sigs.k8s.io/yaml" +) + +const ( + DefaultAPIVersion = "datumctl.config.datum.net/v1alpha1" + DefaultKind = "DatumctlConfig" + DefaultNamespace = "default" +) + +type Config struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Clusters []NamedCluster `json:"clusters,omitempty" yaml:"clusters,omitempty"` + Users []NamedUser `json:"users,omitempty" yaml:"users,omitempty"` + Contexts []NamedContext `json:"contexts,omitempty" yaml:"contexts,omitempty"` + CurrentContext string `json:"current-context,omitempty" yaml:"current-context,omitempty"` +} + +type NamedCluster struct { + Name string `json:"name" yaml:"name"` + Cluster Cluster `json:"cluster" yaml:"cluster"` +} + +type Cluster struct { + Server string `json:"server" yaml:"server"` + TLSServerName string `json:"tls-server-name,omitempty" yaml:"tls-server-name,omitempty"` + InsecureSkipTLSVerify bool `json:"insecure-skip-tls-verify,omitempty" yaml:"insecure-skip-tls-verify,omitempty"` + CertificateAuthorityData string `json:"certificate-authority-data,omitempty" yaml:"certificate-authority-data,omitempty"` +} + +type NamedContext struct { + Name string `json:"name" yaml:"name"` + Context Context `json:"context" yaml:"context"` +} + +type Context struct { + Cluster string `json:"cluster" yaml:"cluster"` + User string `json:"user,omitempty" yaml:"user,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + ProjectID string `json:"project_id,omitempty" yaml:"project_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty" yaml:"organization_id,omitempty"` +} + +type NamedUser struct { + Name string `json:"name" yaml:"name"` + User User `json:"user" yaml:"user"` +} + +type User struct { + Key string `json:"key" yaml:"key"` +} + +func New() *Config { + return &Config{ + APIVersion: DefaultAPIVersion, + Kind: DefaultKind, + } +} + +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("get home dir: %w", err) + } + return filepath.Join(home, ".datumctl", "config"), nil +} + +func Load() (*Config, error) { + path, err := DefaultPath() + if err != nil { + return nil, err + } + return LoadFromPath(path) +} + +func LoadFromPath(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return New(), nil + } + return nil, fmt.Errorf("read config: %w", err) + } + + if len(strings.TrimSpace(string(data))) == 0 { + return New(), nil + } + + cfg := New() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + cfg.ensureDefaults() + return cfg, nil +} + +func Save(cfg *Config) error { + path, err := DefaultPath() + if err != nil { + return err + } + return SaveToPath(cfg, path) +} + +func SaveToPath(cfg *Config, path string) error { + if cfg == nil { + return errors.New("config is nil") + } + cfg.ensureDefaults() + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("ensure config dir: %w", err) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write temp config: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace config: %w", err) + } + + return nil +} + +func (c *Config) ensureDefaults() { + if c.APIVersion == "" { + c.APIVersion = DefaultAPIVersion + } + if c.Kind == "" { + c.Kind = DefaultKind + } +} + +func (c *Config) ContextByName(name string) (*NamedContext, bool) { + for i := range c.Contexts { + if c.Contexts[i].Name == name { + return &c.Contexts[i], true + } + } + return nil, false +} + +func (c *Config) ClusterByName(name string) (*NamedCluster, bool) { + for i := range c.Clusters { + if c.Clusters[i].Name == name { + return &c.Clusters[i], true + } + } + return nil, false +} + +func (c *Config) UserByName(name string) (*NamedUser, bool) { + for i := range c.Users { + if c.Users[i].Name == name { + return &c.Users[i], true + } + } + return nil, false +} + +func (c *Config) UpsertCluster(entry NamedCluster) { + for i := range c.Clusters { + if c.Clusters[i].Name == entry.Name { + c.Clusters[i] = entry + return + } + } + c.Clusters = append(c.Clusters, entry) +} + +func (c *Config) UpsertUser(entry NamedUser) { + for i := range c.Users { + if c.Users[i].Name == entry.Name { + c.Users[i] = entry + return + } + } + c.Users = append(c.Users, entry) +} + +func (c *Config) UpsertContext(entry NamedContext) { + for i := range c.Contexts { + if c.Contexts[i].Name == entry.Name { + c.Contexts[i] = entry + return + } + } + c.Contexts = append(c.Contexts, entry) +} + +func (c *Config) CurrentContextEntry() (*NamedContext, bool) { + if c.CurrentContext == "" { + return nil, false + } + return c.ContextByName(c.CurrentContext) +} + +func (c *Config) EnsureContextDefaults(ctx *Context) { + if ctx.Namespace == "" { + ctx.Namespace = DefaultNamespace + } +} + +func (c *Config) ValidateContext(ctx Context) error { + if ctx.Cluster == "" { + return errors.New("context cluster is required") + } + if ctx.ProjectID != "" && ctx.OrganizationID != "" { + return errors.New("context cannot set both project_id and organization_id") + } + return nil +} + +func (c *Config) ValidateCluster(cluster Cluster) error { + if cluster.Server == "" { + return errors.New("cluster server is required") + } + return nil +} + +func LoadCurrentContext() (*Config, *NamedContext, *NamedCluster, error) { + cfg, err := Load() + if err != nil { + return nil, nil, nil, err + } + ctx, ok := cfg.CurrentContextEntry() + if !ok { + return cfg, nil, nil, nil + } + if ctx.Context.Cluster == "" { + return cfg, ctx, nil, fmt.Errorf("context %q is missing cluster", ctx.Name) + } + cluster, ok := cfg.ClusterByName(ctx.Context.Cluster) + if !ok { + return cfg, ctx, nil, fmt.Errorf("cluster %q referenced by context %q not found", ctx.Context.Cluster, ctx.Name) + } + cfg.EnsureContextDefaults(&ctx.Context) + return cfg, ctx, cluster, nil +} + +func EnsureScheme(server string) string { + if server == "" { + return server + } + if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") { + return server + } + return "https://" + server +} + +func CleanBaseServer(server string) string { + if server == "" { + return server + } + return strings.TrimRight(server, "/") +} diff --git a/internal/datumconfig/config_test.go b/internal/datumconfig/config_test.go new file mode 100644 index 0000000..331b53c --- /dev/null +++ b/internal/datumconfig/config_test.go @@ -0,0 +1,65 @@ +package datumconfig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingConfigReturnsDefaults(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config") + + cfg, err := LoadFromPath(path) + if err != nil { + t.Fatalf("LoadFromPath: %v", err) + } + if cfg.APIVersion == "" || cfg.Kind == "" { + t.Fatalf("expected defaults, got apiVersion=%q kind=%q", cfg.APIVersion, cfg.Kind) + } +} + +func TestSaveAndLoadConfig(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "config") + + cfg := New() + cfg.UpsertCluster(NamedCluster{ + Name: "primary", + Cluster: Cluster{ + Server: "https://api.example.com", + }, + }) + cfg.UpsertContext(NamedContext{ + Name: "ctx", + Context: Context{ + Cluster: "primary", + Namespace: "default", + ProjectID: "proj", + OrganizationID: "", + }, + }) + cfg.CurrentContext = "ctx" + + if err := SaveToPath(cfg, path); err != nil { + t.Fatalf("SaveToPath: %v", err) + } + + loaded, err := LoadFromPath(path) + if err != nil { + t.Fatalf("LoadFromPath: %v", err) + } + if loaded.CurrentContext != "ctx" { + t.Fatalf("expected current-context ctx, got %q", loaded.CurrentContext) + } + if _, ok := loaded.ClusterByName("primary"); !ok { + t.Fatalf("expected cluster to be loaded") + } + if _, ok := loaded.ContextByName("ctx"); !ok { + t.Fatalf("expected context to be loaded") + } + + if err := os.Remove(path); err != nil { + t.Fatalf("cleanup: %v", err) + } +}