diff --git a/README.md b/README.md index 92e23a1ea..177668ba6 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ make build `make build` creates `./cudly` from `./cmd`. The build does not deploy or configure a cloud account. -Read the [CLI reference](docs/cli/README.md) for commands. See the guides for [cloud setup](docs/cli/cloud-setup.md), [filtering](docs/cli/filtering.md), and [purchase safety](docs/cli/purchase-safety.md). `archera-comparison` is an optional, default-off, read-only Archera plan comparison: see [Archera comparison](docs/cli/archera-comparison.md). +Read the [CLI reference](docs/cli/README.md) for commands. See the guides for [cloud setup](docs/cli/cloud-setup.md), [filtering](docs/cli/filtering.md), and [purchase safety](docs/cli/purchase-safety.md). `archera-comparison` is an optional, default-off, read-only Archera plan comparison: see [Archera comparison](docs/cli/archera-comparison.md). Discover vendor UUIDs with [Archera line items](docs/cli/archera-line-items.md). ## Common workflows diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index efd30b218..21fc71984 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -80,7 +80,7 @@ func runArcheraComparison(cmd *cobra.Command, _ []string) error { if format != "table" && format != "json" { return errors.New("--format must be table or json") } - key, orgID, planID, err := archeraSettings() + key, orgID, planID, err := archeraSettings(archeraOpts.OrgID, archeraOpts.PlanID) if err != nil { return err } @@ -153,16 +153,16 @@ func archeraFilterValues(flag string, raw []string, limit int) ([]string, error) // archeraSettings reads the key, org ID and plan ID, naming (never echoing) // the first missing setting. The key is returned to RunE only. -func archeraSettings() (key, orgID, planID string, err error) { +func archeraSettings(orgFlag, planFlag string) (key, orgID, planID string, err error) { key = strings.TrimSpace(os.Getenv(archeraEnvAPIKey)) if key == "" { return "", "", "", fmt.Errorf("%s is not set (read from the environment only)", archeraEnvAPIKey) } - orgID = archeraSetting(archeraOpts.OrgID, archeraEnvOrgID) + orgID = archeraSetting(orgFlag, archeraEnvOrgID) if orgID == "" { return "", "", "", fmt.Errorf("archera org ID is not set (--org-id or %s)", archeraEnvOrgID) } - planID = archeraSetting(archeraOpts.PlanID, archeraEnvPlanID) + planID = archeraSetting(planFlag, archeraEnvPlanID) if planID == "" { return "", "", "", fmt.Errorf("archera plan ID is not set (--plan-id or %s)", archeraEnvPlanID) } diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index 89eb508b0..3040d4029 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -100,7 +100,7 @@ func okHandler(w http.ResponseWriter, _ *http.Request) { // resetHelpFlags clears the sticky --help flag cobra leaves set after a help run. func resetHelpFlags() { - for _, c := range []*cobra.Command{rootCmd, archeraComparisonCmd, configureGCPCmd} { + for _, c := range []*cobra.Command{rootCmd, archeraComparisonCmd, archeraLineItemsCmd, configureGCPCmd} { if f := c.Flags().Lookup("help"); f != nil { _ = f.Value.Set("false") f.Changed = false @@ -109,6 +109,11 @@ func resetHelpFlags() { } func runArchera(t *testing.T, env map[string]string, args ...string) (stdout, stderr string, err error) { + t.Helper() + return runArcheraCommand(t, "archera-comparison", env, args...) +} + +func runArcheraCommand(t *testing.T, command string, env map[string]string, args ...string) (stdout, stderr string, err error) { t.Helper() for _, k := range []string{archeraEnvAPIKey, archeraEnvOrgID, archeraEnvPlanID} { t.Setenv(k, "") @@ -122,10 +127,15 @@ func runArchera(t *testing.T, env map[string]string, args ...string) (stdout, st for _, name := range []string{"org-id", "plan-id", "format", "line-item-ids", "contract-terms", "payment-options"} { archeraComparisonCmd.Flags().Lookup(name).Changed = false } + archeraListingOpts.OrgID, archeraListingOpts.PlanID, archeraListingOpts.Format = "", "", "table" + archeraListingOpts.Offset, archeraListingOpts.Limit = 0, 100 + for _, name := range []string{"org-id", "plan-id", "format", "offset", "limit"} { + archeraLineItemsCmd.Flags().Lookup(name).Changed = false + } var out, errb bytes.Buffer rootCmd.SetOut(&out) rootCmd.SetErr(&errb) - rootCmd.SetArgs(append([]string{"archera-comparison"}, args...)) + rootCmd.SetArgs(append([]string{command}, args...)) t.Cleanup(func() { rootCmd.SetOut(nil); rootCmd.SetErr(nil); rootCmd.SetArgs(nil) }) realOut, realErr := captureRealStd(t, func() { err = rootCmd.Execute() }) // Anything the command wrote to the real process streams is returned with diff --git a/cmd/archera_line_items.go b/cmd/archera_line_items.go new file mode 100644 index 000000000..4ba68b8fa --- /dev/null +++ b/cmd/archera_line_items.go @@ -0,0 +1,214 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + "strconv" + "strings" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/spf13/cobra" +) + +const archeraListingNote = "Local pages repeat a complete vendor read; no atomic snapshot is guaranteed. Vendor UUIDs do not establish recommendation or cross-account association. Selection is not a quote or purchase; no currency is supplied." + +var archeraListingOpts struct { + OrgID, PlanID, Format string + Offset, Limit int64 +} + +var archeraLineItemsCmd = &cobra.Command{ + Use: "archera-line-items", Short: "List read-only Archera plan line-item identities", + Long: "Fetch all vendor pages, then print a local window of line items. Configuration: ARCHERA_API_KEY (environment only), ARCHERA_ORG_ID/--org-id, ARCHERA_PLAN_ID/--plan-id. No request occurs unless explicitly invoked.", + Args: func(_ *cobra.Command, args []string) error { + if len(args) > 0 { + return errors.New("archera-line-items accepts no positional arguments") + } + return nil + }, SilenceUsage: true, SilenceErrors: true, RunE: runArcheraLineItems, +} + +func init() { + rootCmd.AddCommand(archeraLineItemsCmd) + archeraLineItemsCmd.SetFlagErrorFunc(func(_ *cobra.Command, _ error) error { + return errors.New("invalid archera-line-items flag; use --help for integer bounds and options") + }) + f := archeraLineItemsCmd.Flags() + f.StringVar(&archeraListingOpts.OrgID, "org-id", "", "Archera organization UUID (default: $ARCHERA_ORG_ID)") + f.StringVar(&archeraListingOpts.PlanID, "plan-id", "", "Archera plan UUID (default: $ARCHERA_PLAN_ID)") + f.StringVar(&archeraListingOpts.Format, "format", "table", "Output format: table or json") + f.Int64Var(&archeraListingOpts.Offset, "offset", 0, "Local output offset, nonnegative; all vendor pages are fetched") + f.Int64Var(&archeraListingOpts.Limit, "limit", 100, "Local output limit, 1..200; all vendor pages are fetched") +} + +type archeraListingRow struct { + Region *string `json:"region"` + InstanceFamily *string `json:"instance_family"` + NativePlanType *string `json:"native_plan_type"` + NativeDurationSeconds *string `json:"native_duration_seconds"` + LineItemID string `json:"line_item_id"` + OfferID string `json:"offer_id"` + SelectedQuantity string `json:"selected_quantity"` + SelectedCommitment string `json:"selected_commitment"` + ContractTerm string `json:"contract_term"` + PaymentOption string `json:"payment_option"` + Provider string `json:"provider"` + CommitmentType string `json:"commitment_type"` + IsSelected bool `json:"is_selected"` +} + +type archeraListingPage struct { + NextOffset *int64 `json:"next_offset"` + NonGatingDisclosure string `json:"non_gating_disclosure"` + SponsorshipDisclosure string `json:"sponsorship_disclosure"` + Note string `json:"note"` + LineItems []archeraListingRow `json:"line_items"` + Total int64 `json:"total"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` +} + +func archeraListingText(s, key string) string { + s = strings.ReplaceAll(archeraSanitizeN(s, math.MaxInt), key, "[redacted]") + if archeraKeyReadable(s, key) { + return archeraWithheld + } + return archeraSanitize(s) +} + +func archeraListingRowDTO(row *insurance.LineItem, key string) (archeraListingRow, error) { + if archeraListingText(row.ID, key) != row.ID || archeraListingText(row.OfferID, key) != row.OfferID { + return archeraListingRow{}, errors.New("line-item identities cannot be safely displayed") + } + text := func(s string) string { return archeraListingText(s, key) } + nullable := func(s *string) *string { + if s == nil { + return nil + } + value := text(*s) + return &value + } + return archeraListingRow{ + LineItemID: row.ID, OfferID: row.OfferID, IsSelected: row.IsSelected, + SelectedQuantity: strconv.FormatInt(row.SelectedQuantity, 10), + SelectedCommitment: *archeraMoney(row.SelectedCommitment), + ContractTerm: text(row.ContractTerm), PaymentOption: text(string(row.PaymentOption)), + Provider: text(string(row.Offer.Provider)), CommitmentType: text(row.ContractSpec.CommitmentType), + Region: nullable(row.Offer.Region), InstanceFamily: nullable(row.Offer.InstanceFamily), + NativePlanType: nullable(row.Offer.PlanType), NativeDurationSeconds: archeraMoney(row.Offer.DurationSeconds), + }, nil +} + +func archeraFetchLineItems(cmd *cobra.Command, key, org, plan string) ([]insurance.LineItem, error) { + client, err := insurance.NewClient(insurance.Config{APIKey: insurance.NewSecret(key), OrgID: org}, archeraHTTPClient) + if err != nil { + return nil, archeraSafeError(err, key) + } + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + rows, err := client.LineItems(ctx, plan) + if err != nil { + return nil, archeraSafeError(err, key) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + return rows, nil +} + +func archeraListingWindow(rows []insurance.LineItem, offset, limit int64, key string) (archeraListingPage, error) { + total := int64(len(rows)) + if offset > total { + return archeraListingPage{}, errors.New("--offset exceeds the total line-item count") + } + slices.SortFunc(rows, func(a, b insurance.LineItem) int { + return strings.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID)) + }) + end := offset + min(limit, total-offset) + page := archeraListingPage{ + Total: total, Offset: offset, Limit: limit, LineItems: []archeraListingRow{}, + NonGatingDisclosure: common.ArcheraNonGatingDisclosure, + SponsorshipDisclosure: common.ArcheraSponsorshipDisclosure, Note: archeraListingNote, + } + if end < total { + page.NextOffset = &end + } + for i := offset; i < end; i++ { + dto, err := archeraListingRowDTO(&rows[i], key) + if err != nil { + return archeraListingPage{}, err + } + page.LineItems = append(page.LineItems, dto) + } + return page, nil +} + +func archeraListingRender(page *archeraListingPage, format, key string) ([]byte, error) { + output, err := json.Marshal(page) + if err != nil { + return nil, err + } + if archeraKeyReadable(string(output), key) { + return nil, errors.New("line-item output cannot be safely displayed") + } + if format == "table" { + output = archeraListingTable(page) + } + if len(output) > 512*1024 { + return nil, errors.New("line-item output exceeds 512 KiB; choose a smaller --limit") + } + return output, nil +} + +func runArcheraLineItems(cmd *cobra.Command, _ []string) error { + o := archeraListingOpts + if o.Format != "table" && o.Format != "json" { + return errors.New("--format must be table or json") + } + if o.Offset < 0 || o.Limit < 1 || o.Limit > 200 { + return errors.New("--offset must be nonnegative and --limit must be 1..200") + } + key, org, plan, err := archeraSettings(o.OrgID, o.PlanID) + if err != nil { + return err + } + rows, err := archeraFetchLineItems(cmd, key, org, plan) + if err != nil { + return err + } + page, err := archeraListingWindow(rows, o.Offset, o.Limit, key) + if err != nil { + return err + } + output, err := archeraListingRender(&page, o.Format, key) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(append(output, '\n')) + return err +} + +func archeraListingTable(page *archeraListingPage) []byte { + var out bytes.Buffer + _, _ = fmt.Fprintf(&out, "Total %d; offset %d; limit %d; next %s\n%s\n%s\n%s\n", page.Total, page.Offset, page.Limit, archeraListingNext(page.NextOffset), page.Note, page.NonGatingDisclosure, page.SponsorshipDisclosure) + _, _ = fmt.Fprintln(&out, "LINE ITEM\tOFFER\tSELECTED\tQUANTITY\tCOMMITMENT\tTERM\tPAYMENT\tPROVIDER\tTYPE\tREGION\tFAMILY\tNATIVE PLAN\tNATIVE SECONDS") + for i := range page.LineItems { + row := &page.LineItems[i] + _, _ = fmt.Fprintf(&out, "%s\t%s\t%t\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", row.LineItemID, row.OfferID, row.IsSelected, row.SelectedQuantity, row.SelectedCommitment, row.ContractTerm, row.PaymentOption, row.Provider, row.CommitmentType, archeraStr(row.Region), archeraStr(row.InstanceFamily), archeraStr(row.NativePlanType), archeraStr(row.NativeDurationSeconds)) + } + return out.Bytes() +} + +func archeraListingNext(next *int64) string { + if next == nil { + return "none" + } + return strconv.FormatInt(*next, 10) +} diff --git a/cmd/archera_line_items_test.go b/cmd/archera_line_items_test.go new file mode 100644 index 000000000..ae498c21d --- /dev/null +++ b/cmd/archera_line_items_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArcheraLineItemsRegistration(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected request") }) + cmd, _, err := rootCmd.Find([]string{"archera-line-items"}) + require.NoError(t, err) + require.Equal(t, "archera-line-items", cmd.Name()) + require.Empty(t, rec.requests) +} + +func listingWire(t *testing.T, number int) string { + t.Helper() + body, err := os.ReadFile("testdata/archera_line_item.json") + require.NoError(t, err) + id := fmt.Sprintf("%08x-aaaa-4aaa-8aaa-aaaaaaaaaaaa", number) + if number == 2 { + id = strings.ToUpper(id) + } + return strings.ReplaceAll(string(body), "LINE_ID", id) +} + +func listingHandler(t *testing.T, total int, late http.HandlerFunc) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/comparison") { + okHandler(w, r) + return + } + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/org/"+archeraTestOrg+"/commitment-plans/"+archeraTestPlan+"/line-items", r.URL.Path) + assert.Equal(t, archeraTestKey, r.Header.Get("x-api-key")) + page, err := strconv.Atoi(r.URL.Query().Get("page")) + require.NoError(t, err) + assert.Equal(t, "100", r.URL.Query().Get("page_size")) + assert.Equal(t, "id", r.URL.Query().Get("order_by")) + assert.Len(t, r.URL.Query(), 3) + pages := (total + 99) / 100 + w.Header().Set("X-Pagination", fmt.Sprintf(`{"total":%d,"total_pages":%d,"page":%d,"first_page":1,"last_page":%d}`, total, pages, page, pages)) + if page == 2 && late != nil { + late(w, r) + return + } + rows := []string{} + for i := (page - 1) * 100; i < min(page*100, total); i++ { + rows = append(rows, listingWire(t, total-i)) + } + _, _ = w.Write([]byte("[" + strings.Join(rows, ",") + "]")) + } +} + +func runListing(t *testing.T, env map[string]string, args ...string) (string, string, error) { + t.Helper() + return runArcheraCommand(t, "archera-line-items", env, args...) +} + +func TestArcheraLineItemsWindowsAndComparisonReuse(t *testing.T) { + for _, tc := range []struct{ offset, limit, total, want, next int }{ + {0, 100, 101, 100, 100}, {1, 1, 101, 1, 2}, {0, 200, 101, 101, -1}, {101, 100, 101, 0, -1}, {102, 100, 101, -1, -1}, {0, 100, 0, 0, -1}, + } { + t.Run(fmt.Sprint(tc), func(t *testing.T) { + rec := setupArchera(t, listingHandler(t, tc.total, nil)) + out, stderr, err := runListing(t, fullEnv(), "--format=json", "--offset", strconv.Itoa(tc.offset), "--limit", strconv.Itoa(tc.limit)) + if tc.want < 0 { + require.ErrorContains(t, err, "--offset") + assert.Empty(t, out) + return + } + require.NoError(t, err) + var dto archeraListingPage + require.NoError(t, json.Unmarshal([]byte(out), &dto)) + assert.EqualValues(t, tc.total, dto.Total) + assert.EqualValues(t, tc.offset, dto.Offset) + assert.EqualValues(t, tc.limit, dto.Limit) + require.Len(t, dto.LineItems, tc.want) + assert.NotNil(t, dto.LineItems) + if tc.next < 0 { + assert.Nil(t, dto.NextOffset) + } else { + require.NotNil(t, dto.NextOffset) + assert.EqualValues(t, tc.next, *dto.NextOffset) + } + assert.Contains(t, dto.Note, "no atomic snapshot") + assert.NotEmpty(t, dto.NonGatingDisclosure) + assert.NotEmpty(t, dto.SponsorshipDisclosure) + assert.NotContains(t, out+stderr, archeraTestKey) + assert.NotContains(t, out, archeraTestPlan) + assert.NotContains(t, out, "private-account") + assert.NotContains(t, out, "private-catalog") + require.Len(t, rec.requests, max(1, (tc.total+99)/100)) + if tc.offset == 1 { + row := dto.LineItems[0] + assert.Equal(t, "00000002-AAAA-4AAA-8AAA-AAAAAAAAAAAA", row.LineItemID) + assert.Equal(t, "9007199254740993", row.SelectedQuantity) + assert.Equal(t, "0."+strings.Repeat("0", 100)+"1", row.SelectedCommitment) + assert.Equal(t, "contract-type", row.CommitmentType) + assert.Equal(t, "no_upfront", row.PaymentOption) + assert.Nil(t, row.Region) + assert.Nil(t, row.InstanceFamily) + assert.Nil(t, row.NativePlanType) + require.NotNil(t, row.NativeDurationSeconds) + assert.Equal(t, "31536000.25", *row.NativeDurationSeconds) + var raw map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &raw)) + assert.Len(t, raw["line_items"].([]any)[0].(map[string]any), 13) + _, _, err = runArchera(t, fullEnv(), "--line-item-ids", row.LineItemID) + require.NoError(t, err) + require.Len(t, rec.requests, 3) + assert.Equal(t, []string{row.LineItemID}, rec.requests[2].URL.Query()["line_item_ids"]) + } + }) + } +} + +func TestArcheraLineItemsRejectsBeforeRequestAndDefaultOff(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected request") }) + for _, args := range [][]string{ + {"--limit=0"}, {"--limit=201"}, {"--limit=-1"}, {"--limit=1.5"}, {"--limit=9223372036854775808"}, + {"--offset=-1"}, {"--offset=1.5"}, {"--offset=9223372036854775808"}, {"--format=xml"}, + {"--org-id=bad"}, {"--plan-id=bad"}, {"--limit=" + archeraTestKey}, {archeraTestKey}, + } { + out, stderr, err := runListing(t, fullEnv(), args...) + require.Error(t, err) + assert.Empty(t, out) + assert.NotContains(t, stderr+err.Error(), archeraTestKey) + } + _, _, err := runListing(t, nil) + require.ErrorContains(t, err, "ARCHERA_API_KEY") + for _, setting := range []string{archeraEnvOrgID, archeraEnvPlanID} { + env := fullEnv() + delete(env, setting) + _, _, err = runListing(t, env) + require.ErrorContains(t, err, setting) + } + out, _, err := runListing(t, nil, "--help") + require.NoError(t, err) + for _, flag := range []string{"--org-id", "--plan-id", "--format", "--offset", "--limit"} { + assert.Contains(t, out, flag) + } + rootCmd.SetArgs([]string{"configure-gcp", "--help"}) + require.NoError(t, rootCmd.Execute()) + resetHelpFlags() + assert.Empty(t, rec.requests) +} + +func TestArcheraLineItemsLateFailuresNeverPrintPartialRows(t *testing.T) { + for _, failure := range []string{"http", "decode", "metadata", "duplicate", "size", "cancel", "429", "redirect"} { + t.Run(failure, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + archeraLineItemsCmd.SetContext(ctx) + t.Cleanup(func() { archeraLineItemsCmd.SetContext(context.Background()) }) + rec := setupArchera(t, listingHandler(t, 101, func(w http.ResponseWriter, _ *http.Request) { + switch failure { + case "http": + http.Error(w, archeraTestKey, http.StatusInternalServerError) + case "decode": + _, _ = w.Write([]byte("[{}]")) + case "metadata": + w.Header().Set("X-Pagination", "{}") + _, _ = w.Write([]byte("[]")) + case "duplicate": + _, _ = w.Write([]byte("[" + listingWire(t, 101) + "]")) + case "size": + _, _ = w.Write([]byte("[" + listingWire(t, 1) + "]" + strings.Repeat(" ", 8<<20))) + case "cancel": + cancel() + case "429": + w.Header().Set("Retry-After", "2") + w.WriteHeader(http.StatusTooManyRequests) + case "redirect": + w.Header().Set("Location", "https://api.archera.ai/redirect") + w.WriteHeader(http.StatusFound) + } + })) + out, stderr, err := runListing(t, fullEnv(), "--format=json", "--limit=1") + require.Error(t, err) + assert.Empty(t, out) + assert.NotContains(t, stderr+err.Error(), archeraTestKey) + assert.Len(t, rec.requests, 2) + if failure == "429" { + assert.Contains(t, err.Error(), "retry after 2s") + } + if failure == "redirect" { + assert.Contains(t, err.Error(), "302") + } + }) + } +} + +type listingTrip func(*http.Request) (*http.Response, error) + +func (f listingTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestArcheraLineItemsAggregateDeadline(t *testing.T) { + setupArchera(t, listingHandler(t, 101, nil)) + base := archeraHTTPClient.Transport + var first time.Time + archeraHTTPClient.Transport = listingTrip(func(r *http.Request) (*http.Response, error) { + deadline, ok := r.Context().Deadline() + require.True(t, ok) + assert.LessOrEqual(t, time.Until(deadline), 30*time.Second) + if first.IsZero() { + first = deadline + } else { + assert.Equal(t, first, deadline) + } + return base.RoundTrip(r) + }) + _, _, err := runListing(t, fullEnv(), "--format=json", "--limit=1") + require.NoError(t, err) +} + +func TestArcheraLineItemsPrivacyAndTable(t *testing.T) { + var logs bytes.Buffer + oldLog, oldSlog := log.Writer(), slog.Default() + log.SetOutput(&logs) + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { log.SetOutput(oldLog); slog.SetDefault(oldSlog) }) + for _, format := range []string{"json", "table"} { + rec := setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Pagination", `{"total":1,"total_pages":1,"page":1,"first_page":1,"last_page":1}`) + wire := strings.Replace(listingWire(t, 1), `"contract-type"`, mustJSON(t, archeraTestKey+"\x1b[31m"+strings.Repeat("x", 500)), 1) + wire = strings.Replace(wire, "9007199254740993", "0", 1) + wire = strings.Replace(wire, "0.1e-100", "0", 1) + _, _ = w.Write([]byte("[" + wire + "]")) + }) + out, stderr, err := runListing(t, fullEnv(), "--format", format) + require.NoError(t, err) + assert.NotContains(t, out+stderr+logs.String(), archeraTestKey) + assert.NotContains(t, out, "\x1b") + assert.Contains(t, out, "[redacted]") + assert.NotContains(t, out, strings.Repeat("x", 257)) + require.Len(t, rec.requests, 1) + if format == "json" { + var dto archeraListingPage + require.NoError(t, json.Unmarshal([]byte(out), &dto)) + assert.Equal(t, "0", dto.LineItems[0].SelectedQuantity) + assert.Equal(t, "0", dto.LineItems[0].SelectedCommitment) + } else { + assert.Contains(t, out, "Total 1; offset 0; limit 100") + assert.Contains(t, out, "QUANTITY") + } + } + env := fullEnv() + env[archeraEnvAPIKey] = "00000001-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Pagination", `{"total":1,"total_pages":1,"page":1,"first_page":1,"last_page":1}`) + _, _ = w.Write([]byte("[" + listingWire(t, 1) + "]")) + }) + out, stderr, err := runListing(t, env, "--format=json") + require.Error(t, err) + assert.Empty(t, out) + assert.NotContains(t, stderr+logs.String()+err.Error(), env[archeraEnvAPIKey]) +} diff --git a/cmd/testdata/archera_line_item.json b/cmd/testdata/archera_line_item.json new file mode 100644 index 000000000..a05c257e3 --- /dev/null +++ b/cmd/testdata/archera_line_item.json @@ -0,0 +1 @@ +{"id":"LINE_ID","plan_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","offer_id":"99999999-9999-4999-8999-999999999999","account_id":"private-account","selected_quantity":9007199254740993,"selected_commitment":0.1e-100,"is_selected":true,"contract_term":"one_year","payment_option":"No Upfront","contract_spec":{"commitment_type":"contract-type"},"offer":{"id":"99999999-9999-4999-8999-999999999999","provider":"aws","type":"catalog-type","provider_offering_id":"private-provider","offering_id":"private-catalog","duration_seconds":31536000.25,"region":null,"az":null,"instance_type":null,"instance_family":null,"product_description":null,"tenancy":null,"plan_type":null,"is_multi_az":null,"is_flexible":null}} diff --git a/docs/cli/README.md b/docs/cli/README.md index c79a8938c..8313ef871 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -1,10 +1,12 @@ # CUDly CLI Reference -This section documents the full CLI surface of the `cudly` binary. The Makefile builds it as `cudly`, while the cobra root command is named `ri-helper` (a legacy name that still appears in `--help` output and in auto-generated CSV filenames). Three cobra commands are available: +This section documents the full CLI surface of the `cudly` binary. The Makefile builds it as `cudly`, while the cobra root command is named `ri-helper` (a legacy name that still appears in `--help` output and in auto-generated CSV filenames). The following cobra commands are available: - **root** (`ri-helper`, invoked via the `cudly` binary) - the main RI/SP analysis and purchase command - **configure-azure** - bootstrap Azure Service Principal credentials - **configure-gcp** - bootstrap GCP Service Account credentials +- **archera-comparison** - read-only vendor comparison with optional filters +- **archera-line-items** - [discover vendor line-item UUIDs](archera-line-items.md) ## Topic pages diff --git a/docs/cli/archera-line-items.md b/docs/cli/archera-line-items.md new file mode 100644 index 000000000..3cf524e0e --- /dev/null +++ b/docs/cli/archera-line-items.md @@ -0,0 +1,33 @@ +# Archera line-item discovery + +`cudly archera-line-items` is opt-in and read-only. Configure environment-only +`ARCHERA_API_KEY`, plus `ARCHERA_ORG_ID` and `ARCHERA_PLAN_ID` (or `--org-id` +and `--plan-id`). It performs no purchase, enrollment or audit write. + +```bash +cudly archera-line-items --format json --offset 0 --limit 100 +cudly archera-comparison --line-item-ids +``` + +`--format` accepts `table` (default) or `json`. `--offset` defaults to 0 and +must be nonnegative; `--limit` defaults to 100 and accepts 1..200. Every call +fetches and validates **all vendor pages first**, within a 30-second aggregate +deadline. Offset equal to total returns an empty array; larger offsets fail. +Rows are sorted by case-insensitive UUID, preserving the original spelling. +Local pages repeat a complete read; they have no guaranteed atomic snapshot. + +Both formats expose total, offset, limit and next offset (null/none at the end), +plus both existing disclosures. JSON `line_items` is always an array. +Rows contain vendor line_item_id and offer_id, is_selected, exact integer-text +selected_quantity, exact decimal-text selected_commitment, contract_term, +payment_option, provider, commitment_type, region, instance_family, +native_plan_type and exact decimal-text native_duration_seconds. Null offer +dimensions remain null (table: unknown); zero remains "0". Native duration is +distinct from effective contract term; commitment_type comes from contract_spec. + +Selected commitment is hourly selection for Savings Plans and zero for RIs, +not monthly cost, savings or a quote. No currency is supplied. Account/org/plan +and unused vendor fields are omitted. IDs do not prove recommendation, +cross-account association or economic equivalence. Call comparison explicitly +with chosen vendor UUIDs; discovery never invokes it. Failures print no partial +rows; output above 512 KiB is rejected with guidance to reduce the local limit.