From e191eaf458eab142822cfab330c3ec9f3c8a8342 Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 7 Jul 2026 11:46:38 +0200 Subject: [PATCH] Reorganize repo into dirs + central run cmd Signed-off-by: Josef Karasek --- .../recommendations.go} | 23 +- .../recommendations_test.go} | 361 ++++++----- internal/cli/auth.go | 16 - internal/cli/auth/login.go | 34 ++ internal/cli/auth/login_test.go | 129 ++++ internal/cli/auth/token.go | 19 + internal/cli/auth/token_test.go | 64 ++ internal/cli/auth_token.go | 15 - internal/cli/commands_test.go | 570 ------------------ internal/cli/context/context.go | 34 ++ internal/cli/context/context_test.go | 52 ++ internal/cli/deps.go | 18 - internal/cli/{get.go => get/cluster.go} | 18 +- internal/cli/get/cluster_test.go | 242 ++++++++ internal/cli/list.go | 15 - internal/cli/list/clusters.go | 23 + internal/cli/list/clusters_test.go | 133 ++++ internal/cli/list/recommendations.go | 24 + internal/cli/list/recommendations_test.go | 131 ++++ internal/cli/login.go | 31 - internal/cli/output.go | 11 - internal/cli/recommendations.go | 20 - internal/cli/run.go | 92 +-- internal/errors/errors.go | 10 + internal/output/output.go | 4 + internal/service/cluster.go | 7 + internal/service/credentials_store.go | 29 + 27 files changed, 1201 insertions(+), 924 deletions(-) rename internal/cli/{apply_recommendations.go => apply/recommendations.go} (97%) rename internal/cli/{apply_recommendations_test.go => apply/recommendations_test.go} (67%) delete mode 100644 internal/cli/auth.go create mode 100644 internal/cli/auth/login.go create mode 100644 internal/cli/auth/login_test.go create mode 100644 internal/cli/auth/token.go create mode 100644 internal/cli/auth/token_test.go delete mode 100644 internal/cli/auth_token.go delete mode 100644 internal/cli/commands_test.go create mode 100644 internal/cli/context/context.go create mode 100644 internal/cli/context/context_test.go delete mode 100644 internal/cli/deps.go rename internal/cli/{get.go => get/cluster.go} (73%) create mode 100644 internal/cli/get/cluster_test.go delete mode 100644 internal/cli/list.go create mode 100644 internal/cli/list/clusters.go create mode 100644 internal/cli/list/clusters_test.go create mode 100644 internal/cli/list/recommendations.go create mode 100644 internal/cli/list/recommendations_test.go delete mode 100644 internal/cli/login.go delete mode 100644 internal/cli/output.go delete mode 100644 internal/cli/recommendations.go create mode 100644 internal/errors/errors.go create mode 100644 internal/service/cluster.go create mode 100644 internal/service/credentials_store.go diff --git a/internal/cli/apply_recommendations.go b/internal/cli/apply/recommendations.go similarity index 97% rename from internal/cli/apply_recommendations.go rename to internal/cli/apply/recommendations.go index f432a4e..182caae 100644 --- a/internal/cli/apply_recommendations.go +++ b/internal/cli/apply/recommendations.go @@ -1,4 +1,4 @@ -package cli +package apply import ( "bytes" @@ -12,6 +12,9 @@ import ( "strings" "gopkg.in/yaml.v3" + + clictx "github.com/kedify/cli/internal/cli/context" + clierrors "github.com/kedify/cli/internal/errors" ) var supportedRecommendationResources = []string{ @@ -113,7 +116,7 @@ type selectedContainerRecommendations struct { SelectedResources []string } -func (c *ApplyRecommendationsCmd) Run(ctx *context) error { +func (c *ApplyRecommendationsCmd) Run(ctx *clictx.Context) error { if c.Format == "override" && !c.DryRun && strings.TrimSpace(c.OutputFile) == "" { return errors.New("--output-file is required when --format override is used without --dry-run") } @@ -137,7 +140,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { Code: "unsupported", Message: fmt.Sprintf("workload kind %q is not supported in v1", kind), }) - return &commandResultError{exitCode: 1, payload: result} + return &clierrors.CommandResultError{ExitCode: 1, Payload: result} } recommendations, err := loadRecommendationsFile(c.RecommendationsFile) @@ -159,7 +162,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { Code: resolutionErr.Code, Message: resolutionErr.Message, }) - return &commandResultError{exitCode: 1, payload: result} + return &clierrors.CommandResultError{ExitCode: 1, Payload: result} } return err } @@ -240,7 +243,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { } if len(containerSelections) == 0 { - return &commandResultError{exitCode: 1, payload: result} + return &clierrors.CommandResultError{ExitCode: 1, Payload: result} } renderedManifest, err := renderHelmChart(c.ChartPath, c.ValuesFile, c.Namespace) @@ -290,7 +293,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { } if len(result.Reasons) > 0 { - return &commandResultError{exitCode: 1, payload: result} + return &clierrors.CommandResultError{ExitCode: 1, Payload: result} } overrideValues := map[string]any{} @@ -321,7 +324,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { } if len(result.Reasons) > 0 { - return &commandResultError{exitCode: 1, payload: result} + return &clierrors.CommandResultError{ExitCode: 1, Payload: result} } patchedValues, err := marshalYAMLNode(&root) @@ -351,7 +354,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { result.ChangedFiles = append(result.ChangedFiles, c.OutputFile) result.OutputFile = c.OutputFile } - _, err = ctx.stdout.Write(overrideBytes) + _, err = ctx.Stdout.Write(overrideBytes) return err case "diff": diffOutput, err := unifiedDiff(c.ValuesFile, valuesData, patchedValues) @@ -371,7 +374,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { if diffOutput == "" { diffOutput = "\n" } - _, err = io.WriteString(ctx.stdout, diffOutput) + _, err = io.WriteString(ctx.Stdout, diffOutput) return err default: result.Result = "patched" @@ -384,7 +387,7 @@ func (c *ApplyRecommendationsCmd) Run(ctx *context) error { } result.ChangedFiles = append(result.ChangedFiles, c.ValuesFile) } - return ctx.writeOutput(ctx.stdout, result, "json") + return ctx.WriteOutput(ctx.Stdout, result, "json") } } diff --git a/internal/cli/apply_recommendations_test.go b/internal/cli/apply/recommendations_test.go similarity index 67% rename from internal/cli/apply_recommendations_test.go rename to internal/cli/apply/recommendations_test.go index 8e98105..c6ea516 100644 --- a/internal/cli/apply_recommendations_test.go +++ b/internal/cli/apply/recommendations_test.go @@ -1,13 +1,18 @@ -package cli +package apply import ( "bytes" "encoding/json" + "errors" "os" "path/filepath" "slices" "strings" "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + clierrors "github.com/kedify/cli/internal/errors" + "github.com/kedify/cli/internal/output" ) func TestApplyRecommendationsDiffDryRunDoesNotMutateValuesFile(t *testing.T) { @@ -20,21 +25,24 @@ func TestApplyRecommendationsDiffDryRunDoesNotMutateValuesFile(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator", - "--namespace", "keda", - "--container", "keda-operator", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests,memory-limits", - "--min-confidence", "20", - "--format", "diff", - "--dry-run", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator", + Namespace: "keda", + Container: "keda-operator", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests,memory-limits", + MinConfidence: 20, + Format: "diff", + DryRun: true, + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code != 0 { - t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + t.Fatalf("runApply() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) @@ -57,20 +65,23 @@ func TestApplyRecommendationsDiffPatchesValuesFile(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator", - "--namespace", "keda", - "--container", "keda-operator", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests,memory-limits", - "--min-confidence", "20", - "--format", "diff", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator", + Namespace: "keda", + Container: "keda-operator", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests,memory-limits", + MinConfidence: 20, + Format: "diff", + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code != 0 { - t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + t.Fatalf("runApply() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "+ memory: 138Mi") { t.Fatalf("stdout = %q, want unified diff with memory change", stdout.String()) @@ -103,20 +114,23 @@ func TestApplyRecommendationsOverrideWritesOutputFile(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/kedify-agent", - "--namespace", "keda", - "--container", "manager", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--min-confidence", "20", - "--format", "override", - "--output-file", outputFile, - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/kedify-agent", + Namespace: "keda", + Container: "manager", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + MinConfidence: 20, + Format: "override", + OutputFile: outputFile, + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code != 0 { - t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + t.Fatalf("runApply() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) @@ -156,21 +170,24 @@ func TestApplyRecommendationsJSONReportsContainerScopedPath(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator", - "--namespace", "keda", - "--container", "keda-operator", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests,memory-limits", - "--min-confidence", "20", - "--format", "json", - "--dry-run", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator", + Namespace: "keda", + Container: "keda-operator", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests,memory-limits", + MinConfidence: 20, + Format: "json", + DryRun: true, + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code != 0 { - t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + t.Fatalf("runApply() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) @@ -215,20 +232,23 @@ func TestApplyRecommendationsWithoutContainerAutoResolvesSingleMatchingContainer stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator-metrics-apiserver", - "--namespace", "keda", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests,memory-limits", - "--min-confidence", "20", - "--format", "json", - "--dry-run", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator-metrics-apiserver", + Namespace: "keda", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests,memory-limits", + MinConfidence: 20, + Format: "json", + DryRun: true, + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code != 0 { - t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + t.Fatalf("runApply() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) } var result applyRecommendationsResult @@ -256,20 +276,23 @@ func TestApplyRecommendationsWithoutContainerPatchesAllMatchedContainers(t *test stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator", - "--namespace", "keda", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests,memory-limits", - "--min-confidence", "20", - "--format", "json", - "--dry-run", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator", + Namespace: "keda", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests,memory-limits", + MinConfidence: 20, + Format: "json", + DryRun: true, + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code != 0 { - t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + t.Fatalf("runApply() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) } var result applyRecommendationsResult @@ -366,32 +389,35 @@ func TestApplyRecommendationsWithoutContainerFailsWhenOneMatchedContainerIsMissi stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator", - "--namespace", "keda", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests,memory-limits", - "--min-confidence", "20", - "--format", "json", - "--dry-run", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator", + Namespace: "keda", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests,memory-limits", + MinConfidence: 20, + Format: "json", + DryRun: true, + }, stdout, stderr) - if code != 0 { - var result applyRecommendationsResult - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) - } - if !containsReasonCode(result.Reasons, "not_found") { - t.Fatalf("reasons = %#v, want not_found", result.Reasons) - } - if !strings.Contains(stdout.String(), "audit-sidecar") || !strings.Contains(stdout.String(), "memory-limits") { - t.Fatalf("stdout = %q, want failing container and resource details", stdout.String()) - } - return + if err != nil { + t.Fatalf("runApply() error = %v", err) + } + if code == 0 { + t.Fatalf("runApply() exit code = %d, want non-zero", code) + } + + var result applyRecommendationsResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("Unmarshal() error = %v, stdout = %q", err, stdout.String()) + } + if !containsReasonCode(result.Reasons, "not_found") { + t.Fatalf("reasons = %#v, want not_found", result.Reasons) + } + if !strings.Contains(stdout.String(), "audit-sidecar") || !strings.Contains(stdout.String(), "memory-limits") { + t.Fatalf("stdout = %q, want failing container and resource details", stdout.String()) } - t.Fatalf("Run() exit code = %d, want non-zero", code) } func TestApplyRecommendationsFailsBelowConfidenceThreshold(t *testing.T) { @@ -400,19 +426,23 @@ func TestApplyRecommendationsFailsBelowConfidenceThreshold(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator", - "--namespace", "keda", - "--container", "keda-operator", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests", - "--format", "json", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator", + Namespace: "keda", + Container: "keda-operator", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests", + MinConfidence: 90, + Format: "json", + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code == 0 { - t.Fatalf("Run() exit code = %d, want non-zero", code) + t.Fatalf("runApply() exit code = %d, want non-zero", code) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) @@ -436,20 +466,23 @@ func TestApplyRecommendationsFailsWhenResourceIsMissing(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/kedify-agent", - "--namespace", "keda", - "--container", "manager", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-limits", - "--min-confidence", "20", - "--format", "json", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/kedify-agent", + Namespace: "keda", + Container: "manager", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-limits", + MinConfidence: 20, + Format: "json", + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code == 0 { - t.Fatalf("Run() exit code = %d, want non-zero", code) + t.Fatalf("runApply() exit code = %d, want non-zero", code) } var result applyRecommendationsResult @@ -484,20 +517,23 @@ duplicates: stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/keda-operator", - "--namespace", "keda", - "--container", "keda-operator", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests", - "--min-confidence", "20", - "--format", "json", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/keda-operator", + Namespace: "keda", + Container: "keda-operator", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests", + MinConfidence: 20, + Format: "json", + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code == 0 { - t.Fatalf("Run() exit code = %d, want non-zero", code) + t.Fatalf("runApply() exit code = %d, want non-zero", code) } var result applyRecommendationsResult @@ -561,20 +597,23 @@ spec: stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - code := Run([]string{ - "apply", "recommendations", "deployment/demo", - "--namespace", "custom-ns", - "--chart-path", chartPath, - "--values-file", valuesFile, - "--recommendations-file", recommendationsFile, - "--resources", "cpu-requests", - "--min-confidence", "20", - "--format", "json", - "--dry-run", - }, bytes.NewBuffer(nil), stdout, stderr) + code, err := runApply(t, &ApplyRecommendationsCmd{ + Target: "deployment/demo", + Namespace: "custom-ns", + ChartPath: chartPath, + ValuesFile: valuesFile, + RecommendationsFile: recommendationsFile, + Resources: "cpu-requests", + MinConfidence: 20, + Format: "json", + DryRun: true, + }, stdout, stderr) + if err != nil { + t.Fatalf("runApply() error = %v", err) + } if code != 0 { - t.Fatalf("Run() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + t.Fatalf("runApply() exit code = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) @@ -592,10 +631,36 @@ spec: } } +func runApply(t *testing.T, cmd *ApplyRecommendationsCmd, stdout, stderr *bytes.Buffer) (int, error) { + t.Helper() + + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + WriteOutput: output.WriteOutput, + } + + if err := cmd.Run(ctx); err != nil { + var cmdErr *clierrors.CommandResultError + if errors.As(err, &cmdErr) { + if cmdErr.Payload != nil { + if writeErr := output.WriteOutput(stdout, cmdErr.Payload, "json"); writeErr != nil { + return 1, writeErr + } + } + return cmdErr.ExitCode, nil + } + return 1, err + } + + return 0, nil +} + func copyTestChart(t *testing.T) (string, string) { t.Helper() - sourceRoot, err := filepath.Abs("../../test/chart") + sourceRoot, err := filepath.Abs("../../../test/chart") if err != nil { t.Fatalf("Abs() error = %v", err) } @@ -643,7 +708,7 @@ func copyDir(t *testing.T, source, target string) { func testRecommendationsFile(t *testing.T) string { t.Helper() - path, err := filepath.Abs("../../test/recommendations.json") + path, err := filepath.Abs("../../../test/recommendations.json") if err != nil { t.Fatalf("Abs() error = %v", err) } diff --git a/internal/cli/auth.go b/internal/cli/auth.go deleted file mode 100644 index 1d13c7c..0000000 --- a/internal/cli/auth.go +++ /dev/null @@ -1,16 +0,0 @@ -package cli - -import "strings" - -func resolveToken(ctx *context) (string, error) { - if token := strings.TrimSpace(ctx.token); token != "" { - return token, nil - } - - creds, err := ctx.credentials.ReadCredentials() - if err != nil { - return "", err - } - - return strings.TrimSpace(creds.Token), nil -} diff --git a/internal/cli/auth/login.go b/internal/cli/auth/login.go new file mode 100644 index 0000000..833aaa4 --- /dev/null +++ b/internal/cli/auth/login.go @@ -0,0 +1,34 @@ +package auth + +import ( + "fmt" + "strings" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/service" +) + +type LoginCmd struct { + Token string `arg:"" optional:"" name:"token" help:"Token to store without prompting."` +} + +func (c *LoginCmd) Run(ctx *clictx.Context) error { + token := strings.TrimSpace(c.Token) + if token == "" { + token = strings.TrimSpace(ctx.Token) + } + if token == "" { + var err error + token, err = ctx.ReadSecret(ctx.Stdin, ctx.Stdout, ctx.Stderr) + if err != nil { + return err + } + } + + if err := ctx.Credentials.WriteCredentials(service.Credentials{Token: token}); err != nil { + return err + } + + _, err := fmt.Fprintln(ctx.Stderr, "Credentials stored.") + return err +} diff --git a/internal/cli/auth/login_test.go b/internal/cli/auth/login_test.go new file mode 100644 index 0000000..583e1a7 --- /dev/null +++ b/internal/cli/auth/login_test.go @@ -0,0 +1,129 @@ +package auth + +import ( + "bytes" + "io" + "strings" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/service" +) + +type fakeCredentialsStore struct { + creds service.Credentials + readErr error + writeErr error + wrote service.Credentials +} + +func (f *fakeCredentialsStore) ReadCredentials() (service.Credentials, error) { + if f.readErr != nil { + return service.Credentials{}, f.readErr + } + return f.creds, nil +} + +func (f *fakeCredentialsStore) WriteCredentials(creds service.Credentials) error { + if f.writeErr != nil { + return f.writeErr + } + f.wrote = creds + return nil +} + +func TestLoginCmdRunStoresCredentials(t *testing.T) { + store := &fakeCredentialsStore{} + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + Credentials: store, + ReadSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + return "secret-token", nil + }, + } + + if err := (&LoginCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "secret-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "secret-token") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "Credentials stored.") { + t.Fatalf("stderr = %q, want confirmation message", stderr.String()) + } +} + +func TestLoginCmdRunUsesExplicitToken(t *testing.T) { + store := &fakeCredentialsStore{} + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + Credentials: store, + ReadSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + t.Fatal("ReadSecret should not be called when token is provided explicitly") + return "", nil + }, + } + + if err := (&LoginCmd{Token: "arg-token"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "arg-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "arg-token") + } +} + +func TestLoginCmdRunIgnoresWhitespaceExplicitToken(t *testing.T) { + store := &fakeCredentialsStore{} + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + Token: "env-token", + Credentials: store, + ReadSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + t.Fatal("ReadSecret should not be called when trimmed context token is available") + return "", nil + }, + } + + if err := (&LoginCmd{Token: " "}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "env-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "env-token") + } +} + +func TestLoginCmdRunIgnoresWhitespaceContextToken(t *testing.T) { + store := &fakeCredentialsStore{} + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + Token: " ", + Credentials: store, + ReadSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + return "secret-token", nil + }, + } + + if err := (&LoginCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "secret-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "secret-token") + } +} diff --git a/internal/cli/auth/token.go b/internal/cli/auth/token.go new file mode 100644 index 0000000..07ebc40 --- /dev/null +++ b/internal/cli/auth/token.go @@ -0,0 +1,19 @@ +package auth + +import ( + "fmt" + + clictx "github.com/kedify/cli/internal/cli/context" +) + +type AuthTokenCmd struct{} + +func (c *AuthTokenCmd) Run(ctx *clictx.Context) error { + token, err := clictx.ResolveToken(ctx) + if err != nil { + return err + } + + _, err = fmt.Fprintln(ctx.Stdout, token) + return err +} diff --git a/internal/cli/auth/token_test.go b/internal/cli/auth/token_test.go new file mode 100644 index 0000000..9fef9c6 --- /dev/null +++ b/internal/cli/auth/token_test.go @@ -0,0 +1,64 @@ +package auth + +import ( + "bytes" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/service" +) + +type fakeTokenCredentialsStore struct { + creds service.Credentials +} + +func (f *fakeTokenCredentialsStore) ReadCredentials() (service.Credentials, error) { + return f.creds, nil +} + +func (f *fakeTokenCredentialsStore) WriteCredentials(service.Credentials) error { + return nil +} + +func TestAuthTokenCmdRunPrintsStoredToken(t *testing.T) { + store := &fakeTokenCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + Credentials: store, + } + + if err := (&AuthTokenCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.String() != "stored-token\n" { + t.Fatalf("stdout = %q, want token with newline", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestAuthTokenCmdRunPrefersContextToken(t *testing.T) { + store := &fakeTokenCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + stdout := &bytes.Buffer{} + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: &bytes.Buffer{}, + Token: "override-token", + Credentials: store, + } + + if err := (&AuthTokenCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.String() != "override-token\n" { + t.Fatalf("stdout = %q, want override token with newline", stdout.String()) + } +} diff --git a/internal/cli/auth_token.go b/internal/cli/auth_token.go deleted file mode 100644 index e4fe25a..0000000 --- a/internal/cli/auth_token.go +++ /dev/null @@ -1,15 +0,0 @@ -package cli - -import "fmt" - -type AuthTokenCmd struct{} - -func (c *AuthTokenCmd) Run(ctx *context) error { - token, err := resolveToken(ctx) - if err != nil { - return err - } - - _, err = fmt.Fprintln(ctx.stdout, token) - return err -} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go deleted file mode 100644 index 29c4bf1..0000000 --- a/internal/cli/commands_test.go +++ /dev/null @@ -1,570 +0,0 @@ -package cli - -import ( - "bytes" - "io" - "strings" - "testing" -) - -type fakeCredentialsStore struct { - creds credentials - readErr error - writeErr error - wrote credentials -} - -func (f *fakeCredentialsStore) ReadCredentials() (credentials, error) { - if f.readErr != nil { - return credentials{}, f.readErr - } - return f.creds, nil -} - -func (f *fakeCredentialsStore) WriteCredentials(creds credentials) error { - if f.writeErr != nil { - return f.writeErr - } - f.wrote = creds - return nil -} - -type fakeClusterService struct { - clusters []map[string]any - cluster map[string]any - recommendations any - err error - lastURL string - lastToken string - lastID string -} - -func (f *fakeClusterService) ListClusters(apiURL, token string) ([]map[string]any, error) { - f.lastURL = apiURL - f.lastToken = token - if f.err != nil { - return nil, f.err - } - return f.clusters, nil -} - -func (f *fakeClusterService) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { - f.lastURL = apiURL - f.lastToken = token - f.lastID = clusterID - if f.err != nil { - return nil, f.err - } - return f.cluster, nil -} - -func (f *fakeClusterService) GetRecommendations(apiURL, token, clusterID string) (any, error) { - f.lastURL = apiURL - f.lastToken = token - f.lastID = clusterID - if f.err != nil { - return nil, f.err - } - return f.recommendations, nil -} - -func TestLoginCmdRunStoresCredentials(t *testing.T) { - store := &fakeCredentialsStore{} - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: stdout, - stderr: stderr, - credentials: store, - readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { - return "secret-token", nil - }, - } - - if err := (&LoginCmd{}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if store.wrote.Token != "secret-token" { - t.Fatalf("stored token = %q, want %q", store.wrote.Token, "secret-token") - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } - if !strings.Contains(stderr.String(), "Credentials stored.") { - t.Fatalf("stderr = %q, want confirmation message", stderr.String()) - } -} - -func TestLoginCmdRunUsesExplicitToken(t *testing.T) { - store := &fakeCredentialsStore{} - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - credentials: store, - readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { - t.Fatal("readSecret should not be called when token is provided explicitly") - return "", nil - }, - } - - if err := (&LoginCmd{Token: "arg-token"}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if store.wrote.Token != "arg-token" { - t.Fatalf("stored token = %q, want %q", store.wrote.Token, "arg-token") - } -} - -func TestLoginCmdRunIgnoresWhitespaceExplicitToken(t *testing.T) { - store := &fakeCredentialsStore{} - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - token: "env-token", - credentials: store, - readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { - t.Fatal("readSecret should not be called when trimmed context token is available") - return "", nil - }, - } - - if err := (&LoginCmd{Token: " "}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if store.wrote.Token != "env-token" { - t.Fatalf("stored token = %q, want %q", store.wrote.Token, "env-token") - } -} - -func TestLoginCmdRunIgnoresWhitespaceContextToken(t *testing.T) { - store := &fakeCredentialsStore{} - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - token: " ", - credentials: store, - readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { - return "secret-token", nil - }, - } - - if err := (&LoginCmd{}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if store.wrote.Token != "secret-token" { - t.Fatalf("stored token = %q, want %q", store.wrote.Token, "secret-token") - } -} - -func TestAuthTokenCmdRunPrintsStoredToken(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: stdout, - stderr: stderr, - credentials: store, - } - - if err := (&AuthTokenCmd{}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if stdout.String() != "stored-token\n" { - t.Fatalf("stdout = %q, want token with newline", stdout.String()) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} - -func TestAuthTokenCmdRunPrefersContextToken(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - stdout := &bytes.Buffer{} - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: stdout, - stderr: &bytes.Buffer{}, - token: "override-token", - credentials: store, - } - - if err := (&AuthTokenCmd{}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if stdout.String() != "override-token\n" { - t.Fatalf("stdout = %q, want override token with newline", stdout.String()) - } -} - -func TestListClustersCmdRunWritesClusters(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - clusters: []map[string]any{{"name": "alpha"}, {"name": "beta"}}, - } - - var gotValue any - var gotFormat string - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - writeOutput: func(_ io.Writer, value any, format string) error { - gotValue = value - gotFormat = format - return nil - }, - } - - cmd := &ListClustersCmd{Output: "yaml"} - if err := cmd.Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - clusters, ok := gotValue.([]map[string]any) - if !ok || len(clusters) != 2 { - t.Fatalf("got output value = %#v", gotValue) - } - if gotFormat != "yaml" { - t.Fatalf("output format = %q, want %q", gotFormat, "yaml") - } - if service.lastToken != "override-token" { - t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") - } -} - -func TestListClustersCmdRunWritesResultsOnlyToStdout(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - clusters: []map[string]any{{"name": "alpha"}}, - } - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: stdout, - stderr: stderr, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - writeOutput: writeOutput, - } - - if err := (&ListClustersCmd{Output: "json"}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if stdout.Len() == 0 { - t.Fatal("stdout is empty, want rendered output") - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} - -func TestGetClusterCmdRunFindsNamedCluster(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - clusters: []map[string]any{ - {"id": "1", "name": "alpha"}, - {"id": "2", "name": "beta"}, - }, - } - - var gotValue any - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { - t.Fatal("selector should not be called when name is provided") - return nil, nil - }, - writeOutput: func(_ io.Writer, value any, _ string) error { - gotValue = value - return nil - }, - } - - cmd := &GetClusterCmd{Name: "beta", Output: "json"} - if err := cmd.Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - cluster, ok := gotValue.(map[string]any) - if !ok { - t.Fatalf("got output value = %#v", gotValue) - } - if cluster["id"] != "2" { - t.Fatalf("selected cluster = %#v", cluster) - } - if service.lastToken != "override-token" { - t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") - } -} - -func TestGetClusterCmdRunWritesResultsOnlyToStdout(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - clusters: []map[string]any{ - {"id": "2", "name": "beta"}, - }, - } - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: stdout, - stderr: stderr, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { - t.Fatal("selector should not be called when name is provided") - return nil, nil - }, - writeOutput: writeOutput, - } - - if err := (&GetClusterCmd{Name: "beta", Output: "json"}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if stdout.Len() == 0 { - t.Fatal("stdout is empty, want rendered output") - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} - -func TestGetClusterCmdRunUsesDedicatedEndpointForUUID(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - cluster: map[string]any{ - "id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", - "name": "alpha", - }, - } - - var gotValue any - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { - t.Fatal("selector should not be called when uuid is provided") - return nil, nil - }, - writeOutput: func(_ io.Writer, value any, _ string) error { - gotValue = value - return nil - }, - } - - id := "fc6af0dc-685b-4055-805d-0d3e0ead1596" - cmd := &GetClusterCmd{Name: id, Output: "json"} - if err := cmd.Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - cluster, ok := gotValue.(map[string]any) - if !ok || cluster["id"] != id { - t.Fatalf("got output value = %#v", gotValue) - } - if service.lastID != id { - t.Fatalf("service lastID = %q, want %q", service.lastID, id) - } -} - -func TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - clusters: []map[string]any{{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha"}}, - cluster: map[string]any{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha", "agentStatus": "connected"}, - } - selected := map[string]any{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha"} - - var gotValue any - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, clusters []map[string]any) (map[string]any, error) { - if len(clusters) != 1 { - t.Fatalf("selector clusters len = %d, want 1", len(clusters)) - } - return selected, nil - }, - writeOutput: func(_ io.Writer, value any, _ string) error { - gotValue = value - return nil - }, - } - - if err := (&GetClusterCmd{Output: "json"}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - cluster, ok := gotValue.(map[string]any) - if !ok { - t.Fatalf("got output value = %#v", gotValue) - } - if cluster["id"] != selected["id"] { - t.Fatalf("got output value = %#v, want selected cluster", gotValue) - } - if service.lastToken != "override-token" { - t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") - } - if service.lastID != "fc6af0dc-685b-4055-805d-0d3e0ead1596" { - t.Fatalf("service lastID = %q", service.lastID) - } -} - -func TestListRecommendationsCmdRunWritesRecommendations(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - recommendations: map[string]any{ - "items": []map[string]any{{"kind": "cpu"}}, - }, - } - - var gotValue any - var gotFormat string - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - writeOutput: func(_ io.Writer, value any, format string) error { - gotValue = value - gotFormat = format - return nil - }, - } - - id := "fc6af0dc-685b-4055-805d-0d3e0ead1596" - if err := (&ListRecommendationsCmd{ClusterID: id, Output: "yaml"}).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - payload, ok := gotValue.(map[string]any) - if !ok { - t.Fatalf("got output value = %#v", gotValue) - } - if gotFormat != "yaml" { - t.Fatalf("output format = %q, want %q", gotFormat, "yaml") - } - if payload["items"] == nil { - t.Fatalf("payload = %#v", payload) - } - if service.lastToken != "override-token" { - t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") - } - if service.lastID != id { - t.Fatalf("service lastID = %q, want %q", service.lastID, id) - } -} - -func TestListRecommendationsCmdRunWritesResultsOnlyToStdout(t *testing.T) { - store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} - service := &fakeClusterService{ - recommendations: []map[string]any{{"kind": "cpu"}}, - } - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - - ctx := &context{ - stdin: bytes.NewBuffer(nil), - stdout: stdout, - stderr: stderr, - apiURL: "https://api.dev.kedify.io/v1", - token: "override-token", - client: service, - credentials: store, - writeOutput: writeOutput, - } - - if err := (&ListRecommendationsCmd{ - ClusterID: "fc6af0dc-685b-4055-805d-0d3e0ead1596", - Output: "json", - }).Run(ctx); err != nil { - t.Fatalf("Run() error = %v", err) - } - - if stdout.Len() == 0 { - t.Fatal("stdout is empty, want rendered output") - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} - -func TestFindClusterReturnsErrorWhenMissing(t *testing.T) { - _, err := findCluster([]map[string]any{{"name": "alpha"}}, "beta") - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestResolveTokenFallsBackToCredentials(t *testing.T) { - ctx := &context{ - credentials: &fakeCredentialsStore{creds: credentials{Token: "stored-token"}}, - } - - token, err := resolveToken(ctx) - if err != nil { - t.Fatalf("resolveToken() error = %v", err) - } - if token != "stored-token" { - t.Fatalf("token = %q, want %q", token, "stored-token") - } -} - -func TestResolveTokenPrefersContextToken(t *testing.T) { - ctx := &context{ - token: "override-token", - credentials: &fakeCredentialsStore{creds: credentials{Token: "stored-token"}}, - } - - token, err := resolveToken(ctx) - if err != nil { - t.Fatalf("resolveToken() error = %v", err) - } - if token != "override-token" { - t.Fatalf("token = %q, want %q", token, "override-token") - } -} diff --git a/internal/cli/context/context.go b/internal/cli/context/context.go new file mode 100644 index 0000000..79a2a65 --- /dev/null +++ b/internal/cli/context/context.go @@ -0,0 +1,34 @@ +package context + +import ( + "io" + "strings" + + "github.com/kedify/cli/internal/service" +) + +type Context struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + APIURL string + Token string + Client service.ClusterService + Credentials service.CredentialsStore + ReadSecret func(io.Reader, io.Writer, io.Writer) (string, error) + SelectCluster func(io.Reader, io.Writer, io.Writer, []map[string]any) (map[string]any, error) + WriteOutput func(io.Writer, any, string) error +} + +func ResolveToken(ctx *Context) (string, error) { + if token := strings.TrimSpace(ctx.Token); token != "" { + return token, nil + } + + creds, err := ctx.Credentials.ReadCredentials() + if err != nil { + return "", err + } + + return strings.TrimSpace(creds.Token), nil +} diff --git a/internal/cli/context/context_test.go b/internal/cli/context/context_test.go new file mode 100644 index 0000000..10c7595 --- /dev/null +++ b/internal/cli/context/context_test.go @@ -0,0 +1,52 @@ +package context + +import ( + "testing" + + "github.com/kedify/cli/internal/service" +) + +type fakeCredentialsStore struct { + creds service.Credentials + readErr error +} + +func (f *fakeCredentialsStore) ReadCredentials() (service.Credentials, error) { + if f.readErr != nil { + return service.Credentials{}, f.readErr + } + return f.creds, nil +} + +func (f *fakeCredentialsStore) WriteCredentials(service.Credentials) error { + return nil +} + +func TestResolveTokenFallsBackToCredentials(t *testing.T) { + ctx := &Context{ + Credentials: &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}}, + } + + token, err := ResolveToken(ctx) + if err != nil { + t.Fatalf("ResolveToken() error = %v", err) + } + if token != "stored-token" { + t.Fatalf("token = %q, want %q", token, "stored-token") + } +} + +func TestResolveTokenPrefersContextToken(t *testing.T) { + ctx := &Context{ + Token: "override-token", + Credentials: &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}}, + } + + token, err := ResolveToken(ctx) + if err != nil { + t.Fatalf("ResolveToken() error = %v", err) + } + if token != "override-token" { + t.Fatalf("token = %q, want %q", token, "override-token") + } +} diff --git a/internal/cli/deps.go b/internal/cli/deps.go deleted file mode 100644 index 510fffa..0000000 --- a/internal/cli/deps.go +++ /dev/null @@ -1,18 +0,0 @@ -package cli - -import "github.com/kedify/cli/internal/config" - -type configStore struct{} - -func (configStore) ReadCredentials() (credentials, error) { - creds, err := config.ReadCredentials() - if err != nil { - return credentials{}, err - } - - return credentials{Token: creds.Token}, nil -} - -func (configStore) WriteCredentials(creds credentials) error { - return config.WriteCredentials(config.Credentials{Token: creds.Token}) -} diff --git a/internal/cli/get.go b/internal/cli/get/cluster.go similarity index 73% rename from internal/cli/get.go rename to internal/cli/get/cluster.go index 09d263e..5ae5011 100644 --- a/internal/cli/get.go +++ b/internal/cli/get/cluster.go @@ -1,9 +1,11 @@ -package cli +package get import ( "fmt" "github.com/google/uuid" + + clictx "github.com/kedify/cli/internal/cli/context" ) type GetClusterCmd struct { @@ -11,15 +13,15 @@ type GetClusterCmd struct { Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` } -func (c *GetClusterCmd) Run(ctx *context) error { - token, err := resolveToken(ctx) +func (c *GetClusterCmd) Run(ctx *clictx.Context) error { + token, err := clictx.ResolveToken(ctx) if err != nil { return err } var clusters []map[string]any if c.Name == "" || !isUUID(c.Name) { - clusters, err = ctx.client.ListClusters(ctx.apiURL, token) + clusters, err = ctx.Client.ListClusters(ctx.APIURL, token) if err != nil { return err } @@ -28,7 +30,7 @@ func (c *GetClusterCmd) Run(ctx *context) error { var cluster map[string]any if c.Name != "" { if isUUID(c.Name) { - cluster, err = ctx.client.GetCluster(ctx.apiURL, token, c.Name) + cluster, err = ctx.Client.GetCluster(ctx.APIURL, token, c.Name) if err != nil { return err } @@ -39,20 +41,20 @@ func (c *GetClusterCmd) Run(ctx *context) error { } } } else { - selectedCluster, err := ctx.selectCluster(ctx.stdin, ctx.stdout, ctx.stderr, clusters) + selectedCluster, err := ctx.SelectCluster(ctx.Stdin, ctx.Stdout, ctx.Stderr, clusters) if err != nil { return err } cluster = selectedCluster if id := clusterString(selectedCluster, "id"); isUUID(id) { - cluster, err = ctx.client.GetCluster(ctx.apiURL, token, id) + cluster, err = ctx.Client.GetCluster(ctx.APIURL, token, id) if err != nil { return err } } } - return ctx.writeOutput(ctx.stdout, cluster, c.Output) + return ctx.WriteOutput(ctx.Stdout, cluster, c.Output) } func findCluster(clusters []map[string]any, query string) (map[string]any, error) { diff --git a/internal/cli/get/cluster_test.go b/internal/cli/get/cluster_test.go new file mode 100644 index 0000000..5f2948a --- /dev/null +++ b/internal/cli/get/cluster_test.go @@ -0,0 +1,242 @@ +package get + +import ( + "bytes" + "io" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/output" + "github.com/kedify/cli/internal/service" +) + +type fakeCredentialsStore struct { + creds service.Credentials +} + +func (f *fakeCredentialsStore) ReadCredentials() (service.Credentials, error) { + return f.creds, nil +} + +func (f *fakeCredentialsStore) WriteCredentials(service.Credentials) error { + return nil +} + +type fakeClusterService struct { + clusters []map[string]any + cluster map[string]any + recommendations any + err error + lastURL string + lastToken string + lastID string +} + +func (f *fakeClusterService) ListClusters(apiURL, token string) ([]map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + if f.err != nil { + return nil, f.err + } + return f.clusters, nil +} + +func (f *fakeClusterService) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.cluster, nil +} + +func (f *fakeClusterService) GetRecommendations(apiURL, token, clusterID string) (any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.recommendations, nil +} + +func TestGetClusterCmdRunFindsNamedCluster(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + clusters: []map[string]any{ + {"id": "1", "name": "alpha"}, + {"id": "2", "name": "beta"}, + }, + } + + var gotValue any + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + SelectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("SelectCluster should not be called when name is provided") + return nil, nil + }, + WriteOutput: func(_ io.Writer, value any, _ string) error { + gotValue = value + return nil + }, + } + + cmd := &GetClusterCmd{Name: "beta", Output: "json"} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + cluster, ok := gotValue.(map[string]any) + if !ok { + t.Fatalf("got output value = %#v", gotValue) + } + if cluster["id"] != "2" { + t.Fatalf("selected cluster = %#v", cluster) + } + if clusterService.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", clusterService.lastToken, "override-token") + } +} + +func TestGetClusterCmdRunWritesResultsOnlyToStdout(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + clusters: []map[string]any{ + {"id": "2", "name": "beta"}, + }, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + SelectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("SelectCluster should not be called when name is provided") + return nil, nil + }, + WriteOutput: output.WriteOutput, + } + + if err := (&GetClusterCmd{Name: "beta", Output: "json"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.Len() == 0 { + t.Fatal("stdout is empty, want rendered output") + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestGetClusterCmdRunUsesDedicatedEndpointForUUID(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + cluster: map[string]any{ + "id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", + "name": "alpha", + }, + } + + var gotValue any + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + SelectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("SelectCluster should not be called when UUID is provided") + return nil, nil + }, + WriteOutput: func(_ io.Writer, value any, _ string) error { + gotValue = value + return nil + }, + } + + id := "fc6af0dc-685b-4055-805d-0d3e0ead1596" + cmd := &GetClusterCmd{Name: id, Output: "json"} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + cluster, ok := gotValue.(map[string]any) + if !ok || cluster["id"] != id { + t.Fatalf("got output value = %#v", gotValue) + } + if clusterService.lastID != id { + t.Fatalf("service lastID = %q, want %q", clusterService.lastID, id) + } +} + +func TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + clusters: []map[string]any{{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha"}}, + cluster: map[string]any{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha", "agentStatus": "connected"}, + } + selected := map[string]any{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha"} + + var gotValue any + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + SelectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, clusters []map[string]any) (map[string]any, error) { + if len(clusters) != 1 { + t.Fatalf("selector clusters len = %d, want 1", len(clusters)) + } + return selected, nil + }, + WriteOutput: func(_ io.Writer, value any, _ string) error { + gotValue = value + return nil + }, + } + + if err := (&GetClusterCmd{Output: "json"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + cluster, ok := gotValue.(map[string]any) + if !ok { + t.Fatalf("got output value = %#v", gotValue) + } + if cluster["id"] != selected["id"] { + t.Fatalf("got output value = %#v, want selected cluster", gotValue) + } + if clusterService.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", clusterService.lastToken, "override-token") + } + if clusterService.lastID != "fc6af0dc-685b-4055-805d-0d3e0ead1596" { + t.Fatalf("service lastID = %q", clusterService.lastID) + } +} + +func TestFindClusterReturnsErrorWhenMissing(t *testing.T) { + _, err := findCluster([]map[string]any{{"name": "alpha"}}, "beta") + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/cli/list.go b/internal/cli/list.go deleted file mode 100644 index 1747d27..0000000 --- a/internal/cli/list.go +++ /dev/null @@ -1,15 +0,0 @@ -package cli - -func (c *ListClustersCmd) Run(ctx *context) error { - token, err := resolveToken(ctx) - if err != nil { - return err - } - - clusters, err := ctx.client.ListClusters(ctx.apiURL, token) - if err != nil { - return err - } - - return ctx.writeOutput(ctx.stdout, clusters, c.Output) -} diff --git a/internal/cli/list/clusters.go b/internal/cli/list/clusters.go new file mode 100644 index 0000000..2cb72eb --- /dev/null +++ b/internal/cli/list/clusters.go @@ -0,0 +1,23 @@ +package list + +import ( + clictx "github.com/kedify/cli/internal/cli/context" +) + +type ListClustersCmd struct { + Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` +} + +func (c *ListClustersCmd) Run(ctx *clictx.Context) error { + token, err := clictx.ResolveToken(ctx) + if err != nil { + return err + } + + clusters, err := ctx.Client.ListClusters(ctx.APIURL, token) + if err != nil { + return err + } + + return ctx.WriteOutput(ctx.Stdout, clusters, c.Output) +} diff --git a/internal/cli/list/clusters_test.go b/internal/cli/list/clusters_test.go new file mode 100644 index 0000000..fce749e --- /dev/null +++ b/internal/cli/list/clusters_test.go @@ -0,0 +1,133 @@ +package list + +import ( + "bytes" + "io" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/output" + "github.com/kedify/cli/internal/service" +) + +type fakeCredentialsStore struct { + creds service.Credentials +} + +func (f *fakeCredentialsStore) ReadCredentials() (service.Credentials, error) { + return f.creds, nil +} + +func (f *fakeCredentialsStore) WriteCredentials(service.Credentials) error { + return nil +} + +type fakeClusterService struct { + clusters []map[string]any + cluster map[string]any + recommendations any + err error + lastURL string + lastToken string + lastID string +} + +func (f *fakeClusterService) ListClusters(apiURL, token string) ([]map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + if f.err != nil { + return nil, f.err + } + return f.clusters, nil +} + +func (f *fakeClusterService) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.cluster, nil +} + +func (f *fakeClusterService) GetRecommendations(apiURL, token, clusterID string) (any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.recommendations, nil +} + +func TestListClustersCmdRunWritesClusters(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + clusters: []map[string]any{{"name": "alpha"}, {"name": "beta"}}, + } + + var gotValue any + var gotFormat string + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + WriteOutput: func(_ io.Writer, value any, format string) error { + gotValue = value + gotFormat = format + return nil + }, + } + + cmd := &ListClustersCmd{Output: "yaml"} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + clusters, ok := gotValue.([]map[string]any) + if !ok || len(clusters) != 2 { + t.Fatalf("got output value = %#v", gotValue) + } + if gotFormat != "yaml" { + t.Fatalf("output format = %q, want %q", gotFormat, "yaml") + } + if clusterService.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", clusterService.lastToken, "override-token") + } +} + +func TestListClustersCmdRunWritesResultsOnlyToStdout(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + clusters: []map[string]any{{"name": "alpha"}}, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + WriteOutput: output.WriteOutput, + } + + if err := (&ListClustersCmd{Output: "json"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.Len() == 0 { + t.Fatal("stdout is empty, want rendered output") + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} diff --git a/internal/cli/list/recommendations.go b/internal/cli/list/recommendations.go new file mode 100644 index 0000000..b2810e1 --- /dev/null +++ b/internal/cli/list/recommendations.go @@ -0,0 +1,24 @@ +package list + +import ( + clictx "github.com/kedify/cli/internal/cli/context" +) + +type ListRecommendationsCmd struct { + ClusterID string `arg:"" name:"cluster-id" help:"Cluster id."` + Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` +} + +func (c *ListRecommendationsCmd) Run(ctx *clictx.Context) error { + token, err := clictx.ResolveToken(ctx) + if err != nil { + return err + } + + recommendations, err := ctx.Client.GetRecommendations(ctx.APIURL, token, c.ClusterID) + if err != nil { + return err + } + + return ctx.WriteOutput(ctx.Stdout, recommendations, c.Output) +} diff --git a/internal/cli/list/recommendations_test.go b/internal/cli/list/recommendations_test.go new file mode 100644 index 0000000..cbe3c8c --- /dev/null +++ b/internal/cli/list/recommendations_test.go @@ -0,0 +1,131 @@ +package list + +import ( + "bytes" + "io" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/output" + "github.com/kedify/cli/internal/service" +) + +type fakeRecommendationCredentialsStore struct { + creds service.Credentials +} + +func (f *fakeRecommendationCredentialsStore) ReadCredentials() (service.Credentials, error) { + return f.creds, nil +} + +func (f *fakeRecommendationCredentialsStore) WriteCredentials(service.Credentials) error { + return nil +} + +type fakeRecommendationClusterService struct { + recommendations any + err error + lastURL string + lastToken string + lastID string +} + +func (f *fakeRecommendationClusterService) ListClusters(string, string) ([]map[string]any, error) { + return nil, nil +} + +func (f *fakeRecommendationClusterService) GetCluster(string, string, string) (map[string]any, error) { + return nil, nil +} + +func (f *fakeRecommendationClusterService) GetRecommendations(apiURL, token, clusterID string) (any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.recommendations, nil +} + +func TestListRecommendationsCmdRunWritesRecommendations(t *testing.T) { + store := &fakeRecommendationCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeRecommendationClusterService{ + recommendations: map[string]any{ + "items": []map[string]any{{"kind": "cpu"}}, + }, + } + + var gotValue any + var gotFormat string + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + WriteOutput: func(_ io.Writer, value any, format string) error { + gotValue = value + gotFormat = format + return nil + }, + } + + id := "fc6af0dc-685b-4055-805d-0d3e0ead1596" + if err := (&ListRecommendationsCmd{ClusterID: id, Output: "yaml"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + payload, ok := gotValue.(map[string]any) + if !ok { + t.Fatalf("got output value = %#v", gotValue) + } + if gotFormat != "yaml" { + t.Fatalf("output format = %q, want %q", gotFormat, "yaml") + } + if payload["items"] == nil { + t.Fatalf("payload = %#v", payload) + } + if clusterService.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", clusterService.lastToken, "override-token") + } + if clusterService.lastID != id { + t.Fatalf("service lastID = %q, want %q", clusterService.lastID, id) + } +} + +func TestListRecommendationsCmdRunWritesResultsOnlyToStdout(t *testing.T) { + store := &fakeRecommendationCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeRecommendationClusterService{ + recommendations: []map[string]any{{"kind": "cpu"}}, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + WriteOutput: output.WriteOutput, + } + + if err := (&ListRecommendationsCmd{ + ClusterID: "fc6af0dc-685b-4055-805d-0d3e0ead1596", + Output: "json", + }).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.Len() == 0 { + t.Fatal("stdout is empty, want rendered output") + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} diff --git a/internal/cli/login.go b/internal/cli/login.go deleted file mode 100644 index 9fb1f4f..0000000 --- a/internal/cli/login.go +++ /dev/null @@ -1,31 +0,0 @@ -package cli - -import ( - "fmt" - "strings" -) - -type LoginCmd struct { - Token string `arg:"" optional:"" name:"token" help:"Token to store without prompting."` -} - -func (c *LoginCmd) Run(ctx *context) error { - token := strings.TrimSpace(c.Token) - if token == "" { - token = strings.TrimSpace(ctx.token) - } - if token == "" { - var err error - token, err = ctx.readSecret(ctx.stdin, ctx.stdout, ctx.stderr) - if err != nil { - return err - } - } - - if err := ctx.credentials.WriteCredentials(credentials{Token: token}); err != nil { - return err - } - - _, err := fmt.Fprintln(ctx.stderr, "Credentials stored.") - return err -} diff --git a/internal/cli/output.go b/internal/cli/output.go deleted file mode 100644 index 559b0eb..0000000 --- a/internal/cli/output.go +++ /dev/null @@ -1,11 +0,0 @@ -package cli - -import ( - "io" - - "github.com/kedify/cli/internal/output" -) - -func writeOutput(w io.Writer, value any, format string) error { - return output.Write(w, value, format) -} diff --git a/internal/cli/recommendations.go b/internal/cli/recommendations.go deleted file mode 100644 index 5ecd3d4..0000000 --- a/internal/cli/recommendations.go +++ /dev/null @@ -1,20 +0,0 @@ -package cli - -type ListRecommendationsCmd struct { - ClusterID string `arg:"" name:"cluster-id" help:"Cluster id."` - Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` -} - -func (c *ListRecommendationsCmd) Run(ctx *context) error { - token, err := resolveToken(ctx) - if err != nil { - return err - } - - recommendations, err := ctx.client.GetRecommendations(ctx.apiURL, token, c.ClusterID) - if err != nil { - return err - } - - return ctx.writeOutput(ctx.stdout, recommendations, c.Output) -} diff --git a/internal/cli/run.go b/internal/cli/run.go index bcc7635..bc165fe 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -6,7 +6,16 @@ import ( "io" "github.com/alecthomas/kong" + "github.com/kedify/cli/internal/api" + "github.com/kedify/cli/internal/cli/apply" + "github.com/kedify/cli/internal/cli/auth" + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/cli/get" + "github.com/kedify/cli/internal/cli/list" + clierrors "github.com/kedify/cli/internal/errors" + "github.com/kedify/cli/internal/output" + "github.com/kedify/cli/internal/service" "github.com/kedify/cli/internal/tui" ) @@ -20,65 +29,33 @@ type CLI struct { } type AuthCmd struct { - Login LoginCmd `cmd:"" help:"Read an auth token from stdin and store it locally. Generate a token at https://dashboard.dev.kedify.io/api-keys."` - Token AuthTokenCmd `cmd:"" help:"Print the auth token."` + Login auth.LoginCmd `cmd:"" help:"Read an auth token from stdin and store it locally. Generate a token at https://dashboard.dev.kedify.io/api-keys."` + Token auth.AuthTokenCmd `cmd:"" help:"Print the auth token."` } type GetCmd struct { - Cluster GetClusterCmd `cmd:"" help:"Get a cluster by name or id. If no name is provided, an interactive picker is shown."` + Cluster get.GetClusterCmd `cmd:"" help:"Get a cluster by name or id. If no name is provided, an interactive picker is shown."` } type ApplyCmd struct { - Recommendations ApplyRecommendationsCmd `cmd:"" help:"Apply recommendations to a Helm values file."` + Recommendations apply.ApplyRecommendationsCmd `cmd:"" help:"Apply recommendations to a Helm values file."` } type ListCmd struct { - Clusters ListClustersCmd `cmd:"" help:"List clusters."` - Recommendations ListRecommendationsCmd `cmd:"" help:"List recommendations for a cluster id."` -} - -type ListClustersCmd struct { - Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` -} - -type credentialsStore interface { - ReadCredentials() (credentials, error) - WriteCredentials(credentials) error -} - -type clusterService interface { - ListClusters(apiURL, token string) ([]map[string]any, error) - GetCluster(apiURL, token, clusterID string) (map[string]any, error) - GetRecommendations(apiURL, token, clusterID string) (any, error) -} - -type credentials struct { - Token string -} - -type context struct { - stdin io.Reader - stdout io.Writer - stderr io.Writer - apiURL string - token string - client clusterService - credentials credentialsStore - readSecret func(io.Reader, io.Writer, io.Writer) (string, error) - selectCluster func(io.Reader, io.Writer, io.Writer, []map[string]any) (map[string]any, error) - writeOutput func(io.Writer, any, string) error + Clusters list.ListClustersCmd `cmd:"" help:"List clusters."` + Recommendations list.ListRecommendationsCmd `cmd:"" help:"List recommendations for a cluster id."` } func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { - app := &context{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - client: api.NewClient(), - credentials: configStore{}, - readSecret: tui.ReadSecretOrPipe, - selectCluster: tui.SelectClusterOrFail, - writeOutput: writeOutput, + app := &clictx.Context{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + Client: api.NewClient(), + Credentials: service.ConfigStore{}, + ReadSecret: tui.ReadSecretOrPipe, + SelectCluster: tui.SelectClusterOrFail, + WriteOutput: output.WriteOutput, } var cli CLI @@ -99,18 +76,18 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } - app.apiURL = cli.APIURL - app.token = cli.Token + app.APIURL = cli.APIURL + app.Token = cli.Token if err := kctx.Run(app); err != nil { - var cmdErr *commandResultError + var cmdErr *clierrors.CommandResultError if errors.As(err, &cmdErr) { - if cmdErr.payload != nil { - if writeErr := app.writeOutput(stdout, cmdErr.payload, "json"); writeErr != nil { + if cmdErr.Payload != nil { + if writeErr := app.WriteOutput(stdout, cmdErr.Payload, "json"); writeErr != nil { _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", writeErr) return 1 } } - return cmdErr.exitCode + return cmdErr.ExitCode } _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) return 1 @@ -118,12 +95,3 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 } - -type commandResultError struct { - exitCode int - payload any -} - -func (e *commandResultError) Error() string { - return "command failed" -} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..842adeb --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,10 @@ +package errors + +type CommandResultError struct { + ExitCode int + Payload any +} + +func (e *CommandResultError) Error() string { + return "command failed" +} diff --git a/internal/output/output.go b/internal/output/output.go index 6729a5b..a3cf2bc 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -12,6 +12,10 @@ import ( "gopkg.in/yaml.v3" ) +func WriteOutput(w io.Writer, value any, format string) error { + return Write(w, value, format) +} + func Write(w io.Writer, value any, format string) error { var ( data []byte diff --git a/internal/service/cluster.go b/internal/service/cluster.go new file mode 100644 index 0000000..06d7491 --- /dev/null +++ b/internal/service/cluster.go @@ -0,0 +1,7 @@ +package service + +type ClusterService interface { + ListClusters(apiURL, token string) ([]map[string]any, error) + GetCluster(apiURL, token, clusterID string) (map[string]any, error) + GetRecommendations(apiURL, token, clusterID string) (any, error) +} diff --git a/internal/service/credentials_store.go b/internal/service/credentials_store.go new file mode 100644 index 0000000..40a364b --- /dev/null +++ b/internal/service/credentials_store.go @@ -0,0 +1,29 @@ +package service + +import ( + "github.com/kedify/cli/internal/config" +) + +type CredentialsStore interface { + ReadCredentials() (Credentials, error) + WriteCredentials(Credentials) error +} + +type Credentials struct { + Token string +} + +type ConfigStore struct{} + +func (ConfigStore) ReadCredentials() (Credentials, error) { + creds, err := config.ReadCredentials() + if err != nil { + return Credentials{}, err + } + + return Credentials{Token: creds.Token}, nil +} + +func (ConfigStore) WriteCredentials(creds Credentials) error { + return config.WriteCredentials(config.Credentials{Token: creds.Token}) +}