refactor
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s

This commit is contained in:
2026-06-29 12:34:58 +02:00
parent d711348863
commit 46be260fcc
35 changed files with 1765 additions and 198 deletions
+3 -6
View File
@@ -10,8 +10,8 @@ import (
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
"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"
)
@@ -41,10 +41,7 @@ func testServer(t *testing.T) *server {
applier := &orderedApplier{pool: pool, storage: storage}
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, nil)
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")
@@ -60,7 +57,7 @@ func testServer(t *testing.T) *server {
return &server{
pool: pool, applier: applier, storage: storage, reg: reg,
engine: engine, freg: freg,
flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}},
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)),
}
+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
}
+5 -8
View File
@@ -18,9 +18,9 @@ import (
// 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"`
CheckoutId string `json:"checkoutId"`
IdempotencyKey string `json:"idempotencyKey"`
Flow string `json:"flow,omitempty"`
CartId string `json:"cartId"`
Currency string `json:"currency"`
@@ -120,11 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, s.applier, provider)
order.RegisterEmitHook(freg, nil)
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
@@ -178,6 +174,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
TotalAmount: lineTotal,
TotalTax: lineTax,
Location: l.Location,
DropShip: l.DropShip,
})
}
po.TotalAmount = total
+75 -23
View File
@@ -12,6 +12,7 @@ import (
"log"
"log/slog"
"net/http"
"net/mail"
"os"
"path/filepath"
"strconv"
@@ -36,19 +37,20 @@ import (
)
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
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() {
@@ -101,6 +103,18 @@ func main() {
// 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
@@ -138,13 +152,7 @@ func main() {
}
}
}
freg := flow.NewRegistry()
flow.RegisterBuiltinHooks(freg)
order.RegisterFlowActions(freg, applier, provider)
order.RegisterEmitHook(freg, emitPub)
// order.created domain event → durable outbox → "order" topic exchange.
// Reactors: inventory commit, fulfillment allocation.
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, logger)
engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay")
@@ -171,6 +179,7 @@ func main() {
engine: engine, freg: freg, flows: flows, provider: provider,
taxProvider: taxProvider,
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
inventoryReservations: inventoryReservations,
}
mux := http.NewServeMux()
@@ -276,8 +285,9 @@ type lineReq struct {
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%)
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
}
@@ -384,6 +394,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
TaxRate: l.TaxRate,
TotalAmount: lineTotal,
TotalTax: lineTax,
DropShip: l.DropShip,
})
}
po.TotalAmount = total
@@ -502,7 +513,6 @@ func selectTaxProvider() tax.Provider {
// 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 == "" {
@@ -515,6 +525,48 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
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 {