diff --git a/CHANGELOG.md b/CHANGELOG.md index de0ade427..7e5d56a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added + +- `archera-comparison` command: a read-only, default-off comparison of an + Archera commitment plan (table or JSON). See `docs/cli/archera-comparison.md` + (#2156). + ### Fixed - Ctrl-C now cancels the whole invocation instead of only setting a flag that diff --git a/README.md b/README.md index aeecf78a7..0e6249f97 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). +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). ## Common workflows diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go new file mode 100644 index 000000000..92df84415 --- /dev/null +++ b/cmd/archera_comparison.go @@ -0,0 +1,113 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/spf13/cobra" +) + +// Archera settings come from the environment, read only inside RunE. The API +// key has no flag by design. +const ( + archeraEnvAPIKey = "ARCHERA_API_KEY" // #nosec G101 -- environment variable name, not a credential + archeraEnvOrgID = "ARCHERA_ORG_ID" + archeraEnvPlanID = "ARCHERA_PLAN_ID" +) + +// archeraHTTPClient is nil in production, which selects the hardened default +// client inside pkg/insurance. Tests replace it with a fixed-origin transport. +// It never holds the key; the key lives only in RunE locals. +var archeraHTTPClient *http.Client + +var archeraOpts struct { + OrgID string + PlanID string + Format string +} + +var archeraComparisonCmd = &cobra.Command{ + Use: "archera-comparison", + Short: "Show a read-only Archera commitment plan comparison", + Long: `Fetch and print the comparison for one Archera commitment plan. + +This command is read-only: it never purchases, binds or submits anything, and +it is independent of --purchase. It makes no Archera request unless it is run. + +Configuration (default off; there is no flag for the API key): + ARCHERA_API_KEY Archera API key (environment only) + ARCHERA_ORG_ID Archera organization UUID (or --org-id) + ARCHERA_PLAN_ID Archera commitment plan UUID (or --plan-id)`, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: runArcheraComparison, +} + +func init() { + rootCmd.AddCommand(archeraComparisonCmd) + archeraComparisonCmd.Flags().StringVar(&archeraOpts.OrgID, "org-id", "", "Archera organization UUID (default: $"+archeraEnvOrgID+")") + archeraComparisonCmd.Flags().StringVar(&archeraOpts.PlanID, "plan-id", "", "Archera commitment plan UUID (default: $"+archeraEnvPlanID+")") + archeraComparisonCmd.Flags().StringVar(&archeraOpts.Format, "format", "table", "Output format: table or json") +} + +// archeraSetting returns the flag value, else the environment value, trimmed. +func archeraSetting(flagValue, envName string) string { + if v := strings.TrimSpace(flagValue); v != "" { + return v + } + return strings.TrimSpace(os.Getenv(envName)) +} + +func runArcheraComparison(cmd *cobra.Command, _ []string) error { + format := archeraOpts.Format + if format != "table" && format != "json" { + return errors.New("--format must be table or json") + } + key, orgID, planID, err := archeraSettings() + if err != nil { + return err + } + + // Config and client stay local to this call; never store them in a + // package variable or struct. + client, err := insurance.NewClient(insurance.Config{APIKey: key, OrgID: orgID}, archeraHTTPClient) + if err != nil { + return err + } + comparison, err := client.Comparison(cmd.Context(), insurance.ComparisonRequest{PlanID: planID}) + if err != nil { + var httpErr *insurance.HTTPError + if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { + return fmt.Errorf("%w (retry after %s; not retried automatically)", err, httpErr.RetryAfter) + } + return err + } + dto := buildArcheraDTO(comparison) + if format == "json" { + return renderArcheraJSON(cmd.OutOrStdout(), dto) + } + return renderArcheraTable(cmd.OutOrStdout(), dto) +} + +// 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) { + 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) + if orgID == "" { + return "", "", "", fmt.Errorf("archera org ID is not set (--org-id or %s)", archeraEnvOrgID) + } + planID = archeraSetting(archeraOpts.PlanID, archeraEnvPlanID) + if planID == "" { + return "", "", "", fmt.Errorf("archera plan ID is not set (--plan-id or %s)", archeraEnvPlanID) + } + return key, orgID, planID, nil +} diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go new file mode 100644 index 000000000..32a3ae88a --- /dev/null +++ b/cmd/archera_comparison_test.go @@ -0,0 +1,385 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + "unicode" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + archeraTestKey = "test-key-SYNTHETIC-0000" + archeraTestOrg = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + archeraTestPlan = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +) + +// archeraComparisonFixture is a synthetic document shaped like the documented +// commitment-plans schema. Every money field carries a distinct value: the +// hundreds digit is the block (1 current totals, 2 and 6 hypotheticals, 3 +// current offer, 4 and 5 candidates) and the units digit is the field (1 cost +// total, 2 cloud cost, 3 premium, 4 gross, 5 net, 6 covered on-demand, 7 +// upfront, 8 and 9 deltas), so a swapped field mapping changes the output. The +// current-totals premium is a long decimal that float64 cannot hold exactly. +func archeraComparisonFixture() string { + b, err := os.ReadFile("testdata/archera_comparison.json") + if err != nil { + panic(err) + } + return string(b) +} + +type archeraRecorder struct { + mu sync.Mutex + requests []*http.Request + hits map[string]int +} + +// archeraTestTransport asserts the production origin before rewriting the +// request to the httptest server, so only the fixed origin is ever exercised. +type archeraTestTransport struct { + t *testing.T + target *url.URL + rec *archeraRecorder +} + +func (a *archeraTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { + require.Equal(a.t, "https", r.URL.Scheme) + require.Equal(a.t, "api.archera.ai", r.URL.Host) + a.rec.mu.Lock() + a.rec.requests = append(a.rec.requests, r.Clone(context.Background())) + a.rec.mu.Unlock() + rewritten := r.Clone(r.Context()) + rewritten.URL.Scheme, rewritten.URL.Host = a.target.Scheme, a.target.Host + rewritten.Host = a.target.Host + return http.DefaultTransport.RoundTrip(rewritten) +} + +func setupArchera(t *testing.T, handler http.HandlerFunc) *archeraRecorder { + t.Helper() + rec := &archeraRecorder{hits: map[string]int{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.hits[r.URL.Path]++ + rec.mu.Unlock() + handler(w, r) + })) + t.Cleanup(srv.Close) + target, err := url.Parse(srv.URL) + require.NoError(t, err) + archeraHTTPClient = &http.Client{Transport: &archeraTestTransport{t: t, target: target, rec: rec}} + t.Cleanup(func() { archeraHTTPClient = nil }) + return rec +} + +func okHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(archeraComparisonFixture())) +} + +// resetHelpFlags clears the sticky --help flag cobra leaves set after a help run. +func resetHelpFlags() { + for _, c := range []*cobra.Command{rootCmd, archeraComparisonCmd, configureGCPCmd} { + if f := c.Flags().Lookup("help"); f != nil { + _ = f.Value.Set("false") + f.Changed = false + } + } +} + +func runArchera(t *testing.T, env map[string]string, args ...string) (stdout, stderr string, err error) { + t.Helper() + for _, k := range []string{archeraEnvAPIKey, archeraEnvOrgID, archeraEnvPlanID} { + t.Setenv(k, "") + } + for k, v := range env { + t.Setenv(k, v) + } + resetHelpFlags() + savedLocal := time.Local + time.Local = time.FixedZone("archera-test", 5*3600+1800) + t.Cleanup(func() { time.Local = savedLocal }) + archeraOpts.OrgID, archeraOpts.PlanID, archeraOpts.Format = "", "", "table" + var out, errb bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&errb) + rootCmd.SetArgs(append([]string{"archera-comparison"}, args...)) + t.Cleanup(func() { rootCmd.SetOut(nil); rootCmd.SetErr(nil); rootCmd.SetArgs(nil) }) + err = rootCmd.Execute() + return out.String(), errb.String(), err +} + +func fullEnv() map[string]string { + return map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} +} + +func TestArcheraComparisonDefaultOffMakesNoRequest(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Archera request") }) + for _, args := range [][]string{{"--help"}} { + _, _, err := runArchera(t, fullEnv(), args...) + require.NoError(t, err) + } + rootCmd.SetArgs([]string{"configure-gcp", "--help"}) + var out bytes.Buffer + rootCmd.SetOut(&out) + require.NoError(t, rootCmd.Execute()) + resetHelpFlags() + assert.Empty(t, rec.requests) +} + +func TestArcheraComparisonIncompleteConfig(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected request") }) + cases := []struct { + name string + env map[string]string + wantMsg string + }{ + {"no key", map[string]string{archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan}, archeraEnvAPIKey}, + {"no org", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvPlanID: archeraTestPlan}, "org ID"}, + {"no plan", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg}, "plan ID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, errOut, err := runArchera(t, tc.env) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantMsg) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + }) + } + assert.Empty(t, rec.requests) +} + +func TestArcheraComparisonBadFormat(t *testing.T) { + setupArchera(t, okHandler) + _, _, err := runArchera(t, fullEnv(), "--format", "xml") + require.ErrorContains(t, err, "--format") +} + +var archeraFetchedRE = regexp.MustCompile(`(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)`) + +// assertArcheraFetchedUTC proves the printed fetch time is the current instant +// in UTC. runArchera shifts time.Local, so a local-zone time mislabelled "Z" +// would be hours off. +func assertArcheraFetchedUTC(t *testing.T, got string) { + t.Helper() + stamp := archeraFetchedRE.FindString(got) + require.NotEmpty(t, stamp) + require.True(t, strings.HasSuffix(stamp, "Z")) + parsed, err := time.Parse("2006-01-02T15:04:05Z", stamp) + require.NoError(t, err) + assert.WithinDuration(t, time.Now().UTC(), parsed, 2*time.Minute) +} + +// assertArcheraNoControlChars fails if any control character survives, in raw +// form or as a JSON escape. The fixture injects ESC and C1 characters into the +// vendor string fields. +func assertArcheraNoControlChars(t *testing.T, out string) { + t.Helper() + for _, r := range out { + if r != '\n' && unicode.IsControl(r) { + t.Fatalf("control character %U in output", r) + } + } + for _, esc := range []string{`\u001b`, `\u0085`, `\u009b`} { + assert.NotContains(t, out, esc) + } +} + +// checkArcheraGolden compares output with testdata/ after masking the +// wall-clock fetch time. ARCHERA_UPDATE_GOLDEN=1 rewrites the file. +func checkArcheraGolden(t *testing.T, name, got string) { + t.Helper() + assertArcheraFetchedUTC(t, got) + got = archeraFetchedRE.ReplaceAllString(got, "") + path := filepath.Join("testdata", name) + if os.Getenv("ARCHERA_UPDATE_GOLDEN") == "1" { + require.NoError(t, os.WriteFile(path, []byte(got), 0o600)) + } + want, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(want), got) +} + +func TestArcheraComparisonTable(t *testing.T) { + rec := setupArchera(t, okHandler) + out, errOut, err := runArchera(t, fullEnv()) + require.NoError(t, err) + + require.Len(t, rec.requests, 1) + req := rec.requests[0] + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "/v1/org/"+archeraTestOrg+"/commitment-plans/"+archeraTestPlan+"/comparison", req.URL.Path) + assert.Empty(t, req.URL.RawQuery) + assert.Equal(t, archeraTestKey, req.Header.Get("x-api-key")) + + checkArcheraGolden(t, "archera_table.golden", out) + assert.Contains(t, out, "never a bindable insurance quote") + assert.Contains(t, out, "103.1234567890123456789", "long decimal kept exact, not float64") + assert.NotContains(t, strings.ToLower(out), "insured quote") + assertArcheraNoControlChars(t, out) + assert.Contains(t, out, common.ArcheraNonGatingDisclosure) + assert.Contains(t, out, common.ArcheraSponsorshipDisclosure) + assert.NotContains(t, out, common.ArcheraSignupURL) + assert.NotContains(t, out+errOut, archeraTestKey) +} + +func TestArcheraComparisonJSON(t *testing.T) { + setupArchera(t, okHandler) + out, errOut, err := runArchera(t, fullEnv(), "--format", "json") + require.NoError(t, err) + assert.Empty(t, errOut) + dec := json.NewDecoder(strings.NewReader(out)) + var doc map[string]any + require.NoError(t, dec.Decode(&doc), "stdout must be exactly one JSON document") + assert.False(t, dec.More()) + assert.NotContains(t, doc, "disclosures") + assert.NotContains(t, out, archeraTestKey) + assertArcheraNoControlChars(t, out) + checkArcheraGolden(t, "archera_json.golden", out) +} + +func TestArcheraMoneyExact(t *testing.T) { + for in, want := range map[string]string{"0": "0", "7.25": "7.25", "10.125": "10.125", "1200": "1200", "0.00001": "0.00001"} { + r, ok := new(big.Rat).SetString(in) + require.True(t, ok) + got := archeraMoney(r) + require.NotNil(t, got) + assert.Equal(t, want, *got, in) + } + assert.Nil(t, archeraMoney(nil)) +} + +func TestArcheraSanitize(t *testing.T) { + got := archeraSanitize("a\x1bb\u009bc\nd") + assert.Equal(t, "abcd", got) + long := archeraSanitize(strings.Repeat("é", 300)) + assert.LessOrEqual(t, len(long), archeraMaxFieldSize) +} + +func TestArcheraVendorErrorsAndRedaction(t *testing.T) { + cases := []struct { + name string + status int + header map[string]string + body string + want string + }{ + {"401", 401, nil, `{"message":"nope"}`, "HTTP 401"}, + {"403 echoing key", 403, nil, `{"message":"bad key ` + archeraTestKey + `"}`, "bad key [redacted]"}, + {"429", 429, map[string]string{"Retry-After": "30"}, `{"message":"slow down"}`, "retry after 30s"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + for k, v := range tc.header { + w.Header().Set(k, v) + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + assert.Len(t, rec.requests, 1, "no automatic retry") + }) + } + t.Run("invalid uuid", func(t *testing.T) { + setupArchera(t, okHandler) + env := fullEnv() + env[archeraEnvOrgID] = "not-a-uuid" + out, errOut, err := runArchera(t, env) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + assert.NotContains(t, err.Error(), "not-a-uuid") + }) +} + +func TestArcheraRedirectNotFollowed(t *testing.T) { + rec := setupArchera(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/second") { + t.Error("redirect target was requested") + return + } + http.Redirect(w, r, "/second", http.StatusFound) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + assert.Zero(t, rec.hits["/second"]) +} + +func TestArcheraNoKeyFlagAndStructsRedacted(t *testing.T) { + usages := strings.ToLower(archeraComparisonCmd.Flags().FlagUsages()) + for _, bad := range []string{"key", "token", "secret", "password"} { + assert.NotContains(t, usages, bad, "a flag looks like a credential flag") + } + check := func(label string) { + comparison, err := insurance.DecodeComparison(strings.NewReader(archeraComparisonFixture()), archeraTestOrg, archeraTestPlan, time.Unix(0, 0)) + require.NoError(t, err) + dto := buildArcheraDTO(comparison) + for _, v := range []any{archeraOpts, &archeraOpts, dto, &dto} { + for _, f := range []string{"%v", "%+v", "%#v", "%s"} { + assert.NotContains(t, fmt.Sprintf(f, v), archeraTestKey, label+" "+f) + } + } + } + setupArchera(t, okHandler) + check("before") + _, _, err := runArchera(t, fullEnv()) + require.NoError(t, err) + check("after") +} + +// TestArcheraDTOSanitizesEveryVendorString builds the comparison directly, so +// fields the decoder would reject (enums, UUIDs, terms) still carry control +// characters into the DTO. +func TestArcheraDTOSanitizesEveryVendorString(t *testing.T) { + dirty := func(s string) string { return s + "\x1b[31m\u0085\u009b" } + str := func(s string) *string { v := dirty(s); return &v } + pay := insurance.PaymentOption(dirty("no_upfront")) + entry := func(id string) insurance.OfferEntry { + return insurance.OfferEntry{ + OfferID: dirty(id), CommitmentType: dirty("type" + id), Provider: common.ProviderType(dirty("aws")), + ContractTerm: str("term"), PaymentOption: &pay, Region: str("region"), GuaranteedDisplayName: str("name"), + LeaseMenuItemID: str("lease"), + } + } + c := &insurance.Comparison{ + PlanID: dirty("plan"), + Currency: str("usd"), + Hypotheticals: []insurance.Hypothetical{{ + ContractTerm: str("hterm"), PaymentOption: pay, + LineItems: []insurance.HypotheticalLineItem{{ + LineItemID: dirty("li"), ActualTerm: str("aterm"), ActualPaymentOption: &pay, + ActualCommitmentType: str("atype"), Reason: insurance.TermReason(dirty("exact_match")), + }}, + }}, + Rows: []insurance.ComparisonRow{{LineItemID: dirty("row"), Current: entry("c"), Candidates: []insurance.OfferEntry{entry("k")}}}, + } + var buf bytes.Buffer + dto := buildArcheraDTO(c) + require.NoError(t, renderArcheraJSON(&buf, dto)) + assertArcheraNoControlChars(t, buf.String()) + buf.Reset() + require.NoError(t, renderArcheraTable(&buf, dto)) + assertArcheraNoControlChars(t, buf.String()) + assert.Equal(t, "plan[31m", dto.PlanID) +} diff --git a/cmd/archera_render.go b/cmd/archera_render.go new file mode 100644 index 000000000..15587e51d --- /dev/null +++ b/cmd/archera_render.go @@ -0,0 +1,338 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "math/big" + "strings" + "unicode" + "unicode/utf8" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" +) + +const ( + archeraTitle = "Archera commitment plan comparison (read-only)" + archeraMonthlyBasis = "730-hour monthly rate" + archeraUpfrontBasis = "one-time" + archeraPlanWideNote = "This comparison covers the whole Archera plan. It is not mapped onto CUDly recommendation rows or recommendation CSV output." + archeraCaveat = "A plan comparison is a hypothetical rollup, never a bindable insurance quote, and never a purchase. " + + "An insured target of 100% is a requested target subject to Archera underwriting allowances, not a guarantee. " + + "The API exposes no per-line product support or allowance verdict, so product support and allowance are shown as unknown unless Archera's documentation says otherwise." + archeraPremiumNote = "Totals include the Archera premium. The API reports no currency, so amounts are unlabelled." + archeraMaxFieldSize = 256 + archeraMaxMoneyPrec = 100 +) + +// archeraSanitize strips control characters (ESC, C1 and the rest of +// unicode.IsControl) from a vendor string and caps it at archeraMaxFieldSize +// bytes on a rune boundary, so it is safe to print to a terminal. +func archeraSanitize(s string) string { + var b strings.Builder + for _, r := range s { + if unicode.IsControl(r) { + continue + } + if b.Len()+utf8.RuneLen(r) > archeraMaxFieldSize { + break + } + b.WriteRune(r) + } + return b.String() +} + +func archeraSanitizePtr(s *string) *string { + if s == nil { + return nil + } + v := archeraSanitize(*s) + return &v +} + +// archeraMoney renders an exact decimal using the smallest precision at which +// FloatString round-trips to the same Rat. No float64 and no division. nil is +// unknown and returns nil so JSON gets null. +func archeraMoney(r *big.Rat) *string { + if r == nil { + return nil + } + for p := 0; p <= archeraMaxMoneyPrec; p++ { + s := r.FloatString(p) + if back, ok := new(big.Rat).SetString(s); ok && back.Cmp(r) == 0 { + return &s + } + } + s := r.String() + return &s +} + +type archeraFinancialsDTO struct { + CommitmentCostTotal *string `json:"commitment_cost_total"` + CloudProviderCost *string `json:"cloud_provider_cost"` + Premium *string `json:"premium"` + GrossSavings *string `json:"gross_savings"` + NetSavings *string `json:"net_savings"` + CoveredOnDemandCost *string `json:"covered_on_demand_cost"` +} + +type archeraTotalsDTO struct { + Monthly archeraFinancialsDTO `json:"monthly_rate_730h"` + UpfrontCost *string `json:"upfront_cost_one_time"` +} + +type archeraLineItemDTO struct { + LineItemID string `json:"line_item_id"` + ActualTerm *string `json:"actual_term"` + ActualPaymentOption *string `json:"actual_payment_option"` + ActualCommitmentType *string `json:"actual_commitment_type"` + Reason string `json:"reason"` +} + +type archeraHypotheticalDTO struct { + ContractTerm *string `json:"contract_term"` + PaymentOption string `json:"payment_option"` + Totals archeraTotalsDTO `json:"totals"` + DeltaMonthlyNetSavings *string `json:"delta_monthly_net_savings"` + DeltaMonthlyCommitmentCost *string `json:"delta_monthly_commitment_cost"` + DeltaUpfrontCost *string `json:"delta_upfront_cost"` + LineItems []archeraLineItemDTO `json:"line_items"` +} + +type archeraProductSupportDTO struct { + Status string `json:"status"` + Source string `json:"source,omitempty"` + Evidence string `json:"evidence,omitempty"` +} + +type archeraDeltaDTO struct { + MonthlyNetSavings *string `json:"monthly_net_savings"` + UpfrontCost *string `json:"upfront_cost"` + DiscountRate *string `json:"discount_rate"` + BreakevenDays *string `json:"breakeven_days"` +} + +type archeraOfferDTO struct { + IsCurrent bool `json:"is_current"` + OfferID string `json:"offer_id"` + CommitmentType string `json:"commitment_type"` + Provider string `json:"provider"` + Region *string `json:"region"` + ContractTerm *string `json:"contract_term"` + PaymentOption *string `json:"payment_option"` + LeaseAttached bool `json:"lease_attached"` + ArcheraOfferName *string `json:"archera_offer_name"` + DiscountRate *string `json:"discount_rate"` + BreakevenDays *string `json:"breakeven_days"` + Totals archeraTotalsDTO `json:"totals"` + DeltaVsCurrent archeraDeltaDTO `json:"delta_vs_current"` + ArcheraProductSupport archeraProductSupportDTO `json:"archera_product_support"` +} + +type archeraRowDTO struct { + LineItemID string `json:"line_item_id"` + Current archeraOfferDTO `json:"current"` + Candidates []archeraOfferDTO `json:"candidates"` +} + +// archeraComparisonDTO is the CLI's stable JSON contract. Money values are +// exact decimal strings; null means unknown, never zero. +type archeraComparisonDTO struct { + Title string `json:"title"` + PlanID string `json:"plan_id"` + FetchedAt string `json:"fetched_at"` + Currency *string `json:"currency"` + PremiumIncluded bool `json:"premium_included"` + MonthlyBasis string `json:"monthly_basis"` + UpfrontBasis string `json:"upfront_basis"` + PlanWide string `json:"plan_wide_note"` + Caveat string `json:"caveat"` + Current archeraTotalsDTO `json:"current_totals"` + Hypotheticals []archeraHypotheticalDTO `json:"hypothetical_totals"` + Rows []archeraRowDTO `json:"rows"` + NonGatingDisclosure string `json:"non_gating_disclosure"` + SponsorshipDisclosure string `json:"sponsorship_disclosure"` +} + +func archeraFinancials(f insurance.Financials) archeraFinancialsDTO { + return archeraFinancialsDTO{ + CommitmentCostTotal: archeraMoney(f.CommitmentCostTotal), + CloudProviderCost: archeraMoney(f.CloudProviderCost), + Premium: archeraMoney(f.Premium), + GrossSavings: archeraMoney(f.GrossSavings), + NetSavings: archeraMoney(f.NetSavings), + CoveredOnDemandCost: archeraMoney(f.CoveredOnDemandCost), + } +} + +func archeraTotals(t insurance.Totals) archeraTotalsDTO { + return archeraTotalsDTO{Monthly: archeraFinancials(t.Monthly), UpfrontCost: archeraMoney(t.UpfrontCost)} +} + +func archeraPayment(p *insurance.PaymentOption) *string { + if p == nil { + return nil + } + s := archeraSanitize(string(*p)) + return &s +} + +func archeraOffer(e insurance.OfferEntry) archeraOfferDTO { + support := insurance.AssessProductSupport(e.Provider, e.CommitmentType) + ps := archeraProductSupportDTO{Status: string(support.Status)} + if support.Status == insurance.ProductSupportSupported { + ps.Source, ps.Evidence = support.Source, support.Evidence + } + return archeraOfferDTO{ + IsCurrent: e.IsCurrent, + OfferID: archeraSanitize(e.OfferID), + CommitmentType: archeraSanitize(e.CommitmentType), + Provider: archeraSanitize(string(e.Provider)), + Region: archeraSanitizePtr(e.Region), + ContractTerm: archeraSanitizePtr(e.ContractTerm), + PaymentOption: archeraPayment(e.PaymentOption), + LeaseAttached: e.LeaseBacked(), + ArcheraOfferName: archeraSanitizePtr(e.GuaranteedDisplayName), + DiscountRate: archeraMoney(e.DiscountRate), + BreakevenDays: archeraMoney(e.BreakevenDays), + Totals: archeraTotalsDTO{Monthly: archeraFinancials(e.Monthly), UpfrontCost: archeraMoney(e.UpfrontCost)}, + DeltaVsCurrent: archeraDeltaDTO{ + MonthlyNetSavings: archeraMoney(e.Delta.MonthlyNetSavings), + UpfrontCost: archeraMoney(e.Delta.UpfrontCost), + DiscountRate: archeraMoney(e.Delta.DiscountRate), + BreakevenDays: archeraMoney(e.Delta.BreakevenDays), + }, + ArcheraProductSupport: ps, + } +} + +func buildArcheraDTO(c *insurance.Comparison) archeraComparisonDTO { + dto := archeraComparisonDTO{ + Title: archeraTitle, + PlanID: archeraSanitize(c.PlanID), + FetchedAt: c.FetchedAt.UTC().Format("2006-01-02T15:04:05Z"), + Currency: archeraSanitizePtr(c.Currency), + PremiumIncluded: true, + MonthlyBasis: archeraMonthlyBasis, + UpfrontBasis: archeraUpfrontBasis, + PlanWide: archeraPlanWideNote, + Caveat: archeraCaveat, + Current: archeraTotals(c.Current), + Hypotheticals: []archeraHypotheticalDTO{}, + Rows: []archeraRowDTO{}, + NonGatingDisclosure: common.ArcheraNonGatingDisclosure, + SponsorshipDisclosure: common.ArcheraSponsorshipDisclosure, + } + for i := range c.Hypotheticals { + h := &c.Hypotheticals[i] + hd := archeraHypotheticalDTO{ + ContractTerm: archeraSanitizePtr(h.ContractTerm), + PaymentOption: archeraSanitize(string(h.PaymentOption)), + Totals: archeraTotals(h.Totals), + DeltaMonthlyNetSavings: archeraMoney(h.DeltaMonthlyNetSavings), + DeltaMonthlyCommitmentCost: archeraMoney(h.DeltaMonthlyCommitmentCost), + DeltaUpfrontCost: archeraMoney(h.DeltaUpfrontCost), + LineItems: []archeraLineItemDTO{}, + } + for _, li := range h.LineItems { + hd.LineItems = append(hd.LineItems, archeraLineItemDTO{ + LineItemID: archeraSanitize(li.LineItemID), + ActualTerm: archeraSanitizePtr(li.ActualTerm), + ActualPaymentOption: archeraPayment(li.ActualPaymentOption), + ActualCommitmentType: archeraSanitizePtr(li.ActualCommitmentType), + Reason: archeraSanitize(string(li.Reason)), + }) + } + dto.Hypotheticals = append(dto.Hypotheticals, hd) + } + for i := range c.Rows { + r := &c.Rows[i] + rd := archeraRowDTO{LineItemID: archeraSanitize(r.LineItemID), Current: archeraOffer(r.Current), Candidates: []archeraOfferDTO{}} + for j := range r.Candidates { + rd.Candidates = append(rd.Candidates, archeraOffer(r.Candidates[j])) + } + dto.Rows = append(dto.Rows, rd) + } + return dto +} + +func renderArcheraJSON(w io.Writer, dto archeraComparisonDTO) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(dto) +} + +func archeraStr(s *string) string { + if s == nil { + return "unknown" + } + return *s +} + +func archeraTotalsLine(t archeraTotalsDTO) string { + m := t.Monthly + return fmt.Sprintf("commitment cost %s, cloud provider cost %s, premium %s, gross savings %s, net savings %s, covered on-demand %s (%s); upfront %s (%s)", + archeraStr(m.CommitmentCostTotal), archeraStr(m.CloudProviderCost), archeraStr(m.Premium), archeraStr(m.GrossSavings), + archeraStr(m.NetSavings), archeraStr(m.CoveredOnDemandCost), archeraMonthlyBasis, archeraStr(t.UpfrontCost), archeraUpfrontBasis) +} + +func renderArcheraTable(w io.Writer, d archeraComparisonDTO) error { + var b strings.Builder + p := func(format string, a ...any) { fmt.Fprintf(&b, format+"\n", a...) } + p("%s", d.Title) + p("Plan: %s Fetched: %s", d.PlanID, d.FetchedAt) + p("%s", archeraPremiumNote) + p("%s", d.PlanWide) + p("%s", d.Caveat) + p("") + p("Current totals: %s", archeraTotalsLine(d.Current)) + for i := range d.Hypotheticals { + h := &d.Hypotheticals[i] + p("") + p("Hypothetical %d: term %s, payment %s", i+1, archeraStr(h.ContractTerm), h.PaymentOption) + p(" totals: %s", archeraTotalsLine(h.Totals)) + p(" delta vs current: monthly net savings %s, monthly commitment cost %s, upfront %s", + archeraStr(h.DeltaMonthlyNetSavings), archeraStr(h.DeltaMonthlyCommitmentCost), archeraStr(h.DeltaUpfrontCost)) + for _, li := range h.LineItems { + p(" line item %s: term %s, payment %s, type %s, reason %s", li.LineItemID, archeraStr(li.ActualTerm), + archeraStr(li.ActualPaymentOption), archeraStr(li.ActualCommitmentType), li.Reason) + } + } + for i := range d.Rows { + r := &d.Rows[i] + p("") + p("Line item %s", r.LineItemID) + writeArcheraOffer(p, "current", r.Current) + for j := range r.Candidates { + writeArcheraOffer(p, "candidate", r.Candidates[j]) + } + } + p("") + p("%s", d.NonGatingDisclosure) + p("%s", d.SponsorshipDisclosure) + _, err := io.WriteString(w, b.String()) + return err +} + +func writeArcheraOffer(p func(string, ...any), kind string, o archeraOfferDTO) { + lease := "" + if o.LeaseAttached { + lease = ", lease attached" + } + p(" %s offer %s: %s %s, region %s, term %s, payment %s%s", kind, o.OfferID, o.Provider, o.CommitmentType, + archeraStr(o.Region), archeraStr(o.ContractTerm), archeraStr(o.PaymentOption), lease) + if o.ArcheraOfferName != nil { + p(" Archera offer name: %s", *o.ArcheraOfferName) + } + p(" discount rate %s (0-1 basis), breakeven days %s", archeraStr(o.DiscountRate), archeraStr(o.BreakevenDays)) + p(" %s", archeraTotalsLine(o.Totals)) + p(" delta vs current: monthly net savings %s, upfront %s, discount rate %s, breakeven days %s", + archeraStr(o.DeltaVsCurrent.MonthlyNetSavings), archeraStr(o.DeltaVsCurrent.UpfrontCost), + archeraStr(o.DeltaVsCurrent.DiscountRate), archeraStr(o.DeltaVsCurrent.BreakevenDays)) + if o.ArcheraProductSupport.Status == string(insurance.ProductSupportSupported) { + p(" Archera product support: supported (%s; %s)", o.ArcheraProductSupport.Source, o.ArcheraProductSupport.Evidence) + } else { + p(" Archera product support: unknown") + } +} diff --git a/cmd/testdata/archera_comparison.json b/cmd/testdata/archera_comparison.json new file mode 100644 index 000000000..0d6a6bbd7 --- /dev/null +++ b/cmd/testdata/archera_comparison.json @@ -0,0 +1 @@ +{"current_totals":{"commitment_financials_monthly_rate":{"commitment_cost":{"total":101,"breakdown":{"cloud_provider_cost":{"total":102},"archera_premium":103.1234567890123456789}},"commitment_savings":{"net":105,"gross":104},"covered_ondemand_cost":106},"commitment_upfront_cost":107},"hypothetical_totals":[{"contract_term":"one_year_gris","payment_option":"no_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":201,"breakdown":{"cloud_provider_cost":{"total":202},"archera_premium":203}},"commitment_savings":{"net":205,"gross":204},"covered_ondemand_cost":206},"commitment_upfront_cost":207,"delta_vs_current":{"monthly_net_savings":208,"monthly_commitment_cost":209,"upfront_cost":210},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b1\u001b[31m\u0085","actual_term":"six_month_gris","actual_payment_option":"partial_upfront","actual_commitment_type":"aws/AmazonEC2\u001b[31m\u0085","actual_term_reason":"fallback_closest_shorter"}]},{"contract_term":null,"payment_option":"all_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":601,"breakdown":{"cloud_provider_cost":{"total":602},"archera_premium":603}},"commitment_savings":{"net":605,"gross":604},"covered_ondemand_cost":606},"commitment_upfront_cost":607,"delta_vs_current":{"monthly_net_savings":608,"monthly_commitment_cost":609,"upfront_cost":610},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b2","actual_term":null,"actual_payment_option":null,"actual_commitment_type":null,"actual_term_reason":"no_alternative"}]}],"data":[{"line_item_id":"70000000-0000-4000-8000-0000000000c1\u001b[31m\u0085","current":{"is_current":true,"offer_id":"30000000-0000-4000-8000-000000000001\u001b[31m\u0085","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonEC2","region":"us-east-1\u001b[31m\u0085","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonEC2","contract_term":null,"payment_option":null,"discount_rate":0.31,"breakeven_days":31,"commitment_upfront_cost":307,"commitment_financials_monthly_rate":{"commitment_cost":{"total":301,"breakdown":{"cloud_provider_cost":{"total":302},"archera_premium":303}},"commitment_savings":{"net":305,"gross":304},"covered_ondemand_cost":306},"delta_vs_current":{"monthly_net_savings":308,"upfront_cost":309,"discount_rate":0.32,"breakeven_days":32}},"candidates":[{"is_current":false,"offer_id":"40000000-0000-4000-8000-000000000002","offer_org_id":"public","offer":{"provider":"aws","type":"aws/savingsplan/Compute","region":"eu-west-1\u001b[31m\u0085","guaranteed_display_name":"Name\u001b[31m\u0085FourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},"lease_menu_item_id":"40000000-0000-4000-8000-0000000000a1","selected_amount":3,"commitment_type":"aws/savingsplan/Compute","contract_term":"one_year_gris","payment_option":"no_upfront","discount_rate":0.41,"breakeven_days":41,"commitment_upfront_cost":407,"commitment_financials_monthly_rate":{"commitment_cost":{"total":401,"breakdown":{"cloud_provider_cost":{"total":402},"archera_premium":403}},"commitment_savings":{"net":405,"gross":404},"covered_ondemand_cost":406},"delta_vs_current":{"monthly_net_savings":408,"upfront_cost":409,"discount_rate":0.42,"breakeven_days":42}},{"is_current":false,"offer_id":"50000000-0000-4000-8000-000000000003","offer_org_id":"public","offer":{"provider":"aws","type":"aws/Unlisted\u001b[31m\u0085","region":"ap-south-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/Unlisted\u001b[31m\u0085","contract_term":"six_month_gris","payment_option":"all_upfront","discount_rate":0.51,"breakeven_days":null,"commitment_upfront_cost":507,"commitment_financials_monthly_rate":{"commitment_cost":{"total":501,"breakdown":{"cloud_provider_cost":{"total":502},"archera_premium":503}},"commitment_savings":{"net":null,"gross":504},"covered_ondemand_cost":506},"delta_vs_current":{"monthly_net_savings":508,"upfront_cost":509,"discount_rate":0.52,"breakeven_days":null}}]}]} diff --git a/cmd/testdata/archera_json.golden b/cmd/testdata/archera_json.golden new file mode 100644 index 000000000..28e8221f6 --- /dev/null +++ b/cmd/testdata/archera_json.golden @@ -0,0 +1,190 @@ +{ + "title": "Archera commitment plan comparison (read-only)", + "plan_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "fetched_at": "", + "currency": null, + "premium_included": true, + "monthly_basis": "730-hour monthly rate", + "upfront_basis": "one-time", + "plan_wide_note": "This comparison covers the whole Archera plan. It is not mapped onto CUDly recommendation rows or recommendation CSV output.", + "caveat": "A plan comparison is a hypothetical rollup, never a bindable insurance quote, and never a purchase. An insured target of 100% is a requested target subject to Archera underwriting allowances, not a guarantee. The API exposes no per-line product support or allowance verdict, so product support and allowance are shown as unknown unless Archera's documentation says otherwise.", + "current_totals": { + "monthly_rate_730h": { + "commitment_cost_total": "101", + "cloud_provider_cost": "102", + "premium": "103.1234567890123456789", + "gross_savings": "104", + "net_savings": "105", + "covered_on_demand_cost": "106" + }, + "upfront_cost_one_time": "107" + }, + "hypothetical_totals": [ + { + "contract_term": "one_year_gris", + "payment_option": "no_upfront", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "201", + "cloud_provider_cost": "202", + "premium": "203", + "gross_savings": "204", + "net_savings": "205", + "covered_on_demand_cost": "206" + }, + "upfront_cost_one_time": "207" + }, + "delta_monthly_net_savings": "208", + "delta_monthly_commitment_cost": "209", + "delta_upfront_cost": "210", + "line_items": [ + { + "line_item_id": "60000000-0000-4000-8000-0000000000b1[31m", + "actual_term": "six_month_gris", + "actual_payment_option": "partial_upfront", + "actual_commitment_type": "aws/AmazonEC2[31m", + "reason": "fallback_closest_shorter" + } + ] + }, + { + "contract_term": null, + "payment_option": "all_upfront", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "601", + "cloud_provider_cost": "602", + "premium": "603", + "gross_savings": "604", + "net_savings": "605", + "covered_on_demand_cost": "606" + }, + "upfront_cost_one_time": "607" + }, + "delta_monthly_net_savings": "608", + "delta_monthly_commitment_cost": "609", + "delta_upfront_cost": "610", + "line_items": [ + { + "line_item_id": "60000000-0000-4000-8000-0000000000b2", + "actual_term": null, + "actual_payment_option": null, + "actual_commitment_type": null, + "reason": "no_alternative" + } + ] + } + ], + "rows": [ + { + "line_item_id": "70000000-0000-4000-8000-0000000000c1[31m", + "current": { + "is_current": true, + "offer_id": "30000000-0000-4000-8000-000000000001[31m", + "commitment_type": "aws/AmazonEC2", + "provider": "aws", + "region": "us-east-1[31m", + "contract_term": null, + "payment_option": null, + "lease_attached": false, + "archera_offer_name": null, + "discount_rate": "0.31", + "breakeven_days": "31", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "301", + "cloud_provider_cost": "302", + "premium": "303", + "gross_savings": "304", + "net_savings": "305", + "covered_on_demand_cost": "306" + }, + "upfront_cost_one_time": "307" + }, + "delta_vs_current": { + "monthly_net_savings": "308", + "upfront_cost": "309", + "discount_rate": "0.32", + "breakeven_days": "32" + }, + "archera_product_support": { + "status": "supported", + "source": "https://docs.archera.ai/help-center/getting-started/supported-reservable-services", + "evidence": "AWS Reserved Instances table, service \"EC2\": Guaranteed terms \"30 day, 1 year\". Mapping of API type \"aws/AmazonEC2\" to page service \"EC2\" is inferred from the schema example; not stated by Archera." + } + }, + "candidates": [ + { + "is_current": false, + "offer_id": "40000000-0000-4000-8000-000000000002", + "commitment_type": "aws/savingsplan/Compute", + "provider": "aws", + "region": "eu-west-1[31m", + "contract_term": "one_year_gris", + "payment_option": "no_upfront", + "lease_attached": true, + "archera_offer_name": "Name[31mFourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN", + "discount_rate": "0.41", + "breakeven_days": "41", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "401", + "cloud_provider_cost": "402", + "premium": "403", + "gross_savings": "404", + "net_savings": "405", + "covered_on_demand_cost": "406" + }, + "upfront_cost_one_time": "407" + }, + "delta_vs_current": { + "monthly_net_savings": "408", + "upfront_cost": "409", + "discount_rate": "0.42", + "breakeven_days": "42" + }, + "archera_product_support": { + "status": "supported", + "source": "https://docs.archera.ai/help-center/getting-started/supported-reservable-services", + "evidence": "AWS Savings Plans table, \"Compute Savings Plan\": Guaranteed terms \"30 day, 1 year\". Mapping of API type \"aws/savingsplan/Compute\" to page service \"Compute Savings Plan\" is inferred from the schema example; not stated by Archera." + } + }, + { + "is_current": false, + "offer_id": "50000000-0000-4000-8000-000000000003", + "commitment_type": "aws/Unlisted[31m", + "provider": "aws", + "region": "ap-south-1", + "contract_term": "six_month_gris", + "payment_option": "all_upfront", + "lease_attached": false, + "archera_offer_name": null, + "discount_rate": "0.51", + "breakeven_days": null, + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "501", + "cloud_provider_cost": "502", + "premium": "503", + "gross_savings": "504", + "net_savings": null, + "covered_on_demand_cost": "506" + }, + "upfront_cost_one_time": "507" + }, + "delta_vs_current": { + "monthly_net_savings": "508", + "upfront_cost": "509", + "discount_rate": "0.52", + "breakeven_days": null + }, + "archera_product_support": { + "status": "unknown" + } + } + ] + } + ], + "non_gating_disclosure": "This is entirely optional. CUDly's purchase and management features work fully without Archera.", + "sponsorship_disclosure": "For full disclosure, Archera sponsors CUDly's Open Source development from a fraction of their insurance premiums." +} diff --git a/cmd/testdata/archera_table.golden b/cmd/testdata/archera_table.golden new file mode 100644 index 000000000..4b83cd6c3 --- /dev/null +++ b/cmd/testdata/archera_table.golden @@ -0,0 +1,38 @@ +Archera commitment plan comparison (read-only) +Plan: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb Fetched: +Totals include the Archera premium. The API reports no currency, so amounts are unlabelled. +This comparison covers the whole Archera plan. It is not mapped onto CUDly recommendation rows or recommendation CSV output. +A plan comparison is a hypothetical rollup, never a bindable insurance quote, and never a purchase. An insured target of 100% is a requested target subject to Archera underwriting allowances, not a guarantee. The API exposes no per-line product support or allowance verdict, so product support and allowance are shown as unknown unless Archera's documentation says otherwise. + +Current totals: commitment cost 101, cloud provider cost 102, premium 103.1234567890123456789, gross savings 104, net savings 105, covered on-demand 106 (730-hour monthly rate); upfront 107 (one-time) + +Hypothetical 1: term one_year_gris, payment no_upfront + totals: commitment cost 201, cloud provider cost 202, premium 203, gross savings 204, net savings 205, covered on-demand 206 (730-hour monthly rate); upfront 207 (one-time) + delta vs current: monthly net savings 208, monthly commitment cost 209, upfront 210 + line item 60000000-0000-4000-8000-0000000000b1[31m: term six_month_gris, payment partial_upfront, type aws/AmazonEC2[31m, reason fallback_closest_shorter + +Hypothetical 2: term unknown, payment all_upfront + totals: commitment cost 601, cloud provider cost 602, premium 603, gross savings 604, net savings 605, covered on-demand 606 (730-hour monthly rate); upfront 607 (one-time) + delta vs current: monthly net savings 608, monthly commitment cost 609, upfront 610 + line item 60000000-0000-4000-8000-0000000000b2: term unknown, payment unknown, type unknown, reason no_alternative + +Line item 70000000-0000-4000-8000-0000000000c1[31m + current offer 30000000-0000-4000-8000-000000000001[31m: aws aws/AmazonEC2, region us-east-1[31m, term unknown, payment unknown + discount rate 0.31 (0-1 basis), breakeven days 31 + commitment cost 301, cloud provider cost 302, premium 303, gross savings 304, net savings 305, covered on-demand 306 (730-hour monthly rate); upfront 307 (one-time) + delta vs current: monthly net savings 308, upfront 309, discount rate 0.32, breakeven days 32 + Archera product support: supported (https://docs.archera.ai/help-center/getting-started/supported-reservable-services; AWS Reserved Instances table, service "EC2": Guaranteed terms "30 day, 1 year". Mapping of API type "aws/AmazonEC2" to page service "EC2" is inferred from the schema example; not stated by Archera.) + candidate offer 40000000-0000-4000-8000-000000000002: aws aws/savingsplan/Compute, region eu-west-1[31m, term one_year_gris, payment no_upfront, lease attached + Archera offer name: Name[31mFourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + discount rate 0.41 (0-1 basis), breakeven days 41 + commitment cost 401, cloud provider cost 402, premium 403, gross savings 404, net savings 405, covered on-demand 406 (730-hour monthly rate); upfront 407 (one-time) + delta vs current: monthly net savings 408, upfront 409, discount rate 0.42, breakeven days 42 + Archera product support: supported (https://docs.archera.ai/help-center/getting-started/supported-reservable-services; AWS Savings Plans table, "Compute Savings Plan": Guaranteed terms "30 day, 1 year". Mapping of API type "aws/savingsplan/Compute" to page service "Compute Savings Plan" is inferred from the schema example; not stated by Archera.) + candidate offer 50000000-0000-4000-8000-000000000003: aws aws/Unlisted[31m, region ap-south-1, term six_month_gris, payment all_upfront + discount rate 0.51 (0-1 basis), breakeven days unknown + commitment cost 501, cloud provider cost 502, premium 503, gross savings 504, net savings unknown, covered on-demand 506 (730-hour monthly rate); upfront 507 (one-time) + delta vs current: monthly net savings 508, upfront 509, discount rate 0.52, breakeven days unknown + Archera product support: unknown + +This is entirely optional. CUDly's purchase and management features work fully without Archera. +For full disclosure, Archera sponsors CUDly's Open Source development from a fraction of their insurance premiums. diff --git a/docs/cli/archera-comparison.md b/docs/cli/archera-comparison.md new file mode 100644 index 000000000..eb087ff00 --- /dev/null +++ b/docs/cli/archera-comparison.md @@ -0,0 +1,51 @@ +# Archera plan comparison + +`ri-helper archera-comparison` prints a read-only comparison for one Archera +commitment plan. It never purchases, binds or submits anything, it is +independent of `--purchase`, and it makes no Archera request unless you run it. + +## Configuration + +The feature is off until configured. There is no flag for the API key. + +| Setting | Source | Secret | +|---------|--------|--------| +| `ARCHERA_API_KEY` | environment only | yes | +| `ARCHERA_ORG_ID` | `--org-id` or environment | no | +| `ARCHERA_PLAN_ID` | `--plan-id` or environment | no | + +The org and plan IDs are Archera UUIDs, not CUDly recommendation IDs. If a +setting is missing, the command exits with status 1 and names the missing +setting. It never prints a value. + +## Usage + +```bash +export ARCHERA_API_KEY=... # keep out of shell history and CI logs +export ARCHERA_ORG_ID= +export ARCHERA_PLAN_ID= +ri-helper archera-comparison # table +ri-helper archera-comparison --format json # one JSON document on stdout +``` + +Errors go to stderr and the exit status is 1 for every failure. A vendor error +shows the sanitized HTTP status and message, plus `Retry-After` when given +(capped at 24 hours). The command does not retry. + +## Reading the output + +- Totals include the Archera premium. The API reports no currency, so amounts + are unlabelled. +- Monthly figures are 730-hour monthly rates. Upfront figures are one-time and + are never added to monthly figures. +- Unknown values print `unknown` in the table and `null` in JSON, never `0`. +- Money is printed as an exact decimal. `discount_rate` is shown as sent + (0-1 basis). +- The comparison is a hypothetical rollup for the whole plan. It is not a + bindable quote, not a guarantee, and not mapped onto recommendation rows or + CSV output. +- Product support is `supported` only when Archera's documentation lists the + commitment type; otherwise `unknown`. +- Archera sponsors CUDly development. The command prints the same two + disclosures as the post-purchase message, in both formats + (`non_gating_disclosure` and `sponsorship_disclosure` in JSON).