mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Rewrite the CLI as a thin client over the authenticated API so agents and scripts reach every resource the UI does (ROADMAP2 phase A2). - shared Client with bearer auth, error-envelope decoding, and stable exit codes: 0 ok / 1 error / 2 auth / 3 not-found / 4 validation / 5 unreachable - named profiles in ~/.containr.yaml (current_profile + profiles.*); resolution order --flag > env > profile > localhost default; CONTAINR_TOKEN / CONTAINR_API_URL / CONTAINR_PROFILE env support - --json prints raw API responses; --yes skips confirmations; destructive commands refuse non-interactively without it - auth login verifies the token against /user/profile before saving, interactive prompt via term.ReadPassword - commands for every resource: tokens, projects, services, deploy, deployments (logs/rollback), logs --follow (SSE), exec, variables (set/unset merge with secret masking preserved), databases (backup/restore/download), cron, templates, nodes + agent tokens, scaling, ha, security, gateway, admin, notifications - containr up: git remote + branch → create-or-reuse service → deploy Verified live: login/status/tokens/projects/services, up creating a service + deployment, non-TTY confirmation refusal, scope-capped admin rejection (exit 2), revoke. Authored By: TDvorak <[email protected]>
172 lines
4.5 KiB
Go
172 lines
4.5 KiB
Go
package commands
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// LogsCmd prints service runtime logs.
|
|
var LogsCmd = &cobra.Command{
|
|
Use: "logs <service-id>",
|
|
Short: "Print or follow service logs",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
follow, _ := cmd.Flags().GetBool("follow")
|
|
tail, _ := cmd.Flags().GetInt("tail")
|
|
path := fmt.Sprintf("/services/%s/logs?tail=%d", args[0], tail)
|
|
if follow {
|
|
return streamSSE(c, path+"&follow=true")
|
|
}
|
|
data, err := c.Do("GET", path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return printLogEntries(data)
|
|
},
|
|
}
|
|
|
|
// ExecCmd runs a one-off command inside a service container.
|
|
var ExecCmd = &cobra.Command{
|
|
Use: "exec <service-id> -- <command>",
|
|
Short: "Run a one-off command in the service container (30s ceiling)",
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
command := strings.Join(args[1:], " ")
|
|
if command == "" {
|
|
return &APIError{Message: "no command given — usage: containr exec <id> -- <command>", ExitCode: ExitValidation}
|
|
}
|
|
data, err := c.Do("POST", "/services/"+args[0]+"/exec", map[string]string{"command": command})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
resp, _ := unwrapObject(data)
|
|
fmt.Print(str(resp, "output", "stdout"))
|
|
if code := str(resp, "exit_code", "exitCode"); code != "-" && code != "0" {
|
|
return &APIError{Message: fmt.Sprintf("command exited with %s", code), ExitCode: ExitError}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
type logEntry struct {
|
|
Timestamp string `json:"timestamp"`
|
|
Message string `json:"message"`
|
|
Stream string `json:"stream"`
|
|
}
|
|
|
|
// printLogEntries decodes the {"logs":[...]} envelope both log endpoints use.
|
|
func printLogEntries(data []byte) error {
|
|
var payload struct {
|
|
Logs []logEntry `json:"logs"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
if JSONMode() {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
for _, e := range payload.Logs {
|
|
fmt.Println(e.Message)
|
|
}
|
|
if len(payload.Logs) == 0 && payload.Message != "" {
|
|
fmt.Fprintln(os.Stderr, payload.Message)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runDeploymentLogs prints persisted build+runtime logs for a deployment.
|
|
func runDeploymentLogs(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logType, _ := cmd.Flags().GetString("type")
|
|
data, err := c.Do("GET", "/deployments/"+args[0]+"/logs?type="+logType, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
var payload struct {
|
|
BuildLog string `json:"build_log"`
|
|
RuntimeLog string `json:"runtime_log"`
|
|
}
|
|
if json.Unmarshal(data, &payload) == nil && (payload.BuildLog != "" || payload.RuntimeLog != "") {
|
|
if payload.BuildLog != "" {
|
|
fmt.Print(payload.BuildLog)
|
|
}
|
|
if payload.RuntimeLog != "" {
|
|
fmt.Print(payload.RuntimeLog)
|
|
}
|
|
return nil
|
|
}
|
|
return printLogEntries(data)
|
|
}
|
|
|
|
// streamSSE consumes a text/event-stream response and prints each
|
|
// `data:` line's message field until the server closes the stream.
|
|
func streamSSE(c *Client, path string) error {
|
|
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
hc := c.HTTPClient
|
|
if hc == nil {
|
|
hc = http.DefaultClient
|
|
}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return &APIError{Message: fmt.Sprintf("cannot reach %s: %v", c.BaseURL, err), ExitCode: ExitUnavailable}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return &APIError{Status: resp.StatusCode, ExitCode: exitCodeFor(resp.StatusCode)}
|
|
}
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 64<<10), 4<<20)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
var e logEntry
|
|
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &e); err == nil {
|
|
fmt.Println(e.Message)
|
|
} else {
|
|
fmt.Println(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func init() {
|
|
LogsCmd.Flags().BoolP("follow", "f", false, "stream new log lines (SSE)")
|
|
LogsCmd.Flags().Int("tail", 100, "number of lines to return")
|
|
deploymentsLogsCmd.Flags().String("type", "all", "log type: all|build|runtime")
|
|
}
|