diff --git a/README.md b/README.md index 71d8a76..d9eaa3e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The actuated-cli requires an access token for GitHub and is designed to be used Most of the operations on the actuated dashboard are available as CLI commands. -In GitHub's terminology, which is carried over into actuated, the flag `--owner` and the term "OWNER" refer exclusively to a GitHub organization, and not your personal user account (which would be the "actor"). +In GitHub's terminology, which is carried over into actuated, the flag `--owner` and the term "OWNER" refer to a repository owner. This can be a GitHub organization or your personal account. ## Installation @@ -42,6 +42,20 @@ actuated-cli jobs actuated-samples actuated-cli runners actuated-samples ``` +## Profile completed jobs + +List recent profiling snapshots for an organisation or personal repository owner: + +```bash +actuated-cli profile OWNER +``` + +Inspect the full snapshot for a GitHub Actions job: + +```bash +actuated-cli profile OWNER --id JOB_ID +``` + ## View SSH sessions available: ```bash diff --git a/cmd/profile.go b/cmd/profile.go new file mode 100644 index 0000000..dc33e9e --- /dev/null +++ b/cmd/profile.go @@ -0,0 +1,333 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/olekukonko/tablewriter" + "github.com/self-actuated/actuated-cli/pkg" + "github.com/spf13/cobra" +) + +func makeProfile() *cobra.Command { + cmd := &cobra.Command{ + Use: "profile OWNER", + Short: "View profiling snapshots for completed jobs", + Args: cobra.ExactArgs(1), + Example: ` # Show the 20 most recent profiling snapshots + actuated-cli profile openfaasltd + + # Show more snapshots or return the raw JSON + actuated-cli profile openfaasltd --limit 50 + actuated-cli profile openfaasltd --json + + # Inspect the full snapshot for a GitHub Actions job + actuated-cli profile openfaasltd --id 96357108346`, + RunE: runProfileE, + } + + cmd.Flags().IntP("limit", "l", 20, "Number of recent snapshots to show") + cmd.Flags().String("id", "", "GitHub Actions job ID for a detailed snapshot") + cmd.Flags().BoolP("json", "j", false, "Request output in JSON format") + + return cmd +} + +func runProfileE(cmd *cobra.Command, args []string) error { + owner := strings.TrimSpace(args[0]) + if owner == "" { + return fmt.Errorf("owner is required") + } + + pat, err := getPat(cmd) + if err != nil { + return err + } + if pat == "" { + return fmt.Errorf("pat is required") + } + + limit, err := cmd.Flags().GetInt("limit") + if err != nil { + return err + } + if limit < 1 { + return fmt.Errorf("limit must be greater than zero") + } + + jobID, err := cmd.Flags().GetString("id") + if err != nil { + return err + } + if jobID != "" { + if _, err := strconv.ParseInt(jobID, 10, 64); err != nil { + return fmt.Errorf("id must be a numeric GitHub Actions job ID") + } + } + + staff, err := cmd.Flags().GetBool("staff") + if err != nil { + return err + } + requestJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + + client := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + var body string + var status int + if jobID == "" { + body, status, err = client.ListProfiles(pat, owner, limit, staff) + } else { + body, status, err = client.GetProfile(pat, owner, jobID, staff) + } + if err != nil { + return err + } + if status == http.StatusUnauthorized { + return fmt.Errorf("GitHub token rejected, run \"actuated-cli auth\" and try again") + } + if status != http.StatusOK { + return fmt.Errorf("unexpected status code: %d, message: %s", status, body) + } + + if requestJSON { + return printProfileJSON(os.Stdout, []byte(body)) + } + + if jobID != "" { + var snapshot ProfileSnapshot + if err := json.Unmarshal([]byte(body), &snapshot); err != nil { + return err + } + printProfileDetails(os.Stdout, snapshot) + return nil + } + + var snapshots []ProfileSummary + if err := json.Unmarshal([]byte(body), &snapshots); err != nil { + return err + } + printProfiles(os.Stdout, snapshots) + + return nil +} + +func printProfileJSON(w io.Writer, body []byte) error { + var formatted bytes.Buffer + if err := json.Indent(&formatted, body, "", " "); err != nil { + return err + } + _, err := fmt.Fprintln(w, formatted.String()) + return err +} + +func printProfiles(w io.Writer, snapshots []ProfileSummary) { + table := newProfileTable(w) + table.SetHeader([]string{"AGE", "REPO", "JOB/WORKFLOW", "AGENT/VM", "RUNTIME", "VCPU", "LOAD 1/5", "RAM", "DISK"}) + + for _, snapshot := range snapshots { + table.Append([]string{ + profileAge(snapshot.CompletedAt), + snapshot.Repo, + snapshot.Job + "\n" + snapshot.Workflow, + profileAgent(snapshot.AgentName) + "\n" + valueOrNA(snapshot.Hostname), + profileRuntime(snapshot.StartedAt, snapshot.CompletedAt), + profileCPU(snapshot.TotalCPU, snapshot.ShareFactor), + profileLoads(snapshot.MaxLoadAvg1, snapshot.MaxLoadAvg5), + profileUsage(snapshot.TotalMemoryGB, snapshot.MinAvailableMemoryGB), + profileUsage(snapshot.DiskSpaceTotalGB, snapshot.DiskSpaceFreeGB), + }) + } + + table.Render() +} + +func printProfileDetails(w io.Writer, snapshot ProfileSnapshot) { + table := newProfileTable(w) + table.SetHeader([]string{"FIELD", "VALUE"}) + rows := [][]string{ + {"Job ID", snapshot.JobID}, + {"Repository", snapshot.Owner + "/" + snapshot.Repo}, + {"Job", snapshot.Job}, + {"Workflow", snapshot.Workflow}, + {"Agent", profileAgent(snapshot.AgentName)}, + {"VM", valueOrNA(snapshot.Hostname)}, + {"Runtime", profileRuntime(snapshot.StartedAt, snapshot.CompletedAt)}, + {"vCPU", profileCPU(snapshot.TotalCPU, snapshot.ShareFactor)}, + {"Load average 1/5/15m", joinProfileValues(snapshot.MaxLoadAvg1, snapshot.MaxLoadAvg5, snapshot.MaxLoadAvg15)}, + {"RAM total", formatProfileBytes(snapshot.TotalMemoryBytes)}, + {"RAM minimum available", formatProfileBytes(snapshot.MinAvailableMemoryBytes)}, + {"Disk total/free/used", joinProfileBytes(snapshot.DiskSpaceTotalBytes, snapshot.DiskSpaceFreeBytes, snapshot.DiskSpaceUsedBytes)}, + {"Disk read/write", joinProfileBytes(snapshot.DiskReadTotalBytes, snapshot.DiskWriteTotalBytes)}, + {"Network RX/TX", joinProfileBytes(snapshot.NetworkReadTotalBytes, snapshot.NetworkWriteTotalBytes)}, + } + for _, row := range rows { + table.Append(row) + } + table.Render() +} + +func newProfileTable(w io.Writer) *tablewriter.Table { + table := tablewriter.NewWriter(w) + table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true}) + table.SetCenterSeparator("|") + table.SetColumnSeparator("|") + table.SetRowSeparator("-") + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(false) + return table +} + +func profileAge(completed *time.Time) string { + if completed == nil { + return "n/a" + } + age := time.Since(*completed) + if age < 0 { + age = 0 + } + return age.Round(time.Second).String() +} + +func profileAgent(agentName string) string { + if agentName == "" { + return "n/a" + } + return agentName +} + +func profileRuntime(startedAt, completedAt *time.Time) string { + if startedAt == nil || completedAt == nil { + return "n/a" + } + return completedAt.Sub(*startedAt).Round(time.Second).String() +} + +func profileCPU(totalCPU *int, shareFactor *float64) string { + if totalCPU == nil { + return "n/a" + } + cpu := float64(*totalCPU) + if shareFactor != nil && *shareFactor > 0 { + cpu *= *shareFactor + } + return strconv.FormatFloat(cpu, 'f', -1, 64) +} + +func profileLoads(load1, load5 *float64) string { + return joinProfileValues(load1, load5) +} + +func profileUsage(total, available *float64) string { + if total == nil || available == nil || *total <= 0 || + math.IsNaN(*total) || math.IsNaN(*available) || + math.IsInf(*total, 0) || math.IsInf(*available, 0) { + return "n/a" + } + used := *total - *available + percentage := used / *total * 100 + return fmt.Sprintf("%.2f/%.2fGB (%.0f%%)", used, *total, percentage) +} + +func valueOrNA(value string) string { + if value == "" { + return "n/a" + } + return value +} + +func joinProfileValues(values ...*float64) string { + parts := make([]string, len(values)) + for i, value := range values { + if value == nil { + parts[i] = "n/a" + } else { + parts[i] = strconv.FormatFloat(*value, 'f', -1, 64) + } + } + return strings.Join(parts, "/") +} + +func joinProfileBytes(values ...*float64) string { + parts := make([]string, len(values)) + for i, value := range values { + parts[i] = formatProfileBytes(value) + } + return strings.Join(parts, "/") +} + +func formatProfileBytes(value *float64) string { + if value == nil { + return "n/a" + } + units := []string{"B", "KB", "MB", "GB", "TB"} + size := *value + unit := 0 + for math.Abs(size) >= 1000 && unit < len(units)-1 { + size /= 1000 + unit++ + } + return fmt.Sprintf("%.2f%s", size, units[unit]) +} + +// ProfileSummary matches the controller's snapshots list response. Capacity +// fields are converted to decimal GB by the controller query. +type ProfileSummary struct { + JobID string `json:"job_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Job string `json:"job"` + Workflow string `json:"workflow"` + AgentName string `json:"agent_name,omitempty"` + Hostname string `json:"hostname,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + + TotalCPU *int `json:"total_cpu,omitempty"` + TotalMemoryGB *float64 `json:"total_memory,omitempty"` + MinAvailableMemoryGB *float64 `json:"min_memory_available_gb,omitempty"` + ShareFactor *float64 `json:"share_factor,omitempty"` + MaxLoadAvg1 *float64 `json:"max_load_avg1,omitempty"` + MaxLoadAvg5 *float64 `json:"max_load_avg5,omitempty"` + DiskSpaceTotalGB *float64 `json:"disk_space_total,omitempty"` + DiskSpaceFreeGB *float64 `json:"disk_space_free,omitempty"` +} + +// ProfileSnapshot matches the controller's detailed snapshot response. Byte +// counters are returned directly from the stored vmmeter snapshot. +type ProfileSnapshot struct { + JobID string `json:"job_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Job string `json:"job"` + Workflow string `json:"workflow"` + AgentName string `json:"agent_name,omitempty"` + Hostname string `json:"hostname,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + + TotalCPU *int `json:"total_cpu,omitempty"` + TotalMemoryBytes *float64 `json:"total_memory,omitempty"` + MinAvailableMemoryBytes *float64 `json:"min_memory_available_gb,omitempty"` + ShareFactor *float64 `json:"share_factor,omitempty"` + MaxLoadAvg1 *float64 `json:"max_load_avg1,omitempty"` + MaxLoadAvg5 *float64 `json:"max_load_avg5,omitempty"` + MaxLoadAvg15 *float64 `json:"max_load_avg15,omitempty"` + DiskSpaceTotalBytes *float64 `json:"disk_space_total,omitempty"` + DiskSpaceFreeBytes *float64 `json:"disk_space_free,omitempty"` + DiskSpaceUsedBytes *float64 `json:"disk_space_used,omitempty"` + DiskReadTotalBytes *float64 `json:"disk_read_total,omitempty"` + DiskWriteTotalBytes *float64 `json:"disk_write_total,omitempty"` + NetworkReadTotalBytes *float64 `json:"network_read_total,omitempty"` + NetworkWriteTotalBytes *float64 `json:"network_write_total,omitempty"` +} diff --git a/cmd/profile_test.go b/cmd/profile_test.go new file mode 100644 index 0000000..7322ae9 --- /dev/null +++ b/cmd/profile_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +func TestProfileUsage(t *testing.T) { + total := 8.0 + available := 2.0 + if got, want := profileUsage(&total, &available), "6.00/8.00GB (75%)"; got != want { + t.Fatalf("want %q, got %q", want, got) + } + if got := profileUsage(nil, &available); got != "n/a" { + t.Fatalf("want n/a for unavailable total, got %q", got) + } +} + +func TestProfileDetailsFormatsRawMemoryBytes(t *testing.T) { + total := 12_260_000_000.0 + available := 9_920_000_000.0 + var output bytes.Buffer + + printProfileDetails(&output, ProfileSnapshot{ + TotalMemoryBytes: &total, + MinAvailableMemoryBytes: &available, + }) + + for _, want := range []string{"12.26GB", "9.92GB"} { + if !strings.Contains(output.String(), want) { + t.Fatalf("want output to contain %q, got:\n%s", want, output.String()) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 4faf3b1..77e8324 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,8 +18,8 @@ func init() { Long: `This CLI can be used to review and manage jobs, and the actuated agent installed on your servers. -The --owner flag or OWNER argument is a GitHub organization, i.e. for the path: -self-actuated/actuated-cli, the owner is "self-actuated" also known as an org. +The --owner flag or OWNER argument is a GitHub repository owner. This can be +an organisation such as "self-actuated", or your personal GitHub account. Run "actuated-cli auth" to authenticate with GitHub. @@ -52,6 +52,7 @@ https://github.com/self-actuated/actuated-cli root.AddCommand(makeJobs()) root.AddCommand(makeRepair()) root.AddCommand(makeIncreases()) + root.AddCommand(makeProfile()) root.AddCommand(makeRestart()) root.AddCommand(makeAgentLogs()) diff --git a/pkg/client.go b/pkg/client.go index 703d618..97a3ebd 100644 --- a/pkg/client.go +++ b/pkg/client.go @@ -24,6 +24,70 @@ func NewClient(httpClient *http.Client, baseURL string) *Client { } } +func (c *Client) ListProfiles(patStr, owner string, limit int, staff bool) (string, int, error) { + u, _ := url.Parse(c.baseURL) + u.Path = "/api/v1/snapshots" + q := u.Query() + q.Set("owners", owner) + q.Set("limit", fmt.Sprintf("%d", limit)) + if staff { + q.Set("staff", "1") + } + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", http.StatusBadRequest, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+patStr) + + res, err := c.httpClient.Do(req) + if err != nil { + return "", http.StatusServiceUnavailable, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", res.StatusCode, err + } + + return string(body), res.StatusCode, nil +} + +func (c *Client) GetProfile(patStr, owner, jobID string, staff bool) (string, int, error) { + u, _ := url.Parse(c.baseURL) + u.Path = "/api/v1/snapshot" + q := u.Query() + q.Set("owner", owner) + q.Set("job", jobID) + if staff { + q.Set("staff", "1") + } + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", http.StatusBadRequest, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+patStr) + + res, err := c.httpClient.Do(req) + if err != nil { + return "", http.StatusServiceUnavailable, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", res.StatusCode, err + } + + return string(body), res.StatusCode, nil +} + func (c *Client) ListJobs(patStr string, owner string, staff bool, json bool) (string, int, error) { u, _ := url.Parse(c.baseURL) diff --git a/pkg/client_profile_test.go b/pkg/client_profile_test.go new file mode 100644 index 0000000..d27d556 --- /dev/null +++ b/pkg/client_profile_test.go @@ -0,0 +1,61 @@ +package pkg + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListProfiles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/snapshots" { + t.Fatalf("want snapshots path, got %q", r.URL.Path) + } + if got := r.URL.Query().Get("owners"); got != "alexellis" { + t.Fatalf("want owner alexellis, got %q", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Fatalf("want limit 50, got %q", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer token" { + t.Fatalf("want bearer token, got %q", got) + } + io.WriteString(w, "[]") + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL) + body, status, err := client.ListProfiles("token", "alexellis", 50, false) + if err != nil { + t.Fatal(err) + } + if status != http.StatusOK || body != "[]" { + t.Fatalf("want 200 and empty list, got %d and %q", status, body) + } +} + +func TestGetProfile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/snapshot" { + t.Fatalf("want snapshot path, got %q", r.URL.Path) + } + if got := r.URL.Query().Get("owner"); got != "openfaasltd" { + t.Fatalf("want owner openfaasltd, got %q", got) + } + if got := r.URL.Query().Get("job"); got != "123" { + t.Fatalf("want job 123, got %q", got) + } + io.WriteString(w, `{}`) + })) + defer server.Close() + + client := NewClient(server.Client(), server.URL) + _, status, err := client.GetProfile("token", "openfaasltd", "123", false) + if err != nil { + t.Fatal(err) + } + if status != http.StatusOK { + t.Fatalf("want 200, got %d", status) + } +}