Merge pull request #15 from Dvorinka/feat/cli-v2

feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-10-09 00:48:16 +02:00
commit f34fba165c
26 files changed
+2798 -350

No files matched your search

+1
View File
@@ -57,6 +57,7 @@ dedicated console.
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **HA & scaling** — failover policies, node agents, autoscaling rules
- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans
- **CLI** — `containr` covers the full API: projects, services, deployments, logs, exec, variables, databases, cron, templates, nodes, scaling, HA, security, gateway, admin — with `--json`, `--yes`, named profiles, and `containr up` for current-directory deploys
- **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
+1 -4
View File
@@ -15,8 +15,5 @@ var (
func main() {
cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime))
if err := cli.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.Exit(cli.Execute())
}
+1
View File
@@ -86,6 +86,7 @@ require (
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.49.0 // indirect
+2
View File
@@ -219,6 +219,8 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+361
View File
@@ -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)
}
+94 -83
View File
@@ -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
}
+41 -5
View File
@@ -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)
}
}
+160
View File
@@ -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)
}
+106
View File
@@ -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
}
+169
View File
@@ -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)
}
+135
View File
@@ -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)
}
+171
View File
@@ -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")
}
+150
View File
@@ -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
}
+101 -197
View File
@@ -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)
}
+101
View File
@@ -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)
}
+145
View File
@@ -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)
}
+56 -36
View File
@@ -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()
}
-25
View File
@@ -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
}
+71
View File
@@ -0,0 +1,71 @@
# Containr CLI
The CLI controls every part of a Containr instance — the same API surface
the web UI uses. Build it with `scripts/build-cli.sh` or
`go build ./cmd/cli` from `app/backend`.
## Authentication
Create a personal access token in the web UI under
**Settings → Personal Access Tokens** (or `containr tokens create`), then:
```bash
containr auth login cnp_... # verify + store
containr auth login cnp_... --url https://containr.example.com
containr auth login cnp_... --name production # named profile
containr auth status # show active profile + identity
containr auth logout
```
Resolution order for every command: `--token`/`--api-url` flags >
`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >
`http://localhost:8080/api/v1`. The active profile is `--profile` >
`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.
## Output and scripting
- `--json` prints the raw API response — safe to pipe and parse.
- `--yes` / `-y` skips confirmation on destructive commands (delete,
revoke, restore, failover). Non-interactive shells refuse destructive
commands without it.
- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,
`3` not found, `4` validation, `5` unreachable.
## Commands
```
auth login|logout|status token auth and profiles
tokens list|create|revoke personal access tokens
projects list|get|create|delete
services list|get|create|delete|start|stop|restart|redeploy
deploy <service-id> trigger a deployment
deployments list|get|logs|rollback
logs <service-id> [--follow] runtime logs (SSE follow)
exec <service-id> -- <cmd> one-off container command
variables list|set|unset env vars (secrets masked)
databases list|get|create|delete|action|backup|restore|download-backup
cron list|get|create|delete|trigger|executions
templates list|get|create|delete|deploy
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
scaling status|policies|services|scale
ha status|alerts|health|policies|enable|disable|failover
security scan|vulnerabilities|metrics
gateway services|keys|analytics (admin)
admin overview|users|settings|audit-logs
notifications list|read
up deploy the current directory
```
## containr up
Run inside a git checkout: uses `remote.origin.url` + the current branch
as the build source, creates a service named after the directory when none
exists, then triggers a deployment.
```bash
containr up --project <id>
containr up --project <id> --image ghcr.io/example/app:latest # image, no build
```
Set `project_id` on the active profile to omit `--project` (stored in
`~/.containr.yaml` under `profiles.<name>.project_id`).