mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Rewrite the CLI as a thin client over the authenticated API so agents and scripts reach every resource the UI does (ROADMAP2 phase A2). - shared Client with bearer auth, error-envelope decoding, and stable exit codes: 0 ok / 1 error / 2 auth / 3 not-found / 4 validation / 5 unreachable - named profiles in ~/.containr.yaml (current_profile + profiles.*); resolution order --flag > env > profile > localhost default; CONTAINR_TOKEN / CONTAINR_API_URL / CONTAINR_PROFILE env support - --json prints raw API responses; --yes skips confirmations; destructive commands refuse non-interactively without it - auth login verifies the token against /user/profile before saving, interactive prompt via term.ReadPassword - commands for every resource: tokens, projects, services, deploy, deployments (logs/rollback), logs --follow (SSE), exec, variables (set/unset merge with secret masking preserved), databases (backup/restore/download), cron, templates, nodes + agent tokens, scaling, ha, security, gateway, admin, notifications - containr up: git remote + branch → create-or-reuse service → deploy Verified live: login/status/tokens/projects/services, up creating a service + deployment, non-TTY confirmation refusal, scope-capped admin rejection (exit 2), revoke. Authored By: TDvorak <[email protected]>
152 lines
3.9 KiB
Go
152 lines
3.9 KiB
Go
package commands
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// AuthCmd groups authentication commands.
|
|
var AuthCmd = &cobra.Command{
|
|
Use: "auth",
|
|
Short: "Authenticate with a Containr instance",
|
|
Long: `Login with a personal access token (cnp_...) created under
|
|
Settings → Personal Access Tokens, check status, or switch profiles.`,
|
|
}
|
|
|
|
var loginCmd = &cobra.Command{
|
|
Use: "login [token]",
|
|
Short: "Store a personal access token",
|
|
Long: `Login with a cnp_ token. Reads interactively when no argument is
|
|
given. The token is verified against the API before it is stored.`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runLogin,
|
|
}
|
|
|
|
var logoutCmd = &cobra.Command{
|
|
Use: "logout",
|
|
Short: "Remove the stored token",
|
|
RunE: runLogout,
|
|
}
|
|
|
|
var statusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show the active profile and verify the token",
|
|
RunE: runStatus,
|
|
}
|
|
|
|
func init() {
|
|
loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
|
|
loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
|
|
AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
|
|
}
|
|
|
|
type profileResponse struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
}
|
|
|
|
func runLogin(cmd *cobra.Command, args []string) error {
|
|
var token string
|
|
if len(args) > 0 {
|
|
token = args[0]
|
|
} else if isTerminal() {
|
|
fmt.Fprint(os.Stderr, "Token: ")
|
|
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token = string(raw)
|
|
}
|
|
if token == "" {
|
|
return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
|
|
}
|
|
|
|
// Verify before saving so a mistyped token fails loudly.
|
|
urlFlag, _ := cmd.Flags().GetString("url")
|
|
probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
|
|
if urlFlag != "" {
|
|
probe.BaseURL = normalizeBase(urlFlag)
|
|
}
|
|
var profile profileResponse
|
|
if err := probe.DoJSON("GET", "/user/profile", nil, &profile); err != nil {
|
|
return fmt.Errorf("token verification failed: %w", err)
|
|
}
|
|
|
|
name, _ := cmd.Flags().GetString("name")
|
|
if name == "" {
|
|
name = ActiveProfile()
|
|
}
|
|
if err := SaveProfile(name, probe.BaseURL, token); err != nil {
|
|
return fmt.Errorf("failed to save profile: %w", err)
|
|
}
|
|
|
|
if JSONMode() {
|
|
return PrintJSON(map[string]interface{}{
|
|
"profile": name, "api_url": probe.BaseURL,
|
|
"user": profile,
|
|
})
|
|
}
|
|
fmt.Printf("Logged in as %s (%s) — profile %q → %s\n", profile.Name, profile.Email, name, probe.BaseURL)
|
|
return nil
|
|
}
|
|
|
|
func runLogout(cmd *cobra.Command, args []string) error {
|
|
name := ActiveProfile()
|
|
if err := ClearProfileToken(name); err != nil {
|
|
return fmt.Errorf("failed to update config: %w", err)
|
|
}
|
|
if !JSONMode() {
|
|
fmt.Printf("Removed token from profile %q\n", name)
|
|
} else {
|
|
return PrintJSON(map[string]string{"status": "ok", "profile": name})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runStatus(cmd *cobra.Command, args []string) error {
|
|
client := NewClient()
|
|
profile := ActiveProfile()
|
|
|
|
if JSONMode() {
|
|
out := map[string]interface{}{
|
|
"profile": profile,
|
|
"api_url": client.BaseURL,
|
|
}
|
|
if client.Token != "" {
|
|
var p profileResponse
|
|
if err := client.DoJSON("GET", "/user/profile", nil, &p); err == nil {
|
|
out["user"] = p
|
|
out["authenticated"] = true
|
|
} else {
|
|
out["authenticated"] = false
|
|
}
|
|
} else {
|
|
out["authenticated"] = false
|
|
}
|
|
return PrintJSON(out)
|
|
}
|
|
|
|
fmt.Printf("Profile: %s\n", profile)
|
|
fmt.Printf("API URL: %s\n", client.BaseURL)
|
|
if client.Token == "" {
|
|
fmt.Println("Token: not set — run `containr auth login <token>`")
|
|
return nil
|
|
}
|
|
var p profileResponse
|
|
if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
|
|
fmt.Printf("Token: present but rejected (%v)\n", err)
|
|
return nil
|
|
}
|
|
fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
|
|
if p.IsAdmin {
|
|
fmt.Println("Role: platform admin")
|
|
}
|
|
return nil
|
|
}
|