diff --git a/README.md b/README.md index d0b9fff..248f2fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/backend/cmd/cli/main.go b/app/backend/cmd/cli/main.go index 854452b..322914d 100644 --- a/app/backend/cmd/cli/main.go +++ b/app/backend/cmd/cli/main.go @@ -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()) } diff --git a/app/backend/go.mod b/app/backend/go.mod index 74842cd..3879dd2 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -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 diff --git a/app/backend/go.sum b/app/backend/go.sum index ebfe1d5..47fb828 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -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= diff --git a/app/backend/internal/cli/commands/admin.go b/app/backend/internal/cli/commands/admin.go new file mode 100644 index 0000000..1971455 --- /dev/null +++ b/app/backend/internal/cli/commands/admin.go @@ -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 ", + 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 ", + 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 ", + 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 ", + 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) +} diff --git a/app/backend/internal/cli/commands/auth.go b/app/backend/internal/cli/commands/auth.go index c32305a..7d8087b 100644 --- a/app/backend/internal/cli/commands/auth.go +++ b/app/backend/internal/cli/commands/auth.go @@ -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 ' 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 `") 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 -} diff --git a/app/backend/internal/cli/commands/auth_test.go b/app/backend/internal/cli/commands/auth_test.go index 1c88094..c177f3c 100644 --- a/app/backend/internal/cli/commands/auth_test.go +++ b/app/backend/internal/cli/commands/auth_test.go @@ -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) + } } diff --git a/app/backend/internal/cli/commands/client.go b/app/backend/internal/cli/commands/client.go new file mode 100644 index 0000000..776cc9a --- /dev/null +++ b/app/backend/internal/cli/commands/client.go @@ -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 ` 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) +} diff --git a/app/backend/internal/cli/commands/config.go b/app/backend/internal/cli/commands/config.go new file mode 100644 index 0000000..0f68d9c --- /dev/null +++ b/app/backend/internal/cli/commands/config.go @@ -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 +} diff --git a/app/backend/internal/cli/commands/cron.go b/app/backend/internal/cli/commands/cron.go new file mode 100644 index 0000000..bde7a2d --- /dev/null +++ b/app/backend/internal/cli/commands/cron.go @@ -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 ", + 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 ", + 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 ", + 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 ", + 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) +} diff --git a/app/backend/internal/cli/commands/databases.go b/app/backend/internal/cli/commands/databases.go new file mode 100644 index 0000000..92d80ea --- /dev/null +++ b/app/backend/internal/cli/commands/databases.go @@ -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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 [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) +} diff --git a/app/backend/internal/cli/commands/deploy.go b/app/backend/internal/cli/commands/deploy.go new file mode 100644 index 0000000..6b6a32f --- /dev/null +++ b/app/backend/internal/cli/commands/deploy.go @@ -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 ", + 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 ", + 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 ", + Short: "Stream a deployment's build/deploy logs", + Args: cobra.ExactArgs(1), + RunE: runDeploymentLogs, +} + +var deploymentsRollbackCmd = &cobra.Command{ + Use: "rollback ", + 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) +} diff --git a/app/backend/internal/cli/commands/helpers.go b/app/backend/internal/cli/commands/helpers.go new file mode 100644 index 0000000..1b708bc --- /dev/null +++ b/app/backend/internal/cli/commands/helpers.go @@ -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 {"": [...]} 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) +} diff --git a/app/backend/internal/cli/commands/logs.go b/app/backend/internal/cli/commands/logs.go new file mode 100644 index 0000000..80dc3c8 --- /dev/null +++ b/app/backend/internal/cli/commands/logs.go @@ -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 ", + 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 -- ", + 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 -- ", 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") +} diff --git a/app/backend/internal/cli/commands/nodes.go b/app/backend/internal/cli/commands/nodes.go new file mode 100644 index 0000000..a087239 --- /dev/null +++ b/app/backend/internal/cli/commands/nodes.go @@ -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 ", + 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 ", + 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 ", + 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) +} diff --git a/app/backend/internal/cli/commands/notifications.go b/app/backend/internal/cli/commands/notifications.go new file mode 100644 index 0000000..024e89b --- /dev/null +++ b/app/backend/internal/cli/commands/notifications.go @@ -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 |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) +} diff --git a/app/backend/internal/cli/commands/output.go b/app/backend/internal/cli/commands/output.go new file mode 100644 index 0000000..a4a148c --- /dev/null +++ b/app/backend/internal/cli/commands/output.go @@ -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 +} diff --git a/app/backend/internal/cli/commands/projects.go b/app/backend/internal/cli/commands/projects.go index 180f5d8..3957944 100644 --- a/app/backend/internal/cli/commands/projects.go +++ b/app/backend/internal/cli/commands/projects.go @@ -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 ", + 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 ", + 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 ", + 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) } diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go new file mode 100644 index 0000000..5c9313c --- /dev/null +++ b/app/backend/internal/cli/commands/services.go @@ -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 ", + 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 ", + 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 ", + 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 + " ", + Short: verb + " a service", + Args: cobra.ExactArgs(1), + RunE: serviceAction(verb), + }) + } + ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesDeleteCmd) +} diff --git a/app/backend/internal/cli/commands/templates.go b/app/backend/internal/cli/commands/templates.go new file mode 100644 index 0000000..9cf3ccf --- /dev/null +++ b/app/backend/internal/cli/commands/templates.go @@ -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 ", + 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 ", + 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 ", + 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 ", + 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) +} diff --git a/app/backend/internal/cli/commands/tokens.go b/app/backend/internal/cli/commands/tokens.go new file mode 100644 index 0000000..15be669 --- /dev/null +++ b/app/backend/internal/cli/commands/tokens.go @@ -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 ", + 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 ", + 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) +} diff --git a/app/backend/internal/cli/commands/up.go b/app/backend/internal/cli/commands/up.go new file mode 100644 index 0000000..0e49524 --- /dev/null +++ b/app/backend/internal/cli/commands/up.go @@ -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 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") +} diff --git a/app/backend/internal/cli/commands/variables.go b/app/backend/internal/cli/commands/variables.go new file mode 100644 index 0000000..77afb7b --- /dev/null +++ b/app/backend/internal/cli/commands/variables.go @@ -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 ", + 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 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 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) +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index ee9c8ab..a38c2a8 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -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() } diff --git a/app/backend/internal/cli/utils.go b/app/backend/internal/cli/utils.go deleted file mode 100644 index 51c300b..0000000 --- a/app/backend/internal/cli/utils.go +++ /dev/null @@ -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 -} diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md new file mode 100644 index 0000000..a2619bf --- /dev/null +++ b/docs/guides/CLI.md @@ -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 trigger a deployment +deployments list|get|logs|rollback +logs [--follow] runtime logs (SSE follow) +exec -- 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 +containr up --project --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..project_id`).