diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8b41322..2d1baaf5c 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 - A `--purchase` run now stops, and exits 1, when an audit record cannot be 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..a70d35627 --- /dev/null +++ b/cmd/archera_comparison.go @@ -0,0 +1,273 @@ +package main + +import ( + "errors" + "fmt" + "math" + "net/http" + "os" + "regexp" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/spf13/cobra" + "golang.org/x/text/unicode/norm" +) + +// 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 { + return archeraSafeError(err, key) + } + 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 +} + +var archeraQuotedEscapeRE = regexp.MustCompile(`\\(?:u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|x[0-9a-fA-F]{2})`) + +// archeraWithheld replaces vendor text in which the key is still readable +// after masking. +const archeraWithheld = "[message withheld: contained the API key]" + +// archeraSafeError renders a client error as bounded, terminal-safe text. +// HTTP errors keep their status and Retry-After. Decode errors echo the +// rejected vendor value. Both are vendor-controlled, so the order is: clean +// the text (controls, format and separator characters removed, which would +// otherwise let a key split by U+200B slip past an exact-match mask), mask the +// trimmed key, check the full text for a key hidden behind characters the +// clean leaves in place (combining marks, variation selectors, fillers, +// invalid bytes) and withhold it if one is found, then cap. +func archeraSafeError(err error, key string) error { + key = strings.TrimSpace(key) + msg, suffix := err.Error(), "" + var httpErr *insurance.HTTPError + isHTTP := errors.As(err, &httpErr) + if isHTTP && httpErr.RetryAfter > 0 { + suffix = fmt.Sprintf(" (retry after %s; not retried automatically)", httpErr.RetryAfter) + } + // pkg decode errors quote the rejected value with %q, which spells control + // and format characters as literal \uXXXX text; drop those escapes too so + // they cannot split the key. + msg = archeraQuotedEscapeRE.ReplaceAllString(archeraSanitizeN(msg+suffix, math.MaxInt), "") + if key != "" { + msg = strings.ReplaceAll(msg, key, "[redacted]") + if archeraKeyReadable(msg, key) { + msg = archeraWithheld + if isHTTP { + msg = fmt.Sprintf("archera API returned HTTP %d: %s%s", httpErr.StatusCode, archeraWithheld, suffix) + } + } + } + return errors.New(archeraSanitizeN(msg, archeraMaxErrorSize)) +} + +// archeraFragmentLen is the longest key fragment that may remain readable in +// vendor text. The pkg cuts HTTP messages at 200 bytes before the CLI sees +// them, so a split key can arrive as a prefix rather than whole. +const archeraFragmentLen = 8 + +// archeraMaxGap is how many other runes may sit at one irregular spot between +// two consecutive key characters before the text stops reading as the key. +const archeraMaxGap = 2 + +// archeraMaxSep is the longest separator, in key-alphabet runes, that may be +// repeated between every pair of key characters (such as "-- -" or "0x0"). +const archeraMaxSep = 16 + +// archeraCheckSlack is how far past the displayed cap the readability check +// looks, so a key cut by the cap is still seen whole. It also bounds the work: +// the check never reads more than archeraMaxErrorSize + archeraCheckSlack bytes. +const archeraCheckSlack = archeraFragmentLen * (archeraMaxSep + 1) * utf8.UTFMax + +// archeraFold maps text to the form a reader would compare: NFKD (fullwidth +// to ASCII, accents split off as droppable marks) then lower case. +func archeraFold(s string) string { return strings.ToLower(norm.NFKD.String(s)) } + +// archeraKeyReadable reports whether the displayable part of msg shows +// min(len(key), archeraFragmentLen) consecutive key characters, either +// separated by one repeated separator or in order with at most archeraMaxGap +// other characters at each spot. Both sides are folded, and msg is reduced to +// the runes that occur in the key, so any inserted character class drops out. +// The key's own non-graphic and space runes are ignored, as the reader does. +// Look-alike letters from other scripts are not handled. +func archeraKeyReadable(msg, key string) bool { + var ks []rune + alphabet := map[rune]bool{} + for _, r := range archeraFold(key) { + if unicode.IsGraphic(r) && !unicode.IsSpace(r) { + ks = append(ks, r) + alphabet[r] = true + } + } + if len(ks) == 0 { + return true + } + var ms []rune + for _, r := range archeraFold(archeraSanitizeN(msg, archeraMaxErrorSize+archeraCheckSlack)) { + if alphabet[r] { + ms = append(ms, r) + } + } + n := min(len(ks), archeraFragmentLen) + for i := 0; i+n <= len(ks); i++ { + run := ks[i : i+n] + if archeraHasRepeatedSeparatorRun(ms, run) || archeraHasGappedRun(ms, run) { + return true + } + } + return false +} + +// archeraHasRepeatedSeparatorRun reports whether ms spells run with the same +// separator (possibly empty, up to archeraMaxSep runes) between every pair of +// neighbors. The separator is read off the first pair. +func archeraHasRepeatedSeparatorRun(ms, run []rune) bool { + if len(run) < 2 { + return false + } + for i, r := range ms { + if r != run[0] { + continue + } + for j := i + 1; j < len(ms) && j-i-1 <= archeraMaxSep; j++ { + if ms[j] == run[1] && spellsWithSeparator(ms[i:], run, ms[i+1:j]) { + return true + } + } + } + return false +} + +func spellsWithSeparator(ms, run, sep []rune) bool { + pos := 0 + for k, r := range run { + if k > 0 { + if pos+len(sep) > len(ms) || !slices.Equal(ms[pos:pos+len(sep)], sep) { + return false + } + pos += len(sep) + } + if pos >= len(ms) || ms[pos] != r { + return false + } + pos++ + } + return true +} + +// archeraHasGappedRun reports whether run occurs in ms as a subsequence with +// at most archeraMaxGap skipped runes between neighbors. Gap 0 is a plain +// substring match. It is O(len(ms)*len(run)). +func archeraHasGappedRun(ms, run []rune) bool { + reach := make([]bool, len(ms)) + for i, r := range ms { + reach[i] = r == run[0] + } + for _, want := range run[1:] { + next := make([]bool, len(ms)) + for j, r := range ms { + if r != want { + continue + } + for g := 0; g <= archeraMaxGap && j-1-g >= 0; g++ { + if reach[j-1-g] { + next[j] = true + break + } + } + } + reach = next + } + return slices.Contains(reach, true) +} diff --git a/cmd/archera_comparison_key_test.go b/cmd/archera_comparison_key_test.go new file mode 100644 index 000000000..586af8194 --- /dev/null +++ b/cmd/archera_comparison_key_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A 41-character synthetic key. The pinned pkg cuts HTTP error messages at 200 +// bytes before the CLI sees them, and a key this long split by an invisible +// character spans that cut, which the 23-byte archeraTestKey never does. +const archeraLongKey = "PRB-sYnTh3t1c-QZ9x-7Kd2-mW8v-Lp4n-Ht6r-00" + +// archeraInvisibleSeparators are characters a terminal shows as nothing or as +// a blank, including ones outside every Unicode category list (unassigned +// default-ignorables) and U+2800. +var archeraInvisibleSeparators = map[string]string{ + "zwsp": "\u200b", "word joiner": "\u2060", "vs16": "\ufe0f", "vs17": "\U000e0100", + "cgj": "\u034f", "acute": "\u0301", "enclosing circle": "\u20dd", + "hangul choseong filler": "\u115f", "hangul jungseong filler": "\u1160", + "hangul filler": "\u3164", "halfwidth hangul filler": "\uffa0", + "space": " ", "nbsp": "\u00a0", "ideographic space": "\u3000", "replacement char": "\ufffd", + "khmer inherent vowel": "\u17b4", "mongolian vowel separator": "\u180e", + "tag letter": "\U000e0041", "unassigned fff0": "\ufff0", "unassigned 2065": "\u2065", + "unassigned e0002": "\U000e0002", "braille blank": "\u2800", "zwnj": "\u200c", + "soft hyphen": "\u00ad", "lri": "\u2066", "literal escape": `\u200b`, + "invalid utf-8": "\xff", "unassigned fff8": "\ufff8", "unassigned e001f": "\U000e001f", + "unassigned e0003": "\U000e0003", + "triple braille blank": "\u2800\u2800\u2800", "triple unassigned 2065": "\u2065\u2065\u2065", +} + +// archeraKeyAlphabetSeparators are characters that occur in archeraLongKey +// itself, so an allow-list skeleton keeps them between the key characters. +var archeraKeyAlphabetSeparators = []string{"-", "x", "0", "--", "---", "0x0", "-- -", "-x0-9"} + +// archeraFullwidth maps printable ASCII to its fullwidth form. +func archeraFullwidth(s string) string { + return strings.Map(func(r rune) rune { + if r >= 0x21 && r <= 0x7e { + return r + 0xfee0 + } + return r + }, s) +} + +// archeraReadableKeyFragment reports a run of 8+ key characters readable in +// out, ignoring case and fullwidth forms and everything but ASCII letters, +// digits and '-'. 8 is the command's own threshold. +func archeraReadableKeyFragment(out string) string { + var b strings.Builder + for _, r := range out { + if r >= 0xff01 && r <= 0xff5e { + r -= 0xfee0 + } + if r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' { + b.WriteRune(r) + } + } + s := strings.ToLower(b.String()) + key := strings.ToLower(archeraLongKey) + for i := 0; i+8 <= len(key); i++ { + if frag := key[i : i+8]; strings.Contains(s, frag) { + return frag + } + } + return "" +} + +// forArcheraErrorPaths drives every error path with msg and calls check with +// each result. pads are the padding lengths swept across the pkg's 200-byte +// HTTP cut. +func forArcheraErrorPaths(t *testing.T, echo string, pads []int, check func(path, got string)) { + t.Helper() + env := map[string]string{archeraEnvAPIKey: " " + archeraLongKey + "\t", archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} + done := func(path string, out, errOut string, err error) { + t.Helper() + require.Error(t, err, path) + check(path, out+errOut+err.Error()) + } + for _, pad := range pads { + msg := "bad key " + strings.Repeat("p", pad) + echo + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "7") + w.WriteHeader(401) + b, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(b) + }) + out, errOut, err := runArchera(t, env) + done(fmt.Sprintf("http pad %d", pad), out, errOut, err) + } + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, env) + done("decode", out, errOut, err) + msg := "bad key " + echo + for kind, wrapped := range map[string]error{ + "transport": errors.New(msg), + "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), + "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), + } { + setupArchera(t, okHandler) + archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} + out, errOut, err := runArchera(t, env) + done(kind, out, errOut, err) + } +} + +func TestArcheraLongKeyNeverReadableOnAnyPath(t *testing.T) { + require.Len(t, archeraLongKey, 41) + var pads []int + for pad := 0; pad <= 199; pad += 9 { + pads = append(pads, pad) + } + for name, sep := range archeraInvisibleSeparators { + for shape, echo := range map[string]string{ + "every": archeraSplitEverywhere(archeraLongKey, sep), + "once": archeraLongKey[:7] + sep + archeraLongKey[7:], + } { + forArcheraErrorPaths(t, echo, pads, func(path, got string) { + require.Empty(t, archeraReadableKeyFragment(got), "%s %s %s: %q", path, name, shape, got) + }) + } + } +} + +// Case-changed and fullwidth echoes are the key to a reader, and so are keys +// spaced out by characters taken from the key itself. +func TestArcheraLongKeyVisibleDisguisesAreWithheld(t *testing.T) { + echoes := map[string]string{ + "lower": strings.ToLower(archeraLongKey), + "upper": strings.ToUpper(archeraLongKey), + "fullwidth": archeraFullwidth(archeraLongKey), + } + for _, sep := range archeraKeyAlphabetSeparators { + echoes["sep "+sep] = archeraSplitEverywhere(archeraLongKey, sep) + echoes["fullwidth sep "+sep] = archeraSplitEverywhere(archeraFullwidth(archeraLongKey), sep) + } + for name, echo := range echoes { + forArcheraErrorPaths(t, echo, []int{0}, func(path, got string) { + assert.Contains(t, got, archeraWithheld, "%s %s: %q", path, name, got) + }) + } +} + +// Exactly 8 consecutive key characters are withheld, 7 are shown; the first +// and last 8 count as much as any other. +func TestArcheraKeyFragmentBoundary(t *testing.T) { + spaced := func(frag string) error { + return archeraSafeError(errors.New("A: "+archeraSplitEverywhere(frag, "\u2800")), archeraLongKey) + } + k := archeraLongKey + for name, frag := range map[string]string{"middle 8": k[10:18], "first 8": k[:8], "last 8": k[len(k)-8:]} { + assert.Contains(t, spaced(frag).Error(), archeraWithheld, name) + } + for name, frag := range map[string]string{"middle 7": k[10:17], "first 7": k[:7], "last 7": k[len(k)-7:]} { + assert.NotContains(t, spaced(frag).Error(), archeraWithheld, name) + } +} + +// The irregular-insertion rule is bounded: key characters 2 apart read as the +// key, 3 apart do not. Uses an 8-character key so there is a single window. +func TestArcheraKeyGapBound(t *testing.T) { + const key = "abcdefgh" + two := archeraSafeError(errors.New("A: abc"+"aa"+"defgh"), key).Error() + assert.Contains(t, two, archeraWithheld) + three := archeraSafeError(errors.New("A: abc"+"aaa"+"defgh"), key).Error() + assert.NotContains(t, three, archeraWithheld) +} + +// The matcher is linear and looks only at what could be displayed: a hostile +// 1 MB rejected value must not stall the command. +func TestArcheraHostileLargeDecodeValueIsBounded(t *testing.T) { + const n = 1 << 20 + value := strings.Repeat("p", n/2) + strings.Repeat("r", n/2) + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, value), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + env := map[string]string{archeraEnvAPIKey: archeraLongKey, archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} + start := time.Now() + _, _, err := runArchera(t, env) + elapsed := time.Since(start) + require.Error(t, err) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + assert.Less(t, elapsed, time.Second) +} + +// Only text that could be displayed (plus a small slack for a key cut by the +// cap) is checked: a key echoed far beyond the cap changes nothing. +func TestArcheraKeyBeyondCheckWindowIsIgnored(t *testing.T) { + far := strings.Repeat("A", archeraMaxErrorSize+archeraCheckSlack+64) + archeraSplitEverywhere(archeraLongKey, "\u2800") + got := archeraSafeError(errors.New(far), archeraLongKey).Error() + assert.NotContains(t, got, archeraWithheld) + assert.Equal(t, strings.Repeat("A", archeraMaxErrorSize), got) +} + +// A key spaced by the longest separator and cut by the cap is still withheld, +// so the look-ahead slack covers a whole window of such a key. +func TestArcheraKeySpacedByLongestSeparatorAtCapIsWithheld(t *testing.T) { + sep := strings.Repeat("-", archeraMaxSep) + msg := strings.Repeat("A", archeraMaxErrorSize-4) + archeraSplitEverywhere(archeraLongKey, sep) + got := archeraSafeError(errors.New(msg), archeraLongKey).Error() + assert.Equal(t, archeraWithheld, got) +} + +// A key with an inner space or a format character is matched on what a reader +// sees of it. +func TestArcheraKeyWithInnerSpaceOrFormatRune(t *testing.T) { + for name, key := range map[string]string{"inner space": "abcd efgh ijkl", "format rune": "abcdefg\u200bhijklmn"} { + got := archeraSafeError(errors.New("A: abcd\u2800efgh\u2800ijkl and abcdefghijklmn"), key).Error() + assert.Contains(t, got, archeraWithheld, name) + } +} + +// Ordinary vendor text must survive: the gap rule only fires on the key. +func TestArcheraOrdinaryVendorTextIsNotWithheld(t *testing.T) { + msgs := []string{ + "invalid credentials for the account; request id 4821-aa; HTTP 403 forbidden", + "organization not found: check the org id and the plan id", + "hypothetical_totals[0]: payment_option: unsupported value \"partial_upfront_x\"", + } + for _, key := range []string{archeraLongKey, archeraTestKey} { + for _, m := range msgs { + assert.Equal(t, m, archeraSafeError(errors.New(m), key).Error(), key) + } + } +} diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go new file mode 100644 index 000000000..d8666eebf --- /dev/null +++ b/cmd/archera_comparison_test.go @@ -0,0 +1,647 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "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() + 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) }) + realOut, realErr := captureRealStd(t, func() { err = rootCmd.Execute() }) + // Anything the command wrote to the real process streams is returned with + // the cobra stderr so every caller's key grep covers it. + return out.String(), errb.String() + realOut + realErr, err +} + +// captureRealStd swaps os.Stdout and os.Stderr for pipes while fn runs, so a +// write that bypasses cobra's buffers is still observed. +func captureRealStd(t *testing.T, fn func()) (stdout, stderr string) { + t.Helper() + savedOut, savedErr := os.Stdout, os.Stderr + outR, outW, err := os.Pipe() + require.NoError(t, err) + errR, errW, err := os.Pipe() + require.NoError(t, err) + var outBuf, errBuf bytes.Buffer + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); _, _ = io.Copy(&outBuf, outR) }() + go func() { defer wg.Done(); _, _ = io.Copy(&errBuf, errR) }() + os.Stdout, os.Stderr = outW, errW + func() { + defer func() { os.Stdout, os.Stderr = savedOut, savedErr }() + fn() + }() + require.NoError(t, outW.Close()) + require.NoError(t, errW.Close()) + wg.Wait() + return outBuf.String(), errBuf.String() +} + +// The test binary runs in a non-UTC zone from the start, so a fetch time +// printed in local time but labeled Z is visible. Set here, before any +// goroutine exists, because time.Local is read concurrently by net/http. +func init() { time.Local = time.FixedZone("archera-test", 5*3600+1800) } + +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}, "ARCHERA_API_KEY is not set (read from the environment only)"}, + {"no org", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvPlanID: archeraTestPlan}, "archera org ID is not set (--org-id or ARCHERA_ORG_ID)"}, + {"no plan", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg}, "archera plan ID is not set (--plan-id or ARCHERA_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.Equal(t, tc.wantMsg, err.Error()) + 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 mislabeled "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) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)) { + t.Fatalf("control or format 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\u202e\u200b\u2028\u2029" } + 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) + require.NotNil(t, dto.Currency) + assert.Equal(t, "usd[31m", *dto.Currency) + buf.Reset() + require.NoError(t, renderArcheraJSON(&buf, dto)) + assert.Contains(t, buf.String(), `"currency": "usd[31m"`) +} + +func TestArcheraFlagsOverrideEnvironment(t *testing.T) { + rec := setupArchera(t, okHandler) + flagOrg := "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + flagPlan := "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + _, _, err := runArchera(t, fullEnv(), "--org-id", flagOrg, "--plan-id", flagPlan) + require.NoError(t, err) + require.Len(t, rec.requests, 1) + assert.Equal(t, "/v1/org/"+flagOrg+"/commitment-plans/"+flagPlan+"/comparison", rec.requests[0].URL.Path) +} + +// Decode errors from pkg/insurance echo the rejected vendor value, so the +// command must bound and clean them. +func TestArcheraDecodeErrorsAreBoundedAndClean(t *testing.T) { + hostile := strings.Repeat("X", 5000) + `\u001b[31m\u202e` + paths := map[string]string{ + "hypothetical payment_option": `"payment_option":"no_upfront"`, + "hypothetical contract_term": `"contract_term":"one_year_gris"`, + "line item actual_term": `"actual_term":"six_month_gris"`, + "line item actual payment": `"actual_payment_option":"partial_upfront"`, + "line item actual_term_reason": `"actual_term_reason":"fallback_closest_shorter"`, + "offer contract_term": `"contract_term":"six_month_gris"`, + "offer payment_option": `"payment_option":"all_upfront"`, + } + for name, field := range paths { + t.Run(name, func(t *testing.T) { + key := field[:strings.Index(field, `":`)+2] + body := strings.Replace(archeraComparisonFixture(), field, key+`"`+hostile+`"`, 1) + require.NotEqual(t, archeraComparisonFixture(), body) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + assert.NotContains(t, err.Error(), strings.Repeat("X", 600)) + assertArcheraNoControlChars(t, err.Error()) + assert.Empty(t, out) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + }) + } +} + +func TestArcheraErrorsMaskObfuscatedKey(t *testing.T) { + k := archeraTestKey + split := func(sep string) string { return k[:5] + sep + k[5:] } + pad := strings.Repeat("p", archeraMaxErrorSize-len("archera API returned HTTP 403: ")-len(k)/2) + cases := map[string]string{ + "zero-width split": "bad key " + split("\u200b"), + "control split": "bad key " + split("\u0001"), + "bidi split": "bad key " + split("\u202e"), + "echoed twice": k + " and " + split("\u200b"), + "straddles the cap": pad + k, + "bidi isolate+RLO": "x\u202e\u2066y\u200b" + k, + } + for name, msg := range cases { + t.Run("http "+name, func(t *testing.T) { + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(403) + body, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(body) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), k) + assertArcheraNoControlChars(t, err.Error()) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + assert.Contains(t, err.Error(), "retry after 30s") + assert.NotContains(t, err.Error(), "\u202e") + }) + } + t.Run("key as enum value in a decode error", func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+split("\u200b")+`"`, 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), k) + assert.Contains(t, err.Error(), "[redacted]") + }) + t.Run("plain key as enum value", func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+k+`"`, 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + _, _, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, err.Error(), k) + }) +} + +// archeraKeyHidingSeparators split the key with characters that survive the +// strip, so an exact-match mask misses a key the terminal still displays. +var archeraKeyHidingSeparators = map[string]string{ + "variation selector 16": "\ufe0f", + "variation selector 17": "\U000e0100", + "combining grapheme join": "\u034f", + "combining acute": "\u0301", + "enclosing circle": "\u20dd", + "hangul choseong filler": "\u115f", + "hangul jungseong filler": "\u1160", + "hangul filler": "\u3164", + "halfwidth hangul filler": "\uffa0", + "space": " ", + "invalid utf-8": "\xff", +} + +func archeraSplitEverywhere(k, sep string) string { + return strings.Join(strings.Split(k, ""), sep) +} + +type archeraFailingTransport struct{ err error } + +func (f archeraFailingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, f.err } + +func TestArcheraErrorsWithholdKeyHiddenBehindSurvivingCharacters(t *testing.T) { + k := archeraTestKey + checkMsg := func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, k) + assert.Contains(t, got, archeraWithheld) + assertArcheraNoControlChars(t, got) + assert.LessOrEqual(t, len(got), archeraMaxErrorSize) + } + for name, sep := range archeraKeyHidingSeparators { + for shape, echo := range map[string]string{ + "split everywhere": archeraSplitEverywhere(k, sep), + "split once": k[:5] + sep + k[5:], + } { + msg := "bad key " + echo + " was rejected" + t.Run("http "+name+" "+shape, func(t *testing.T) { + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(403) + body, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(body) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + assert.Contains(t, err.Error(), "HTTP 403") + assert.Contains(t, err.Error(), "retry after 30s") + }) + t.Run("decode "+name+" "+shape, func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + }) + for kind, wrapped := range map[string]error{ + "transport": errors.New(msg), + "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), + "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), + } { + t.Run(kind+" "+name+" "+shape, func(t *testing.T) { + setupArchera(t, okHandler) + archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + }) + } + } + } +} + +func mustJSON(t *testing.T, v string) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return string(b) +} + +// The mask must run before the cap: a key cut by the cap boundary would +// otherwise survive as a readable prefix. Sweep the boundary and assert no +// 4+ byte prefix of the key remains, for a plain and a strip-split key. +func TestArcheraErrorsLeaveNoKeyPrefixAtCap(t *testing.T) { + k := archeraTestKey + for _, echo := range []string{k, k[:5] + "\u200b" + k[5:], archeraSplitEverywhere(k, "\u200b")} { + for padLen := archeraMaxErrorSize - len(k) - 8; padLen <= archeraMaxErrorSize+2; padLen++ { + err := archeraSafeError(errors.New(strings.Repeat("p", padLen)+echo), k) + got := err.Error() + for n := 4; n <= len(k); n++ { + for start := 0; start+n <= len(k); start++ { + require.NotContains(t, got, k[start:start+n], "pad %d, fragment %q", padLen, k[start:start+n]) + } + } + } + } +} + +// The withhold check must see the full text: a cut through a split key would +// hide it from a check run after the cap while leaving a readable prefix. +func TestArcheraErrorsWithholdSplitKeyAtAnyCapOffset(t *testing.T) { + k := archeraTestKey + for name, sep := range map[string]string{"variation selector": "\ufe0f", "invalid utf-8": "\xff", "combining acute": "\u0301"} { + for padLen := archeraMaxErrorSize - 3*len(k); padLen <= archeraMaxErrorSize+2; padLen++ { + got := archeraSafeError(errors.New(strings.Repeat("p", padLen)+archeraSplitEverywhere(k, sep)), k).Error() + require.Equal(t, archeraWithheld, got, "%s pad %d", name, padLen) + } + } +} + +func TestArcheraErrorsTrimKeyBeforeMasking(t *testing.T) { + got := archeraSafeError(errors.New("bad key "+archeraTestKey[:5]+"\u200b"+archeraTestKey[5:]), " "+archeraTestKey+"\n").Error() + assert.Equal(t, "bad key [redacted]", got) +} + +// A log or slog write of the key would bypass os.Stderr captured per test, so +// route both default loggers into the capture for the whole run. +func TestArcheraRunDoesNotLogKey(t *testing.T) { + var logs bytes.Buffer + savedLog, savedFlags := log.Writer(), log.Flags() + log.SetOutput(&logs) + savedSlog := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { log.SetOutput(savedLog); log.SetFlags(savedFlags); slog.SetDefault(savedSlog) }) + setupArchera(t, okHandler) + _, _, err := runArchera(t, fullEnv()) + require.NoError(t, err) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(403) }) + _, _, _ = runArchera(t, fullEnv()) + assert.NotContains(t, logs.String(), archeraTestKey) +} diff --git a/cmd/archera_render.go b/cmd/archera_render.go new file mode 100644 index 000000000..fd5849424 --- /dev/null +++ b/cmd/archera_render.go @@ -0,0 +1,343 @@ +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 + archeraMaxErrorSize = 512 + archeraMaxMoneyPrec = 100 +) + +// archeraSanitize strips control characters (ESC, C1 and the rest of +// unicode.IsControl), format characters (bidi overrides, zero-width) and line +// or paragraph separators 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 { return archeraSanitizeN(s, archeraMaxFieldSize) } + +func archeraSanitizeN(s string, maxBytes int) string { + var b strings.Builder + for _, r := range s { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) { + continue + } + if b.Len()+utf8.RuneLen(r) > maxBytes { + 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..d6f6fa03d --- /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"},{"line_item_id":"61000000-0000-4000-8000-0000000000b3","actual_term":"one_year_gris","actual_payment_option":"all_upfront","actual_commitment_type":"aws/AmazonRDS","actual_term_reason":"exact_match"}]},{"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"}]},{"contract_term":"twelve_month_gris","payment_option":"partial_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":901,"breakdown":{"cloud_provider_cost":{"total":902},"archera_premium":903}},"commitment_savings":{"net":905,"gross":904},"covered_ondemand_cost":906},"commitment_upfront_cost":907,"delta_vs_current":{"monthly_net_savings":908,"monthly_commitment_cost":909,"upfront_cost":910},"line_items":[{"line_item_id":"90000000-0000-4000-8000-0000000000d1","actual_term":"twelve_month_gris","actual_payment_option":"all_upfront","actual_commitment_type":"aws/AmazonRDS","actual_term_reason":"exact_match"},{"line_item_id":"90000000-0000-4000-8000-0000000000d2","actual_term":"three_month_gris","actual_payment_option":"no_upfront","actual_commitment_type":"aws/AmazonElastiCache","actual_term_reason":"bundle_member"}]}],"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}}]},{"line_item_id":"71000000-0000-4000-8000-0000000000c2","current":{"is_current":true,"offer_id":"70000000-0000-4000-8000-000000000004","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonRDS","region":"us-west-2","guaranteed_display_name":"Row Two Current"},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonRDS","contract_term":null,"payment_option":null,"discount_rate":0.71,"breakeven_days":71,"commitment_upfront_cost":707,"commitment_financials_monthly_rate":{"commitment_cost":{"total":701,"breakdown":{"cloud_provider_cost":{"total":702},"archera_premium":703}},"commitment_savings":{"net":705,"gross":704},"covered_ondemand_cost":706},"delta_vs_current":{"monthly_net_savings":708,"upfront_cost":709,"discount_rate":0.72,"breakeven_days":72}},"candidates":[{"is_current":false,"offer_id":"80000000-0000-4000-8000-000000000005","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonElastiCache","region":"ca-central-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonElastiCache","contract_term":"three_month_gris","payment_option":"partial_upfront","discount_rate":0.81,"breakeven_days":81,"commitment_upfront_cost":807,"commitment_financials_monthly_rate":{"commitment_cost":{"total":801,"breakdown":{"cloud_provider_cost":{"total":802},"archera_premium":803}},"commitment_savings":{"net":805,"gross":804},"covered_ondemand_cost":806},"delta_vs_current":{"monthly_net_savings":808,"upfront_cost":809,"discount_rate":0.82,"breakeven_days":82}}]}]} diff --git a/cmd/testdata/archera_json.golden b/cmd/testdata/archera_json.golden new file mode 100644 index 000000000..58658df58 --- /dev/null +++ b/cmd/testdata/archera_json.golden @@ -0,0 +1,302 @@ +{ + "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" + }, + { + "line_item_id": "61000000-0000-4000-8000-0000000000b3", + "actual_term": "one_year_gris", + "actual_payment_option": "all_upfront", + "actual_commitment_type": "aws/AmazonRDS", + "reason": "exact_match" + } + ] + }, + { + "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" + } + ] + }, + { + "contract_term": "twelve_month_gris", + "payment_option": "partial_upfront", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "901", + "cloud_provider_cost": "902", + "premium": "903", + "gross_savings": "904", + "net_savings": "905", + "covered_on_demand_cost": "906" + }, + "upfront_cost_one_time": "907" + }, + "delta_monthly_net_savings": "908", + "delta_monthly_commitment_cost": "909", + "delta_upfront_cost": "910", + "line_items": [ + { + "line_item_id": "90000000-0000-4000-8000-0000000000d1", + "actual_term": "twelve_month_gris", + "actual_payment_option": "all_upfront", + "actual_commitment_type": "aws/AmazonRDS", + "reason": "exact_match" + }, + { + "line_item_id": "90000000-0000-4000-8000-0000000000d2", + "actual_term": "three_month_gris", + "actual_payment_option": "no_upfront", + "actual_commitment_type": "aws/AmazonElastiCache", + "reason": "bundle_member" + } + ] + } + ], + "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" + } + } + ] + }, + { + "line_item_id": "71000000-0000-4000-8000-0000000000c2", + "current": { + "is_current": true, + "offer_id": "70000000-0000-4000-8000-000000000004", + "commitment_type": "aws/AmazonRDS", + "provider": "aws", + "region": "us-west-2", + "contract_term": null, + "payment_option": null, + "lease_attached": false, + "archera_offer_name": "Row Two Current", + "discount_rate": "0.71", + "breakeven_days": "71", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "701", + "cloud_provider_cost": "702", + "premium": "703", + "gross_savings": "704", + "net_savings": "705", + "covered_on_demand_cost": "706" + }, + "upfront_cost_one_time": "707" + }, + "delta_vs_current": { + "monthly_net_savings": "708", + "upfront_cost": "709", + "discount_rate": "0.72", + "breakeven_days": "72" + }, + "archera_product_support": { + "status": "unknown" + } + }, + "candidates": [ + { + "is_current": false, + "offer_id": "80000000-0000-4000-8000-000000000005", + "commitment_type": "aws/AmazonElastiCache", + "provider": "aws", + "region": "ca-central-1", + "contract_term": "three_month_gris", + "payment_option": "partial_upfront", + "lease_attached": false, + "archera_offer_name": null, + "discount_rate": "0.81", + "breakeven_days": "81", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "801", + "cloud_provider_cost": "802", + "premium": "803", + "gross_savings": "804", + "net_savings": "805", + "covered_on_demand_cost": "806" + }, + "upfront_cost_one_time": "807" + }, + "delta_vs_current": { + "monthly_net_savings": "808", + "upfront_cost": "809", + "discount_rate": "0.82", + "breakeven_days": "82" + }, + "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..27995b1f8 --- /dev/null +++ b/cmd/testdata/archera_table.golden @@ -0,0 +1,58 @@ +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 + line item 61000000-0000-4000-8000-0000000000b3: term one_year_gris, payment all_upfront, type aws/AmazonRDS, reason exact_match + +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 + +Hypothetical 3: term twelve_month_gris, payment partial_upfront + totals: commitment cost 901, cloud provider cost 902, premium 903, gross savings 904, net savings 905, covered on-demand 906 (730-hour monthly rate); upfront 907 (one-time) + delta vs current: monthly net savings 908, monthly commitment cost 909, upfront 910 + line item 90000000-0000-4000-8000-0000000000d1: term twelve_month_gris, payment all_upfront, type aws/AmazonRDS, reason exact_match + line item 90000000-0000-4000-8000-0000000000d2: term three_month_gris, payment no_upfront, type aws/AmazonElastiCache, reason bundle_member + +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 + +Line item 71000000-0000-4000-8000-0000000000c2 + current offer 70000000-0000-4000-8000-000000000004: aws aws/AmazonRDS, region us-west-2, term unknown, payment unknown + Archera offer name: Row Two Current + discount rate 0.71 (0-1 basis), breakeven days 71 + commitment cost 701, cloud provider cost 702, premium 703, gross savings 704, net savings 705, covered on-demand 706 (730-hour monthly rate); upfront 707 (one-time) + delta vs current: monthly net savings 708, upfront 709, discount rate 0.72, breakeven days 72 + Archera product support: unknown + candidate offer 80000000-0000-4000-8000-000000000005: aws aws/AmazonElastiCache, region ca-central-1, term three_month_gris, payment partial_upfront + discount rate 0.81 (0-1 basis), breakeven days 81 + commitment cost 801, cloud provider cost 802, premium 803, gross savings 804, net savings 805, covered on-demand 806 (730-hour monthly rate); upfront 807 (one-time) + delta vs current: monthly net savings 808, upfront 809, discount rate 0.82, breakeven days 82 + 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). diff --git a/go.mod b/go.mod index 26b5c834c..2ce6dbe77 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/text v0.42.0 golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.274.0 google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect