diff --git a/cmd/platform_plan.go b/cmd/platform_plan.go new file mode 100644 index 000000000..2307c4fc3 --- /dev/null +++ b/cmd/platform_plan.go @@ -0,0 +1,310 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + "unicode" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/httpclient" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/spf13/cobra" +) + +var platformPlanHTTPClient *http.Client + +type platformPlanSummary struct { //nolint:govet // JSON key order is user-visible + ID string `json:"id"` + Name string `json:"name"` + RampType string `json:"ramp_type"` + UpdatedAt *string `json:"updated_at"` + NextExecutionDate *string `json:"next_execution_date"` + LastExecutionDate *string `json:"last_execution_date"` + CurrentStep int64 `json:"current_step"` + TotalSteps int64 `json:"total_steps"` + Enabled bool `json:"enabled"` + AutoPurchase bool `json:"auto_purchase"` +} + +func init() { + group := &cobra.Command{Use: "platform-plan", Short: "Inspect saved platform plans"} + get := &cobra.Command{ + Use: "get ", Short: "Show a read-only saved-plan summary", + SilenceUsage: true, SilenceErrors: true, RunE: runPlatformPlanGet, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("platform-plan get requires exactly one platform plan UUID") + } + return validateAzureUUID(args[0], "platform plan ID") + }, + } + get.Flags().String("format", "table", "Output format: table or json") + group.AddCommand(get) + rootCmd.AddCommand(group) +} + +func runPlatformPlanGet(cmd *cobra.Command, args []string) error { + // Cobra children retain earlier contexts across repeated root executions. + ctx, cancel := context.WithTimeout(cmd.Root().Context(), 30*time.Second) + defer cancel() + format, err := cmd.Flags().GetString("format") + if err != nil { + return err + } + if format != "table" && format != "json" { + return platformPlanResultError(ctx, errors.New("--format must be table or json")) + } + origin, key, err := platformPlanSettings() + if err != nil { + return platformPlanResultError(ctx, err) + } + summary, err := fetchPlatformPlan(ctx, origin, args[0], key) + if err != nil { + return platformPlanResultError(ctx, err) + } + output, err := renderPlatformPlan(summary, key, format) + if err != nil { + return platformPlanResultError(ctx, err) + } + if err := ctx.Err(); err != nil { + return err + } + if n, err := cmd.OutOrStdout().Write(output); err != nil || n != len(output) { + return platformPlanResultError(ctx, errors.New("platform plan output could not be written")) + } + return nil +} + +func platformPlanResultError(ctx context.Context, err error) error { + if contextErr := ctx.Err(); contextErr != nil { + return contextErr + } + return err +} + +func platformPlanSettings() (*url.URL, insurance.Secret, error) { + origin, err := platformPlanOrigin(strings.TrimSpace(os.Getenv("CUDLY_PLATFORM_URL"))) + if err != nil { + return nil, insurance.Secret{}, err + } + key := insurance.NewSecret(strings.TrimSpace(os.Getenv("CUDLY_PLATFORM_USER_API_KEY"))) + if key.Reveal() == "" { + return nil, key, errors.New("CUDLY_PLATFORM_USER_API_KEY is not set") + } + for _, r := range key.Reveal() { + if r < 32 || r > 126 { + return nil, key, errors.New("CUDLY_PLATFORM_USER_API_KEY contains invalid header bytes") + } + } + return origin, key, nil +} + +func fetchPlatformPlan(ctx context.Context, origin *url.URL, id string, key insurance.Secret) (platformPlanSummary, error) { + if err := ctx.Err(); err != nil { + return platformPlanSummary{}, err + } + origin.Path = "/api/plans/" + id + req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin.String(), http.NoBody) + if err != nil { + return platformPlanSummary{}, errors.New("platform plan request could not be constructed") + } + req.Header.Set("X-API-Key", key.Reveal()) + req.Header.Set("Accept", "application/json") + base := platformPlanHTTPClient + if base == nil { + base = httpclient.New() + } + client := *base + client.Timeout = 30 * time.Second + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return errors.New("platform plan redirects are refused") + } + response, err := client.Do(req) //nolint:gosec // G704: operator-supplied platform origin validated by platformPlanOrigin; redirects refused + if err != nil { + return platformPlanSummary{}, errors.New("platform plan request failed") + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return platformPlanSummary{}, platformPlanStatusError(response.StatusCode) + } + const maxBody = 1 << 20 + body, err := io.ReadAll(io.LimitReader(response.Body, maxBody+1)) + if err != nil || len(body) > maxBody { + return platformPlanSummary{}, errors.New("platform plan response could not be read within the size limit") + } + return decodePlatformPlan(body, id) +} + +func platformPlanStatusError(status int) error { + messages := map[int]string{ + 401: "platform plan authentication failed (HTTP 401)", + 403: "platform plan access denied (HTTP 403)", + 404: "platform plan not found (HTTP 404)", + 429: "platform plan rate limited (HTTP 429); not retried", + } + if message, ok := messages[status]; ok { + return errors.New(message) + } + return errors.New("platform plan returned an unsuccessful HTTP status") +} + +func renderPlatformPlan(summary platformPlanSummary, key insurance.Secret, format string) ([]byte, error) { + values := []string{summary.ID, summary.Name, summary.RampType} + for _, date := range []*string{summary.UpdatedAt, summary.NextExecutionDate, summary.LastExecutionDate} { + if date != nil { + values = append(values, *date) + } + } + for _, value := range values { + if platformPlanContainsKey(value, key) { + return nil, errors.New("platform plan result withheld because it contains the configured credential") + } + } + summary.Name, summary.RampType = archeraSanitize(summary.Name), archeraSanitize(summary.RampType) + var output []byte + if format == "json" { + encoded, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return nil, errors.New("platform plan result could not be rendered") + } + output = append(append(output, encoded...), '\n') + } else { + output = renderPlatformPlanTable(summary) + } + if platformPlanContainsKey(string(output), key) { + return nil, errors.New("platform plan result withheld because it contains the configured credential") + } + return output, nil +} + +func renderPlatformPlanTable(summary platformPlanSummary) []byte { + date := func(value *string) string { + if value == nil { + return "unknown" + } + return *value + } + return []byte(fmt.Sprintf("id\t%s\nname\t%s\nenabled\t%t\nauto_purchase\t%t\nramp_type\t%s\ncurrent_step\t%d\ntotal_steps\t%d\nupdated_at\t%s\nnext_execution_date (next execution reported by platform)\t%s\nlast_execution_date\t%s\n", + summary.ID, summary.Name, summary.Enabled, summary.AutoPurchase, summary.RampType, + summary.CurrentStep, summary.TotalSteps, date(summary.UpdatedAt), date(summary.NextExecutionDate), date(summary.LastExecutionDate))) +} + +func platformPlanOrigin(setting string) (*url.URL, error) { + if setting == "" { + return nil, errors.New("CUDLY_PLATFORM_URL is not set") + } + u, err := url.Parse(setting) + if err != nil { + return nil, errors.New("CUDLY_PLATFORM_URL must be an HTTPS origin with no credentials, path, query or fragment") + } + if u.Scheme != "https" || u.Hostname() == "" || platformPlanHasResource(u) || strings.Contains(setting, "#") || strings.HasSuffix(u.Host, ":") { + return nil, errors.New("CUDLY_PLATFORM_URL must be an HTTPS origin with no credentials, path, query or fragment") + } + return platformPlanPort(u) +} + +func platformPlanHasResource(u *url.URL) bool { + return u.User != nil || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawPath != "" || (u.Path != "" && u.Path != "/") +} + +func platformPlanPort(u *url.URL) (*url.URL, error) { + if port := u.Port(); port != "" { + if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 { + return nil, errors.New("CUDLY_PLATFORM_URL contains an invalid port") + } + } + return u, nil +} + +type platformPlanRamp struct { + Type *string `json:"type"` + CurrentStep *int64 `json:"current_step"` + TotalSteps *int64 `json:"total_steps"` +} + +type platformPlanWire struct { + ID *string `json:"id"` + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + AutoPurchase *bool `json:"auto_purchase"` + RampSchedule *platformPlanRamp `json:"ramp_schedule"` + UpdatedAt *string `json:"updated_at"` + NextExecutionDate *string `json:"next_execution_date"` + LastExecutionDate *string `json:"last_execution_date"` +} + +func decodePlatformPlan(body []byte, expectedID string) (platformPlanSummary, error) { + var wire *platformPlanWire + invalid := errors.New("platform plan response is invalid") + decoder := json.NewDecoder(bytes.NewReader(body)) + if err := decoder.Decode(&wire); err != nil || wire == nil { + return platformPlanSummary{}, invalid + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return platformPlanSummary{}, invalid + } + return projectPlatformPlan(wire, expectedID) +} + +func projectPlatformPlan(wire *platformPlanWire, expectedID string) (platformPlanSummary, error) { + invalid := errors.New("platform plan response is invalid") + if wire.ID == nil || wire.Name == nil || wire.Enabled == nil || wire.AutoPurchase == nil || wire.RampSchedule == nil { + return platformPlanSummary{}, invalid + } + ramp := wire.RampSchedule + if validateAzureUUID(*wire.ID, "platform plan ID") != nil || !strings.EqualFold(*wire.ID, expectedID) || !ramp.valid() { + return platformPlanSummary{}, invalid + } + if !platformPlanDatesValid(wire.UpdatedAt, wire.NextExecutionDate, wire.LastExecutionDate) { + return platformPlanSummary{}, invalid + } + return platformPlanSummary{ID: *wire.ID, Name: *wire.Name, Enabled: *wire.Enabled, AutoPurchase: *wire.AutoPurchase, + RampType: *ramp.Type, CurrentStep: *ramp.CurrentStep, TotalSteps: *ramp.TotalSteps, + UpdatedAt: wire.UpdatedAt, NextExecutionDate: wire.NextExecutionDate, LastExecutionDate: wire.LastExecutionDate}, nil +} + +func (r *platformPlanRamp) valid() bool { + return r.Type != nil && r.CurrentStep != nil && r.TotalSteps != nil && *r.CurrentStep >= 0 && *r.TotalSteps >= 0 +} + +func platformPlanDatesValid(dates ...*string) bool { + for _, date := range dates { + if date != nil { + if _, err := time.Parse(time.RFC3339Nano, *date); err != nil { + return false + } + } + } + return true +} + +func platformPlanContainsKey(value string, key insurance.Secret) bool { + keep := func(r rune) bool { + return !unicode.IsControl(r) && !unicode.Is(unicode.Cf, r) && !unicode.IsSpace(r) && !unicode.IsMark(r) + } + alphabet := map[rune]bool{} + var normalized strings.Builder + for _, r := range archeraFold(key.Reveal()) { + if keep(r) { + normalized.WriteRune(r) + alphabet[r] = true + } + } + var candidate strings.Builder + for _, r := range archeraFold(value) { + if keep(r) && alphabet[r] { + candidate.WriteRune(r) + } + } + return normalized.Len() == 0 || strings.Contains(candidate.String(), normalized.String()) +} diff --git a/cmd/platform_plan_helpers_test.go b/cmd/platform_plan_helpers_test.go new file mode 100644 index 000000000..3b9c9cf50 --- /dev/null +++ b/cmd/platform_plan_helpers_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "io" + "log" + "log/slog" + "net/http" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const platformPlanTestID = "abcdefab-1234-5678-9abc-abcdefabcdef" +const platformPlanTestKey = "SYNTHETIC-zqKey-VX98" +const platformPlanFixture = `{"id":"abcdefab-1234-5678-9abc-abcdefabcdef","name":"Weekly synthetic","enabled":true,"auto_purchase":false,"ramp_schedule":{"type":"weekly","current_step":2,"total_steps":7},"updated_at":"2026-10-01T12:34:56+02:00","next_execution_date":"2026-11-02T03:04:05Z","last_execution_date":null,"services":{"secret":"unlisted"},"unknown":"unlisted"}` + +type platformPlanTransport func(*http.Request) (*http.Response, error) + +func (f platformPlanTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type platformPlanBody struct { + data string + consumed int + closed bool + cancel context.CancelFunc +} + +func (b *platformPlanBody) Read(p []byte) (int, error) { + n := copy(p, b.data[b.consumed:]) + b.consumed += n + if b.consumed == len(b.data) { + if b.cancel != nil { + b.cancel() + } + return n, io.EOF + } + return n, nil +} +func (b *platformPlanBody) Close() error { b.closed = true; return nil } + +func platformPlanEnv(t *testing.T) { + t.Helper() + t.Setenv("CUDLY_PLATFORM_URL", " https://platform.example.invalid/ ") + t.Setenv("CUDLY_PLATFORM_USER_API_KEY", " "+platformPlanTestKey+" \n") + old := platformPlanHTTPClient + t.Cleanup(func() { platformPlanHTTPClient = old }) +} + +func platformPlanResponse(t *testing.T, status int, body string) (*hookTransport, *platformPlanBody) { + t.Helper() + b := &platformPlanBody{data: body} + h := &hookTransport{handle: func(r *http.Request) (int, string) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "https://platform.example.invalid/api/plans/"+platformPlanTestID, r.URL.String()) + assert.Equal(t, strings.TrimSpace(os.Getenv("CUDLY_PLATFORM_USER_API_KEY")), r.Header.Get("X-API-Key")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + assert.Len(t, r.Header, 2) + deadline, ok := r.Context().Deadline() + assert.True(t, ok) + assert.LessOrEqual(t, time.Until(deadline), 30*time.Second) + return status, body + }} + platformPlanHTTPClient = &http.Client{Transport: platformPlanTransport(func(r *http.Request) (*http.Response, error) { + response, err := h.RoundTrip(r) + response.Header.Set("Content-Type", "application/json") + response.Body = b + return response, err + })} + return h, b +} + +func platformPlanGet(t *testing.T, format string, ctx context.Context) (string, string, error) { + t.Helper() + return platformPlanRun(t, ctx, "platform-plan", "get", platformPlanTestID, "--format", format) +} + +func platformPlanRejected(t *testing.T, out, sinks string, err error, expected string) { + t.Helper() + require.Error(t, err) + assert.Empty(t, out) + assert.Contains(t, err.Error(), expected) + assert.Empty(t, sinks) + assert.NotContains(t, out+sinks+err.Error(), platformPlanTestKey) +} + +// Reflect reads Cobra's private nil/argument state; restoration uses public setters. +type platformPlanCtxKey struct{} + +func platformPlanBoundary(t *testing.T) func() { + t.Helper() + var restore []func() + var visit func(*cobra.Command) + visit = func(c *cobra.Command) { + v := reflect.ValueOf(c).Elem() + for _, name := range []string{"outWriter", "errWriter", "args"} { + if !v.FieldByName(name).IsValid() { + t.Fatalf("cobra.Command has no field %q; update platformPlanBoundary", name) + } + } + ctx := c.Context() + out, errOut := c.OutOrStdout(), c.ErrOrStderr() + if v.FieldByName("outWriter").IsNil() { + out = nil + } + if v.FieldByName("errWriter").IsNil() { + errOut = nil + } + var args []string + if a := v.FieldByName("args"); !a.IsNil() { + args = make([]string, a.Len()) + for i := range args { + args[i] = a.Index(i).String() + } + } + restore = append(restore, func() { c.SetContext(ctx); c.SetOut(out); c.SetErr(errOut); c.SetArgs(args) }) + c.Flags().VisitAll(func(f *pflag.Flag) { + if f.Name == "help" || f.Name == "format" { + value, changed := f.Value.String(), f.Changed + restore = append(restore, func() { require.NoError(t, f.Value.Set(value)); f.Changed = changed }) + } + }) + if c.Flags().Lookup("help") == nil { + // Cobra may initialize its standard help catalog; restore absent help semantically. + restore = append(restore, func() { + if f := c.Flags().Lookup("help"); f != nil { + require.NoError(t, f.Value.Set("false")) + f.Changed = false + } + }) + } + for _, child := range c.Commands() { + visit(child) + } + } + visit(rootCmd) + oldConfig, oldClient := toolCfg, platformPlanHTTPClient + oldLog, oldFlags, oldPrefix := log.Writer(), log.Flags(), log.Prefix() + oldApp, appFlags, appPrefix := AppLogger.Writer(), AppLogger.Flags(), AppLogger.Prefix() + oldSlog, oldLevel := slog.Default(), logging.GetLevel() + var logs syncBuffer + oldShared := logging.SetOutput(&logs) + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + log.SetOutput(&logs) + AppLogger.SetOutput(&logs) + return func() { + for _, fn := range restore { + fn() + } + toolCfg, platformPlanHTTPClient = oldConfig, oldClient + slog.SetDefault(oldSlog) + log.SetOutput(oldLog) + log.SetFlags(oldFlags) + log.SetPrefix(oldPrefix) + AppLogger.SetOutput(oldApp) + AppLogger.SetFlags(appFlags) + AppLogger.SetPrefix(appPrefix) + logging.SetOutput(oldShared) + logging.SetLevelValue(oldLevel) + } +} + +func platformPlanExecute(t *testing.T, ctx context.Context, args ...string) (string, string, error) { + t.Helper() + var out, diagnostics syncBuffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&diagnostics) + rootCmd.SetArgs(args) + log.SetFlags(0) + log.SetPrefix("") + AppLogger.SetFlags(0) + AppLogger.SetPrefix("") + logging.SetLevelValue(logging.LevelDebug) + log.SetOutput(&diagnostics) + AppLogger.SetOutput(&diagnostics) + logging.SetOutput(&diagnostics) + slog.SetDefault(slog.New(slog.NewTextHandler(&diagnostics, &slog.HandlerOptions{Level: slog.LevelDebug}))) + slog.Debug("platform-plan debug capture fixture") + fixture := diagnostics.String() + require.Contains(t, fixture, "level=DEBUG") + require.Contains(t, fixture, "platform-plan debug capture fixture") + var err error + realOut, realErr := captureRealStd(t, func() { err = rootCmd.ExecuteContext(ctx) }) + return out.String(), diagnostics.String()[len(fixture):] + realOut + realErr, err +} + +func platformPlanRun(t *testing.T, ctx context.Context, args ...string) (string, string, error) { + t.Helper() + restore := platformPlanBoundary(t) + defer restore() + var clearHelp func(*cobra.Command) + clearHelp = func(c *cobra.Command) { + if f := c.Flags().Lookup("help"); f != nil { + require.NoError(t, f.Value.Set("false")) + f.Changed = false + } + for _, child := range c.Commands() { + clearHelp(child) + } + } + clearHelp(rootCmd) + return platformPlanExecute(t, ctx, args...) +} diff --git a/cmd/platform_plan_state_test.go b/cmd/platform_plan_state_test.go new file mode 100644 index 000000000..232386194 --- /dev/null +++ b/cmd/platform_plan_state_test.go @@ -0,0 +1,255 @@ +package main + +import ( + "bytes" + "context" + "errors" + "log" + "log/slog" + "net/http" + "os" + "os/exec" + "reflect" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlatformPlanRootStateAndStickyContexts(t *testing.T) { + platformPlanEnv(t) + restore := platformPlanBoundary(t) + defer restore() + group, _, err := rootCmd.Find([]string{"platform-plan"}) + require.NoError(t, err) + leaf, _, err := rootCmd.Find([]string{"platform-plan", "get"}) + require.NoError(t, err) + for _, seeded := range []bool{false, true} { + var seed context.Context + if seeded { + c, cancel := context.WithCancel(context.WithValue(context.Background(), platformPlanCtxKey{}, "marker")) + cancel() + seed = c + } + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + c.SetContext(seed) + } + var seededOut, seededErr bytes.Buffer + rootCmd.SetOut(&seededOut) + rootCmd.SetErr(&seededErr) + var seededArgs []string + if seeded { + seededArgs = []string{"archera-comparison", "--help"} + } + rootCmd.SetArgs(seededArgs) + seededClient := &http.Client{} + platformPlanHTTPClient = seededClient + var stdSink, appSink, sharedSink, slogSink syncBuffer + for _, sink := range []*syncBuffer{&stdSink, &appSink, &sharedSink, &slogSink} { + _, werr := sink.Write([]byte("untouched sentinel")) + require.NoError(t, werr) + } + seededSlog := slog.New(slog.NewTextHandler(&slogSink, &slog.HandlerOptions{Level: slog.LevelWarn})) + slog.SetDefault(seededSlog) + log.SetOutput(&stdSink) + log.SetFlags(log.Llongfile) + log.SetPrefix("seed-standard:") + AppLogger.SetOutput(&appSink) + AppLogger.SetFlags(log.Lshortfile) + AppLogger.SetPrefix("seed-app:") + logging.SetOutput(&sharedSink) + logging.SetLevelValue(logging.LevelWarn) + toolCfg.ActualPurchase = true + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + c.InitDefaultHelpFlag() + f := c.Flags().Lookup("help") + require.NoError(t, f.Value.Set("true")) + f.Changed = true + } + f := leaf.Flags().Lookup("format") + require.NoError(t, f.Value.Set("table")) + f.Changed = true + for _, step := range []struct { + name string + args []string + missingConfig bool + }{ + {"help", []string{"platform-plan", "get", "--help"}, false}, + {"valid JSON", []string{"platform-plan", "get", platformPlanTestID, "--format", "json"}, false}, + {"invalid", []string{"platform-plan", "get", "bad"}, false}, + {"missing configuration", []string{"platform-plan", "get", platformPlanTestID}, true}, + {"valid table", []string{"platform-plan", "get", platformPlanTestID, "--format", "table"}, false}, + } { + t.Run(step.name, func(t *testing.T) { + restore := platformPlanBoundary(t) + defer restore() + if step.missingConfig { + t.Setenv("CUDLY_PLATFORM_URL", "") + } + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, runErr := platformPlanRun(t, context.Background(), step.args...) + if step.name == "invalid" || step.missingConfig { + require.Error(t, runErr) + assert.Empty(t, out) + } else { + require.NoError(t, runErr) + assert.NotEmpty(t, out) + } + assert.Empty(t, sinks) + if step.name == "help" || step.name == "invalid" || step.missingConfig { + assert.Empty(t, h.paths) + } else { + assert.Len(t, h.paths, 1) + } + }) + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + assert.True(t, seed == c.Context()) + assert.Equal(t, "true", c.Flags().Lookup("help").Value.String()) + assert.True(t, c.Flags().Lookup("help").Changed) + } + assert.Same(t, &seededOut, rootCmd.OutOrStdout()) + assert.Same(t, &seededErr, rootCmd.ErrOrStderr()) + rawArgs := reflect.ValueOf(rootCmd).Elem().FieldByName("args") + assert.Equal(t, seededArgs == nil, rawArgs.IsNil()) + require.Equal(t, len(seededArgs), rawArgs.Len()) + for i, want := range seededArgs { + assert.Equal(t, want, rawArgs.Index(i).String()) + } + assert.Same(t, seededClient, platformPlanHTTPClient) + assert.Same(t, &stdSink, log.Writer()) + assert.Equal(t, log.Llongfile, log.Flags()) + assert.Equal(t, "seed-standard:", log.Prefix()) + assert.Same(t, &appSink, AppLogger.Writer()) + assert.Equal(t, log.Lshortfile, AppLogger.Flags()) + assert.Equal(t, "seed-app:", AppLogger.Prefix()) + assert.Same(t, seededSlog, slog.Default()) + assert.Equal(t, logging.LevelWarn, logging.GetLevel()) + sharedWriter := logging.SetOutput(&sharedSink) + assert.Same(t, &sharedSink, sharedWriter) + logging.SetOutput(sharedWriter) + for _, sink := range []*syncBuffer{&stdSink, &appSink, &sharedSink, &slogSink} { + assert.Equal(t, "untouched sentinel", sink.String()) + } + assert.True(t, toolCfg.ActualPurchase) + assert.Equal(t, "table", f.Value.String()) + assert.True(t, f.Changed) + } + } + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + c.SetContext(nil) //nolint:staticcheck // restore the pristine nil context + require.NoError(t, c.Flags().Lookup("help").Value.Set("false")) + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanExecute(t, canceled, "platform-plan", "get", platformPlanTestID, "--format", "json") + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, out+sinks) + assert.Empty(t, h.paths) + sticky := leaf.Context() + require.ErrorIs(t, sticky.Err(), context.Canceled) + h, _ = platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err = platformPlanExecute(t, context.Background(), "platform-plan", "get", platformPlanTestID, "--format", "table") + require.NoError(t, err) + assert.NotEmpty(t, out) + assert.Empty(t, sinks) + assert.Len(t, h.paths, 1) + assert.Same(t, sticky, leaf.Context()) +} + +func TestPlatformPlanFreshHelpRestoration(t *testing.T) { + const marker = "CUDLY_PLATFORM_PLAN_HELP_TEST_CHILD" + if os.Getenv(marker) != "1" { + executable, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + child := exec.CommandContext(ctx, executable, "-test.run=^TestPlatformPlanFreshHelpRestoration$", "-test.timeout=8s", "-test.count=1") + child.Env = append(os.Environ(), marker+"=1") + child.WaitDelay = time.Second + diagnostics, err := child.CombinedOutput() + require.NoError(t, ctx.Err(), "fresh help child exceeded its deadline: %s", diagnostics) + require.NoError(t, err, "fresh help child failed: %s", diagnostics) + return + } + platformPlanEnv(t) + group, _, err := rootCmd.Find([]string{"platform-plan"}) + require.NoError(t, err) + leaf, _, err := rootCmd.Find([]string{"platform-plan", "get"}) + require.NoError(t, err) + // Standard lazy help catalog initialization is permitted; its state must be reset immediately. + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + require.Nil(t, c.Flags().Lookup("help")) + } + helpRequests := 0 + platformPlanHTTPClient = &http.Client{Transport: platformPlanTransport(func(*http.Request) (*http.Response, error) { + helpRequests++ + return nil, errors.New("unexpected platform help request") + })} + for _, args := range [][]string{{"--help"}, {"platform-plan", "--help"}, {"platform-plan", "get", "--help"}} { + var out, sinks string + out, sinks, err = platformPlanRun(t, context.Background(), args...) + require.Zero(t, helpRequests) + require.NoError(t, err) + assert.NotEmpty(t, out) + assert.Empty(t, sinks) + var c *cobra.Command + c, _, err = rootCmd.Find(args[:len(args)-1]) + require.NoError(t, err) + f := c.Flags().Lookup("help") + require.NotNil(t, f) + assert.Equal(t, "false", f.Value.String()) + assert.False(t, f.Changed) + } + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + client := platformPlanHTTPClient + out, sinks, err := platformPlanGet(t, "json", context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, out) + assert.Empty(t, sinks) + assert.Len(t, h.paths, 1) + assert.Same(t, client, platformPlanHTTPClient) + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + assert.Nil(t, c.Context()) + f := c.Flags().Lookup("help") + assert.Equal(t, "false", f.Value.String()) + assert.False(t, f.Changed) + } + assert.True(t, reflect.ValueOf(rootCmd).Elem().FieldByName("args").IsNil()) + assert.Equal(t, "table", leaf.Flags().Lookup("format").Value.String()) + assert.False(t, leaf.Flags().Lookup("format").Changed) +} + +func TestPlatformPlanPresentHelpRestoration(t *testing.T) { + platformPlanEnv(t) + restore := platformPlanBoundary(t) + defer restore() + group, _, err := rootCmd.Find([]string{"platform-plan"}) + require.NoError(t, err) + leaf, _, err := rootCmd.Find([]string{"platform-plan", "get"}) + require.NoError(t, err) + for _, value := range []string{"false", "true"} { + for _, changed := range []bool{false, true} { + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + c.InitDefaultHelpFlag() + f := c.Flags().Lookup("help") + require.NoError(t, f.Value.Set(value)) + f.Changed = changed + } + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanGet(t, "json", context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, out) + assert.Empty(t, sinks) + assert.Len(t, h.paths, 1) + for _, c := range []*cobra.Command{rootCmd, group, leaf} { + f := c.Flags().Lookup("help") + assert.Equal(t, value, f.Value.String()) + assert.Equal(t, changed, f.Changed) + } + } + } +} diff --git a/cmd/platform_plan_test.go b/cmd/platform_plan_test.go new file mode 100644 index 000000000..99d32e446 --- /dev/null +++ b/cmd/platform_plan_test.go @@ -0,0 +1,268 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlatformPlanRootSummary(t *testing.T) { + platformPlanEnv(t) + for _, format := range []string{"table", "json"} { + t.Run(format, func(t *testing.T) { + h, b := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanGet(t, format, context.Background()) + require.NoError(t, err) + assert.Equal(t, 1, h.count("/api/plans/"+platformPlanTestID)) + assert.Len(t, h.paths, 1) + assert.True(t, b.closed) + assert.Equal(t, len(platformPlanFixture), b.consumed) + assert.Empty(t, sinks) + assert.NotContains(t, out, "unlisted") + if format == "json" { + assert.Equal(t, []string{"id", "name", "ramp_type", "updated_at", "next_execution_date", "last_execution_date", "current_step", "total_steps", "enabled", "auto_purchase"}, platformPlanJSONKeys(t, out)) + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, map[string]any{"id": platformPlanTestID, "name": "Weekly synthetic", "enabled": true, "auto_purchase": false, "ramp_type": "weekly", "current_step": float64(2), "total_steps": float64(7), "updated_at": "2026-10-01T12:34:56+02:00", "next_execution_date": "2026-11-02T03:04:05Z", "last_execution_date": nil}, got) + } else { + assert.Equal(t, "id\t"+platformPlanTestID+"\nname\tWeekly synthetic\nenabled\ttrue\nauto_purchase\tfalse\nramp_type\tweekly\ncurrent_step\t2\ntotal_steps\t7\nupdated_at\t2026-10-01T12:34:56+02:00\nnext_execution_date (next execution reported by platform)\t2026-11-02T03:04:05Z\nlast_execution_date\tunknown\n", out) + } + assert.NotContains(t, out, platformPlanTestKey) + }) + } +} + +// platformPlanJSONKeys returns the top-level keys in wire order; maps lose it. +func platformPlanJSONKeys(t *testing.T, raw string) []string { + t.Helper() + dec := json.NewDecoder(strings.NewReader(raw)) + _, err := dec.Token() + require.NoError(t, err) + var keys []string + for dec.More() { + k, err := dec.Token() + require.NoError(t, err) + keys = append(keys, k.(string)) + var skip json.RawMessage + require.NoError(t, dec.Decode(&skip)) + } + return keys +} + +func TestPlatformPlanZeroHTTP(t *testing.T) { + platformPlanEnv(t) + for _, args := range [][]string{{"--help"}, {"platform-plan", "--help"}, {"platform-plan", "get", "--help"}, {"archera-comparison", "--help"}, {"platform-plan", "get"}, {"platform-plan", "get", platformPlanTestID, "extra"}, {"platform-plan", "get", "urn:uuid:" + platformPlanTestID}, {"platform-plan", "get", platformPlanTestID + "?bad"}, {"platform-plan", "get", platformPlanTestID, "--format", "bad"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanRun(t, context.Background(), args...) + if args[len(args)-1] == "--help" { + require.NoError(t, err) + assert.NotEmpty(t, out) + } else { + require.Error(t, err) + assert.Empty(t, out) + } + assert.Empty(t, h.paths) + assert.NotContains(t, sinks, platformPlanTestKey) + }) + } + for _, endpoint := range []string{"", "http://platform.example.invalid", "https://user:pass@platform.example.invalid", "https://platform.example.invalid/path", "https://platform.example.invalid/?x=1", "https://platform.example.invalid/#", "https://platform.example.invalid:bad", "https://platform.example.invalid:65536", "https:///", "https:opaque", "https://platform.example.invalid/%2f"} { + t.Run(endpoint, func(t *testing.T) { + t.Setenv("CUDLY_PLATFORM_URL", endpoint) + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanGet(t, "json", context.Background()) + platformPlanRejected(t, out, sinks, err, "CUDLY_PLATFORM_URL") + assert.Empty(t, h.paths) + }) + } + for _, key := range []string{"", "bad\rkey", "bad\tkey", "bad\nkey", "bad\x7fkey", "badékey"} { + t.Run(key, func(t *testing.T) { + t.Setenv("CUDLY_PLATFORM_USER_API_KEY", key) + for _, name := range []string{"ARCHERA_API_KEY", "CUDLY_API_KEY", "AWS_SECRET_ACCESS_KEY", "SERVER_API_KEY"} { + t.Setenv(name, platformPlanTestKey) + } + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanGet(t, "table", context.Background()) + platformPlanRejected(t, out, sinks, err, "CUDLY_PLATFORM_USER_API_KEY") + assert.Empty(t, h.paths) + }) + } +} + +func TestPlatformPlanResponseFailuresAndBoundary(t *testing.T) { + platformPlanEnv(t) + for status, message := range map[int]string{ + 401: "platform plan authentication failed (HTTP 401)", + 403: "platform plan access denied (HTTP 403)", + 404: "platform plan not found (HTTP 404)", + 429: "platform plan rate limited (HTTP 429); not retried", + 500: "platform plan returned an unsuccessful HTTP status", + } { + t.Run(http.StatusText(status), func(t *testing.T) { + h, b := platformPlanResponse(t, status, platformPlanTestKey) + out, sinks, err := platformPlanGet(t, "json", context.Background()) + platformPlanRejected(t, out, sinks, err, message) + assert.EqualError(t, err, message) + assert.Len(t, h.paths, 1) + assert.Zero(t, b.consumed) + assert.True(t, b.closed) + }) + } + for _, body := range []string{"{", "null", `[]`, `{}`, strings.TrimSuffix(platformPlanFixture, "}"), platformPlanFixture + ` {}`, strings.Replace(platformPlanFixture, `"enabled":true`, `"enabled":"true"`, 1), strings.Replace(platformPlanFixture, `"enabled":true,`, "", 1), strings.Replace(platformPlanFixture, platformPlanTestID, "bad-id", 1), strings.Replace(platformPlanFixture, platformPlanTestID, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", 1), strings.Replace(platformPlanFixture, `"current_step":2`, `"current_step":2.5`, 1), strings.Replace(platformPlanFixture, `"total_steps":7`, `"total_steps":-1`, 1), strings.Replace(platformPlanFixture, `"type":"weekly"`, `"type":null`, 1), strings.Replace(platformPlanFixture, "2026-11-02T03:04:05Z", "bad-date", 1), platformPlanFixture + strings.Repeat(" ", (1<<20)+1-len(platformPlanFixture))} { + t.Run("invalid response", func(t *testing.T) { + h, b := platformPlanResponse(t, 200, body) + out, sinks, err := platformPlanGet(t, "json", context.Background()) + platformPlanRejected(t, out, sinks, err, "platform plan response") + assert.Len(t, h.paths, 1) + assert.True(t, b.closed) + }) + } + for _, body := range []string{platformPlanFixture + strings.Repeat(" ", (1<<20)-len(platformPlanFixture)), strings.Replace(platformPlanFixture, `"last_execution_date":null,`, "", 1), strings.Replace(platformPlanFixture, `"current_step":2`, `"current_step":9`, 1)} { + h, _ := platformPlanResponse(t, 200, body) + out, _, err := platformPlanGet(t, "json", context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, out) + assert.Len(t, h.paths, 1) + } + platformPlanHTTPClient = &http.Client{Transport: platformPlanTransport(func(*http.Request) (*http.Response, error) { return nil, errors.New(platformPlanTestKey) })} + out, sinks, err := platformPlanGet(t, "json", context.Background()) + platformPlanRejected(t, out, sinks, err, "platform plan request failed") +} + +func TestPlatformPlanSuccessfulFinalReadCancellation(t *testing.T) { + platformPlanEnv(t) + for _, format := range []string{"table", "json"} { + t.Run(format, func(t *testing.T) { + _, live := platformPlanResponse(t, 200, platformPlanFixture) + control, _, err := platformPlanGet(t, format, context.Background()) + require.NoError(t, err) + require.NotEmpty(t, control) + require.Equal(t, len(platformPlanFixture), live.consumed) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + h, b := platformPlanResponse(t, 200, platformPlanFixture) + b.cancel = cancel + out, sinks, err := platformPlanGet(t, format, ctx) + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, out) + assert.Empty(t, sinks) + assert.Len(t, h.paths, 1) + assert.Equal(t, len(platformPlanFixture), b.consumed) + assert.True(t, b.closed) + }) + } +} + +func TestPlatformPlanContextControlsAndRedirects(t *testing.T) { + platformPlanEnv(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanGet(t, "json", ctx) + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, out+sinks) + assert.Empty(t, h.paths) + ctx, cancel = context.WithTimeout(context.Background(), time.Second) + defer cancel() + deadline, _ := ctx.Deadline() + platformPlanHTTPClient = &http.Client{Transport: platformPlanTransport(func(r *http.Request) (*http.Response, error) { + got, ok := r.Context().Deadline() + require.True(t, ok) + assert.Equal(t, deadline, got) + <-r.Context().Done() + return nil, errors.New(platformPlanTestKey) + })} + out, sinks, err = platformPlanGet(t, "json", ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Empty(t, out+sinks) + platformPlanHTTPClient = &http.Client{Transport: platformPlanTransport(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })} + out, sinks, err = platformPlanGet(t, "json", context.Background()) + platformPlanRejected(t, out, sinks, err, "platform plan request failed") + assert.NotErrorIs(t, err, context.DeadlineExceeded) + for _, location := range []string{"https://other.example.invalid/", "https://platform.example.invalid/else"} { + calls := 0 + callback := false + original := &http.Client{Timeout: time.Hour, CheckRedirect: func(*http.Request, []*http.Request) error { callback = true; return nil }, Transport: platformPlanTransport(func(r *http.Request) (*http.Response, error) { + calls++ + if calls > 1 { + t.Errorf("redirect to %s was followed", r.URL) + return nil, errors.New("unexpected redirect request") + } + return &http.Response{StatusCode: 302, Header: http.Header{"Location": []string{location}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil + })} + platformPlanHTTPClient = original + out, sinks, err = platformPlanGet(t, "json", context.Background()) + platformPlanRejected(t, out, sinks, err, "platform plan request failed") + assert.Equal(t, 1, calls) + assert.False(t, callback) + assert.Equal(t, time.Hour, original.Timeout) + require.NotNil(t, original.CheckRedirect) + } +} + +func TestPlatformPlanAcceptedSuccessPrivacy(t *testing.T) { + platformPlanEnv(t) + for _, format := range []string{"table", "json"} { + for _, name := range platformPlanKeyVariants() { + t.Run(format+" name", func(t *testing.T) { + encoded, _ := json.Marshal(name) + body := strings.Replace(platformPlanFixture, `"Weekly synthetic"`, string(encoded), 1) + h, _ := platformPlanResponse(t, 200, body) + t.Setenv("CUDLY_PLATFORM_USER_API_KEY", "unrelated-control-credential-ZZW") + control, _, err := platformPlanGet(t, format, context.Background()) + require.NoError(t, err) + require.NotEmpty(t, control) + assert.Len(t, h.paths, 1) + t.Setenv("CUDLY_PLATFORM_USER_API_KEY", platformPlanTestKey) + h, _ = platformPlanResponse(t, 200, body) + out, sinks, err := platformPlanGet(t, format, context.Background()) + platformPlanRejected(t, out, sinks, err, "result withheld") + assert.Len(t, h.paths, 1) + }) + } + for _, key := range []string{platformPlanTestID, "2026-11-02T03:04:05Z", "2", "7", "a"} { + t.Run(format+" typed collision", func(t *testing.T) { + t.Setenv("CUDLY_PLATFORM_USER_API_KEY", key) + h, _ := platformPlanResponse(t, 200, platformPlanFixture) + out, sinks, err := platformPlanGet(t, format, context.Background()) + platformPlanRejected(t, out, sinks, err, "result withheld") + assert.Len(t, h.paths, 1) + }) + } + for _, name := range []string{"SYNTHETIC-zqKey-VX97", "SYNTHETIC-zqKeey-VX98", "ordinary\u001b[31m\u202e name"} { + encoded, _ := json.Marshal(name) + body := strings.Replace(platformPlanFixture, `"Weekly synthetic"`, string(encoded), 1) + platformPlanResponse(t, 200, body) + out, sinks, err := platformPlanGet(t, format, context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, out) + assert.Empty(t, sinks) + assert.NotContains(t, out, "\u001b") + assert.NotContains(t, out, "\u202e") + } + } +} + +func platformPlanKeyVariants() []string { + interleave := func(sep string) string { + return strings.Join(strings.Split(platformPlanTestKey, ""), sep) + } + fullWidth := "\uff33\uff39\uff2e\uff34\uff28\uff25\uff34\uff29\uff23-zqKey-VX98" + return []string{ + platformPlanTestKey, + strings.Repeat("x", 700) + platformPlanTestKey, + interleave("\u200b"), + interleave("\x01"), + interleave("\u0301"), + interleave(" * "), + fullWidth, + } +} diff --git a/docs/cli/platform-plan.md b/docs/cli/platform-plan.md new file mode 100644 index 000000000..398f0dd34 --- /dev/null +++ b/docs/cli/platform-plan.md @@ -0,0 +1,41 @@ +# Saved platform plan summary + +`cudly platform-plan get [--format table|json]` reads one saved +platform plan. The default format is table. The ID is the platform plan UUID, +not an Archera organization, plan or line-item ID or a provider commitment ID. + +Set `CUDLY_PLATFORM_URL` to an HTTPS origin, with an optional trailing slash. +Set `CUDLY_PLATFORM_USER_API_KEY` to a previously issued, scoped platform user +API key with `view:plans` permission and access to the plan's accounts. The +server enforces permission and account scope. The client cannot identify the +issuance type of an arbitrary string; do not put an infrastructure admin key +in this variable. There are no credential flags or persisted client settings. +Archera, inbound server and provider credentials are never fallback values. + +The command makes one GET `/api/plans/` with `X-API-Key`, never a purchase +or modification. Help and invalid arguments/configuration make no request. +Redirects are refused and responses are bounded to 1 MiB. The request and +operation context use a 30-second deadline, shortened by an earlier caller +deadline, with cancellation checked before publication. A blocking stdout +write cannot be interrupted by this context. + +Table and JSON expose only `id`, `name`, `enabled`, `auto_purchase`, `ramp_type`, +`current_step`, `total_steps`, `updated_at`, `next_execution_date` and +`last_execution_date`. This is the documented JSON allowlist. Null or absent +dates remain unknown (JSON null). Next execution is reported by the platform; +it may be calculated by the server rather than persisted. It is neither a +guarantee nor an execution reservation. Timestamps are not recomputed. + +Names and ramp labels are terminal-cleaned and capped at 256 bytes. Before +display, whole-key reflection detection folds NFKD/lowercase and removes +controls, format/separator/combining runes and runes outside the normalized +key's alphabet. A matching whole normalized key refuses the entire result. +Short/numeric keys can conservatively collide with real fields. Inserting +additional runes inside the key alphabet can break this contiguous match; +the policy does not detect every semantic encoding or look-alike alphabet. +Rejection or cancellation observed before output starts emits no result. +Once a writer starts, cancellation or writer failure cannot retract bytes. + +Full service configuration, monetary caps, ramp interval/start settings, +health, execution history, listing and all edits remain outside this summary. +No platform-plan MCP tool is supplied by this CLI feature. diff --git a/go.mod b/go.mod index 7fb6388f5..e37fffb28 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.5 github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect