mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
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]>
This commit is contained in:
1 parent
17e429b86b
commit
e51b20b2d4
26 files changed
+2798
-350
No files matched your search
@@ -0,0 +1,361 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AdminCmd groups platform-admin operations.
|
||||
var AdminCmd = &cobra.Command{
|
||||
Use: "admin",
|
||||
Short: "Platform administration (admin scope required)",
|
||||
}
|
||||
|
||||
var adminOverviewCmd = &cobra.Command{
|
||||
Use: "overview",
|
||||
Short: "Platform overview",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/admin/overview", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var adminUsersCmd = &cobra.Command{
|
||||
Use: "users",
|
||||
Short: "List all users",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/admin/users", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, u := range items {
|
||||
rows = append(rows, []string{
|
||||
str(u, "id"), str(u, "email"), str(u, "name"), str(u, "is_admin"), relTime(u, "created_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "EMAIL", "NAME", "ADMIN", "CREATED"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var adminSettingsCmd = &cobra.Command{
|
||||
Use: "settings",
|
||||
Short: "Show platform settings",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/settings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var adminAuditCmd = &cobra.Command{
|
||||
Use: "audit-logs",
|
||||
Short: "List audit log entries",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/audit-logs", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// GatewayCmd manages the API gateway (admin).
|
||||
var GatewayCmd = &cobra.Command{
|
||||
Use: "gateway",
|
||||
Short: "Manage the API gateway (admin)",
|
||||
}
|
||||
|
||||
var gatewayServicesCmd = &cobra.Command{
|
||||
Use: "services",
|
||||
Short: "List gateway services",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/gateway/services", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var gatewayKeysCmd = &cobra.Command{
|
||||
Use: "keys",
|
||||
Short: "List gateway API keys",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/gateway/keys", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var gatewayAnalyticsCmd = &cobra.Command{
|
||||
Use: "analytics",
|
||||
Short: "Gateway traffic analytics",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kind, _ := cmd.Flags().GetString("type")
|
||||
data, err := c.Do("GET", "/gateway/analytics/"+kind, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// HACmd manages high availability.
|
||||
var HACmd = &cobra.Command{
|
||||
Use: "ha",
|
||||
Short: "High availability controls",
|
||||
}
|
||||
|
||||
var haStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show HA status",
|
||||
RunE: simpleGet("/ha/status"),
|
||||
}
|
||||
|
||||
var haAlertsCmd = &cobra.Command{
|
||||
Use: "alerts",
|
||||
Short: "List active HA alerts",
|
||||
RunE: simpleGet("/ha/alerts/active"),
|
||||
}
|
||||
|
||||
var haHealthCmd = &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "List health check results",
|
||||
RunE: simpleGet("/ha/health/results"),
|
||||
}
|
||||
|
||||
var haPoliciesCmd = &cobra.Command{
|
||||
Use: "policies",
|
||||
Short: "List failover policies",
|
||||
RunE: simpleGet("/ha/failover/policies"),
|
||||
}
|
||||
|
||||
var haEnableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable the HA manager",
|
||||
RunE: simplePost("/ha/enable"),
|
||||
}
|
||||
|
||||
var haDisableCmd = &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Disable the HA manager",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !Confirm("Disable HA? Services will no longer be failed over.") {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
return simplePost("/ha/disable")(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
var haFailoverCmd = &cobra.Command{
|
||||
Use: "failover",
|
||||
Short: "Trigger a manual failover",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !Confirm("Trigger a platform failover now? Services may be rescheduled.") {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
return simplePost("/ha/failover")(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
// ScalingCmd manages autoscaling.
|
||||
var ScalingCmd = &cobra.Command{
|
||||
Use: "scaling",
|
||||
Short: "Autoscaling controls",
|
||||
}
|
||||
|
||||
var scalingStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show autoscaler status",
|
||||
RunE: simpleGet("/scaling/status"),
|
||||
}
|
||||
|
||||
var scalingPoliciesCmd = &cobra.Command{
|
||||
Use: "policies",
|
||||
Short: "List scaling policies",
|
||||
RunE: simpleGet("/scaling/policies"),
|
||||
}
|
||||
|
||||
var scalingServicesCmd = &cobra.Command{
|
||||
Use: "services",
|
||||
Short: "Show per-service scaling state",
|
||||
RunE: simpleGet("/scaling/services"),
|
||||
}
|
||||
|
||||
var scalingScaleCmd = &cobra.Command{
|
||||
Use: "scale <service-id> <replicas>",
|
||||
Short: "Manually scale a service",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var replicas int
|
||||
fmt.Sscanf(args[1], "%d", &replicas)
|
||||
data, err := c.Do("POST", "/scaling/services/"+args[0]+"/scale", map[string]int{"replicas": replicas})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SecurityCmd manages security scans and findings.
|
||||
var SecurityCmd = &cobra.Command{
|
||||
Use: "security",
|
||||
Short: "Security scans and vulnerabilities",
|
||||
}
|
||||
|
||||
var securityScanCmd = &cobra.Command{
|
||||
Use: "scan <project-id>",
|
||||
Short: "Run a security scan",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanType, _ := cmd.Flags().GetString("type")
|
||||
serviceID, _ := cmd.Flags().GetString("service")
|
||||
body := map[string]interface{}{"project_id": args[0], "scan_type": scanType}
|
||||
if serviceID != "" {
|
||||
body["service_id"] = serviceID
|
||||
}
|
||||
data, err := c.Do("POST", "/security/scans", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var securityVulnsCmd = &cobra.Command{
|
||||
Use: "vulnerabilities <project-id>",
|
||||
Short: "List a project's vulnerabilities",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/projects/"+args[0]+"/vulnerabilities", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var securityMetricsCmd = &cobra.Command{
|
||||
Use: "metrics <project-id>",
|
||||
Short: "Show a project's security metrics",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/projects/"+args[0]+"/security/metrics", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// simpleGet/simplePost cover the many read/trigger endpoints whose full
|
||||
// JSON response is the useful output.
|
||||
func simpleGet(path string) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func simplePost(path string) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", path, map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
gatewayAnalyticsCmd.Flags().String("type", "traffic", "traffic|ops")
|
||||
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
|
||||
securityScanCmd.Flags().String("service", "", "limit scan to one service")
|
||||
|
||||
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd)
|
||||
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
|
||||
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
|
||||
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
|
||||
SecurityCmd.AddCommand(securityScanCmd, securityVulnsCmd, securityMetricsCmd)
|
||||
}
|
||||
@@ -2,139 +2,150 @@ package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// AuthCmd represents the auth command
|
||||
// AuthCmd groups authentication commands.
|
||||
var AuthCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authenticate with Containr API",
|
||||
Long: `Manage authentication with the Containr API.
|
||||
You can login, logout, and check your current authentication status.`,
|
||||
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.`,
|
||||
}
|
||||
|
||||
// loginCmd represents the login command
|
||||
var loginCmd = &cobra.Command{
|
||||
Use: "login [token]",
|
||||
Short: "Login to Containr",
|
||||
Long: `Login to Containr using your API token.
|
||||
You can get your token from the Containr web interface.`,
|
||||
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,
|
||||
}
|
||||
|
||||
// logoutCmd represents the logout command
|
||||
var logoutCmd = &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Logout from Containr",
|
||||
Long: `Remove stored authentication credentials.`,
|
||||
Short: "Remove the stored token",
|
||||
RunE: runLogout,
|
||||
}
|
||||
|
||||
// statusCmd represents the status command
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check authentication status",
|
||||
Long: `Check if you are currently authenticated with Containr.`,
|
||||
Short: "Show the active profile and verify the token",
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
func init() {
|
||||
AuthCmd.AddCommand(loginCmd)
|
||||
AuthCmd.AddCommand(logoutCmd)
|
||||
AuthCmd.AddCommand(statusCmd)
|
||||
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 {
|
||||
// Prompt for token
|
||||
fmt.Print("Enter your Containr API token: ")
|
||||
fmt.Scanln(&token)
|
||||
} 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 fmt.Errorf("token is required")
|
||||
return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
|
||||
}
|
||||
|
||||
// Store token in config
|
||||
viper.Set("token", token)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save token: %w", err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Successfully logged in to Containr")
|
||||
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 {
|
||||
// Remove token from config
|
||||
viper.Set("token", "")
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to remove token: %w", err)
|
||||
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})
|
||||
}
|
||||
|
||||
fmt.Println("✓ Successfully logged out from Containr")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, args []string) error {
|
||||
token := viper.GetString("token")
|
||||
if token == "" {
|
||||
fmt.Println("❌ Not authenticated")
|
||||
fmt.Println("Run 'containr auth login <token>' to authenticate")
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Println("✓ Authenticated with Containr")
|
||||
apiURL := buildAPIURL("/user/profile")
|
||||
fmt.Printf("API URL: %s\n", strings.TrimSuffix(buildAPIURL(""), "/"))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create verification request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠ Token verification unavailable: %v\n", err)
|
||||
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
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
fmt.Println("✓ Token verified against API")
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
fmt.Println("❌ Token rejected by API (expired/invalid)")
|
||||
default:
|
||||
fmt.Printf("⚠ Token verification returned unexpected status: %s\n", resp.Status)
|
||||
fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
|
||||
if p.IsAdmin {
|
||||
fmt.Println("Role: platform admin")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildAPIURL(endpoint string) string {
|
||||
baseURL := viper.GetString("api-url")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080/api/v1"
|
||||
}
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
if endpoint == "" {
|
||||
return baseURL
|
||||
}
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
return baseURL + endpoint
|
||||
}
|
||||
@@ -6,16 +6,52 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func TestBuildAPIURLDefaultsAndTrimming(t *testing.T) {
|
||||
func TestResolveAPIURLDefaultsAndTrimming(t *testing.T) {
|
||||
viper.Set("api-url", "")
|
||||
got := buildAPIURL("/user/profile")
|
||||
if got != "http://localhost:8080/api/v1/user/profile" {
|
||||
viper.Set("current_profile", "")
|
||||
viper.Set("profiles.default.api_url", "")
|
||||
if got := ResolveAPIURL(); got != "http://localhost:8080/api/v1" {
|
||||
t.Fatalf("unexpected default api url: %s", got)
|
||||
}
|
||||
|
||||
viper.Set("api-url", "https://api.example.com/api/v1/")
|
||||
got = buildAPIURL("user/profile")
|
||||
if got != "https://api.example.com/api/v1/user/profile" {
|
||||
if got := ResolveAPIURL(); got != "https://api.example.com/api/v1" {
|
||||
t.Fatalf("unexpected trimmed api url: %s", got)
|
||||
}
|
||||
|
||||
// Bare hosts gain the /api/v1 suffix.
|
||||
viper.Set("api-url", "https://containr.example.com")
|
||||
if got := ResolveAPIURL(); got != "https://containr.example.com/api/v1" {
|
||||
t.Fatalf("unexpected suffixed api url: %s", got)
|
||||
}
|
||||
viper.Set("api-url", "")
|
||||
}
|
||||
|
||||
func TestResolveTokenProfileAndEnv(t *testing.T) {
|
||||
viper.Set("token", "")
|
||||
viper.Set("current_profile", "staging")
|
||||
viper.Set("profiles.staging.token", "cnp_staging")
|
||||
if got := ResolveToken(); got != "cnp_staging" {
|
||||
t.Fatalf("expected profile token, got %s", got)
|
||||
}
|
||||
t.Setenv("CONTAINR_TOKEN", "cnp_env")
|
||||
if got := ResolveToken(); got != "cnp_env" {
|
||||
t.Fatalf("env should win over profile, got %s", got)
|
||||
}
|
||||
viper.Set("token", "cnp_flag")
|
||||
if got := ResolveToken(); got != "cnp_flag" {
|
||||
t.Fatalf("flag should win over env, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeMapping(t *testing.T) {
|
||||
if got := exitCodeFor(401); got != ExitAuth {
|
||||
t.Fatalf("401 → %d, want %d", got, ExitAuth)
|
||||
}
|
||||
if got := exitCodeFor(404); got != ExitNotFound {
|
||||
t.Fatalf("404 → %d, want %d", got, ExitNotFound)
|
||||
}
|
||||
if got := exitCodeFor(400); got != ExitValidation {
|
||||
t.Fatalf("400 → %d, want %d", got, ExitValidation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exit codes — stable for scripting and agent use.
|
||||
const (
|
||||
ExitOK = 0
|
||||
ExitError = 1
|
||||
ExitAuth = 2
|
||||
ExitNotFound = 3
|
||||
ExitValidation = 4
|
||||
ExitUnavailable = 5
|
||||
)
|
||||
|
||||
// APIError is a non-2xx response from the Containr API. Code carries the
|
||||
// stable exit code so `main` can map errors to exit statuses.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Message string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return fmt.Sprintf("request failed with status %d", e.Status)
|
||||
}
|
||||
|
||||
func exitCodeFor(status int) int {
|
||||
switch status {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return ExitAuth
|
||||
case http.StatusNotFound:
|
||||
return ExitNotFound
|
||||
case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity:
|
||||
return ExitValidation
|
||||
default:
|
||||
return ExitError
|
||||
}
|
||||
}
|
||||
|
||||
// Client is a thin authenticated HTTP client for the Containr API. All CLI
|
||||
// commands go through it — no command builds its own request.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient resolves the active profile (flag > env > current_profile) and
|
||||
// returns a client ready to call the API. Call RequireAuth() for commands
|
||||
// that need a token.
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
BaseURL: ResolveAPIURL(),
|
||||
Token: ResolveToken(),
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth returns an error suitable for the CLI when no token is set.
|
||||
func (c *Client) RequireAuth() error {
|
||||
if c.Token == "" {
|
||||
return &APIError{
|
||||
Status: 0,
|
||||
Message: "not authenticated — run `containr auth login <token>` or set CONTAINR_TOKEN",
|
||||
ExitCode: ExitAuth,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do performs an API request. body may be nil, a []byte, or any value that
|
||||
// marshals to JSON. Returns the raw response body.
|
||||
func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
var data []byte
|
||||
switch v := body.(type) {
|
||||
case []byte:
|
||||
data = v
|
||||
default:
|
||||
var err error
|
||||
data, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode request: %w", err)
|
||||
}
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
url := c.BaseURL + path
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
Status: 0,
|
||||
Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err),
|
||||
ExitCode: ExitUnavailable,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
msg := strings.TrimSpace(string(data))
|
||||
var envelope struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
|
||||
msg = envelope.Error
|
||||
}
|
||||
return nil, &APIError{
|
||||
Status: resp.StatusCode,
|
||||
Message: msg,
|
||||
ExitCode: exitCodeFor(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DoJSON performs a request and unmarshals the response into out.
|
||||
func (c *Client) DoJSON(method, path string, body interface{}, out interface{}) error {
|
||||
data, err := c.Do(method, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// ActiveProfile resolves which profile applies: --profile flag >
|
||||
// CONTAINR_PROFILE env > current_profile in config > "default".
|
||||
func ActiveProfile() string {
|
||||
if p := viper.GetString("profile"); p != "" {
|
||||
return p
|
||||
}
|
||||
if p := os.Getenv("CONTAINR_PROFILE"); p != "" {
|
||||
return p
|
||||
}
|
||||
if p := viper.GetString("current_profile"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// ResolveAPIURL returns the effective API base URL:
|
||||
// --api-url flag > CONTAINR_API_URL env > profile.api_url > localhost default.
|
||||
func ResolveAPIURL() string {
|
||||
if u := viper.GetString("api-url"); u != "" {
|
||||
return normalizeBase(u)
|
||||
}
|
||||
if u := os.Getenv("CONTAINR_API_URL"); u != "" {
|
||||
return normalizeBase(u)
|
||||
}
|
||||
if u := viper.GetString("profiles." + ActiveProfile() + ".api_url"); u != "" {
|
||||
return normalizeBase(u)
|
||||
}
|
||||
return "http://localhost:8080/api/v1"
|
||||
}
|
||||
|
||||
// ResolveToken returns the effective auth token:
|
||||
// --token flag > CONTAINR_TOKEN env > profile.token.
|
||||
func ResolveToken() string {
|
||||
if t := viper.GetString("token"); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := os.Getenv("CONTAINR_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
return viper.GetString("profiles." + ActiveProfile() + ".token")
|
||||
}
|
||||
|
||||
func normalizeBase(u string) string {
|
||||
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
|
||||
// Accept the bare host too — append the API prefix when missing.
|
||||
if u != "" && !strings.HasSuffix(u, "/api/v1") {
|
||||
u += "/api/v1"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// SaveProfile persists api_url/token under a named profile and makes it
|
||||
// current. Creates the config file if none exists.
|
||||
func SaveProfile(name, apiURL, token string) error {
|
||||
base := "profiles." + name
|
||||
if apiURL != "" {
|
||||
viper.Set(base+".api_url", apiURL)
|
||||
}
|
||||
if token != "" {
|
||||
viper.Set(base+".token", token)
|
||||
}
|
||||
viper.Set("current_profile", name)
|
||||
return writeConfig()
|
||||
}
|
||||
|
||||
// ClearProfileToken removes only the token from the active profile.
|
||||
func ClearProfileToken(name string) error {
|
||||
viper.Set("profiles."+name+".token", "")
|
||||
return writeConfig()
|
||||
}
|
||||
|
||||
// ListProfiles returns profile name → api_url for `auth status --all`.
|
||||
func ListProfiles() map[string]string {
|
||||
out := map[string]string{}
|
||||
profiles := viper.GetStringMap("profiles")
|
||||
for name := range profiles {
|
||||
if u := viper.GetString("profiles." + name + ".api_url"); u != "" {
|
||||
out[name] = u
|
||||
} else {
|
||||
out[name] = "(no url)"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeConfig() error {
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
// No config file yet — create one.
|
||||
home, herr := os.UserHomeDir()
|
||||
if herr != nil {
|
||||
return fmt.Errorf("cannot locate home directory: %w", herr)
|
||||
}
|
||||
return viper.WriteConfigAs(home + "/.containr.yaml")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CronCmd manages scheduled jobs.
|
||||
var CronCmd = &cobra.Command{
|
||||
Use: "cron",
|
||||
Aliases: []string{"cron-jobs"},
|
||||
Short: "Manage cron jobs",
|
||||
}
|
||||
|
||||
var cronListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List cron jobs",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/cron-jobs", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "cron_jobs", "jobs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, j := range items {
|
||||
rows = append(rows, []string{
|
||||
str(j, "id"), str(j, "name"), str(j, "schedule"), str(j, "service_id"),
|
||||
str(j, "enabled", "is_active"), relTime(j, "last_run_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "SCHEDULE", "SERVICE", "ENABLED", "LAST RUN"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a cron job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/cron-jobs/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a cron job (5-field expression)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
for _, f := range []string{"name", "schedule", "command", "service-id", "timezone"} {
|
||||
v, _ := cmd.Flags().GetString(f)
|
||||
if v != "" {
|
||||
key := f
|
||||
if f == "service-id" {
|
||||
key = "service_id"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if body["service_id"] == nil || body["schedule"] == nil || body["command"] == nil {
|
||||
return &APIError{Message: "--service-id, --schedule, and --command are required", ExitCode: ExitValidation}
|
||||
}
|
||||
data, err := c.Do("POST", "/cron-jobs", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a cron job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete cron job %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/cron-jobs/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted cron job %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronTriggerCmd = &cobra.Command{
|
||||
Use: "trigger <id>",
|
||||
Short: "Run a cron job now",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/cron-jobs/"+args[0]+"/trigger", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var cronExecutionsCmd = &cobra.Command{
|
||||
Use: "executions <id>",
|
||||
Short: "List a cron job's execution history",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/cron-jobs/"+args[0]+"/executions", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "executions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, e := range items {
|
||||
rows = append(rows, []string{
|
||||
str(e, "id"), str(e, "status", "exit_code"), str(e, "started_at"), str(e, "output", "stdout"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "STATUS", "STARTED", "OUTPUT"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
cronCreateCmd.Flags().String("name", "", "job name")
|
||||
cronCreateCmd.Flags().String("service-id", "", "target service id (required)")
|
||||
cronCreateCmd.Flags().String("schedule", "", "5-field cron expression (required)")
|
||||
cronCreateCmd.Flags().String("command", "", "command run inside the container (required)")
|
||||
cronCreateCmd.Flags().String("timezone", "", "IANA timezone (default UTC)")
|
||||
CronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronDeleteCmd, cronTriggerCmd, cronExecutionsCmd)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// DatabasesCmd manages managed databases.
|
||||
var DatabasesCmd = &cobra.Command{
|
||||
Use: "databases",
|
||||
Aliases: []string{"database", "db"},
|
||||
Short: "Manage databases",
|
||||
}
|
||||
|
||||
var dbListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List managed databases",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/databases", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "databases")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
rows = append(rows, []string{
|
||||
str(d, "id"), str(d, "name"), str(d, "type", "engine"), str(d, "status"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a database (connection URL included)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/databases/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Provision a managed database",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbType, _ := cmd.Flags().GetString("type")
|
||||
body := map[string]interface{}{"name": args[0], "type": dbType}
|
||||
for _, f := range []string{"version", "storage-size"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
if f == "storage-size" {
|
||||
key = "storage_size"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
data, err := c.Do("POST", "/databases", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a database and its data",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete database %s and all its data?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/databases/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted database %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbActionCmd = &cobra.Command{
|
||||
Use: "action <id> <start|stop|restart>",
|
||||
Short: "Start, stop, or restart a database",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/databases/"+args[0]+"/action", map[string]string{"action": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbBackupCmd = &cobra.Command{
|
||||
Use: "backup <id>",
|
||||
Short: "Take a manual backup snapshot",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/databases/"+args[0]+"/backup", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbRestoreCmd = &cobra.Command{
|
||||
Use: "restore <id> <backup-id>",
|
||||
Short: "Restore a database from a backup",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Restore database %s from backup %s? Current data will be overwritten.", args[0], args[1])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("POST", "/databases/"+args[0]+"/restore", map[string]string{"backup_id": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbDownloadCmd = &cobra.Command{
|
||||
Use: "download-backup <id> <backup-id> [file]",
|
||||
Short: "Download a backup archive",
|
||||
Args: cobra.RangeArgs(2, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", fmt.Sprintf("/databases/%s/backups/%s/download", args[0], args[1]), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := args[1] + ".tar.gz"
|
||||
if len(args) > 2 {
|
||||
out = args[2]
|
||||
}
|
||||
if err := os.WriteFile(out, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"file": out})
|
||||
}
|
||||
fmt.Printf("Wrote %s (%d bytes)\n", out, len(data))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse")
|
||||
dbCreateCmd.Flags().String("version", "", "engine version tag")
|
||||
dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)")
|
||||
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// DeployCmd triggers a deployment for a service.
|
||||
var DeployCmd = &cobra.Command{
|
||||
Use: "deploy <service-id>",
|
||||
Short: "Trigger a deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"trigger": "cli"}
|
||||
for _, f := range []string{"commit-hash", "branch"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := "commit_hash"
|
||||
if f == "branch" {
|
||||
key = "branch"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
data, err := c.Do("POST", "/services/"+args[0]+"/deployments", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
d, _ := unwrapObject(data, "deployment")
|
||||
fmt.Printf("Deployment %s started for service %s (status: %s)\n",
|
||||
str(d, "id"), args[0], str(d, "status"))
|
||||
fmt.Println("Follow with: containr deployments logs " + str(d, "id"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DeploymentsCmd manages deployment history.
|
||||
var DeploymentsCmd = &cobra.Command{
|
||||
Use: "deployments",
|
||||
Aliases: []string{"deployment"},
|
||||
Short: "Inspect deployments",
|
||||
}
|
||||
|
||||
var deploymentsListCmd = &cobra.Command{
|
||||
Use: "list [service-id]",
|
||||
Short: "List deployments (all recent, or for one service)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/deployments"
|
||||
if len(args) > 0 {
|
||||
path = "/services/" + args[0] + "/deployments"
|
||||
}
|
||||
data, err := c.Do("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "deployments")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
rows = append(rows, []string{
|
||||
str(d, "id"), str(d, "service_id"), str(d, "status"), str(d, "trigger"), relTime(d, "created_at", "started_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "SERVICE", "STATUS", "TRIGGER", "STARTED"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deploymentsGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/deployments/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deploymentsLogsCmd = &cobra.Command{
|
||||
Use: "logs <id>",
|
||||
Short: "Stream a deployment's build/deploy logs",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDeploymentLogs,
|
||||
}
|
||||
|
||||
var deploymentsRollbackCmd = &cobra.Command{
|
||||
Use: "rollback <id>",
|
||||
Short: "Roll back to a deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/deployments/"+args[0]+"/rollback", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Rolled back to deployment %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
DeployCmd.Flags().String("commit-hash", "", "commit to deploy")
|
||||
DeployCmd.Flags().String("branch", "", "branch to deploy")
|
||||
DeploymentsCmd.AddCommand(deploymentsListCmd, deploymentsGetCmd, deploymentsLogsCmd, deploymentsRollbackCmd)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// client returns an authenticated API client or an ExitAuth error.
|
||||
func client() (*Client, error) {
|
||||
c := NewClient()
|
||||
if err := c.RequireAuth(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// unwrapList accepts both top-level arrays and {"<key>": [...]} envelopes,
|
||||
// which the Containr API uses inconsistently between endpoints.
|
||||
func unwrapList(data []byte, keys ...string) ([]map[string]interface{}, error) {
|
||||
var arr []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return nil, fmt.Errorf("unexpected response shape: %w", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if raw, ok := obj[k]; ok {
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode %q: %w", k, err)
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
}
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
func unwrapObject(data []byte, keys ...string) (map[string]interface{}, error) {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return nil, fmt.Errorf("unexpected response shape: %w", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if inner, ok := obj[k].(map[string]interface{}); ok {
|
||||
return inner, nil
|
||||
}
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// str extracts a display string from a response map.
|
||||
func str(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
if t == float64(int64(t)) {
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
}
|
||||
return fmt.Sprintf("%v", t)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func shortID(m map[string]interface{}) string {
|
||||
id := str(m, "id")
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func relTime(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
s, ok := m[k].(string)
|
||||
if !ok || s == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// printRows renders a table, or raw JSON for the full response in --json mode.
|
||||
func printRows(raw []byte, headers []string, rows [][]string) {
|
||||
if JSONMode() {
|
||||
PrintRaw(raw)
|
||||
return
|
||||
}
|
||||
PrintTable(headers, rows)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// LogsCmd prints service runtime logs.
|
||||
var LogsCmd = &cobra.Command{
|
||||
Use: "logs <service-id>",
|
||||
Short: "Print or follow service logs",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
follow, _ := cmd.Flags().GetBool("follow")
|
||||
tail, _ := cmd.Flags().GetInt("tail")
|
||||
path := fmt.Sprintf("/services/%s/logs?tail=%d", args[0], tail)
|
||||
if follow {
|
||||
return streamSSE(c, path+"&follow=true")
|
||||
}
|
||||
data, err := c.Do("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printLogEntries(data)
|
||||
},
|
||||
}
|
||||
|
||||
// ExecCmd runs a one-off command inside a service container.
|
||||
var ExecCmd = &cobra.Command{
|
||||
Use: "exec <service-id> -- <command>",
|
||||
Short: "Run a one-off command in the service container (30s ceiling)",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
command := strings.Join(args[1:], " ")
|
||||
if command == "" {
|
||||
return &APIError{Message: "no command given — usage: containr exec <id> -- <command>", ExitCode: ExitValidation}
|
||||
}
|
||||
data, err := c.Do("POST", "/services/"+args[0]+"/exec", map[string]string{"command": command})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
resp, _ := unwrapObject(data)
|
||||
fmt.Print(str(resp, "output", "stdout"))
|
||||
if code := str(resp, "exit_code", "exitCode"); code != "-" && code != "0" {
|
||||
return &APIError{Message: fmt.Sprintf("command exited with %s", code), ExitCode: ExitError}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
Stream string `json:"stream"`
|
||||
}
|
||||
|
||||
// printLogEntries decodes the {"logs":[...]} envelope both log endpoints use.
|
||||
func printLogEntries(data []byte) error {
|
||||
var payload struct {
|
||||
Logs []logEntry `json:"logs"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
for _, e := range payload.Logs {
|
||||
fmt.Println(e.Message)
|
||||
}
|
||||
if len(payload.Logs) == 0 && payload.Message != "" {
|
||||
fmt.Fprintln(os.Stderr, payload.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDeploymentLogs prints persisted build+runtime logs for a deployment.
|
||||
func runDeploymentLogs(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logType, _ := cmd.Flags().GetString("type")
|
||||
data, err := c.Do("GET", "/deployments/"+args[0]+"/logs?type="+logType, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
var payload struct {
|
||||
BuildLog string `json:"build_log"`
|
||||
RuntimeLog string `json:"runtime_log"`
|
||||
}
|
||||
if json.Unmarshal(data, &payload) == nil && (payload.BuildLog != "" || payload.RuntimeLog != "") {
|
||||
if payload.BuildLog != "" {
|
||||
fmt.Print(payload.BuildLog)
|
||||
}
|
||||
if payload.RuntimeLog != "" {
|
||||
fmt.Print(payload.RuntimeLog)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return printLogEntries(data)
|
||||
}
|
||||
|
||||
// streamSSE consumes a text/event-stream response and prints each
|
||||
// `data:` line's message field until the server closes the stream.
|
||||
func streamSSE(c *Client, path string) error {
|
||||
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return &APIError{Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return &APIError{Status: resp.StatusCode, ExitCode: exitCodeFor(resp.StatusCode)}
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
var e logEntry
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &e); err == nil {
|
||||
fmt.Println(e.Message)
|
||||
} else {
|
||||
fmt.Println(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func init() {
|
||||
LogsCmd.Flags().BoolP("follow", "f", false, "stream new log lines (SSE)")
|
||||
LogsCmd.Flags().Int("tail", 100, "number of lines to return")
|
||||
deploymentsLogsCmd.Flags().String("type", "all", "log type: all|build|runtime")
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NodesCmd manages node agents (admin).
|
||||
var NodesCmd = &cobra.Command{
|
||||
Use: "nodes",
|
||||
Aliases: []string{"node", "agents"},
|
||||
Short: "Manage node agents (admin)",
|
||||
}
|
||||
|
||||
var nodesListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List registered node agents",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/agents", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "agents")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, a := range items {
|
||||
rows = append(rows, []string{
|
||||
str(a, "id"), str(a, "name", "hostname"), str(a, "status"), str(a, "version"), relTime(a, "last_heartbeat_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "STATUS", "VERSION", "HEARTBEAT"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a node agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/agents/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodesDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Remove a node agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Remove node agent %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/agents/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Removed agent %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Agent tokens (cagt_) onboard new nodes.
|
||||
var nodeTokensCmd = &cobra.Command{
|
||||
Use: "tokens",
|
||||
Short: "Manage agent onboarding tokens",
|
||||
}
|
||||
|
||||
var nodeTokensIssueCmd = &cobra.Command{
|
||||
Use: "issue [label]",
|
||||
Short: "Issue an onboarding token (shown once)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if len(args) > 0 {
|
||||
body["label"] = args[0]
|
||||
}
|
||||
data, err := c.Do("POST", "/agent-tokens", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeTokensListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List onboarding tokens",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/agent-tokens", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeTokensRevokeCmd = &cobra.Command{
|
||||
Use: "revoke <id>",
|
||||
Short: "Revoke an onboarding token",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("DELETE", "/agent-tokens/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
|
||||
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NotificationsCmd lists and manages notifications.
|
||||
var NotificationsCmd = &cobra.Command{
|
||||
Use: "notifications",
|
||||
Aliases: []string{"notifs"},
|
||||
Short: "Manage notifications",
|
||||
}
|
||||
|
||||
var notifListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List notifications",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/notifications", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "notifications")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, n := range items {
|
||||
rows = append(rows, []string{
|
||||
str(n, "id"), str(n, "type"), str(n, "title"), str(n, "read"), relTime(n, "created_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "TYPE", "TITLE", "READ", "AGE"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var notifReadCmd = &cobra.Command{
|
||||
Use: "read <id>|all",
|
||||
Short: "Mark a notification (or all) as read",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/notifications/" + args[0] + "/read"
|
||||
if args[0] == "all" {
|
||||
path = "/notifications/read-all"
|
||||
}
|
||||
data, err := c.Do("POST", path, map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
NotificationsCmd.AddCommand(notifListCmd, notifReadCmd)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// JSONMode reports whether --json was passed. In JSON mode every command
|
||||
// prints the raw API response and nothing else — safe for agents and pipes.
|
||||
func JSONMode() bool {
|
||||
return viper.GetBool("json")
|
||||
}
|
||||
|
||||
// PrintResult renders data: pretty JSON in --json mode, otherwise the
|
||||
// default JSON rendering is used unless a command prints its own table
|
||||
// first (commands that render a table should check JSONMode() themselves).
|
||||
func PrintJSON(v interface{}) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintRaw prints raw response bytes as pretty JSON when they decode as
|
||||
// JSON, else verbatim.
|
||||
func PrintRaw(data []byte) {
|
||||
var v interface{}
|
||||
if json.Unmarshal(data, &v) == nil {
|
||||
out, _ := json.MarshalIndent(v, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
// PrintTable renders rows under headers with tabwriter alignment.
|
||||
// Skipped entirely in JSON mode.
|
||||
func PrintTable(headers []string, rows [][]string) {
|
||||
if JSONMode() {
|
||||
return
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, strings.Join(headers, "\t"))
|
||||
for _, row := range rows {
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// Confirm asks the user to confirm a destructive action. Returns true
|
||||
// immediately when --yes/--force was passed or stdin is not a TTY and
|
||||
// --yes was given. In non-interactive contexts without --yes it refuses.
|
||||
func Confirm(prompt string) bool {
|
||||
if viper.GetBool("yes") {
|
||||
return true
|
||||
}
|
||||
if !isTerminal() {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
func isTerminal() bool {
|
||||
info, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// ExitCode inspects an error and returns the process exit code.
|
||||
func ExitCode(err error) int {
|
||||
if err == nil {
|
||||
return ExitOK
|
||||
}
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.ExitCode
|
||||
}
|
||||
return ExitError
|
||||
}
|
||||
@@ -1,219 +1,123 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"containr/internal/pkg/utils"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Project represents a Containr project
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectsCmd represents the projects command
|
||||
// ProjectsCmd manages projects.
|
||||
var ProjectsCmd = &cobra.Command{
|
||||
Use: "projects",
|
||||
Short: "Manage projects",
|
||||
Long: `Manage your Containr projects.
|
||||
You can list, create, update, and delete projects.`,
|
||||
Use: "projects",
|
||||
Aliases: []string{"project"},
|
||||
Short: "Manage projects",
|
||||
}
|
||||
|
||||
// listProjectsCmd represents the list command
|
||||
var listProjectsCmd = &cobra.Command{
|
||||
var projectsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all projects",
|
||||
Long: `List all your Containr projects.`,
|
||||
RunE: runListProjects,
|
||||
Short: "List projects",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/projects", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "projects")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, p := range items {
|
||||
rows = append(rows, []string{
|
||||
str(p, "id"), str(p, "name"), str(p, "description"), relTime(p, "created_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "DESCRIPTION", "CREATED"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// createProjectCmd represents the create command
|
||||
var createProjectCmd = &cobra.Command{
|
||||
Use: "create [name]",
|
||||
Short: "Create a new project",
|
||||
Long: `Create a new Containr project.
|
||||
Provide a name and optional description.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runCreateProject,
|
||||
}
|
||||
|
||||
// deleteProjectCmd represents the delete command
|
||||
var deleteProjectCmd = &cobra.Command{
|
||||
Use: "delete [project-id]",
|
||||
Short: "Delete a project",
|
||||
Long: `Delete a Containr project by ID.`,
|
||||
var projectsGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDeleteProject,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/projects/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
p, _ := unwrapObject(data, "project")
|
||||
fmt.Printf("ID: %s\n", str(p, "id"))
|
||||
fmt.Printf("Name: %s\n", str(p, "name"))
|
||||
fmt.Printf("Description: %s\n", str(p, "description"))
|
||||
fmt.Printf("Created: %s\n", str(p, "created_at"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var projectDescription string
|
||||
var projectsCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
body := map[string]interface{}{"name": args[0]}
|
||||
if desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
data, err := c.Do("POST", "/projects", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
p, _ := unwrapObject(data, "project")
|
||||
fmt.Printf("Created project %s (%s)\n", str(p, "name"), str(p, "id"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// getAPIURL constructs the full API URL for a given endpoint
|
||||
func getAPIURL(endpoint string) string {
|
||||
baseURL := viper.GetString("api-url")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080/api/v1" // Default for development
|
||||
}
|
||||
|
||||
// Ensure baseURL doesn't end with / and endpoint starts with /
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
|
||||
return baseURL + endpoint
|
||||
var projectsDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a project and all its services",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete project %s and all its services?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/projects/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted project %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
ProjectsCmd.AddCommand(listProjectsCmd)
|
||||
ProjectsCmd.AddCommand(createProjectCmd)
|
||||
ProjectsCmd.AddCommand(deleteProjectCmd)
|
||||
|
||||
// Add flags
|
||||
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
|
||||
}
|
||||
|
||||
func runListProjects(cmd *cobra.Command, args []string) error {
|
||||
apiURL := getAPIURL("/projects")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var projects []Project
|
||||
if err := json.Unmarshal(body, &projects); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(projects) == 0 {
|
||||
fmt.Println("No projects found")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Your Projects:")
|
||||
fmt.Println()
|
||||
for _, project := range projects {
|
||||
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
|
||||
if project.Description != "" {
|
||||
fmt.Printf(" %s\n", project.Description)
|
||||
}
|
||||
fmt.Printf(" Created: %s\n", utils.FormatTime(project.CreatedAt))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCreateProject(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
projectData := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
|
||||
if projectDescription != "" {
|
||||
projectData["description"] = projectDescription
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(projectData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal project data: %w", err)
|
||||
}
|
||||
|
||||
apiURL := getAPIURL("/projects")
|
||||
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var project Project
|
||||
if err := json.Unmarshal(body, &project); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
|
||||
fmt.Printf("ID: %s\n", project.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDeleteProject(cmd *cobra.Command, args []string) error {
|
||||
projectID := args[0]
|
||||
|
||||
apiURL := getAPIURL("/projects/" + projectID)
|
||||
|
||||
req, err := http.NewRequest("DELETE", apiURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
|
||||
return nil
|
||||
projectsCreateCmd.Flags().StringP("description", "d", "", "project description")
|
||||
ProjectsCmd.AddCommand(projectsListCmd, projectsGetCmd, projectsCreateCmd, projectsDeleteCmd)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ServicesCmd manages services.
|
||||
var ServicesCmd = &cobra.Command{
|
||||
Use: "services",
|
||||
Aliases: []string{"service", "svc"},
|
||||
Short: "Manage services",
|
||||
}
|
||||
|
||||
var servicesListCmd = &cobra.Command{
|
||||
Use: "list [project-id]",
|
||||
Short: "List services in a project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "services")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, s := range items {
|
||||
rows = append(rows, []string{
|
||||
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/services/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
s, _ := unwrapObject(data, "service")
|
||||
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
|
||||
fmt.Printf("%-14s %s\n", k+":", str(s, k))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesCreateCmd = &cobra.Command{
|
||||
Use: "create <project-id> <name>",
|
||||
Short: "Create a service (image- or git-based)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": args[1],
|
||||
}
|
||||
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||
key := f
|
||||
// JSON uses snake_case keys matching the API schema.
|
||||
switch f {
|
||||
case "git-repo":
|
||||
key = "git_repo"
|
||||
case "git-branch":
|
||||
key = "git_branch"
|
||||
case "build-path":
|
||||
key = "build_path"
|
||||
case "restart-policy":
|
||||
key = "restart_policy"
|
||||
case "healthcheck-path":
|
||||
key = "healthcheck_path"
|
||||
}
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
||||
body["port"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
||||
body["replicas"] = v
|
||||
}
|
||||
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
s, _ := unwrapObject(data, "service")
|
||||
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var servicesDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted service %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// serviceAction returns a RunE for the simple POST action endpoints.
|
||||
func serviceAction(verb string) func(*cobra.Command, []string) error {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s: %s\n", verb, args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
f := servicesCreateCmd.Flags()
|
||||
f.String("type", "web", "service type: web|worker|database|cron")
|
||||
f.String("image", "", "container image (image-based service)")
|
||||
f.String("git-repo", "", "git repository URL (build-from-source service)")
|
||||
f.String("git-branch", "main", "git branch")
|
||||
f.String("build-path", "", "build context subdirectory")
|
||||
f.String("command", "", "container start command override")
|
||||
f.String("environment", "production", "production|preview|development")
|
||||
f.String("domain", "", "public domain")
|
||||
f.String("restart-policy", "unless-stopped", "docker restart policy")
|
||||
f.String("healthcheck-path", "", "HTTP health check path")
|
||||
f.String("cpu", "", "CPU limit (e.g. 0.5)")
|
||||
f.String("memory", "", "memory limit (e.g. 512m)")
|
||||
f.Int("port", 0, "container port")
|
||||
f.Int("replicas", 0, "replica count")
|
||||
|
||||
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
||||
verb := a
|
||||
ServicesCmd.AddCommand(&cobra.Command{
|
||||
Use: verb + " <id>",
|
||||
Short: verb + " a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: serviceAction(verb),
|
||||
})
|
||||
}
|
||||
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesDeleteCmd)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TemplatesCmd manages the template catalog.
|
||||
var TemplatesCmd = &cobra.Command{
|
||||
Use: "templates",
|
||||
Aliases: []string{"template"},
|
||||
Short: "Browse and deploy templates",
|
||||
}
|
||||
|
||||
var templatesListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List templates",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/templates", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "templates")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, t := range items {
|
||||
rows = append(rows, []string{
|
||||
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Show a template definition",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/templates/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesCreateCmd = &cobra.Command{
|
||||
Use: "create <file>",
|
||||
Short: "Create a user template from a JSON file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read %s: %w", args[0], err)
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/templates", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a user template",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Deleted template %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesDeployCmd = &cobra.Command{
|
||||
Use: "deploy <template-id> <project-id>",
|
||||
Short: "Deploy a template into a project",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TokensCmd manages personal access tokens (cnp_...).
|
||||
var TokensCmd = &cobra.Command{
|
||||
Use: "tokens",
|
||||
Short: "Manage personal access tokens",
|
||||
Long: `Create, list, and revoke the cnp_ tokens used by this CLI,
|
||||
the MCP server, and external agents.`,
|
||||
}
|
||||
|
||||
var tokensListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List your tokens (metadata only)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/user/tokens", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, t := range items {
|
||||
rows = append(rows, []string{
|
||||
str(t, "id"), str(t, "name"), str(t, "key_prefix"), str(t, "scope"),
|
||||
relTime(t, "last_used_at"), str(t, "expires_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "PREFIX", "SCOPE", "LAST USED", "EXPIRES"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var tokensCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a token (raw value printed once)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope, _ := cmd.Flags().GetString("scope")
|
||||
days, _ := cmd.Flags().GetInt("expires-in-days")
|
||||
data, err := c.Do("POST", "/user/tokens", map[string]interface{}{
|
||||
"name": args[0], "scope": scope, "expires_in_days": days,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
t, _ := unwrapObject(data)
|
||||
fmt.Println(str(t, "token"))
|
||||
fmt.Fprintln(os.Stderr, "Store it now — it is shown only once. Scope: "+str(t, "scope"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var tokensRevokeCmd = &cobra.Command{
|
||||
Use: "revoke <id>",
|
||||
Short: "Revoke a token",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Confirm(fmt.Sprintf("Revoke token %s? Clients using it lose access immediately.", args[0])) {
|
||||
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
||||
}
|
||||
if _, err := c.Do("DELETE", "/user/tokens/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
return PrintJSON(map[string]string{"status": "revoked", "id": args[0]})
|
||||
}
|
||||
fmt.Printf("Revoked token %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
tokensCreateCmd.Flags().String("scope", "write", "read|write|admin (admin requires an admin account)")
|
||||
tokensCreateCmd.Flags().Int("expires-in-days", 0, "0 = never expires, max 3650")
|
||||
TokensCmd.AddCommand(tokensListCmd, tokensCreateCmd, tokensRevokeCmd)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// UpCmd deploys the current directory: resolves a project, creates (or
|
||||
// reuses) a service named after the directory, and triggers a deployment.
|
||||
// The git remote URL and current branch become the build source.
|
||||
var UpCmd = &cobra.Command{
|
||||
Use: "up",
|
||||
Short: "Deploy the current directory (create service + deploy)",
|
||||
Long: `Deploy the current directory to Containr.
|
||||
|
||||
The git remote and current branch are used as the build source. If no
|
||||
service named after this directory exists in the project, one is
|
||||
created; an existing service is redeployed.`,
|
||||
RunE: runUp,
|
||||
}
|
||||
|
||||
func runUp(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectID, _ := cmd.Flags().GetString("project")
|
||||
if projectID == "" {
|
||||
projectID = viper.GetString("profiles." + ActiveProfile() + ".project_id")
|
||||
}
|
||||
if projectID == "" {
|
||||
return &APIError{
|
||||
Message: "no project selected — pass --project <id> or set project_id on the active profile",
|
||||
ExitCode: ExitValidation,
|
||||
}
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if name == "" {
|
||||
name = filepath.Base(cwd)
|
||||
}
|
||||
repo := gitOutput(cwd, "config", "--get", "remote.origin.url")
|
||||
branch := gitOutput(cwd, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
commit := gitOutput(cwd, "rev-parse", "HEAD")
|
||||
image, _ := cmd.Flags().GetString("image")
|
||||
|
||||
if repo == "" && image == "" {
|
||||
return &APIError{
|
||||
Message: "no git remote and no --image — either init git with an origin remote or pass --image",
|
||||
ExitCode: ExitValidation,
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse an existing same-named service when present.
|
||||
svcID, _ := cmd.Flags().GetString("service")
|
||||
if svcID == "" {
|
||||
if data, err := c.Do("GET", "/projects/"+projectID+"/services", nil); err == nil {
|
||||
if items, err := unwrapList(data, "services"); err == nil {
|
||||
for _, s := range items {
|
||||
if str(s, "name") == name {
|
||||
svcID = str(s, "id")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if svcID == "" {
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": "web",
|
||||
"environment": "production",
|
||||
}
|
||||
if repo != "" {
|
||||
body["git_repo"] = repo
|
||||
if branch != "" {
|
||||
body["git_branch"] = branch
|
||||
}
|
||||
}
|
||||
if image != "" {
|
||||
body["image"] = image
|
||||
}
|
||||
data, err := c.Do("POST", "/projects/"+projectID+"/services", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, _ := unwrapObject(data, "service")
|
||||
svcID = str(s, "id")
|
||||
if svcID == "" || svcID == "-" {
|
||||
PrintRaw(data)
|
||||
return &APIError{Message: "service created but id missing from response", ExitCode: ExitError}
|
||||
}
|
||||
if !JSONMode() {
|
||||
fmt.Fprintf(os.Stderr, "Created service %s (%s)\n", name, svcID)
|
||||
}
|
||||
}
|
||||
|
||||
deployBody := map[string]interface{}{"trigger": "cli-up"}
|
||||
if commit != "" {
|
||||
deployBody["commit_hash"] = commit
|
||||
}
|
||||
if branch != "" {
|
||||
deployBody["branch"] = branch
|
||||
}
|
||||
data, err := c.Do("POST", "/services/"+svcID+"/deployments", deployBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
d, _ := unwrapObject(data, "deployment")
|
||||
fmt.Printf("Deploying %s → service %s (deployment %s)\n", name, svcID, str(d, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func gitOutput(dir string, args ...string) string {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func init() {
|
||||
UpCmd.Flags().String("project", "", "target project id (or set project_id on the profile)")
|
||||
UpCmd.Flags().String("name", "", "service name (default: directory name)")
|
||||
UpCmd.Flags().String("service", "", "existing service id to redeploy")
|
||||
UpCmd.Flags().String("image", "", "deploy this image instead of building from git")
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// VariablesCmd manages service environment variables.
|
||||
var VariablesCmd = &cobra.Command{
|
||||
Use: "variables",
|
||||
Aliases: []string{"vars", "env"},
|
||||
Short: "Manage service environment variables",
|
||||
}
|
||||
|
||||
var varsListCmd = &cobra.Command{
|
||||
Use: "list <service-id>",
|
||||
Short: "List variables (secrets shown masked)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/services/"+args[0]+"/variables", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "variables")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, v := range items {
|
||||
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
|
||||
}
|
||||
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Variables are replaced wholesale server-side — set/unset fetch the
|
||||
// current list, mutate it, and PUT it back. Masked secret placeholders are
|
||||
// preserved by the server.
|
||||
var varsSetCmd = &cobra.Command{
|
||||
Use: "set <service-id> KEY=VALUE [KEY=VALUE...]",
|
||||
Short: "Set one or more variables",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
secret, _ := cmd.Flags().GetBool("secret")
|
||||
return mutateVars(args[0], func(vars []map[string]interface{}) []map[string]interface{} {
|
||||
for _, pair := range args[1:] {
|
||||
k, v, found := strings.Cut(pair, "=")
|
||||
if !found || k == "" {
|
||||
continue
|
||||
}
|
||||
vars = upsertVar(vars, k, v, secret)
|
||||
}
|
||||
return vars
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var varsUnsetCmd = &cobra.Command{
|
||||
Use: "unset <service-id> KEY [KEY...]",
|
||||
Short: "Remove variables",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
drop := map[string]bool{}
|
||||
for _, k := range args[1:] {
|
||||
drop[k] = true
|
||||
}
|
||||
return mutateVars(args[0], func(vars []map[string]interface{}) []map[string]interface{} {
|
||||
out := vars[:0]
|
||||
for _, v := range vars {
|
||||
if !drop[str(v, "key")] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) {
|
||||
data, err := c.Do("GET", "/services/"+serviceID+"/variables", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unwrapList(data, "variables")
|
||||
}
|
||||
|
||||
func mutateVars(serviceID string, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vars, err := fetchVars(c, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vars = mutate(vars)
|
||||
payload := make([]map[string]interface{}, 0, len(vars))
|
||||
for _, v := range vars {
|
||||
payload = append(payload, map[string]interface{}{
|
||||
"key": str(v, "key"),
|
||||
"value": str(v, "value"),
|
||||
"is_secret": v["is_secret"] == true,
|
||||
})
|
||||
}
|
||||
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", map[string]interface{}{"variables": payload})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if JSONMode() {
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertVar(vars []map[string]interface{}, key, value string, secret bool) []map[string]interface{} {
|
||||
for _, v := range vars {
|
||||
if str(v, "key") == key {
|
||||
v["value"] = value
|
||||
v["is_secret"] = secret
|
||||
return vars
|
||||
}
|
||||
}
|
||||
return append(vars, map[string]interface{}{"key": key, "value": value, "is_secret": secret})
|
||||
}
|
||||
|
||||
func init() {
|
||||
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
|
||||
}
|
||||
@@ -12,23 +12,35 @@ import (
|
||||
|
||||
var cfgFile string
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "containr",
|
||||
Short: "Containr CLI - Manage your self-hosted PaaS",
|
||||
Long: `Containr CLI is a command-line interface for managing your Containr platform.
|
||||
You can manage projects, services, deployments, databases, and more from your terminal.`,
|
||||
Version: "1.0.0",
|
||||
Short: "Containr CLI - manage your self-hosted PaaS",
|
||||
Long: `Containr CLI controls every part of a Containr instance: projects,
|
||||
services, deployments, databases, cron jobs, templates, nodes, scaling,
|
||||
HA, security, and the API gateway.
|
||||
|
||||
Authenticate once with a personal access token (Settings → Personal
|
||||
Access Tokens in the web UI):
|
||||
|
||||
containr auth login cnp_...
|
||||
|
||||
Every command supports --json for machine-readable output, and
|
||||
destructive actions accept --yes to skip confirmation.`,
|
||||
Version: "2.0.0",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
cobra.CheckErr(rootCmd.Execute())
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return commands.ExitCode(err)
|
||||
}
|
||||
return commands.ExitOK
|
||||
}
|
||||
|
||||
// SetVersion overrides the version reported by `containr --version`.
|
||||
// Called by main with the value injected via -ldflags at build time.
|
||||
func SetVersion(v string) {
|
||||
rootCmd.Version = v
|
||||
}
|
||||
@@ -36,46 +48,54 @@ func SetVersion(v string) {
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
// Global flags
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)")
|
||||
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)")
|
||||
rootCmd.PersistentFlags().String("token", "", "Authentication token")
|
||||
pf := rootCmd.PersistentFlags()
|
||||
pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
|
||||
pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
|
||||
pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
|
||||
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
|
||||
pf.Bool("json", false, "machine-readable JSON output")
|
||||
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
|
||||
|
||||
// Bind flags to viper
|
||||
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
|
||||
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
|
||||
viper.BindPFlag("api-url", pf.Lookup("api-url"))
|
||||
viper.BindPFlag("token", pf.Lookup("token"))
|
||||
viper.BindPFlag("profile", pf.Lookup("profile"))
|
||||
viper.BindPFlag("json", pf.Lookup("json"))
|
||||
viper.BindPFlag("yes", pf.Lookup("yes"))
|
||||
|
||||
// Add command groups
|
||||
rootCmd.AddCommand(commands.AuthCmd)
|
||||
rootCmd.AddCommand(commands.TokensCmd)
|
||||
rootCmd.AddCommand(commands.ProjectsCmd)
|
||||
rootCmd.AddCommand(commands.ServicesCmd)
|
||||
rootCmd.AddCommand(commands.DeployCmd)
|
||||
rootCmd.AddCommand(commands.DeploymentsCmd)
|
||||
rootCmd.AddCommand(commands.LogsCmd)
|
||||
rootCmd.AddCommand(commands.ExecCmd)
|
||||
rootCmd.AddCommand(commands.VariablesCmd)
|
||||
rootCmd.AddCommand(commands.DatabasesCmd)
|
||||
rootCmd.AddCommand(commands.CronCmd)
|
||||
rootCmd.AddCommand(commands.TemplatesCmd)
|
||||
rootCmd.AddCommand(commands.NodesCmd)
|
||||
rootCmd.AddCommand(commands.ScalingCmd)
|
||||
rootCmd.AddCommand(commands.HACmd)
|
||||
rootCmd.AddCommand(commands.SecurityCmd)
|
||||
rootCmd.AddCommand(commands.GatewayCmd)
|
||||
rootCmd.AddCommand(commands.AdminCmd)
|
||||
rootCmd.AddCommand(commands.NotificationsCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
}
|
||||
|
||||
// initConfig reads in config file and ENV variables if set.
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
// Find home directory.
|
||||
home, err := os.UserHomeDir()
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Search config in home directory with name ".containr" (without extension).
|
||||
viper.AddConfigPath(home)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigName(".containr")
|
||||
}
|
||||
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
// If a config file is found, read it in.
|
||||
if err := viper.ReadInConfig(); err == nil {
|
||||
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the CLI
|
||||
func Run() error {
|
||||
rootCmd.Execute()
|
||||
return nil
|
||||
viper.AutomaticEnv()
|
||||
// CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
|
||||
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
|
||||
viper.ReadInConfig()
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// getAPIURL constructs the full API URL for a given endpoint
|
||||
func getAPIURL(endpoint string) string {
|
||||
baseURL := viper.GetString("api-url")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080/api/v1" // Default for development
|
||||
}
|
||||
|
||||
// Ensure baseURL doesn't end with / and endpoint starts with /
|
||||
if strings.HasSuffix(baseURL, "/") {
|
||||
baseURL = baseURL[:len(baseURL)-1]
|
||||
}
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
|
||||
return baseURL + endpoint
|
||||
}
|
||||
Reference in new issue
Block a user