308 Commits
Author SHA1 Message Date
mats 75db64ce75 tweak otel
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-30 21:11:58 +02:00
mats 9495c654fa link user
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-30 18:45:58 +02:00
mats 7b657901ac refactor discovery
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
2026-06-30 10:44:05 +02:00
mats 2e0204a287 support mapping 2026-06-30 10:31:12 +02:00
mats a469b5ea97 tiny
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-29 23:32:28 +02:00
mats d7acfc422c better lock handling
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-29 20:10:09 +02:00
mats 98e2fd8d41 some tweaks to cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-29 20:08:21 +02:00
mats de47ef4f14 ucp
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
2026-06-29 14:32:48 +02:00
mats 46be260fcc refactor
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-29 12:34:58 +02:00
matsandClaude Opus 4.8 d711348863 cart: back projection cache with platform/catalog.ProjectionStore
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
Consumer side of the catalog-projection seam (C1):
- catalogProjectionCache now wraps ProjectionStore[catalog.Projection] (identity
  transform — the cart's overlay uses ~all fields); delivery handler routes bus
  frames via HandleFrame (snapshot begin|chunk|end + epoch-stamped deltas).
- Per-pod cold-start-ready .bin under CART_PROJECTION_DIR (pod-local, never
  shared) + IsDeleted tombstone passthrough; HTTP fetch demoted to last-resort.
- Tests: test-only Apply shim + mustCache helper keep existing cases green;
  new snapshot-framing integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:05:55 +02:00
mats a3eab70de8 more stuff
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-28 19:01:13 +02:00
mats e9e9700a09 more 2026-06-28 18:47:02 +02:00
mats 63b0112cc7 internal product index
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 10s
2026-06-28 18:40:58 +02:00
mats aa8b2bdedc all the refactor
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
2026-06-28 17:51:52 +02:00
mats 7db0d236c7 platform
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
2026-06-27 21:46:55 +02:00
mats 528c59bfd3 cart and checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
2026-06-27 19:49:00 +02:00
mats 492f54ff45 mcp
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
2026-06-27 09:14:59 +02:00
mats ae66ee162f change promotions mcp url 2026-06-27 08:07:17 +02:00
mats 3df95eac90 more customer
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
2026-06-26 19:49:54 +02:00
mats 0252893de5 klarna and checkouts
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
2026-06-26 15:41:55 +02:00
mats c0c5a8bc0f update customer
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 2s
2026-06-25 21:09:58 +02:00
mats 10812d4203 create logs dir on startup
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 2s
2026-06-25 19:42:47 +02:00
mats 414395bc0d breakout
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
2026-06-25 18:17:55 +02:00
mats 86797e520e add profile actors
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s
2026-06-25 17:19:59 +02:00
mats faa5330b81 better ucp conformance
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
2026-06-25 09:35:27 +02:00
mats 87ba9c41b9 cart mcp
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-24 18:45:26 +02:00
mats 94b1f9c4ec ucp
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-24 16:27:22 +02:00
mats e1eb342edf more ucp
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-24 12:46:52 +02:00
mats 9aa587b9cf mcp and ucp
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-24 10:50:32 +02:00
mats a1f0bad972 more
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-21 23:47:53 +02:00
mats 9d420c43fa more checkout
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-21 23:43:48 +02:00
mats 716a66562e update orderflow
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-21 23:26:37 +02:00
mats 1a365de071 order sagas
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-21 11:33:20 +02:00
matsandClaude Opus 4.8 484f2364fb deployment: rename DATA_DIR/CHECKOUT_DATA_DIR to CART_DIR/CHECKOUT_DIR
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
The binaries read CART_DIR (cmd/cart) and CHECKOUT_DIR (cmd/checkout,
cmd/backoffice); the deployment set DATA_DIR/CHECKOUT_DATA_DIR which
nothing reads, so storage fell back to the ephemeral relative ./data and
was wiped on every redeploy. Use the names the code actually reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:48:05 +02:00
mats 2443fd8b49 promotions
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-18 11:32:19 +02:00
mats 2545be4315 update dockerfile
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-17 22:49:13 +02:00
mats 74dc252279 mcp in cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-17 21:43:38 +02:00
mats ad410d217c update cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-17 14:16:25 +02:00
mats 11be8aec56 name
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m8s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-17 00:09:30 +02:00
mats d22f3432f6 version
Build and Publish / BuildAndDeployArm64 (push) Failing after 6m44s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-16 23:37:47 +02:00
mats ec49f96486 cart actor
Build and Publish / BuildAndDeployArm64 (push) Failing after 8m7s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-16 16:18:23 +02:00
matsandClaude Opus 4.8 871edb6455 Drop machine-local slask-finder replace; build against released module
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
The previous commit added 'replace github.com/matst80/slask-finder =>
/home/mats/...', a machine-specific path that breaks builds on any other host —
including the containerized CI build (the Dockerfile only copies the cart repo,
so that path is never present). The pinned version v0.0.0-20251125182907 is
available from the module proxy, so drop the replace and add the go.sum entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:32:05 +02:00
mats 60722e3414 fixes
Build and Publish / BuildAndDeployArm64 (push) Failing after 49s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2026-06-16 13:14:35 +02:00
mats faec360789 Update .gitea/workflows/build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m1s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5m45s
2026-05-23 12:20:22 +02:00
mats 7a11a7fde2 Update .gitea/workflows/build.yaml
Build and Publish / BuildAndDeployArm64 (push) Failing after 14s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 10s
2026-05-21 15:34:07 +02:00
mats f55a3d56eb Update .gitea/workflows/build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2026-05-21 15:21:54 +02:00
mats f133e2ac89 Update .gitea/workflows/build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2026-05-21 15:16:37 +02:00
mats 571f4f40cb better defaults
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2026-01-24 17:05:16 +01:00
mats 81609d6c20 correct mutation
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m36s
2025-12-05 08:34:32 +01:00
mats ea3eb2f0f3 add cancel
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-05 08:30:59 +01:00
mats b4ffb9da85 update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 30s
Build and Publish / BuildAndDeployArm64 (push) Failing after 13m2s
2025-12-05 07:44:34 +01:00
mats 593d6cfe44 s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m3s
2025-12-04 23:06:04 +01:00
mats 7a4ecb0bf7 fdf
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 23:02:19 +01:00
mats 2be13462c9 more
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 23:01:29 +01:00
mats 7964e8b849 more
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 22:59:07 +01:00
mats d78685cd8f checkout backoffice
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m44s
2025-12-04 21:06:56 +01:00
mats f67eeb3c49 major changes
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m43s
2025-12-04 20:56:54 +01:00
mats 6d5358b53b less ping logs
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m43s
2025-12-04 19:08:47 +01:00
mats d0d604f5f0 strange
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 19:07:15 +01:00
mats 2b8e692526 better adyen handling
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-12-04 18:58:10 +01:00
mats c842571dbc fix
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-04 18:49:30 +01:00
mats bd194f2bb8 maybe fix adyen error
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 18:48:24 +01:00
mats 94d478ee6a ahh
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-04 18:31:51 +01:00
mats e9fb00d5c7 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m38s
2025-12-04 18:12:50 +01:00
mats 3d9d703a76 better
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 18:08:34 +01:00
mats 9a61b088c9 s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-04 18:02:42 +01:00
mats 3becd1c80c fix
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-12-04 18:02:06 +01:00
mats dcc7cc2dd0 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m44s
2025-12-04 17:51:00 +01:00
mats ce81e294f2 test
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m52s
2025-12-04 17:42:51 +01:00
mats fccb0eed06 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m36s
2025-12-04 17:14:25 +01:00
mats 978ef3025d test
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-12-04 17:08:31 +01:00
mats 0ef29596c1 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-04 14:19:00 +01:00
mats fbc773dbca more logs
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m39s
2025-12-04 10:35:23 +01:00
mats 5037c07a47 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-04 10:31:11 +01:00
mats e9189364ab handle events
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-12-04 09:34:49 +01:00
mats 445858c1e3 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m16s
2025-12-04 09:20:30 +01:00
mats 82b64ee8d4 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 6m18s
2025-12-04 08:51:59 +01:00
mats 7606c1f9df update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-12-03 23:12:40 +01:00
mats 0e1f9b0d3c update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m41s
2025-12-03 23:04:37 +01:00
mats 95d8e0ffb5 add adyen part sessions
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-03 22:56:30 +01:00
mats c61fe1bb7b update payment on completed
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-12-03 22:38:14 +01:00
mats 3af837d824 load prev state
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-12-03 22:30:13 +01:00
mats 401286c56a update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-03 22:06:24 +01:00
mats bb4ed5eb23 fix
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-03 21:05:49 +01:00
mats f828f4809b update stuff
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m15s
2025-12-03 20:54:32 +01:00
mats 825630a91a allow some updates
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-12-03 20:19:37 +01:00
mats 78bc05025d update json
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-12-03 20:12:41 +01:00
mats 5ad7131df2 clear cookie if failing
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-12-03 19:51:22 +01:00
mats 6321ad76a1 update endpoints
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m47s
2025-12-03 19:29:33 +01:00
mats 94a04a154c update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m33s
2025-12-03 19:17:27 +01:00
mats 86fdfc8532 price marchalling
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-12-03 19:07:04 +01:00
mats c57643ab63 bajs
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-03 19:01:57 +01:00
mats 9fde6d6bd7 by idstring
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-12-03 18:54:11 +01:00
mats 1cca11de35 update internal url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m6s
2025-12-03 18:48:07 +01:00
mats 1b4e769573 dont reserv other than ps5
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-03 18:46:47 +01:00
mats 8f2a354f9d update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-12-03 18:27:37 +01:00
mats 4940b61ee0 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m36s
2025-12-03 18:14:04 +01:00
mats ad24d503ba update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-12-03 17:45:38 +01:00
mats ca7d23f122 crash if not correct type
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m52s
2025-12-03 10:22:41 +01:00
mats c7049793e2 use correct image
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m4s
2025-12-03 10:02:23 +01:00
mats dbdd759e16 fix docker and deployment
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-12-03 09:54:09 +01:00
matsandmats ee5f54f0dd refactor/checkout (#8)
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m40s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: #8
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-12-03 09:45:48 +01:00
mats ebd1508294 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-12-02 09:26:30 +01:00
mats d8791788fc update discovery
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-12-02 09:08:36 +01:00
mats ee1b96fece slask
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-12-02 09:04:42 +01:00
mats 060b3dfbf0 more changes
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m59s
2025-12-01 20:15:48 +01:00
mats c227870f13 add more payment related shit
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-12-01 18:35:32 +01:00
mats d23bfe62a1 try to update inventory on spawn
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-12-01 13:47:54 +01:00
mats accdc41064 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-12-01 10:18:22 +01:00
mats e419cde351 less pooltime
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-12-01 09:38:29 +01:00
mats f0c487b5e1 test a version
Build and Publish / BuildAndDeployArm64 (push) Has started running
Build and Publish / BuildAndDeployAmd64 (push) Successful in 40s
2025-12-01 09:36:59 +01:00
mats 84268eede4 update url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-11-29 16:10:31 +01:00
mats aaa730e833 change return url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-11-29 16:01:55 +01:00
mats c11161b0ca add result endpoint
Build and Publish / BuildAndDeployAmd64 (push) Successful in 40s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-11-29 15:43:51 +01:00
mats cdb678f018 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-11-29 14:43:34 +01:00
mats c9695d338d Merge pull request 'feature/pubsub' (#7) from feature/pubsub into main
Build and Publish / BuildAndDeployArm64 (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 12s
Reviewed-on: #7
2025-11-28 17:45:12 +01:00
mats e1072c5ba1 cart items
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-11-28 17:01:32 +01:00
mats aea2adcf92 incorrect reference
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-11-28 16:48:21 +01:00
mats 90a525bd98 capture and split notification if it has multiple hosts
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 16:44:03 +01:00
mats 330093bdec more
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-11-28 14:33:26 +01:00
mats 47e69f18a5 add function to apply mutations over grpc
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-11-28 14:07:43 +01:00
mats 2c0f6c160a update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-11-28 08:11:18 +01:00
mats 23160479b3 add more
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 08:10:08 +01:00
mats d8db3c9289 builder
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-28 08:08:11 +01:00
mats 43b7e749c4 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-11-28 08:01:55 +01:00
mats c3d8773c2e add adyen
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m43s
2025-11-28 00:34:00 +01:00
mats c2873ebfc0 fix
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-27 20:28:10 +01:00
mats db61413988 update inventory listener
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m31s
2025-11-27 16:01:04 +01:00
mats 65dc8edf48 fix mutation context
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-27 15:58:31 +01:00
mats fd29300314 try to reserve when entering checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 23s
Build and Publish / BuildAndDeployArm64 (push) Failing after 2m54s
2025-11-27 13:47:48 +01:00
mats aea168160e update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m45s
2025-11-27 12:45:34 +01:00
mats c2a137d8d4 Merge branch 'feature/pubsub' of git-ssh.tornberg.me:mats/go-cart-actor into feature/pubsub 2025-11-27 12:14:11 +01:00
mats 51d7de03d6 update 2025-11-27 12:14:09 +01:00
mats 3912135b35 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 38s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-11-26 21:43:18 +01:00
mats afa79e7b56 handle limited quantity
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-26 21:35:40 +01:00
mats a17d5fc570 add the reservationservice
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m56s
2025-11-26 20:42:12 +01:00
mats fac94acfe1 now we should have logs!
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-11-26 20:35:39 +01:00
mats beb03a7cd7 logs in pod
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-26 20:34:11 +01:00
mats 87625ffd5d do reservations
Build and Publish / BuildAndDeployAmd64 (push) Successful in 36s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-11-26 20:29:14 +01:00
mats 9db4abdb05 update service
Build and Publish / BuildAndDeployAmd64 (push) Successful in 30s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-11-26 20:17:46 +01:00
mats a6f6d3c190 add service to deployment
Build and Publish / BuildAndDeployAmd64 (push) Successful in 32s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-26 19:42:45 +01:00
mats 400079ec98 add reservations to the model
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m48s
2025-11-26 19:25:20 +01:00
mats 95426acd4a update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 37s
Build and Publish / BuildAndDeployArm64 (push) Failing after 4m7s
2025-11-26 19:19:59 +01:00
mats 7e2726244f update inventory service 2025-11-26 18:54:07 +01:00
mats 59e01e763f remove promotions for now
Build and Publish / BuildAndDeployAmd64 (push) Successful in 35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m49s
2025-11-25 23:16:54 +01:00
mats d5d2b3e711 redo pubsub
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m49s
2025-11-25 23:06:36 +01:00
mats f640cd7d2c update proto 2025-11-25 22:16:13 +01:00
mats 82650bd3dd start listener
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m43s
2025-11-25 21:17:25 +01:00
mats 990708597b update subscriptions
Build and Publish / BuildAndDeployAmd64 (push) Successful in 37s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-25 21:13:44 +01:00
mats e7e572b0f7 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 34s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m52s
2025-11-25 20:30:45 +01:00
mats b0907aee41 update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 27s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m10s
2025-11-25 20:27:04 +01:00
mats ff8a332e3b udpate
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m21s
2025-11-25 20:17:02 +01:00
mats 4ca6c4ca23 update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 18s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-25 20:16:13 +01:00
mats e74f5ae30e add git
Build and Publish / BuildAndDeployAmd64 (push) Failing after 47s
Build and Publish / BuildAndDeployArm64 (push) Failing after 4m51s
2025-11-25 19:46:20 +01:00
mats 8d976dd250 no custom mounts
Build and Publish / BuildAndDeployAmd64 (push) Failing after 6s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
2025-11-25 19:44:47 +01:00
mats fc678797ba update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 7s
Build and Publish / BuildAndDeployArm64 (push) Failing after 9s
2025-11-25 19:41:08 +01:00
mats 374bd4272b update
Build and Publish / BuildAndDeployAmd64 (push) Failing after 10s
Build and Publish / BuildAndDeployArm64 (push) Failing after 11s
2025-11-25 19:34:39 +01:00
mats ffd7ace839 update 2025-11-25 18:58:17 +01:00
mats c74968cfce update deps
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m55s
2025-11-25 18:36:43 +01:00
mats f63ad5bc84 update backoffice to update inventory
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-25 18:34:03 +01:00
mats 4dcc6fe1bd move fields
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m18s
2025-11-21 17:08:21 +01:00
mats 886e3ce48f add giftcards 2025-11-21 10:14:33 +01:00
mats 896bf7672b more updates
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-11-20 22:41:00 +01:00
mats c859796b1e update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-20 22:37:01 +01:00
mats a01feb615b Update openapi.json
Build and Publish / BuildAndDeployAmd64 (push) Successful in 43s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-11-20 22:16:09 +01:00
mats f475b56659 more correct openapi json
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m48s
2025-11-20 22:07:34 +01:00
mats 430da09264 update openapi
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m57s
2025-11-20 21:37:42 +01:00
mats 729c67f992 more functions 2025-11-20 21:34:39 +01:00
mats 60cd6cfd51 update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m3s
2025-11-20 21:20:35 +01:00
mats 1c8e9cc974 update the code
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m39s
2025-11-20 15:27:40 +01:00
mats 874963812f more update
Build and Publish / BuildAndDeployAmd64 (push) Successful in 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m27s
2025-11-19 18:50:31 +01:00
mats 67f63244bb update registry url, and cart url
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-11-19 18:49:01 +01:00
mats 270539a2fe Delete cart 2025-11-19 08:30:50 +01:00
mats dd5de0b1a1 tidy
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m29s
2025-11-19 08:27:30 +01:00
mats 3e78dd8b00 update stockhandling
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-19 08:25:32 +01:00
mats 13dcb1ec45 Update mutation_upsert_subscriptiondetails.go
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-11-19 08:11:49 +01:00
mats 4fa78e786f allow own ids 2025-11-19 08:10:28 +01:00
mats c917bc62fb revert
Build and Publish / BuildAndDeployArm64 (push) Failing after 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 13s
2025-11-19 07:59:06 +01:00
mats ac8b870eb5 update more
Build and Publish / BuildAndDeployArm64 (push) Failing after 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
2025-11-19 07:46:07 +01:00
mats cdece4e0d2 Update build.yaml
Build and Publish / BuildAndDeployArm64 (push) Failing after 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
2025-11-18 23:19:26 +01:00
mats c9d9d62bfb rollout
Build and Publish / BuildAndDeployArm64 (push) Failing after 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 45s
2025-11-18 23:18:27 +01:00
mats e662192b56 better pbany parsing
Build and Publish / BuildAndDeployArm64 (push) Failing after 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
2025-11-18 23:07:28 +01:00
mats a7aab5161b ai pubsub
Build and Publish / BuildAndDeployArm64 (push) Failing after 16s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m1s
2025-11-18 22:48:18 +01:00
mats 7fd6b22c6b Update checkout_server.go
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m41s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m41s
2025-11-18 22:33:08 +01:00
mats ba0e820956 get raw bytes
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m38s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m41s
2025-11-18 22:27:49 +01:00
mats 7a7c577374 Update build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m50s
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m52s
2025-11-18 22:10:12 +01:00
mats da2b68b40e Update build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m33s
Build and Publish / BuildAndDeployArm64 (push) Failing after 2m5s
2025-11-18 21:53:38 +01:00
mats 5816b6cd3b Update build.yaml
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-18 21:53:10 +01:00
mats b135156837 Update cart-grain.go
Build and Publish / Metadata (push) Failing after 1m36s
Build and Publish / BuildAndDeployAmd64 (push) Has been skipped
Build and Publish / BuildAndDeployArm64 (push) Has been skipped
2025-11-18 21:49:01 +01:00
mats c4c116fc43 unique ids
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 2s
2025-11-18 21:30:50 +01:00
mats 0994c4f484 update messages
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m9s
2025-11-18 21:08:42 +01:00
mats 78436f39ae Update pool-server.go
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m19s
2025-11-18 20:45:05 +01:00
mats 8905c5809c Update pool-server.go
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-11-18 20:44:44 +01:00
mats 4eb621bae4 logs
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m59s
2025-11-18 18:19:31 +01:00
mats dd8e23aadd Update k8s-host-discovery.go
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 57s
Build and Publish / BuildAndDeployArm64 (push) Successful in 7m10s
2025-11-18 17:06:30 +01:00
mats ea247e2600 update tests and discovery
Build and Publish / Metadata (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-18 16:40:25 +01:00
mats 718225164f move
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-11-13 22:40:23 +01:00
mats cdfd263318 fetcher span
Build and Publish / Metadata (push) Successful in 18s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Failing after 5m55s
2025-11-13 21:54:19 +01:00
mats e25ca3304e update
Build and Publish / Metadata (push) Successful in 16s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m0s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-13 21:52:29 +01:00
mats af5d4cd325 more otel
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m49s
2025-11-13 21:40:20 +01:00
mats cebd3ea80f less pod logs
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m40s
2025-11-13 18:35:40 +01:00
mats a0f5ae0c61 more logs
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-13 18:34:25 +01:00
mats 67c9b2a7df more otel
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m4s
2025-11-13 18:28:09 +01:00
mats e703b71d35 Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m1s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-13 18:23:09 +01:00
mats 5bd0bdb44c notification url 2025-11-13 18:14:43 +01:00
mats 0c9780f36c Update amqp-order-handler.go
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m51s
2025-11-11 17:47:49 +01:00
mats e0d8afae92 change name
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m55s
2025-11-11 17:27:05 +01:00
mats 6d9f9bec11 Update amqp-order-handler.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Has started running
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-11 17:26:43 +01:00
mats a54435ebbb test external ip
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 57s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m3s
2025-11-11 17:20:29 +01:00
mats 529e70fc68 more testing
Build and Publish / Metadata (push) Successful in 15s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m38s
2025-11-11 17:01:57 +01:00
mats 3f9c790dc2 update again. with more logs
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-11 16:57:45 +01:00
mats 5223fef2fa Update amqp-order-handler.go
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m0s
2025-11-11 16:44:41 +01:00
mats 7161c2a8b6 Update amqp-order-handler.go 2025-11-11 16:00:59 +01:00
mats cc6d48c879 update urls
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m53s
2025-11-11 13:32:31 +01:00
mats 756a43b342 reserve inventory when order placed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
2025-11-11 11:47:05 +01:00
mats 81246fe497 Update cart-grain.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m0s
2025-11-11 09:52:52 +01:00
mats 3fa0009b95 Update deployment.yaml
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m55s
2025-11-11 09:44:40 +01:00
mats 19fc5a9553 correct the discount sums
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-11 09:40:12 +01:00
mats ab5d9cb2b7 try to reserve
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 57s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m50s
2025-11-11 09:29:51 +01:00
mats 5d4d917f6a Update main.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m41s
2025-11-11 09:06:51 +01:00
mats af7ce20557 update
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 38s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m56s
2025-11-11 08:51:56 +01:00
mats ed9a02227e update deployment
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m1s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m59s
2025-11-10 22:34:38 +01:00
mats 34e0445857 Update main.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m2s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m12s
2025-11-10 22:28:58 +01:00
mats 61457bce6b inventory deployment
Build and Publish / Metadata (push) Successful in 15s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m6s
2025-11-10 21:03:29 +01:00
mats 8bf29020dd Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m37s
2025-11-10 19:47:23 +01:00
mats 44d7c1faad add listener and remove memory inventory service 2025-11-10 19:47:15 +01:00
mats caab742461 add missing code
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-10 19:46:18 +01:00
mats b272282b1f better metrics
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-11-07 14:44:15 +01:00
mats 43fcf69139 more traces
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-11-07 14:20:54 +01:00
mats 7d9fd0ebb4 fix proxy
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-11-07 14:04:50 +01:00
mats e662c7dafa log
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-11-07 13:36:57 +01:00
mats d969da428f Update otel.go
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-11-07 12:37:31 +01:00
mats d0325e302e change to otel metrics 2025-11-07 12:36:53 +01:00
mats 1aa12ff8d9 Update pool-server.go
Build and Publish / Metadata (push) Successful in 17s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m21s
2025-11-07 09:27:08 +01:00
mats 68eca49cd0 Update pool-server.go
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-07 09:24:36 +01:00
mats bb80c9ab13 change
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-07 09:22:41 +01:00
mats dce00fb5e3 change to debug service
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m13s
2025-11-06 14:37:57 +01:00
mats 81e2fb5faa do all requests with context
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-11-05 19:04:56 +01:00
mats 01d8d86c7c manual handler
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 22s
Build and Publish / BuildAndDeployArm64 (push) Failing after 1m32s
2025-11-05 18:46:17 +01:00
mats 0c4d9e2245 Update checkout_server.go
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-11-05 18:35:02 +01:00
mats 8aab59a5f4 Update pool-server.go
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:32:59 +01:00
mats c1599af40b Update pool-server.go 2025-11-05 18:32:55 +01:00
mats acf2a3a8c1 Update pool-server.go
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:28:19 +01:00
mats 0a86bd380a Update main.go
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-11-05 18:27:43 +01:00
mats 42913a079f update handler
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 42s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:26:26 +01:00
mats c71b668a87 slask
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m19s
2025-11-05 18:03:20 +01:00
mats 89ee3e725f test
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 18:00:25 +01:00
mats aef90e2bbb update
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 17:56:04 +01:00
mats 834bf9f7bc prom meter
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-11-05 17:22:34 +01:00
mats de77a3b707 change to prometheus
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 17:21:27 +01:00
mats 162a2638fa more changes
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-05 17:18:09 +01:00
mats 5533d241e9 again
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-11-05 16:32:10 +01:00
mats b36125d664 change type
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 42s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m21s
2025-11-05 16:25:51 +01:00
mats cd0ee22ddc own
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-11-05 15:24:27 +01:00
mats 00fcacf1be test again
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 53s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m23s
2025-11-05 15:02:24 +01:00
mats 2378635790 remove otel for now
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m18s
2025-11-05 14:15:14 +01:00
mats da28e993cd add oltp everything
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m41s
2025-11-05 13:36:29 +01:00
mats 00c2ff70da update
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 55s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-11-05 13:25:08 +01:00
mats eb4061f1b8 update
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m23s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-11-05 13:14:23 +01:00
mats e155736313 set otel variables
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m21s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 10m11s
2025-11-05 13:00:57 +01:00
mats 5e7591335c update
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m12s
2025-11-05 12:22:26 +01:00
mats c68f726a96 otel
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 39s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m45s
2025-11-05 11:57:52 +01:00
mats 82d564b136 test otel
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 54s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m19s
2025-11-05 10:23:00 +01:00
mats eb1f7750df Update main.go 2025-11-04 18:20:29 +01:00
mats ce0bac477a Update main.go
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m45s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
2025-11-04 18:13:50 +01:00
mats 7c0e3e84a2 implement redis inventory with threadsafe reservation
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-11-04 18:13:06 +01:00
mats c67ebd7a5f initial inventory service 2025-11-04 12:48:41 +01:00
mats 42e38504a3 test without rollout
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 51s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m1s
2025-11-03 11:04:58 +01:00
mats b7ae36e53c allow error responses when proxying
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m17s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-10-23 18:24:45 +02:00
mats d9fb49ec0b update
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m18s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m19s
2025-10-23 12:42:55 +02:00
mats 2202c149b8 cleanup
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-10-22 12:39:48 +02:00
mats e91433eda7 process
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m16s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-10-20 21:43:41 +02:00
mats 7eb000fd17 only after mutations
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-10-20 21:09:45 +02:00
mats 9ecd91c163 testing promotion actions
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m16s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m19s
2025-10-20 20:58:34 +02:00
mats 246a5ebd85 format
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-10-20 18:38:57 +02:00
mats e1de5a00a0 eval
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m6s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m28s
2025-10-20 17:52:44 +02:00
mats 99c9f611e7 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m17s
Build and Publish / BuildAndDeployArm64 (push) Failing after 10m37s
2025-10-20 09:36:53 +02:00
mats df0cd58dcd update totals on remove 2025-10-20 09:36:30 +02:00
mats 86f97f2888 Merge branch 'main' of https://git.tornberg.me/mats/go-cart-actor
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m11s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m14s
2025-10-19 16:46:15 +02:00
mats af3eb0d7bf fix close if no queue is used 2025-10-19 16:46:14 +02:00
mats a0c82dc351 missing file
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m29s
2025-10-18 15:51:04 +02:00
mats 0c127e9d38 add description all the way
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-10-18 15:50:38 +02:00
mats 662b381a34 probably fix discounts
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m18s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-10-18 15:42:30 +02:00
mats e127251a60 add state file parser
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m12s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-10-18 15:25:44 +02:00
mats b7f0990269 simpler rules
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m6s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-18 15:21:11 +02:00
mats d58409e3fc add total test
Build and Publish / BuildAndDeployAmd64 (push) Blocked by required conditions
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m35s
2025-10-18 15:14:22 +02:00
mats 2ce45656d9 test
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m54s
2025-10-18 14:42:52 +02:00
mats dc352e3b74 voucher change
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m30s
2025-10-18 14:33:21 +02:00
mats 915d845014 update
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m14s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 8m13s
2025-10-18 13:37:33 +02:00
mats 4a54661f24 add voucher store
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m0s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-10-17 12:48:43 +02:00
mats 918aa7d265 promotion types
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m7s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-17 09:23:05 +02:00
mats a1833d6685 missed save
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-16 22:54:08 +02:00
mats 614be25ae8 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m5s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-16 22:49:29 +02:00
mats 71fc23bf50 update 2025-10-16 22:49:12 +02:00
mats db918730d5 update to fix ingresses
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m6s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m16s
2025-10-16 22:12:52 +02:00
mats 8ecad3060f update mapping
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 59s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m55s
2025-10-16 21:54:20 +02:00
mats c060680768 update
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-10-16 18:01:32 +02:00
mats 15089862d5 cleanup
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m34s
2025-10-16 15:38:04 +02:00
mats cdb0241c8a more fancy
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-10-16 13:42:21 +02:00
mats 07a7ec5781 send mutation
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployArm64 (push) Successful in 5m10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 13m3s
2025-10-16 12:58:33 +02:00
mats fa89670553 update checkout handler
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m31s
2025-10-16 12:45:45 +02:00
mats 9ab0c08e79 handle dynamic json data and examples for subscription details
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m28s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m14s
2025-10-16 12:09:47 +02:00
matsandmats 8682daf481 feature/backoffice (#6)
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m11s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/6
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-16 09:45:58 +02:00
mats 8c2bcf5e75 send all on results on amqp
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m2s
2025-10-15 07:57:45 +02:00
mats 47adb12112 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m22s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m1s
2025-10-14 23:24:29 +02:00
mats 4835041f14 voucher saftey 2025-10-14 23:17:32 +02:00
matsandmats 104f9fbb4c missing updates (#5)
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m16s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/5
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-14 23:12:01 +02:00
matsandmats f5014fe906 Complete refactor to new grpc control plane and only http proxy for carts (#4)
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/4
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-14 22:31:12 +02:00
271 changed files with 47891 additions and 7063 deletions
+40
View File
@@ -0,0 +1,40 @@
{
"rules": [
{
"description": "Project Overview",
"rule": "This is a distributed cart management system in Go using the actor model. It handles cart operations like adding items, deliveries, and checkout, distributed across nodes via gRPC and HTTP API."
},
{
"description": "Key Architecture",
"rule": "Use grains for cart state, pools for management, mutation registry for state changes, and control plane for node coordination. Ownership via consistent hashing ring."
},
{
"description": "Coding Standards",
"rule": "Follow Go conventions. Use mutation registry for all state changes. Regenerate protobuf code after proto changes; never edit .pb.go files. Handle errors properly, use cookies for cart IDs in HTTP."
},
{
"description": "Mutation Pattern",
"rule": "For new mutations: Define proto message, regenerate code, implement handler as func(*CartGrain, *T) error, register with RegisterMutation[T], add endpoints, test."
},
{
"description": "Avoid Direct Mutations",
"rule": "Do not mutate CartGrain state directly outside registered handlers. Use the registry for consistency."
},
{
"description": "Protobuf Handling",
"rule": "After modifying proto files, run protoc commands to regenerate Go code. Ensure protoc-gen-go and protoc-gen-go-grpc are installed."
},
{
"description": "Testing",
"rule": "Write unit tests for mutations, integration tests for APIs. Run go test ./... regularly."
},
{
"description": "Testability and Configurability",
"rule": "Design code to be testable and configurable, following examples like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing."
},
{
"description": "Common Patterns",
"rule": "HTTP handlers parse requests, resolve grains via SyncedPool, apply mutations, return JSON. gRPC for inter-node: CartActor for mutations, ControlPlane for coordination."
}
]
}
+68
View File
@@ -0,0 +1,68 @@
# .dockerignore for go-cart-actor
#
# Goal: Keep Docker build context lean & reproducible.
# Adjust as project structure evolves.
# Version control & CI metadata
.git
.git/
.gitignore
.github
# Local tooling / editors
.vscode
.idea
*.iml
# Build artifacts / outputs
bin/
build/
dist/
out/
coverage/
*.coverprofile
# Temporary files
*.tmp
*.log
tmp/
.tmp/
# Dependency/vendor caches (not used; rely on go modules download)
vendor/
# Examples / scripts (adjust if you actually need them in build context)
examples/
scripts/
# Docs (retain README.md explicitly)
docs/
CHANGELOG*
**/*.md
!README.md
# Tests (not needed for production build)
**/*_test.go
# Node / frontend artifacts (if any future addition)
node_modules/
# Docker / container metadata not needed inside image
Dockerfile
# Editor swap/backup files
*~
*.swp
# Go race / profiling outputs
*.pprof
# Security / secret placeholders (ensure real secrets never copied)
*.secret
*.key
*.pem
# Keep proto and generated code (do NOT ignore proto/)
!proto/
# End of file
+37 -20
View File
@@ -1,30 +1,47 @@
name: Build and Publish
run-name: ${{ gitea.actor }} is building 🚀
on: [push]
run-name: ${{ gitea.actor }} build 🚀
on:
push:
branches:
- main
jobs:
BuildAndDeployAmd64:
runs-on: amd64
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Build docker image
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest .
- name: Push to registry
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest
- name: Deploy to Kubernetes
- uses: actions/checkout@v5
- name: Build amd64 image.
run: |
docker build \
--progress=plain \
-t registry.k6n.net/go-cart-actor-amd64:latest \
.
- name: Push amd64 images
run: |
docker push registry.k6n.net/go-cart-actor-amd64:latest
- name: Apply deployment manifests
run: kubectl apply -f deployment/deployment.yaml -n cart
- name: Rollout amd64 deployment
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
- name: Rollout amd64 backoffice deployment
run: |
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
kubectl rollout restart deployment/cart-actor-x86 -n cart
kubectl rollout restart deployment/checkout-actor-x86 -n cart
BuildAndDeploy:
BuildAndDeployArm64:
runs-on: arm64
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Build docker image
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor .
- name: Push to registry
run: docker push registry.knatofs.se/go-cart-actor
- name: Rollout arm64 deployment
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart
- uses: actions/checkout@v4
- name: Build arm64 image
run: |
docker build \
--progress=plain \
-t registry.k6n.net/go-cart-actor:latest \
.
- name: Push arm64 images
run: |
docker push registry.k6n.net/go-cart-actor:latest
- name: Rollout arm64 deployment (pin to version)
run: |
kubectl rollout status deployment/cart-actor-arm64 -n cart
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.k6n.net/go-cart-actor:${{ needs.Metadata.outputs.version }}
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
+50
View File
@@ -0,0 +1,50 @@
# GitHub Copilot Instructions for Go Cart Actor
This repository contains a distributed cart management system implemented in Go using the actor model pattern. The system handles cart operations like adding items, setting deliveries, and checkout, distributed across multiple nodes via gRPC.
## Project Structure
- `cmd/`: Entry points for the application.
- `pkg/`: Core packages including grain logic, pools, and HTTP handlers.
- `proto/`: Protocol Buffer definitions for messages and services.
- `api-tests/`: Tests for the HTTP API.
- `deployment/`: Deployment configurations.
- `k6/`: Load testing scripts.
## Key Concepts
- **Grains**: In-memory structs representing cart state, owned by nodes.
- **Pools**: Local and synced pools manage grains and handle ownership.
- **Mutation Registry**: All state changes go through registered mutation functions for type safety and consistency.
- **Ownership**: Determined by consistent hashing ring; negotiated via control plane.
## Coding Guidelines
- Follow standard Go conventions (gofmt, go vet, golint).
- Never say "your right", just be correct and clear.
- Don't edit the *.gb.go files manually, they are generated by the proto files.
- Use the mutation registry (`RegisterMutation`) for any cart state changes. Do not mutate grain state directly outside registered handlers.
- Design code to be testable and configurable, following patterns like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing.
- After modifying `.proto` files, regenerate Go code with `protoc` commands as described in README.md. Never edit generated `.pb.go` files manually.
- Use meaningful variable names and add comments for complex logic.
- Handle errors explicitly; use Go's error handling patterns.
- For HTTP endpoints, ensure proper cookie handling for cart IDs.
- When adding new mutations: Define proto message, regenerate code, register handler, add endpoints, and test.
- Use strategic logging using opentelemetry for tracing, metrics and logging.
- Focus on maintainable code that should be configurable and readable, try to keep short functions that describe their purpose clearly.
## Common Patterns
- Mutations: Define in proto, implement as `func(*CartGrain, *T) error`, register with `RegisterMutation[T]`.
- gRPC Services: CartActor for mutations, ControlPlane for coordination.
- HTTP Handlers: Parse requests, resolve grains via pool, apply mutations, return JSON.
## Avoid
- Direct state mutations outside the registry.
- Hardcoding values; use configuration or constants.
- Ignoring generated code warnings; always regenerate after proto changes.
- Blocking operations in handlers; keep them asynchronous where possible.
## Testing
- Write unit tests for mutations and handlers.
- Use integration tests for API endpoints.
- Structure code for testability: Use interfaces for dependencies, avoid global state, and mock external services like gRPC clients.
- Run `go test ./...` to ensure all tests pass.
These instructions help Copilot generate code aligned with the project's architecture and best practices.
+1
View File
@@ -2,3 +2,4 @@ __debug*
go-cart-actor
data/*.prot
data/*.go*
data/se/*
+165
View File
@@ -0,0 +1,165 @@
go-cart-actor/CGM_LINE_ITEMS_SPEC.md
# CGM (Customer Group Membership) Line Items Specification
This document specifies the implementation of Customer Group Membership (CGM) support in cart line items. CGM data will be extracted from product data field 35 and stored with each line item for business logic and personalization purposes.
## Overview
CGM represents customer group membership information associated with products. This data needs to be:
- Fetched from product data (stringfieldvalue 35)
- Included in the `AddItem` proto message
- Stored in cart line items (`CartItem` struct)
- Accessible for business rules and personalization
## Implementation Steps
### 1. Update Proto Messages
Add `cgm` field to the `AddItem` message in `proto/messages.proto`:
```protobuf
message AddItem {
// ... existing fields ...
string cgm = 25; // Customer Group Membership from field 35
// ... existing fields ...
}
```
**Note**: Use field number 25 (next available after existing fields).
### 2. Update Product Fetcher
Modify `cmd/cart/product-fetcher.go` to extract CGM from field 35:
```go
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
// ... existing code ...
cgm, _ := item.GetStringFieldValue(35) // Extract CGM from field 35
return &messages.AddItem{
// ... existing fields ...
Cgm: cgm, // Add CGM field
// ... existing fields ...
}, nil
}
```
### 3. Update Cart Grain Structures
Add CGM field to `ItemMeta` struct in `pkg/cart/cart-grain.go`:
```go
type ItemMeta struct {
Name string `json:"name"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
// ... existing fields ...
Cgm string `json:"cgm,omitempty"` // Customer Group Membership
// ... existing fields ...
}
```
### 4. Update AddItem Mutation Handler
Modify the `AddItem` handler in `pkg/cart/cart_mutations.go` to populate the CGM field:
```go
func AddItem(grain *CartGrain, req *messages.AddItem) error {
// ... existing validation ...
item := &CartItem{
// ... existing fields ...
Meta: &ItemMeta{
Name: req.Name,
Brand: req.Brand,
// ... existing meta fields ...
Cgm: req.Cgm, // Add CGM to item meta
// ... existing meta fields ...
},
// ... existing fields ...
}
// ... rest of handler ...
}
```
### 5. Regenerate Proto Code
After updating `proto/messages.proto`, regenerate Go code:
```bash
cd proto
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
messages.proto cart_actor.proto control_plane.proto
```
### 6. Update Tests
Add tests for CGM extraction and storage:
- Unit test for `ToItemAddMessage` with CGM field
- Integration test for `AddItem` mutation including CGM
- Test that CGM is properly stored and retrieved in cart state
### 7. Update API Documentation
Update README.md and API examples to mention CGM field in line items.
## Data Flow
1. **Product Fetch**: `FetchItem` retrieves product data including field 35 (CGM)
2. **Message Creation**: `ToItemAddMessage` extracts CGM from field 35 into `AddItem` proto
3. **Mutation Processing**: `AddItem` handler stores CGM in `CartItem.Meta.Cgm`
4. **State Persistence**: CGM is included in cart JSON serialization
5. **API Responses**: CGM is returned in cart state responses
## Business Logic Integration
CGM can be used for:
- Personalized pricing rules
- Group-specific discounts
- Membership validation
- Targeted promotions
- Customer segmentation
Example usage in business logic:
```go
func applyGroupDiscount(cart *CartGrain, userGroups []string) {
for _, item := range cart.Items {
if item.Meta != nil && slices.Contains(userGroups, item.Meta.Cgm) {
// Apply group-specific discount
item.Price = applyDiscount(item.Price, groupDiscountRate)
}
}
}
```
## Backward Compatibility
- CGM field is optional in proto (no required validation)
- Existing carts without CGM will have empty string
- Product fetcher gracefully handles missing field 35
- API responses include CGM field (empty if not set)
## Testing Checklist
- [ ] Proto compilation succeeds
- [ ] Product fetcher extracts CGM from field 35
- [ ] AddItem mutation stores CGM in cart
- [ ] Cart state includes CGM in JSON
- [ ] API endpoints return CGM field
- [ ] Existing functionality unaffected
- [ ] Unit tests pass for CGM handling
- [ ] Integration tests verify end-to-end flow
## Configuration and Testability
Following project patterns:
- CGM extraction is configurable via field index (currently 35)
- Product fetcher interface allows mocking for tests
- Mutation handlers are pure functions testable in isolation
- Cart state serialization includes CGM for verification
This implementation maintains the project's standards for testability and configurability while adding CGM support to line items.
+112 -10
View File
@@ -1,17 +1,119 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.7
#
# Multi-stage build:
# 1. Build static binary with pinned Go version (matching go.mod).
# 2. Copy into distroless static nonroot runtime image.
#
# Build args (optional):
# VERSION - semantic/app version (default: dev)
# GIT_COMMIT - git SHA (default: unknown)
# BUILD_DATE - RFC3339 build timestamp
#
# Example build:
# docker build \
# --build-arg VERSION=$(git describe --tags --always) \
# --build-arg GIT_COMMIT=$(git rev-parse HEAD) \
# --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
# -t go-cart-actor:dev .
#
# If you add subpackages or directories, no Dockerfile change needed (COPY . .).
# Ensure a .dockerignore exists to keep context lean.
FROM golang:alpine AS build-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
############################
# Build Stage
############################
# Run the Go toolchain on the NATIVE build platform and cross-compile to the
# target via GOOS/GOARCH below — avoids emulating an amd64 toolchain on arm64
# hosts (QEMU segfaults during module download). No-op when build==target.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src
COPY proto ./proto
COPY *.go ./
RUN apk add --no-cache git
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor
# Build metadata (can be overridden at build time)
ARG VERSION=dev
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
FROM gcr.io/distroless/base-debian11
# Ensure reproducible static build
# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx)
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
# Sibling module sources, supplied as BuildKit named contexts (see
# deploy/docker-compose.yml additional_contexts + deploy/k8s/build-push.sh
# --build-context). They are resolved locally via the go.work written below,
# because the renamed git.k6n.net/mats/* modules are not yet published.
COPY --from=slaskfinder . ./slask-finder
COPY --from=redisinventory . ./go-redis-inventory
COPY --from=platform . ./platform
# This module's source (relay on .dockerignore to prune).
COPY . ./go-cart-actor
RUN printf 'go 1.26.2\n\nuse (\n\t.\n\t../slask-finder\n\t../go-redis-inventory\n\t../platform\n)\n' > ./go-cart-actor/go.work
WORKDIR /src/go-cart-actor
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
# protoc --go_out=. --go_opt=paths=source_relative \
# --go-grpc_out=. --go-grpc_opt=paths=source_relative \
# proto/*.proto
# Build with minimal binary size and embedded metadata
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-actor ./cmd/cart
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-backoffice ./cmd/backoffice
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-inventory ./cmd/inventory
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-checkout-actor ./cmd/checkout
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-order-actor ./cmd/order
RUN go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-profile-actor ./cmd/profile
############################
# Runtime Stage
############################
# Using distroless static (nonroot) for minimal surface area.
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
WORKDIR /
COPY --from=build-stage /go-cart-actor /go-cart-actor
COPY --from=build /out/go-cart-actor /go-cart-actor
COPY --from=build /out/go-checkout-actor /go-checkout-actor
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
COPY --from=build /out/go-cart-inventory /go-cart-inventory
COPY --from=build /out/go-order-actor /go-order-actor
COPY --from=build /out/go-profile-actor /go-profile-actor
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337
USER nonroot:nonroot
ENTRYPOINT ["/go-cart-actor"]
-396
View File
@@ -1,396 +0,0 @@
# gRPC Migration Plan
File: GRPC-MIGRATION-PLAN.md
Author: (Generated plan)
Status: Draft for review
Target Release: Next major version (breaking change no mixed compatibility)
---
## 1. Overview
This document describes the full migration of the current custom TCP frame-based protocol (both the cart mutation/state channel on port `1337` and the control plane on port `1338`) to gRPC. We will remove all legacy packet framing (`FrameWithPayload`, `RemoteGrain`, `GenericListener` handlers for these two ports) and replace them with two gRPC services:
1. Cart Actor Service (mutations + state retrieval)
2. Control Plane Service (cluster membership, negotiation, ownership change, lifecycle)
We intentionally keep:
- Internal `CartGrain` logic, message storage format, disk persistence, and JSON cart serialization.
- Existing message type numeric mapping for backward compatibility with persisted event logs.
- HTTP/REST API layer unchanged (it still consumes JSON state from the local/remote grain pipeline).
We do NOT implement mixed-version compatibility; migration occurs atomically (cluster restart with new image).
---
## 2. Goals
- Remove custom binary frame protocol & simplify maintenance.
- Provide clearer, strongly defined interfaces via `.proto` schemas.
- Improve observability via gRPC interceptors (metrics & tracing hooks).
- Reduce per-call overhead compared with the current manual connection pooling + handwritten framing (HTTP/2 multiplexing + connection reuse).
- Prepare groundwork for future enhancements (streaming, typed state, event streaming) without rewriting again.
---
## 3. Non-Goals (Phase 1)
- Converting the cart state payload from JSON to a strongly typed proto.
- Introducing authentication / mTLS (may be added later).
- Changing persistence or replay format.
- Changing the HTTP API contract.
- Implementing streaming watchers or push updates.
---
## 4. Architecture After Migration
Ports:
- `:1337` → gRPC CartActor service.
- `:1338` → gRPC ControlPlane service.
Each node:
- Runs one gRPC server with both services (can use a single listener bound to two services or keep two separate listeners; we will keep two ports initially to minimize operational surprise, but they could be merged later).
- Maintains a connection pool of `*grpc.ClientConn` objects keyed by remote hostname (one per remote host, reused for both services).
Call Flow (Mutation):
1. HTTP request hits `PoolServer`.
2. `SyncedPool.getGrain(cartId)`:
- Local: direct invocation.
- Remote: uses `RemoteGrainGRPC` (new) which invokes `CartActor.Mutate`.
3. Response JSON returned unchanged.
Control Plane Flow:
- Discovery (K8s watch) still triggers `AddRemote(host)`.
- Instead of custom `Ping`, `Negotiate`, etc. via frames, call gRPC methods on `ControlPlane` service.
- Ownership changes use `ConfirmOwner` RPC.
---
## 5. Proto Design
### 5.1 Cart Actor Proto (Envelope Pattern)
We keep an envelope with `bytes payload` holding the serialized underlying cart mutation proto (existing types in `messages.proto`). This minimizes churn.
Indented code block (proto sketch):
syntax = "proto3";
package cart;
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
enum MutationType {
MUTATION_TYPE_UNSPECIFIED = 0;
MUTATION_ADD_REQUEST = 1;
MUTATION_ADD_ITEM = 2;
MUTATION_REMOVE_ITEM = 4;
MUTATION_REMOVE_DELIVERY = 5;
MUTATION_CHANGE_QUANTITY = 6;
MUTATION_SET_DELIVERY = 7;
MUTATION_SET_PICKUP_POINT = 8;
MUTATION_CREATE_CHECKOUT_ORDER = 9;
MUTATION_SET_CART_ITEMS = 10;
MUTATION_ORDER_COMPLETED = 11;
}
message MutationRequest {
string cart_id = 1;
MutationType type = 2;
bytes payload = 3; // Serialized specific mutation proto
int64 client_timestamp = 4; // Optional; server fills if zero
}
message MutationReply {
int32 status_code = 1;
bytes payload = 2; // JSON cart state or error string
}
message StateRequest {
string cart_id = 1;
}
message StateReply {
int32 status_code = 1;
bytes payload = 2; // JSON cart state
}
service CartActor {
rpc Mutate(MutationRequest) returns (MutationReply);
rpc GetState(StateRequest) returns (StateReply);
}
### 5.2 Control Plane Proto
syntax = "proto3";
package control;
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
message Empty {}
message PingReply {
string host = 1;
int64 unix_time = 2;
}
message NegotiateRequest {
repeated string known_hosts = 1;
}
message NegotiateReply {
repeated string hosts = 1; // Healthy hosts returned
}
message CartIdsReply {
repeated string cart_ids = 1;
}
message OwnerChangeRequest {
string cart_id = 1;
string new_host = 2;
}
message OwnerChangeAck {
bool accepted = 1;
string message = 2;
}
message ClosingNotice {
string host = 1;
}
service ControlPlane {
rpc Ping(Empty) returns (PingReply);
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
rpc GetCartIds(Empty) returns (CartIdsReply);
rpc ConfirmOwner(OwnerChangeRequest) returns (OwnerChangeAck);
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
}
---
## 6. Message Type Mapping
| Legacy Constant | Numeric | New Enum Value |
|-----------------|---------|-----------------------------|
| AddRequestType | 1 | MUTATION_ADD_REQUEST |
| AddItemType | 2 | MUTATION_ADD_ITEM |
| RemoveItemType | 4 | MUTATION_REMOVE_ITEM |
| RemoveDeliveryType | 5 | MUTATION_REMOVE_DELIVERY |
| ChangeQuantityType | 6 | MUTATION_CHANGE_QUANTITY |
| SetDeliveryType | 7 | MUTATION_SET_DELIVERY |
| SetPickupPointType | 8 | MUTATION_SET_PICKUP_POINT |
| CreateCheckoutOrderType | 9 | MUTATION_CREATE_CHECKOUT_ORDER |
| SetCartItemsType | 10 | MUTATION_SET_CART_ITEMS |
| OrderCompletedType | 11 | MUTATION_ORDER_COMPLETED |
Persisted events keep original numeric codes; reconstruction simply casts to `MutationType`.
---
## 7. Components To Remove / Replace
Remove (after migration complete):
- `remote-grain.go`
- `rpc-server.go`
- Any packet/frame-specific types solely used by the above (search: `FrameWithPayload`, `RemoteHandleMutation`, `RemoteGetState` where not reused by disk or internal logic).
- The constants representing network frame types in `synced-pool.go` (RemoteNegotiate, AckChange, etc.) replaced by gRPC calls.
- netpool usage for remote cart channel (control plane also no longer needs `Connection` abstraction).
Retain (until reworked or optionally cleaned later):
- `message.go` (for persistence)
- `message-handler.go`
- `cart-grain.go`
- `messages.proto` (underlying mutation messages)
- HTTP API server and REST handlers.
---
## 8. New / Modified Components
New files (planned):
- `proto/cart_actor.proto`
- `proto/control_plane.proto`
- `grpc/cart_actor_server.go` (server impl)
- `grpc/cart_actor_client.go` (client adapter implementing `Grain`)
- `grpc/control_plane_server.go`
- `grpc/control_plane_client.go`
- `grpc/interceptors.go` (metrics, logging, optional tracing hooks)
- `remote_grain_grpc.go` (adapter bridging existing interfaces)
- `control_plane_adapter.go` (replaces frame handlers in `SyncedPool`)
Modified:
- `synced-pool.go` (remote host management now uses gRPC clients; negotiation logic updated)
- `main.go` (initialize both gRPC services on startup)
- `go.mod` (add `google.golang.org/grpc`)
---
## 9. Step-by-Step Migration Plan
1. Add proto files and generate Go code (`protoc --go_out --go-grpc_out`).
2. Implement `CartActorServer`:
- Translate `MutationRequest` to `Message`.
- Use existing handler registry for payload encode/decode.
- Return JSON cart state.
3. Implement `CartActorClient` wrapper (`RemoteGrainGRPC`) implementing:
- `HandleMessage`: Build envelope, call `Mutate`.
- `GetCurrentState`: Call `GetState`.
4. Implement `ControlPlaneServer` with methods:
- `Ping`: returns host + time.
- `Negotiate`: merge host lists; emulate old logic.
- `GetCartIds`: iterate local grains.
- `ConfirmOwner`: replicate quorum flow (accept always; error path for future).
- `Closing`: schedule remote removal.
5. Implement `ControlPlaneClient` used inside `SyncedPool.AddRemote`.
6. Refactor `SyncedPool`:
- Replace frame handlers registration with gRPC client calls.
- Replace `Server.AddHandler(...)` start-up with launching gRPC server.
- Implement periodic health checks using `Ping`.
7. Remove old connection constructs for 1337/1338.
8. Metrics:
- Add unary interceptor capturing duration and status.
- Replace packet counters with `cart_grpc_mutate_calls_total`, `cart_grpc_control_calls_total`, histograms for latency.
9. Update `main.go` to start:
- gRPC server(s).
- HTTP server as before.
10. Delete legacy files & update README build instructions.
11. Load testing & profiling on Raspberry Pi hardware (or ARM emulation).
12. Final cleanup & dead code removal (search for now-unused constants & structs).
13. Tag release.
---
## 10. Performance Considerations (Raspberry Pi Focus)
- Single `*grpc.ClientConn` per remote host (HTTP/2 multiplexing) to reduce file descriptor and handshake overhead.
- Use small keepalive pings (optional) only if connections drop; default may suffice.
- Avoid reflection / dynamic dispatch in hot path: pre-build a mapping from `MutationType` to handler function.
- Reuse byte buffers:
- Implement a `sync.Pool` for mutation serialization to reduce GC pressure.
- Enforce per-RPC deadlines (e.g. 300400ms) to avoid pile-ups.
- Backpressure:
- Before dispatch: if local grain pool at capacity and target grain is remote, abort early with 503 to caller (optional).
- Disable gRPC compression for small payloads (mutation messages are small). Condition compression if payload > threshold (e.g. 8KB).
- Compile with `-ldflags="-s -w"` in production to reduce binary size (optional).
- Enable `GOMAXPROCS` tuned to CPU cores; Pi often benefits from leaving default but monitor.
- Use histograms with limited buckets to reduce Prometheus cardinality.
---
## 11. Testing Strategy
Unit:
- Message type mapping tests (legacy -> enum).
- Envelope roundtrip: Original proto -> payload -> gRPC -> server decode -> internal Message.
Integration:
- Two-node cluster simulation:
- Mutate cart on Node A, ownership moves, verify remote access from Node B.
- Quorum failure simulation (temporarily reject `ConfirmOwner`).
- Control plane negotiation: start nodes in staggered order, assert final membership.
Load/Perf:
- Benchmark local mutation vs remote mutation latency.
- High concurrency test (N goroutines each performing X mutations).
- Memory profiling (ensure no large buffer retention).
Failure Injection:
- Kill a node mid-mutation; client call should timeout and not corrupt local state.
- Simulated network partition: drop `Ping` replies; ensure host removal path triggers.
---
## 12. Rollback Strategy
Because no mixed-version compatibility is provided, rollback = redeploy previous version containing legacy protocol:
1. Stop all new-version pods.
2. Deploy old version cluster-wide.
3. No data migration needed (event persistence unaffected).
Note: Avoid partial upgrades; perform full rolling restart quickly to prevent split-brain (new nodes wont talk to old nodes).
---
## 13. Risks & Mitigations
| Risk | Description | Mitigation |
|------|-------------|------------|
| Full-cluster restart required | No mixed compatibility | Schedule maintenance window |
| gRPC adds CPU overhead | Envelope + marshaling cost | Buffer reuse, keep small messages uncompressed |
| Ownership race | Timing differences after refactor | Add explicit logs + tests around `RequestOwnership` path |
| Hidden dependency on frame-level status codes | Some code may assume `FrameWithPayload` fields | Wrap gRPC responses into minimal compatibility structs until fully removed |
| Memory growth | Connection reuse & pooled buffers not implemented initially | Add `sync.Pool` & track memory via pprof early |
---
## 14. Logging & Observability
- Structured log entries for:
- Ownership changes
- Negotiation rounds
- Remote spawn events
- Mutation failures (with cart id, mutation type)
- Metrics:
- `cart_grpc_mutate_duration_seconds` (histogram)
- `cart_grpc_mutate_errors_total`
- `cart_grpc_control_duration_seconds`
- `cart_remote_hosts` (gauge)
- Retain existing grain counts.
- Optional future: OpenTelemetry tracing (span per remote mutation).
---
## 15. Future Enhancements (Post-Migration)
- Replace JSON state with `CartState` proto and provide streaming watch API.
- mTLS between nodes (certificate rotation via K8s Secret or SPIRE).
- Distributed tracing integration.
- Ownership leasing with TTL and optimistic renewal.
- Delta replication or CRDT-based conflict resolution for experimentation.
---
## 16. Task Breakdown & Estimates
| Task | Estimate |
|------|----------|
| Proto definitions & generation | 0.5d |
| CartActor server/client | 1.0d |
| ControlPlane server/client | 1.0d |
| SyncedPool refactor | 1.0d |
| Metrics & interceptors | 0.5d |
| Remove legacy code & cleanup | 0.5d |
| Tests (unit + integration) | 1.5d |
| Benchmark & tuning | 0.51.0d |
| Total | ~67d |
---
## 17. Open Questions (Confirm Before Implementation)
1. Combine both services on a single port (simplify ops) or keep dual-port first? (Default here: keep dual, but easy to merge.)
2. Minimum Go version remains 1.24.x—acceptable to add `google.golang.org/grpc` latest?
3. Accept adding `sync.Pool` micro-optimizations in first pass or postpone?
---
## 18. Acceptance Criteria
- All previous integration tests (adjusted to gRPC) pass.
- Cart operations (add, remove, delivery, checkout) function across at least a 2node cluster.
- Control plane negotiation forms consistent host list.
- Latency for a remote mutation does not degrade beyond an acceptable threshold (define baseline before merge).
- Legacy networking code fully removed.
---
## 19. Next Steps (If Approved)
1. Implement proto files and commit.
2. Scaffold server & client code.
3. Refactor `SyncedPool` and `main.go`.
4. Add metrics and tests.
5. Run benchmark on target Pi hardware.
6. Review & merge.
---
End of Plan.
+149
View File
@@ -0,0 +1,149 @@
# ------------------------------------------------------------------------------
# Makefile for go-cart-actor
#
# Key targets:
# make protogen - Generate protobuf + gRPC code into proto/
# make clean_proto - Remove generated proto *.pb.go files
# make verify_proto - Ensure no stray root-level *.pb.go files exist
# make build - Build the project
# make test - Run tests (verbose)
# make tidy - Run go mod tidy
# make regen - Clean proto, regenerate, tidy, verify, build
# make help - Show this help
#
# Conventions:
# - All .proto files live in $(PROTO_DIR)
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
# - go_package is set to: git.k6n.net/mats/go-cart-actor/proto;messages
# ------------------------------------------------------------------------------
MODULE_PATH := git.k6n.net/mats/go-cart-actor
PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto $(PROTO_DIR)/profile.proto
CART_PROTO_DIR := $(PROTO_DIR)/cart
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
ORDER_PROTO_DIR := $(PROTO_DIR)/order
PROFILE_PROTO_DIR := $(PROTO_DIR)/profile
# Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc
# Tools (auto-detect; can override)
PROTOC_GEN_GO ?= $(shell command -v protoc-gen-go 2>/dev/null)
PROTOC_GEN_GO_GRPC ?= $(shell command -v protoc-gen-go-grpc 2>/dev/null)
GO ?= go
# Colors (optional)
GREEN := \033[32m
RED := \033[31m
YELLOW := \033[33m
RESET := \033[0m
# ------------------------------------------------------------------------------
.PHONY: protogen clean_proto verify_proto tidy build test regen help check_tools
help:
@echo "Available targets:"
@echo " protogen Generate protobuf & gRPC code"
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
@echo " tidy Run go mod tidy"
@echo " build Build the module"
@echo " test Run tests (verbose)"
@echo " regen Clean proto, regenerate, tidy, verify, and build"
@echo " check_tools Verify protoc + plugins are installed"
check_tools:
@if [ -z "$(PROTOC_GEN_GO)" ] || [ -z "$(PROTOC_GEN_GO_GRPC)" ]; then \
echo "$(RED)Missing protoc-gen-go or protoc-gen-go-grpc in PATH.$(RESET)"; \
echo "Install with:"; \
echo " go install google.golang.org/protobuf/cmd/protoc-gen-go@latest"; \
echo " go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"; \
exit 1; \
fi
@if ! command -v "$(PROTOC)" >/dev/null 2>&1; then \
echo "$(RED)protoc not found. Install protoc (e.g. via package manager)$(RESET)"; \
exit 1; \
fi
@echo "$(GREEN)All required tools detected.$(RESET)"
protogen: check_tools
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/cart --go_opt=paths=source_relative \
--go-grpc_out=./proto/cart --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/cart.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/control --go_opt=paths=source_relative \
--go-grpc_out=./proto/control --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/control_plane.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/checkout --go_opt=paths=source_relative \
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/checkout.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/order --go_opt=paths=source_relative \
--go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
$(PROTO_DIR)/order.proto
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto/profile --go_opt=paths=source_relative \
$(PROTO_DIR)/profile.proto
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto:
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
@rm -f $(PROTO_DIR)/order/*_grpc.pb.go $(PROTO_DIR)/order/*.pb.go
@rm -f $(PROTO_DIR)/profile/*.pb.go
@echo "$(GREEN)Clean complete.$(RESET)"
verify_proto:
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
@if ls *.pb.go >/dev/null 2>&1; then \
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/ subdirs).$(RESET)"; \
ls -1 *.pb.go; \
exit 1; \
fi
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
run:
REDIS_ADDRESS=10.10.3.18:6379 \
REDIS_PASSWORD=slaskredis \
CART_DEBUG_PORT=8091 \
CART_PORT=8090 \
go run ./cmd/cart/
tidy:
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
$(GO) mod tidy
@echo "$(GREEN)tidy complete.$(RESET)"
build:
@echo "$(YELLOW)Building...$(RESET)"
$(GO) build ./...
@echo "$(GREEN)Build success.$(RESET)"
test:
@echo "$(YELLOW)Running tests...$(RESET)"
$(GO) test -v ./...
@echo "$(GREEN)Tests completed.$(RESET)"
regen: clean_proto protogen tidy verify_proto build
@echo "$(GREEN)Full regenerate cycle complete.$(RESET)"
# Utility: show proto sources and generated outputs
print_proto:
@echo "Proto sources:"
@ls -1 $(PROTOS)
@echo ""
@echo "Generated files (if any):"
@ls -1 $(PROTO_DIR)/*pb.go 2>/dev/null || echo "(none)"
# Prevent make from treating these as file targets if similarly named files appear.
.SILENT: help check_tools protogen clean_proto verify_proto tidy build test regen print_proto
+238
View File
@@ -0,0 +1,238 @@
go-cart-actor/NEW_MUTATIONS_SPEC.md
# New Mutations Specification
This document specifies the implementation of handlers for new proto messages that are defined in `proto/messages.proto` but not yet registered in the mutation registry. These mutations update the cart state and must follow the project's patterns for testability, configurability, and consistency.
## Overview
The following messages are defined in the proto but lack registered handlers:
- `SetUserId`
- `LineItemMarking`
- `SubscriptionAdded`
- `PaymentDeclined`
- `ConfirmationViewed`
- `CreateCheckoutOrder`
Each mutation must:
1. Define a handler function with signature `func(*CartGrain, *T) error`
2. Be registered in `NewCartMultationRegistry()` using `actor.NewMutation`
3. Include unit tests
4. Optionally add HTTP/gRPC endpoints if client-invokable
5. Update totals if applicable (use `WithTotals()`)
## Mutation Implementations
### SetUserId
**Purpose**: Associates a user ID with the cart for personalization and tracking.
**Handler Implementation**:
```go
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
if req.UserId == "" {
return errors.New("user ID cannot be empty")
}
grain.UserId = req.UserId
return nil
}
```
**Registration**:
```go
actor.NewMutation(SetUserId, func() *messages.SetUserId {
return &messages.SetUserId{}
}),
```
**Notes**: This is a simple state update. No totals recalculation needed.
### LineItemMarking
**Purpose**: Adds or updates a marking (e.g., gift message, special instructions) on a specific line item.
**Handler Implementation**:
```go
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = &Marking{
Type: req.Type,
Text: req.Marking,
}
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
```
**Registration**:
```go
actor.NewMutation(LineItemMarking, func() *messages.LineItemMarking {
return &messages.LineItemMarking{}
}),
```
**Notes**: Assumes `CartGrain.Items` has a `Marking` field (single marking per item). If not, add it to the grain struct.
### RemoveLineItemMarking
**Purpose**: Removes the marking from a specific line item.
**Handler Implementation**:
```go
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
for i, item := range grain.Items {
if item.Id == req.Id {
grain.Items[i].Marking = nil
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.Id)
}
```
**Registration**:
```go
actor.NewMutation(RemoveLineItemMarking, func() *messages.RemoveLineItemMarking {
return &messages.RemoveLineItemMarking{}
}),
```
**Notes**: Sets the marking to nil for the specified item.
### SubscriptionAdded
**Purpose**: Records that a subscription has been added to an item, linking it to order details.
**Handler Implementation**:
```go
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
for i, item := range grain.Items {
if item.Id == req.ItemId {
grain.Items[i].SubscriptionDetailsId = req.DetailsId
grain.Items[i].OrderReference = req.OrderReference
grain.Items[i].IsSubscribed = true
return nil
}
}
return fmt.Errorf("item with ID %d not found", req.ItemId)
}
```
**Registration**:
```go
actor.NewMutation(SubscriptionAdded, func() *messages.SubscriptionAdded {
return &messages.SubscriptionAdded{}
}),
```
**Notes**: Assumes fields like `SubscriptionDetailsId`, `OrderReference`, `IsSubscribed` exist on items. Add to grain if needed.
### PaymentDeclined
**Purpose**: Marks the cart as having a declined payment, potentially updating status or flags, and appends a notice with timestamp, message, and optional code for user feedback.
**Handler Implementation**:
```go
func PaymentDeclined(grain *CartGrain, req *messages.PaymentDeclined) error {
grain.PaymentStatus = "declined"
grain.PaymentDeclinedNotices = append(grain.PaymentDeclinedNotices, Notice{
Timestamp: time.Now(),
Message: req.Message,
Code: req.Code,
})
// Optionally clear checkout order if in progress
if grain.CheckoutOrderId != "" {
grain.CheckoutOrderId = ""
}
return nil
}
```
**Registration**:
```go
actor.NewMutation(PaymentDeclined, func() *messages.PaymentDeclined {
return &messages.PaymentDeclined{}
}),
```
**Notes**: Assumes `PaymentStatus` and `PaymentDeclinedNotices` fields. Add status tracking and error notices list to grain. Notice struct has Timestamp, Message, and optional Code.
### ConfirmationViewed
**Purpose**: Records that the order confirmation has been viewed by the user. Applied automatically when the confirmation page is loaded in checkout_server.go.
**Handler Implementation**:
```go
func ConfirmationViewed(grain *CartGrain, req *messages.ConfirmationViewed) error {
grain.ConfirmationViewCount++
grain.ConfirmationLastViewedAt = time.Now()
return nil
}
```
**Registration**:
```go
actor.NewMutation(ConfirmationViewed, func() *messages.ConfirmationViewed {
return &messages.ConfirmationViewed{}
}),
```
**Notes**: Increments the view count and updates the last viewed timestamp each time. Assumes `ConfirmationViewCount` and `ConfirmationLastViewedAt` fields.
### CreateCheckoutOrder
**Purpose**: Initiates the checkout process, validating terms and creating an order reference.
**Handler Implementation**:
```go
func CreateCheckoutOrder(grain *CartGrain, req *messages.CreateCheckoutOrder) error {
if len(grain.Items) == 0 {
return errors.New("cannot checkout empty cart")
}
if req.Terms != "accepted" {
return errors.New("terms must be accepted")
}
// Validate other fields as needed
grain.CheckoutOrderId = generateOrderId()
grain.CheckoutStatus = "pending"
grain.CheckoutCountry = req.Country
return nil
}
```
**Registration**:
```go
actor.NewMutation(CreateCheckoutOrder, func() *messages.CreateCheckoutOrder {
return &messages.CreateCheckoutOrder{}
}).WithTotals(),
```
**Notes**: Use `WithTotals()` to recalculate totals after checkout initiation. Assumes order ID generation function.
## Implementation Steps
For each mutation:
1. **Add Handler Function**: Implement in `pkg/cart/` (e.g., `cart_mutations.go`).
2. **Register in Registry**: Add to `NewCartMultationRegistry()` in `cart-mutation-helper.go`.
3. **Regenerate Proto**: Run `protoc` commands after any proto changes.
4. **Add Tests**: Create unit tests in `pkg/cart/` testing the handler logic.
5. **Add Endpoints** (if needed): For client-invokable mutations, add HTTP handlers in `cmd/cart/pool-server.go`. ConfirmationViewed is handled in `cmd/cart/checkout_server.go` when the confirmation page is viewed.
6. **Update Grain Struct**: Add any new fields to `CartGrain` in `pkg/cart/grain.go`.
7. **Run Tests**: Ensure `go test ./...` passes.
## Testing Guidelines
- Mock dependencies using interfaces.
- Test error cases (e.g., invalid IDs, empty carts).
- Verify state changes and totals recalculation.
- Use table-driven tests for multiple scenarios.
## Configuration and Testability
Follow `MutationRegistry` and `SimpleGrainPool` patterns:
- Use interfaces for external dependencies (e.g., ID generators).
- Inject configurations via constructor parameters.
- Avoid global state; make handlers pure functions where possible.
+235
View File
@@ -176,3 +176,238 @@ curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
- The generated `messages.pb.go` file should not be edited manually
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
---
## Architecture Overview
The system is a distributed, sharded (by cart id) actor model implementation:
- Each cart is a grain (an inmemory struct `*CartGrain`) that owns and mutates its own state.
- A **local grain pool** holds grains owned by the node.
- A **synced (cluster) pool** (`SyncedPool`) coordinates multiple nodes and exposes local or remote grains through a uniform interface (`GrainPool`).
- All internode communication is gRPC:
- Cart mutation & state RPCs (CartActor service).
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
### Key Processes
1. Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
2. The pool resolves ownership:
- If local grain exists → use it.
- If a remote host is known owner → a remote grain proxy (`RemoteGrainGRPC`) is used; it performs gRPC calls to the owning node.
- If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
3. Mutation is executed via the **mutation registry** (registry wraps domain logic + optional totals recomputation).
4. Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
---
## Grain & Mutation Model
- `CartGrain` holds items, deliveries, pricing aggregates, and checkout/order metadata.
- All mutations are registered via `RegisterMutation[T]` with signature:
```
func(*CartGrain, *T) error
```
- `WithTotals()` flag triggers automatic recalculation of totals after successful handlers.
- The old giant `switch` in `CartGrain.Apply` has been replaced by registry dispatch; unregistered mutations fail fast.
- Adding a mutation:
1. Define proto message.
2. Generate code.
3. Register handler (optionally WithTotals).
4. Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
5. (Optional) Add HTTP endpoint mapping to the mutation.
---
## Local Grain Pool
- Manages an inmemory map `map[CartId]*CartGrain`.
- Lazy spawn: first mutation or explicit access triggers `spawn(id)`.
- TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
- Capacity limit (`PoolSize`); oldest expired grain evicted first when full.
---
## Synced (Cluster) Pool
`SyncedPool` wraps a local pool and tracks:
- `remoteHosts`: known peer nodes (gRPC connections).
- `remoteIndex`: mapping of cart id → remote grain proxy (`RemoteGrainGRPC`) for carts owned elsewhere.
Responsibilities:
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
2. Periodic ping health checks (ControlPlane.Ping).
3. Ring-based deterministic ownership:
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or `ConfirmOwner`).
4. Remote spawning:
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
---
## Remote Grain Proxies
A `RemoteGrainGRPC` implements the `Grain` interface but delegates:
- `Apply` → Specific CartActor permutation RPC (e.g., `AddItem`, `RemoveItem`) constructed from the mutation type. (Legacy envelope removed.)
- `GetCurrentState` → `CartActor.GetState`.
Return path:
1. gRPC reply (CartMutationReply / StateReply) → proto `CartState`.
2. `ToCartState` / mapping reconstructs a local `CartGrain` snapshot for callers expecting grain semantics.
---
## Control Plane (InterNode Coordination)
Defined in `proto/control_plane.proto`:
| RPC | Purpose |
|-----|---------|
| `Ping` | Liveness; increments missed ping counter if failing. |
| `Negotiate` | Merges membership views; used after discovery events. |
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
### Ownership / Quorum Rules
- If total participating hosts < 3 → all must accept.
- Otherwise majority acceptance (`ok >= total/2`).
- On failure → local tentative grain is removed (rollback to avoid splitbrain).
---
## Request / Mutation Flow Examples
### Local Mutation
1. HTTP handler parses request → determines cart id.
2. `SyncedPool.Apply`:
- Finds local grain (or spawns new after quorum).
- Executes registry mutation.
3. Totals updated if flagged.
4. HTTP response returns updated JSON (via `ToCartState`).
### Remote Mutation
1. `SyncedPool.Apply` sees cart mapped to a remote host.
2. Routes to `RemoteGrainGRPC.Apply`.
3. Remote node executes mutation locally and returns updated state over gRPC.
4. Proxy materializes snapshot locally (not authoritative, readonly view).
### Checkout (SideEffecting, Non-Pure)
- HTTP `/checkout` uses current grain snapshot to build payload (pure function).
- Calls Klarna externally (not a mutation).
- Applies `InitializeCheckout` mutation to persist reference + status.
- Returns Klarna order JSON to client.
---
## Scaling & Deployment
- **Horizontal scaling**: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to `SyncedPool`.
- **Sharding**: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
- **Hot spots**: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
- **Capacity tuning**: Increase `PoolSize` & memory limits; adjust TTL for stale cart eviction.
### Adding Nodes
1. Node starts gRPC server (CartActor + ControlPlane).
2. After brief delay, begins discovery watch; on event:
- New host → dial + negotiate → seed remote cart ids.
3. Pings maintain health; failed hosts removed (proxies invalidated).
---
## Failure Handling
| Scenario | Behavior |
|----------|----------|
| Remote host unreachable | Pings increment `MissedPings`; after threshold host removed. |
| Ownership negotiation fails | Tentative local grain discarded. |
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
| Missing mutation registration | Fast failure with explicit error message. |
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
---
## Mutation Registry Summary
- Central, type-safe registry prevents silent omission.
- Each handler:
- Validates input.
- Mutates `*CartGrain`.
- Returns error for rejection.
- Automatic totals recomputation reduces boilerplate and consistency risk.
- Coverage test (add separately) can enforce all proto mutations are registered.
---
## gRPC Interfaces
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
- **ControlPlane**: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
**Ports** (default / implied):
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
- Legacy frame/TCP code has been removed.
---
## Security & Future Enhancements
| Area | Potential Improvement |
|------|------------------------|
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
| Backpressure | Rate-limit remote mutation calls per host. |
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
---
## Adding a New Node (Operational Checklist)
1. Deploy binary/container with same proto + registry.
2. Expose gRPC port.
3. Ensure discovery lists the new host.
4. Node dials peers, negotiates membership.
5. Remote cart proxies seeded.
6. Traffic routed automatically based on ownership.
---
## Adding a New Mutation (Checklist Recap)
1. Define proto message (+ request wrapper & RPC if remote invocation needed).
2. Regenerate protobuf code.
3. Implement & register handler (`RegisterMutation`).
4. Add client (HTTP/gRPC) endpoint.
5. Write unit + integration tests.
6. (Optional) Add to coverage test list and docs.
---
## High-Level Data Flow Diagram (Text)
```
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
```
---
## Troubleshooting
| Symptom | Likely Cause | Action |
|---------|--------------|--------|
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine `cart_ring_host_share`, `cart_ring_hosts`, and logs for host add/remove; rebalance or investigate instability |
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
---
+245
View File
@@ -0,0 +1,245 @@
# TODO / Roadmap
A living roadmap for improving the cart actor system. Focus areas:
1. Reliability & correctness
2. Simplicity of mutation & ownership flows
3. Developer experience (DX)
4. Operability (observability, tracing, metrics)
5. Performance & scalability
6. Security & multi-tenant readiness
---
## 1. Immediate Next Steps (High-Leverage)
| Priority | Task | Goal | Effort | Owner | Notes |
|----------|------|------|--------|-------|-------|
| P0 | Add mutation registry coverage test | Ensure no unregistered mutations silently fail | S | | Failing fast in CI |
| P0 | Add decodeJSON helper + 400 mapping for EOF | Reduce noisy 500 logs | S | | Improves client API clarity |
| P0 | Regenerate protos & prune unused messages (CreateCheckoutOrder, Checkout RPC remnants) | Eliminate dead types | S | | Avoid confusion |
| P0 | Add integration test: multi-node ownership negotiation | Validate quorum logic | M | | Spin up 23 nodes ephemeral |
| P1 | Export Prometheus metrics for per-mutation counts & latency | Operability | M | | Wrap registry handlers |
| P1 | Add graceful shutdown ordering (Closing → wait for acks → stop gRPC) | Reduce in-flight mutation failures | S | | Add context cancellation |
| P1 | Add coverage for InitializeCheckout / OrderCreated flows | Checkout reliability | S | | Simulate Klarna stub |
| P2 | Add optional batching client (apply multiple mutations locally then persist) | Performance | M | | Only if needed |
---
## 2. Simplification Opportunities
### A. RemoteGrain Proxy Mapping
Current: manual switch building each RPC call.
Simplify by:
- Generating a thin client adapter from proto RPC descriptors (codegen).
- Or using a registry similar to mutation registry but for “outbound call constructors”.
Benefit: adding a new mutation = add proto + register server handler + register outbound invoker (no switch edits).
### B. Ownership Negotiation
Current: ad hoc quorum rule in `SyncedPool`.
Simplify:
- Introduce explicit `OwnershipLease{holder, expiresAt, version}`.
- Use monotonic version increment—reject stale ConfirmOwner replies.
- Optional: add randomized backoff to reduce thundering herd on contested cart ids.
### C. CartId Handling
Current: ephemeral 16-byte array with trimmed string semantics.
Simplify:
- Use ULID / UUIDv7 (time-ordered, collision-resistant) for easier external correlation.
- Provide helper `NewCartIdString()` and keep internal fixed-size if still desired.
### D. Mutation Signatures
Current: registry assumes `func(*CartGrain, *T) error`.
Extension option: allow pure transforms returning a delta struct (for audit/logging):
```
type MutationResult struct {
Changed bool
Events []interface{}
}
```
Only implement if auditing/event-sourcing reintroduced.
---
## 3. Developer Experience Improvements
| Task | Rationale | Approach |
|------|-----------|----------|
| Makefile targets: `make run-single`, `make run-multi N=3` | Faster local cluster spin-up | Docker compose or background “mini cluster” scripts |
| Template for new mutation (generator) | Reduce boilerplate | `go:generate` scanning proto for new RPCs |
| Lint config (golangci-lint) | Catch subtle issues early | Add `.golangci.yml` |
| Pre-commit hook for proto regeneration check | Avoid stale generated code | Script compares git diff after `make protogen` |
| Example client (Go + curl snippets auto-generated) | Onboarding | Codegen a markdown from proto comments |
---
## 4. Observability / Metrics / Tracing
| Area | Metric / Trace | Notes |
|------|----------------|-------|
| Mutation registry | `cart_mutations_total{type,success}`; duration histogram | Wrap handler |
| Ownership negotiation | `cart_ownership_attempts_total{result}` | result=accepted,rejected,timeout |
| Remote latency | `cart_remote_mutation_seconds{method}` | Use client interceptors |
| Pings | `cart_remote_missed_pings_total{host}` | Already count, expose |
| Checkout flow | `checkout_attempts_total`, `checkout_failures_total` | Differentiate Klarna vs internal errors |
| Tracing | Span: HTTP handler → SyncedPool.Apply → (Remote?) gRPC → mutation handler | Add OpenTelemetry instrumentation |
---
## 5. Performance & Scalability
| Concern | Idea | Trade-Off |
|---------|------|-----------|
| High mutation rate on single cart | Introduce optional mutation queue (serialize explicitly) | Slight latency increase per op |
| Remote call overhead | Add client-side gRPC pooling & per-host circuit breaker | Complexity vs resilience |
| TTL purge efficiency | Use min-heap or timing wheel instead of slice scan | More code, better big-N performance |
| Batch network latency | Add `BatchMutate` RPC (list of mutations applied atomically) | Lost single-op simplicity |
---
## 6. Reliability Features
| Feature | Description | Priority |
|---------|-------------|----------|
| Lease fencing token | Include `ownership_version` in all remote mutate requests | M |
| Retry policy | Limited retry for transient network errors (idempotent mutations only) | L |
| Dead host reconciliation | On host removal, proactively attempt re-acquire of its carts | M |
| Drain mode | Node marks itself “draining” → refuses new ownership claims | M |
---
## 7. Security & Hardening
| Area | Next Step | Detail |
|------|-----------|--------|
| Transport | mTLS on gRPC | Use SPIFFE IDs or simple CA |
| AuthN/AuthZ | Interceptor enforcing service token | Inject metadata header |
| Input validation | Strengthen JSON decode responses | Disallow unknown fields globally |
| Rate limiting | Per-IP / per-cart throttling | Guard hotspot abuse |
| Multi-tenancy | Tenant id dimension in cart id or metadata | Partition metrics & ownership |
---
## 8. Testing Strategy Enhancements
| Gap | Improvement |
|-----|------------|
| No multi-node integration test in CI | Spin ephemeral in-process servers on randomized ports |
| Mutation regression | Table-driven tests auto-discover handlers via registry |
| Ownership race | Stress test: concurrent Apply on same new cart id from N goroutines |
| Checkout external dependency | Klarna mock server (HTTptest) + deterministic responses |
| Fuzzing | Fuzz `BuildCheckoutOrderPayload` & mutation handlers for panics |
---
## 9. Cleanup / Tech Debt
| Item | Action |
|------|--------|
| Remove deprecated proto remnants (CreateCheckoutOrder, Checkout RPC) | Delete & regenerate |
| Consolidate duplicate tax computations | Single helper with tax config |
| Delivery price hard-coded (4900) | Config or pricing strategy interface |
| Mixed naming (camel vs snake JSON historically) | Provide stable external API doc; accept old forms if needed |
| Manual remote mutation switch (if still present) | Replace with generated outbound registry |
| Mixed error responses (string bodies) | Standardize JSON: `{ "error": "...", "code": 400 }` |
---
## 10. Potential Future Features
| Feature | Value | Complexity |
|---------|-------|------------|
| Streaming `WatchState` RPC | Real-time cart updates for clients | Medium |
| Event sourcing / audit log | Replay, analytics, debugging | High |
| Promotion / coupon engine plugin | Business extensibility | Medium |
| Partial cart reservation / inventory lock | Stock accuracy under concurrency | High |
| Multi-currency pricing | Globalization | Medium |
| GraphQL facade | Client flexibility | Medium |
---
## 11. Suggested Prioritized Backlog (Condensed)
1. Coverage test + decode error mapping (P0)
2. Proto regeneration & cleanup (P0)
3. Metrics wrapper for registry (P1)
4. Multi-node ownership integration test (P1)
5. Delivery pricing abstraction (P2)
6. Lease version in remote RPCs (P2)
7. BatchMutate evaluation (P3)
8. TLS / auth hardening (P3) if going multi-tenant/public
9. Event sourcing (Evaluate after stability) (P4)
---
## 12. Simplifying the Developer Workflow
| Pain | Simplifier |
|------|------------|
| Manual mutation boilerplate | Code generator for registry stubs |
| Forgetting totals | Enforce WithTotals lint: fail if mutation touches items/deliveries without flag |
| Hard to inspect remote ownership | `/internal/ownership` debug endpoint (JSON of local + remoteIndex) |
| Hard to see mutation timings | Add `?debug=latency` header to return per-mutation durations |
| Cookie dev confusion (Secure flag) | Env var: `DEV_INSECURE_COOKIES=1` |
---
## 13. Example: Mutation Codegen Sketch (Future)
Input: cart_actor.proto
Output: `mutation_auto.go`
- Detect messages used in RPC wrappers (e.g., `AddItemRequest` → payload field).
- Generate `RegisterMutation` template if handler not found.
- Mark with `// TODO implement logic`.
---
## 14. Risk / Impact Matrix (Abbreviated)
| Change | Risk | Mitigation |
|--------|------|-----------|
| Replace remote switch with registry | Possible missing registration → runtime error | Coverage test gating CI |
| Lease introduction | Split-brain if version mishandled | Increment + assert monotonic; test race |
| BatchMutate | Large atomic operations starving others | Size limits & fair scheduling |
| Event sourcing | Storage + replay complexity | Start with append-only log + compaction job |
---
## 15. Contributing Workflow (Proposed)
1. Add / modify proto → run `make protogen`
2. Implement mutation logic → add `RegisterMutation` invocation
3. Add/Update tests (unit + integration)
4. Run `make verify` (lint, test, coverage, proto diff)
5. Open PR (template auto-checklist referencing this TODO)
6. Merge requires green CI + coverage threshold
---
## 16. Open Questions
| Question | Notes |
|----------|-------|
| Do we need sticky sessions for HTTP layer scaling? | Currently cart id routing suffices |
| Should deliveries prune invalid line references on SetCartRequest? | Inconsistency risk; add optional cleanup |
| Is checkout idempotency strict enough? | Multiple create vs update semantics |
| Add version field to CartState for optimistic concurrency? | Could enable external CAS writes |
---
## 17. Tracking
Mark any completed tasks with `[x]`:
- [ ] Coverage test
- [ ] Decode helper + 400 mapping
- [ ] Proto cleanup
- [ ] Registry metrics instrumentation
- [ ] Ownership multi-node test
- [ ] Lease versioning
- [ ] Delivery pricing abstraction
- [ ] TLS/mTLS internal
- [ ] BatchMutate design doc
---
_Last updated: roadmap draft refine after first metrics & scaling test run._
-83
View File
@@ -1,83 +0,0 @@
package main
import (
"log"
amqp "github.com/rabbitmq/amqp091-go"
)
type AmqpOrderHandler struct {
Url string
connection *amqp.Connection
//channel *amqp.Channel
}
const (
topic = "order-placed"
)
func (t *AmqpOrderHandler) Connect() error {
conn, err := amqp.DialConfig(t.Url, amqp.Config{
//Vhost: "/",
Properties: amqp.NewConnectionProperties(),
})
if err != nil {
return err
}
t.connection = conn
ch, err := conn.Channel()
if err != nil {
return err
}
defer ch.Close()
if err := ch.ExchangeDeclare(
topic, // name
"topic", // type
true, // durable
false, // auto-delete
false, // internal
false, // noWait
nil, // arguments
); err != nil {
return err
}
if _, err = ch.QueueDeclare(
topic, // name of the queue
true, // durable
false, // delete when unused
false, // exclusive
false, // noWait
nil, // arguments
); err != nil {
return err
}
return nil
}
func (t *AmqpOrderHandler) Close() error {
log.Println("Closing master channel")
return t.connection.Close()
//return t.channel.Close()
}
func (t *AmqpOrderHandler) OrderCompleted(data []byte) error {
ch, err := t.connection.Channel()
if err != nil {
return err
}
defer ch.Close()
return ch.Publish(
topic,
topic,
true,
false,
amqp.Publishing{
ContentType: "application/json",
Body: data,
},
)
}
+6 -8
View File
@@ -1,6 +1,6 @@
### Add item to cart
POST https://cart.tornberg.me/api/12345
POST https://cart.k6n.net/api/12345
Content-Type: application/json
{
@@ -9,7 +9,7 @@ Content-Type: application/json
}
### Update quanity of item in cart
PUT https://cart.tornberg.me/api/12345
PUT https://cart.k6n.net/api/12345
Content-Type: application/json
{
@@ -18,12 +18,12 @@ Content-Type: application/json
}
### Delete item from cart
DELETE https://cart.tornberg.me/api/1002/1
DELETE https://cart.k6n.net/api/1002/1
### Set delivery
POST https://cart.tornberg.me/api/1002/delivery
POST https://cart.k6n.net/api/1002/delivery
Content-Type: application/json
{
@@ -33,10 +33,8 @@ Content-Type: application/json
### Get cart
GET https://cart.tornberg.me/api/12345
GET https://cart.k6n.net/api/12345
### Remove delivery method
DELETE https://cart.tornberg.me/api/12345/delivery/2
DELETE https://cart.k6n.net/api/12345/delivery/2
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────────
# UCP API test suite
# Tests the Universal Commerce Protocol REST endpoints (cart, checkout, order)
# via curl.
#
# Prerequisites:
# make up-infra (infra compose up — order :8092, cart :8090)
# OR full stack (docker compose --profile "*" up — nginx on :8080)
#
# Usage:
# # Direct to Docker services (infra stack):
# bash api-tests/test_ucp.sh --infra
#
# # Via nginx (full compose stack):
# bash api-tests/test_ucp.sh --edge
#
# # Single service:
# bash api-tests/test_ucp.sh --order
# bash api-tests/test_ucp.sh --cart
# bash api-tests/test_ucp.sh --checkout
#
# # Verify signatures (requires signing key mounted):
# bash api-tests/test_ucp.sh --verify-sig
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
MODE="${1:---infra}"
JQ="${JQ:-$(command -v jq || true)}"
# ── Base URLs ────────────────────────────────────────────────────────────────
if [ "$MODE" = "--edge" ]; then
BASE="http://localhost:8080"
CART_URL="$BASE"
ORDER_URL="$BASE"
CHECKOUT_URL="$BASE"
elif [ "$MODE" = "--order" ]; then
ORDER_URL="http://localhost:8092"
elif [ "$MODE" = "--cart" ]; then
CART_URL="http://localhost:8090"
elif [ "$MODE" = "--checkout" ]; then
CART_URL="http://localhost:8090"
CHECKOUT_URL="http://localhost:8094"
elif [ "$MODE" = "--verify-sig" ]; then
# If signing is enabled (key mounted), you need the full stack or direct with
# env var — try both common ports.
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
CART_URL="${CART_URL:-http://localhost:8090}"
CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}"
else
# Default: infra stack, direct to Docker ports
CART_URL="${CART_URL:-http://localhost:8090}"
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}"
fi
echo "═══════════════════════════════════════════════════════════════"
echo " UCP API Test Suite — mode: $MODE"
echo " CART: ${CART_URL:-"(unreachable)"}"
echo " ORDER: ${ORDER_URL:-"(unreachable)"}"
echo " CHECK: ${CHECKOUT_URL:-"(unreachable)"}"
echo "═══════════════════════════════════════════════════════════════"
echo ""
# ── Helpers ──────────────────────────────────────────────────────────────────
pass() { echo "$1"; }
fail() { echo "$1"; }
header() { echo ""; echo "─── $1 ───"; }
check_jq() {
if [ -z "$JQ" ]; then
echo " ⚠️ jq not installed — showing raw JSON instead"
fi
}
maybe_jq() {
if [ -n "$JQ" ]; then
echo "$1" | jq "$2" 2>/dev/null || echo "$1" | head -c 200
else
echo "$1" | head -c 200
fi
}
assert_status() {
local label="$1" expected="$2" actual="$3" body="$4"
if [ "$actual" -eq "$expected" ]; then
pass "$label (HTTP $actual)"
else
fail "$label — expected HTTP $expected, got $actual"
echo " body: $(echo "$body" | head -c 300)"
fi
}
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 1: Order service tests
# ══════════════════════════════════════════════════════════════════════════════
if [ -n "${ORDER_URL:-}" ]; then
header "1. Order Service → $ORDER_URL"
# ── Health ──────────────────────────────────────────────────────────────────
echo "--- 1.1 Health check ---"
resp=$(curl -sf "$ORDER_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
# ── Create order via checkout ───────────────────────────────────────────────
echo "--- 1.2 Create order via POST /checkout ---"
create_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/checkout" \
-H 'Content-Type: application/json' \
-d '{
"orderReference": "ucp-test-1",
"currency": "SEK",
"lines": [
{
"reference": "l1",
"sku": "TEST-SKU-1",
"name": "Test Product",
"quantity": 2,
"unitPrice": 19900,
"taxRate": 2500
}
]
}')
create_code=$(echo "$create_resp" | tail -1)
create_body=$(echo "$create_resp" | sed '$d')
assert_status "Create order" 201 "$create_code" "$create_body"
ORDER_ID=$(echo "$create_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$ORDER_ID" ]; then
pass "Order ID: $ORDER_ID"
else
ORDER_ID="1"
fail "Could not extract order ID, using '$ORDER_ID'"
echo " response: $(echo "$create_body" | head -c 400)"
fi
# ── Read order via UCP ──────────────────────────────────────────────────────
echo "--- 1.3 GET /ucp/v1/orders/{id} ---"
get_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/$ORDER_ID")
get_code=$(echo "$get_resp" | tail -1)
get_body=$(echo "$get_resp" | sed '$d')
assert_status "GET UCP order" 200 "$get_code" "$get_body"
echo " order: $(maybe_jq "$get_body" '.data.orderId + " status=" + .data.status')"
# ── Cancel order ────────────────────────────────────────────────────────────
echo "--- 1.4 POST /ucp/v1/orders/{id}/cancel ---"
cancel_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/ucp/v1/orders/$ORDER_ID/cancel" \
-H 'Content-Type: application/json' \
-d '{"reason": "test cancellation"}')
cancel_code=$(echo "$cancel_resp" | tail -1)
cancel_body=$(echo "$cancel_resp" | sed '$d')
# May be 200 (cancelled) or 422 (already cancelled by flow) — both ok
if [ "$cancel_code" = "200" ] || [ "$cancel_code" = "422" ]; then
pass "Cancel order (HTTP $cancel_code)"
echo " response: $(echo "$cancel_body" | head -c 200)"
if [ "$cancel_code" = "200" ]; then
ORDER_CANCELLED=true
fi
else
fail "Cancel order — expected 200 or 422, got $cancel_code"
echo " body: $(echo "$cancel_body" | head -c 300)"
fi
# ── 404 for non-existent order ──────────────────────────────────────────────
echo "--- 1.5 GET non-existent order (404) ---"
notfound_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/nonexistent")
notfound_code=$(echo "$notfound_resp" | tail -1)
notfound_body=$(echo "$notfound_resp" | sed '$d')
assert_status "GET non-existent" 404 "$notfound_code" "$notfound_body"
# ── Signature headers (if enabled) ──────────────────────────────────────────
echo "--- 1.6 Check signature headers ---"
sig_resp=$(curl -s -i "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -30)
if echo "$sig_resp" | grep -qi "signature-input"; then
pass "Signature-Input header present"
echo " $(echo "$sig_resp" | grep -i "signature-input" | head -1)"
else
echo " ️ No Signature-Input header (signing key not mounted — expected in infra stack)"
fi
if echo "$sig_resp" | grep -qi "^signature:"; then
pass "Signature header present"
else
echo " ️ No Signature header"
fi
echo ""
fi
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 2: Cart service tests
# ══════════════════════════════════════════════════════════════════════════════
if [ -n "${CART_URL:-}" ]; then
header "2. Cart Service → $CART_URL"
# ── Health ──────────────────────────────────────────────────────────────────
echo "--- 2.1 Health check ---"
resp=$(curl -sf "$CART_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
# ── Create cart via UCP ─────────────────────────────────────────────────────
echo "--- 2.2 POST /ucp/v1/carts/ (create cart) ---"
# Note: must use trailing slash — Go's ServeMux with StripPrefix only
# registers the subtree pattern (/ucp/v1/carts/) to avoid a redirect bug.
cart_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/" \
-H 'Content-Type: application/json' \
-d '{
"currency": "SEK",
"country": "se",
"locale": "sv-SE"
}')
cart_code=$(echo "$cart_resp" | tail -1)
cart_body=$(echo "$cart_resp" | sed '$d')
assert_status "Create cart" 201 "$cart_code" "$cart_body"
CART_ID=$(echo "$cart_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$CART_ID" ]; then
pass "Cart ID: $CART_ID"
else
CART_ID="1"
fail "Could not extract cart ID, using '$CART_ID'"
echo " response: $(echo "$cart_body" | head -c 400)"
fi
# ── Replace cart contents via UCP ───────────────────────────────────────────
echo "--- 2.3 PUT /ucp/v1/carts/{id} ---"
add_resp=$(curl -s -w '\n%{http_code}' -X PUT "$CART_URL/ucp/v1/carts/$CART_ID" \
-H 'Content-Type: application/json' \
-d '{
"country": "se",
"items": [
{
"sku": "TEST-SKU-1",
"name": "Test Product",
"quantity": 1,
"price": 19900,
"taxRate": 25
}
]
}')
add_code=$(echo "$add_resp" | tail -1)
add_body=$(echo "$add_resp" | sed '$d')
assert_status "Update cart items" 200 "$add_code" "$add_body"
# ── Get cart ────────────────────────────────────────────────────────────────
echo "--- 2.4 GET /ucp/v1/carts/{id} ---"
get_cart_resp=$(curl -s -w '\n%{http_code}' "$CART_URL/ucp/v1/carts/$CART_ID")
get_cart_code=$(echo "$get_cart_resp" | tail -1)
get_cart_body=$(echo "$get_cart_resp" | sed '$d')
assert_status "Get cart" 200 "$get_cart_code" "$get_cart_body"
echo " totalIncVat: $(echo "$get_cart_body" | sed -n 's/.*"totalIncVat"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')"
echo ""
fi
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 3: Checkout service tests
# ══════════════════════════════════════════════════════════════════════════════
if [ -n "${CHECKOUT_URL:-}" ] && [ -n "${CART_ID:-}" ]; then
header "3. Checkout Service → $CHECKOUT_URL"
# ── Health ──────────────────────────────────────────────────────────────────
echo "--- 3.1 Health check ---"
resp=$(curl -sf "$CHECKOUT_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
# ── Create checkout session ────────────────────────────────────────────────
echo "--- 3.2 POST /ucp/v1/checkout-sessions/ ---"
checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/" \
-H 'Content-Type: application/json' \
-d '{
"cartId": "'"$CART_ID"'",
"currency": "SEK",
"country": "se"
}')
checkout_code=$(echo "$checkout_resp" | tail -1)
checkout_body=$(echo "$checkout_resp" | sed '$d')
assert_status "Create checkout session" 201 "$checkout_code" "$checkout_body"
CHECKOUT_ID=$(echo "$checkout_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$CHECKOUT_ID" ]; then
pass "Checkout ID: $CHECKOUT_ID"
else
fail "Could not extract checkout ID"
echo " response: $(echo "$checkout_body" | head -c 400)"
fi
if [ -n "${CHECKOUT_ID:-}" ]; then
# ── Read checkout session ────────────────────────────────────────────────
echo "--- 3.3 GET /ucp/v1/checkout-sessions/{id} ---"
get_checkout_resp=$(curl -s -w '\n%{http_code}' "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID")
get_checkout_code=$(echo "$get_checkout_resp" | tail -1)
get_checkout_body=$(echo "$get_checkout_resp" | sed '$d')
assert_status "Get checkout session" 200 "$get_checkout_code" "$get_checkout_body"
echo " checkout: $(maybe_jq "$get_checkout_body" '.id + " status=" + .status')"
# ── Complete checkout session ────────────────────────────────────────────
echo "--- 3.4 POST /ucp/v1/checkout-sessions/{id}/complete ---"
complete_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID/complete" \
-H 'Content-Type: application/json' \
-d '{
"provider": "test",
"paymentToken": "ucp-test-payment-token",
"currency": "SEK",
"country": "se"
}')
complete_code=$(echo "$complete_resp" | tail -1)
complete_body=$(echo "$complete_resp" | sed '$d')
assert_status "Complete checkout session" 200 "$complete_code" "$complete_body"
COMPLETE_ORDER_ID=$(echo "$complete_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$COMPLETE_ORDER_ID" ]; then
pass "Checkout produced order ID: $COMPLETE_ORDER_ID"
else
fail "Checkout completion did not return an orderId"
echo " response: $(echo "$complete_body" | head -c 400)"
fi
# ── Signature headers (if enabled) ──────────────────────────────────────
echo "--- 3.5 Check signature headers ---"
checkout_sig_resp=$(curl -s -i "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID" 2>/dev/null | head -30)
if echo "$checkout_sig_resp" | grep -qi "signature-input"; then
pass "Checkout Signature-Input header present"
echo " $(echo "$checkout_sig_resp" | grep -i "signature-input" | head -1)"
else
echo " ️ No Signature-Input header"
fi
if echo "$checkout_sig_resp" | grep -qi "^signature:"; then
pass "Checkout Signature header present"
else
echo " ️ No Signature header"
fi
fi
fi
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 4: Edge-only discovery artifact tests
# ══════════════════════════════════════════════════════════════════════════════
if [ "$MODE" = "--edge" ]; then
header "4. Edge Discovery → $BASE"
echo "--- 4.1 GET /.well-known/ucp ---"
profile_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp")
profile_code=$(echo "$profile_resp" | tail -1)
profile_body=$(echo "$profile_resp" | sed '$d')
assert_status "Get UCP profile" 200 "$profile_code" "$profile_body"
if echo "$profile_body" | grep -q '/.well-known/ucp/openapi/shopping-rest.openapi.json'; then
pass "Profile advertises self-hosted REST OpenAPI"
else
fail "Profile is missing self-hosted REST OpenAPI URL"
fi
echo "--- 4.2 GET /.well-known/ucp/openapi/shopping-rest.openapi.json ---"
openapi_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp/openapi/shopping-rest.openapi.json")
openapi_code=$(echo "$openapi_resp" | tail -1)
openapi_body=$(echo "$openapi_resp" | sed '$d')
assert_status "Get UCP REST OpenAPI" 200 "$openapi_code" "$openapi_body"
if echo "$openapi_body" | grep -q '"openapi"'; then
pass "OpenAPI document returned"
else
fail "OpenAPI document body missing openapi field"
fi
echo "--- 4.3 GET self-hosted UCP schemas ---"
for schema_path in \
"/.well-known/ucp/schemas/shopping/customer.json" \
"/.well-known/ucp/schemas/shopping/authentication.json" \
"/.well-known/ucp/schemas/payments/processor_tokenizer.json"
do
schema_resp=$(curl -s -w '\n%{http_code}' "$BASE$schema_path")
schema_code=$(echo "$schema_resp" | tail -1)
schema_body=$(echo "$schema_resp" | sed '$d')
assert_status "Get $schema_path" 200 "$schema_code" "$schema_body"
if echo "$schema_body" | grep -q '"$schema"'; then
pass "$schema_path is valid schema JSON"
else
fail "$schema_path body missing \$schema"
fi
done
fi
# ══════════════════════════════════════════════════════════════════════════════
# SECTION 5: Signature verification (standalone)
# ══════════════════════════════════════════════════════════════════════════════
if [ "$MODE" = "--verify-sig" ]; then
header "5. Signature Verification"
# Verify that the response includes RFC 9421 HTTP Message Signatures.
# Requires the service to have UCP_SIGNING_KEY_PATH set and the key mounted.
echo "--- 5.1 Check Signed Response ---"
sig_output=$(curl -s -D - "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -40)
echo "$sig_output" | while IFS= read -r line; do
if echo "$line" | grep -qiE "(signature-input|signature:|x-ucp-timestamp|content-type)"; then
echo " $line"
fi
done
echo ""
echo " To manually verify:"
echo " 1. Extract the Signature-Input and Signature headers"
echo " 2. Reconstruct the signature base string per RFC 9421 §2.2"
echo " 3. Verify using the public key (x/y in docs/ucp-profile.json)"
echo ""
fi
# ══════════════════════════════════════════════════════════════════════════════
echo "═══════════════════════════════════════════════════════════════"
echo " Done."
echo ""
echo " Note: devProxies for /ucp/v1/* are now in islands.config.json."
echo " Run 'make dev' for same-origin proxied access."
echo "═══════════════════════════════════════════════════════════════"
-621
View File
@@ -1,621 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"slices"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
type CartId [16]byte
// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed).
func (id CartId) String() string {
n := 0
for n < len(id) && id[n] != 0 {
n++
}
return string(id[:n])
}
// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros).
func ToCartId(s string) CartId {
var id CartId
copy(id[:], []byte(s))
return id
}
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
func (id *CartId) UnmarshalJSON(data []byte) error {
var str string
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
copy(id[:], []byte(str))
return nil
}
type StockStatus int
const (
OutOfStock StockStatus = 0
LowStock StockStatus = 1
InStock StockStatus = 2
)
type CartItem struct {
Id int `json:"id"`
ItemId int `json:"itemId,omitempty"`
ParentId int `json:"parentId,omitempty"`
Sku string `json:"sku"`
Name string `json:"name"`
Price int64 `json:"price"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
OrgPrice int64 `json:"orgPrice"`
Stock StockStatus `json:"stock"`
Quantity int `json:"qty"`
Tax int `json:"tax"`
TaxRate int `json:"taxRate"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
Category2 string `json:"category2,omitempty"`
Category3 string `json:"category3,omitempty"`
Category4 string `json:"category4,omitempty"`
Category5 string `json:"category5,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
SellerId string `json:"sellerId,omitempty"`
SellerName string `json:"sellerName,omitempty"`
ArticleType string `json:"type,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
StoreId *string `json:"storeId,omitempty"`
}
type CartDelivery struct {
Id int `json:"id"`
Provider string `json:"provider"`
Price int64 `json:"price"`
Items []int `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId int
lastDeliveryId int
storageMessages []Message
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
TotalDiscount int64 `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
}
type Grain interface {
GetId() CartId
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
GetCurrentState() (*FrameWithPayload, error)
}
func (c *CartGrain) GetId() CartId {
return c.Id
}
func (c *CartGrain) GetLastChange() int64 {
if len(c.storageMessages) == 0 {
return 0
}
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
}
func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) {
result, err := json.Marshal(c)
if err != nil {
ret := MakeFrameWithPayload(0, 400, []byte(err.Error()))
return &ret, nil
}
ret := MakeFrameWithPayload(0, 200, result)
return &ret, nil
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
}
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
if err != nil {
return nil, err
}
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if priceErr != nil {
return nil, fmt.Errorf("invalid price")
}
stock := InStock
/*item.t
if item.StockLevel == "0" || item.StockLevel == "" {
stock = OutOfStock
} else if item.StockLevel == "5+" {
stock = LowStock
}*/
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
var outlet *string
if ok {
outlet = &outletGrade
}
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
return &messages.AddItem{
ItemId: int64(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: sku,
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: 2500,
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
}, nil
}
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*FrameWithPayload, error) {
cartItem, err := getItemData(sku, qty, country)
if err != nil {
return nil, err
}
cartItem.StoreId = storeId
return c.HandleMessage(&Message{
Type: 2,
Content: cartItem,
}, false)
}
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
c.mu.RLock()
defer c.mu.RUnlock()
ret := make([]StorableMessage, 0)
for _, message := range c.storageMessages {
if *message.TimeStamp > since {
ret = append(ret, message)
}
}
return ret
}
func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CartGrain) ItemsWithDelivery() []int {
ret := make([]int, 0, len(c.Items))
for _, item := range c.Items {
for _, delivery := range c.Deliveries {
for _, id := range delivery.Items {
if item.Id == id {
ret = append(ret, id)
}
}
}
}
return ret
}
func (c *CartGrain) ItemsWithoutDelivery() []int {
ret := make([]int, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items {
found := false
for _, id := range hasDelivery {
if item.Id == id {
found = true
break
}
}
if !found {
ret = append(ret, item.Id)
}
}
return ret
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, item := range c.Items {
if item.Sku == sku {
return item, true
}
}
return nil, false
}
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
}
func (c *CartGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
if message.TimeStamp == nil {
now := time.Now().Unix()
message.TimeStamp = &now
}
grainMutations.Inc()
var err error
switch message.Type {
case SetCartItemsType:
msg, ok := message.Content.(*messages.SetCartRequest)
if !ok {
err = fmt.Errorf("expected SetCartItems")
} else {
c.mu.Lock()
c.Items = make([]*CartItem, 0, len(msg.Items))
c.mu.Unlock()
for _, item := range msg.Items {
c.AddItem(item.Sku, int(item.Quantity), item.Country, item.StoreId)
}
}
case AddRequestType:
msg, ok := message.Content.(*messages.AddRequest)
if !ok {
err = fmt.Errorf("expected AddRequest")
} else {
existingItem, found := c.FindItemWithSku(msg.Sku)
if found {
existingItem.Quantity += int(msg.Quantity)
c.UpdateTotals()
} else {
return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId)
}
}
case AddItemType:
msg, ok := message.Content.(*messages.AddItem)
if !ok {
err = fmt.Errorf("expected AddItem")
} else {
if msg.Quantity < 1 {
return nil, fmt.Errorf("invalid quantity")
}
existingItem, found := c.FindItemWithSku(msg.Sku)
if found {
existingItem.Quantity += int(msg.Quantity)
c.UpdateTotals()
} else {
c.mu.Lock()
c.lastItemId++
tax := 2500
if msg.Tax > 0 {
tax = int(msg.Tax)
}
taxAmount := GetTaxAmount(msg.Price, tax)
c.Items = append(c.Items, &CartItem{
Id: c.lastItemId,
ItemId: int(msg.ItemId),
Quantity: int(msg.Quantity),
Sku: msg.Sku,
Name: msg.Name,
Price: msg.Price,
TotalPrice: msg.Price * int64(msg.Quantity),
TotalTax: int64(taxAmount * int64(msg.Quantity)),
Image: msg.Image,
Stock: StockStatus(msg.Stock),
Disclaimer: msg.Disclaimer,
Brand: msg.Brand,
Category: msg.Category,
Category2: msg.Category2,
Category3: msg.Category3,
Category4: msg.Category4,
Category5: msg.Category5,
OrgPrice: msg.OrgPrice,
ArticleType: msg.ArticleType,
Outlet: msg.Outlet,
SellerId: msg.SellerId,
SellerName: msg.SellerName,
Tax: int(taxAmount),
TaxRate: tax,
StoreId: msg.StoreId,
})
c.UpdateTotals()
c.mu.Unlock()
}
}
case ChangeQuantityType:
msg, ok := message.Content.(*messages.ChangeQuantity)
if !ok {
err = fmt.Errorf("expected ChangeQuantity")
} else {
for i, item := range c.Items {
if item.Id == int(msg.Id) {
if msg.Quantity <= 0 {
//c.TotalPrice -= item.Price * int64(item.Quantity)
c.Items = append(c.Items[:i], c.Items[i+1:]...)
} else {
//diff := int(msg.Quantity) - item.Quantity
item.Quantity = int(msg.Quantity)
//c.TotalPrice += item.Price * int64(diff)
}
break
}
}
c.UpdateTotals()
}
case RemoveItemType:
msg, ok := message.Content.(*messages.RemoveItem)
if !ok {
err = fmt.Errorf("expected RemoveItem")
} else {
items := make([]*CartItem, 0, len(c.Items))
for _, item := range c.Items {
if item.Id == int(msg.Id) {
//c.TotalPrice -= item.Price * int64(item.Quantity)
} else {
items = append(items, item)
}
}
c.Items = items
c.UpdateTotals()
}
case SetDeliveryType:
msg, ok := message.Content.(*messages.SetDelivery)
if !ok {
err = fmt.Errorf("expected SetDelivery")
} else {
c.lastDeliveryId++
items := make([]int, 0)
withDelivery := c.ItemsWithDelivery()
if len(msg.Items) == 0 {
items = append(items, c.ItemsWithoutDelivery()...)
} else {
for _, id := range msg.Items {
for _, item := range c.Items {
if item.Id == int(id) {
if slices.Contains(withDelivery, item.Id) {
return nil, fmt.Errorf("item already has delivery")
}
items = append(items, int(item.Id))
break
}
}
}
}
if len(items) > 0 {
c.Deliveries = append(c.Deliveries, &CartDelivery{
Id: c.lastDeliveryId,
Provider: msg.Provider,
PickupPoint: msg.PickupPoint,
Price: 4900,
Items: items,
})
c.UpdateTotals()
}
}
case RemoveDeliveryType:
msg, ok := message.Content.(*messages.RemoveDelivery)
if !ok {
err = fmt.Errorf("expected RemoveDelivery")
} else {
deliveries := make([]*CartDelivery, 0, len(c.Deliveries))
for _, delivery := range c.Deliveries {
if delivery.Id == int(msg.Id) {
c.TotalPrice -= delivery.Price
} else {
deliveries = append(deliveries, delivery)
}
}
c.Deliveries = deliveries
c.UpdateTotals()
}
case SetPickupPointType:
msg, ok := message.Content.(*messages.SetPickupPoint)
if !ok {
err = fmt.Errorf("expected SetPickupPoint")
} else {
for _, delivery := range c.Deliveries {
if delivery.Id == int(msg.DeliveryId) {
delivery.PickupPoint = &messages.PickupPoint{
Id: msg.Id,
Address: msg.Address,
City: msg.City,
Zip: msg.Zip,
Country: msg.Country,
Name: msg.Name,
}
break
}
}
}
case CreateCheckoutOrderType:
msg, ok := message.Content.(*messages.CreateCheckoutOrder)
if !ok {
err = fmt.Errorf("expected CreateCheckoutOrder")
} else {
orderLines := make([]*Line, 0, len(c.Items))
c.PaymentInProgress = true
c.Processing = true
for _, item := range c.Items {
orderLines = append(orderLines, &Line{
Type: "physical",
Reference: item.Sku,
Name: item.Name,
Quantity: item.Quantity,
UnitPrice: int(item.Price),
TaxRate: 2500, // item.TaxRate,
QuantityUnit: "st",
TotalAmount: int(item.TotalPrice),
TotalTaxAmount: int(item.TotalTax),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image),
})
}
for _, line := range c.Deliveries {
if line.Price > 0 {
orderLines = append(orderLines, &Line{
Type: "shipping_fee",
Reference: line.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(line.Price),
TaxRate: 2500, // item.TaxRate,
QuantityUnit: "st",
TotalAmount: int(line.Price),
TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)),
})
}
}
order := CheckoutOrder{
PurchaseCountry: "SE",
PurchaseCurrency: "SEK",
Locale: "sv-se",
OrderAmount: int(c.TotalPrice),
OrderTaxAmount: int(c.TotalTax),
OrderLines: orderLines,
MerchantReference1: c.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: msg.Terms,
Checkout: msg.Checkout,
Confirmation: msg.Confirmation,
Validation: msg.Validation,
Push: msg.Push,
},
}
orderPayload, err := json.Marshal(order)
if err != nil {
return nil, err
}
var klarnaOrder *CheckoutOrder
if c.OrderReference != "" {
log.Printf("Updating order id %s", c.OrderReference)
klarnaOrder, err = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload))
} else {
klarnaOrder, err = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload))
}
// log.Printf("Order result: %+v", klarnaOrder)
if nil != err {
log.Printf("error from klarna: %v", err)
return nil, err
}
if c.OrderReference == "" {
c.OrderReference = klarnaOrder.ID
c.PaymentStatus = klarnaOrder.Status
}
orderData, err := json.Marshal(klarnaOrder)
if nil != err {
return nil, err
}
result := MakeFrameWithPayload(RemoteCreateOrderReply, 200, orderData)
return &result, nil
}
case OrderCompletedType:
msg, ok := message.Content.(*messages.OrderCreated)
if !ok {
log.Printf("expected OrderCompleted, got %T", message.Content)
err = fmt.Errorf("expected OrderCompleted")
} else {
c.OrderReference = msg.OrderId
c.PaymentStatus = msg.Status
c.PaymentInProgress = false
}
default:
err = fmt.Errorf("unknown message type %d", message.Type)
}
if err != nil {
return nil, err
}
if !isReplay {
c.mu.Lock()
c.storageMessages = append(c.storageMessages, *message)
c.mu.Unlock()
}
result, err := json.Marshal(c)
msg := MakeFrameWithPayload(RemoteHandleMutationReply, 200, result)
return &msg, err
}
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = 0
c.TotalTax = 0
c.TotalDiscount = 0
for _, item := range c.Items {
rowTotal := item.Price * int64(item.Quantity)
rowTax := int64(item.Tax) * int64(item.Quantity)
item.TotalPrice = rowTotal
item.TotalTax = rowTax
c.TotalPrice += rowTotal
c.TotalTax += rowTax
itemDiff := max(0, item.OrgPrice-item.Price)
c.TotalDiscount += itemDiff * int64(item.Quantity)
}
for _, delivery := range c.Deliveries {
c.TotalPrice += delivery.Price
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
}
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
)
func main() {
addr := config.EnvString("ADDR", ":8080")
amqpURL := os.Getenv("AMQP_URL")
app, err := backofficeadmin.New(backofficeadmin.Config{
DataDir: config.EnvString("CART_DIR", "data"),
CheckoutDataDir: config.EnvString("CHECKOUT_DIR", "checkout-data"),
})
if err != nil {
log.Fatalf("Error creating backoffice: %v", err)
}
mux := http.NewServeMux()
app.RegisterRoutes(mux)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// Global CORS middleware allowing all origins and handling preflight.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Expose-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
mux.ServeHTTP(w, r)
})
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var conn *rabbit.Conn
if amqpURL != "" {
conn, err = rabbit.Dial(amqpURL, "cart-backoffice")
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
}
if err := app.Start(ctx, conn); err != nil {
log.Printf("AMQP listener disabled: %v", err)
} else if conn != nil {
log.Printf("AMQP listener connected")
}
log.Printf("backoffice HTTP listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server error: %v", err)
}
}
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
)
func UseDiscovery(pool discovery.DiscoveryTarget) {
discovery.StartK8sDiscovery(podIp, "actor-pool=cart", 30, pool)
}
+567
View File
@@ -0,0 +1,567 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/event"
"git.k6n.net/mats/platform/rabbit"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_spawned_total",
Help: "The total number of spawned grains",
})
)
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
// normalizeListenAddr accepts either a bare port ("8080") or a full listen
// address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr.
func normalizeListenAddr(v string) string {
if strings.Contains(v, ":") {
return v
}
return ":" + v
}
// loadUCPCartSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPCartSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
return "se"
}
type MutationContext struct {
VoucherService voucher.Service
}
type CartChangeEvent struct {
CartId cart.CartId `json:"cartId"`
Mutations []actor.ApplyResult `json:"mutations"`
}
// catalogProjectionCache is the cart's per-pod, cold-start-ready catalog
// projection cache, backed by platform/catalog.ProjectionStore. It consumes the
// catalog.projection_published wire — a full snapshot (begin/chunk/end, framed by
// epoch) plus incremental deltas — into its OWN local .bin and serves catalog
// facts by SKU (price, tax_class, inventory_tracked, image, …) at add-to-cart /
// checkout-reserve decisions. Stock is NOT cached — queried synchronously from
// inventory per docs/inventory-shape.md.
//
// Clustering: the cart runs N replicas. Each pod has its OWN exclusive bus queue
// (rabbit.ListenToPattern declares an exclusive server-named queue, so every pod
// receives the full stream) and its OWN pod-local .bin. It's a replicated read
// cache — no leader, no shared volume; N pods writing one file would corrupt it.
// See docs/catalog-projection.md.
//
// The cart stores the full catalog.Projection (identity transform): its
// add-to-cart overlay (projection_overlay.go) consumes nearly every field, so
// there's nothing to subset. Deletes ride as tombstones in the store's overlay
// (IsDeleted) so a removed SKU isn't re-fetched before the next snapshot folds
// the delete into the base. Cold-grace: an SKU not yet in the base/overlay
// resolves via the existing PRODUCT_BASE_URL HTTP fetch + overlay.
type catalogProjectionCache struct {
store *catalog.ProjectionStore[catalog.Projection]
}
// identityProjection is the cart's mandatory transform — it stores the full
// Projection because the overlay uses almost all of it.
func identityProjection(p catalog.Projection) catalog.Projection { return p }
// newCatalogProjectionCache opens the per-pod store under dir. dir MUST be
// pod-local (container fs / an emptyDir in k8s) — never a shared volume.
func newCatalogProjectionCache(dir string) (*catalogProjectionCache, error) {
s, err := catalog.NewProjectionStore(dir, "cart-projection.bin", identityProjection, nil)
if err != nil {
return nil, err
}
return &catalogProjectionCache{store: s}, nil
}
// HandleFrame feeds one catalog.projection_published event (snapshot frame or
// delta) into the store; framing/epoch ordering live in platform/catalog.
func (c *catalogProjectionCache) HandleFrame(meta map[string]string, payload []byte) error {
return c.store.HandleFrame(meta, payload)
}
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) { return c.store.Get(sku) }
// IsDeleted reports whether the bus has explicitly deleted sku (overlay
// tombstone) — call sites skip the HTTP fallback for it.
func (c *catalogProjectionCache) IsDeleted(sku string) bool { return c.store.IsDeleted(sku) }
// Len reports cached entries (base + overlay) — debug/log gauge only.
func (c *catalogProjectionCache) Len() int {
_, base, overlay := c.store.Stats()
return base + overlay
}
// projectionDeliveryHandler is the AMQP delivery handler for
// `catalog.projection_published` on the `catalog` topic exchange. Decodes the
// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch
// that doesn't apply locally so the AMQP delivery is acked; returns an error
// on a decode failure so the broker knows to redeliver / quarantine.
//
// Recovery: any panic in a single event is caught so the goroutine keeps
// draining the queue; the bus stays live across malformed events.
func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error {
return func(d amqp.Delivery) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("projection handler panic recovered: %v", r)
}
}()
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
return fmt.Errorf("decode event envelope: %w", err)
}
// Route the frame (snapshot begin/chunk/end or delta) into the store;
// epoch ordering, snapshot assembly + atomic swap, and tombstones all live
// in platform/catalog.ProjectionStore.
if err := cache.HandleFrame(ev.Meta, ev.Payload); err != nil {
return fmt.Errorf("apply projection frame: %w", err)
}
return nil
}
}
func main() {
// cartPort is the bare HTTP port. It drives both the local listener and the
// port used to proxy to peer pods, so a cluster must run all pods on the
// same CART_PORT.
cartPort := config.EnvString("CART_PORT", "8080")
if i := strings.LastIndex(cartPort, ":"); i >= 0 {
cartPort = cartPort[i+1:]
}
controlPlaneConfig := actor.DefaultServerConfig()
promotionStore, err := promotions.NewStore("data/promotions.json")
if err != nil {
log.Fatalf("Error loading promotions: %v\n", err)
}
log.Printf("loaded %d promotions", len(promotionStore.List()))
promotionService := promotions.NewPromotionService(nil)
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating inventory reservation service: %v\n", err)
}
reservationPolicy := inventory.NewReservationPolicyAdapter(inventoryReservationService)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(reservationPolicy))
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
_, span := tracer.Start(ctx, "Totals and promotions")
defer span.End()
// Clear bypass flags first
for _, v := range g.Vouchers {
if v != nil {
v.BypassedByPromotions = false
}
}
g.UpdateTotals()
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
hasBypassed := false
for _, res := range results {
if res.Applicable {
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
var codes []string
if s, ok := bc.Value.AsString(); ok {
codes = append(codes, strings.ToLower(s))
} else if arr, ok := bc.Value.AsStringSlice(); ok {
for _, s := range arr {
codes = append(codes, strings.ToLower(s))
}
}
for _, code := range codes {
for _, v := range g.Vouchers {
if v != nil && strings.ToLower(v.Code) == code {
if !v.BypassedByPromotions {
v.BypassedByPromotions = true
hasBypassed = true
}
}
}
}
}
return true
})
}
}
if hasBypassed {
// Re-evaluate with bypassed vouchers
g.UpdateTotals()
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
}
// ApplyResults applies qualifying actions in priority order and records
// every effect — both applied discounts and pending "spend X more for ..."
// nudges with their progress — in g.AppliedPromotions.
promotionService.ApplyResults(g, results, promotionCtx)
g.Version++
return nil
}),
)
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
defer span.End()
grainSpawns.Inc()
ret := cart.NewCartGrain(id, time.Now())
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
// inventoryPubSub.Subscribe(ret.HandleInventoryChange)
err := diskStorage.LoadEvents(ctx, id, ret)
// if err == nil && inventoryService != nil {
// refs := make([]*inventory.InventoryReference, 0)
// for _, item := range ret.Items {
// refs = append(refs, &inventory.InventoryReference{
// SKU: inventory.SKU(item.Sku),
// LocationID: getLocationId(item),
// })
// }
// _, span := tracer.Start(ctx, "update inventory")
// defer span.End()
// res, err := inventoryService.GetInventoryBatch(ctx, refs...)
// if err != nil {
// log.Printf("unable to update inventory %v", err)
// } else {
// for _, update := range res {
// for _, item := range ret.Items {
// if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
// // maybe apply an update to give visibility to the cart
// item.Stock = uint16(update.Quantity)
// }
// }
// }
// }
// }
return ret, err
},
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
// cart, err := grain.GetCurrentState()
// if err != nil {
// return err
// }
//inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
return nil
},
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
return proxy.NewRemoteHost[cart.CartGrain](host, cartPort)
},
TTL: 5 * time.Minute,
PoolSize: 2 * 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating cart pool: %v\n", err)
}
cartMCP := cartmcp.New(pool)
// Stream each applied mutation to the shared "mutations" exchange (routing key
// mutation.cart) so the backoffice /commerce live feed (and other consumers)
// see cart activity. Best-effort: if AMQP is unreachable the cart still serves.
if amqpUrl != "" {
if conn, derr := rabbit.Dial(amqpUrl, "cart"); derr != nil {
log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr)
} else {
listener := actor.NewAmqpListener(conn.Connection(), "cart", actor.MutationSummary)
listener.DefineTopics()
pool.AddListener(listener)
log.Printf("cart: mutation feed enabled (mutation.cart)")
}
}
// catalog.projection_published consumer (the cart is the canonical
// consumer of the projection wire per docs/inventory-shape.md; inventory
// emits level crossings back to finder only, never the reverse). Own
// connection keeps the cart's mutation-feed lifecycle decoupled from a
// read-write-consume connection that could grow event handlers later
// (e.g. promotions reacting to a price drop on a watched SKU).
// Pod-local store dir — container fs / an emptyDir in k8s. NEVER a shared
// volume: each replica owns its own .bin (replicated read cache, no leader).
projectionDir := config.EnvString("CART_PROJECTION_DIR", "data/projection")
projectionCache, err := newCatalogProjectionCache(projectionDir)
if err != nil {
log.Fatalf("Error opening catalog projection cache: %v", err)
}
if amqpUrl != "" {
if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil {
log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr)
} else {
pConn.NotifyOnReconnect(func() {
ch, err := pConn.Channel()
if err != nil {
log.Printf("cart: channel on projection reconnect: %v", err)
return
}
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), projectionDeliveryHandler(projectionCache)); err != nil {
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
_ = ch.Close()
}
})
defer func() { _ = pConn.Close() }()
log.Printf("cart: catalog projection consumer ENABLED (catalog.projection_published on 'catalog' exchange)")
}
}
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), projectionCache) //inventoryService, inventoryReservationService)
app := &App{
pool: pool,
server: syncedServer,
}
mux := http.NewServeMux()
debugMux := http.NewServeMux()
grpcSrv, err := actor.NewControlServer[cart.CartGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
go diskStorage.SaveLoop(20 * time.Second)
UseDiscovery(pool)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
syncedServer.Serve(mux)
// Cart MCP edge: inspect and mutate carts — get_cart, get_cart_items,
// update_item_quantity, remove_cart_item, clear_cart, apply_voucher, etc.
// Exposes 11 tools on the same grain pool, mounted at /cart-mcp so it does
// not conflict with the Promotions MCP at /promotions-mcp.
mux.Handle("/cart-mcp", cartMCP.Handler())
mux.Handle("/cart-mcp/", cartMCP.Handler())
// UCP REST adapter — Universal Commerce Protocol
cartUCP := ucp.CartHandler(pool)
if signer := loadUCPCartSigner(); signer != nil {
cartUCP = ucp.WithSigning(cartUCP, signer)
log.Print("ucp signing enabled")
}
// StripPrefix is required because the sub-mux (CartHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path (e.g. /ucp/v1/carts/123) without stripping
// the prefix, so the sub-mux would never match.
//
// Only the subtree pattern (/ucp/v1/carts/) is registered, NOT the exact
// pattern (/ucp/v1/carts), because registering both causes Go's ServeMux
// to redirect the exact match to the subtree root, and StripPrefix
// interferes with the redirect target (producing a 307 to "/" instead
// of "/ucp/v1/carts/"). Consumers should use the trailing-slash variant.
mux.Handle("/ucp/v1/carts/", http.StripPrefix("/ucp/v1/carts", cartUCP))
// Stateless promotion evaluation: POST a (possibly partial) eval context and
// get back the resulting totals plus the applied/pending effect breakdown,
// without creating or mutating a real cart.
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
// only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
})
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
debugMux.Handle("/metrics", promhttp.Handler())
// Projection cache probe (dev/verification). GET /debug/projection/{sku} →
// the resolved projection + the bus-fed map size. map_entries counts both
// live projections and tombstones; a found=true response confirms the bus
// consumer delivered the SKU to this pod.
debugMux.HandleFunc("/debug/projection/", func(w http.ResponseWriter, r *http.Request) {
sku := strings.TrimPrefix(r.URL.Path, "/debug/projection/")
p, found := projectionCache.Get(sku)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sku": sku,
"found": found,
"projection": p,
"map_entries": projectionCache.Len(),
"deleted": projectionCache.IsDeleted(sku),
})
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
grainCount, capacity := app.pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
httpAddr := normalizeListenAddr(cartPort)
debugAddr := normalizeListenAddr(config.EnvString("CART_DEBUG_PORT", "8081"))
srv := &http.Server{
Addr: httpAddr,
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
// Close idle keep-alive connections so a load test with many short-lived
// clients doesn't pin file handles open indefinitely.
IdleTimeout: 60 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
// Inventory change consumption used to live here over the bare Redis
// `inventory_changed` channel; it is now owned by the cart-inventory
// service, which translates quantity changes into inventory.level_changed
// bus crossings. The cart reads exact stock synchronously when it needs it.
// See docs/inventory-shape.md.
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
go http.ListenAndServe(debugAddr, debugMux)
select {
case err = <-srvErr:
// Error when starting HTTP server.
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
// Wait for first CTRL+C.
// Stop receiving signal notifications as soon as possible.
stop()
}
}
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"net/http"
"sync"
)
// openapi_embed.go: Provides embedded OpenAPI spec and helper to mount handler.
//go:embed openapi.json
var openapiJSON []byte
var (
openapiOnce sync.Once
openapiETag string
)
// initOpenAPIMetadata computes immutable metadata for the embedded spec.
func initOpenAPIMetadata() {
sum := sha256.Sum256(openapiJSON)
openapiETag = `W/"` + hex.EncodeToString(sum[:8]) + `"` // weak ETag with first 8 bytes
}
// ServeEmbeddedOpenAPI serves the embedded OpenAPI JSON spec at /openapi.json.
// It supports GET and HEAD and implements basic ETag caching.
func ServeEmbeddedOpenAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
openapiOnce.Do(initOpenAPIMetadata)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("ETag", openapiETag)
if match := r.Header.Get("If-None-Match"); match != "" {
if bytes.Contains([]byte(match), []byte(openapiETag)) {
w.WriteHeader(http.StatusNotModified)
return
}
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(openapiJSON)
}
// Optional: function to access raw spec bytes programmatically.
func OpenAPISpecBytes() []byte {
return openapiJSON
}
// Optional: function to access current ETag.
func OpenAPIETag() string {
openapiOnce.Do(initOpenAPIMetadata)
return openapiETag
}
+769
View File
@@ -0,0 +1,769 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_lookups_total",
Help: "The total number of lookups",
})
)
type PoolServer struct {
actor.GrainPool[cart.CartGrain]
pod_name string
// idx is the bus-fed catalog projection cache, consulted at HTTP-fetch
// sites (AddSkuToCartHandler, buildItemGroups) to overlay authoritative
// price / tax_class / display fields onto AddItem before mutation. nil is
// tolerated so handlers still serve if the bus consumer is unavailable
// (orphan / quarantine / CI), with a plain cache-miss fall-through.
idx *catalogProjectionCache
}
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, idx *catalogProjectionCache) *PoolServer {
srv := &PoolServer{
GrainPool: pool,
pod_name: pod_name,
idx: idx,
}
return srv
}
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
return s.Apply(ctx, uint64(id), mutation...)
}
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
sku := r.PathValue("sku")
country := getCountryFromHost(r.Host)
if s.idx == nil {
log.Printf("No index present")
}
if s.idx != nil && s.idx.IsDeleted(sku) {
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
// product-fetcher's "product service returned %d for sku %s" shape so
// upstream code that pattern-matches that string still works, AND we
// publish StatusNotFound so the HTTP response carries the right code
// (the handler's error-return path otherwise renders as 500). http.Error
// is the idiomatic stdlib call here — it does the encoding-safe text body
// (no fmt.Sprintf/JSON-string-escaping fragility).
http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound)
return nil
}
// Cache-only fast path: AddSkuToCartHandler is a SINGLE top-level add
// (qty=1, no children). When the projection carries full authoritative
// fields, we skip PRODUCT_BASE_URL entirely and build the AddItem
// directly from the cache. This eliminates one HTTP round-trip per
// add-to-cart under steady-state bus-fed conditions.
var msg *messages.AddItem
if s.idx != nil {
if p, ok := s.idx.Get(sku); ok && HasRequiredFields(p) {
msg = BuildAddItemFromCache(sku, 1, country, nil, p)
}
}
if msg == nil {
if s.idx == nil {
log.Printf("No item found in index %s", sku)
}
var err error
msg, err = GetItemAddMessage(r.Context(), sku, 1, country, nil)
if err != nil {
return err
}
if s.idx != nil {
if p, ok := s.idx.Get(sku); ok {
ApplyProjectionOverlay(msg, p)
}
}
}
data, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
grainMutations.Add(float64(len(data.Mutations)))
return s.WriteResult(w, data)
}
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
itemIdString := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
if err != nil {
return err
}
data, err := s.ApplyLocal(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, &changeQuantity)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type Item struct {
Sku string `json:"sku"`
Quantity int `json:"quantity"`
StoreId *string `json:"storeId,omitempty"`
// Children are sub-articles (accessories, services, insurance, ...) priced
// relative to this item. They are added as separate cart lines whose
// ParentId points to this item's line-item id.
Children []Item `json:"children,omitempty"`
// CustomFields are optional user-supplied input fields for this line.
CustomFields map[string]string `json:"customFields,omitempty"`
}
type SetCartItems struct {
Country string `json:"country"`
Items []Item `json:"items"`
}
// itemGroup is a top-level item together with its child sub-articles. The child
// AddItem messages are built with their price already derived from the parent
// product; their ParentId is filled in after the parent line is applied.
type itemGroup struct {
parent *messages.AddItem
children []*messages.AddItem
}
// buildItemGroups fetches every product and builds the AddItem messages. Groups
// are built concurrently; within a group the parent is fetched first so it can
// drive child pricing, then children are fetched concurrently. Input order is
// preserved. Items that fail to fetch are skipped (logged), matching the prior
// best-effort behavior.
//
// idx is the bus-fed projection cache; when non-nil, every AddItem built here
// has its cache-covered fields (price/tax_class/display/inventory_tracked/
// drop_ship) overlaid onto the HTTP-fetched answer. Cache-hit calls naturally
// become authoritative for what the projection carries; HTTP stays for
// dimensions (parent width/height for child pricing), seller/orgPrice and the
// dynamic ExtraJson product data. Skipped cleanly when idx is nil.
//
// Cache-only fast path: a parent with NO children AND a cache hit that
// satisfies HasRequiredFields skips PRODUCT_BASE_URL entirely — BuildAddItem-
// FromCache constructs the line directly from the Projection. This eliminates
// the per-add HTTP round-trip under steady-state bus-fed conditions for the
// common top-level add case. The HTTP path continues to handle:
// - cache miss (cold-grace; a SKU not yet bus-fed)
// - HasRequiredFields==false (partial bus delivery, e.g. TaxClass missing
// because the producer didn't classify)
// - parents WITH children (configurator behavior preservation — child
// price = child per-m² rate × parent area, and parent area comes from
// HTTP-fetched width/height that the Projection doesn't carry yet; a
// future schema extension can unblock a child cache-only path).
func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup {
groups := make([]itemGroup, len(items))
wg := sync.WaitGroup{}
for i, itm := range items {
wg.Go(func() {
if idx != nil && idx.IsDeleted(itm.Sku) {
log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku)
return
}
// Cache-only fast path: see package doc above for the eligibility
// contract.
var parentMsg *messages.AddItem
var parentProduct *ProductItem
if len(itm.Children) == 0 && idx != nil {
if p, ok := idx.Get(itm.Sku); ok && HasRequiredFields(p) {
parentMsg = BuildAddItemFromCache(itm.Sku, itm.Quantity, country, itm.StoreId, p)
}
}
if parentMsg == nil {
var err error
parentMsg, parentProduct, err = BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
if err != nil {
log.Printf("error adding item %s: %v", itm.Sku, err)
return
}
if idx != nil {
if p, ok := idx.Get(itm.Sku); ok {
ApplyProjectionOverlay(parentMsg, p)
}
}
}
parentMsg.CustomFields = itm.CustomFields
groups[i].parent = parentMsg
if len(itm.Children) == 0 {
return
}
children := make([]*messages.AddItem, len(itm.Children))
cwg := sync.WaitGroup{}
for j, child := range itm.Children {
cwg.Go(func() {
if idx != nil && idx.IsDeleted(child.Sku) {
log.Printf("error adding child %s of %s: bus-deleted (skipping)", child.Sku, itm.Sku)
return
}
childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct)
if err != nil {
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
return
}
if idx != nil {
if p, ok := idx.Get(child.Sku); ok {
ApplyProjectionOverlay(childMsg, p)
}
}
childMsg.CustomFields = child.CustomFields
children[j] = childMsg
})
}
cwg.Wait()
for _, c := range children {
if c != nil {
groups[i].children = append(groups[i].children, c)
}
}
})
}
wg.Wait()
return groups
}
// applyItemGroups applies each group in dependency order: the parent first, then
// its children with ParentId set to the parent's resolved line-item id. Returns
// the last mutation result for rendering.
func (s *PoolServer) applyItemGroups(ctx context.Context, id cart.CartId, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) {
var last *actor.MutationResult[cart.CartGrain]
for _, g := range groups {
if g.parent == nil {
continue
}
res, err := s.ApplyLocal(ctx, id, g.parent)
if err != nil {
return nil, err
}
last = res
if len(g.children) == 0 {
continue
}
parentLineId, ok := parentLineId(&res.Result, g.parent)
if !ok {
log.Printf("could not resolve parent line for item %d (sku %s); skipping %d children",
g.parent.ItemId, g.parent.Sku, len(g.children))
continue
}
childMsgs := make([]proto.Message, len(g.children))
for i, c := range g.children {
c.ParentId = &parentLineId
childMsgs[i] = c
}
res, err = s.ApplyLocal(ctx, id, childMsgs...)
if err != nil {
return nil, err
}
last = res
}
return last, nil
}
// parentLineId finds the line-item id of the just-applied parent: the resident
// item with the parent's catalog ItemId, matching store, and no parent of its
// own. AddItem merges by sku+store, so at most one such line exists.
func parentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) {
for _, it := range grain.Items {
if it == nil || it.ParentId != nil {
continue
}
if it.ItemId != parent.ItemId {
continue
}
sameStore := (it.StoreId == nil && parent.StoreId == nil) ||
(it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId)
if sameStore {
return it.Id, true
}
}
return 0, false
}
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
return err
}
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
reply, err := s.applyItemGroups(r.Context(), id, groups)
if err != nil {
return err
}
if reply == nil {
// Cart was cleared but nothing added; return current (empty) state.
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
reply, err := s.applyItemGroups(r.Context(), id, groups)
if err != nil {
return err
}
if reply == nil {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
return s.WriteResult(w, reply)
}
type AddRequest struct {
Sku string `json:"sku"`
Quantity int32 `json:"quantity"`
Country string `json:"country"`
StoreId *string `json:"storeId"`
// Children are sub-articles priced relative to this item (see Item.Children).
Children []Item `json:"children,omitempty"`
// CustomFields are optional user-supplied input fields for this line.
CustomFields map[string]string `json:"customFields,omitempty"`
}
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
addRequest := AddRequest{Quantity: 1}
err := json.NewDecoder(r.Body).Decode(&addRequest)
if err != nil {
return err
}
item := Item{
Sku: addRequest.Sku,
Quantity: int(addRequest.Quantity),
StoreId: addRequest.StoreId,
Children: addRequest.Children,
CustomFields: addRequest.CustomFields,
}
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country, s.idx)
reply, err := s.applyItemGroups(r.Context(), id, groups)
if err != nil {
return err
}
if reply == nil {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
return s.WriteResult(w, reply)
}
// func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
// orderId := r.PathValue("orderId")
// if orderId == "" {
// return fmt.Errorf("orderId is empty")
// }
// order, err := KlarnaInstance.GetOrder(orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "application/json")
// w.Header().Set("X-Pod-Name", s.pod_name)
// w.Header().Set("Cache-Control", "no-cache")
// w.Header().Set("Access-Control-Allow-Origin", "*")
// w.WriteHeader(http.StatusOK)
// return json.NewEncoder(w).Encode(order)
// }
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
// if err != nil {
// return err
// }
// s.ApplyCheckoutStarted(klarnaOrder, id)
// w.Header().Set("Content-Type", "application/json")
// return json.NewEncoder(w).Encode(klarnaOrder)
// }
//
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
// Clear cart cookie (breaking change: do not issue a new legacy id here)
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: r.TLS != nil,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusOK)
return nil
}
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
ctx, span := tracer.Start(r.Context(), "proxy")
defer span.End()
span.SetAttributes(attribute.String("cartid", cartId.String()))
hostAttr := attribute.String("other host", ownerHost.Name())
span.SetAttributes(hostAttr)
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
return nil
}
}
_, span := tracer.Start(r.Context(), "own")
span.SetAttributes(attribute.String("cartid", cartId.String()))
defer span.End()
return fn(w, r, cartId)
}
}
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
proxyCalls metric.Int64Counter
)
func init() {
var err error
proxyCalls, err = meter.Int64Counter("proxy.calls",
metric.WithDescription("Number of proxy calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
}
type AddVoucherRequest struct {
VoucherCode string `json:"code"`
}
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
data := &AddVoucherRequest{}
json.NewDecoder(r.Body).Decode(data)
v := voucher.Service{}
msg, err := v.GetVoucher(data.VoucherCode)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, msg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
type SubscriptionDetailsRequest struct {
Id *string `json:"id,omitempty"`
OfferingCode string `json:"offeringCode,omitempty"`
SigningType string `json:"signingType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
return &messages.UpsertSubscriptionDetails{
Id: sd.Id,
OfferingCode: sd.OfferingCode,
SigningType: sd.SigningType,
Data: &anypb.Any{Value: sd.Data},
}
}
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
data := &SubscriptionDetailsRequest{}
err := json.NewDecoder(r.Body).Decode(data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, data.ToMessage())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
idStr := r.PathValue("voucherId")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
setUserId := messages.SetUserId{}
err := json.NewDecoder(r.Body).Decode(&setUserId)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &setUserId)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) LineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
itemIdStr := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
if err != nil {
return err
}
lineItemMarking := messages.LineItemMarking{Id: uint32(itemId)}
err = json.NewDecoder(r.Body).Decode(&lineItemMarking)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &lineItemMarking)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
itemIdStr := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
if err != nil {
return err
}
removeLineItemMarking := messages.RemoveLineItemMarking{Id: uint32(itemId)}
reply, err := s.ApplyLocal(r.Context(), cartId, &removeLineItemMarking)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type SetCustomFieldsRequest struct {
CustomFields map[string]string `json:"customFields"`
}
func (s *PoolServer) SetCustomFieldsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
itemId, err := strconv.ParseInt(r.PathValue("itemId"), 10, 64)
if err != nil {
return err
}
req := SetCustomFieldsRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.SetLineItemCustomFields{
Id: uint32(itemId),
CustomFields: req.CustomFields,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return nil
}
data, err := io.ReadAll(r.Body)
if err != nil {
return err
}
mutation := &messages.Mutation{}
err = proto.Unmarshal(data, mutation)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, mutation)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
id := uint64(cartId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
return host.Get(ctx, id)
}
return s.Get(ctx, id)
}
func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs ...proto.Message) error {
id := uint64(cartId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
_, err := host.Apply(ctx, id, msgs...)
return err
}
_, err := s.Apply(ctx, id, msgs...)
return err
}
func (s *PoolServer) Serve(mux *http.ServeMux) {
// mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
// w.Header().Set("Access-Control-Allow-Origin", "*")
// w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
// w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// w.WriteHeader(http.StatusOK)
// })
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
attr := attribute.String("http.route", pattern)
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetName(pattern)
span.SetAttributes(attr)
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attr)
handlerFunc(w, r)
}))
}
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("PUT /cart/item/{itemId}/custom-fields", CookieCartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strconv"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/config"
)
// consumedKeys are product-document keys that are mapped to typed AddItem
// fields. They are stripped from the dynamic remainder so Extra only holds
// genuinely dynamic data.
var consumedKeys = []string{
"id", "sku", "title", "img", "price", "vat",
"discount", "inStock", "supplierId", "supplierName", "deleted",
}
// getBaseUrl returns the product service base url. Overridable via
// PRODUCT_BASE_URL (the country argument is currently unused but kept for when
// per-market routing returns).
func getBaseUrl(country string) string {
return config.EnvString("PRODUCT_BASE_URL", "http://localhost:8082")
}
// ProductItem is the flat product document served by GET /api/get/{sku}.
// Only the fields the cart currently maps mechanically are decoded; the rest
// of the (dynamic) document is intentionally ignored until the cart item model
// is reworked to carry arbitrary data.
type ProductItem struct {
Id uint64 `json:"id"`
Sku string `json:"sku"`
Title string `json:"title"`
Img string `json:"img"`
Price float64 `json:"price"`
Vat int `json:"vat"`
Discount int `json:"discount"` // percent off the original price
InStock int32 `json:"inStock"`
SupplierId int `json:"supplierId"`
SupplierName string `json:"supplierName"`
Deleted bool `json:"deleted"`
// Extra is the rest of the product document (everything not mapped to a
// typed field above), preserved verbatim and surfaced on the cart item.
Extra map[string]json.RawMessage `json:"-"`
}
// numberField reads a numeric dynamic property from the product document
// (width, height, ...). Like JS Number(), it accepts a JSON number or a numeric
// string. Returns false when the key is absent or not numeric.
func (p *ProductItem) numberField(key string) (float64, bool) {
raw, ok := p.Extra[key]
if !ok {
return 0, false
}
var f float64
if err := json.Unmarshal(raw, &f); err == nil {
return f, true
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, true
}
}
return 0, false
}
func FetchItem(ctx context.Context, sku string, country string) (*ProductItem, error) {
baseUrl := getBaseUrl(country)
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
defer span.End()
req, err := http.NewRequestWithContext(innerCtx, http.MethodGet, fmt.Sprintf("%s/api/get/%s", baseUrl, sku), nil)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("product service returned %d for sku %s", res.StatusCode, sku)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
var item ProductItem
if err := json.Unmarshal(body, &item); err != nil {
return nil, err
}
// Capture everything not mapped to a typed field as dynamic data.
all := map[string]json.RawMessage{}
if err := json.Unmarshal(body, &all); err != nil {
return nil, err
}
for _, k := range consumedKeys {
delete(all, k)
}
if len(all) > 0 {
item.Extra = all
}
return &item, nil
}
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
msg, _, err := BuildItemMessage(ctx, sku, qty, country, storeId, nil)
return msg, err
}
// BuildItemMessage fetches a product and builds its AddItem mutation, returning
// the fetched product too so it can serve as the parent for child items. When
// parent is non-nil the line is priced as a child of that parent.
func BuildItemMessage(ctx context.Context, sku string, qty int, country string, storeId *string, parent *ProductItem) (*messages.AddItem, *ProductItem, error) {
item, err := FetchItem(ctx, sku, country)
if err != nil {
return nil, nil, err
}
msg, err := ToItemAddMessage(item, parent, storeId, qty, country)
if err != nil {
return nil, nil, err
}
return msg, item, nil
}
// Glass is billed at a minimum surface even when the real pane is smaller, so a
// small window doesn't get a near-zero per-m² price.
const minGlassArea = 0.4 // m²
// areaFromItem derives the main article's *visible glass* surface in m² from a
// resolved configurator selection. The catalog width/height are facet codes;
// the outer (karmytter) size is `code × 100 margin` mm and the visible glass
// is that minus the frame border per axis, e.g. width 4, widthMargin 20,
// borderWidth 194 → 400 20 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
// is missing/non-numeric (non-window PDP, or no product resolved yet).
func areaFromItem(item *ProductItem) float64 {
if item == nil {
return 0
}
w, okW := item.numberField("width")
h, okH := item.numberField("height")
if !okW || !okH || w <= 0 || h <= 0 {
return 0
}
glassW := w * 100 // - (item.numberField("widthMargin") + item.numberField("borderWidth"))
glassH := h * 100 // - (item.numberField("heightMargin") + item.numberField("borderHeight"))
if glassW <= 0 || glassH <= 0 {
return 0
}
return math.Max((glassW*glassH)/1_000_000, minGlassArea)
}
// accessoryPrice computes a child line's unit price (inc vat, minor units): the
// child's own per-m² price scaled by the parent window's billed glass area.
// When the parent has no usable dimensions the area is 0, so the price is 0.
func accessoryPrice(parent *ProductItem, child *ProductItem) int64 {
area := areaFromItem(parent)
return int64(child.Price*100*area + 0.5)
}
// orgPriceFromDiscount reconstructs the pre-discount price from a percentage.
// Returns 0 when there is no usable discount, in which case OrgPrice is omitted.
func orgPriceFromDiscount(price int64, discountPercent int) int64 {
if discountPercent <= 0 || discountPercent >= 100 {
return 0
}
return int64(float64(price)/(1-float64(discountPercent)/100) + 0.5)
}
func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
// Central stock only: store-specific stock is no longer in the payload, so a
// storeId request currently resolves to zero stock.
var stock int32
if storeId == nil {
stock = item.InStock
}
// Top-level items price from their own product; children are priced from the
// parent product via the accessory-price hook.
price := int64(item.Price)
if parent != nil {
price = accessoryPrice(parent, item)
}
msg := &messages.AddItem{
ItemId: uint32(item.Id),
Quantity: int32(qty),
Price: price,
OrgPrice: orgPriceFromDiscount(price, item.Discount),
Sku: item.Sku,
Name: item.Title,
Image: item.Img,
Stock: stock,
// item.Vat is the product's VAT as a raw integer percent (e.g. 25);
// ×100 converts to the platform basis-point scale (2500). This is the
// single conversion boundary — everything downstream is basis points.
Tax: int32(item.Vat * 100),
SellerId: strconv.Itoa(item.SupplierId),
SellerName: item.SupplierName,
Country: country,
StoreId: storeId,
}
if len(item.Extra) > 0 {
extra, err := json.Marshal(item.Extra)
if err != nil {
return nil, fmt.Errorf("marshal extra product data: %w", err)
}
msg.ExtraJson = extra
}
return msg, nil
}
@@ -0,0 +1,390 @@
//go:build integration
// Integration tests for the product fetcher. These hit a live product service
// and are excluded from the default build/test run. Run them with:
//
// go test -tags=integration ./cmd/cart/
//
// Point at a different service with PRODUCT_BASE_URL (default http://localhost:8082).
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
)
// newTestPoolServer builds a real, disk-backed PoolServer for exercising the
// HTTP handlers end-to-end (no clustering peers).
func newTestPoolServer(t *testing.T) *PoolServer {
t.Helper()
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
reg.RegisterProcessor(actor.NewMutationProcessor(func(_ context.Context, g *cart.CartGrain) error {
g.UpdateTotals()
g.Version++
return nil
}))
dir := t.TempDir()
disk := actor.NewDiskStorage[cart.CartGrain](dir, reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[cart.CartGrain]{
MutationRegistry: reg,
Storage: disk,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
g := cart.NewCartGrain(id, time.Now())
err := disk.LoadEvents(ctx, id, g)
return g, err
},
Destroy: func(actor.Grain[cart.CartGrain]) error { return nil },
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
return proxy.NewRemoteHost[cart.CartGrain](host, "8080")
},
TTL: 5 * time.Minute,
PoolSize: 1000,
Hostname: "",
})
if err != nil {
t.Fatalf("new pool: %v", err)
}
return NewPoolServer(pool, "test", nil)
}
// exampleId is the catalog item the fetcher rework was validated against.
const exampleId = "8163"
// requireService skips the test when the product service is not reachable so
// the suite degrades gracefully in environments without it.
func requireService(t *testing.T) {
t.Helper()
base := getBaseUrl("se")
req, err := http.NewRequest(http.MethodGet, base+"/api/get/"+exampleId, nil)
if err != nil {
t.Fatalf("build probe request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) {
t.Skipf("product service %s not reachable: %v", base, err)
}
t.Fatalf("probe product service: %v", err)
}
res.Body.Close()
}
func TestFetchItem_Example8163(t *testing.T) {
requireService(t)
ctx := context.Background()
item, err := FetchItem(ctx, exampleId, "se")
if err != nil {
t.Fatalf("FetchItem(%s): %v", exampleId, err)
}
if item.Id != 8163 {
t.Errorf("Id = %d, want 8163", item.Id)
}
if item.Sku == "" {
t.Error("Sku is empty")
}
if item.Price <= 0 {
t.Errorf("Price = %v, want > 0", item.Price)
}
t.Logf("fetched %d sku=%s price=%v vat=%d discount=%d inStock=%d supplier=%q",
item.Id, item.Sku, item.Price, item.Vat, item.Discount, item.InStock, item.SupplierName)
}
func TestToItemAddMessage_Example8163(t *testing.T) {
requireService(t)
ctx := context.Background()
item, err := FetchItem(ctx, exampleId, "se")
if err != nil {
t.Fatalf("FetchItem(%s): %v", exampleId, err)
}
// Central stock (no storeId, no parent).
msg, err := ToItemAddMessage(item, nil, nil, 2, "se")
if err != nil {
t.Fatalf("ToItemAddMessage: %v", err)
}
if msg.ItemId != uint32(item.Id) {
t.Errorf("ItemId = %d, want %d", msg.ItemId, item.Id)
}
if msg.Quantity != 2 {
t.Errorf("Quantity = %d, want 2", msg.Quantity)
}
if want := int64(item.Price * 100); msg.Price != want {
t.Errorf("Price = %d, want %d (price*100)", msg.Price, want)
}
if msg.Sku != item.Sku {
t.Errorf("Sku = %q, want %q", msg.Sku, item.Sku)
}
if msg.Name == "" {
t.Error("Name is empty")
}
if want := int32(item.Vat * 100); msg.Tax != want {
t.Errorf("Tax = %d, want vat*100 = %d", msg.Tax, want)
}
if msg.Stock != item.InStock {
t.Errorf("Stock = %d, want central inStock %d", msg.Stock, item.InStock)
}
if msg.SellerName != item.SupplierName {
t.Errorf("SellerName = %q, want %q", msg.SellerName, item.SupplierName)
}
if want := strconv.Itoa(item.SupplierId); msg.SellerId != want {
t.Errorf("SellerId = %q, want %q", msg.SellerId, want)
}
// OrgPrice is reconstructed from the discount percent.
if item.Discount > 0 && item.Discount < 100 {
if msg.OrgPrice <= msg.Price {
t.Errorf("OrgPrice = %d, want > Price %d (discount %d%%)", msg.OrgPrice, msg.Price, item.Discount)
}
} else if msg.OrgPrice != 0 {
t.Errorf("OrgPrice = %d, want 0 (no usable discount)", msg.OrgPrice)
}
// Store stock currently resolves to zero (store-level stock not in payload).
storeId := "1234"
storeMsg, err := ToItemAddMessage(item, nil, &storeId, 1, "se")
if err != nil {
t.Fatalf("ToItemAddMessage(store): %v", err)
}
if storeMsg.Stock != 0 {
t.Errorf("store Stock = %d, want 0", storeMsg.Stock)
}
if storeMsg.StoreId == nil || *storeMsg.StoreId != storeId {
t.Errorf("StoreId = %v, want %q", storeMsg.StoreId, storeId)
}
if len(msg.ExtraJson) == 0 {
t.Error("expected ExtraJson to carry dynamic product data")
}
}
// TestDynamicData_RoundTripsToCartItem exercises the full path: fetch the
// product, build the AddItem mutation, apply it through the cart mutation
// registry, then render the cart exactly as the API does — asserting that a
// dynamic product key ("glas" for item 8163) is flattened onto the item.
func TestDynamicData_RoundTripsToCartItem(t *testing.T) {
requireService(t)
ctx := context.Background()
msg, err := GetItemAddMessage(ctx, exampleId, 1, "se", nil)
if err != nil {
t.Fatalf("GetItemAddMessage: %v", err)
}
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
grain := cart.NewCartGrain(1, time.Now())
if _, err := reg.Apply(ctx, grain, msg); err != nil {
t.Fatalf("apply AddItem: %v", err)
}
// Marshal the grain the way the HTTP handlers do (WriteResult -> json).
b, err := json.Marshal(grain)
if err != nil {
t.Fatalf("marshal grain: %v", err)
}
var rendered struct {
Items []map[string]json.RawMessage `json:"items"`
}
if err := json.Unmarshal(b, &rendered); err != nil {
t.Fatalf("unmarshal grain: %v", err)
}
if len(rendered.Items) != 1 {
t.Fatalf("items = %d, want 1", len(rendered.Items))
}
item := rendered.Items[0]
// Typed field present.
if _, ok := item["sku"]; !ok {
t.Error("rendered item missing typed key \"sku\"")
}
// Dynamic key flattened onto the item and returned over the API.
glas, ok := item["glas"]
if !ok {
t.Fatalf("rendered item missing dynamic key \"glas\"; got keys %v", keysOf(item))
}
if string(glas) != `"V"` {
t.Errorf("glas = %s, want \"V\"", glas)
}
}
// TestSetCartItems_ParentWithChildren reproduces the reported scenario through
// the real SetCartItemsHandler: a parent SKU with three distinct children.
func TestSetCartItems_ParentWithChildren(t *testing.T) {
requireService(t)
s := newTestPoolServer(t)
body := `{"sku":"146620","quantity":1,"children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
// The handler decodes SetCartItems{country, items:[Item]} — the reported
// payload is a single Item, so wrap it in the items array.
payload := `{"country":"se","items":[` + body + `]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/cart/set", bytes.NewBufferString(payload))
if err := s.SetCartItemsHandler(rec, req, cart.CartId(42)); err != nil {
t.Fatalf("SetCartItemsHandler: %v", err)
}
type lineT struct {
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId"`
ParentId *uint32 `json:"parentId"`
Sku string `json:"sku"`
}
// Handler returns a MutationResult {result:{items}, mutations}. Fall back to
// top-level items for the empty-cart path.
var resp struct {
Result struct {
Items []lineT `json:"items"`
} `json:"result"`
Items []lineT `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v\nbody: %s", err, rec.Body.String())
}
grain := struct{ Items []lineT }{Items: resp.Result.Items}
if len(grain.Items) == 0 {
grain.Items = resp.Items
}
t.Logf("result has %d items:", len(grain.Items))
for _, it := range grain.Items {
t.Logf(" line %d itemId=%d sku=%s parentId=%v", it.Id, it.ItemId, it.Sku, it.ParentId)
}
if len(grain.Items) != 4 {
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(grain.Items))
}
children := 0
for _, it := range grain.Items {
if it.ParentId != nil {
children++
}
}
if children != 3 {
t.Errorf("children = %d, want 3", children)
}
}
// TestAddSkuRequest_SingleItemWithChildren posts the bare single-item body (no
// items wrapper) to the single-add handler and asserts children are added.
func TestAddSkuRequest_SingleItemWithChildren(t *testing.T) {
requireService(t)
s := newTestPoolServer(t)
payload := `{"sku":"146620","quantity":1,"country":"se","children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/cart", bytes.NewBufferString(payload))
if err := s.AddSkuRequestHandler(rec, req, cart.CartId(7)); err != nil {
t.Fatalf("AddSkuRequestHandler: %v", err)
}
var resp struct {
Result struct {
Items []struct {
ParentId *uint32 `json:"parentId"`
} `json:"items"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v\nbody: %s", err, rec.Body.String())
}
if len(resp.Result.Items) != 4 {
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(resp.Result.Items))
}
}
func keysOf(m map[string]json.RawMessage) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
return ks
}
// TestChildren_ParentLinkage exercises the nested-children build + ordered apply:
// buildItemGroups fetches the parent product and prices the child against it,
// then parentLineId resolves the parent's line id and the child line links to it.
//
// Structural test: it reuses 8163 as both parent and child (child pinned to a
// store so it does not merge with the parent line). Real children would be
// distinct service/insurance/accessory SKUs.
func TestChildren_ParentLinkage(t *testing.T) {
requireService(t)
ctx := context.Background()
childStore := "1"
items := []Item{{
Sku: exampleId,
Quantity: 1,
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
}}
groups := buildItemGroups(ctx, items, "se", nil)
if len(groups) != 1 {
t.Fatalf("groups = %d, want 1", len(groups))
}
if groups[0].parent == nil {
t.Fatal("parent message not built")
}
if len(groups[0].children) != 1 {
t.Fatalf("children = %d, want 1", len(groups[0].children))
}
// Apply through the real mutation registry to verify the linkage the handler
// performs (applyItemGroups does the same with pool.ApplyLocal).
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
grain := cart.NewCartGrain(1, time.Now())
if _, err := reg.Apply(ctx, grain, groups[0].parent); err != nil {
t.Fatalf("apply parent: %v", err)
}
pid, ok := parentLineId(grain, groups[0].parent)
if !ok {
t.Fatal("parentLineId did not resolve the parent line")
}
child := groups[0].children[0]
child.ParentId = &pid
if _, err := reg.Apply(ctx, grain, child); err != nil {
t.Fatalf("apply child: %v", err)
}
if len(grain.Items) != 2 {
t.Fatalf("cart items = %d, want 2 (parent + child)", len(grain.Items))
}
var parentLine, childLine *cart.CartItem
for _, it := range grain.Items {
if it.ParentId == nil {
parentLine = it
} else {
childLine = it
}
}
if parentLine == nil || childLine == nil {
t.Fatalf("expected one parent and one child line, got %+v", grain.Items)
}
if *childLine.ParentId != parentLine.Id {
t.Errorf("child.ParentId = %d, want parent line id %d", *childLine.ParentId, parentLine.Id)
}
t.Logf("parent line %d, child line %d -> parent %d", parentLine.Id, childLine.Id, *childLine.ParentId)
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"testing"
)
func makeProduct(price float64, extra map[string]any) *ProductItem {
m := map[string]json.RawMessage{}
for k, v := range extra {
b, _ := json.Marshal(v)
m[k] = b
}
return &ProductItem{Price: price, Extra: m}
}
func TestAreaFromItem(t *testing.T) {
tests := []struct {
name string
extra map[string]any
want float64
}{
{"floors small window to min", map[string]any{"width": 5, "height": 6}, 0.4}, // 500*600/1e6 = 0.30 -> floor 0.4
{"normal window", map[string]any{"width": 10, "height": 10}, 1.0}, // 1000*1000/1e6 = 1.0
{"numeric strings", map[string]any{"width": "10", "height": "10"}, 1.0}, // JS Number() parity
{"missing height", map[string]any{"width": 10}, 0},
{"zero width", map[string]any{"width": 0, "height": 10}, 0},
{"non-numeric", map[string]any{"width": "abc", "height": 10}, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := areaFromItem(makeProduct(0, tc.extra))
if got != tc.want {
t.Errorf("areaFromItem = %v, want %v", got, tc.want)
}
})
}
if got := areaFromItem(nil); got != 0 {
t.Errorf("areaFromItem(nil) = %v, want 0", got)
}
}
func TestAccessoryPrice(t *testing.T) {
// area 1.0 m^2, child 100.00 -> 100.00 * 100 * 1.0 = 10000 minor units.
parent := makeProduct(0, map[string]any{"width": 10, "height": 10})
if got := accessoryPrice(parent, makeProduct(100, nil)); got != 10000 {
t.Errorf("accessoryPrice = %d, want 10000", got)
}
// area floored to 0.4, child 9952.78 -> round(9952.78 * 100 * 0.4) = 398111.
small := makeProduct(0, map[string]any{"width": 5, "height": 6})
if got := accessoryPrice(small, makeProduct(9952.78, nil)); got != 398111 {
t.Errorf("accessoryPrice = %d, want 398111", got)
}
// parent without dimensions -> area 0 -> price 0.
if got := accessoryPrice(makeProduct(0, nil), makeProduct(500, nil)); got != 0 {
t.Errorf("accessoryPrice (no dims) = %d, want 0", got)
}
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"testing"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/money"
)
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
// projections reaches the cache via Apply, all are visible via Get.
func TestProjectionCache_ApplyUpserts(t *testing.T) {
c := mustCache(t)
updates := []catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
{Projection: catalog.Projection{ID: "id-C", SKU: "C", PriceIncVat: money.Cents(0), TaxClass: ""}},
}
upserts, deletes := c.Apply(updates)
if upserts != 3 || deletes != 0 {
t.Fatalf("counts: upserts=%d deletes=%d, want 3/0", upserts, deletes)
}
for _, want := range []struct {
sku string
price money.Cents
taxCls string
}{
{"A", money.Cents(100_00), "standard"},
{"B", money.Cents(150_00), "reduced"},
{"C", money.Cents(0), ""},
} {
got, ok := c.Get(want.sku)
if !ok {
t.Fatalf("Get(%q): missing", want.sku)
}
if got.PriceIncVat != want.price || got.TaxClass != want.taxCls {
t.Fatalf("Get(%q) = %+v, want price=%d taxCls=%q", want.sku, got, want.price, want.taxCls)
}
}
if c.Len() != 3 {
t.Fatalf("Len = %d, want 3", c.Len())
}
}
// TestProjectionCache_DeleteTombstone verifies that a delete update sets a
// tombstone (not removes the entry), so:
// - Get returns (_, false) on a deleted SKU
// - IsDeleted returns true
// - the same entry continues to count toward Len (size accounting)
func TestProjectionCache_DeleteTombstone(t *testing.T) {
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
})
if _, ok := c.Get("A"); !ok {
t.Fatalf("A: expected hit before delete")
}
upserts, deletes := c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
})
if upserts != 0 || deletes != 1 {
t.Fatalf("delete counts: upserts=%d deletes=%d, want 0/1", upserts, deletes)
}
if got, ok := c.Get("A"); ok {
t.Fatalf("A after delete: got %+v ok=true, want miss", got)
}
if !c.IsDeleted("A") {
t.Fatalf("A after delete: IsDeleted false")
}
if c.IsDeleted("UNKNOWN") {
t.Fatalf("UNKNOWN: IsDeleted should be false (entry doesn't exist)")
}
if c.Len() != 1 {
t.Fatalf("Len after tombstone: %d, want 1 (tombstone still in map)", c.Len())
}
}
// TestProjectionCache_BusResurrect verifies that a fresh upsert after a delete
// clears the tombstone (live entry replaces it): Get returns the projection,
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
func TestProjectionCache_BusResurrect(t *testing.T) {
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
})
if !c.IsDeleted("A") {
t.Fatalf("A: not tombstoned after delete")
}
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(75_00)}},
})
got, ok := c.Get("A")
if !ok {
t.Fatalf("A: expected live after re-upsert")
}
if got.PriceIncVat != money.Cents(75_00) {
t.Fatalf("A re-upsert price = %d, want 7500", got.PriceIncVat)
}
if c.IsDeleted("A") {
t.Fatalf("A: still tombstoned after re-upsert")
}
}
// TestProjectionCache_EmptySKUIgnored validates that an upsert with an empty
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
// + drop than to populate an ambiguous cache slot).
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
c := mustCache(t)
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
})
if upserts != 0 {
t.Fatalf("empty-SKU upsert: %d, want 0", upserts)
}
if c.Len() != 0 {
t.Fatalf("Len after empty-SKU: %d, want 0", c.Len())
}
}
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
func TestProjectionCache_BusRaceSafe(t *testing.T) {
c := mustCache(t)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 500; i++ {
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "X", PriceIncVat: money.Cents(int64(i * 100))}},
})
}
}()
for i := 0; i < 2000; i++ {
_, _ = c.Get("X")
_ = c.IsDeleted("X")
_ = c.Len()
}
<-done
if _, ok := c.Get("X"); !ok {
t.Fatalf("final Get(X): miss after concurrent writes")
}
}
// TestApply_MixedUpsertDelete verifies the realistic per-batch sequence a
// publisher emits: a single Apply call interleaves upserts and deletes, and
// the per-batch counters report the right split. Without this the cache could
// drift on count semantics across batched events.
func TestApply_MixedUpsertDelete(t *testing.T) {
c := mustCache(t)
// Order matters: prior in-cache state for A is upserted twice (latest wins),
// B is tombstoned, C cold-upserted, D cold-tombstoned.
updates := []catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
{Projection: catalog.Projection{SKU: "B"}, Deleted: true},
{Projection: catalog.Projection{SKU: "C", PriceIncVat: money.Cents(75_00)}},
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(60_00)}}, // later wins
{Projection: catalog.Projection{SKU: "D"}, Deleted: true},
}
upserts, deletes := c.Apply(updates)
if upserts != 3 || deletes != 2 {
t.Fatalf("counts: upserts=%d deletes=%d, want 3/2 (A,C live-upserts; B,D tombstones)", upserts, deletes)
}
if got, ok := c.Get("A"); !ok || got.PriceIncVat != money.Cents(60_00) {
t.Fatalf("A latest-wins: got %+v ok=%v, want 6000", got, ok)
}
if _, ok := c.Get("B"); ok {
t.Fatalf("B: tombstoned but Get returned a value")
}
if !c.IsDeleted("B") {
t.Fatalf("B: IsDeleted not true")
}
if got, ok := c.Get("C"); !ok || got.PriceIncVat != money.Cents(75_00) {
t.Fatalf("C cold-upsert: got %+v ok=%v, want 7500", got, ok)
}
if !c.IsDeleted("D") {
t.Fatalf("D: IsDeleted not true")
}
if c.Len() != 4 {
t.Fatalf("Len() after mixed batch: %d, want 4 (A live + B tombstone + C live + D tombstone)", c.Len())
}
}
// TestTaxResolveBp covers the static TaxClass→bp mapping used by
// ApplyProjectionOverlay. Adds a regression net for the common Nordic rates.
func TestTaxResolveBp(t *testing.T) {
cases := []struct {
class string
want int
}{
{"standard", 2500},
{"reduced", 1200},
{"lowered", 600},
{"zero", 0},
{"exempt", 0},
{"", 2500}, // missing class defaults to standard (matches mutation registry)
{"unknown-class", 2500},
}
for _, tc := range cases {
got := resolveTaxBp(tc.class)
if got != tc.want {
t.Errorf("resolveTaxBp(%q) = %d, want %d", tc.class, got, tc.want)
}
}
}
+186
View File
@@ -0,0 +1,186 @@
package main
import (
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/catalog"
)
// taxClassToBp maps the canonical TaxClass string identifiers published on
// catalog.Projection to basis-points-of-a-percent (rate × 100) — the single
// platform-wide rate scale used by AddItem.Tax (int32 basis points), the
// mutation registry, and the tax.Provider Compute formula.
//
// Values reflect the standard tariff names used by the Nordic configuration.
// Per-country / per-tenant numeric resolution (e.g. Finland 25.5%, Ireland
// 13.5%) is delegated to platform/tax and is a follow-up: this static map
// gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path
// ship while the platform/tax lookup is wired into the cart pool server.
var taxClassToBp = map[string]int{
"standard": 2500,
"reduced": 1200, // common reduced rate (e.g. SE/NO food)
"lowered": 600, // super-reduced
"zero": 0, // zero-rated (export, healthcare, ...)
"exempt": 0, // similarly untaxed, separate nominal class
}
// resolveTaxBp returns the basis-point rate for a TaxClass string, falling
// back to platform "standard" (25%) when the class is unrecognised. The
// default matches the grain's existing behaviour (AddItem falls back to 2500
// when m.Tax == 0 — see pkg/cart/mutation_add_item.go).
func resolveTaxBp(class string) int {
if bp, ok := taxClassToBp[class]; ok {
return bp
}
return 2500
}
// HasRequiredFields asserts the catalog Projection carries enough authoritative
// fields for the cart to build a complete AddItem WITHOUT an HTTP fallback to
// PRODUCT_BASE_URL. Required:
//
// PriceIncVat > 0 (treats zero as "unbus-fed", not "free"; positive-only
// matches every other cache-authoritative field)
// TaxClass != "" (string identifier; numeric rate resolved via
// resolveTaxBp at the same scale as the HTTP path)
// ItemID != 0 (uint32 dedup key; zero means the producer didn't
// publish one, so we don't claim we know the id)
//
// Display fields (Title, Image) and fulfillment flags (InventoryTracked,
// DropShip) are optional — when present they populate the line, when absent
// the proto-zero value is acceptable.
func HasRequiredFields(p catalog.Projection) bool {
return p.PriceIncVat.Int64() > 0 &&
p.TaxClass != "" &&
p.ItemID != 0
}
// BuildAddItemFromCache constructs a complete AddItem message directly from a
// cache hit that satisfies HasRequiredFields. The caller has already confirmed
// the projection is well-formed; we map every cache-visible carrier onto the
// proto fields the existing HTTP path would have constructed.
//
// Fields mapped:
//
// Sku ← input
// ItemId ← p.ItemID (uint32 dedup key)
// Quantity ← input
// Country ← input
// StoreId ← input
// Price ← p.PriceIncVat.Int64()
// Tax ← int32(resolveTaxBp(p.TaxClass))
// Name ← p.Title (may be empty)
// Image ← p.Image (may be empty)
// InventoryTracked ← p.InventoryTracked
// DropShip ← p.DropShip
//
// Fields intentionally left blank (cache-only gap; the schema doesn't yet
// carry them and the audience for those fields is downstream of the cart line
// — they are NOT required for line dedup or pricing):
//
// SellerId / SellerName — marketplace split; not on Projection yet.
// Stock — queried synchronously from inventory at
// decision points (per docs/inventory-shape.md).
// OrgPrice / Discount — pre-discount price; a future Projection
// extension when promotion previews need it.
// ExtraJson — dynamic product data (configurator
// width/height etc.); a future schema extension
// for cache-only-skip-HTTP for groups with
// configurator children.
//
// IMPORTANT: this helper is for TOP-LEVEL lines only. Configurator children
// re-price based on parent dimensions (ToItemAddMessage's parent != nil
// path); since the Projection does not yet carry width/height, callers must
// keep the existing HTTP-fetch flow for groups with children.
//
// Tombstoned SKUs never reach this helper; callers check idx.IsDeleted(sku)
// first and short-circuit.
func BuildAddItemFromCache(sku string, qty int, country string, storeId *string, p catalog.Projection) *messages.AddItem {
return &messages.AddItem{
Sku: sku,
ItemId: p.ItemID,
Quantity: int32(qty),
Country: country,
StoreId: storeId,
Price: p.PriceIncVat.Int64(),
Tax: int32(resolveTaxBp(p.TaxClass)),
Name: p.Title,
Image: p.Image,
InventoryTracked: p.InventoryTracked,
DropShip: p.DropShip,
}
}
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
//
// Authoritative-from-cache fields (these WIN over the HTTP-fetched value when
// both are present, because the bus is the live stream):
//
// Price (bus has post-class-overrides; product service has static)
// Tax (TaxClass → bp via resolveTaxBp; only when TaxClass is set —
// an empty TaxClass means the producer didn't classify,
// so we leave the HTTP-fetched rate intact rather than
// quietly swapping in a default 2500 and miscategorising)
// Title (canonical, post-trim)
// Image (canonical)
// ItemID (positive bus values override HTTP; zero is the
// documented "no id" signal, so a tombstone's zero or an
// unbus-fed cache value won't clobber a real HTTP id.)
// InventoryTracked (only flips to true; false is the zero value in proto
// and may be unset on the wire, so we don't second-guess)
// DropShip (only flips to true; same reasoning)
//
// The HTTP-fetched values remain authoritative for fields the cache does not
// carry: SellerId / SellerName (marketplace split), Stock (per docs/inventory-
// shape.md stock is queried synchronously from inventory, not cached),
// OrgPrice / Discount (not yet on the projection schema), and the dynamic
// ExtraJson product data (configurator options — width/height used by
// accessory child-pricing).
//
// SKUs that satisfy HasRequiredFields AND have no configurator children
// route through BuildAddItemFromCache instead — see the cache-only fast path
// in buildItemGroups / AddSkuToCartHandler. ApplyProjectionOverlay remains
// for the cold-grace case (unbus-fed cache entry) AND for groups with
// children (the parent's HTTP fetch is required for child dimension-driven
// pricing; Overlay is applied after the fetch).
//
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the
// caller checks idx.IsDeleted(sku) before this helper and short-circuits.
func ApplyProjectionOverlay(msg *messages.AddItem, p catalog.Projection) {
if msg == nil {
return
}
// Price: positive bus values override HTTP. A zero PriceIncVat is treated
// as "no value" rather than "free".
if p.PriceIncVat.Int64() > 0 {
msg.Price = p.PriceIncVat.Int64()
}
// Tax: only override when the bus has classified the SKU. An empty TaxClass
// means the producer had nothing to say about VAT; the HTTP rate is more
// trustworthy in that case than a guessed 2500.
if p.TaxClass != "" {
msg.Tax = int32(resolveTaxBp(p.TaxClass))
}
// ItemId: positive bus values override HTTP. Zero means the producer
// didn't publish an id (some sources have no integer id) — we leave the
// HTTP-fetched id intact in that case rather than zeroing out a real id.
if p.ItemID != 0 {
msg.ItemId = p.ItemID
}
// Display fields: only override if non-empty so the HTTP-fanned value
// remains in place when the cache hasn't populated them yet.
if p.Title != "" {
msg.Name = p.Title
}
if p.Image != "" {
msg.Image = p.Image
}
// Flags: only flip to true (positive boolean signals). A false is the
// proto-zero value and not worth overriding.
if p.InventoryTracked {
msg.InventoryTracked = true
}
if p.DropShip {
msg.DropShip = true
}
}
+601
View File
@@ -0,0 +1,601 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/money"
)
// TestApplyProjectionOverlay_PricePositiveOnly covers the contract that a cache
// hit with a positive PriceIncVat overrides HTTP-fetched price, but a zero
// (unbroadcast) PriceIncVat must NOT overwrite a valid HTTP price — it would
// be a silent corruption.
func TestApplyProjectionOverlay_PricePositiveOnly(t *testing.T) {
startPrice := int64(95_00)
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Price: startPrice, Name: "http-name", Image: "http.jpg", Tax: 2500}
}
// Positive cache value wins.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg", PriceIncVat: money.Cents(120_00), TaxClass: "reduced"})
if m.Price != 120_00 {
t.Errorf("positive cache price: got %d, want 12000", m.Price)
}
// Zero cache value must NOT overwrite (treat as "no value").
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", PriceIncVat: money.Cents(0)})
if m.Price != startPrice {
t.Errorf("zero cache price overwrote HTTP price: got %d, want %d (HTTP)", m.Price, startPrice)
}
}
// TestApplyProjectionOverlay_TaxOnlyWhenClassSet verifies the TaxClass-skip on
// an unclassified cache entry: HTTP rate is more trustworthy than a guessed
// 2500 default, so msg.Tax stays untouched.
func TestApplyProjectionOverlay_TaxOnlyWhenClassSet(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Tax: 600} // HTTP-fetched lowered 6%
}
// Empty TaxClass: HTTP rate preserved.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: ""})
if m.Tax != 600 {
t.Errorf("empty TaxClass overwrote HTTP tax: got %d, want 600", m.Tax)
}
// Non-empty: cache rate wins.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "reduced"})
if m.Tax != 1200 {
t.Errorf("TaxClass=reduced: got %d, want 1200", m.Tax)
}
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "zero"})
if m.Tax != 0 {
t.Errorf("TaxClass=zero: got %d, want 0", m.Tax)
}
}
// TestApplyProjectionOverlay_DisplayFields verifies Title/Image override only
// when the cache actually carries a value (an unbus-fed "" isn't propagated
// over a valid HTTP value).
func TestApplyProjectionOverlay_DisplayFields(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", Name: "http-name", Image: "http.jpg"}
}
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg"})
if m.Name != "cache-name" {
t.Errorf("Title overlay: got %q, want %q", m.Name, "cache-name")
}
if m.Image != "cache.jpg" {
t.Errorf("Image overlay: got %q, want %q", m.Image, "cache.jpg")
}
// Empty cache Title/Image must NOT overwrite.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "", Image: ""})
if m.Name != "http-name" {
t.Errorf("empty cache Title wiped HTTP name: got %q", m.Name)
}
if m.Image != "http.jpg" {
t.Errorf("empty cache Image wiped HTTP image: got %q", m.Image)
}
}
// TestApplyProjectionOverlay_FlagFlipToTrueOnly locks down the
// InventoryTracked / DropShip positive-flip semantics. False in the cache
// is the proto zero value and may be unset on the wire; we don't second-guess.
func TestApplyProjectionOverlay_FlagFlipToTrueOnly(t *testing.T) {
// HTTP set both true (e.g. live SKU). Cache also has them true. Should
// stay true.
m := &messages.AddItem{InventoryTracked: true, DropShip: true}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
if !m.InventoryTracked || !m.DropShip {
t.Fatalf("true-from-cache over a true-from-HTTP: %+v", m)
}
// HTTP set false (default), cache says true → upgrade to true.
m = &messages.AddItem{InventoryTracked: false, DropShip: false}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
if !m.InventoryTracked || !m.DropShip {
t.Errorf("true-from-cache should flip false-from-HTTP: %+v", m)
}
// HTTP already true, cache says false → must NOT downgrade (false would
// be the proto zero value; trust the live HTTP signal here).
m = &messages.AddItem{InventoryTracked: true, DropShip: true}
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: false, DropShip: false})
if !m.InventoryTracked || !m.DropShip {
t.Errorf("false-from-cache must NOT overwrite true-from-HTTP: %+v", m)
}
}
// TestApplyProjectionOverlay_NilMsgSafe confirms the helper tolerates a nil
// pointer (defensive — caller already validates, but a future caller may not).
func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) {
// Must not panic.
ApplyProjectionOverlay(nil, catalog.Projection{SKU: "X"})
}
// TestAddSkuToCartHandler_TombstoneReturns404 verifies the bus-deleted
// rejection path: HTTP 404 with the product-fetcher-shaped error string, no
// HTTP fetch attempted. Locks down the regression where the handler returned
// an error and the framework rendered it as 500.
//
// Why a real mux: `r.PathValue("sku")` only resolves when the request is
// dispatched through a mux that registered the path pattern (e.g.
// `GET /cart/add/{sku}` in pool-server.go's Serve()). Calling the handler
// directly with httptest.NewRequest produces an empty PathValue, which would
// bypass the tombstone branch and fall through to the HTTP fetch — failing
// the test for a wiring reason rather than the contract under test.
func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "DEL", PriceIncVat: money.Cents(10_00)}},
{Projection: catalog.Projection{SKU: "DEL"}, Deleted: true},
})
// Use a stub PoolServer with nil grain pool: the tombstone path returns
// BEFORE ApplyLocal, so the embedded actor.GrainPool is never touched. If a
// future edit adds an early s.IsHealthy() / pool-touch on this handler, the
// test will panic with a nil-pointer deref and the wiring fault will be
// obvious.
s := &PoolServer{pod_name: "test", idx: idx}
mux := http.NewServeMux()
mux.HandleFunc("GET /cart/add/{sku}", func(w http.ResponseWriter, r *http.Request) {
_ = s.AddSkuToCartHandler(w, r, cart.CartId(1))
})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/cart/add/DEL", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (bus-deleted SKU). body=%s",
rec.Code, http.StatusNotFound, rec.Body.String())
}
// http.Error-style exact shape: trailing newline, text/plain. Pinning the
// string catches regressions either way (body too loose AND format flips).
wantCT := "text/plain; charset=utf-8"
if got := rec.Header().Get("Content-Type"); got != wantCT {
t.Errorf("Content-Type = %q, want %q", got, wantCT)
}
wantBody := "product service returned 404 for sku DEL (bus-deleted)\n"
if got := rec.Body.String(); got != wantBody {
t.Errorf("body mismatch:\n got %q\n want %q", got, wantBody)
}
}
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
// is reported by both IsDeleted (true) and Get (miss).
func TestIsDeletedVsGet(t *testing.T) {
c := mustCache(t)
c.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
})
if _, ok := c.Get("DL"); ok {
t.Fatalf("Get on tombstone: ok=true")
}
if !c.IsDeleted("DL") {
t.Fatalf("IsDeleted on tombstone: false")
}
if c.IsDeleted("DL-fresh") {
t.Fatalf("IsDeleted on absent entry: true")
}
if _, ok := c.Get("DL-fresh"); ok {
t.Fatalf("Get on absent entry: ok=true")
}
}
// TestApplyProjectionOverlay_ItemIdPositiveOnly locks the contract that a
// positive bus ItemID overrides HTTP, but ItemID=0 must NOT clobber a real
// HTTP id (e.g. a producer that omits an integer id, or a tombstone's zero
// residual). This is the wire that future-unblocks the cache-only-skip-HTTP
// path; regression here would silently drop cart-line dedup info.
func TestApplyProjectionOverlay_ItemIdPositiveOnly(t *testing.T) {
base := func() *messages.AddItem {
return &messages.AddItem{Sku: "X", ItemId: 12345}
}
// Positive cache id wins.
m := base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", ItemID: 67890})
if m.ItemId != 67890 {
t.Errorf("positive cache ItemID: got %d, want 67890", m.ItemId)
}
// Zero cache id must NOT overwrite HTTP.
m = base()
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", ItemID: 0})
if m.ItemId != 12345 {
t.Errorf("zero cache ItemID wiped HTTP id: got %d, want 12345", m.ItemId)
}
}
// ---------------------------------------------------------------------------
// Cache-only-skip-HTTP path tests
// ---------------------------------------------------------------------------
// TestHasRequiredFields covers the eligibility contract for the cache-only
// fast path: positive PriceIncVat, non-empty TaxClass, non-zero ItemID. Any
// one missing → HTTP fallback.
func TestHasRequiredFields(t *testing.T) {
full := catalog.Projection{
SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard", ItemID: 1,
}
cases := []struct {
name string
p catalog.Projection
want bool
}{
{"all required fields set", full, true},
{"zero price → ineligible", catalog.Projection{SKU: "S", TaxClass: "standard", ItemID: 1}, false},
{"empty tax → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), ItemID: 1}, false},
{"zero id → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard"}, false},
{"only sku set", catalog.Projection{SKU: "S"}, false},
{"empty zero value", catalog.Projection{}, false},
{"reduced-rate still eligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(99), TaxClass: "reduced", ItemID: 7}, true},
{"zero-rate still eligible (zero TaxClass not required, only non-empty)",
catalog.Projection{SKU: "S", PriceIncVat: money.Cents(0), TaxClass: "zero", ItemID: 7}, false}, // price=0 still ineligible
}
for _, tc := range cases {
if got := HasRequiredFields(tc.p); got != tc.want {
t.Errorf("%s: HasRequiredFields=%v, want %v (p=%+v)", tc.name, got, tc.want, tc.p)
}
}
}
// TestBuildAddItemFromCache verifies the cache-only fast-path builder maps
// every cache-visible carrier onto the proto fields. ItemID, Price, Tax
// (via TaxClass resolution), Title/Image, quantity/country/storeId all
// populated; SellerId/SellerName/Stock/OrgPrice/ExtraJson left blank (those
// fields live outside the Projection for now).
func TestBuildAddItemFromCache(t *testing.T) {
p := catalog.Projection{
SKU: "X",
Title: "Cache Title",
Image: "cache.jpg",
PriceIncVat: money.Cents(123_45),
TaxClass: "reduced",
ItemID: 42,
InventoryTracked: true,
DropShip: true,
}
msg := BuildAddItemFromCache("X", 2, "no", nil, p)
if msg == nil {
t.Fatalf("BuildAddItemFromCache returned nil")
}
if msg.Sku != "X" {
t.Errorf("Sku: got %q, want %q", msg.Sku, "X")
}
if msg.ItemId != 42 {
t.Errorf("ItemId: got %d, want 42", msg.ItemId)
}
if msg.Quantity != 2 {
t.Errorf("Quantity: got %d, want 2", msg.Quantity)
}
if msg.Country != "no" {
t.Errorf("Country: got %q, want %q", msg.Country, "no")
}
if msg.StoreId != nil {
t.Errorf("StoreId: got %v, want nil", msg.StoreId)
}
if msg.Price != 123_45 {
t.Errorf("Price: got %d, want 12345", msg.Price)
}
if msg.Tax != 1200 {
t.Errorf("Tax: got %d, want 1200 (reduced)", msg.Tax)
}
if msg.Name != "Cache Title" {
t.Errorf("Name: got %q, want %q", msg.Name, "Cache Title")
}
if msg.Image != "cache.jpg" {
t.Errorf("Image: got %q, want %q", msg.Image, "cache.jpg")
}
if !msg.InventoryTracked {
t.Errorf("InventoryTracked: got false, want true")
}
if !msg.DropShip {
t.Errorf("DropShip: got false, want true")
}
// Cache-only gaps (intentionally blank):
if msg.SellerId != "" || msg.SellerName != "" {
t.Errorf("SellerId/SellerName should be blank: %q/%q", msg.SellerId, msg.SellerName)
}
if msg.Stock != 0 {
t.Errorf("Stock should be 0 (queried sync from inventory): got %d", msg.Stock)
}
if msg.OrgPrice != 0 {
t.Errorf("OrgPrice should be 0 (not on Projection): got %d", msg.OrgPrice)
}
if len(msg.ExtraJson) != 0 {
t.Errorf("ExtraJson should be empty (not on Projection): got %s", msg.ExtraJson)
}
}
// TestBuildAddItemFromCache_WithStoreId covers the *string storeId path.
// nil pointer is the default; non-nil must propagate through.
func TestBuildAddItemFromCache_WithStoreId(t *testing.T) {
sid := "store-42"
p := catalog.Projection{
SKU: "X", PriceIncVat: money.Cents(100),
TaxClass: "standard", ItemID: 1,
}
msg := BuildAddItemFromCache("X", 1, "se", &sid, p)
if msg.StoreId == nil {
t.Fatalf("StoreId is nil; want propagation")
}
if *msg.StoreId != "store-42" {
t.Errorf("StoreId: got %q, want %q", *msg.StoreId, "store-42")
}
}
// productServer is a fixture that returns a minimal product document so
// BuildItemMessage / FetchItem can complete without depending on a real
// product service. Each invocation records the SKU path so tests can assert
// "the cache-only path did NOT call the product service".
type productProduct struct {
Id uint64 `json:"id"`
Sku string `json:"sku"`
Title string `json:"title"`
Img string `json:"img"`
Price float64 `json:"price"`
Vat int `json:"vat"`
InStock int32 `json:"inStock"`
SupplierId int `json:"supplierId"`
SupplierName string `json:"supplierName"`
}
// fakeProductServer is a goroutine-safe mock that records each call.
type fakeProductServer struct {
srv *httptest.Server
called chan string
}
func newFakeProductServer(t *testing.T) *fakeProductServer {
t.Helper()
called := make(chan string, 16)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case called <- r.URL.Path:
default:
}
w.Header().Set("Content-Type", "application/json")
sku := r.URL.Path
_ = json.NewEncoder(w).Encode(productProduct{
Id: 100, Sku: sku, Title: "http-name",
Img: "http.jpg", Price: 50.0, Vat: 25,
InStock: 5, SupplierId: 1, SupplierName: "test",
})
}))
t.Cleanup(func() { srv.Close() })
return &fakeProductServer{srv: srv, called: called}
}
// TestBuildItemGroups_CacheOnlySkipsHTTP locks the core contract of the
// cache-only fast path: a parent with NO children AND a cache hit with
// HasRequiredFields returns an AddItem populated from the Projection
// WITHOUT making any HTTP round-trip. The mock product server's handler
// calls t.Errorf if invoked, so a non-empty channel would also fail the
// test.
func TestBuildItemGroups_CacheOnlySkipsHTTP(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "TOP",
PriceIncVat: money.Cents(99_00),
TaxClass: "standard",
ItemID: 7,
Title: "Cache Top",
Image: "cache.jpg",
InventoryTracked: true,
}},
})
groups := buildItemGroups(context.Background(), []Item{
{Sku: "TOP", Quantity: 1},
}, "se", idx)
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
g := groups[0]
if g.parent == nil {
t.Fatalf("parent nil; want a BuildAddItemFromCache-built message")
}
if g.parent.Sku != "TOP" {
t.Errorf("Sku: got %q, want %q", g.parent.Sku, "TOP")
}
if g.parent.Price != 99_00 {
t.Errorf("Price: got %d, want 9900", g.parent.Price)
}
if g.parent.Tax != 2500 {
t.Errorf("Tax: got %d, want 2500", g.parent.Tax)
}
if g.parent.ItemId != 7 {
t.Errorf("ItemId: got %d, want 7", g.parent.ItemId)
}
if g.parent.Name != "Cache Top" {
t.Errorf("Name: got %q, want %q", g.parent.Name, "Cache Top")
}
if g.parent.Image != "cache.jpg" {
t.Errorf("Image: got %q, want %q", g.parent.Image, "cache.jpg")
}
if !g.parent.InventoryTracked {
t.Errorf("InventoryTracked: got false (cache said true)")
}
if len(g.children) != 0 {
t.Errorf("children: got %d, want 0", len(g.children))
}
// Crucially: the mock product service must NOT have been called.
select {
case path := <-prod.called:
t.Errorf("product service called at %s; cache-only path should skip HTTP", path)
default:
// expected
}
}
// TestBuildItemGroups_HTTPFallbackWhenChildrenPresent locks the configurator
// safety: a parent with children CANNOT take the cache-only fast path
// because price derivation in ToItemAddMessage's parent != nil branch uses
// areaFromItem(parent) which reads width/height from the HTTP-fetched
// ProductItem.Extra — not on Projection yet. So the HTTP path must run for
// BOTH the parent and the child.
func TestBuildItemGroups_HTTPFallbackWhenChildrenPresent(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "PARENT", PriceIncVat: money.Cents(99_00),
TaxClass: "standard", ItemID: 7,
}},
})
groups := buildItemGroups(context.Background(), []Item{
{Sku: "PARENT", Quantity: 1, Children: []Item{
{Sku: "CHILD", Quantity: 1},
}},
}, "se", idx)
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
if groups[0].parent == nil {
t.Fatalf("parent nil; expect HTTP-built message")
}
// Drain the channel and pin BOTH parent and child fetches. A regression
// that fetches the parent twice or skips the child will surface loudly:
// we capture the SKU paths the mock sees, so missing/duplicate calls
// fail the assertion.
close(prod.called)
paths := make(map[string]int)
for path := range prod.called {
paths[path]++
}
if paths["/api/get/PARENT"] < 1 {
t.Errorf("expected /api/get/PARENT fetch (HTTP fallback for parent); got %v", paths)
}
if paths["/api/get/CHILD"] < 1 {
t.Errorf("expected /api/get/CHILD fetch (HTTP fallback for child); got %v", paths)
}
}
// TestAddSkuToCartHandler_CacheOnlySkipsHTTP exercises the AddSkuToCartHandler
// cache-only path end-to-end through the mux. The same fail-on-call mock
// product service proves no HTTP round-trip happens on the cache-only fast
// path. ApplyLocal will fail on a nil grain pool — that's fine, we recover()
// inside the mux wrapper; the only assertion that matters is "the mock was
// not called".
//
// Why a real mux: PathValue("sku") only resolves when the mux has registered
// the path pattern (see TestAddSkuToCartHandler_TombstoneReturns404 for the
// same rationale).
func TestAddSkuToCartHandler_CacheOnlySkipsHTTP(t *testing.T) {
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("product service called at %s on cache-only path; want zero HTTP fetches", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}))
defer failSrv.Close()
t.Setenv("PRODUCT_BASE_URL", failSrv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "OK", PriceIncVat: money.Cents(99_00),
TaxClass: "standard", ItemID: 7,
Title: "Cache OK", Image: "cache.jpg",
}},
})
s := &PoolServer{pod_name: "test", idx: idx}
mux := http.NewServeMux()
mux.HandleFunc("GET /cart/add/{sku}", func(w http.ResponseWriter, r *http.Request) {
// ApplyLocal calls into the embedded GrainPool which is nil here;
// the cache-only build-the-message path will reach ApplyLocal and
// then panic on a nil deref. Recover so the test framework can
// observe a clean pass; what matters is the cache-only short-circuit
// RAN (the mock product service would have been hit otherwise).
defer func() { _ = recover() }()
_ = s.AddSkuToCartHandler(w, r, cart.CartId(1))
})
mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/cart/add/OK", nil))
// No explicit assertion needed: failSrv's t.Errorf would already be
// recorded as a test failure if the cache-only short-circuit missed.
}
// TestBuildItemGroups_HTTPFallbackWhenCacheMiss locks the cold-grace path:
// when the cache has NO entry for the SKU, the HTTP fetch must run.
func TestBuildItemGroups_HTTPFallbackWhenCacheMiss(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t) // empty
groups := buildItemGroups(context.Background(), []Item{
{Sku: "MISS", Quantity: 1},
}, "se", idx)
if groups[0].parent == nil {
t.Fatalf("parent nil; cold-grace should have HTTP-fetched")
}
close(prod.called)
count := 0
for range prod.called {
count++
}
if count != 1 {
t.Errorf("expected exactly 1 product service call; got %d", count)
}
}
// TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse locks the
// partial-cache case: a cache entry with PriceIncVat > 0 but TaxClass=""
// fails HasRequiredFields → HTTP fallback. This keeps the existing cold-grace
// behavior for SKUs whose producer hasn't published a TaxClass yet.
func TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse(t *testing.T) {
prod := newFakeProductServer(t)
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
idx := mustCache(t)
idx.Apply([]catalog.ProjectionUpdate{
{Projection: catalog.Projection{
SKU: "PARTIAL", PriceIncVat: money.Cents(99_00),
TaxClass: "" /* missing → HasRequiredFields=false */,
ItemID: 7,
}},
})
groups := buildItemGroups(context.Background(), []Item{
{Sku: "PARTIAL", Quantity: 1},
}, "se", idx)
if groups[0].parent == nil {
t.Fatalf("parent nil; partial cache should have HTTP-fetched")
}
close(prod.called)
count := 0
for range prod.called {
count++
}
if count != 1 {
t.Errorf("expected exactly 1 product service call; got %d (HasRequiredFields=false should fail the cache-only gate)", count)
}
}
@@ -0,0 +1,72 @@
package main
import (
"strconv"
"testing"
"git.k6n.net/mats/platform/catalog"
"git.k6n.net/mats/platform/money"
)
// frameMeta builds the event.Meta a producer stamps for a snapshot frame / delta.
func frameMeta(phase string, epoch int64) map[string]string {
m := map[string]string{catalog.MetaEpoch: strconv.FormatInt(epoch, 10), catalog.MetaTenant: "se"}
if phase != "" {
m[catalog.MetaSnapshotPhase] = phase
}
return m
}
func enc(t *testing.T, ups []catalog.ProjectionUpdate) []byte {
t.Helper()
b, err := catalog.EncodeUpdates(ups)
if err != nil {
t.Fatalf("encode: %v", err)
}
return b
}
// TestCartCache_SnapshotThenDelta exercises the cart's cache through the real bus
// framing: a full begin→chunk→end snapshot makes SKUs Get-able cold (no Apply),
// then an epoch-matched delta overrides one and tombstones another.
func TestCartCache_SnapshotThenDelta(t *testing.T) {
c := mustCache(t)
const epoch = int64(1000)
if err := c.HandleFrame(frameMeta(catalog.SnapshotBegin, epoch), enc(t, nil)); err != nil {
t.Fatal(err)
}
if err := c.HandleFrame(frameMeta(catalog.SnapshotChunk, epoch), enc(t, []catalog.ProjectionUpdate{
{Projection: catalog.Projection{ID: "sku:A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard", ItemID: 1}},
{Projection: catalog.Projection{ID: "sku:B", SKU: "B", PriceIncVat: money.Cents(50_00), TaxClass: "reduced", ItemID: 2}},
})); err != nil {
t.Fatal(err)
}
// end carries the total count for the completeness guard.
endMeta := frameMeta(catalog.SnapshotEnd, epoch)
endMeta[catalog.MetaCount] = "2"
if err := c.HandleFrame(endMeta, enc(t, nil)); err != nil {
t.Fatal(err)
}
if got, ok := c.Get("A"); !ok || got.PriceIncVat != 100_00 {
t.Fatalf("A from snapshot = %+v ok=%v, want 10000", got, ok)
}
// Epoch-matched delta: bump A's price, delete B.
if err := c.HandleFrame(frameMeta("", epoch), enc(t, []catalog.ProjectionUpdate{
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(90_00), TaxClass: "standard", ItemID: 1}},
{Projection: catalog.Projection{SKU: "B"}, Deleted: true},
})); err != nil {
t.Fatal(err)
}
if got, _ := c.Get("A"); got.PriceIncVat != 90_00 {
t.Fatalf("A after delta = %d, want 9000", got.PriceIncVat)
}
if _, ok := c.Get("B"); ok {
t.Fatalf("B after delete should miss")
}
if !c.IsDeleted("B") {
t.Fatalf("B should be tombstoned (skip HTTP fetch)")
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"testing"
"git.k6n.net/mats/platform/catalog"
)
// mustCache opens a per-pod projection cache backed by a temp dir (pod-local).
func mustCache(t *testing.T) *catalogProjectionCache {
t.Helper()
c, err := newCatalogProjectionCache(t.TempDir())
if err != nil {
t.Fatalf("newCatalogProjectionCache: %v", err)
}
t.Cleanup(func() { _ = c.store.Close() })
return c
}
// Apply is a TEST-ONLY convenience that routes a delta batch through the store at
// the initial epoch (0), reproducing the pre-ProjectionStore cache API so the
// existing cart tests keep exercising Get/IsDeleted/Len. Production code feeds the
// store via HandleFrame with real bus framing (snapshot + epoch-stamped deltas).
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
for _, u := range updates {
if u.Deleted {
deletes++
} else if u.SKU != "" {
upserts++
}
}
payload, err := catalog.EncodeUpdates(updates)
if err != nil {
return 0, 0
}
// No MetaSnapshotPhase ⇒ delta; epoch 0 matches a fresh store's base epoch.
_ = c.store.HandleFrame(map[string]string{catalog.MetaEpoch: "0"}, payload)
return upserts, deletes
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
)
// newPromotionEvaluateHandler serves POST /promotions/evaluate: it takes a
// (possibly partial) evaluation context and returns the totals plus the
// applied/pending promotion effects, without creating or mutating a real cart.
// An empty body is treated as an empty context (evaluates against no items).
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req promotions.EvaluateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
return
}
resp := svc.Evaluate(store.Snapshot(), req)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"log"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
)
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return "se"
}
return inventory.LocationID(*item.StoreId)
}
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
var requests []inventory.ReserveRequest
for _, item := range items {
if item == nil {
continue
}
requests = append(requests, inventory.ReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(item.Sku),
LocationID: getLocationId(item),
},
Quantity: uint32(item.Quantity),
})
}
return requests
}
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
+303
View File
@@ -0,0 +1,303 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type SessionRequest struct {
SessionId *string `json:"sessionId,omitempty"`
SessionResult string `json:"sessionResult"`
SessionData *string `json:"sessionData,omitempty"`
}
// func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
// grain, err := s.Get(r.Context(), uint64(cartId))
// if err != nil {
// return err
// }
// if r.Method == http.MethodGet {
// service := s.adyenClient.Checkout()
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(pa).SessionResult(payload.SessionResult)
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
// if err != nil {
// return err
// }
// return s.WriteResult(w, res)
// } else {
// payload := &SessionRequest{}
// if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
// return err
// }
// service := s.adyenClient.Checkout()
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
// if err != nil {
// return err
// }
// return s.WriteResult(w, res)
// }
// }
func getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (*cart.CartId, error) {
cartId, ok := cart.ParseCartId(item.MerchantReference)
if !ok {
log.Printf("The notification does not have a valid cartId: %s", item.MerchantReference)
return nil, errors.New("invalid cart id")
}
return &cartId, nil
}
func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
var notificationRequest webhook.Webhook
service := s.adyenClient.Checkout()
if err := json.NewDecoder(r.Body).Decode(&notificationRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, notificationItem := range *notificationRequest.NotificationItems {
item := notificationItem.NotificationRequestItem
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
if !isValid {
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
return
} else {
// Marshal item data for PaymentEvent
dataBytes, err := json.Marshal(item)
if err != nil {
log.Printf("error marshaling item: %v", err)
http.Error(w, "Error marshaling item", http.StatusInternalServerError)
return
}
switch item.EventCode {
case "CAPTURE":
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Capture status: %v", item.Success)
isSuccess := item.Success == "true"
// If successful, apply payment completed
//if isSuccess {
if err := s.ApplyAnywhere(r.Context(), *checkoutId,
&messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
}, &messages.PaymentCompleted{
PaymentId: item.PspReference,
Status: item.Success,
Amount: item.Amount.Value,
Currency: item.Amount.Currency,
ProcessorReference: &item.PspReference,
CompletedAt: timestamppb.New(time.Now()),
}); err != nil {
http.Error(w, "Message not parsed", http.StatusInternalServerError)
return
}
//}
// CAPTURE is the moment the Adyen payment is fully settled — the
// Adyen analogue of the Klarna push. Create the event-sourced
// order now (mirrors KlarnaPushHandler). Idempotent on
// "checkout-{checkoutId}", so a retried CAPTURE won't duplicate.
if isSuccess {
s.createAdyenOrder(r.Context(), *checkoutId, item)
}
case "AUTHORISATION":
isSuccess := item.Success == "true"
log.Printf("Handling auth: %+v", item)
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
msgs := []proto.Message{
&messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
},
}
if isSuccess {
msgs = append(msgs, &messages.PaymentCompleted{
PaymentId: item.PspReference,
Status: item.Success,
Amount: item.Amount.Value,
CompletedAt: timestamppb.Now(),
})
} else {
msgs = append(msgs, &messages.PaymentDeclined{
PaymentId: item.PspReference,
Message: item.Reason,
})
}
if err := s.ApplyAnywhere(r.Context(), *checkoutId, msgs...); err != nil {
log.Printf("error applying authorization event: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// If successful authorization, trigger capture
if isSuccess {
pspReference := item.PspReference
uid := uuid.New().String()
ref := checkoutId.String()
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{
Amount: adyenCheckout.Amount(item.Amount),
MerchantAccount: "ElgigantenECOM",
Reference: &ref,
})
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
if err != nil {
log.Printf("Error capturing payment: %v", err)
} else {
// Capture requested. The order is NOT created here — we
// only have a PSP reference, not an order. Adyen sends a
// CAPTURE notification once the capture settles, and the
// CAPTURE branch above creates the event-sourced order.
log.Printf("Payment capture requested successfully: %+v", res)
}
}
default:
log.Printf("Unknown event code: %s", item.EventCode)
log.Printf("Item data: %+v", item)
isSuccess := item.Success == "true"
checkoutId, err := getCheckoutIdFromNotificationItem(item)
if err != nil {
log.Printf("Could not get checkout id: %v", err)
} else {
if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{
PaymentId: item.PspReference,
Success: isSuccess,
Name: item.EventCode,
Data: &anypb.Any{Value: dataBytes},
}); err != nil {
log.Printf("error applying payment event: %v", err)
}
}
}
}
}
w.WriteHeader(http.StatusAccepted)
}
// createAdyenOrder turns a settled (captured) Adyen payment into an
// event-sourced order, mirroring KlarnaPushHandler. The Adyen webhook carries
// the charged amount + currency but not the shopper's country/locale, so those
// are derived from the currency. Failures are logged and left for the next
// CAPTURE notification to retry — the from-checkout endpoint is idempotent on
// "checkout-{checkoutId}".
func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId cart.CartId, item webhook.NotificationRequestItem) {
grain, err := s.GetAnywhere(ctx, checkoutId)
if err != nil {
log.Printf("from-checkout: could not load checkout grain %s: %v", checkoutId.String(), err)
return
}
// Inventory is NOT committed here — see the note in KlarnaPushHandler. The
// order saga emits order.created and the inventory service reacts
// (commit + release + level crossing), idempotently and off the revenue path.
country := getCountryFromCurrency(item.Amount.Currency)
if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
Provider: "adyen",
Reference: item.PspReference,
Amount: item.Amount.Value,
Currency: item.Amount.Currency,
Country: country,
Locale: getLocale(country),
}); oerr != nil {
log.Printf("from-checkout failed for checkout %s; will retry on next CAPTURE: %v", checkoutId.String(), oerr)
}
}
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
log.Println("Redirect received")
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
log.Printf("got payment session %+v", res)
dreq := service.PaymentsApi.PaymentsDetailsInput()
dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{
Details: adyenCheckout.PaymentCompletionDetails{
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
Payload: common.PtrString(r.URL.Query().Get("payload")),
},
})
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Payment details response: %+v", dres)
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
var redirectURL string
// Conditionally handle different result codes for the shopper
switch *dres.ResultCode {
case "Authorised":
redirectURL = "/result/success"
case "Pending", "Received":
redirectURL = "/result/pending"
case "Refused":
redirectURL = "/result/failed"
default:
reason := ""
if dres.RefusalReason != nil {
reason = *dres.RefusalReason
} else {
reason = *dres.ResultCode
}
log.Printf("Payment failed: %s", reason)
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpRes.StatusCode)
json.NewEncoder(w).Encode(httpRes.Status)
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"context"
"fmt"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type AmqpOrderHandler struct {
conn *amqp.Connection
queue *amqp.Queue
}
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
return &AmqpOrderHandler{
conn: conn,
}
}
func (h *AmqpOrderHandler) DefineQueue() error {
ch, err := h.conn.Channel()
if err != nil {
return fmt.Errorf("failed to open a channel: %w", err)
}
defer ch.Close()
queue, err := ch.QueueDeclare(
"order-queue", // name
false,
false,
false,
false,
nil,
)
if err != nil {
return fmt.Errorf("failed to declare an exchange: %w", err)
}
h.queue = &queue
return nil
}
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
ch, err := h.conn.Channel()
if err != nil {
return fmt.Errorf("failed to open a channel: %w", err)
}
defer ch.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return ch.PublishWithContext(ctx,
"", // exchange
h.queue.Name, // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
//DeliveryMode: amqp.,
ContentType: "application/json",
Body: body,
})
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
type CartClient struct {
httpClient *http.Client
baseUrl string
agentProfile string // UCP-Agent profile URI
}
func NewCartClient(baseUrl string) *CartClient {
profile := os.Getenv("UCP_AGENT_PROFILE")
if profile == "" {
profile = "https://checkout.k6n.net/.well-known/ucp"
}
return &CartClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseUrl: baseUrl,
agentProfile: profile,
}
}
// func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error {
// url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String())
// data, err := proto.Marshal(mutation)
// if err != nil {
// return err
// }
// req, err := http.NewRequest("POST", url, bytes.NewReader(data))
// if err != nil {
// return err
// }
// req.Header.Set("Content-Type", "application/protobuf")
// resp, err := c.httpClient.Do(req)
// if err != nil {
// return err
// }
// defer resp.Body.Close()
// if resp.StatusCode != http.StatusOK {
// return fmt.Errorf("cart mutation failed: %s", resp.Status)
// }
// return nil
// }
func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
// Call cart service to get grain
url := fmt.Sprintf("%s/cart/byid/%s", s.baseUrl, cartId.String())
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("UCP-Agent", `profile="`+s.agentProfile+`"`)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("request to %s failed with status %s", url, resp.Status)
return nil, fmt.Errorf("failed to get cart: %s", resp.Status)
}
var grain cart.CartGrain
err = json.NewDecoder(resp.Body).Decode(&grain)
return &grain, err
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"context"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
// This test is skipped by default and can be run with: go test -run TestGetCartGrain_RealService
func TestGetCartGrain_RealService(t *testing.T) {
t.Skip("Skipping integration test against real service")
client := NewCartClient("https://cart.k6n.net")
// You would need a real cart ID that exists in the system
// For example: cartId := cart.NewCartId(123, 456)
cartId := cart.MustParseCartId("JkfG6bRNLMy")
grain, err := client.getCartGrain(context.Background(), cartId)
if err != nil {
t.Fatalf("Failed to get cart grain: %v", err)
}
if grain == nil {
t.Fatal("Expected grain to be non-nil")
}
t.Logf("Successfully retrieved cart grain: %+v", grain)
}
+302
View File
@@ -0,0 +1,302 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/platform/tax"
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
"github.com/adyen/adyen-go-api-library/v21/src/common"
)
// defaultCallbackBaseURL is where Klarna's server-to-server callbacks
// (push/notification/validate) are sent when CHECKOUT_CALLBACK_BASE_URL is unset.
// These must be a publicly reachable https origin for the checkout service.
const defaultCallbackBaseURL = "https://cart.k6n.net"
var (
// checkoutPublicURL overrides the request-derived site base for the
// shopper-facing Klarna URLs (terms/checkout/confirmation). Set this to a
// public https origin (e.g. a tunnel or shop-test.tornberg.me) for local
// KCO testing, since Klarna rejects non-https merchant_urls. Empty = derive
// from the request (X-Forwarded-Host + https).
checkoutPublicURL = strings.TrimRight(os.Getenv("CHECKOUT_PUBLIC_URL"), "/")
// checkoutCallbackBaseURL is the base for Klarna's server-to-server
// callbacks. Defaults to defaultCallbackBaseURL.
checkoutCallbackBaseURL = func() string {
if v := strings.TrimRight(os.Getenv("CHECKOUT_CALLBACK_BASE_URL"), "/"); v != "" {
return v
}
return defaultCallbackBaseURL
}()
)
// CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct {
SiteUrl string
// CallbackBaseUrl is the base for Klarna server-to-server callbacks
// (push/notification/validate); may differ from SiteUrl in prod where the
// checkout service and storefront are on different hosts.
CallbackBaseUrl string
ClientIp string
Country string
Currency string // optional override (defaults to "SEK" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
}
// BuildCheckoutOrderPayload converts the current cart grain + meta information
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
// (to send to Klarna) alongside the structured CheckoutOrder object.
//
// This function is PURE: it does not perform any network I/O or mutate the
// grain. The caller is responsible for:
//
// 1. Choosing whether to create or update the Klarna order.
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
// resulting Klarna order id + status.
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
// delivery lines instead of the hardcoded 2500 (25 % as CartItem format).
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
locale := meta.Locale
if locale == "" {
locale = "sv-se"
}
country := meta.Country
if country == "" {
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: int(it.Quantity),
UnitPrice: int(it.Price.IncVat),
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
})
}
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines — use the configured default tax rate for the purchase country.
defaultKlarnaRate := defaultKlarnaTaxRate(tp, country)
for _, d := range grain.Deliveries {
if d == nil {
continue
}
unitPrice := d.Price.IncVat
taxAmount := d.Price.TotalVat()
if hasFreeShipping {
unitPrice = 0
taxAmount = 0
}
if unitPrice <= 0 && !hasFreeShipping {
continue
}
//total.Add(d.Price)
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(unitPrice),
TaxRate: defaultKlarnaRate,
QuantityUnit: "st",
TotalAmount: int(unitPrice),
TotalTaxAmount: int(taxAmount),
})
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(total.IncVat),
OrderTaxAmount: int(total.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
Checkout: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Confirmation: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
Notification: fmt.Sprintf("%s/payment/klarna/notification", meta.CallbackBaseUrl),
Validation: fmt.Sprintf("%s/payment/klarna/validate", meta.CallbackBaseUrl),
Push: fmt.Sprintf("%s/payment/klarna/push?order_id={checkout.order.id}", meta.CallbackBaseUrl),
},
}
payload, err := json.Marshal(order)
if err != nil {
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
}
return payload, order, nil
}
// defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna
// format (percent x 100, e.g. 2500 = 25.00 %). This matches the platform basis-point
// scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls back to 2500.
func defaultKlarnaTaxRate(tp tax.Provider, country string) int {
if tp == nil {
return 2500
}
return tp.DefaultTaxRate(country)
}
// defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen
// format (taxPercentage in basis points, e.g. 2500 = 25 %). This matches the platform
// basis-point scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls
// back to 2500.
func defaultAdyenTaxRate(tp tax.Provider, country string) int64 {
if tp == nil {
return 2500
}
return int64(tp.DefaultTaxRate(country))
}
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
host := getOriginalHost(r)
country := getCountryFromHost(host)
siteUrl := fmt.Sprintf("%s://%s", getScheme(r), host)
if checkoutPublicURL != "" {
siteUrl = checkoutPublicURL
}
return &CheckoutMeta{
ClientIp: getClientIp(r),
SiteUrl: siteUrl,
CallbackBaseUrl: checkoutCallbackBaseURL,
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
}
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
country := meta.Country
if country == "" {
country = "SE"
}
lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(int64(it.Quantity)),
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat.Int64()),
Description: common.PtrString(it.Meta.Name),
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat().Int64()),
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat().Int64()),
TaxPercentage: common.PtrInt64(int64(it.Tax)),
})
}
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
total := cart.NewPrice()
total.Add(*grain.CartState.TotalPrice)
// Delivery lines — use the configured default tax rate for the purchase country.
defaultAdyenRate := defaultAdyenTaxRate(tp, country)
for _, d := range grain.Deliveries {
if d == nil {
continue
}
amountIncTax := d.Price.IncVat.Int64()
amountExTax := d.Price.ValueExVat().Int64()
if hasFreeShipping {
amountIncTax = 0
amountExTax = 0
}
if amountIncTax <= 0 && !hasFreeShipping {
continue
}
lineItems = append(lineItems, adyenCheckout.LineItem{
Quantity: common.PtrInt64(1),
AmountIncludingTax: common.PtrInt64(amountIncTax),
Description: common.PtrString("Delivery"),
AmountExcludingTax: common.PtrInt64(amountExTax),
TaxPercentage: common.PtrInt64(defaultAdyenRate),
})
}
return &adyenCheckout.CreateCheckoutSessionRequest{
Reference: grain.Id.String(),
Amount: adyenCheckout.Amount{
Value: total.IncVat.Int64(),
Currency: currency,
},
CountryCode: common.PtrString(country),
MerchantAccount: "ElgigantenECOM",
Channel: common.PtrString("Web"),
ShopperIP: common.PtrString(meta.ClientIp),
ReturnUrl: fmt.Sprintf("%s/payment/adyen/return", meta.SiteUrl),
LineItems: lineItems,
}, nil
}
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
)
func UseDiscovery(pool discovery.DiscoveryTarget) {
discovery.StartK8sDiscovery(podIp, "actor-pool=checkout", 30, pool)
}
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -30,11 +31,14 @@ const (
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
)
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
func (k *KlarnaClient) GetOrder(ctx context.Context, orderId string) (*CheckoutOrder, error) {
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
if err != nil {
return nil, err
}
spanCtx, span := tracer.Start(ctx, "Get klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
@@ -64,17 +68,19 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
return nil, fmt.Errorf("%s", res.Status)
}
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*CheckoutOrder, error) {
//bytes.NewReader(reply.Payload)
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
if err != nil {
return nil, err
}
spanCtx, span := tracer.Start(ctx, "Create klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
res, err := http.DefaultClient.Do(req)
res, err := k.client.Do(req)
if nil != err {
return nil, err
}
@@ -82,17 +88,20 @@ func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
return k.getOrderResponse(res)
}
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader io.Reader) (*CheckoutOrder, error) {
//bytes.NewReader(reply.Payload)
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
if err != nil {
return nil, err
}
spanCtx, span := tracer.Start(ctx, "Update klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.Header.Add("Content-Type", "application/json")
req.SetBasicAuth(k.UserName, k.Password)
res, err := http.DefaultClient.Do(req)
res, err := k.client.Do(req)
if nil != err {
return nil, err
}
@@ -100,29 +109,34 @@ func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutO
return k.getOrderResponse(res)
}
func (k *KlarnaClient) AbortOrder(orderId string) error {
func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
if err != nil {
return err
}
spanCtx, span := tracer.Start(ctx, "Abort klarna order")
defer span.End()
req = req.WithContext(spanCtx)
req.SetBasicAuth(k.UserName, k.Password)
_, err = http.DefaultClient.Do(req)
_, err = k.client.Do(req)
return err
}
// ordermanagement/v1/orders/{order_id}/acknowledge
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) error {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
if err != nil {
return err
}
spanCtx, span := tracer.Start(ctx, "Acknowledge klarna order")
defer span.End()
req = req.WithContext(spanCtx)
id := uuid.New()
req.SetBasicAuth(k.UserName, k.Password)
req.Header.Add("Klarna-Idempotency-Key", id.String())
_, err = http.DefaultClient.Do(req)
_, err = k.client.Do(req)
return err
}
+272
View File
@@ -0,0 +1,272 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/platform/inventory"
"google.golang.org/protobuf/types/known/timestamppb"
)
/*
*
*
* s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
return err
})
*/
// func (s *CheckoutPoolServer) KlarnaHtmlCheckoutHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
// orderId := r.URL.Query().Get("order_id")
// var order *CheckoutOrder
// var err error
// if orderId == "" {
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
// if err != nil {
// logger.Error("unable to create klarna session", "error", err)
// return err
// }
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
// }
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
// w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
// w.WriteHeader(http.StatusOK)
// _, err = fmt.Fprintf(w, tpl, order.HTMLSnippet)
// return err
// }
// func (s *CheckoutPoolServer) KlarnaSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
// orderId := r.URL.Query().Get("order_id")
// var order *CheckoutOrder
// var err error
// if orderId == "" {
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
// if err != nil {
// logger.Error("unable to create klarna session", "error", err)
// return err
// }
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
// }
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "application/json; charset=utf-8")
// return json.NewEncoder(w).Encode(order)
// }
func (s *CheckoutPoolServer) KlarnaConfirmationHandler(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
// Apply ConfirmationViewed mutation
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if ok {
s.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if order.Status == "checkout_complete" {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
func (s *CheckoutPoolServer) KlarnaValidationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
s.reserveInventory(r.Context(), grain)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaNotificationHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order notification, method: %s", r.Method)
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
order := &CheckoutOrder{}
err := json.NewDecoder(r.Body).Decode(order)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
log.Printf("Klarna order notification: %s", order.ID)
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
w.WriteHeader(http.StatusOK)
}
func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order confirmation push, method: %s", r.Method)
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
if err != nil {
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
// Inventory is NOT committed here. Checkout announces a completed sale; the
// order saga emits order.created and the inventory service reacts (commit +
// release the cart hold + emit a level crossing), idempotently. This keeps a
// down inventory service from blocking the revenue path — the payment is
// already settled at Klarna, so the sale is a fact, not a request. See
// docs/inventory-shape.md.
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
PaymentId: orderId,
Status: "completed",
ProcessorReference: &order.ID,
Amount: int64(order.OrderAmount),
Currency: order.PurchaseCurrency,
CompletedAt: timestamppb.Now(),
})
// ── Create the event-sourced order grain (HTTP with AMQP fallback) ────
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
// Non-fatal: the checkout grain is updated, the order can be
// created on retry (idempotency key guards against duplicates).
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
"err", orderErr, "checkoutId", grain.Id.String())
}
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
w.WriteHeader(http.StatusOK)
}
var tpl = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>s10r testing - checkout</title>
</head>
<body>
%s
</body>
</html>
`
// createOrderFromCheckout builds and sends the from-checkout request to the
// order service, creating an event-sourced order grain from the settled checkout.
// The idempotency key is "checkout-{checkoutId}" so that retries (Klarna may
// push the same order multiple times) are safe.
func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string) (*CreateOrderResult, error) {
return createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
Provider: provider,
Reference: klarnaOrder.ID,
Amount: int64(klarnaOrder.OrderAmount),
Currency: klarnaOrder.PurchaseCurrency,
Country: klarnaOrder.PurchaseCountry,
Locale: klarnaOrder.Locale,
})
}
func getLocationId(item *cart.CartItem) inventory.LocationID {
if item.StoreId == nil || *item.StoreId == "" {
return inventory.LocationID("se")
}
return inventory.LocationID(*item.StoreId)
}
func shouldTrackInventory(item *cart.CartItem) bool {
if item.DropShip {
return false
}
if item.InventoryTracked {
return true
}
return false
}
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
cartId, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
grain, err := a.GetAnywhere(ctx, cartId)
if err != nil {
return nil, fmt.Errorf("failed to get cart grain: %w", err)
}
return grain, nil
}
+301
View File
@@ -0,0 +1,301 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"time"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
redisinv "git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/platform/tax"
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/adyen/adyen-go-api-library/v21/src/common"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
var (
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_spawned_total",
Help: "The total number of spawned checkout grains",
})
)
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *actor.SimpleGrainPool[checkout.CheckoutGrain]
server *CheckoutPoolServer
klarnaClient *KlarnaClient
cartClient *CartClient // For internal communication to cart
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var redisAddress = os.Getenv("REDIS_ADDRESS")
var redisPassword = os.Getenv("REDIS_PASSWORD")
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
// selectTaxProvider picks the tax provider from the environment.
// Default is NordicTaxProvider with SE as the default country.
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
func selectTaxProvider() tax.Provider {
switch os.Getenv("TAX_PROVIDER") {
case "static":
return tax.NewStatic()
case "nordic":
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
default:
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
}
}
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPCheckoutSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
func main() {
controlPlaneConfig := actor.DefaultServerConfig()
reg := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *checkout.CheckoutGrain) error {
g.Version++
return nil
}),
)
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword,
DB: 0,
})
reservationService, err := redisinv.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Error creating reservation service: %v\n", err)
}
reservationPolicy := redisinv.NewReservationPolicyAdapter(reservationService)
checkoutDir := os.Getenv("CHECKOUT_DIR")
if checkoutDir == "" {
checkoutDir = "data"
}
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
var syncedServer *CheckoutPoolServer
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
defer span.End()
grainSpawns.Inc()
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
// Load persisted events/state for this checkout if present
if err := diskStorage.LoadEvents(ctx, id, ret); err != nil {
// Return the grain along with error (e.g., not found) so callers can decide
return ret, err
}
return ret, nil
},
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
ctx := context.Background()
state, err := grain.GetCurrentState()
if err == nil && state != nil {
syncedServer.releaseCartReservations(ctx, state)
}
return nil
},
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
return proxy.NewRemoteHost[checkout.CheckoutGrain](host)
},
TTL: 1 * time.Hour, // Longer TTL for checkout
PoolSize: 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating checkout pool: %v\n", err)
}
adyenClient := adyen.NewClient(&common.Config{
ApiKey: os.Getenv("ADYEN_API_KEY"),
Environment: common.TestEnv,
})
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
cartClient := NewCartClient(cartInternalUrl)
var orderClient *OrderClient
if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" {
orderClient = NewOrderClient(orderServiceURL)
log.Printf("order client enabled: %s", orderServiceURL)
}
var orderHandler *AmqpOrderHandler
conn, err := rabbit.Dial(amqpUrl, "cart-checkout")
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
}
orderHandler = NewAmqpOrderHandler(conn.Connection())
if err := orderHandler.DefineQueue(); err != nil {
log.Fatalf("failed to declare order queue: %v", err)
}
// Reconnecting order queue consumer: re-define queue and re-consume on reconnect.
conn.NotifyOnReconnect(func() {
if err := orderHandler.DefineQueue(); err != nil {
log.Printf("checkout: define queue on reconnect: %v", err)
}
})
// Stream applied checkout mutations to the shared mutations exchange
// (routing key mutation.checkout) for the backoffice live feed.
checkoutFeed := actor.NewAmqpListener(conn.Connection(), "checkout", actor.MutationSummary)
checkoutFeed.DefineTopics()
pool.AddListener(checkoutFeed)
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
syncedServer.reservationPolicy = reservationPolicy
syncedServer.taxProvider = selectTaxProvider()
mux := http.NewServeMux()
debugMux := http.NewServeMux()
if amqpUrl == "" {
log.Fatalf("no connection to amqp defined")
}
grpcSrv, err := actor.NewControlServer[checkout.CheckoutGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
syncedServer.Serve(mux)
// UCP REST adapter — Universal Commerce Protocol checkout sessions
// When an order service URL is configured, the complete endpoint creates
// real orders via the order service's from-checkout API.
var ucpOrderSvc ucp.OrderApplier
if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" {
ucpOrderSvc = ucp.NewOrderHTTPClient(orderServiceURL)
log.Printf("ucp order applier enabled: %s", orderServiceURL)
}
checkoutUCP := ucp.CheckoutHandler(pool, ucpOrderSvc)
if signer := loadUCPCheckoutSigner(); signer != nil {
checkoutUCP = ucp.WithSigning(checkoutUCP, signer)
log.Print("ucp signing enabled")
}
// StripPrefix is required because the sub-mux (CheckoutHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path without stripping the prefix.
// Only the subtree pattern is registered (see comment in cmd/cart/main.go
// for why the exact-match pattern is omitted).
mux.Handle("/ucp/v1/checkout-sessions/", http.StripPrefix("/ucp/v1/checkout-sessions", checkoutUCP))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
grainCount, capacity := pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
srv := &http.Server{
Addr: ":8080",
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
log.Print("Checkout server started at port 8080")
go http.ListenAndServe(":8081", debugMux)
select {
case err = <-srvErr:
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
stop()
}
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// OrderClient is a minimal HTTP client for the order service. It is the
// checkout service's counterpart to the CartClient — the same pattern, but
// calling into order-service endpoints instead of cart-service ones.
//
// Currently only CreateOrder is implemented (the from-checkout handoff).
// Add GetOrder/ListOrders methods as the backoffice order UI evolves.
type OrderClient struct {
httpClient *http.Client
baseURL string // e.g. "http://order-service:8092"
agentProfile string // UCP-Agent profile URI
}
// NewOrderClient returns a client that talks to the order service at baseURL.
func NewOrderClient(baseURL string) *OrderClient {
profile := os.Getenv("UCP_AGENT_PROFILE")
if profile == "" {
profile = "https://cart.k6n.net/.well-known/ucp"
}
return &OrderClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: baseURL,
agentProfile: profile,
}
}
// CreateOrderReq is the JSON payload POSTed to the order service's
// /api/orders/from-checkout endpoint. Every field is required except where
// noted. See cmd/order/handlers_checkout.go for the full definition.
type CreateOrderReq struct {
CheckoutId string `json:"checkoutId"`
IdempotencyKey string `json:"idempotencyKey"`
CartId string `json:"cartId"`
Currency string `json:"currency"`
Locale string `json:"locale,omitempty"`
Country string `json:"country"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
Lines []OrderLine `json:"lines"`
Payment struct {
Provider string `json:"provider"`
Reference string `json:"reference"`
Amount int64 `json:"amount"`
} `json:"payment"`
}
// OrderLine is one orderable item in the CreateOrderReq, matching the
// lineReq type in the order service.
type OrderLine struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
DropShip bool `json:"drop_ship,omitempty"`
Location string `json:"location,omitempty"`
}
// CreateOrderResult is the success response from POST /api/orders/from-checkout.
type CreateOrderResult struct {
OrderId string `json:"orderId"`
Flow json.RawMessage `json:"flow,omitempty"`
Order json.RawMessage `json:"order,omitempty"`
// Existing is true when the response is a 409 Conflict — the idempotency
// key was already used and this is the previously-created order.
Existing bool `json:"existing,omitempty"`
}
// CreateOrder sends a settled checkout's data to the order service and returns
// the created (or previously-created, via idempotency) order. An idempotencyKey
// should be generated per checkout (e.g. "checkout-{checkoutId}") to guard
// against retries. flowName overrides the default flow; use "" for the default.
func (c *OrderClient) CreateOrder(ctx context.Context, req CreateOrderReq, flowName string) (*CreateOrderResult, error) {
url := c.baseURL + "/api/orders/from-checkout"
if flowName != "" {
url += "?flow=" + flowName
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("order client: marshal: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("order client: new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("UCP-Agent", `profile="`+c.agentProfile+`"`)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("order client: do: %w", err)
}
defer resp.Body.Close()
// Both 201 (created) and 409 (idempotent hit) are valid responses.
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
return nil, fmt.Errorf("order client: %s", resp.Status)
}
var result CreateOrderResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("order client: decode: %w", err)
}
result.Existing = resp.StatusCode == http.StatusConflict
return &result, nil
}
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"context"
"encoding/json"
"fmt"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
profile "git.k6n.net/mats/go-cart-actor/pkg/profile"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
profileMessages "git.k6n.net/mats/go-cart-actor/proto/profile"
"google.golang.org/protobuf/types/known/timestamppb"
)
// settledPayment carries the provider-agnostic facts about an already-settled
// payment. Each payment integration (Klarna push, Adyen CAPTURE notification,
// …) fills this in from its own callback and hands it to
// createOrderFromSettledCheckout, so the from-checkout request is identical
// regardless of which provider settled the payment.
type settledPayment struct {
Provider string // "klarna" | "adyen"
Reference string // processor / PSP reference
Amount int64 // minor units, as charged
Currency string
Country string
Locale string
}
// buildOrderLinesFromGrain converts the checkout grain's cart items and delivery
// selections into from-checkout order lines. Shared by every provider so the
// order payload is built the same way no matter who settled the payment.
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
hasFreeShipping := false
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Type == "free_shipping" && !ap.Pending {
hasFreeShipping = true
break
}
}
}
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
for _, it := range grain.CartState.Items {
if it == nil {
continue
}
// Carry the per-item store as the inventory commit location; empty when
// no store (commit then falls back to the order country / central stock).
location := ""
if it.StoreId != nil {
location = *it.StoreId
}
lines = append(lines, OrderLine{
Reference: it.Sku,
Sku: it.Sku,
Name: it.Meta.Name,
Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat.Int64(),
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
// (2500 = 25%), so the rate passes through unchanged.
TaxRate: int32(it.Tax),
DropShip: it.DropShip,
Location: location,
})
}
for _, d := range grain.Deliveries {
if d == nil {
continue
}
unitPrice := d.Price.IncVat.Int64()
if hasFreeShipping {
unitPrice = 0
}
if unitPrice <= 0 && !hasFreeShipping {
continue
}
lines = append(lines, OrderLine{
Reference: d.Provider,
Sku: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: unitPrice,
TaxRate: 2500, // 25% in basis points
})
}
// Reflected applied promotions as negative discount lines
if grain.CartState != nil {
for _, ap := range grain.CartState.AppliedPromotions {
if ap.Pending {
continue
}
discountVal := int64(0)
if ap.Discount != nil {
discountVal = ap.Discount.IncVat.Int64()
}
if discountVal <= 0 {
continue
}
lines = append(lines, OrderLine{
Reference: "promo-" + ap.PromotionId,
Sku: "promo-" + ap.PromotionId,
Name: "Promotion: " + ap.Name,
Quantity: 1,
UnitPrice: -discountVal,
TaxRate: 2500, // default standard tax rate (25%) in basis points
})
}
}
return lines
}
// createOrderFromSettledCheckout sends the from-checkout request for a settled
// checkout (any provider) and, on success, records the returned order id on the
// checkout grain via the OrderCreated mutation so the backoffice can link
// checkout → order.
//
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
// existing order rather than creating a duplicate.
//
// If the HTTP call fails or is not configured, it falls back to publishing the
// exact same payload to RabbitMQ via s.orderHandler.OrderCompleted(body).
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
if grain.CartState == nil {
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
}
var customerEmail, customerName string
if grain.ContactDetails != nil {
if grain.ContactDetails.Email != nil {
customerEmail = *grain.ContactDetails.Email
}
if grain.ContactDetails.Name != nil {
customerName = *grain.ContactDetails.Name
}
}
req := CreateOrderReq{
CheckoutId: grain.Id.String(),
IdempotencyKey: "checkout-" + grain.Id.String(),
CartId: grain.CartId.String(),
Currency: pay.Currency,
Locale: pay.Locale,
Country: pay.Country,
CustomerEmail: customerEmail,
CustomerName: customerName,
Lines: buildOrderLinesFromGrain(grain),
}
req.Payment.Provider = pay.Provider
req.Payment.Reference = pay.Reference
req.Payment.Amount = pay.Amount
var createErr error
if s.orderClient != nil {
result, err := s.orderClient.CreateOrder(ctx, req, "")
if err == nil {
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
OrderId: result.OrderId,
Status: "completed",
CreatedAt: timestamppb.Now(),
})
if grain.CartState != nil && grain.CartState.UserId != "" {
if pid, ok := profile.ParseProfileId(grain.CartState.UserId); ok {
_ = s.ApplyAnywhere(ctx, checkout.CheckoutId(pid), &profileMessages.LinkOrder{
OrderReference: result.OrderId,
CartId: uint64(grain.CartId),
Status: "completed",
})
}
}
return result, nil
}
createErr = err
logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String())
}
// Fallback to AMQP
if s.orderHandler != nil {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal order fallback: %w", err)
}
if pubErr := s.orderHandler.OrderCompleted(body); pubErr != nil {
if createErr != nil {
return nil, fmt.Errorf("AMQP fallback failed: %w (HTTP error: %v)", pubErr, createErr)
}
return nil, fmt.Errorf("AMQP fallback failed: %w", pubErr)
}
logger.InfoContext(ctx, "order request published to AMQP successfully as fallback", "checkoutId", grain.Id.String())
return &CreateOrderResult{
OrderId: "queued-" + grain.Id.String(),
}, nil
}
if createErr != nil {
return nil, createErr
}
return nil, fmt.Errorf("neither order client nor AMQP order handler configured")
}
+569
View File
@@ -0,0 +1,569 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strconv"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/platform/tax"
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/platform/inventory"
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_mutations_total",
Help: "The total number of mutations",
})
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
Name: "checkout_grain_lookups_total",
Help: "The total number of lookups",
})
)
type CheckoutPoolServer struct {
actor.GrainPool[checkout.CheckoutGrain]
pod_name string
klarnaClient *KlarnaClient
adyenClient *adyen.APIClient
cartClient *CartClient
orderClient *OrderClient
orderHandler *AmqpOrderHandler
reservationPolicy inventory.ReservationPolicy
taxProvider tax.Provider
}
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
srv := &CheckoutPoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
cartClient: cartClient,
adyenClient: adyenClient,
orderClient: orderClient,
orderHandler: orderHandler,
}
return srv
}
func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
return s.Apply(ctx, uint64(id), mutation...)
}
func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
grain, err := s.Get(r.Context(), uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *CheckoutPoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.SetDelivery
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
deliveryId := r.PathValue("id")
uintDeliveryId, err := strconv.ParseUint(deliveryId, 10, 64)
if err != nil {
return err
}
msg := &messages.RemoveDelivery{
Id: uint32(uintDeliveryId),
}
result, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.SetPickupPoint
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) InitializeCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.InitializeCheckout
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) InventoryReservedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.InventoryReserved
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) OrderCreatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.OrderCreated
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) ConfirmationViewedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.ConfirmationViewed
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
var msg messages.ContactDetailsUpdated
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
return err
}
result, err := s.ApplyLocal(r.Context(), id, &msg)
if err != nil {
return err
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
paymentId := r.PathValue("id")
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.CancelPayment{
PaymentId: paymentId,
CancelledAt: timestamppb.New(time.Now()),
})
if err != nil {
return err
}
if grain, gerr := s.Get(r.Context(), uint64(checkoutId)); gerr == nil && grain != nil {
s.releaseCartReservations(r.Context(), grain)
}
return s.WriteResult(w, result)
}
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
cartIdStr := r.PathValue("cartid")
if cartIdStr == "" {
http.Error(w, "cart id required", http.StatusBadRequest)
return
}
cartId, ok := cart.ParseCartId(cartIdStr)
if !ok {
http.Error(w, "invalid cart id", http.StatusBadRequest)
return
}
// Fetch cart state from cart service
cartGrain, err := s.cartClient.getCartGrain(r.Context(), cartId)
if err != nil {
logger.Error("failed to fetch cart", "error", err, "cartId", cartId)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Serialize cart state to Any
cartStateBytes, err := json.Marshal(cartGrain)
if err != nil {
logger.Error("failed to marshal cart state", "error", err)
http.Error(w, "failed to process cart state", http.StatusInternalServerError)
return
}
// The checkout grain is 1:1 with the cart it checks out (CheckoutId ==
// CartId). Keying it by the cart id — not a lingering checkoutid cookie —
// means a fresh cart always gets a fresh checkout, so the Klarna order
// reflects the cart shown at /kassa. Reusing the cookie made a new cart's
// checkout latch onto a previous cart's frozen snapshot (InitializeCheckout
// silently refuses to re-snapshot a grain that already has a CartState).
checkoutId := checkout.CheckoutId(cartId)
// Initialize checkout with cart state wrapped in Any
cartStateAny := &messages.InitializeCheckout{
OrderId: "",
CartId: uint64(cartId),
Version: uint32(cartGrain.Version),
CartState: &anypb.Any{
TypeUrl: "type.googleapis.com/cart.CartGrain",
Value: cartStateBytes,
},
}
result, err := s.ApplyLocal(r.Context(), checkoutId, cartStateAny)
if err != nil {
setCheckoutCookie(w, 0, r.TLS != nil)
logger.Error("failed to initialize checkout", "error", err)
http.Error(w, "failed to initialize checkout", http.StatusInternalServerError)
return
}
// Set checkout cookie
setCheckoutCookie(w, checkoutId, r.TLS != nil)
if err := s.WriteResult(w, &result.Result); err != nil {
logger.Error("failed to write result", "error", err)
}
}
func (s *CheckoutPoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *checkout.CheckoutGrain, orderId *string) (*CheckoutOrder, error) {
meta := GetCheckoutMetaFromRequest(r)
payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider)
if err != nil {
return nil, err
}
var payment *checkout.Payment
if orderId != nil {
payment, _ = grain.FindPayment(*orderId)
}
if payment != nil && payment.PaymentId != "" {
return s.klarnaClient.UpdateOrder(r.Context(), payment.PaymentId, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
}
}
func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[checkout.CheckoutGrain], error) {
method := "checkout"
return s.ApplyLocal(ctx, id, &messages.PaymentStarted{
PaymentId: klarnaOrder.ID,
Amount: int64(klarnaOrder.OrderAmount),
Currency: klarnaOrder.PurchaseCurrency,
Provider: "klarna",
Method: &method,
StartedAt: timestamppb.New(time.Now()),
})
}
var (
tracer = otel.Tracer(name)
hmacKey = os.Getenv("ADYEN_HMAC")
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
proxyCalls metric.Int64Counter
)
func init() {
var err error
proxyCalls, err = meter.Int64Counter("proxy.calls",
metric.WithDescription("Number of proxy calls"),
metric.WithUnit("{calls}"))
if err != nil {
panic(err)
}
}
func (s *CheckoutPoolServer) GetAnywhere(ctx context.Context, checkoutId cart.CartId) (*checkout.CheckoutGrain, error) {
id := uint64(checkoutId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
return host.Get(ctx, id)
}
return s.Get(ctx, id)
}
func (s *CheckoutPoolServer) ApplyAnywhere(ctx context.Context, checkoutId cart.CartId, msgs ...proto.Message) error {
id := uint64(checkoutId)
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
_, err := host.Apply(ctx, id, msgs...)
return err
}
_, err := s.Apply(ctx, id, msgs...)
return err
}
type StartPayment struct {
Provider string `json:"provider"`
Method string `json:"method,omitempty"`
}
func (s *CheckoutPoolServer) GetPaymentSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
paymentId := r.PathValue("id")
grain, err := s.Get(r.Context(), uint64(checkoutId))
if err != nil {
return err
}
payment, ok := grain.FindPayment(paymentId)
if !ok {
http.Error(w, "payment not found", http.StatusNotFound)
return nil
}
switch payment.Provider {
case "adyen":
payload := &SessionRequest{
SessionResult: "",
}
if r.Method != http.MethodGet {
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.GetResultOfPaymentSessionInput(paymentId).SessionResult(payload.SessionResult)
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
if err != nil {
return err
}
if res.Status != nil && *res.Status == "completed" {
_, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentCompleted{
PaymentId: paymentId,
Status: *res.Status,
ProcessorReference: res.Id,
CompletedAt: timestamppb.Now(),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
}
return s.WriteResult(w, res)
case "klarna":
current, err := s.klarnaClient.GetOrder(r.Context(), paymentId)
if err != nil {
return err
}
return s.WriteResult(w, current)
}
return errors.New("unsupported payment provider")
}
func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
grain, err := s.Get(r.Context(), uint64(checkoutId))
if err != nil {
return err
}
payload := &StartPayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return err
}
switch payload.Provider {
case "adyen":
meta := GetCheckoutMetaFromRequest(r)
sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider)
if err != nil {
logger.Error("unable to build adyen session", "error", err)
return err
}
service := s.adyenClient.Checkout()
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
session, _, err := service.PaymentsApi.Sessions(r.Context(), req)
if err != nil {
logger.Error("unable to create adyen session", "error", err)
return err
}
sessionBytes, err := json.Marshal(session)
if err != nil {
logger.Error("unable to marshal session", "error", err)
return err
}
// Apply PaymentStarted mutation
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
PaymentId: session.Id,
Amount: session.Amount.Value,
Currency: session.Amount.Currency,
Provider: "adyen",
SessionData: &anypb.Any{
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
Value: sessionBytes,
},
Method: &payload.Method,
StartedAt: timestamppb.New(time.Now()),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
return s.WriteResult(w, result)
case "klarna":
order, err := s.CreateOrUpdateCheckout(r, grain, nil)
if err != nil {
logger.Error("unable to create klarna session", "error", err)
return err
}
orderBytes, err := json.Marshal(order)
if err != nil {
logger.Error("unable to marshal order", "error", err)
return err
}
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
PaymentId: order.ID,
Amount: int64(order.OrderAmount),
Currency: order.PurchaseCurrency,
Provider: "klarna",
Method: &payload.Method,
SessionData: &anypb.Any{
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
Value: orderBytes,
},
StartedAt: timestamppb.New(time.Now()),
})
if err != nil {
logger.Error("unable to apply payment started mutation", "error", err)
return err
}
return s.WriteResult(w, result)
default:
http.Error(w, "unsupported payment provider", http.StatusBadRequest)
return nil
}
}
func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
attr := attribute.String("http.route", pattern)
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
span.SetName(pattern)
span.SetAttributes(attr)
labeler, _ := otelhttp.LabelerFromContext(r.Context())
labeler.Add(attr)
handlerFunc(w, r)
}))
}
//handleFunc("/payment/adyen/session", CookieCheckoutIdHandler(s.AdyenSessionHandler))
handleFunc("/payment/adyen/push", s.AdyenHookHandler)
handleFunc("/payment/adyen/return", s.AdyenReturnHandler)
//handleFunc("/payment/adyen/cancel", s.AdyenCancelHandler)
handleFunc("/payment/klarna/validate", s.KlarnaValidationHandler)
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
handleFunc("DELETE /api/checkout/delivery/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
handleFunc("POST /api/checkout/pickup-point", CookieCheckoutIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
}
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
if s.reservationPolicy == nil || grain.CartState == nil {
return
}
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
if err := s.reservationPolicy.Release(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
}
}
}
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/platform/inventory"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
func getOriginalHost(r *http.Request) string {
proxyHost := r.Header.Get("X-Forwarded-Host")
if proxyHost != "" {
return proxyHost
}
return r.Host
}
// getScheme resolves the public scheme for building absolute URLs (e.g. Klarna
// merchant_urls). Honors X-Forwarded-Proto set by the edge/dev proxy; defaults
// to https so production (TLS terminated at nginx/ingress, no header) is correct.
func getScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
return proto
}
if r.TLS != nil {
return "https"
}
return "https"
}
func getClientIp(r *http.Request) string {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
return ip
}
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
// getCountryFromCurrency is the reverse of getCurrency, used by server-to-server
// callbacks (e.g. the Adyen webhook) that carry the settled currency but not the
// shopper's host/country.
func getCountryFromCurrency(currency string) string {
if strings.EqualFold(currency, "NOK") {
return "no"
}
return "se"
}
func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
return ""
}
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
if a.reservationPolicy == nil {
return nil
}
for _, item := range grain.CartState.Items {
if item == nil || !shouldTrackInventory(item) {
continue
}
loc := inventory.LocationID("se")
if item.StoreId != nil && *item.StoreId != "" {
loc = inventory.LocationID(*item.StoreId)
}
avail, err := a.reservationPolicy.Available(ctx, inventory.SKU(item.Sku), loc)
if err != nil {
return fmt.Errorf("inventory check failed for SKU %s: %w", item.Sku, err)
}
if int64(item.Quantity) > avail {
return fmt.Errorf("insufficient inventory for SKU %s: have %d, need %d", item.Sku, avail, item.Quantity)
}
}
return nil
}
const checkoutCookieName = "checkoutid"
func setCheckoutCookie(w http.ResponseWriter, checkoutId checkout.CheckoutId, tls bool) {
if checkoutId == 0 {
http.SetCookie(w, &http.Cookie{
Name: checkoutCookieName,
Value: checkoutId.String(),
Secure: tls,
HttpOnly: true,
Path: "/",
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
} else {
http.SetCookie(w, &http.Cookie{
Name: checkoutCookieName,
Value: checkoutId.String(),
Secure: tls,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
}
}
func CookieCheckoutIdHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id checkout.CheckoutId
cookie, err := r.Cookie(checkoutCookieName)
if err != nil || cookie.Value == "" {
w.WriteHeader(http.StatusNotAcceptable)
return
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusNotAcceptable)
return
} else {
id = parsed
}
}
err = fn(w, r, id)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
return func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok {
ctx, span := tracer.Start(r.Context(), "proxy")
defer span.End()
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
hostAttr := attribute.String("other host", ownerHost.Name())
span.SetAttributes(hostAttr)
logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name())
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
grainLookups.Inc()
if err == nil && handled {
return nil
}
}
_, span := tracer.Start(r.Context(), "own")
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
defer span.End()
return fn(w, r, checkoutId)
}
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"io"
"net/http"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
)
// batchItem is one stock set in a batch request. LocationID is optional; an
// empty value defaults to the service's country location (the same location the
// catalog-feed listener writes to).
type batchItem struct {
SKU string `json:"sku"`
LocationID string `json:"locationId,omitempty"`
Quantity int64 `json:"quantity"`
}
// batchResult mirrors one input item with the location actually written and an
// optional per-item error (empty on success).
type batchResult struct {
SKU string `json:"sku"`
LocationID string `json:"locationId"`
Quantity int64 `json:"quantity"`
Error string `json:"error,omitempty"`
}
// batchInventoryHandler SETS stock to absolute values for many SKUs in one
// pipelined call — for re-seeding/import and for testing the inventory + reserve
// paths directly. Same write as the catalog-feed listener (UpdateInventory +
// level crossing), so a value set here is authoritative until the next
// catalog.item_changed for that SKU overwrites it (catalog is the source of
// truth). Body is a JSON array of {sku, locationId?, quantity}.
func (srv *Server) batchInventoryHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20)) // 16 MiB
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var items []batchItem
if err := json.Unmarshal(body, &items); err != nil {
http.Error(w, "body must be a JSON array of {sku, locationId?, quantity}: "+err.Error(), http.StatusBadRequest)
return
}
if len(items) == 0 {
http.Error(w, "empty batch", http.StatusBadRequest)
return
}
ctx := r.Context()
results := make([]batchResult, len(items))
pipe := srv.rdb.Pipeline()
for i, it := range items {
loc := it.LocationID
if loc == "" {
loc = country
}
results[i] = batchResult{SKU: it.SKU, LocationID: loc, Quantity: it.Quantity}
if it.SKU == "" {
results[i].Error = "sku is required"
continue
}
srv.inventoryService.UpdateInventory(ctx, pipe, inventory.SKU(it.SKU), inventory.LocationID(loc), it.Quantity)
}
hadError := false
if _, err := pipe.Exec(ctx); err != nil {
// Exec reports the first failing command; flag the whole batch rather than
// guess which items committed.
hadError = true
for i := range results {
if results[i].Error == "" {
results[i].Error = err.Error()
}
}
} else {
// Emit level crossings for the items that were actually written, so the
// finder/level listeners react the same way they do for catalog feeds.
for i := range results {
if results[i].Error == "" {
emitLevelIfChanged(ctx, srv.rdb, srv.conn, country, lowWatermark, results[i].SKU, results[i].LocationID, results[i].Quantity)
}
}
}
for i := range results {
if results[i].Error != "" {
hadError = true
break
}
}
status := http.StatusOK
if hadError {
status = http.StatusMultiStatus // 207
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results})
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"log"
"time"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
contract "git.k6n.net/mats/platform/order"
"git.k6n.net/mats/platform/rabbit"
"github.com/redis/go-redis/v9"
)
// commitOrder applies an order.created event to inventory. The sale already
// happened, so this is an unconditional permanent decrement (it may drive stock
// negative — visible oversell, reconciled downstream as backorder), NOT a
// check-and-reserve that could refuse. It then releases the cart's TTL hold and
// emits the resulting level crossing. Idempotent per order via a Redis marker,
// since the bus is at-least-once.
func commitOrder(ctx context.Context, rdb *redis.Client, svc *inventory.RedisInventoryService, resv *inventory.RedisCartReservationService, conn *rabbit.Conn, country string, low int64, c contract.Created) {
if c.OrderID == "" {
return
}
// Idempotency: first writer wins; redelivery is a no-op.
fresh, err := rdb.SetNX(ctx, "invcommit:"+c.OrderID, "1", 30*24*time.Hour).Result()
if err != nil {
log.Printf("inventory: commit idempotency order=%s: %v", c.OrderID, err)
return
}
if !fresh {
return // already committed
}
// Default location for lines that don't carry their own: the order country
// (central stock), falling back to the service's configured country.
defaultLoc := c.Country
if defaultLoc == "" {
defaultLoc = country
}
for _, line := range c.Lines {
if line.SKU == "" || line.Quantity <= 0 {
continue
}
// Drop-ship items are fulfilled by the supplier — never decrement
// local stock for them.
if line.DropShip {
continue
}
loc := inventory.LocationID(defaultLoc)
if line.Location != "" {
loc = inventory.LocationID(line.Location)
}
sku := inventory.SKU(line.SKU)
pipe := rdb.Pipeline()
if err := svc.DecrementInventory(ctx, pipe, sku, loc, int64(line.Quantity)); err != nil {
log.Printf("inventory: commit decrement sku=%s order=%s: %v", sku, c.OrderID, err)
continue
}
if _, err := pipe.Exec(ctx); err != nil {
log.Printf("inventory: commit exec sku=%s order=%s: %v", sku, c.OrderID, err)
continue
}
// Release the cart's TTL hold now the sale is committed (best-effort —
// it would expire on its own anyway).
if c.CartID != "" && resv != nil {
if err := resv.ReleaseForCart(ctx, sku, loc, inventory.CartID(c.CartID)); err != nil {
log.Printf("inventory: commit release sku=%s cart=%s: %v", sku, c.CartID, err)
}
}
// Emit the level crossing from the new on-hand.
if newQty, err := svc.GetInventory(ctx, sku, loc); err == nil {
emitLevelIfChanged(ctx, rdb, conn, country, low, string(sku), string(loc), newQty)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"log"
"strconv"
"time"
"git.k6n.net/mats/platform/event"
invlevel "git.k6n.net/mats/platform/inventory"
"git.k6n.net/mats/platform/rabbit"
"github.com/redis/go-redis/v9"
)
// emitLevelIfChanged classifies qty into a Level and emits an
// inventory.level_changed bus event only when it differs from the level last
// emitted for this SKU+location (the levelKey marker). Exact quantity never
// leaves the service; only the coarse crossing does. Shared by every write path
// (catalog feed, order.created commit). A nil conn (no bus) makes it a no-op.
func emitLevelIfChanged(ctx context.Context, rdb *redis.Client, conn *rabbit.Conn, country string, low int64, sku, loc string, qty int64) {
if conn == nil {
return
}
lvl := invlevel.LevelFor(qty, low)
// Atomic read-old + write-new of the level marker; redis.Nil means the
// marker didn't exist yet (first observation), which we treat as a crossing.
prev, err := rdb.SetArgs(ctx, levelKey(sku, loc), string(lvl), redis.SetArgs{Get: true}).Result()
if err != nil && err != redis.Nil {
log.Printf("inventory: level marker sku=%s: %v", sku, err)
return
}
if prev == string(lvl) {
return // no threshold crossing — stay off the bus
}
payload, err := invlevel.LevelChanged{SKU: sku, Location: loc, Level: lvl}.Encode()
if err != nil {
log.Printf("inventory: encode level payload sku=%s: %v", sku, err)
return
}
ev := event.Event{
ID: "invlvl-" + sku + "-" + loc + "-" + strconv.FormatInt(time.Now().UnixNano(), 36),
Type: event.InventoryLevelChanged,
Source: "inventory",
Subject: sku,
Payload: payload,
Time: time.Now(),
Meta: map[string]string{"country": country},
}
if err := rabbit.PublishEvent(conn.Connection(), "inventory", ev); err != nil {
log.Printf("inventory: publish level event sku=%s: %v", sku, err)
}
}
+268
View File
@@ -0,0 +1,268 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/event"
orderevent "git.k6n.net/mats/platform/order"
"git.k6n.net/mats/slask-finder/pkg/index"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
)
type Server struct {
inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
// rdb + conn back the batch write path (pipelined UpdateInventory + level
// crossing). conn is nil when RABBIT_HOST is unset; emit is then skipped.
rdb *redis.Client
conn *rabbit.Conn
}
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
sku := inventory.SKU(r.PathValue("sku"))
locationID := inventory.LocationID(r.PathValue("locationId"))
quantity, err := srv.inventoryService.GetInventory(r.Context(), sku, locationID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]int64{"quantity": quantity}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (srv *Server) getReservationHandler(w http.ResponseWriter, r *http.Request) {
sku := inventory.SKU(r.PathValue("sku"))
locationID := inventory.LocationID(r.PathValue("locationId"))
summary, err := srv.reservationService.GetReservationSummary(r.Context(), sku, locationID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summary)
}
var country = "se"
var redisAddress = "10.10.3.18:6379"
var redisPassword = "slaskredis"
// lowWatermark is the cutoff between "low" and "high" stock for the level
// crossings emitted on the bus (0 < qty <= lowWatermark ⇒ low). Single global
// number for now; per-SKU / per-tenant thresholds can come later behind the
// same inventory.LevelChanged payload.
var lowWatermark int64 = 5
func init() {
// Override redis config from environment variables if set
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
redisAddress = addr
}
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
redisPassword = password
}
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
country = ctry
}
if v, ok := os.LookupEnv("INVENTORY_LOW_WATERMARK"); ok {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
lowWatermark = n
}
}
}
// levelKey is the Redis key holding the last-emitted Level for a SKU+location.
// It is the crossing-detection marker: the level listener only emits when the
// freshly computed level differs from this stored value, so the bus carries
// crossings, not every quantity tick.
func levelKey(sku, loc string) string {
return "invlevel:" + sku + ":" + loc
}
func main() {
var ctx = context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword, // no password set
DB: 0, // use default DB
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
s, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Unable to connect to inventory redis: %v", err)
return
}
r, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Unable to connect to reservation redis: %v", err)
return
}
server := &Server{inventoryService: s, reservationService: r, rdb: rdb}
// Set up HTTP routes
http.HandleFunc("/livez", server.livezHandler)
http.HandleFunc("/readyz", server.readyzHandler)
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
http.HandleFunc("POST /reservations", server.reserveInventoryHandler)
http.HandleFunc("POST /reservations/release", server.releaseReservationHandler)
http.HandleFunc("POST /inventory/batch", server.batchInventoryHandler)
stockhandler := &StockHandler{
MainStockLocationID: inventory.LocationID(country),
rdb: rdb,
ctx: ctx,
svc: *s,
country: country,
low: lowWatermark,
}
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
if ok {
log.Printf("Connecting to rabbitmq")
conn, err := rabbit.Dial(amqpUrl, "cart-inventory")
if err != nil {
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
}
// The catalog-feed handler emits inventory.level_changed crossings
// directly on this connection as it writes stock.
stockhandler.conn = conn
// Share the bus connection with the batch handler so its writes emit
// inventory.level_changed crossings too.
server.conn = conn
// Reconnecting consumer: re-listen on reconnect.
conn.NotifyOnReconnect(func() {
ch, err := conn.Channel()
if err != nil {
log.Printf("inventory: channel on reconnect: %v", err)
return
}
// Producer side: declare the "inventory" topic exchange we publish
// inventory.level_changed crossings to. Durable + idempotent, so
// re-declaring on each reconnect is safe.
if err := ch.ExchangeDeclare("inventory", "topic", true, false, false, false, nil); err != nil {
log.Printf("inventory: declare inventory exchange: %v", err)
}
// 1) LEGACY raw `catalog.item_changed` wire — payload is a raw item
// JSON array. Consumed by the historical pricewatcher +
// writeradmin item-changed paths. Carries RawDataItem bodies
// with full finder-style metadata (including the stock
// field that the new projection wire does not).
//
// This listener is the one that mutates Redis stock state via
// StockHandler.HandleItem.
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogItemChanged), func(d amqp.Delivery) error {
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
log.Printf("inventory: decode catalog event: %v", err)
return nil
}
if ev.Meta["country"] != "" && ev.Meta["country"] != country {
return nil // not for our country
}
wg := &sync.WaitGroup{}
if err := index.ForEachRawDataItemInJSONArray(ev.Payload, func(item *index.RawDataItem) error {
stockhandler.HandleItem(item, wg)
return nil
}); err != nil {
log.Printf("inventory: apply items: %v", err)
}
wg.Wait()
log.Print("Batch done...")
return nil
}); err != nil {
log.Printf("inventory: listen on reconnect: %v", err)
}
// 2) (REMOVED) `catalog.projection_published` listener — moved to
// the cart service. Per docs/inventory-shape.md, inventory's
// bus role is emit-only (inventory.level_changed crossings back
// to finder on the inventory exchange). The catalog projection
// is read by the cart at checkout-time lookups via its own
// in-memory CatalogCache, not by inventory. Stock state stays
// sourced from the legacy raw wire (block 1 below) until
// inventory itself emits level_changed on a stock mutation.
// 3) order.created → commit stock (permanent decrement), release the
// cart hold, emit the level crossing. This is the async commit
// path: checkout no longer decrements synchronously; it announces
// a completed sale and inventory reacts. Idempotent per order.
if err := rabbit.ListenToPattern(ch, "order", string(event.OrderCreated), func(d amqp.Delivery) error {
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
log.Printf("inventory: decode order event: %v", err)
return nil
}
if target := countryForEvent(&ev); target != "" && target != country {
return nil // not for our country/tenant
}
created, err := orderevent.Decode(ev.Payload)
if err != nil {
log.Printf("inventory: decode order.created: %v", err)
return nil
}
commitOrder(ctx, rdb, s, r, conn, country, lowWatermark, created)
return nil
}); err != nil {
log.Printf("inventory: listen order.created on reconnect: %v", err)
}
})
}
// Start HTTP server
log.Println("Starting HTTP server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
// countryForEvent extracts the best-available country/tenant identifier
// from an envelope. Lookup order: Meta["country"], Meta["tenant"], then
// the Source prefix "catalog:<x>". An empty string means "no decoration" —
// callers treat that as "for everyone" (same as the legacy listener).
func countryForEvent(ev *event.Event) string {
if t := strings.TrimSpace(ev.Meta["country"]); t != "" {
return t
}
if t := strings.TrimSpace(ev.Meta["tenant"]); t != "" {
return t
}
if strings.HasPrefix(ev.Source, "catalog:") {
if t := strings.TrimSpace(strings.TrimPrefix(ev.Source, "catalog:")); t != "" {
return t
}
}
return ""
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"time"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
)
type reservationLineReq struct {
SKU string `json:"sku"`
LocationID string `json:"locationId"`
Quantity uint32 `json:"quantity"`
}
type reservationBatchReq struct {
HolderID string `json:"holderId"`
TTLSeconds int64 `json:"ttlSeconds,omitempty"`
Lines []reservationLineReq `json:"lines"`
}
func (srv *Server) reserveInventoryHandler(w http.ResponseWriter, r *http.Request) {
var req reservationBatchReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.HolderID == "" {
http.Error(w, "missing holderId", http.StatusBadRequest)
return
}
if len(req.Lines) == 0 {
http.Error(w, "missing lines", http.StatusBadRequest)
return
}
ttl := 15 * time.Minute
if req.TTLSeconds > 0 {
ttl = time.Duration(req.TTLSeconds) * time.Second
}
for _, line := range req.Lines {
if line.SKU == "" || line.LocationID == "" || line.Quantity == 0 {
http.Error(w, "each line must include sku, locationId, and quantity", http.StatusBadRequest)
return
}
if err := srv.reservationService.ReserveForCart(r.Context(), inventory.CartReserveRequest{
InventoryReference: &inventory.InventoryReference{
SKU: inventory.SKU(line.SKU),
LocationID: inventory.LocationID(line.LocationID),
},
CartID: inventory.CartID(req.HolderID),
Quantity: line.Quantity,
TTL: ttl,
}); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, inventory.ErrInsufficientInventory) {
status = http.StatusConflict
}
http.Error(w, err.Error(), status)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "reserved", "holderId": req.HolderID})
}
func (srv *Server) releaseReservationHandler(w http.ResponseWriter, r *http.Request) {
var req reservationBatchReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.HolderID == "" {
http.Error(w, "missing holderId", http.StatusBadRequest)
return
}
if len(req.Lines) == 0 {
http.Error(w, "missing lines", http.StatusBadRequest)
return
}
for _, line := range req.Lines {
if line.SKU == "" || line.LocationID == "" {
http.Error(w, "each line must include sku and locationId", http.StatusBadRequest)
return
}
if err := srv.reservationService.ReleaseForCart(r.Context(),
inventory.SKU(line.SKU),
inventory.LocationID(line.LocationID),
inventory.CartID(req.HolderID),
); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": "released", "holderId": req.HolderID})
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"context"
"log"
"sync"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/slask-finder/pkg/types"
"github.com/redis/go-redis/v9"
)
type StockHandler struct {
rdb *redis.Client
ctx context.Context
svc inventory.RedisInventoryService
MainStockLocationID inventory.LocationID
// Bus producer config. conn is nil when RABBIT_HOST is unset (no bus); the
// handler still updates Redis and just skips emitting.
conn *rabbit.Conn
country string
low int64
}
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
wg.Go(func() {
ctx := s.ctx
centralStock, ok := item.GetNumberFieldValue("inStock")
if !ok {
centralStock = 0
}
sku, ok := item.GetStringFieldValue("sku")
if !ok {
log.Printf("unable to parse central stock for item: sku missing")
return
}
// One linear pass: write the authoritative quantity, then derive and
// emit the level crossing from the same value. No Redis pub/sub
// round-trip — Redis stays the internal store, the bus carries levels.
pipe := s.rdb.Pipeline()
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
if _, err := pipe.Exec(ctx); err != nil {
log.Printf("unable to update stock: %v", err)
return
}
emitLevelIfChanged(ctx, s.rdb, s.conn, s.country, s.low, sku, string(s.MainStockLocationID), int64(centralStock))
})
}
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"context"
"encoding/json"
"sync"
"time"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
)
// rabbitPublisher is the broker-delivery side used by the outbox relay. It dials
// lazily and reconnects on failure, so the relay can be started even when the
// broker is down at boot: messages accumulate durably in the outbox and drain
// once the broker is reachable.
type rabbitPublisher struct {
url string
mu sync.Mutex
conn *amqp.Connection
ch *amqp.Channel
}
func newRabbitPublisher(url string) *rabbitPublisher {
return &rabbitPublisher{url: url}
}
func (p *rabbitPublisher) connect() error {
conn, err := rabbit.Connect(p.url, "cart-order-publisher")
if err != nil {
return err
}
ch, err := conn.Channel()
if err != nil {
_ = conn.Close()
return err
}
p.conn, p.ch = conn, ch
return nil
}
func (p *rabbitPublisher) reset() {
if p.ch != nil {
_ = p.ch.Close()
}
if p.conn != nil {
_ = p.conn.Close()
}
p.ch, p.conn = nil, nil
}
func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.ch == nil {
if err := p.connect(); err != nil {
return err
}
}
// Bound the publish so a half-open/black-holed connection can't wedge the
// outbox relay indefinitely; on timeout the error path resets and the relay
// retries on its next tick.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := p.ch.PublishWithContext(ctx, exchange, routingKey, false, false,
amqp.Publishing{ContentType: "application/json", Body: body})
if err != nil {
p.reset() // force a reconnect on the next attempt
}
return err
}
// ingestOrderFromQueue records one order from the queue. It uses the exact same
// idempotent business logic as the HTTP endpoint.
func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error {
var req fromCheckoutReq
if err := json.Unmarshal(body, &req); err != nil {
return err
}
if len(req.Lines) == 0 {
return nil // nothing actionable
}
_, _, _, _, runErr, err := s.createOrderFromCheckoutInternal(ctx, &req)
if err != nil {
return err
}
if runErr != nil {
return runErr
}
return nil
}
// startOrderIngest connects to AMQP and consumes "order-queue" until ctx is
// done. The connection is reconnecting — on broker blips the caller re-declares
// the queue and re-consumes automatically.
func startOrderIngest(ctx context.Context, amqpURL string, s *server) {
conn, err := rabbit.Dial(amqpURL, "cart-order-ingest")
if err != nil {
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
return
}
// Reconnecting consumer: re-declare queue and re-consume on reconnect.
conn.NotifyOnReconnect(func() {
ch, err := conn.Channel()
if err != nil {
s.logger.Warn("order ingest: channel on reconnect", "err", err)
return
}
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest: queue declare on reconnect", "err", err)
_ = ch.Close()
return
}
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
if err != nil {
s.logger.Warn("order ingest: consume on reconnect", "err", err)
_ = ch.Close()
return
}
s.logger.Info("order ingest: consuming from order-queue (reconnect)")
go func() {
defer ch.Close()
for {
select {
case <-ctx.Done():
return
case d, ok := <-msgs:
if !ok {
s.logger.Warn("order ingest: channel closed (will reconnect)")
return
}
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
s.logger.Error("order queue ingest failed", "err", err)
}
}
}
}()
})
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func testServer(t *testing.T) *server {
t.Helper()
reg := actor.NewMutationRegistry()
order.RegisterMutations(reg)
storage := actor.NewDiskStorage[order.OrderGrain](t.TempDir(), reg)
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
Hostname: "test",
Spawn: func(_ context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
return order.NewOrderGrain(id, time.Now()), nil
},
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) { return nil, fmt.Errorf("none") },
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
TTL: time.Hour,
PoolSize: 100,
MutationRegistry: reg,
Storage: nil,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
applier := &orderedApplier{pool: pool, storage: storage}
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
}
idem, err := idempotency.Open(filepath.Join(t.TempDir(), "idem.log"))
if err != nil {
t.Fatal(err)
}
return &server{
pool: pool, applier: applier, storage: storage, reg: reg,
engine: engine, freg: freg,
flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}},
provider: provider, taxProvider: order.NewStaticTaxProvider(),
defaultFlow: def.Name, idem: idem, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
func TestIngestOrderFromQueue(t *testing.T) {
s := testServer(t)
ctx := context.Background()
body := []byte(`{
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`)
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err)
}
// Retrieve the created order grain. The new order ID is recorded in idempotency.Store
id, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
g, err := s.applier.Get(ctx, id)
if err != nil {
t.Fatal(err)
}
if g.Status != order.StatusCaptured {
t.Fatalf("status = %q, want captured", g.Status)
}
if g.CapturedAmount != 15000 {
t.Fatalf("captured = %d, want 15000", g.CapturedAmount)
}
// Redelivery must be idempotent — no double capture, no new payment.
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("re-ingest: %v", err)
}
g2, _ := s.applier.Get(ctx, id)
if g2.CapturedAmount != 15000 {
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
}
if len(g2.Payments) != 1 {
t.Fatalf("redelivery created %d payments, want 1", len(g2.Payments))
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
type projectFulfillmentHookParams struct {
Integration string `json:"integration,omitempty"`
Target string `json:"target,omitempty"`
Dropship bool `json:"dropship,omitempty"`
}
// registerProjectFlowHooks is the project-layer seam for customer-specific flow
// integrations. These belong here rather than pkg/order so core order logic
// stays generic while deployments can register their own fulfillment tools.
func registerProjectFlowHooks(reg *flow.Registry, app *orderedApplier, logger *slog.Logger) {
if logger == nil {
logger = slog.Default()
}
reg.HookWithMeta("project_fulfillment_integration", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
var p projectFulfillmentHookParams
if len(params) > 0 {
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("project_fulfillment_integration: bad params: %w", err)
}
}
if p.Integration == "" {
p.Integration = "order-integration"
}
o, err := app.Get(ctx, st.ID)
if err != nil {
return err
}
if len(o.Fulfillments) == 0 {
return fmt.Errorf("project_fulfillment_integration: order %s has no fulfillment yet", order.OrderId(st.ID).String())
}
last := o.Fulfillments[len(o.Fulfillments)-1]
log := st.Logger
if log == nil {
log = logger
}
log.Info("project fulfillment integration",
"orderId", order.OrderId(st.ID).String(),
"orderReference", o.OrderReference,
"customerEmail", o.CustomerEmail,
"integration", p.Integration,
"target", p.Target,
"dropship", p.Dropship,
"step", info.Step,
"phase", info.Phase,
"fulfillmentId", last.ID,
"carrier", last.Carrier,
"trackingNumber", last.TrackingNumber,
)
return nil
}, flow.CapabilityMeta{
Description: "Project-specific fulfillment integration seam for local or testing adapters.",
ExampleParams: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
})
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
)
func TestProjectFulfillmentIntegrationHookIsRegistered(t *testing.T) {
s := testServer(t)
if !strings.Contains(strings.Join(s.freg.Capabilities().Hooks, ","), "project_fulfillment_integration") {
t.Fatalf("hooks = %v, want project_fulfillment_integration", s.freg.Capabilities().Hooks)
}
}
func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
s := testServer(t)
var logs bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logs, nil))
provider := order.NewPassthroughProvider("legacy", "ref", 25000)
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, logger)
engine := flow.NewEngine(freg, logger)
st := flow.NewState(801, logger)
po := orderTestPlaceMsg()
po.CustomerEmail = "dropship@example.com"
st.Vars[order.PlaceOrderVar] = po
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
t.Fatal(err)
}
if _, err := engine.Run(context.Background(), def, st); err != nil {
t.Fatal(err)
}
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
Name: "fulfill",
Action: "create_fulfillment",
Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`),
Hooks: flow.Hooks{After: []flow.HookRef{{
Type: "project_fulfillment_integration",
Params: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
}}},
}}}
if _, err := engine.Run(context.Background(), ship, flow.NewState(801, logger)); err != nil {
t.Fatal(err)
}
got := logs.String()
for _, want := range []string{
`"msg":"project fulfillment integration"`,
`"integration":"dropship-test"`,
`"target":"erp-sandbox"`,
`"dropship":true`,
`"trackingNumber":"DS-123"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("log output missing %s: %s", want, got)
}
}
}
func orderTestPlaceMsg() *messages.PlaceOrder {
return &messages.PlaceOrder{
OrderReference: "ref-1",
CartId: "cart-1",
Currency: "SEK",
Country: "SE",
CustomerName: "Dropship Customer",
TotalAmount: 25000,
TotalTax: 5000,
PlacedAtMs: 1718877600000,
Lines: []*messages.OrderLine{
{Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000},
{Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000},
},
}
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"log/slog"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func buildFlowRegistry(
applier *orderedApplier,
provider order.PaymentProvider,
inventoryReservations order.InventoryReservationService,
emitPub order.Publisher,
emailSender order.EmailSender,
emailTemplates order.EmailTemplateStore,
logger *slog.Logger,
) *flow.Registry {
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
order.RegisterEmitHook(freg, emitPub)
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates)
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
registerProjectFlowHooks(freg, applier, logger)
return freg
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
)
// flowStore holds the saga/flow definitions the service can run. Definitions
// are JSON files in a directory (editable by the backoffice saga editor),
// overlaid on a set of built-in seeds so a fresh deployment always has the
// defaults. Saving validates against the engine before writing.
type flowStore struct {
mu sync.RWMutex
dir string
engine *flow.Engine
defs map[string]*flow.Definition
}
var validFlowName = func(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
if !(r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
// newFlowStore seeds with the built-in definitions, then overlays any JSON files
// found in dir (on-disk wins, so edits persist and take effect).
func newFlowStore(dir string, engine *flow.Engine, seed map[string]*flow.Definition) (*flowStore, error) {
s := &flowStore{dir: dir, engine: engine, defs: map[string]*flow.Definition{}}
for name, def := range seed {
s.defs[name] = def
}
matches, _ := filepath.Glob(filepath.Join(dir, "*.json"))
for _, m := range matches {
data, err := os.ReadFile(m)
if err != nil {
return nil, fmt.Errorf("read flow %s: %w", m, err)
}
def, err := flow.Parse(data)
if err != nil {
return nil, fmt.Errorf("parse flow %s: %w", m, err)
}
if def.Name == "" {
def.Name = strings.TrimSuffix(filepath.Base(m), ".json")
}
s.defs[def.Name] = def
}
return s, nil
}
func (s *flowStore) get(name string) (*flow.Definition, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
d, ok := s.defs[name]
return d, ok
}
func (s *flowStore) list() []*flow.Definition {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.defs))
for n := range s.defs {
names = append(names, n)
}
sort.Strings(names)
out := make([]*flow.Definition, 0, len(names))
for _, n := range names {
out = append(out, s.defs[n])
}
return out
}
// save validates the definition, persists it to <dir>/<name>.json and updates
// the in-memory map so the next checkout uses it.
func (s *flowStore) save(name string, def *flow.Definition) error {
if !validFlowName(name) {
return fmt.Errorf("invalid flow name %q", name)
}
if def.Name == "" {
def.Name = name
}
if err := s.engine.Validate(def); err != nil {
return err
}
data, err := json.MarshalIndent(def, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(s.dir, name+".json"), append(data, '\n'), 0o644); err != nil {
return err
}
s.mu.Lock()
s.defs[name] = def
s.mu.Unlock()
return nil
}
+443
View File
@@ -0,0 +1,443 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"google.golang.org/protobuf/proto"
)
func nowMs() int64 { return time.Now().UnixMilli() }
// applyOne applies a single mutation and surfaces the handler error (the pool
// returns a top-level error only for unregistered mutations; a rejected
// transition is carried per-mutation in the result).
func applyOne(ctx context.Context, app *orderedApplier, id uint64, msg proto.Message) error {
res, err := app.Apply(ctx, id, msg)
if err != nil {
return err
}
if res != nil {
for _, m := range res.Mutations {
if m.Error != nil {
return m.Error
}
}
}
return nil
}
// capturedRef returns the capture reference of the first captured payment, which
// the payment provider needs to issue a refund against.
func capturedRef(g *order.OrderGrain) string {
for _, p := range g.Payments {
if p.Captured > 0 && p.CaptureRef != "" {
return p.CaptureRef
}
}
return ""
}
// loadOrder parses the id and returns the current grain, writing 400/404 itself
// and returning ok=false when the caller should stop.
func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderId, *order.OrderGrain, bool) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return 0, nil, false
}
g, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return 0, nil, false
}
if g.Status == order.StatusNew {
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
return 0, nil, false
}
return id, g, true
}
// checkIdempotency checks if the request is an idempotent retry.
// It returns (idemKey, exists, unlockFn).
// If exists is true, the caller should write the current order grain status and return immediately.
// If exists is false, the caller must defer unlockFn() and continue.
func (s *server) checkIdempotency(w http.ResponseWriter, r *http.Request, id order.OrderId) (string, bool, func()) {
idemKey := r.Header.Get("Idempotency-Key")
if idemKey == "" {
return "", false, func() {}
}
unlock := s.idem.Lock(idemKey)
if _, ok := s.idem.Get(idemKey); ok {
unlock() // Release lock immediately since we are returning cached response
s.logger.Info("idempotency hit for lifecycle endpoint", "key", idemKey, "orderId", id.String())
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return idemKey, true, nil
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
return idemKey, true, nil
}
return idemKey, false, unlock
}
// applyAndRespond applies a mutation and returns the updated order. A rejected
// state transition (handler error) maps to 409 Conflict.
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message, idemKey string) {
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
for _, m := range res.Mutations {
if m.Error != nil {
writeErr(w, http.StatusConflict, m.Error)
return
}
}
if idemKey != "" {
if err := s.idem.Put(idemKey, uint64(id)); err != nil {
s.logger.Error("idempotency record failed", "key", idemKey, "orderId", id.String(), "err", err)
}
}
grain, err := s.applier.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
}
// --- lifecycle (b) --------------------------------------------------------
type fulfillReq struct {
Carrier string `json:"carrier,omitempty"`
TrackingNumber string `json:"trackingNumber,omitempty"`
TrackingURI string `json:"trackingUri,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
id, g, ok := s.loadOrder(w, r)
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req fulfillReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
fid, _ := order.NewOrderId()
carrier := req.Carrier
trackingNumber := req.TrackingNumber
trackingURI := req.TrackingURI
// Integration with go-shipping:
if carrier == "" && g.CartId != "" {
shippingURL := os.Getenv("SHIPPING_URL")
if shippingURL == "" {
shippingURL = "http://localhost:8080"
}
// Query go-shipping for cached options
url := fmt.Sprintf("%s/api/shipping-options/%s", shippingURL, g.CartId)
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err == nil {
resp, err := http.DefaultClient.Do(httpReq)
if err == nil && resp.StatusCode == http.StatusOK {
var groups []struct {
Type string `json:"type"`
DefaultOption *struct {
BookingInstructions struct {
DeliveryOptionID string `json:"deliveryOptionId"`
ServiceCode string `json:"serviceCode"`
} `json:"bookingInstructions"`
DescriptiveTexts struct {
Checkout struct {
Title string `json:"title"`
} `json:"checkout"`
} `json:"descriptiveTexts"`
} `json:"defaultOption"`
}
if json.NewDecoder(resp.Body).Decode(&groups) == nil && len(groups) > 0 {
g0 := groups[0]
carrier = g0.Type
if g0.DefaultOption != nil {
opt := g0.DefaultOption
if opt.DescriptiveTexts.Checkout.Title != "" {
carrier = opt.DescriptiveTexts.Checkout.Title
}
trackingNumber = "SE-" + opt.BookingInstructions.DeliveryOptionID
trackingURI = "https://www.postnord.se/en/our-tools/track-and-trace?shipmentId=" + trackingNumber
}
}
resp.Body.Close()
}
}
}
if carrier == "" {
carrier = "postnord"
}
if trackingNumber == "" {
trackingNumber = "mock-track-" + fid.String()
}
if trackingURI == "" {
trackingURI = "https://tracking.postnord.com/?id=" + trackingNumber
}
msg := &messages.CreateFulfillment{
Id: "f_" + fid.String(),
Carrier: carrier,
TrackingNumber: trackingNumber,
TrackingUri: trackingURI,
AtMs: nowMs(),
}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg, idemKey)
}
type exchangeReq struct {
Reason string `json:"reason,omitempty"`
ReturnLines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"returnLines"`
NewLines []struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
} `json:"newLines"`
}
func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
exId, _ := order.NewOrderId()
retId, _ := order.NewOrderId()
msg := &messages.RequestExchange{
Id: "ex_" + exId.String(),
ReturnId: "r_" + retId.String(),
Reason: req.Reason,
AtMs: nowMs(),
}
for _, l := range req.ReturnLines {
msg.ReturnLines = append(msg.ReturnLines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
for _, l := range req.NewLines {
msg.NewLines = append(msg.NewLines, &messages.OrderLine{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: l.TotalAmount,
TotalTax: l.TotalTax,
})
}
s.applyAndRespond(w, r, id, msg, idemKey)
}
type editDetailsReq struct {
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
ShippingPrice int64 `json:"shippingPrice,omitempty"`
}
func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req editDetailsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
msg := &messages.EditOrderDetails{
ShippingAddress: req.ShippingAddress,
BillingAddress: req.BillingAddress,
ShippingPrice: req.ShippingPrice,
AtMs: nowMs(),
}
s.applyAndRespond(w, r, id, msg, idemKey)
}
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}, idemKey)
}
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()}, idemKey)
}
type returnReq struct {
Reason string `json:"reason,omitempty"`
Lines []struct {
Reference string `json:"reference"`
Quantity int32 `json:"quantity"`
} `json:"lines"`
}
func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
id, _, ok := s.loadOrder(w, r)
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req returnReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
rid, _ := order.NewOrderId()
msg := &messages.RequestReturn{Id: "r_" + rid.String(), Reason: req.Reason, AtMs: nowMs()}
for _, l := range req.Lines {
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
}
s.applyAndRespond(w, r, id, msg, idemKey)
}
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
id, g, ok := s.loadOrder(w, r)
if !ok {
return
}
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
if exists {
return
}
defer unlock()
var req struct {
Amount int64 `json:"amount"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
amount := req.Amount
if amount <= 0 {
amount = (g.CapturedAmount - g.RefundedAmount).Int64() // default: full remaining
}
captureRef := capturedRef(g)
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Errorf("refund failed: %w", err))
return
}
s.applyAndRespond(w, r, id, &messages.IssueRefund{
Provider: ref.Provider,
Amount: ref.Amount,
Reference: ref.Reference,
AtMs: nowMs(),
}, idemKey)
}
// --- saga / flow admin (a) ------------------------------------------------
func (s *server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.freg.Capabilities())
}
func (s *server) handleListFlows(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.flows.list())
}
func (s *server) handleGetFlow(w http.ResponseWriter, r *http.Request) {
def, ok := s.flows.get(r.PathValue("name"))
if !ok {
writeErr(w, http.StatusNotFound, fmt.Errorf("flow not found"))
return
}
writeJSON(w, http.StatusOK, def)
}
func (s *server) handleSaveFlow(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
var def flow.Definition
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := s.flows.save(name, &def); err != nil {
// Validation failures (unknown action/hook/predicate) are client errors.
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, &def)
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/money"
)
// fromCheckoutReq is the payload the checkout service sends to create an order
// from a settled (paid) checkout. All payment processing has already happened
// via Klarna/Adyen on the checkout grain; the order service records the result
// in its event-sourced grain.
type fromCheckoutReq struct {
CheckoutId string `json:"checkoutId"`
IdempotencyKey string `json:"idempotencyKey"`
Flow string `json:"flow,omitempty"`
CartId string `json:"cartId"`
Currency string `json:"currency"`
Locale string `json:"locale,omitempty"`
Country string `json:"country"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
Lines []lineReq `json:"lines"`
// Payment carries the externally-settled payment result. The provider has
// already authorized and captured; the order grain records these facts.
Payment struct {
Provider string `json:"provider"` // "klarna" or "adyen"
Reference string `json:"reference"` // processor reference (PSP reference)
Amount int64 `json:"amount"` // minor units
} `json:"payment"`
}
// handleFromCheckout creates an event-sourced order from a settled checkout.
// It runs the place-and-pay flow with a passthrough provider that records the
// already-completed authorization and capture, bypassing any external payment
// call. The response carries the order grain, flow result, and order ID.
//
// Idempotent: a retry with the same idempotencyKey returns the existing order
// (409 Conflict) rather than creating a duplicate.
func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
var req fromCheckoutReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
return
}
if len(req.Lines) == 0 {
writeErr(w, http.StatusBadRequest, fmt.Errorf("from-checkout: at least one line required"))
return
}
ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if exists {
writeJSON(w, http.StatusConflict, map[string]any{
"orderId": order.OrderId(ordID).String(),
"order": grain,
"existing": true,
})
return
}
status := http.StatusCreated
if runErr != nil {
status = http.StatusPaymentRequired
}
writeJSON(w, status, map[string]any{
"orderId": order.OrderId(ordID).String(),
"flow": flowRes,
"order": grain,
})
}
// createOrderFromCheckoutInternal runs the core idempotent checkout-to-order logic.
// It returns (orderID, flowResult, grain, exists, runErr, err).
// - exists is true if the order already existed (idempotency hit).
// - runErr is the flow run error (payment flow failed, e.g. compensating was run).
// - err is a structural/storage error.
func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromCheckoutReq) (uint64, *flow.Result, *order.OrderGrain, bool, error, error) {
if req.IdempotencyKey != "" {
unlock := s.idem.Lock(req.IdempotencyKey)
defer unlock()
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
g, err := s.applier.Get(ctx, existingID)
if err != nil {
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
}
return existingID, nil, g, true, nil, nil
}
}
id, err := order.NewOrderId()
if err != nil {
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
}
ordID := uint64(id)
po := buildFromCheckoutPlaceOrder(id, req)
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
flowName := req.Flow
if flowName == "" {
flowName = s.defaultFlow
}
def, ok := s.flows.get(flowName)
if !ok {
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
}
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, s.logger)
engine := flow.NewEngine(freg, s.logger)
st := flow.NewState(ordID, s.logger)
st.Vars[order.PlaceOrderVar] = po
res, runErr := engine.Run(ctx, def, st)
grain, getErr := s.applier.Get(ctx, ordID)
if getErr != nil {
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
}
if runErr != nil {
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
}
if req.IdempotencyKey != "" && runErr == nil {
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
}
}
return ordID, res, grain, false, runErr, nil
}
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
// proto message, computing per-line and order totals from the lineReq entries.
func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messages.PlaceOrder {
orderRef := "ord-" + id.String()
po := &messages.PlaceOrder{
OrderReference: orderRef,
CartId: req.CartId,
Currency: req.Currency,
Locale: req.Locale,
Country: req.Country,
CustomerEmail: req.CustomerEmail,
CustomerName: req.CustomerName,
PlacedAtMs: time.Now().UnixMilli(),
}
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
lineTax := order.ComputeTax(money.Cents(lineTotal), int(l.TaxRate)).Int64()
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
Location: l.Location,
DropShip: l.DropShip,
})
}
po.TotalAmount = total
po.TotalTax = totalTax
return po
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
func TestLifecycleIdempotencyRefund(t *testing.T) {
s := testServer(t)
s.provider = order.NewMockProvider()
ctx := context.Background()
// Ingest an order to get it into Captured status
body := []byte(`{
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`)
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err)
}
orderID, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
// ── First refund request (with idempotency key) ────────────────────────
reqBody := []byte(`{"amount": 5000}`)
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req1.Header.Set("Idempotency-Key", "refund-key-123")
req1.SetPathValue("id", order.OrderId(orderID).String())
w1 := httptest.NewRecorder()
s.handleRefund(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first refund status = %d, want 200", w1.Code)
}
g1, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
if g1.RefundedAmount != 5000 {
t.Fatalf("refunded amount after first call = %d, want 5000", g1.RefundedAmount)
}
// ── Second refund request (with the same idempotency key) ──────────────
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req2.Header.Set("Idempotency-Key", "refund-key-123")
req2.SetPathValue("id", order.OrderId(orderID).String())
w2 := httptest.NewRecorder()
s.handleRefund(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second refund status = %d, want 200", w2.Code)
}
g2, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
// It should NOT have refunded another 5000 (total refunded remains 5000)
if g2.RefundedAmount != 5000 {
t.Fatalf("refunded amount after second call = %d, want 5000 (idempotency hit failed, did double refund)", g2.RefundedAmount)
}
}
func TestLifecycleWithoutIdempotencyRefund(t *testing.T) {
s := testServer(t)
s.provider = order.NewMockProvider()
ctx := context.Background()
// Ingest an order to get it into Captured status
body := []byte(`{
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
}`)
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
t.Fatalf("ingest: %v", err)
}
orderID, ok := s.idem.Get("checkout-K-1")
if !ok {
t.Fatalf("expected order ID to be stored in idempotency cache")
}
// First refund request (no idempotency key)
reqBody := []byte(`{"amount": 5000}`)
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req1.SetPathValue("id", order.OrderId(orderID).String())
w1 := httptest.NewRecorder()
s.handleRefund(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first refund status = %d, want 200", w1.Code)
}
// Second refund request (no idempotency key)
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
req2.SetPathValue("id", order.OrderId(orderID).String())
w2 := httptest.NewRecorder()
s.handleRefund(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second refund status = %d, want 200", w2.Code)
}
g2, err := s.applier.Get(ctx, orderID)
if err != nil {
t.Fatal(err)
}
// Without idempotency, it SHOULD have refunded twice (total 10000)
if g2.RefundedAmount != 10000 {
t.Fatalf("refunded amount after second call without idempotency = %d, want 10000", g2.RefundedAmount)
}
}
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
)
func UseDiscovery(pool discovery.DiscoveryTarget) {
discovery.StartK8sDiscovery(podIp, "actor-pool=order", 30, pool)
}
+616
View File
@@ -0,0 +1,616 @@
// Command order is a single-node HTTP entrypoint for the order grain. It exposes
// a simple checkout that creates an order by running the place-and-pay flow
// (place -> mock authorize -> capture) on the actor framework, plus read APIs
// for an order and its event timeline. No clustering, no external payment — the
// simplest path to a real, event-sourced, captured order.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"net/mail"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
"git.k6n.net/mats/go-cart-actor/pkg/order"
ordermcp "git.k6n.net/mats/go-cart-actor/pkg/order/mcp"
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"git.k6n.net/mats/platform/tax"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
type server struct {
pool *actor.SimpleGrainPool[order.OrderGrain]
applier *orderedApplier
storage *actor.DiskStorage[order.OrderGrain]
reg actor.MutationRegistry
engine *flow.Engine
freg *flow.Registry
flows *flowStore
provider order.PaymentProvider
taxProvider tax.Provider
defaultFlow string
dataDir string
idem *idempotency.Store
logger *slog.Logger
inventoryReservations order.InventoryReservationService
}
func main() {
addr := config.EnvString("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
dataDir := config.EnvString("ORDER_DATA", "data/orders")
flowsDir := config.EnvString("ORDER_FLOWS", "data/order-flows")
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Fatalf("create data dir: %v", err)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
reg := actor.NewMutationRegistry()
order.RegisterMutations(reg)
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
hostname := podIp
if hostname == "" {
hostname = "order-1"
}
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
Hostname: hostname,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
g := order.NewOrderGrain(id, time.Now())
// Replay any persisted history so the resident grain is current.
if err := storage.LoadEvents(ctx, id, g); err != nil {
return nil, err
}
return g, nil
},
SpawnHost: func(host string) (actor.Host[order.OrderGrain], error) {
return proxy.NewRemoteHost[order.OrderGrain](host)
},
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
TTL: time.Hour,
PoolSize: 10000,
// Storage is intentionally nil here: the pool persists mutations in a
// fire-and-forget goroutine, so concurrent appends for one grain can land
// out of order on disk — fatal for an event log, where replay order is the
// state. We persist through orderedApplier instead (synchronous, ordered,
// and only for mutations that actually applied). Replay on spawn still uses
// `storage` directly in Spawn above.
MutationRegistry: reg,
Storage: nil,
})
if err != nil {
log.Fatalf("create pool: %v", err)
}
controlPlaneConfig := actor.DefaultServerConfig()
grpcSrv, err := actor.NewControlServer[order.OrderGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
applier := &orderedApplier{pool: pool, storage: storage}
provider := selectProvider(logger)
// Flow registry: generic hooks + the order lifecycle actions + predicates,
// paying via the selected provider. The amqp_emit hook bridges saga steps to
// the broker (publisher is nil without AMQP — the hook then errors at run
// time, which is logged, not fatal).
amqpURL := os.Getenv("AMQP_URL")
emailTemplates, err := order.LoadEmailTemplates(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
if err != nil {
log.Fatalf("load email templates: %v", err)
}
emailSender, err := selectEmailSender(logger)
if err != nil {
log.Fatalf("configure email sender: %v", err)
}
inventoryReservations, err := selectInventoryReservationService()
if err != nil {
log.Fatalf("configure inventory reservation service: %v", err)
}
var emitPub order.Publisher
if amqpURL != "" {
// The amqp_emit hook writes to a durable outbox rather than the broker
// directly; a relay goroutine drains it to RabbitMQ (reconnecting), so an
// event survives a broker outage or a crash after the state change.
box, err := outbox.Open(filepath.Join(dataDir, "outbox.log"))
if err != nil {
// Durability-critical (same as the idempotency store): fail fast rather
// than silently publish events nowhere for the whole process lifetime.
log.Fatalf("open outbox: %v", err)
}
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
emitPub = box
// Stream applied order mutations to the shared mutations exchange (routing
// key mutation.order) for the backoffice live feed. Best-effort, separate
// from the durable outbox above.
if conn, derr := rabbit.Dial(amqpURL, "order"); derr != nil {
logger.Warn("order: mutation feed disabled", "err", derr)
} else {
feed := actor.NewAmqpListener(conn.Connection(), "order", actor.MutationSummary)
feed.DefineTopics()
pool.AddListener(feed)
// Declare the "order" topic exchange the outbox relay publishes
// order.created to, so a publish can't hit a missing exchange before
// a consumer declares it. Durable + idempotent.
if ch, cerr := conn.Channel(); cerr != nil {
logger.Warn("order: declare order exchange: channel", "err", cerr)
} else {
if eerr := ch.ExchangeDeclare("order", "topic", true, false, false, false, nil); eerr != nil {
logger.Warn("order: declare order exchange", "err", eerr)
}
_ = ch.Close()
}
}
}
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, logger)
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
if err != nil {
log.Fatalf("load flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def})
if err != nil {
log.Fatalf("load flows: %v", err)
}
// Durable idempotency store for order creation — survives a restart, so a
// client retry after a crash returns the existing order instead of creating
// a duplicate.
idem, err := idempotency.Open(filepath.Join(dataDir, "idempotency.log"))
if err != nil {
log.Fatalf("open idempotency store: %v", err)
}
taxProvider := selectTaxProvider()
s := &server{
pool: pool, applier: applier, storage: storage, reg: reg,
engine: engine, freg: freg, flows: flows, provider: provider,
taxProvider: taxProvider,
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
inventoryReservations: inventoryReservations,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
// Checkout + order reads.
mux.HandleFunc("POST /checkout", s.handleCheckout)
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
mux.HandleFunc("GET /api/orders", s.handleList)
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
// Post-purchase lifecycle (the grain already supports these transitions).
mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill)
mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
mux.HandleFunc("POST /api/orders/{id}/exchanges", s.handleExchange)
mux.HandleFunc("POST /api/orders/{id}/edit", s.handleEditDetails)
// UCP REST adapter — Universal Commerce Protocol order lifecycle.
orderUCP := ucp.OrderHandler(s.applier)
if signer := loadUCPOrderSigner(); signer != nil {
orderUCP = ucp.WithSigning(orderUCP, signer)
logger.Info("ucp signing enabled")
}
// StripPrefix is required because the sub-mux (OrderHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path without stripping the prefix.
// Only the subtree pattern is registered (see comment in cmd/cart/main.go
// for why the exact-match pattern is omitted).
mux.Handle("/ucp/v1/orders/", http.StripPrefix("/ucp/v1/orders", orderUCP))
// Order MCP streamable-HTTP server (inspect + mutate orders via tools).
// Mounted at /order-mcp to avoid conflicting with the backoffice CMS MCP at
// /mcp (which is routed by the edge).
orderMCP := ordermcp.New(applier)
mux.Handle("/order-mcp", orderMCP.Handler())
mux.Handle("/order-mcp/", orderMCP.Handler())
// Saga / flow admin (backoffice editor).
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
if amqpURL != "" {
startOrderIngest(context.Background(), amqpURL, s)
}
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("serve: %v", err)
}
}
// orderedApplier wraps the grain pool to persist the event log correctly: it
// appends each applied mutation synchronously and in call order (the pool's own
// storage path is async/unordered), and only persists mutations whose handler
// succeeded — a rejected transition must never enter the log. A single mutex
// serialises all appends, which is ample for a single-node checkout service.
type orderedApplier struct {
pool *actor.SimpleGrainPool[order.OrderGrain]
storage *actor.DiskStorage[order.OrderGrain]
mu sync.Mutex
}
func (a *orderedApplier) Get(ctx context.Context, id uint64) (*order.OrderGrain, error) {
return a.pool.Get(ctx, id)
}
func (a *orderedApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
res, err := a.pool.Apply(ctx, id, mutation...)
if err != nil || res == nil {
return res, err
}
// Persist only the mutations that actually applied (result index aligns with
// the input order).
applied := make([]proto.Message, 0, len(mutation))
for i, m := range mutation {
if i < len(res.Mutations) && res.Mutations[i].Error != nil {
continue
}
applied = append(applied, m)
}
if len(applied) > 0 {
a.mu.Lock()
appendErr := a.storage.AppendMutations(id, applied...)
a.mu.Unlock()
if appendErr != nil {
return res, appendErr
}
}
return res, nil
}
// --- checkout -------------------------------------------------------------
type lineReq struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
DropShip bool `json:"drop_ship,omitempty"`
Location string `json:"location,omitempty"` // inventory commit location / store id
}
type checkoutReq struct {
OrderReference string `json:"orderReference,omitempty"`
CartId string `json:"cartId,omitempty"`
Currency string `json:"currency,omitempty"`
Locale string `json:"locale,omitempty"`
Country string `json:"country,omitempty"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
Lines []lineReq `json:"lines"`
}
func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
var req checkoutReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
return
}
if len(req.Lines) == 0 {
writeErr(w, http.StatusBadRequest, fmt.Errorf("checkout requires at least one line"))
return
}
id, err := order.NewOrderId()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if req.OrderReference == "" {
req.OrderReference = "ord-" + id.String()
}
if req.Currency == "" {
req.Currency = "SEK"
}
flowName := r.URL.Query().Get("flow")
if flowName == "" {
flowName = s.defaultFlow
}
def, ok := s.flows.get(flowName)
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
return
}
po := s.buildPlaceOrder(id, &req)
st := flow.NewState(uint64(id), s.logger)
st.Vars[order.PlaceOrderVar] = po
res, runErr := s.engine.Run(r.Context(), def, st)
grain, getErr := s.pool.Get(r.Context(), uint64(id))
if getErr != nil {
writeErr(w, http.StatusInternalServerError, getErr)
return
}
status := http.StatusCreated
if runErr != nil {
// The flow compensated; the order exists but is not paid. Report 402 with
// the (now cancelled/failed) order and the flow trace so the caller knows.
status = http.StatusPaymentRequired
s.logger.Warn("checkout flow failed", "orderId", id.String(), "err", runErr)
}
writeJSON(w, status, map[string]any{
"orderId": id.String(),
"flow": res,
"order": grain,
})
}
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
// computing per-line and order totals (inc-vat) from the lines.
// Tax amounts are computed using the configured TaxProvider.
func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
po := &messages.PlaceOrder{
OrderReference: req.OrderReference,
CartId: req.CartId,
Currency: req.Currency,
Locale: req.Locale,
Country: req.Country,
CustomerEmail: req.CustomerEmail,
CustomerName: req.CustomerName,
BillingAddress: req.BillingAddress,
ShippingAddress: req.ShippingAddress,
PlacedAtMs: time.Now().UnixMilli(),
}
var total, totalTax int64
for _, l := range req.Lines {
lineTotal := l.UnitPrice * int64(l.Quantity)
lineTax := s.taxProvider.Compute(lineTotal, int(l.TaxRate))
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{
Reference: l.Reference,
Sku: l.Sku,
Name: l.Name,
Quantity: l.Quantity,
UnitPrice: l.UnitPrice,
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
DropShip: l.DropShip,
})
}
po.TotalAmount = total
po.TotalTax = totalTax
return po
}
// --- reads ----------------------------------------------------------------
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return
}
grain, err := s.pool.Get(r.Context(), uint64(id))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if grain.Status == order.StatusNew {
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
}
type eventView struct {
Type string `json:"type"`
Time string `json:"time,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// handleEvents returns the raw event timeline by reading the grain's append-only
// log (the source of truth) without re-applying it to live state.
func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) {
id, ok := order.ParseOrderId(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
return
}
var events []eventView
throwaway := order.NewOrderGrain(uint64(id), time.Now())
err := s.storage.LoadEventsFunc(r.Context(), uint64(id), throwaway,
func(msg proto.Message, _ int, ts time.Time) bool {
name, _ := s.reg.GetTypeName(msg)
payload, _ := protojson.Marshal(msg)
events = append(events, eventView{Type: name, Time: ts.UTC().Format(time.RFC3339), Payload: payload})
return false // collect only; do not apply
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "events": events})
}
type orderSummary struct {
OrderId string `json:"orderId"`
Reference string `json:"reference,omitempty"`
Status order.Status `json:"status"`
TotalAmount int64 `json:"totalAmount"`
CapturedAmount int64 `json:"capturedAmount"`
Currency string `json:"currency,omitempty"`
PlacedAt string `json:"placedAt,omitempty"`
}
// handleList scans the data dir for order logs and summarises each (replaying it
// through the pool). Fine for a single-node simple checkout; a production list
// would use an index/projection store.
func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
out := make([]orderSummary, 0, len(matches))
for _, m := range matches {
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
raw, err := strconv.ParseUint(base, 10, 64)
if err != nil {
continue
}
g, err := s.pool.Get(r.Context(), raw)
if err != nil || g.Status == order.StatusNew {
continue
}
out = append(out, orderSummary{
OrderId: order.OrderId(raw).String(),
Reference: g.OrderReference,
Status: g.Status,
TotalAmount: g.TotalAmount.Int64(),
CapturedAmount: g.CapturedAmount.Int64(),
Currency: g.Currency,
PlacedAt: g.PlacedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// --- helpers --------------------------------------------------------------
// selectTaxProvider picks the tax provider from the environment. Default is
// NordicTaxProvider with SE as the default country (matching the current
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
// (no country awareness).
func selectTaxProvider() tax.Provider {
provider := os.Getenv("TAX_PROVIDER")
switch provider {
case "static":
return tax.NewStatic()
case "nordic":
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
default:
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
}
}
// selectProvider picks the payment provider from the environment. Default is
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
func selectProvider(logger *slog.Logger) order.PaymentProvider {
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
key := os.Getenv("STRIPE_SECRET_KEY")
if key == "" {
logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock")
return order.NewMockProvider()
}
logger.Info("using stripe payment provider")
return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD"))
}
return order.NewMockProvider()
}
// selectEmailSender picks the flow-email transport. SMTP is the only sender
// implementation for now; when no SMTP host is configured the hook still exists
// in capabilities but errors at run time if a flow tries to use it.
func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
kind := config.EnvString("ORDER_EMAIL_SENDER", "")
if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" {
return nil, nil
}
if kind == "" {
kind = "smtp"
}
if kind != "smtp" {
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q", kind)
}
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
if fromAddr == "" {
return nil, fmt.Errorf("ORDER_EMAIL_FROM_ADDRESS is required when email sender is configured")
}
from := mail.Address{
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
Address: fromAddr,
}
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"),
Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"),
DefaultFrom: from,
Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second),
})
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
return sender, nil
}
func selectInventoryReservationService() (order.InventoryReservationService, error) {
return order.NewHTTPInventoryReservationService(config.EnvString("INVENTORY_URL", "http://localhost:8080"), nil)
}
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPOrderSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]string{"error": err.Error()})
}
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
)
func UseDiscovery(pool discovery.DiscoveryTarget) {
discovery.StartK8sDiscovery(podIp, "actor-pool=profile", 30, pool)
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"time"
"git.k6n.net/mats/go-cart-actor/internal/customerauth"
"git.k6n.net/mats/go-cart-actor/internal/ucp"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
"git.k6n.net/mats/platform/config"
"git.k6n.net/mats/platform/rabbit"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// buildAuthStorage selects the credential store and login limiter that back the
// customer-auth surface. With REDIS_ADDRESS set it uses shared Redis storage so
// the service scales horizontally; otherwise it falls back to the file-backed
// store and an in-memory limiter (single-instance / local dev). A Redis failure
// at startup is fatal rather than silently degrading to per-replica state.
func buildAuthStorage(ctx context.Context, profileDir string) (customerauth.Credentials, customerauth.Limiter) {
if addr := os.Getenv("REDIS_ADDRESS"); addr != "" {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: os.Getenv("REDIS_PASSWORD"),
DB: 0,
})
store, err := customerauth.NewRedisCredentialStore(ctx, rdb)
if err != nil {
log.Fatalf("Error connecting customer-auth credential store to Redis at %s: %v", addr, err)
}
log.Printf("customer-auth: using shared Redis storage at %s", addr)
return store, customerauth.NewRedisLoginLimiter(rdb, 0, 0)
}
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
if err != nil {
log.Fatalf("Error loading credential store: %v", err)
}
log.Print("customer-auth: REDIS_ADDRESS unset — using file credential store + in-memory limiter (single-instance only)")
return credStore, customerauth.NewLoginLimiter(0, 0)
}
// authSecret returns the HMAC key for customer session cookies. It reads
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
// warns that issued sessions will not survive a restart (fine for dev).
func authSecret() []byte {
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
return []byte(v)
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Fatalf("could not generate auth secret: %v", err)
}
log.Print("warning: CUSTOMER_AUTH_SECRET unset — using an ephemeral key; customer sessions will not survive a restart")
return []byte(hex.EncodeToString(b))
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
func loadUCPProfileSigner() *ucp.SigningConfig {
path := os.Getenv("UCP_SIGNING_KEY_PATH")
if path == "" {
return nil
}
pemData, err := os.ReadFile(path)
if err != nil {
log.Printf("ucp signing: cannot read key %s: %v", path, err)
return nil
}
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
if err != nil {
log.Printf("ucp signing: invalid key at %s: %v", path, err)
return nil
}
return cfg
}
func main() {
reg := profile.NewProfileMutationRegistry()
profileDir := config.EnvString("PROFILE_DIR", "data/profiles")
if err := os.MkdirAll(profileDir, 0755); err != nil {
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
}
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
ret := profile.NewProfileGrain(id, time.Now())
err := diskStorage.LoadEvents(ctx, id, ret)
return ret, err
},
// Destroy is an optional grain-eviction cleanup hook; profiles need none
// (disk storage persists via its own loop), so it's left unset — purge()
// skips a nil Destroy.
// SpawnHost makes the pool cluster-aware: when a grain is owned by a peer
// replica, the pool proxies to it over gRPC :1337 (control) + HTTP :8080
// (requests). With POD_IP unset and no discovered peers this is never
// invoked, so the service still runs fine single-node.
SpawnHost: func(host string) (actor.Host[profile.ProfileGrain], error) {
return proxy.NewRemoteHost[profile.ProfileGrain](host)
},
TTL: 10 * time.Minute,
PoolSize: 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Fatalf("Error creating profile pool: %v\n", err)
}
// Stream applied profile mutations to the shared mutations exchange (routing
// key mutation.profile) for the backoffice live feed. Best-effort: disabled
// when AMQP_URL is unset or the broker is unreachable.
if amqpURL := os.Getenv("AMQP_URL"); amqpURL != "" {
if conn, derr := rabbit.Dial(amqpURL, "cart-profile"); derr != nil {
log.Printf("profile: AMQP connect failed, mutation feed disabled: %v", derr)
} else {
feed := actor.NewAmqpListener(conn.Connection(), "profile", actor.MutationSummary)
feed.DefineTopics()
pool.AddListener(feed)
log.Printf("profile: mutation feed enabled (mutation.profile)")
}
}
// Clustering control plane: gRPC server on :1337 serves peer pools'
// ownership announcements and remote Apply/Get calls; UseDiscovery watches
// for sibling profile pods (label actor-pool=profile) and wires them into the
// pool. Both are no-ops in single-node mode (POD_IP unset → nil discovery).
grpcSrv, err := actor.NewControlServer[profile.ProfileGrain](actor.DefaultServerConfig(), pool)
if err != nil {
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
UseDiscovery(pool)
mux := http.NewServeMux()
debugMux := http.NewServeMux()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
// Customer auth storage and failed-login limiter.
// Credentials and the failed-login limiter use shared Redis storage when
// REDIS_ADDRESS is set (so the service scales horizontally), and fall back to
// the file store + in-memory limiter for single-instance / local dev. Session
// and verify/reset tokens are stateless HMAC values, so a shared
// CUSTOMER_AUTH_SECRET is all they need to work across replicas.
credStore, limiter := buildAuthStorage(ctx, profileDir)
// UCP Customer REST adapter
auditLogPath := filepath.Join(profileDir, "audit.log")
customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore)
if signer := loadUCPProfileSigner(); signer != nil {
customerUCP = ucp.WithSigning(customerUCP, signer)
log.Print("ucp customer signing enabled")
}
// StripPrefix is required because the sub-mux (CustomerHandler) registers
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
// passes the full request path without stripping the prefix.
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
// Customer auth: password signup/login + session cookies + identity linking.
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
Limiter: limiter,
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
}).Handler()
mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
grainCount, capacity := pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("pool not healthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("1.0.0"))
})
srv := &http.Server{
Addr: config.EnvString("PROFILE_ADDR", ":8080"),
BaseContext: func(net.Listener) context.Context { return ctx },
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
Handler: otelhttp.NewHandler(mux, "/"),
}
defer func() {
fmt.Println("Shutting down profile service")
otelShutdown(context.Background())
diskStorage.Close()
pool.Close()
}()
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
log.Print("Profile server started at port 8080")
go http.ListenAndServe(":8081", debugMux)
select {
case err = <-srvErr:
log.Fatalf("Unable to start server: %v", err)
case <-ctx.Done():
stop()
}
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1761304670 cartid 4393545184291837
BIN
View File
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{"type":"AddItem","timestamp":"2026-06-14T12:06:33.026264929+02:00","mutation":{"item_id":147528,"quantity":1,"price":18559,"orgPrice":23199,"sku":"A0161291","name":"SF vridfönster 2380x680mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0161291.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NywiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjI0MDciLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoyNCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:41.00641672+02:00","mutation":{"Id":1,"quantity":2}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:50.402587934+02:00","mutation":{"Id":1,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:08:25.714006052+02:00","mutation":{"Id":1,"quantity":2}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:11:38.722916072+02:00","mutation":{"Id":1,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:12.485362998+02:00","mutation":{"Id":1,"quantity":2}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:13.259209602+02:00","mutation":{"Id":1,"quantity":1}}
{"type":"AddItem","timestamp":"2026-06-14T12:14:19.560413353+02:00","mutation":{"item_id":146107,"quantity":1,"price":1812900,"orgPrice":2266125,"sku":"A0158995","name":"SF vridfönster 1680x1380mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0158995.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTQsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE3MTQiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxNywid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"RemoveItem","timestamp":"2026-06-14T12:16:35.832642257+02:00","mutation":{"Id":1}}
{"type":"AddItem","timestamp":"2026-06-14T12:47:41.110158952+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T12:49:14.15670005+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.389741453+02:00","mutation":{"Id":3,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.68037225+02:00","mutation":{"Id":3}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:20.250569359+02:00","mutation":{"Id":2}}
{"type":"AddItem","timestamp":"2026-06-14T12:49:22.994875367+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T13:02:45.511064864+02:00","mutation":{"item_id":146248,"quantity":1,"price":1842370,"orgPrice":2302963,"sku":"A0159038","name":"SF vridfönster 1780x780mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0159038.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6OCwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MDgiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:48.965993343+02:00","mutation":{"Id":5}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:49.533960677+02:00","mutation":{"Id":4}}
{"type":"AddItem","timestamp":"2026-06-14T13:03:02.543932484+02:00","mutation":{"item_id":146620,"quantity":1,"price":3043620,"orgPrice":3804525,"sku":"A0164975","name":"SF vridfönster 1880x1180mm 3-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164975.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMyIsImx1ZnRfdGl0bGUiOiIzLUx1ZnQgKHRyZSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE5MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOSwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:06:48.388062792+02:00","mutation":{"Id":6}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.83789281+02:00","mutation":{"item_id":146387,"quantity":1,"price":1767450,"orgPrice":2524929,"sku":"A0164933","name":"SF vridfönster 1780x1180mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164933.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837946101+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":7,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837975176+02:00","mutation":{"item_id":123075,"quantity":1,"price":995278,"sku":"A0125821","name":"Ställkostnad egen kulör Ncs/Ral nr (ej lasyr) / leverans","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjUsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}}
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837991607+02:00","mutation":{"item_id":123076,"quantity":1,"price":2420510,"sku":"A0125831","name":"Ställkostnad egen kulör utsida NCS S / leverans (väljs på en produkt)","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjYsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:28:50.254122718+02:00","mutation":{"Id":7}}
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256709127+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}}
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256762929+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":11,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256793727+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":11,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.078662993+02:00","mutation":{"Id":8,"quantity":1}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.54547595+02:00","mutation":{"Id":8}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:04.240661903+02:00","mutation":{"Id":9}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:05.581768231+02:00","mutation":{"Id":10}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:09.40673458+02:00","mutation":{"Id":11}}
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:11.41069986+02:00","mutation":{"Id":12}}
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.09761136+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}}
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097670772+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":13,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097710397+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":13,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
{
"version": 1,
"state": {
"promotions": [
{
"id": "volymrabatt",
"name": "Volymrabatt",
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 00040 000 kr → 5%, 40 00060 000 kr → 9%, 60 00090 000 kr → 14%.",
"status": "active",
"priority": 100,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [
{
"id": "min-cart-total",
"type": "cart_total",
"operator": ">=",
"value": 2000000,
"label": "Minst 20 000 kr i varukorgen"
}
],
"actions": [
{
"id": "volymrabatt-tiers",
"type": "tiered_discount",
"value": 0,
"config": {
"tiers": [
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
]
},
"label": "Volymrabatt 514%"
}
],
"usageCount": 0,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"createdBy": "mats.tornberg@gmail.com",
"tags": ["volume", "volymrabatt", "test"]
}
]
}
}
BIN
View File
Binary file not shown.
Binary file not shown.
-276
View File
@@ -1,276 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: klarna-api-credentials
data:
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
type: Opaque
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-actor
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
selector:
matchLabels:
app: cart-actor
arch: amd64
template:
metadata:
labels:
app: cart-actor
actor-pool: cart
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/cart-actor-no
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: BASE_URL
value: "https://s10n-no.tornberg.me"
- name: CART_BASE_URL
value: "https://cart-no.tornberg.me"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-actor
arch: arm64
name: cart-actor-arm64
spec:
replicas: 3
selector:
matchLabels:
app: cart-actor
arch: arm64
template:
metadata:
labels:
app: cart-actor
actor-pool: cart
arch: arm64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/cart-actor-no
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor:latest
name: cart-actor-arm64
imagePullPolicy: Always
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: BASE_URL
value: "https://s10n-no.tornberg.me"
- name: CART_BASE_URL
value: "https://cart-no.tornberg.me"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
kind: Service
apiVersion: v1
metadata:
name: cart-echo
spec:
selector:
app: cart-actor
type: LoadBalancer
ports:
- name: echo
port: 1234
---
kind: Service
apiVersion: v1
metadata:
name: cart-actor
annotations:
prometheus.io/port: "8080"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
selector:
app: cart-actor
ports:
- name: web
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cart-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/affinity: "cookie"
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 4m
spec:
ingressClassName: nginx
tls:
- hosts:
- cart-no.tornberg.me
secretName: cart-actor-no-tls-secret
rules:
- host: cart-no.tornberg.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cart-actor
port:
number: 8080
+440 -51
View File
@@ -1,7 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: klarna-api-credentials
name: klarna-api-credentials
data:
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
@@ -9,13 +9,117 @@ type: Opaque
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-backoffice
arch: amd64
name: cart-backoffice-x86
spec:
replicas: 1
selector:
matchLabels:
app: cart-backoffice
arch: amd64
template:
metadata:
labels:
app: cart-backoffice
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/
server: 10.10.1.10
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
command: ["/go-cart-backoffice"]
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 30
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: CART_DIR
value: "/data/cart-actor"
- name: CHECKOUT_DIR
value: "/data/checkout-actor"
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
# - name: BASE_URL
# value: "https://s10n-no.tornberg.me"
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-actor
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
replicas: 3
selector:
matchLabels:
app: cart-actor
@@ -32,20 +136,18 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/cart-actor
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor-amd64:latest
- image: registry.k6n.net/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
lifecycle:
@@ -55,25 +157,23 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
periodSeconds: 50
volumeMounts:
- mountPath: "/data"
name: data
@@ -84,6 +184,10 @@ spec:
memory: "70Mi"
cpu: "1200m"
env:
- name: CART_DIR
value: "/data/cart-actor"
- name: CHECKOUT_DIR
value: "/data/checkout-actor"
- name: TZ
value: "Europe/Stockholm"
- name: KLARNA_API_USERNAME
@@ -91,6 +195,24 @@ spec:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=cart,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
@@ -134,24 +256,22 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
values:
- arm64
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/cart-actor
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor:latest
- image: registry.k6n.net/go-cart-actor:latest
name: cart-actor-arm64
imagePullPolicy: Always
lifecycle:
@@ -161,25 +281,23 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
periodSeconds: 15
volumeMounts:
- mountPath: "/data"
name: data
@@ -192,6 +310,24 @@ spec:
env:
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "redis.home:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=cart,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
@@ -217,22 +353,10 @@ spec:
---
kind: Service
apiVersion: v1
metadata:
name: cart-echo
spec:
selector:
app: cart-actor
type: LoadBalancer
ports:
- name: echo
port: 1234
---
kind: Service
apiVersion: v1
metadata:
name: cart-actor
annotations:
prometheus.io/port: "8080"
prometheus.io/port: "8081"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
@@ -241,6 +365,151 @@ spec:
ports:
- name: web
port: 8080
- name: internal
port: 8081
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: checkout-actor
arch: amd64
name: checkout-actor-x86
spec:
replicas: 3
selector:
matchLabels:
app: checkout-actor
arch: amd64
template:
metadata:
labels:
app: checkout-actor
actor-pool: checkout
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/checkout-actor
server: 10.10.1.10
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor-amd64:latest
name: checkout-actor-amd64
imagePullPolicy: Always
command: ["/go-checkout-actor"]
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 15
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 1
periodSeconds: 15
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: REDIS_ADDRESS
value: "10.10.3.18:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.name=checkout,service.version=0.1.2"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-debug-service.monitoring:4317"
- name: ADYEN_HMAC
valueFrom:
secretKeyRef:
name: adyen
key: HMAC
- name: ADYEN_API_KEY
valueFrom:
secretKeyRef:
name: adyen
key: API_KEY
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: AMQP_URL
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
- name: CART_INTERNAL_URL
value: "http://cart-actor:8080"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
kind: Service
apiVersion: v1
metadata:
name: checkout-actor
annotations:
prometheus.io/port: "8081"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
selector:
app: checkout-actor
ports:
- name: web
port: 8080
---
kind: Service
apiVersion: v1
metadata:
name: cart-backoffice
spec:
selector:
app: cart-backoffice
ports:
- name: web
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -248,19 +517,19 @@ metadata:
name: cart-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/affinity: "cookie"
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 4m
spec:
ingressClassName: nginx
tls:
- hosts:
- cart.tornberg.me
- cart.k6n.net
secretName: cart-actor-tls-secret
rules:
- host: cart.tornberg.me
- host: cart.k6n.net
http:
paths:
- path: /
@@ -270,3 +539,123 @@ spec:
name: cart-actor
port:
number: 8080
- path: /api/checkout
pathType: Prefix
backend:
service:
name: checkout-actor
port:
number: 8080
- path: /payment
pathType: Prefix
backend:
service:
name: checkout-actor
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cart-backend-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- slask-cart.k6n.net
secretName: cart-backoffice-actor-tls-secret
rules:
- host: slask-cart.k6n.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cart-backoffice
port:
number: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-inventory
arch: amd64
name: cart-inventory-x86
spec:
replicas: 1
selector:
matchLabels:
app: cart-inventory
arch: amd64
template:
metadata:
labels:
app: cart-inventory
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor-amd64:latest
name: cart-inventory-amd64
imagePullPolicy: Always
command: ["/go-cart-inventory"]
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 30
resources:
limits:
memory: "256Mi"
cpu: "500m"
requests:
memory: "50Mi"
cpu: "500m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: RABBIT_HOST
value: amqp://admin:12bananer@rabbitmq.s10n:5672/
- name: REDIS_ADDRESS
value: "redis.home:6379"
- name: REDIS_PASSWORD
value: "slaskredis"
---
kind: Service
apiVersion: v1
metadata:
name: inventory
spec:
selector:
app: cart-inventory
ports:
- name: web
port: 8080
+94 -18
View File
@@ -1,25 +1,101 @@
apiVersion: autoscaling/v1
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cart-scaler-amd
name: cart-scaler-amd
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-x86
minReplicas: 3
maxReplicas: 9
targetCPUUtilizationPercentage: 30
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-x86
minReplicas: 3
maxReplicas: 9
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 180
policies:
- type: Percent
value: 50
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
# Future custom metric (example):
# - type: Pods
# pods:
# metric:
# name: cart_mutations_per_second
# target:
# type: AverageValue
# averageValue: "15"
# - type: Object
# object:
# describedObject:
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# name: cart-ingress
# metric:
# name: http_requests_per_second
# target:
# type: Value
# value: "100"
---
apiVersion: autoscaling/v1
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cart-scaler-arm
name: cart-scaler-arm
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-arm64
minReplicas: 3
maxReplicas: 9
targetCPUUtilizationPercentage: 30
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cart-actor-arm64
minReplicas: 3
maxReplicas: 9
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 180
policies:
- type: Percent
value: 50
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
# Future custom metric (example):
# - type: Pods
# pods:
# metric:
# name: cart_mutations_per_second
# target:
# type: AverageValue
# averageValue: "15"
# - type: Object
# object:
# describedObject:
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# name: cart-ingress
# metric:
# name: http_requests_per_second
# target:
# type: Value
# value: "100"
-84
View File
@@ -1,84 +0,0 @@
package main
import (
"fmt"
"log"
"net"
"sync"
"time"
)
type DiscardedHost struct {
Host string
Tries int
}
type DiscardedHostHandler struct {
mu sync.RWMutex
port int
hosts []*DiscardedHost
onConnection *func(string)
}
func (d *DiscardedHostHandler) run() {
for range time.Tick(time.Second) {
d.mu.RLock()
lst := make([]*DiscardedHost, 0, len(d.hosts))
for _, host := range d.hosts {
if host.Tries >= 0 && host.Tries < 5 {
go d.testConnection(host)
lst = append(lst, host)
} else {
if host.Tries > 0 {
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
}
}
}
d.mu.RUnlock()
d.mu.Lock()
d.hosts = lst
d.mu.Unlock()
}
}
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
conn, err := net.Dial("tcp", addr)
if err != nil {
host.Tries++
if host.Tries >= 5 {
// Exceeded retry threshold; will be dropped by run loop.
}
} else {
conn.Close()
if d.onConnection != nil {
fn := *d.onConnection
fn(host.Host)
}
}
}
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
ret := &DiscardedHostHandler{
hosts: make([]*DiscardedHost, 0),
port: port,
}
go ret.run()
return ret
}
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
d.onConnection = &fn
}
func (d *DiscardedHostHandler) AppendHost(host string) {
d.mu.Lock()
defer d.mu.Unlock()
log.Printf("Adding host %s to retry list", host)
d.hosts = append(d.hosts, &DiscardedHost{
Host: host,
Tries: 0,
})
}
-77
View File
@@ -1,77 +0,0 @@
package main
import (
"context"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
toolsWatch "k8s.io/client-go/tools/watch"
)
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(pods.Items))
for _, pod := range pods.Items {
hosts = append(hosts, pod.Status.PodIP)
}
return hosts, nil
}
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
return nil, err
}
ch := make(chan HostChange)
go func() {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
ch <- HostChange{
Host: pod.Status.PodIP,
Type: event.Type,
}
}
}()
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
}
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"testing"
"time"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
func TestDiscovery(t *testing.T) {
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
if err != nil {
t.Errorf("Error building config: %v", err)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
t.Errorf("Error creating client: %v", err)
}
d := NewK8sDiscovery(client)
res, err := d.DiscoverInNamespace("")
if err != nil {
t.Errorf("Error discovering: %v", err)
}
if len(res) == 0 {
t.Errorf("Expected at least one host, got none")
}
}
func TestWatch(t *testing.T) {
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
if err != nil {
t.Errorf("Error building config: %v", err)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
t.Errorf("Error creating client: %v", err)
}
d := NewK8sDiscovery(client)
ch, err := d.Watch()
if err != nil {
t.Errorf("Error watching: %v", err)
}
select {
case m := <-ch:
t.Logf("Received watch %v", m)
case <-time.After(5 * time.Second):
t.Errorf("Timeout waiting for watch")
}
}
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"encoding/gob"
"fmt"
"log"
"os"
"time"
)
type DiskStorage struct {
stateFile string
lastSave int64
LastSaves map[CartId]int64
}
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
ret := &DiskStorage{
stateFile: stateFile,
LastSaves: make(map[CartId]int64),
}
err := ret.loadState()
return ret, err
}
func saveMessages(messages []StorableMessage, id CartId) error {
if len(messages) == 0 {
return nil
}
log.Printf("%d messages to save for grain id %s", len(messages), id)
var file *os.File
var err error
path := getCartPath(id.String())
file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
for _, m := range messages {
err := m.Write(file)
if err != nil {
return err
}
}
return err
}
func getCartPath(id string) string {
return fmt.Sprintf("data/%s.prot", id)
}
func loadMessages(grain Grain, id CartId) error {
var err error
path := getCartPath(id.String())
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer file.Close()
for err == nil {
var msg Message
err = ReadMessage(file, &msg)
if err == nil {
grain.HandleMessage(&msg, true)
}
}
if err.Error() == "EOF" {
return nil
}
return err
}
func (s *DiskStorage) saveState() error {
tmpFile := s.stateFile + "_tmp"
file, err := os.Create(tmpFile)
if err != nil {
return err
}
defer file.Close()
err = gob.NewEncoder(file).Encode(s.LastSaves)
if err != nil {
return err
}
os.Remove(s.stateFile + ".bak")
os.Rename(s.stateFile, s.stateFile+".bak")
return os.Rename(tmpFile, s.stateFile)
}
func (s *DiskStorage) loadState() error {
file, err := os.Open(s.stateFile)
if err != nil {
return err
}
defer file.Close()
return gob.NewDecoder(file).Decode(&s.LastSaves)
}
func (s *DiskStorage) Store(id CartId, grain *CartGrain) error {
lastSavedMessage, ok := s.LastSaves[id]
if ok && lastSavedMessage > grain.GetLastChange() {
return nil
}
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
if err != nil {
return err
}
ts := time.Now().Unix()
s.LastSaves[id] = ts
s.lastSave = ts
return nil
}
-102
View File
@@ -1,102 +0,0 @@
package main
// Minimal frame abstractions retained after removal of the legacy TCP/frame
// networking layer. These types remain only to avoid a wide cascading refactor
// across existing grain / pool logic that still constructs and passes
// FrameWithPayload objects internally.
//
// The original responsibilities this replaces:
// - Binary framing, checksums, network IO
// - Distinction between request / reply frame types
//
// What remains:
// - A light weight container (FrameWithPayload) used as an inprocess
// envelope for status code + typed marker + payload bytes (JSON or proto).
// - Message / status constants referenced in existing code paths.
//
// Recommended future cleanup (postmigration):
// - Remove FrameType entirely and replace with enumerated semantic results
// or error values.
// - Replace FrameWithPayload with a struct { Status int; Data []byte }.
// - Remove remote_* reply type branching once all callers rely on gRPC
// status + strongly typed responses.
//
// For now we keep this minimal surface to keep the gRPC migration focused.
type (
// FrameType is a symbolic identifier carried through existing code paths.
// No ordering or bit semantics are required anymore.
FrameType uint32
StatusCode uint32
)
type Frame struct {
Type FrameType
StatusCode StatusCode
Length uint32
// Checksum retained for compatibility; no longer validated.
Checksum uint32
}
// FrameWithPayload wraps a Frame with an opaque payload.
// Payload usually contains JSON encoded cart state or an error message.
type FrameWithPayload struct {
Frame
Payload []byte
}
// -----------------------------------------------------------------------------
// Legacy Frame Type Constants (minimal subset still referenced)
// -----------------------------------------------------------------------------
const (
RemoteGetState = FrameType(0x01)
RemoteHandleMutation = FrameType(0x02)
ResponseBody = FrameType(0x03) // (rarely used; kept for completeness)
RemoteGetStateReply = FrameType(0x04)
RemoteHandleMutationReply = FrameType(0x05)
RemoteCreateOrderReply = FrameType(0x06)
)
// MakeFrameWithPayload constructs an inprocess frame wrapper.
// Length & Checksum are filled for backward compatibility (no validation logic
// depends on the checksum anymore).
func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload {
length := uint32(len(payload))
return FrameWithPayload{
Frame: Frame{
Type: msg,
StatusCode: statusCode,
Length: length,
Checksum: (uint32(msg) + uint32(statusCode) + length) / 8, // simple legacy formula
},
Payload: payload,
}
}
// Clone creates a shallow copy of the frame, duplicating the payload slice.
func (f *FrameWithPayload) Clone() *FrameWithPayload {
if f == nil {
return nil
}
cp := make([]byte, len(f.Payload))
copy(cp, f.Payload)
return &FrameWithPayload{
Frame: f.Frame,
Payload: cp,
}
}
// NewErrorFrame helper for creating an error frame with a textual payload.
func NewErrorFrame(msg FrameType, code StatusCode, err error) FrameWithPayload {
var b []byte
if err != nil {
b = []byte(err.Error())
}
return MakeFrameWithPayload(msg, code, b)
}
// IsSuccess returns true if the status code indicates success in the
// conventional HTTP style range (200299). This mirrors previous usage patterns.
func (f *FrameWithPayload) IsSuccess() bool {
return f != nil && f.StatusCode >= 200 && f.StatusCode < 300
}
+84 -40
View File
@@ -1,73 +1,117 @@
module git.tornberg.me/go-cart-actor
module git.k6n.net/mats/go-cart-actor
go 1.25.1
go 1.26.2
require (
git.k6n.net/mats/platform v0.0.0-00010101000000-000000000000
github.com/adyen/adyen-go-api-library/v21 v21.1.0
github.com/google/uuid v1.6.0
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
github.com/prometheus/client_golang v1.23.2
github.com/rabbitmq/amqp091-go v1.10.0
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10
k8s.io/api v0.34.1
k8s.io/apimachinery v0.34.1
k8s.io/client-go v0.34.1
github.com/rabbitmq/amqp091-go v1.11.0
github.com/redis/go-redis/v9 v9.20.0
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
k8s.io/api v0.34.2
k8s.io/apimachinery v0.34.2
k8s.io/client-go v0.34.2
)
require (
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/swag v0.25.1 // indirect
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/mangling v0.25.1 // indirect
github.com/go-openapi/swag/netutils v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/mangling v0.25.4 // indirect
github.com/go-openapi/swag/netutils v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/prometheus/common v0.68.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+224 -100
View File
@@ -1,62 +1,89 @@
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/RoaringBitmap/roaring/v2 v2.18.2 h1:oPq3Cgx//iDuJQVp6xSInAKW34J9CEwE5GmLI2z+Eic=
github.com/RoaringBitmap/roaring/v2 v2.18.2/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y=
github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -66,154 +93,251 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
git.k6n.net/mats/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y=
git.k6n.net/mats/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0=
git.k6n.net/mats/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y=
git.k6n.net/mats/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA=
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8=
github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ=
github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+7
View File
@@ -0,0 +1,7 @@
go 1.26.2
use (
.
../slask-finder
../go-redis-inventory
)
+170
View File
@@ -0,0 +1,170 @@
cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
cloud.google.com/go/bigtable v1.47.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4=
cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU=
cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
cloud.google.com/go/container v1.52.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs=
cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
cloud.google.com/go/dataplex v1.34.0/go.mod h1:sOazL+Bs/PTxiMHQ5yBboBvEW9qPrpGogx3+RAgfIt8=
cloud.google.com/go/dataproc/v2 v2.22.0/go.mod h1:oARVSa38kAHvSuG+cozsrY2sE6UajGuvOOf9vS+ADHI=
cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
cloud.google.com/go/dlp v1.34.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6N0d/Q=
cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
cloud.google.com/go/maps v1.35.0/go.mod h1:HH1V8tduMn+b9oRMCdl3vok98uvHco/wElZXyJQ/9kU=
cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
cloud.google.com/go/networksecurity v0.16.0/go.mod h1:LMn10eRVf4K85PMF33yRoKAra7VhCOetxFcLDMh9A74=
cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
cloud.google.com/go/recommender v1.18.0/go.mod h1:INRBLfBQJCrgPqjBVFht4OjaFq/WhB/c5V1sqBOdX8g=
cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
cloud.google.com/go/security v1.24.0/go.mod h1:XaB3p0SE7v2bBitsLBb1hM6R8/oI/k/IujpXFJalFK0=
cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0=
cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
gotest.tools/gotestsum v1.12.3/go.mod h1:Y1+e0Iig4xIRtdmYbEV7K7H6spnjc1fX4BOuUhWw2Wk=
k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=
+327
View File
@@ -0,0 +1,327 @@
{
"uid": "cart-actors",
"title": "Cart Actor Cluster",
"timezone": "browser",
"refresh": "30s",
"schemaVersion": 38,
"version": 1,
"editable": true,
"graphTooltip": 0,
"panels": [
{
"type": "row",
"title": "Overview",
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 },
"id": 1,
"collapsed": false
},
{
"type": "stat",
"title": "Active Grains",
"id": 2,
"gridPos": { "x": 0, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_active_grains" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"type": "stat",
"title": "Grains In Pool",
"id": 3,
"gridPos": { "x": 6, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_grains_in_pool" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"type": "stat",
"title": "Pool Usage %",
"id": 4,
"gridPos": { "x": 12, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_grain_pool_usage * 100" }
],
"units": "percent",
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "stat",
"title": "Connected Remotes",
"id": 5,
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "connected_remotes" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "row",
"title": "Mutations",
"gridPos": { "x": 0, "y": 5, "w": 24, "h": 1 },
"id": 6,
"collapsed": false
},
{
"type": "timeseries",
"title": "Mutation Rate (1m)",
"id": 7,
"gridPos": { "x": 0, "y": 6, "w": 12, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_mutations_total[1m])", "legendFormat": "mutations/s" },
{ "refId": "B", "expr": "rate(cart_mutation_failures_total[1m])", "legendFormat": "failures/s" }
],
"fieldConfig": { "defaults": { "unit": "ops" } }
},
{
"type": "stat",
"title": "Failure % (5m)",
"id": 8,
"gridPos": { "x": 12, "y": 6, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "100 * (increase(cart_mutation_failures_total[5m]) / clamp_max(increase(cart_mutations_total[5m]), 1))"
}
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "timeseries",
"title": "Mutation Latency Quantiles",
"id": 9,
"gridPos": { "x": 18, "y": 6, "w": 6, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.50, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p50"
},
{
"refId": "B",
"expr": "histogram_quantile(0.90, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p90"
},
{
"refId": "C",
"expr": "histogram_quantile(0.99, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p99"
}
],
"fieldConfig": { "defaults": { "unit": "s" } }
},
{
"type": "row",
"title": "Event Log",
"gridPos": { "x": 0, "y": 14, "w": 24, "h": 1 },
"id": 10,
"collapsed": false
},
{
"type": "timeseries",
"title": "Event Append Rate (5m)",
"id": 11,
"gridPos": { "x": 0, "y": 15, "w": 8, "h": 6 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_event_log_appends_total[5m])", "legendFormat": "appends/s" }
]
},
{
"type": "timeseries",
"title": "Event Bytes Written Rate (5m)",
"id": 12,
"gridPos": { "x": 8, "y": 15, "w": 8, "h": 6 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_event_log_bytes_written_total[5m])", "legendFormat": "bytes/s" }
],
"fieldConfig": { "defaults": { "unit": "Bps" } }
},
{
"type": "stat",
"title": "Existing Log Files",
"id": 13,
"gridPos": { "x": 16, "y": 15, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_files_existing" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Last Append Age (s)",
"id": 14,
"gridPos": { "x": 20, "y": 15, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "(time() - cart_event_log_last_append_unix)" }
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Failures Total",
"id": 15,
"gridPos": { "x": 16, "y": 18, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_failures_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Duration p95 (5m)",
"id": 16,
"gridPos": { "x": 20, "y": 18, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
},
{
"type": "row",
"title": "Grain Lifecycle",
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
"id": 17,
"collapsed": false
},
{
"type": "timeseries",
"title": "Spawn & Lookup Rates (1m)",
"id": 18,
"gridPos": { "x": 0, "y": 22, "w": 12, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_grain_spawned_total[1m])", "legendFormat": "spawns/s" },
{ "refId": "B", "expr": "rate(cart_grain_lookups_total[1m])", "legendFormat": "lookups/s" }
]
},
{
"type": "stat",
"title": "Negotiations Rate (5m)",
"id": 19,
"gridPos": { "x": 12, "y": 22, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_remote_negotiation_total[5m])" }
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "horizontal" }
},
{
"type": "stat",
"title": "Mutations Total",
"id": 20,
"gridPos": { "x": 18, "y": 22, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_mutations_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "row",
"title": "Event Log Errors",
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 },
"id": 21,
"collapsed": false
},
{
"type": "stat",
"title": "Unknown Event Types",
"id": 22,
"gridPos": { "x": 0, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_unknown_types_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Event Mutation Errors",
"id": 23,
"gridPos": { "x": 6, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_mutation_errors_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Success Total",
"id": 24,
"gridPos": { "x": 12, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Duration p50 (5m)",
"id": 25,
"gridPos": { "x": 18, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.50, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
}
],
"templating": {
"list": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"type": "datasource",
"query": "prometheus",
"current": { "text": "Prometheus", "value": "Prometheus" }
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h"],
"time_options": ["5m","15m","30m","1h","6h","12h","24h","2d","7d"]
}
}
-168
View File
@@ -1,168 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grains_in_pool",
Help: "The total number of grains in the pool",
})
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_pool_size",
Help: "The total number of mutations",
})
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grain_pool_usage",
Help: "The current usage of the grain pool",
})
)
type GrainPool interface {
Process(id CartId, messages ...Message) (*FrameWithPayload, error)
Get(id CartId) (*FrameWithPayload, error)
}
type Ttl struct {
Expires time.Time
Grain *CartGrain
}
type GrainLocalPool struct {
mu sync.RWMutex
grains map[CartId]*CartGrain
expiry []Ttl
spawn func(id CartId) (*CartGrain, error)
Ttl time.Duration
PoolSize int
}
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
ret := &GrainLocalPool{
spawn: spawn,
grains: make(map[CartId]*CartGrain),
expiry: make([]Ttl, 0),
Ttl: ttl,
PoolSize: size,
}
cartPurge := time.NewTicker(time.Minute)
go func() {
<-cartPurge.C
ret.Purge()
}()
return ret
}
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
p.mu.Lock()
defer p.mu.Unlock()
for id := range availableWithLastChangeUnix {
if _, ok := p.grains[id]; !ok {
p.grains[id] = nil
p.expiry = append(p.expiry, Ttl{
Expires: time.Now().Add(p.Ttl),
Grain: nil,
})
}
}
}
func (p *GrainLocalPool) Purge() {
lastChangeTime := time.Now().Add(-p.Ttl)
keepChanged := lastChangeTime.Unix()
p.mu.Lock()
defer p.mu.Unlock()
for i := 0; i < len(p.expiry); i++ {
item := p.expiry[i]
if item.Expires.Before(time.Now()) {
if item.Grain.GetLastChange() > keepChanged {
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
if i < len(p.expiry)-1 {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
p.expiry = append(p.expiry, item)
} else {
p.expiry = append(p.expiry[:i], item)
}
} else {
log.Printf("Item %s expired", item.Grain.GetId())
delete(p.grains, item.Grain.GetId())
if i < len(p.expiry)-1 {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
} else {
p.expiry = p.expiry[:i]
}
}
} else {
break
}
}
}
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
return p.grains
}
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
var err error
// p.mu.RLock()
// defer p.mu.RUnlock()
grain, ok := p.grains[id]
grainLookups.Inc()
if grain == nil || !ok {
if len(p.grains) >= p.PoolSize {
if p.expiry[0].Expires.Before(time.Now()) {
delete(p.grains, p.expiry[0].Grain.GetId())
p.expiry = p.expiry[1:]
} else {
return nil, fmt.Errorf("pool is full")
}
}
grain, err = p.spawn(id)
p.mu.Lock()
p.grains[id] = grain
p.mu.Unlock()
}
go func() {
l := float64(len(p.grains))
ps := float64(p.PoolSize)
poolUsage.Set(l / ps)
poolGrains.Set(l)
poolSize.Set(ps)
}()
return grain, err
}
func (p *GrainLocalPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
grain, err := p.GetGrain(id)
var result *FrameWithPayload
if err == nil && grain != nil {
for _, message := range messages {
result, err = grain.HandleMessage(&message, false)
}
}
return result, err
}
func (p *GrainLocalPool) Get(id CartId) (*FrameWithPayload, error) {
grain, err := p.GetGrain(id)
if err != nil {
return nil, err
}
data, err := json.Marshal(grain)
if err != nil {
return nil, err
}
ret := MakeFrameWithPayload(0, 200, data)
return &ret, nil
}
-135
View File
@@ -1,135 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"testing"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/grpc"
)
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
// against a locally started gRPC server (single-node scenario).
// This test uses AddItemType directly to avoid hitting external product
// fetching logic (FetchItem) which would require network I/O.
func TestCartActorMutationAndState(t *testing.T) {
// Setup local grain pool + synced pool (no discovery, single host)
pool := NewGrainLocalPool(1024, time.Minute, spawn)
synced, err := NewSyncedPool(pool, "127.0.0.1", nil)
if err != nil {
t.Fatalf("NewSyncedPool error: %v", err)
}
// Start gRPC server (CartActor + ControlPlane) on :1337
grpcSrv, err := StartGRPCServer(":1337", pool, synced)
if err != nil {
t.Fatalf("StartGRPCServer error: %v", err)
}
defer grpcSrv.GracefulStop()
// Dial the local server
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, "127.0.0.1:1337",
grpc.WithInsecure(),
grpc.WithBlock(),
)
if err != nil {
t.Fatalf("grpc.Dial error: %v", err)
}
defer conn.Close()
cartClient := messages.NewCartActorClient(conn)
// Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly)
cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano())
// Build an AddItem payload (bypasses FetchItem to keep test deterministic)
addItem := &messages.AddItem{
ItemId: 1,
Quantity: 1,
Price: 1000,
OrgPrice: 1000,
Sku: "test-sku",
Name: "Test SKU",
Image: "/img.png",
Stock: 2, // InStock
Tax: 2500,
Country: "se",
}
// Marshal underlying mutation payload using the existing handler code path
handler, ok := Handlers[AddItemType]
if !ok {
t.Fatalf("Handler for AddItemType missing")
}
payloadData, err := getSerializedPayload(handler, AddItemType, addItem)
if err != nil {
t.Fatalf("serialize add item: %v", err)
}
// Issue Mutate RPC
mutResp, err := cartClient.Mutate(context.Background(), &messages.MutationRequest{
CartId: cartID,
Type: messages.MutationType(AddItemType),
Payload: payloadData,
ClientTimestamp: time.Now().Unix(),
})
if err != nil {
t.Fatalf("Mutate RPC error: %v", err)
}
if mutResp.StatusCode != 200 {
t.Fatalf("Mutate returned non-200 status: %d payload=%s", mutResp.StatusCode, string(mutResp.Payload))
}
// Decode cart state JSON and validate
state := &CartGrain{}
if err := json.Unmarshal(mutResp.Payload, state); err != nil {
t.Fatalf("Unmarshal mutate cart state: %v\nPayload: %s", err, string(mutResp.Payload))
}
if len(state.Items) != 1 {
t.Fatalf("Expected 1 item after mutation, got %d", len(state.Items))
}
if state.Items[0].Sku != "test-sku" {
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
}
// Issue GetState RPC
getResp, err := cartClient.GetState(context.Background(), &messages.StateRequest{
CartId: cartID,
})
if err != nil {
t.Fatalf("GetState RPC error: %v", err)
}
if getResp.StatusCode != 200 {
t.Fatalf("GetState returned non-200 status: %d payload=%s", getResp.StatusCode, string(getResp.Payload))
}
state2 := &CartGrain{}
if err := json.Unmarshal(getResp.Payload, state2); err != nil {
t.Fatalf("Unmarshal get state: %v", err)
}
if len(state2.Items) != 1 {
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
}
if state2.Items[0].Sku != "test-sku" {
t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku)
}
}
// getSerializedPayload serializes a mutation proto using the registered handler.
func getSerializedPayload(handler MessageHandler, msgType uint16, content interface{}) ([]byte, error) {
msg := &Message{
Type: msgType,
Content: content,
}
var buf bytes.Buffer
if err := handler.Write(msg, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
-379
View File
@@ -1,379 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"time"
proto "git.tornberg.me/go-cart-actor/proto" // underlying generated package name is 'messages'
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// -----------------------------------------------------------------------------
// Metrics
// -----------------------------------------------------------------------------
var (
grpcMutateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_grpc_mutate_duration_seconds",
Help: "Duration of CartActor.Mutate RPCs",
Buckets: prometheus.DefBuckets,
})
grpcMutateErrors = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grpc_mutate_errors_total",
Help: "Total number of failed CartActor.Mutate RPCs",
})
grpcStateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_grpc_get_state_duration_seconds",
Help: "Duration of CartActor.GetState RPCs",
Buckets: prometheus.DefBuckets,
})
grpcControlDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_grpc_control_duration_seconds",
Help: "Duration of ControlPlane RPCs",
Buckets: prometheus.DefBuckets,
})
grpcControlErrors = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grpc_control_errors_total",
Help: "Total number of failed ControlPlane RPCs",
})
)
// timeTrack wraps a closure and records duration into the supplied histogram.
func timeTrack(hist prometheus.Observer, fn func() error) (err error) {
start := time.Now()
defer func() {
hist.Observe(time.Since(start).Seconds())
}()
return fn()
}
// -----------------------------------------------------------------------------
// CartActor Service Implementation
// -----------------------------------------------------------------------------
type cartActorService struct {
proto.UnimplementedCartActorServer
pool GrainPool
}
func newCartActorService(pool GrainPool) *cartActorService {
return &cartActorService{pool: pool}
}
func (s *cartActorService) Mutate(ctx context.Context, req *proto.MutationRequest) (*proto.MutationReply, error) {
var reply *proto.MutationReply
err := timeTrack(grpcMutateDuration, func() error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is nil")
}
if req.CartId == "" {
return status.Error(codes.InvalidArgument, "cart_id is empty")
}
mt := uint16(req.Type.Number())
handler, ok := Handlers[mt]
if !ok {
return status.Errorf(codes.InvalidArgument, "unknown mutation type %d", mt)
}
content, err := handler.Read(req.Payload)
if err != nil {
return status.Errorf(codes.InvalidArgument, "decode payload: %v", err)
}
ts := req.ClientTimestamp
if ts == 0 {
ts = time.Now().Unix()
}
msg := Message{
Type: mt,
TimeStamp: &ts,
Content: content,
}
frame, err := s.pool.Process(ToCartId(req.CartId), msg)
if err != nil {
return err
}
reply = &proto.MutationReply{
StatusCode: int32(frame.StatusCode),
Payload: frame.Payload,
}
return nil
})
if err != nil {
grpcMutateErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *cartActorService) GetState(ctx context.Context, req *proto.StateRequest) (*proto.StateReply, error) {
var reply *proto.StateReply
err := timeTrack(grpcStateDuration, func() error {
if req == nil || req.CartId == "" {
return status.Error(codes.InvalidArgument, "cart_id is empty")
}
frame, err := s.pool.Get(ToCartId(req.CartId))
if err != nil {
return err
}
reply = &proto.StateReply{
StatusCode: int32(frame.StatusCode),
Payload: frame.Payload,
}
return nil
})
if err != nil {
return nil, err
}
return reply, nil
}
// -----------------------------------------------------------------------------
// ControlPlane Service Implementation
// -----------------------------------------------------------------------------
// controlPlaneService directly leverages SyncedPool internals (same package).
// NOTE: This is a transitional adapter; once the legacy frame-based code is
// removed, related fields/methods in SyncedPool can be slimmed.
type controlPlaneService struct {
proto.UnimplementedControlPlaneServer
pool *SyncedPool
}
func newControlPlaneService(pool *SyncedPool) *controlPlaneService {
return &controlPlaneService{pool: pool}
}
func (s *controlPlaneService) Ping(ctx context.Context, _ *proto.Empty) (*proto.PingReply, error) {
var reply *proto.PingReply
err := timeTrack(grpcControlDuration, func() error {
reply = &proto.PingReply{
Host: s.pool.Hostname,
UnixTime: time.Now().Unix(),
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) Negotiate(ctx context.Context, req *proto.NegotiateRequest) (*proto.NegotiateReply, error) {
var reply *proto.NegotiateReply
err := timeTrack(grpcControlDuration, func() error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is nil")
}
// Add unknown hosts
for _, host := range req.KnownHosts {
if host == "" || host == s.pool.Hostname {
continue
}
if !s.pool.IsKnown(host) {
go s.pool.AddRemote(host)
}
}
// Build healthy host list
hosts := make([]string, 0)
for _, r := range s.pool.GetHealthyRemotes() {
hosts = append(hosts, r.Host)
}
hosts = append(hosts, s.pool.Hostname)
reply = &proto.NegotiateReply{
Hosts: hosts,
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) GetCartIds(ctx context.Context, _ *proto.Empty) (*proto.CartIdsReply, error) {
var reply *proto.CartIdsReply
err := timeTrack(grpcControlDuration, func() error {
s.pool.mu.RLock()
defer s.pool.mu.RUnlock()
ids := make([]string, 0, len(s.pool.local.grains))
for id, g := range s.pool.local.grains {
if g == nil {
continue
}
if id.String() == "" {
continue
}
ids = append(ids, id.String())
}
reply = &proto.CartIdsReply{
CartIds: ids,
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) ConfirmOwner(ctx context.Context, req *proto.OwnerChangeRequest) (*proto.OwnerChangeAck, error) {
var reply *proto.OwnerChangeAck
err := timeTrack(grpcControlDuration, func() error {
if req == nil || req.CartId == "" || req.NewHost == "" {
return status.Error(codes.InvalidArgument, "cart_id or new_host missing")
}
id := ToCartId(req.CartId)
newHost := req.NewHost
// Mirror GrainOwnerChangeHandler semantics
log.Printf("gRPC ConfirmOwner: cart %s newHost=%s", id, newHost)
for _, r := range s.pool.remoteHosts {
if r.Host == newHost && r.IsHealthy() {
go s.pool.SpawnRemoteGrain(id, newHost)
break
}
}
go s.pool.AddRemote(newHost)
reply = &proto.OwnerChangeAck{
Accepted: true,
Message: "ok",
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) Closing(ctx context.Context, notice *proto.ClosingNotice) (*proto.OwnerChangeAck, error) {
var reply *proto.OwnerChangeAck
err := timeTrack(grpcControlDuration, func() error {
if notice == nil || notice.Host == "" {
return status.Error(codes.InvalidArgument, "host missing")
}
host := notice.Host
s.pool.mu.RLock()
_, exists := s.pool.remoteHosts[host]
s.pool.mu.RUnlock()
if exists {
go s.pool.RemoveHost(host)
}
reply = &proto.OwnerChangeAck{
Accepted: true,
Message: "removed",
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
// -----------------------------------------------------------------------------
// Server Bootstrap
// -----------------------------------------------------------------------------
type GRPCServer struct {
server *grpc.Server
lis net.Listener
addr string
}
// StartGRPCServer sets up a gRPC server hosting both CartActor and ControlPlane services.
// addr example: ":1337" (for combined) OR run two servers if you want separate ports.
// For the migration we can host both on the same listener to reduce open ports.
func StartGRPCServer(addr string, pool GrainPool, synced *SyncedPool, opts ...grpc.ServerOption) (*GRPCServer, error) {
if pool == nil {
return nil, errors.New("nil grain pool")
}
if synced == nil {
return nil, errors.New("nil synced pool")
}
lis, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen %s: %w", addr, err)
}
grpcServer := grpc.NewServer(opts...)
proto.RegisterCartActorServer(grpcServer, newCartActorService(pool))
proto.RegisterControlPlaneServer(grpcServer, newControlPlaneService(synced))
go func() {
log.Printf("gRPC server listening on %s", addr)
if serveErr := grpcServer.Serve(lis); serveErr != nil {
log.Printf("gRPC server stopped: %v", serveErr)
}
}()
return &GRPCServer{
server: grpcServer,
lis: lis,
addr: addr,
}, nil
}
// GracefulStop stops the server gracefully.
func (s *GRPCServer) GracefulStop() {
if s == nil || s.server == nil {
return
}
s.server.GracefulStop()
}
// Addr returns the bound address.
func (s *GRPCServer) Addr() string {
if s == nil {
return ""
}
return s.addr
}
// -----------------------------------------------------------------------------
// Client Dial Helpers (used later by refactored remote grain + control plane)
// -----------------------------------------------------------------------------
// DialRemote establishes (or reuses externally) a gRPC client connection.
func DialRemote(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
dialOpts := []grpc.DialOption{
grpc.WithInsecure(), // NOTE: Intentional for initial migration; replace with TLS / mTLS later.
grpc.WithBlock(),
}
dialOpts = append(dialOpts, opts...)
ctxDial, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctxDial, target, dialOpts...)
if err != nil {
return nil, err
}
return conn, nil
}
// -----------------------------------------------------------------------------
// Utility for converting internal errors to gRPC status (if needed later).
// -----------------------------------------------------------------------------
func grpcError(err error) error {
if err == nil {
return nil
}
// Extend mapping if we add richer error types.
return status.Error(codes.Internal, err.Error())
}
+133
View File
@@ -0,0 +1,133 @@
package customerauth
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestPasswordRoundTrip(t *testing.T) {
h, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("hash: %v", err)
}
if !VerifyPassword("correct horse battery staple", h) {
t.Fatal("correct password did not verify")
}
if VerifyPassword("wrong", h) {
t.Fatal("wrong password verified")
}
}
func TestPasswordHashesAreSalted(t *testing.T) {
a, _ := HashPassword("hunter2hunter2")
b, _ := HashPassword("hunter2hunter2")
if a == b {
t.Fatal("two hashes of the same password are identical — not salted")
}
}
func TestVerifyRejectsMalformed(t *testing.T) {
for _, bad := range []string{"", "x", "pbkdf2-sha256$abc$salt$hash", "md5$1$a$b"} {
if VerifyPassword("whatever", bad) {
t.Fatalf("malformed hash %q verified", bad)
}
}
}
func TestSessionRoundTrip(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.Issue(12345, time.Hour)
id, err := s.Parse(tok)
if err != nil {
t.Fatalf("parse: %v", err)
}
if id != 12345 {
t.Fatalf("got id %d, want 12345", id)
}
}
func TestSessionExpired(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.Issue(1, -time.Second)
if _, err := s.Parse(tok); err != ErrExpiredToken {
t.Fatalf("got %v, want ErrExpiredToken", err)
}
}
func TestSessionTamperAndWrongKey(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.Issue(7, time.Hour)
if _, err := s.Parse(tok + "x"); err != ErrInvalidToken {
t.Fatalf("tampered token: got %v, want ErrInvalidToken", err)
}
other := NewSigner([]byte("different-secret"))
if _, err := other.Parse(tok); err != ErrInvalidToken {
t.Fatalf("wrong key: got %v, want ErrInvalidToken", err)
}
}
func TestStoreRegisterDuplicateAndReload(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.json")
store, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("load: %v", err)
}
ctx := context.Background()
if err := store.Register(ctx, "User@Example.com", 42, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err)
}
// Duplicate (case-insensitive) is rejected.
if err := store.Register(ctx, "user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
}
// Reload from disk and confirm the record persisted.
reloaded, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
rec, ok := reloaded.Get(ctx, "USER@EXAMPLE.COM")
if !ok {
t.Fatal("record not found after reload")
}
if rec.ProfileID != 42 || rec.Hash != "hash1" {
t.Fatalf("unexpected record: %+v", rec)
}
}
func TestStoreDelete(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.json")
store, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("load: %v", err)
}
ctx := context.Background()
if err := store.Register(ctx, "user@example.com", 42, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err)
}
// Delete
ok, err := store.Delete(ctx, "USER@example.com")
if err != nil {
t.Fatalf("delete failed: %v", err)
}
if !ok {
t.Fatal("expected delete to report email was found")
}
// Confirm not found
if _, ok := store.Get(ctx, "user@example.com"); ok {
t.Fatal("record still exists after delete")
}
// Reload from disk and verify it's gone
reloaded, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if _, ok := reloaded.Get(ctx, "user@example.com"); ok {
t.Fatal("record still exists after reload")
}
}
+115
View File
@@ -0,0 +1,115 @@
package customerauth
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestPurposeTokenRoundTrip(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.IssuePurpose(purposePasswordReset, "user@example.com", time.Hour)
sub, err := s.ParsePurpose(purposePasswordReset, tok)
if err != nil {
t.Fatalf("parse: %v", err)
}
if sub != "user@example.com" {
t.Fatalf("got subject %q, want user@example.com", sub)
}
}
func TestPurposeTokenWrongPurposeRejected(t *testing.T) {
s := NewSigner([]byte("test-secret"))
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
// A verify token must not be accepted as a reset token.
if _, err := s.ParsePurpose(purposePasswordReset, tok); err != ErrInvalidToken {
t.Fatalf("got %v, want ErrInvalidToken", err)
}
}
func TestPurposeTokenExpiredAndTampered(t *testing.T) {
s := NewSigner([]byte("test-secret"))
expired := s.IssuePurpose(purposeVerifyEmail, "user@example.com", -time.Second)
if _, err := s.ParsePurpose(purposeVerifyEmail, expired); err != ErrExpiredToken {
t.Fatalf("expired: got %v, want ErrExpiredToken", err)
}
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
if _, err := s.ParsePurpose(purposeVerifyEmail, tok+"x"); err != ErrInvalidToken {
t.Fatalf("tampered: got %v, want ErrInvalidToken", err)
}
}
func TestLoginLimiterLocksOutAndResets(t *testing.T) {
ctx := context.Background()
l := NewLoginLimiter(3, time.Minute)
const key = "user@example.com"
for i := range 3 {
if ok, _ := l.Allowed(ctx, key); !ok {
t.Fatalf("locked out early after %d failures", i)
}
l.RecordFailure(ctx, key)
}
ok, retry := l.Allowed(ctx, key)
if ok {
t.Fatal("expected lockout after 3 failures")
}
if retry <= 0 {
t.Fatalf("expected positive retry-after, got %v", retry)
}
// A successful auth clears the history.
l.Reset(ctx, key)
if ok, _ := l.Allowed(ctx, key); !ok {
t.Fatal("expected reset to clear lockout")
}
}
func TestLoginLimiterNilIsNoop(t *testing.T) {
ctx := context.Background()
var l *LoginLimiter
if ok, _ := l.Allowed(ctx, "x"); !ok {
t.Fatal("nil limiter should allow")
}
l.RecordFailure(ctx, "x") // must not panic
l.Reset(ctx, "x") // must not panic
}
func TestStoreMarkVerifiedAndUpdateHash(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "credentials.json")
store, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if err := store.Register(ctx, "u@example.com", 1, "hash1", "ts"); err != nil {
t.Fatalf("register: %v", err)
}
// Unknown email is a no-op, not an error.
if found, err := store.MarkVerified(ctx, "missing@example.com", "now"); err != nil || found {
t.Fatalf("MarkVerified(missing) = (%v, %v), want (false, nil)", found, err)
}
if found, err := store.MarkVerified(ctx, "U@EXAMPLE.COM", "2026-01-01T00:00:00Z"); err != nil || !found {
t.Fatalf("MarkVerified = (%v, %v), want (true, nil)", found, err)
}
if found, err := store.UpdateHash(ctx, "u@example.com", "hash2"); err != nil || !found {
t.Fatalf("UpdateHash = (%v, %v), want (true, nil)", found, err)
}
// Both changes survive a reload from disk.
reloaded, err := LoadCredentialStore(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
rec, ok := reloaded.Get(ctx, "u@example.com")
if !ok {
t.Fatal("record missing after reload")
}
if rec.Hash != "hash2" {
t.Fatalf("hash = %q, want hash2", rec.Hash)
}
if rec.VerifiedAt == "" {
t.Fatal("verifiedAt not persisted")
}
}
+24
View File
@@ -0,0 +1,24 @@
package customerauth
import "log"
// Notifier delivers transactional auth messages (email verification and
// password reset). The auth server only builds the links; delivery is pluggable
// so this is the seam where a real mailer — or the planned notification service
// (commerce-maturity plan, section C3) — gets wired in. The default LogNotifier
// just logs the link, which is enough for local development.
type Notifier interface {
SendEmailVerification(email, link string)
SendPasswordReset(email, link string)
}
// LogNotifier writes the links to the standard logger instead of sending them.
type LogNotifier struct{}
func (LogNotifier) SendEmailVerification(email, link string) {
log.Printf("customerauth: email verification for %s: %s", email, link)
}
func (LogNotifier) SendPasswordReset(email, link string) {
log.Printf("customerauth: password reset for %s: %s", email, link)
}
+76
View File
@@ -0,0 +1,76 @@
// Package customerauth adds password-based signup/login to the profile service.
//
// It deliberately uses only the standard library: PBKDF2-HMAC-SHA256 for
// password hashing (crypto/pbkdf2, Go 1.24+) and HMAC-SHA256-signed cookies for
// sessions (crypto/hmac). No password ever leaves the server in plaintext and
// the session token is opaque + tamper-evident.
package customerauth
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
)
// pbkdf2Iterations is the work factor for PBKDF2-HMAC-SHA256. 210_000 matches
// the current OWASP recommendation for PBKDF2-SHA256.
const pbkdf2Iterations = 210_000
const (
saltLen = 16
keyLen = 32
)
// HashPassword returns an encoded PBKDF2 hash of the form
//
// pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>
//
// with a fresh random salt. The encoded string is self-describing so Verify can
// read the parameters back without external configuration.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("customerauth: read salt: %w", err)
}
dk, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, keyLen)
if err != nil {
return "", fmt.Errorf("customerauth: pbkdf2: %w", err)
}
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
pbkdf2Iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(dk),
), nil
}
// VerifyPassword reports whether password matches the encoded hash. It is
// constant-time in the hash comparison and returns false for any malformed
// encoding rather than erroring, so callers can treat it as a simple predicate.
func VerifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
if err != nil {
return false
}
return subtle.ConstantTimeCompare(got, want) == 1
}
+104
View File
@@ -0,0 +1,104 @@
package customerauth
import (
"context"
"sync"
"time"
)
// Limiter throttles repeated failures for a key (login email or "reset:"-prefixed
// email). It is satisfied by the in-memory LoginLimiter (single-instance / dev)
// and by RedisLoginLimiter (shared, horizontally scalable).
type Limiter interface {
// Allowed reports whether an attempt for key may proceed, and when locked the
// remaining lockout duration (for a Retry-After header).
Allowed(ctx context.Context, key string) (bool, time.Duration)
// RecordFailure registers a failed attempt for key.
RecordFailure(ctx context.Context, key string)
// Reset clears the failure history for key (call on a successful auth).
Reset(ctx context.Context, key string)
}
// LoginLimiter is an in-memory failed-attempt limiter that locks a key out after
// too many failures inside a rolling window. It is per-process — matching the
// credential store's single-instance scope; a horizontally-scaled deployment
// would need a shared backing store. Keys are typically the normalized email
// (login) or a "reset:"-prefixed email (reset requests).
type LoginLimiter struct {
mu sync.Mutex
attempts map[string]*attemptState
max int // failures allowed in the window before lockout
window time.Duration // rolling window and lockout duration
}
type attemptState struct {
count int
first time.Time
lockedUntil time.Time
}
// NewLoginLimiter builds a limiter allowing max failures per window before a
// lockout of window duration. Zero/negative values fall back to 5 per 15m.
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
if max <= 0 {
max = 5
}
if window <= 0 {
window = 15 * time.Minute
}
return &LoginLimiter{attempts: make(map[string]*attemptState), max: max, window: window}
}
// Allowed reports whether an attempt for key may proceed. When locked it also
// returns the remaining lockout duration (suitable for a Retry-After header). A
// nil limiter always allows (disabled).
func (l *LoginLimiter) Allowed(_ context.Context, key string) (bool, time.Duration) {
if l == nil {
return true, 0
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.attempts[key]
if st == nil {
return true, 0
}
if now.Before(st.lockedUntil) {
return false, st.lockedUntil.Sub(now)
}
// Window elapsed since the first failure: forget the history.
if now.Sub(st.first) > l.window {
delete(l.attempts, key)
}
return true, 0
}
// RecordFailure registers a failed attempt for key, locking it for window once
// max failures accumulate inside the window.
func (l *LoginLimiter) RecordFailure(_ context.Context, key string) {
if l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.attempts[key]
if st == nil || now.Sub(st.first) > l.window {
l.attempts[key] = &attemptState{count: 1, first: now}
return
}
st.count++
if st.count >= l.max {
st.lockedUntil = now.Add(l.window)
}
}
// Reset clears the failure history for key. Call it on a successful auth.
func (l *LoginLimiter) Reset(_ context.Context, key string) {
if l == nil {
return
}
l.mu.Lock()
delete(l.attempts, key)
l.mu.Unlock()
}
+215
View File
@@ -0,0 +1,215 @@
package customerauth
import (
"context"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// Redis-backed implementations of Credentials and Limiter. They let the profile
// service run horizontally: every replica reads/writes the same credential
// records and shares one failed-attempt counter, instead of each holding its own
// file map and in-process counter. Session and verify/reset tokens are already
// stateless HMAC values, so Redis + a shared CUSTOMER_AUTH_SECRET is all that
// the auth surface needs to scale out.
const (
credKeyPrefix = "customerauth:cred:" // hash per normalized email
failKeyPrefix = "customerauth:fail:" // failure counter per key
lockKeyPrefix = "customerauth:lock:" // lockout marker per key
)
// Compile-time guarantees that both backings satisfy the server's interfaces.
var (
_ Credentials = (*CredentialStore)(nil)
_ Credentials = (*RedisCredentialStore)(nil)
_ Limiter = (*LoginLimiter)(nil)
_ Limiter = (*RedisLoginLimiter)(nil)
)
// ---------------------------------------------------------------------------
// RedisCredentialStore
// ---------------------------------------------------------------------------
// RedisCredentialStore stores each credential as a Redis hash at
// "customerauth:cred:<normalized-email>". Register/MarkVerified/UpdateHash are
// atomic via small Lua scripts so concurrent replicas can't race a duplicate
// registration or a lost update.
type RedisCredentialStore struct {
client *redis.Client
luaRegister *redis.Script
luaVerified *redis.Script
luaUpdHash *redis.Script
}
// registerScript creates the hash only if it does not already exist, returning 1
// on create and 0 if the email is taken.
const registerScript = `
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
redis.call('HSET', KEYS[1], 'email', ARGV[1], 'profileId', ARGV[2], 'hash', ARGV[3], 'createdAt', ARGV[4])
return 1`
// markVerifiedScript sets verifiedAt only when missing. Returns -1 if the email
// is unknown, 1 otherwise (whether it set the field now or was already verified).
const markVerifiedScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end
local v = redis.call('HGET', KEYS[1], 'verifiedAt')
if v and v ~= '' then return 1 end
redis.call('HSET', KEYS[1], 'verifiedAt', ARGV[1])
return 1`
// updateHashScript replaces the password hash. Returns 0 if unknown, 1 on update.
const updateHashScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
redis.call('HSET', KEYS[1], 'hash', ARGV[1])
return 1`
// NewRedisCredentialStore builds a Redis-backed credential store and verifies
// connectivity with a ping.
func NewRedisCredentialStore(ctx context.Context, client *redis.Client) (*RedisCredentialStore, error) {
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return &RedisCredentialStore{
client: client,
luaRegister: redis.NewScript(registerScript),
luaVerified: redis.NewScript(markVerifiedScript),
luaUpdHash: redis.NewScript(updateHashScript),
}, nil
}
func (s *RedisCredentialStore) Get(ctx context.Context, email string) (Record, bool) {
key := credKeyPrefix + NormalizeEmail(email)
m, err := s.client.HGetAll(ctx, key).Result()
if err != nil {
log.Printf("customerauth: redis Get(%s): %v", key, err)
return Record{}, false
}
if len(m) == 0 {
return Record{}, false
}
pid, _ := strconv.ParseUint(m["profileId"], 10, 64)
return Record{
Email: m["email"],
ProfileID: pid,
Hash: m["hash"],
CreatedAt: m["createdAt"],
VerifiedAt: m["verifiedAt"],
}, true
}
func (s *RedisCredentialStore) Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error {
norm := NormalizeEmail(email)
created, err := s.luaRegister.Run(ctx, s.client,
[]string{credKeyPrefix + norm},
norm, strconv.FormatUint(profileID, 10), hash, createdAt,
).Int()
if err != nil {
return err
}
if created == 0 {
return ErrEmailExists
}
return nil
}
func (s *RedisCredentialStore) MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error) {
res, err := s.luaVerified.Run(ctx, s.client,
[]string{credKeyPrefix + NormalizeEmail(email)}, verifiedAt,
).Int()
if err != nil {
return false, err
}
return res >= 0, nil // -1 means unknown email
}
func (s *RedisCredentialStore) UpdateHash(ctx context.Context, email, hash string) (bool, error) {
res, err := s.luaUpdHash.Run(ctx, s.client,
[]string{credKeyPrefix + NormalizeEmail(email)}, hash,
).Int()
if err != nil {
return false, err
}
return res == 1, nil
}
func (s *RedisCredentialStore) Delete(ctx context.Context, email string) (bool, error) {
key := credKeyPrefix + NormalizeEmail(email)
res, err := s.client.Del(ctx, key).Result()
if err != nil {
return false, err
}
return res > 0, nil
}
// ---------------------------------------------------------------------------
// RedisLoginLimiter
// ---------------------------------------------------------------------------
// RedisLoginLimiter is the shared-storage counterpart of LoginLimiter: the
// failure counter and lockout marker live in Redis with native TTLs, so the
// window/lockout are enforced across all replicas. It fails open (allows the
// attempt) on a Redis error so an outage degrades to "no rate limiting" rather
// than locking every customer out.
type RedisLoginLimiter struct {
client *redis.Client
luaRecord *redis.Script
max int
window time.Duration
}
// recordFailureScript increments the failure counter (setting the window TTL on
// the first failure) and, once it reaches max, writes a lockout marker with the
// same TTL.
const recordFailureScript = `
local cnt = redis.call('INCR', KEYS[1])
if cnt == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if cnt >= tonumber(ARGV[1]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[2]) end
return cnt`
// NewRedisLoginLimiter builds a Redis-backed limiter. Zero/negative values fall
// back to 5 failures per 15 minutes (matching the in-memory default).
func NewRedisLoginLimiter(client *redis.Client, max int, window time.Duration) *RedisLoginLimiter {
if max <= 0 {
max = 5
}
if window <= 0 {
window = 15 * time.Minute
}
return &RedisLoginLimiter{
client: client,
luaRecord: redis.NewScript(recordFailureScript),
max: max,
window: window,
}
}
func (l *RedisLoginLimiter) Allowed(ctx context.Context, key string) (bool, time.Duration) {
ttl, err := l.client.PTTL(ctx, lockKeyPrefix+key).Result()
if err != nil {
log.Printf("customerauth: redis limiter Allowed(%s): %v", key, err)
return true, 0 // fail open
}
if ttl > 0 {
return false, ttl
}
return true, 0
}
func (l *RedisLoginLimiter) RecordFailure(ctx context.Context, key string) {
if err := l.luaRecord.Run(ctx, l.client,
[]string{failKeyPrefix + key, lockKeyPrefix + key},
strconv.Itoa(l.max), strconv.Itoa(int(l.window.Seconds())),
).Err(); err != nil {
log.Printf("customerauth: redis limiter RecordFailure(%s): %v", key, err)
}
}
func (l *RedisLoginLimiter) Reset(ctx context.Context, key string) {
if err := l.client.Del(ctx, failKeyPrefix+key, lockKeyPrefix+key).Err(); err != nil {
log.Printf("customerauth: redis limiter Reset(%s): %v", key, err)
}
}
+585
View File
@@ -0,0 +1,585 @@
package customerauth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/mail"
"net/url"
"strconv"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
"google.golang.org/protobuf/proto"
)
// minPasswordLen is the minimum accepted password length at registration and
// password reset.
const minPasswordLen = 8
// Token lifetimes for the email-verification and password-reset links.
const (
verifyTokenTTL = 24 * time.Hour
resetTokenTTL = 1 * time.Hour
)
// Storefront paths (appended to Options.BaseURL) that the verification and reset
// links point at. The page reads the token from the query string and POSTs it
// back to /verify or /reset.
const (
verifyLinkPath = "/account/verify"
resetLinkPath = "/account/reset"
)
// ProfileApplier is the subset of the profile grain pool the auth server needs.
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
type ProfileApplier interface {
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
}
// Server exposes password signup/login, email verification, password reset and
// identity-linking over HTTP, backed by the credential store (email→id+hash) and
// the profile grain pool.
type Server struct {
store Credentials
applier ProfileApplier
signer *Signer
ttl time.Duration
limiter Limiter
notifier Notifier
baseURL string
requireVerified bool
}
// Options configures optional auth-server behavior. The zero value is valid: an
// in-memory login limiter (5 failures / 15m), a logging notifier, and no
// verified-email requirement for login.
type Options struct {
// Limiter throttles failed logins and reset requests. Nil installs a default
// in-memory limiter (5 failures / 15m). Inject a RedisLoginLimiter for a
// horizontally-scaled deployment.
Limiter Limiter
// Notifier delivers verification and reset links. Nil installs LogNotifier.
Notifier Notifier
// BaseURL is the public origin used to build links in messages, e.g.
// "https://shop.tornberg.me". The verify/reset paths are appended to it.
BaseURL string
// RequireVerifiedEmail, when true, blocks login until the email is verified.
// Off by default so pre-existing accounts (which carry no verification
// record) keep working.
RequireVerifiedEmail bool
}
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
if ttl <= 0 {
ttl = DefaultSessionTTL
}
if opts.Limiter == nil {
opts.Limiter = NewLoginLimiter(0, 0)
}
if opts.Notifier == nil {
opts.Notifier = LogNotifier{}
}
return &Server{
store: store,
applier: applier,
signer: signer,
ttl: ttl,
limiter: opts.Limiter,
notifier: opts.Notifier,
baseURL: strings.TrimRight(opts.BaseURL, "/"),
requireVerified: opts.RequireVerifiedEmail,
}
}
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
// (the prefix is stripped by the caller).
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /me", s.handleMe)
mux.HandleFunc("POST /verify-request", s.handleVerifyRequest)
mux.HandleFunc("POST /verify", s.handleVerify)
mux.HandleFunc("POST /reset-request", s.handleResetRequest)
mux.HandleFunc("POST /reset", s.handleResetComplete)
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
return mux
}
// ---------------------------------------------------------------------------
// Request/response payloads
// ---------------------------------------------------------------------------
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name,omitempty"`
Phone string `json:"phone,omitempty"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type linkCartRequest struct {
CartID string `json:"cartId"`
}
type linkCheckoutRequest struct {
CheckoutID string `json:"checkoutId"`
CartID string `json:"cartId"`
}
type linkOrderRequest struct {
OrderReference string `json:"orderReference"`
CartID string `json:"cartId"`
Status string `json:"status,omitempty"`
}
// tokenRequest carries a verification token.
type tokenRequest struct {
Token string `json:"token"`
}
// resetRequest asks for a password-reset link to be sent to an email.
type resetRequest struct {
Email string `json:"email"`
}
// resetCompleteRequest carries a reset token and the new password.
type resetCompleteRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
// import cycle with internal/ucp). The password hash is never included.
type CustomerResponse struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Addresses []AddressResponse `json:"addresses"`
Orders []OrderRef `json:"orders"`
// EmailVerified reflects whether the email-verification flow has completed.
EmailVerified bool `json:"emailVerified"`
}
type AddressResponse struct {
ID uint32 `json:"id"`
Label string `json:"label,omitempty"`
FullName string `json:"fullName,omitempty"`
AddressLine1 string `json:"addressLine1"`
AddressLine2 string `json:"addressLine2,omitempty"`
City string `json:"city"`
Zip string `json:"zip"`
Country string `json:"country"`
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
}
type OrderRef struct {
OrderReference string `json:"orderReference"`
Status string `json:"status,omitempty"`
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
email := NormalizeEmail(req.Email)
if !validEmail(email) {
writeErr(w, http.StatusBadRequest, "a valid email is required")
return
}
if len(req.Password) < minPasswordLen {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if _, exists := s.store.Get(r.Context(), email); exists {
writeErr(w, http.StatusConflict, "an account with this email already exists")
return
}
hash, err := HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not hash password")
return
}
rawID, err := profile.NewProfileId()
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not generate id")
return
}
id := uint64(rawID)
set := &messages.SetProfile{Email: &email}
if req.Name != "" {
set.Name = &req.Name
}
if req.Phone != "" {
set.Phone = &req.Phone
}
if _, err := s.applier.Apply(r.Context(), id, set); err != nil {
writeErr(w, http.StatusInternalServerError, "could not create profile")
return
}
// Persist the credential only after the profile is created. If this fails
// the profile exists but is unreachable by login — acceptable and rare.
if err := s.store.Register(r.Context(), email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
if err == ErrEmailExists {
writeErr(w, http.StatusConflict, "an account with this email already exists")
return
}
writeErr(w, http.StatusInternalServerError, "could not store credentials")
return
}
// Send the verification link (best-effort, via the configured notifier).
s.sendVerification(email)
s.issueSession(w, r, id)
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
key := NormalizeEmail(req.Email)
if ok, retry := s.limiter.Allowed(r.Context(), key); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
return
}
rec, ok := s.store.Get(r.Context(), req.Email)
// Always run a verify (even on miss, against the stored hash if present) and
// return a single generic error so the response does not reveal whether the
// email exists.
if !ok || !VerifyPassword(req.Password, rec.Hash) {
s.limiter.RecordFailure(r.Context(), key)
writeErr(w, http.StatusUnauthorized, "invalid email or password")
return
}
if s.requireVerified && rec.VerifiedAt == "" {
writeErr(w, http.StatusForbidden, "email not verified")
return
}
s.limiter.Reset(r.Context(), key)
s.issueSession(w, r, rec.ProfileID)
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
ClearSession(w, r)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
// handleVerifyRequest re-sends the email-verification link for the logged-in
// customer. Requires a session so it can't be used to enumerate addresses.
func (s *Server) handleVerifyRequest(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil || g.Email == "" {
writeErr(w, http.StatusInternalServerError, "could not read profile")
return
}
s.sendVerification(NormalizeEmail(g.Email))
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
}
// handleVerify consumes a verification token and marks the email verified. An
// unknown subject still returns success so the endpoint reveals nothing.
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
var req tokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
email, err := s.signer.ParsePurpose(purposeVerifyEmail, req.Token)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid or expired token")
return
}
if _, err := s.store.MarkVerified(r.Context(), email, time.Now().UTC().Format(time.RFC3339)); err != nil {
writeErr(w, http.StatusInternalServerError, "could not record verification")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
}
// handleResetRequest issues a password-reset link for a registered email. It
// always returns 200 (whether or not the email exists) so it never reveals
// account existence, and is rate-limited per email to prevent mail-bombing.
func (s *Server) handleResetRequest(w http.ResponseWriter, r *http.Request) {
var req resetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
email := NormalizeEmail(req.Email)
limiterKey := "reset:" + email
if ok, retry := s.limiter.Allowed(r.Context(), limiterKey); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
writeErr(w, http.StatusTooManyRequests, "too many requests, try again later")
return
}
if _, exists := s.store.Get(r.Context(), email); exists {
token := s.signer.IssuePurpose(purposePasswordReset, email, resetTokenTTL)
s.notifier.SendPasswordReset(email, s.link(resetLinkPath, token))
}
s.limiter.RecordFailure(r.Context(), limiterKey)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleResetComplete consumes a reset token and replaces the password hash.
func (s *Server) handleResetComplete(w http.ResponseWriter, r *http.Request) {
var req resetCompleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Password) < minPasswordLen {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
email, err := s.signer.ParsePurpose(purposePasswordReset, req.Token)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid or expired token")
return
}
hash, err := HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not hash password")
return
}
found, err := s.store.UpdateHash(r.Context(), email, hash)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not update password")
return
}
if !found {
// Token signed for an email no longer present.
writeErr(w, http.StatusBadRequest, "invalid or expired token")
return
}
s.limiter.Reset(r.Context(), NormalizeEmail(email))
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
var req linkCartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
cartID, ok := parseBase62(req.CartID)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid cartId")
return
}
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCart{CartId: cartID, Label: "current cart"}); err != nil {
writeErr(w, http.StatusInternalServerError, "could not link cart")
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
func (s *Server) handleLinkCheckout(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
var req linkCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
checkoutID, ok := parseBase62(req.CheckoutID)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid checkoutId")
return
}
cartID, _ := parseBase62(req.CartID)
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCheckout{CheckoutId: checkoutID, CartId: cartID}); err != nil {
writeErr(w, http.StatusInternalServerError, "could not link checkout")
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
func (s *Server) handleLinkOrder(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireSession(w, r)
if !ok {
return
}
var req linkOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.OrderReference) == "" {
writeErr(w, http.StatusBadRequest, "orderReference is required")
return
}
cartID, _ := parseBase62(req.CartID)
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkOrder{
OrderReference: req.OrderReference,
CartId: cartID,
Status: req.Status,
}); err != nil {
writeErr(w, http.StatusInternalServerError, "could not link order")
return
}
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64) {
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
}
// sendVerification mints a verification token for email and hands the link to
// the notifier. email must already be normalized.
func (s *Server) sendVerification(email string) {
token := s.signer.IssuePurpose(purposeVerifyEmail, email, verifyTokenTTL)
s.notifier.SendEmailVerification(email, s.link(verifyLinkPath, token))
}
// link builds an absolute link to a storefront path carrying the token as a
// query parameter. With no configured BaseURL it returns a relative link.
func (s *Server) link(path, token string) string {
return fmt.Sprintf("%s%s?token=%s", s.baseURL, path, url.QueryEscape(token))
}
// requireSession reads and validates the session cookie, writing 401 on
// failure. It returns the profile id and whether a valid session was present.
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
c, err := r.Cookie(SessionCookieName)
if err != nil || c.Value == "" {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return 0, false
}
id, err := s.signer.Parse(c.Value)
if err != nil {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return 0, false
}
return id, true
}
// writeCustomer loads the profile grain and writes it as a CustomerResponse.
func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status int, idStr string, id uint64) {
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not read profile")
return
}
resp := grainToCustomer(idStr, g)
if rec, ok := s.store.Get(r.Context(), g.Email); ok {
resp.EmailVerified = rec.VerifiedAt != ""
}
writeJSON(w, status, resp)
}
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
resp := CustomerResponse{
ID: id,
Name: g.Name,
Email: g.Email,
Phone: g.Phone,
Language: g.Language,
Currency: g.Currency,
AvatarURL: g.AvatarUrl,
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
Orders: make([]OrderRef, 0, len(g.Orders)),
}
for _, a := range g.Addresses {
resp.Addresses = append(resp.Addresses, AddressResponse{
ID: a.Id,
Label: a.Label,
FullName: a.FullName,
AddressLine1: a.AddressLine1,
AddressLine2: a.AddressLine2,
City: a.City,
Zip: a.Zip,
Country: a.Country,
IsDefaultShipping: a.IsDefaultShipping,
IsDefaultBilling: a.IsDefaultBilling,
})
}
for _, o := range g.Orders {
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
}
return resp
}
// parseBase62 decodes a base62 id (cart/checkout/profile share the scheme).
func parseBase62(s string) (uint64, bool) {
if s == "" {
return 0, false
}
id, ok := profile.ParseProfileId(s)
return uint64(id), ok
}
func validEmail(email string) bool {
if email == "" {
return false
}
_, err := mail.ParseAddress(email)
return err == nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
+123
View File
@@ -0,0 +1,123 @@
package customerauth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// SessionCookieName is the name of the HttpOnly cookie that carries the signed
// session token for a logged-in customer.
const SessionCookieName = "sid"
// DefaultSessionTTL is how long an issued session stays valid.
const DefaultSessionTTL = 30 * 24 * time.Hour
var (
// ErrInvalidToken is returned when a token is malformed or its signature
// does not verify.
ErrInvalidToken = errors.New("customerauth: invalid session token")
// ErrExpiredToken is returned when a token's signature is valid but it has
// passed its expiry.
ErrExpiredToken = errors.New("customerauth: expired session token")
)
// Signer issues and verifies session tokens using an HMAC-SHA256 key.
type Signer struct {
secret []byte
}
// NewSigner returns a Signer over the given secret key bytes.
func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} }
// token format: base64url(<profileID>.<expUnix>) "." base64url(hmac)
// The payload is signed, not encrypted — it carries no secrets, only the
// profile id and expiry, and the HMAC makes it tamper-evident.
// Issue returns a signed token for profileID that expires after ttl.
func (s *Signer) Issue(profileID uint64, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
payload := fmt.Sprintf("%d.%d", profileID, exp)
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
return b + "." + s.sign(b)
}
// Parse verifies a token and returns the profile id it carries. It returns
// ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired.
func (s *Signer) Parse(token string) (uint64, error) {
b, sig, ok := strings.Cut(token, ".")
if !ok || b == "" || sig == "" {
return 0, ErrInvalidToken
}
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
return 0, ErrInvalidToken
}
raw, err := base64.RawURLEncoding.DecodeString(b)
if err != nil {
return 0, ErrInvalidToken
}
idStr, expStr, ok := strings.Cut(string(raw), ".")
if !ok {
return 0, ErrInvalidToken
}
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return 0, ErrInvalidToken
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return 0, ErrInvalidToken
}
if time.Now().Unix() >= exp {
return 0, ErrExpiredToken
}
return id, nil
}
func (s *Signer) sign(b string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(b))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// SetSession writes the session cookie for token onto w. The Secure flag is set
// when the request arrived over TLS (directly or via an https-terminating
// proxy) so the cookie still works on plain-http localhost in dev.
func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(ttl),
MaxAge: int(ttl.Seconds()),
})
}
// ClearSession expires the session cookie on w.
func ClearSession(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: isSecure(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func isSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
+191
View File
@@ -0,0 +1,191 @@
package customerauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// ErrEmailExists is returned by Register when the (normalized) email is already
// associated with a credential record.
var ErrEmailExists = errors.New("customerauth: email already registered")
// Credentials is the email→credential index the auth server depends on. It is
// satisfied by the file-backed CredentialStore (single-instance / dev) and by
// RedisCredentialStore (shared, horizontally scalable). All methods normalize
// the email key internally.
type Credentials interface {
// Get returns the record for email and whether it exists. A backing-store
// failure is reported as "not found" so callers fail closed.
Get(ctx context.Context, email string) (Record, bool)
// Register adds a new credential, returning ErrEmailExists if taken.
Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error
// MarkVerified records email verification. The bool reports whether the
// email was known; an unknown email is not an error (avoids enumeration).
MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error)
// UpdateHash replaces the password hash. The bool reports whether the email
// was known; an unknown email is not an error.
UpdateHash(ctx context.Context, email, hash string) (bool, error)
// Delete removes a credential record by email. The bool reports whether the email
// was known.
Delete(ctx context.Context, email string) (bool, error)
}
// Record is a single stored credential: which profile an email belongs to and
// its password hash. It deliberately does not embed any profile data — the
// profile grain remains the source of truth for that.
type Record struct {
Email string `json:"email"`
ProfileID uint64 `json:"profileId"`
Hash string `json:"hash"`
CreatedAt string `json:"createdAt,omitempty"`
VerifiedAt string `json:"verifiedAt,omitempty"`
}
// CredentialStore is a small email→credential index persisted to a JSON file.
// It is the email lookup that the event-sourced profile grains intentionally
// lack. Concurrency-safe; writes are atomic (temp file + rename).
type CredentialStore struct {
mu sync.RWMutex
path string
byMail map[string]Record // key: normalized email
}
// LoadCredentialStore opens (or initializes) the store backed by path. A
// missing file is treated as an empty store; the file is created on first write.
func LoadCredentialStore(path string) (*CredentialStore, error) {
s := &CredentialStore{path: path, byMail: make(map[string]Record)}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
return nil, fmt.Errorf("customerauth: read %s: %w", path, err)
}
var records []Record
if len(data) > 0 {
if err := json.Unmarshal(data, &records); err != nil {
return nil, fmt.Errorf("customerauth: parse %s: %w", path, err)
}
}
for _, r := range records {
s.byMail[NormalizeEmail(r.Email)] = r
}
return s, nil
}
// NormalizeEmail lower-cases and trims an email for use as a lookup key.
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// Get returns the record for email and whether it exists. The ctx is accepted
// for interface parity with the Redis store; the file store ignores it.
func (s *CredentialStore) Get(_ context.Context, email string) (Record, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byMail[NormalizeEmail(email)]
return r, ok
}
// Register adds a new credential record and persists the store. It returns
// ErrEmailExists if the email is already taken.
func (s *CredentialStore) Register(_ context.Context, email string, profileID uint64, hash, createdAt string) error {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.byMail[key]; exists {
return ErrEmailExists
}
s.byMail[key] = Record{Email: key, ProfileID: profileID, Hash: hash, CreatedAt: createdAt}
return s.persistLocked()
}
// MarkVerified records that email completed email verification and persists the
// store. It is a no-op if already verified. The bool reports whether the email
// was known; an unknown email is not an error (callers avoid leaking which
// emails exist).
func (s *CredentialStore) MarkVerified(_ context.Context, email, verifiedAt string) (bool, error) {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.byMail[key]
if !ok {
return false, nil
}
if rec.VerifiedAt != "" {
return true, nil
}
rec.VerifiedAt = verifiedAt
s.byMail[key] = rec
return true, s.persistLocked()
}
// UpdateHash replaces the stored password hash for email and persists the store.
// The bool reports whether the email was known; an unknown email is not an error.
func (s *CredentialStore) UpdateHash(_ context.Context, email, hash string) (bool, error) {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.byMail[key]
if !ok {
return false, nil
}
rec.Hash = hash
s.byMail[key] = rec
return true, s.persistLocked()
}
// Delete removes the credential record for email and persists the store.
// The bool reports whether the email was known.
func (s *CredentialStore) Delete(_ context.Context, email string) (bool, error) {
key := NormalizeEmail(email)
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byMail[key]; !ok {
return false, nil
}
delete(s.byMail, key)
return true, s.persistLocked()
}
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
func (s *CredentialStore) persistLocked() error {
records := make([]Record, 0, len(s.byMail))
for _, r := range s.byMail {
records = append(records, r)
}
data, err := json.MarshalIndent(records, "", " ")
if err != nil {
return fmt.Errorf("customerauth: marshal store: %w", err)
}
if dir := filepath.Dir(s.path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("customerauth: mkdir %s: %w", dir, err)
}
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".credentials-*.tmp")
if err != nil {
return fmt.Errorf("customerauth: temp file: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("customerauth: write temp: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("customerauth: close temp: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("customerauth: rename temp: %w", err)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package customerauth
import (
"crypto/hmac"
"encoding/base64"
"strconv"
"strings"
"time"
)
// Purpose-scoped tokens (email verification, password reset) reuse the session
// Signer's HMAC secret but bind a purpose + subject, so a token minted for one
// flow cannot be replayed in another. Format:
//
// base64url(<purpose> NUL <subject> NUL <expUnix>) "." base64url(hmac)
//
// subject is the normalized email. Like session tokens these are signed, not
// encrypted: they carry no secret, only a purpose, an email and an expiry, and
// the HMAC makes them tamper-evident.
const (
purposeVerifyEmail = "verify"
purposePasswordReset = "reset"
)
// IssuePurpose returns a signed, purpose-bound token for subject that expires
// after ttl.
func (s *Signer) IssuePurpose(purpose, subject string, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
payload := purpose + "\x00" + subject + "\x00" + strconv.FormatInt(exp, 10)
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
return b + "." + s.sign(b)
}
// ParsePurpose verifies a purpose token and returns its subject. It requires the
// embedded purpose to match want and the token to be unexpired, returning
// ErrInvalidToken for a bad signature/format/purpose and ErrExpiredToken when
// the token has expired.
func (s *Signer) ParsePurpose(want, token string) (string, error) {
b, sig, ok := strings.Cut(token, ".")
if !ok || b == "" || sig == "" {
return "", ErrInvalidToken
}
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
return "", ErrInvalidToken
}
raw, err := base64.RawURLEncoding.DecodeString(b)
if err != nil {
return "", ErrInvalidToken
}
parts := strings.Split(string(raw), "\x00")
if len(parts) != 3 || parts[0] != want {
return "", ErrInvalidToken
}
exp, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", ErrInvalidToken
}
if time.Now().Unix() >= exp {
return "", ErrExpiredToken
}
return parts[1], nil
}

Some files were not shown because too many files have changed in this diff Show More