Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package cli

import (
"fmt"
"os"
"strings"
"time"

"github.com/RedBoardDev/gh-runners-tool/v2/internal/auth"
"github.com/RedBoardDev/gh-runners-tool/v2/internal/config"
"github.com/RedBoardDev/gh-runners-tool/v2/internal/doctor"
"github.com/RedBoardDev/gh-runners-tool/v2/internal/launchd"
"github.com/RedBoardDev/gh-runners-tool/v2/internal/state"
"github.com/spf13/cobra"
)

func newDoctorCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "Diagnose ghr installation, configuration, and connectivity",
RunE: runDoctor,
}
cmd.Flags().Bool("json", false, "output in JSON format")
cmd.Flags().Duration("timeout", 8*time.Second, "per-check timeout")
cmd.Flags().String("only", "", "comma-separated list of check names to run")
return cmd
}

func runDoctor(cmd *cobra.Command, _ []string) error {
jsonOutput, err := cmd.Flags().GetBool("json")
if err != nil {
return fmt.Errorf("get json flag: %w", err)
}
timeout, err := cmd.Flags().GetDuration("timeout")
if err != nil {
return fmt.Errorf("get timeout flag: %w", err)
}
only, err := cmd.Flags().GetString("only")
if err != nil {
return fmt.Errorf("get only flag: %w", err)
}

checks := buildChecks()
if only != "" {
checks = filterChecks(checks, only)
}

report := doctor.Run(cmd.Context(), checks, timeout)
if jsonOutput {
if err := doctor.FormatJSON(cmd.OutOrStdout(), report); err != nil {
return fmt.Errorf("write json: %w", err)
}
} else {
doctor.FormatText(cmd.OutOrStdout(), report)
}

if code := report.ExitCode(); code != 0 {
os.Exit(code)
}
return nil
}

func buildChecks() []doctor.Check {
cfg := loadDoctorConfig()
stateDir := cfg.Daemon.StateDir
if stateDir == "" {
stateDir = resolveStateDir()
}

creds, _, _ := auth.Load(auth.LoadOpts{TokenFlag: tokenFlag})

credsPath := auth.FilePath()
credsMethod := ""
keyPath := ""
if creds != nil {
credsMethod = creds.Method
if creds.GitHubApp != nil {
keyPath = creds.GitHubApp.PrivateKeyPath
}
}

token := ""
if creds != nil {
token = creds.PAT
}

label := launchd.DefaultLabel()

return []doctor.Check{
doctor.SocketCheck{Path: state.New(stateDir).Socket()},
doctor.LaunchdCheck{Label: label, PlistPath: launchd.PlistPath(label)},
doctor.CredentialsCheck{Path: credsPath, Method: credsMethod, PrivateKeyPath: keyPath},
doctor.GitHubAPICheck{BaseURL: cfg.GitHub.URL, Token: token},
doctor.DiskCheck{Paths: []string{stateDir, cfg.Runner.CacheDir}, MinFree: 1 << 30},
doctor.RunnerCheck{CacheDir: cfg.Runner.CacheDir},
doctor.CacheCheck{Path: cfg.Runner.CacheDir},
}
}

func loadDoctorConfig() *config.Config {
if cfgFile == "" {
return &config.Config{}
}
cfg, err := config.Load(cfgFile)
if err != nil {
return &config.Config{}
}
return cfg
}

func filterChecks(in []doctor.Check, only string) []doctor.Check {
want := map[string]bool{}
for _, n := range strings.Split(only, ",") {
want[strings.TrimSpace(n)] = true
}
var out []doctor.Check
for _, c := range in {
if want[c.Name()] {
out = append(out, c)
}
}
return out
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func newRootCmd() *cobra.Command {
newLoginCmd(),
newLogoutCmd(),
newAuthCmd(),
newDoctorCmd(),
newVersionCmd(),
)

Expand Down
68 changes: 68 additions & 0 deletions internal/doctor/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package doctor

import (
"context"
"fmt"
"os"
"path/filepath"
)

type CacheCheck struct {
Path string
}

func (c CacheCheck) Name() string { return "cache" }

func (c CacheCheck) Run(ctx context.Context) Result {
res := Result{Name: c.Name(), Details: []string{c.Path}}

if c.Path == "" {
res.Status = StatusSkip
res.Summary = "no cache path configured"
return res
}

if _, err := os.Stat(c.Path); err != nil {
if os.IsNotExist(err) {
res.Status = StatusSkip
res.Summary = "cache directory does not exist yet"
return res
}
res.Status = StatusFail
res.Summary = fmt.Sprintf("stat cache: %v", err)
return res
}

size, err := dirSize(ctx, c.Path)
if err != nil {
res.Status = StatusWarn
res.Summary = fmt.Sprintf("could not measure cache: %v", err)
return res
}

res.Status = StatusOK
res.Summary = "cache size " + humanBytes(size)
return res
}

func dirSize(ctx context.Context, root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
return nil
}
info, ierr := d.Info()
if ierr != nil {
return nil //nolint:nilerr // best-effort: skip files we can't stat
}
total += info.Size()
return nil
})
return total, err
}
36 changes: 36 additions & 0 deletions internal/doctor/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package doctor

import (
"context"
"os"
"path/filepath"
"testing"
)

func TestCacheCheck_EmptyPathIsSkip(t *testing.T) {
res := CacheCheck{}.Run(context.Background())
if res.Status != StatusSkip {
t.Errorf("status = %s, want SKIP", res.Status)
}
}

func TestCacheCheck_MissingDirIsSkip(t *testing.T) {
res := CacheCheck{Path: filepath.Join(t.TempDir(), "absent")}.Run(context.Background())
if res.Status != StatusSkip {
t.Errorf("status = %s, want SKIP", res.Status)
}
}

func TestCacheCheck_ReportsSize(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "blob"), []byte("payload"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
res := CacheCheck{Path: dir}.Run(context.Background())
if res.Status != StatusOK {
t.Errorf("status = %s, want OK", res.Status)
}
if res.Summary == "" {
t.Errorf("summary empty, want size info")
}
}
64 changes: 64 additions & 0 deletions internal/doctor/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package doctor

import (
"context"
"fmt"
"os"
)

type CredentialsCheck struct {
Path string
Method string
PrivateKeyPath string
}

func (c CredentialsCheck) Name() string { return "credentials" }

func (c CredentialsCheck) Run(_ context.Context) Result {
res := Result{Name: c.Name(), Details: []string{c.Path}}

info, err := os.Stat(c.Path)
if err != nil {
if os.IsNotExist(err) {
res.Status = StatusFail
res.Summary = "credentials file missing"
res.Hint = "run 'ghr login' to set up authentication"
return res
}
res.Status = StatusFail
res.Summary = fmt.Sprintf("stat credentials: %v", err)
return res
}

if perm := info.Mode().Perm(); perm != 0o600 {
res.Status = StatusWarn
res.Summary = fmt.Sprintf("credentials file has loose perms %#o", perm)
res.Hint = fmt.Sprintf("chmod 600 %s", c.Path)
return res
}

if c.Method == "github_app" && c.PrivateKeyPath != "" {
keyInfo, kerr := os.Stat(c.PrivateKeyPath)
if kerr != nil {
res.Status = StatusFail
res.Summary = fmt.Sprintf("github app private key unreadable: %v", kerr)
res.Details = append(res.Details, c.PrivateKeyPath)
res.Hint = "verify the path in the credentials file or rerun 'ghr login'"
return res
}
if perm := keyInfo.Mode().Perm(); perm != 0o600 {
res.Status = StatusWarn
res.Summary = fmt.Sprintf("private key has loose perms %#o", perm)
res.Details = append(res.Details, c.PrivateKeyPath)
res.Hint = fmt.Sprintf("chmod 600 %s", c.PrivateKeyPath)
return res
}
res.Details = append(res.Details, "method: github_app", "key: "+c.PrivateKeyPath)
} else {
res.Details = append(res.Details, "method: "+c.Method)
}

res.Status = StatusOK
res.Summary = "credentials file ok (0600)"
return res
}
51 changes: 51 additions & 0 deletions internal/doctor/credentials_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package doctor

import (
"context"
"os"
"path/filepath"
"testing"
)

func TestCredentialsCheck_Missing(t *testing.T) {
res := CredentialsCheck{Path: filepath.Join(t.TempDir(), "absent.json")}.Run(context.Background())
if res.Status != StatusFail {
t.Errorf("status = %s, want FAIL", res.Status)
}
}

func TestCredentialsCheck_LoosePerms(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "creds.json")
if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
res := CredentialsCheck{Path: p, Method: "pat"}.Run(context.Background())
if res.Status != StatusWarn {
t.Errorf("status = %s, want WARN", res.Status)
}
}

func TestCredentialsCheck_OkPat(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "creds.json")
if err := os.WriteFile(p, []byte("{}"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
res := CredentialsCheck{Path: p, Method: "pat"}.Run(context.Background())
if res.Status != StatusOK {
t.Errorf("status = %s, want OK", res.Status)
}
}

func TestCredentialsCheck_GitHubAppKeyLoosePerms(t *testing.T) {
dir := t.TempDir()
creds := filepath.Join(dir, "creds.json")
key := filepath.Join(dir, "key.pem")
_ = os.WriteFile(creds, []byte("{}"), 0o600)
_ = os.WriteFile(key, []byte("---"), 0o644)
res := CredentialsCheck{Path: creds, Method: "github_app", PrivateKeyPath: key}.Run(context.Background())
if res.Status != StatusWarn {
t.Errorf("status = %s, want WARN", res.Status)
}
}
Loading
Loading