callbacks service draft

This commit is contained in:
Stephan D
2026-02-28 10:10:26 +01:00
parent b7900d3beb
commit 0f28f2d088
71 changed files with 5212 additions and 446 deletions

View File

@@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
entrypoint = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go", "_templ.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

3
api/edge/callbacks/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
app
.gocache
tmp

View File

@@ -0,0 +1,64 @@
runtime:
shutdown_timeout_seconds: 15
metrics:
address: ":9420"
database:
driver: mongodb
settings:
host_env: CALLBACKS_MONGO_HOST
port_env: CALLBACKS_MONGO_PORT
database_env: CALLBACKS_MONGO_DATABASE
user_env: CALLBACKS_MONGO_USER
password_env: CALLBACKS_MONGO_PASSWORD
auth_source_env: CALLBACKS_MONGO_AUTH_SOURCE
replica_set_env: CALLBACKS_MONGO_REPLICA_SET
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: Edge Callbacks Service
max_reconnects: 10
reconnect_wait: 5
buffer_size: 1024
ingest:
stream: CALLBACKS
subject: callbacks.events
durable: callbacks-ingest
batch_size: 32
fetch_timeout_ms: 2000
idle_sleep_ms: 500
delivery:
worker_concurrency: 8
worker_poll_ms: 200
lock_ttl_seconds: 30
request_timeout_ms: 10000
max_attempts: 8
min_delay_ms: 1000
max_delay_ms: 300000
jitter_ratio: 0.2
security:
require_https: true
allowed_hosts: []
allowed_ports: [443]
dns_resolve_timeout_ms: 2000
secrets:
cache_ttl_seconds: 60
static: {}
vault:
address: "http://dev-vault:8200"
token_env: VAULT_TOKEN
namespace: ""
mount_path: kv
default_field: value

View File

@@ -0,0 +1,63 @@
runtime:
shutdown_timeout_seconds: 15
metrics:
address: ":9420"
database:
driver: mongodb
settings:
host_env: CALLBACKS_MONGO_HOST
port_env: CALLBACKS_MONGO_PORT
database_env: CALLBACKS_MONGO_DATABASE
user_env: CALLBACKS_MONGO_USER
password_env: CALLBACKS_MONGO_PASSWORD
auth_source_env: CALLBACKS_MONGO_AUTH_SOURCE
replica_set_env: CALLBACKS_MONGO_REPLICA_SET
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: Edge Callbacks Service
max_reconnects: 10
reconnect_wait: 5
buffer_size: 1024
ingest:
stream: CALLBACKS
subject: callbacks.events
durable: callbacks-ingest
batch_size: 32
fetch_timeout_ms: 2000
idle_sleep_ms: 500
delivery:
worker_concurrency: 8
worker_poll_ms: 200
lock_ttl_seconds: 30
request_timeout_ms: 10000
max_attempts: 8
min_delay_ms: 1000
max_delay_ms: 300000
jitter_ratio: 0.2
security:
require_https: true
allowed_hosts: []
allowed_ports: [443]
dns_resolve_timeout_ms: 2000
secrets:
cache_ttl_seconds: 60
static: {}
vault:
address: "https://vault.sendico.io"
token_env: VAULT_TOKEN
namespace: ""
mount_path: kv
default_field: value

View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
if [ -n "${VAULT_TOKEN_FILE:-}" ] && [ -f "${VAULT_TOKEN_FILE}" ]; then
token="$(cat "${VAULT_TOKEN_FILE}" 2>/dev/null | tr -d '[:space:]')"
if [ -n "${token}" ]; then
export VAULT_TOKEN="${token}"
fi
fi
if [ -z "${VAULT_TOKEN:-}" ]; then
echo "[entrypoint] VAULT_TOKEN is not set; expected Vault Agent sink to write a token to ${VAULT_TOKEN_FILE:-/run/vault/token}" >&2
fi
exec "$@"

64
api/edge/callbacks/go.mod Normal file
View File

@@ -0,0 +1,64 @@
module github.com/tech/sendico/edge/callbacks
go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/nats-io/nats.go v1.49.0
github.com/prometheus/client_golang v1.23.2
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/hashicorp/vault/api v1.22.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

256
api/edge/callbacks/go.sum Normal file
View File

@@ -0,0 +1,256 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ=
github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/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/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
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/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
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/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,28 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
info := version.Info{
Program: "Sendico Edge Callbacks Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -0,0 +1,182 @@
package config
import (
"time"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/messaging"
)
const (
defaultShutdownTimeoutSeconds = 15
defaultMetricsAddress = ":9420"
defaultIngestStream = "CALLBACKS"
defaultIngestSubject = "callbacks.events"
defaultIngestDurable = "callbacks-ingest"
defaultIngestBatchSize = 32
defaultIngestFetchTimeoutMS = 2000
defaultIngestIdleSleepMS = 500
defaultTaskCollection = "callback_tasks"
defaultInboxCollection = "callback_inbox"
defaultEndpointsCollection = "webhook_endpoints"
defaultWorkerConcurrency = 8
defaultWorkerPollIntervalMS = 200
defaultLockTTLSeconds = 30
defaultRequestTimeoutMS = 10000
defaultMaxAttempts = 8
defaultMinDelayMS = 1000
defaultMaxDelayMS = 300000
defaultJitterRatio = 0.20
defaultDNSResolveTimeoutMS = 2000
defaultSecretsVaultField = "value"
)
// Loader parses callbacks service configuration.
type Loader interface {
Load(path string) (*Config, error)
}
// Config is the full callbacks service configuration.
type Config struct {
Runtime *RuntimeConfig `yaml:"runtime"`
Metrics *MetricsConfig `yaml:"metrics"`
Database *db.Config `yaml:"database"`
Messaging *messaging.Config `yaml:"messaging"`
Ingest IngestConfig `yaml:"ingest"`
Delivery DeliveryConfig `yaml:"delivery"`
Security SecurityConfig `yaml:"security"`
Secrets SecretsConfig `yaml:"secrets"`
}
// RuntimeConfig contains process lifecycle settings.
type RuntimeConfig struct {
ShutdownTimeoutSeconds int `yaml:"shutdown_timeout_seconds"`
}
func (c *RuntimeConfig) ShutdownTimeout() time.Duration {
if c == nil || c.ShutdownTimeoutSeconds <= 0 {
return defaultShutdownTimeoutSeconds * time.Second
}
return time.Duration(c.ShutdownTimeoutSeconds) * time.Second
}
// MetricsConfig configures observability endpoints.
type MetricsConfig struct {
Address string `yaml:"address"`
}
func (c *MetricsConfig) ListenAddress() string {
if c == nil || c.Address == "" {
return defaultMetricsAddress
}
return c.Address
}
// IngestConfig configures JetStream ingestion.
type IngestConfig struct {
Stream string `yaml:"stream"`
Subject string `yaml:"subject"`
Durable string `yaml:"durable"`
BatchSize int `yaml:"batch_size"`
FetchTimeoutMS int `yaml:"fetch_timeout_ms"`
IdleSleepMS int `yaml:"idle_sleep_ms"`
}
func (c *IngestConfig) FetchTimeout() time.Duration {
if c.FetchTimeoutMS <= 0 {
return time.Duration(defaultIngestFetchTimeoutMS) * time.Millisecond
}
return time.Duration(c.FetchTimeoutMS) * time.Millisecond
}
func (c *IngestConfig) IdleSleep() time.Duration {
if c.IdleSleepMS <= 0 {
return time.Duration(defaultIngestIdleSleepMS) * time.Millisecond
}
return time.Duration(c.IdleSleepMS) * time.Millisecond
}
// DeliveryConfig controls dispatcher behavior.
type DeliveryConfig struct {
WorkerConcurrency int `yaml:"worker_concurrency"`
WorkerPollMS int `yaml:"worker_poll_ms"`
LockTTLSeconds int `yaml:"lock_ttl_seconds"`
RequestTimeoutMS int `yaml:"request_timeout_ms"`
MaxAttempts int `yaml:"max_attempts"`
MinDelayMS int `yaml:"min_delay_ms"`
MaxDelayMS int `yaml:"max_delay_ms"`
JitterRatio float64 `yaml:"jitter_ratio"`
}
func (c *DeliveryConfig) WorkerPollInterval() time.Duration {
if c.WorkerPollMS <= 0 {
return time.Duration(defaultWorkerPollIntervalMS) * time.Millisecond
}
return time.Duration(c.WorkerPollMS) * time.Millisecond
}
func (c *DeliveryConfig) LockTTL() time.Duration {
if c.LockTTLSeconds <= 0 {
return time.Duration(defaultLockTTLSeconds) * time.Second
}
return time.Duration(c.LockTTLSeconds) * time.Second
}
func (c *DeliveryConfig) RequestTimeout() time.Duration {
if c.RequestTimeoutMS <= 0 {
return time.Duration(defaultRequestTimeoutMS) * time.Millisecond
}
return time.Duration(c.RequestTimeoutMS) * time.Millisecond
}
func (c *DeliveryConfig) MinDelay() time.Duration {
if c.MinDelayMS <= 0 {
return time.Duration(defaultMinDelayMS) * time.Millisecond
}
return time.Duration(c.MinDelayMS) * time.Millisecond
}
func (c *DeliveryConfig) MaxDelay() time.Duration {
if c.MaxDelayMS <= 0 {
return time.Duration(defaultMaxDelayMS) * time.Millisecond
}
return time.Duration(c.MaxDelayMS) * time.Millisecond
}
// SecurityConfig controls outbound callback safety checks.
type SecurityConfig struct {
RequireHTTPS bool `yaml:"require_https"`
AllowedHosts []string `yaml:"allowed_hosts"`
AllowedPorts []int `yaml:"allowed_ports"`
DNSResolveTimeout int `yaml:"dns_resolve_timeout_ms"`
}
func (c *SecurityConfig) DNSResolveTimeoutMS() time.Duration {
if c.DNSResolveTimeout <= 0 {
return time.Duration(defaultDNSResolveTimeoutMS) * time.Millisecond
}
return time.Duration(c.DNSResolveTimeout) * time.Millisecond
}
// SecretsConfig controls secret lookup behavior.
type SecretsConfig struct {
CacheTTLSeconds int `yaml:"cache_ttl_seconds"`
Static map[string]string `yaml:"static"`
Vault VaultSecretsConfig `yaml:"vault"`
}
// VaultSecretsConfig controls Vault KV secret resolution.
type VaultSecretsConfig struct {
Address string `yaml:"address"`
TokenEnv string `yaml:"token_env"`
Namespace string `yaml:"namespace"`
MountPath string `yaml:"mount_path"`
DefaultField string `yaml:"default_field"`
}
func (c *SecretsConfig) CacheTTL() time.Duration {
if c == nil || c.CacheTTLSeconds <= 0 {
return 0
}
return time.Duration(c.CacheTTLSeconds) * time.Second
}

View File

@@ -0,0 +1,162 @@
package config
import (
"os"
"strings"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type service struct {
logger mlogger.Logger
}
// New creates a configuration loader.
func New(logger mlogger.Logger) Loader {
if logger == nil {
logger = zap.NewNop()
}
return &service{logger: logger.Named("config")}
}
func (s *service) Load(path string) (*Config, error) {
if strings.TrimSpace(path) == "" {
return nil, merrors.InvalidArgument("config path is required", "path")
}
data, err := os.ReadFile(path)
if err != nil {
s.logger.Error("Failed to read config file", zap.String("path", path), zap.Error(err))
return nil, merrors.InternalWrap(err, "failed to read callbacks config")
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
s.logger.Error("Failed to parse config yaml", zap.String("path", path), zap.Error(err))
return nil, merrors.InternalWrap(err, "failed to parse callbacks config")
}
s.applyDefaults(cfg)
if err := s.validate(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func (s *service) applyDefaults(cfg *Config) {
if cfg.Runtime == nil {
cfg.Runtime = &RuntimeConfig{ShutdownTimeoutSeconds: defaultShutdownTimeoutSeconds}
}
if cfg.Metrics == nil {
cfg.Metrics = &MetricsConfig{Address: defaultMetricsAddress}
} else if strings.TrimSpace(cfg.Metrics.Address) == "" {
cfg.Metrics.Address = defaultMetricsAddress
}
if strings.TrimSpace(cfg.Ingest.Stream) == "" {
cfg.Ingest.Stream = defaultIngestStream
}
if strings.TrimSpace(cfg.Ingest.Subject) == "" {
cfg.Ingest.Subject = defaultIngestSubject
}
if strings.TrimSpace(cfg.Ingest.Durable) == "" {
cfg.Ingest.Durable = defaultIngestDurable
}
if cfg.Ingest.BatchSize <= 0 {
cfg.Ingest.BatchSize = defaultIngestBatchSize
}
if cfg.Ingest.FetchTimeoutMS <= 0 {
cfg.Ingest.FetchTimeoutMS = defaultIngestFetchTimeoutMS
}
if cfg.Ingest.IdleSleepMS <= 0 {
cfg.Ingest.IdleSleepMS = defaultIngestIdleSleepMS
}
if cfg.Delivery.WorkerConcurrency <= 0 {
cfg.Delivery.WorkerConcurrency = defaultWorkerConcurrency
}
if cfg.Delivery.WorkerPollMS <= 0 {
cfg.Delivery.WorkerPollMS = defaultWorkerPollIntervalMS
}
if cfg.Delivery.LockTTLSeconds <= 0 {
cfg.Delivery.LockTTLSeconds = defaultLockTTLSeconds
}
if cfg.Delivery.RequestTimeoutMS <= 0 {
cfg.Delivery.RequestTimeoutMS = defaultRequestTimeoutMS
}
if cfg.Delivery.MaxAttempts <= 0 {
cfg.Delivery.MaxAttempts = defaultMaxAttempts
}
if cfg.Delivery.MinDelayMS <= 0 {
cfg.Delivery.MinDelayMS = defaultMinDelayMS
}
if cfg.Delivery.MaxDelayMS <= 0 {
cfg.Delivery.MaxDelayMS = defaultMaxDelayMS
}
if cfg.Delivery.JitterRatio <= 0 {
cfg.Delivery.JitterRatio = defaultJitterRatio
}
if cfg.Delivery.JitterRatio > 1 {
cfg.Delivery.JitterRatio = 1
}
if cfg.Security.DNSResolveTimeout <= 0 {
cfg.Security.DNSResolveTimeout = defaultDNSResolveTimeoutMS
}
if len(cfg.Security.AllowedPorts) == 0 {
cfg.Security.AllowedPorts = []int{443}
}
if !cfg.Security.RequireHTTPS {
cfg.Security.RequireHTTPS = true
}
if cfg.Secrets.Static == nil {
cfg.Secrets.Static = map[string]string{}
}
if strings.TrimSpace(cfg.Secrets.Vault.DefaultField) == "" {
cfg.Secrets.Vault.DefaultField = defaultSecretsVaultField
}
}
func (s *service) validate(cfg *Config) error {
if cfg.Database == nil {
return merrors.InvalidArgument("database configuration is required", "database")
}
if cfg.Messaging == nil {
return merrors.InvalidArgument("messaging configuration is required", "messaging")
}
if strings.TrimSpace(string(cfg.Messaging.Driver)) == "" {
return merrors.InvalidArgument("messaging.driver is required", "messaging.driver")
}
if cfg.Delivery.MinDelay() > cfg.Delivery.MaxDelay() {
return merrors.InvalidArgument("delivery min delay must be <= max delay", "delivery.min_delay_ms", "delivery.max_delay_ms")
}
if cfg.Delivery.MaxAttempts < 1 {
return merrors.InvalidArgument("delivery.max_attempts must be > 0", "delivery.max_attempts")
}
if cfg.Ingest.BatchSize < 1 {
return merrors.InvalidArgument("ingest.batch_size must be > 0", "ingest.batch_size")
}
vaultAddress := strings.TrimSpace(cfg.Secrets.Vault.Address)
vaultTokenEnv := strings.TrimSpace(cfg.Secrets.Vault.TokenEnv)
vaultMountPath := strings.TrimSpace(cfg.Secrets.Vault.MountPath)
hasVault := vaultAddress != "" || vaultTokenEnv != "" || vaultMountPath != ""
if hasVault {
if vaultAddress == "" {
return merrors.InvalidArgument("secrets.vault.address is required when vault settings are configured", "secrets.vault.address")
}
if vaultTokenEnv == "" {
return merrors.InvalidArgument("secrets.vault.token_env is required when vault settings are configured", "secrets.vault.token_env")
}
if vaultMountPath == "" {
return merrors.InvalidArgument("secrets.vault.mount_path is required when vault settings are configured", "secrets.vault.mount_path")
}
}
return nil
}

View File

@@ -0,0 +1,27 @@
package delivery
import "net/http"
type outcome string
const (
outcomeDelivered outcome = "delivered"
outcomeRetry outcome = "retry"
outcomeFailed outcome = "failed"
)
func classify(statusCode int, reqErr error) outcome {
if reqErr != nil {
return outcomeRetry
}
if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
return outcomeDelivered
}
if statusCode == http.StatusTooManyRequests || statusCode == http.StatusRequestTimeout {
return outcomeRetry
}
if statusCode >= http.StatusInternalServerError {
return outcomeRetry
}
return outcomeFailed
}

View File

@@ -0,0 +1,48 @@
package delivery
import (
"context"
"time"
"github.com/tech/sendico/edge/callbacks/internal/retry"
"github.com/tech/sendico/edge/callbacks/internal/security"
"github.com/tech/sendico/edge/callbacks/internal/signing"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/pkg/mlogger"
)
// Observer captures delivery metrics.
type Observer interface {
ObserveDelivery(result string, statusCode int, duration time.Duration)
}
// Config controls delivery worker runtime.
type Config struct {
WorkerConcurrency int
WorkerPoll time.Duration
LockTTL time.Duration
RequestTimeout time.Duration
JitterRatio float64
}
// Dependencies configure delivery dispatcher.
type Dependencies struct {
Logger mlogger.Logger
Config Config
Tasks storage.TaskRepo
Retry retry.Policy
Security security.Validator
Signer signing.Signer
Observer Observer
}
// Service executes callback delivery tasks.
type Service interface {
Start(ctx context.Context)
Stop()
}
// New creates delivery service.
func New(deps Dependencies) (Service, error) {
return newService(deps)
}

View File

@@ -0,0 +1,263 @@
package delivery
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strconv"
"sync"
"time"
"github.com/tech/sendico/edge/callbacks/internal/signing"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
const responseDrainLimit = 64 * 1024
type service struct {
logger mlogger.Logger
cfg Config
tasks storage.TaskRepo
retry interface {
NextAttempt(attempt int, now time.Time, minDelay, maxDelay time.Duration, jitterRatio float64) time.Time
}
security interface {
ValidateURL(ctx context.Context, target string) error
}
signer signing.Signer
obs Observer
client *http.Client
cancel context.CancelFunc
once sync.Once
stop sync.Once
wg sync.WaitGroup
}
func newService(deps Dependencies) (Service, error) {
if deps.Tasks == nil {
return nil, merrors.InvalidArgument("delivery: task repo is required", "tasks")
}
if deps.Retry == nil {
return nil, merrors.InvalidArgument("delivery: retry policy is required", "retry")
}
if deps.Security == nil {
return nil, merrors.InvalidArgument("delivery: security validator is required", "security")
}
if deps.Signer == nil {
return nil, merrors.InvalidArgument("delivery: signer is required", "signer")
}
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
cfg := deps.Config
if cfg.WorkerConcurrency <= 0 {
cfg.WorkerConcurrency = 1
}
if cfg.WorkerPoll <= 0 {
cfg.WorkerPoll = 200 * time.Millisecond
}
if cfg.LockTTL <= 0 {
cfg.LockTTL = 30 * time.Second
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 10 * time.Second
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: time.Second,
}
client := &http.Client{
Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
return &service{
logger: logger.Named("delivery"),
cfg: cfg,
tasks: deps.Tasks,
retry: deps.Retry,
security: deps.Security,
signer: deps.Signer,
obs: deps.Observer,
client: client,
}, nil
}
func (s *service) Start(ctx context.Context) {
s.once.Do(func() {
runCtx := ctx
if runCtx == nil {
runCtx = context.Background()
}
runCtx, s.cancel = context.WithCancel(runCtx)
for i := 0; i < s.cfg.WorkerConcurrency; i++ {
workerID := "worker-" + strconv.Itoa(i+1)
s.wg.Add(1)
go func(id string) {
defer s.wg.Done()
s.runWorker(runCtx, id)
}(workerID)
}
s.logger.Info("Delivery workers started", zap.Int("workers", s.cfg.WorkerConcurrency))
})
}
func (s *service) Stop() {
s.stop.Do(func() {
if s.cancel != nil {
s.cancel()
}
s.wg.Wait()
s.logger.Info("Delivery workers stopped")
})
}
func (s *service) runWorker(ctx context.Context, workerID string) {
for {
select {
case <-ctx.Done():
return
default:
}
now := time.Now().UTC()
task, err := s.tasks.LockNextTask(ctx, now, workerID, s.cfg.LockTTL)
if err != nil {
s.logger.Warn("Failed to lock next task", zap.String("worker_id", workerID), zap.Error(err))
time.Sleep(s.cfg.WorkerPoll)
continue
}
if task == nil {
time.Sleep(s.cfg.WorkerPoll)
continue
}
s.handleTask(ctx, workerID, task)
}
}
func (s *service) handleTask(ctx context.Context, workerID string, task *storage.Task) {
started := time.Now()
statusCode := 0
result := "failed"
attempt := task.Attempt + 1
defer func() {
if s.obs != nil {
s.obs.ObserveDelivery(result, statusCode, time.Since(started))
}
}()
if err := s.security.ValidateURL(ctx, task.EndpointURL); err != nil {
result = "blocked"
_ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC())
return
}
timeout := task.RequestTimeout
if timeout <= 0 {
timeout = s.cfg.RequestTimeout
}
signed, err := s.signer.Sign(ctx, task.SigningMode, task.SecretRef, task.Payload, time.Now().UTC())
if err != nil {
result = "sign_error"
_ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC())
return
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, task.EndpointURL, bytes.NewReader(signed.Body))
if err != nil {
result = "request_error"
_ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC())
return
}
req.Header.Set("Content-Type", "application/json")
for key, val := range task.Headers {
req.Header.Set(key, val)
}
for key, val := range signed.Headers {
req.Header.Set(key, val)
}
resp, reqErr := s.client.Do(req)
if resp != nil {
statusCode = resp.StatusCode
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, responseDrainLimit))
_ = resp.Body.Close()
}
out := classify(statusCode, reqErr)
now := time.Now().UTC()
switch out {
case outcomeDelivered:
result = string(outcomeDelivered)
if err := s.tasks.MarkDelivered(ctx, task.ID, statusCode, time.Since(started), now); err != nil {
s.logger.Warn("Failed to mark task delivered", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
}
case outcomeRetry:
if attempt < task.MaxAttempts {
next := s.retry.NextAttempt(attempt, now, task.MinDelay, task.MaxDelay, s.cfg.JitterRatio)
result = string(outcomeRetry)
lastErr := stringifyErr(reqErr)
if reqErr == nil && statusCode > 0 {
lastErr = "upstream returned retryable status"
}
if err := s.tasks.MarkRetry(ctx, task.ID, attempt, next, lastErr, statusCode, now); err != nil {
s.logger.Warn("Failed to mark task retry", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
}
} else {
result = string(outcomeFailed)
lastErr := stringifyErr(reqErr)
if reqErr == nil && statusCode > 0 {
lastErr = "upstream returned retryable status but max attempts reached"
}
if err := s.tasks.MarkFailed(ctx, task.ID, attempt, lastErr, statusCode, now); err != nil {
s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
}
}
default:
result = string(outcomeFailed)
lastErr := stringifyErr(reqErr)
if reqErr == nil && statusCode > 0 {
lastErr = "upstream returned non-retryable status"
}
if err := s.tasks.MarkFailed(ctx, task.ID, attempt, lastErr, statusCode, now); err != nil {
s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
}
}
}
func stringifyErr(err error) string {
if err == nil {
return ""
}
if errors.Is(err, context.Canceled) {
return "request canceled"
}
if errors.Is(err, context.DeadlineExceeded) {
return "request timeout"
}
return err.Error()
}

View File

@@ -0,0 +1,33 @@
package events
import (
"context"
"encoding/json"
"time"
)
// Envelope is the canonical incoming event envelope.
type Envelope struct {
EventID string `json:"event_id"`
Type string `json:"type"`
ClientID string `json:"client_id"`
OccurredAt time.Time `json:"occurred_at"`
PublishedAt time.Time `json:"published_at,omitempty"`
Data json.RawMessage `json:"data"`
}
// Service parses incoming messages and builds outbound payload bytes.
type Service interface {
Parse(data []byte) (*Envelope, error)
BuildPayload(ctx context.Context, envelope *Envelope) ([]byte, error)
}
// Payload is the stable outbound JSON body.
type Payload struct {
EventID string `json:"event_id"`
Type string `json:"type"`
ClientID string `json:"client_id"`
OccurredAt string `json:"occurred_at"`
PublishedAt string `json:"published_at,omitempty"`
Data json.RawMessage `json:"data"`
}

View File

@@ -0,0 +1,86 @@
package events
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type parserService struct {
logger mlogger.Logger
}
// New creates event parser/payload builder service.
func New(logger mlogger.Logger) Service {
if logger == nil {
logger = zap.NewNop()
}
return &parserService{logger: logger.Named("events")}
}
func (s *parserService) Parse(data []byte) (*Envelope, error) {
if len(data) == 0 {
return nil, merrors.InvalidArgument("event payload is empty", "data")
}
var envelope Envelope
if err := json.Unmarshal(data, &envelope); err != nil {
return nil, merrors.InvalidArgumentWrap(err, "event payload is not valid JSON", "data")
}
if strings.TrimSpace(envelope.EventID) == "" {
return nil, merrors.InvalidArgument("event_id is required", "event_id")
}
if strings.TrimSpace(envelope.Type) == "" {
return nil, merrors.InvalidArgument("type is required", "type")
}
if strings.TrimSpace(envelope.ClientID) == "" {
return nil, merrors.InvalidArgument("client_id is required", "client_id")
}
if envelope.OccurredAt.IsZero() {
return nil, merrors.InvalidArgument("occurred_at is required", "occurred_at")
}
if len(envelope.Data) == 0 {
envelope.Data = []byte("{}")
}
envelope.EventID = strings.TrimSpace(envelope.EventID)
envelope.Type = strings.TrimSpace(envelope.Type)
envelope.ClientID = strings.TrimSpace(envelope.ClientID)
envelope.OccurredAt = envelope.OccurredAt.UTC()
if !envelope.PublishedAt.IsZero() {
envelope.PublishedAt = envelope.PublishedAt.UTC()
}
return &envelope, nil
}
func (s *parserService) BuildPayload(_ context.Context, envelope *Envelope) ([]byte, error) {
if envelope == nil {
return nil, merrors.InvalidArgument("event envelope is required", "envelope")
}
payload := Payload{
EventID: envelope.EventID,
Type: envelope.Type,
ClientID: envelope.ClientID,
OccurredAt: envelope.OccurredAt.UTC().Format(time.RFC3339Nano),
Data: envelope.Data,
}
if !envelope.PublishedAt.IsZero() {
payload.PublishedAt = envelope.PublishedAt.UTC().Format(time.RFC3339Nano)
}
data, err := json.Marshal(payload)
if err != nil {
s.logger.Warn("Failed to marshal callback payload", zap.Error(err), zap.String("event_id", envelope.EventID))
return nil, merrors.InternalWrap(err, "failed to marshal callback payload")
}
return data, nil
}

View File

@@ -0,0 +1,51 @@
package ingest
import (
"context"
"time"
"github.com/nats-io/nats.go"
"github.com/tech/sendico/edge/callbacks/internal/events"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/edge/callbacks/internal/subscriptions"
"github.com/tech/sendico/pkg/mlogger"
)
// Observer captures ingest metrics.
type Observer interface {
ObserveIngest(result string, duration time.Duration)
}
// Config contains JetStream ingest settings.
type Config struct {
Stream string
Subject string
Durable string
BatchSize int
FetchTimeout time.Duration
IdleSleep time.Duration
}
// Dependencies configure the ingest service.
type Dependencies struct {
Logger mlogger.Logger
JetStream nats.JetStreamContext
Config Config
Events events.Service
Resolver subscriptions.Resolver
InboxRepo storage.InboxRepo
TaskRepo storage.TaskRepo
TaskDefaults storage.TaskDefaults
Observer Observer
}
// Service runs JetStream ingest workers.
type Service interface {
Start(ctx context.Context)
Stop()
}
// New creates ingest service.
func New(deps Dependencies) (Service, error) {
return newService(deps)
}

View File

@@ -0,0 +1,204 @@
package ingest
import (
"context"
"errors"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type service struct {
logger mlogger.Logger
js nats.JetStreamContext
cfg Config
deps Dependencies
cancel context.CancelFunc
wg sync.WaitGroup
once sync.Once
stop sync.Once
}
func newService(deps Dependencies) (Service, error) {
if deps.JetStream == nil {
return nil, merrors.InvalidArgument("ingest: jetstream context is required", "jetstream")
}
if deps.Events == nil {
return nil, merrors.InvalidArgument("ingest: events service is required", "events")
}
if deps.Resolver == nil {
return nil, merrors.InvalidArgument("ingest: subscriptions resolver is required", "resolver")
}
if deps.InboxRepo == nil {
return nil, merrors.InvalidArgument("ingest: inbox repo is required", "inboxRepo")
}
if deps.TaskRepo == nil {
return nil, merrors.InvalidArgument("ingest: task repo is required", "taskRepo")
}
if strings.TrimSpace(deps.Config.Subject) == "" {
return nil, merrors.InvalidArgument("ingest: subject is required", "config.subject")
}
if strings.TrimSpace(deps.Config.Durable) == "" {
return nil, merrors.InvalidArgument("ingest: durable is required", "config.durable")
}
if deps.Config.BatchSize <= 0 {
deps.Config.BatchSize = 1
}
if deps.Config.FetchTimeout <= 0 {
deps.Config.FetchTimeout = 2 * time.Second
}
if deps.Config.IdleSleep <= 0 {
deps.Config.IdleSleep = 500 * time.Millisecond
}
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
return &service{
logger: logger.Named("ingest"),
js: deps.JetStream,
cfg: deps.Config,
deps: deps,
}, nil
}
func (s *service) Start(ctx context.Context) {
s.once.Do(func() {
runCtx := ctx
if runCtx == nil {
runCtx = context.Background()
}
runCtx, s.cancel = context.WithCancel(runCtx)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.run(runCtx)
}()
})
}
func (s *service) Stop() {
s.stop.Do(func() {
if s.cancel != nil {
s.cancel()
}
s.wg.Wait()
})
}
func (s *service) run(ctx context.Context) {
subOpts := []nats.SubOpt{}
if stream := strings.TrimSpace(s.cfg.Stream); stream != "" {
subOpts = append(subOpts, nats.BindStream(stream))
}
sub, err := s.js.PullSubscribe(strings.TrimSpace(s.cfg.Subject), strings.TrimSpace(s.cfg.Durable), subOpts...)
if err != nil {
s.logger.Error("Failed to start JetStream subscription", zap.String("subject", s.cfg.Subject), zap.String("durable", s.cfg.Durable), zap.Error(err))
return
}
s.logger.Info("Ingest consumer started", zap.String("subject", s.cfg.Subject), zap.String("durable", s.cfg.Durable), zap.Int("batch_size", s.cfg.BatchSize))
for {
select {
case <-ctx.Done():
s.logger.Info("Ingest consumer stopped")
return
default:
}
msgs, err := sub.Fetch(s.cfg.BatchSize, nats.MaxWait(s.cfg.FetchTimeout))
if err != nil {
if errors.Is(err, nats.ErrTimeout) {
time.Sleep(s.cfg.IdleSleep)
continue
}
if ctx.Err() != nil {
return
}
s.logger.Warn("Failed to fetch JetStream messages", zap.Error(err))
time.Sleep(s.cfg.IdleSleep)
continue
}
for _, msg := range msgs {
s.handleMessage(ctx, msg)
}
}
}
func (s *service) handleMessage(ctx context.Context, msg *nats.Msg) {
start := time.Now()
result := "ok"
nak := false
defer func() {
if s.deps.Observer != nil {
s.deps.Observer.ObserveIngest(result, time.Since(start))
}
var ackErr error
if nak {
ackErr = msg.Nak()
} else {
ackErr = msg.Ack()
}
if ackErr != nil {
s.logger.Warn("Failed to ack ingest message", zap.Bool("nak", nak), zap.Error(ackErr))
}
}()
envelope, err := s.deps.Events.Parse(msg.Data)
if err != nil {
result = "invalid_event"
nak = false
return
}
inserted, err := s.deps.InboxRepo.TryInsert(ctx, envelope.EventID, envelope.ClientID, envelope.Type, time.Now().UTC())
if err != nil {
result = "inbox_error"
nak = true
return
}
if !inserted {
result = "duplicate"
nak = false
return
}
endpoints, err := s.deps.Resolver.Resolve(ctx, envelope.ClientID, envelope.Type)
if err != nil {
result = "resolve_error"
nak = true
return
}
if len(endpoints) == 0 {
result = "no_endpoints"
nak = false
return
}
payload, err := s.deps.Events.BuildPayload(ctx, envelope)
if err != nil {
result = "payload_error"
nak = true
return
}
if err := s.deps.TaskRepo.UpsertTasks(ctx, envelope.EventID, endpoints, payload, s.deps.TaskDefaults, time.Now().UTC()); err != nil {
result = "task_error"
nak = true
return
}
}

View File

@@ -0,0 +1,36 @@
package ops
import (
"context"
"time"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/mlogger"
)
// Observer records service metrics.
type Observer interface {
ObserveIngest(result string, duration time.Duration)
ObserveDelivery(result string, statusCode int, duration time.Duration)
}
// HTTPServer exposes /metrics and /health.
type HTTPServer interface {
SetStatus(status health.ServiceStatus)
Close(ctx context.Context)
}
// HTTPServerConfig configures observability endpoint.
type HTTPServerConfig struct {
Address string
}
// NewObserver creates process metrics observer.
func NewObserver() Observer {
return newObserver()
}
// NewHTTPServer creates observability HTTP server.
func NewHTTPServer(logger mlogger.Logger, cfg HTTPServerConfig) (HTTPServer, error) {
return newHTTPServer(logger, cfg)
}

View File

@@ -0,0 +1,119 @@
package ops
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
const (
defaultAddress = ":9420"
readHeaderTimeout = 5 * time.Second
defaultShutdownWindow = 5 * time.Second
)
type httpServer struct {
logger mlogger.Logger
server *http.Server
health routers.Health
timeout time.Duration
}
func newHTTPServer(logger mlogger.Logger, cfg HTTPServerConfig) (HTTPServer, error) {
if logger == nil {
return nil, merrors.InvalidArgument("ops: logger is nil")
}
address := strings.TrimSpace(cfg.Address)
if address == "" {
address = defaultAddress
}
r := chi.NewRouter()
r.Handle("/metrics", promhttp.Handler())
metricsLogger := logger.Named("ops")
var healthRouter routers.Health
hr, err := routers.NewHealthRouter(metricsLogger, r, "")
if err != nil {
metricsLogger.Warn("Failed to initialise health router", zap.Error(err))
} else {
hr.SetStatus(health.SSStarting)
healthRouter = hr
}
httpSrv := &http.Server{
Addr: address,
Handler: r,
ReadHeaderTimeout: readHeaderTimeout,
}
wrapper := &httpServer{
logger: metricsLogger,
server: httpSrv,
health: healthRouter,
timeout: defaultShutdownWindow,
}
go func() {
metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address))
serveErr := httpSrv.ListenAndServe()
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(serveErr))
if healthRouter != nil {
healthRouter.SetStatus(health.SSTerminating)
}
}
}()
return wrapper, nil
}
func (s *httpServer) SetStatus(status health.ServiceStatus) {
if s == nil || s.health == nil {
return
}
s.health.SetStatus(status)
}
func (s *httpServer) Close(ctx context.Context) {
if s == nil {
return
}
if s.health != nil {
s.health.SetStatus(health.SSTerminating)
s.health.Finish()
s.health = nil
}
if s.server == nil {
return
}
shutdownCtx := ctx
if shutdownCtx == nil {
shutdownCtx = context.Background()
}
if s.timeout > 0 {
var cancel context.CancelFunc
shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout)
defer cancel()
}
if err := s.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Warn("Failed to stop metrics server", zap.Error(err))
} else {
s.logger.Info("Metrics server stopped")
}
}

View File

@@ -0,0 +1,75 @@
package ops
import (
"strconv"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metricsOnce sync.Once
ingestTotal *prometheus.CounterVec
ingestLatency *prometheus.HistogramVec
deliveryTotal *prometheus.CounterVec
deliveryLatency *prometheus.HistogramVec
)
type observer struct{}
func newObserver() Observer {
initMetrics()
return observer{}
}
func initMetrics() {
metricsOnce.Do(func() {
ingestTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "callbacks",
Name: "ingest_total",
Help: "Total ingest attempts by result",
}, []string{"result"})
ingestLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "sendico",
Subsystem: "callbacks",
Name: "ingest_duration_seconds",
Help: "Ingest latency in seconds",
Buckets: prometheus.DefBuckets,
}, []string{"result"})
deliveryTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "callbacks",
Name: "delivery_total",
Help: "Total delivery attempts by result and status code",
}, []string{"result", "status_code"})
deliveryLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "sendico",
Subsystem: "callbacks",
Name: "delivery_duration_seconds",
Help: "Delivery latency in seconds",
Buckets: prometheus.DefBuckets,
}, []string{"result"})
})
}
func (observer) ObserveIngest(result string, duration time.Duration) {
if result == "" {
result = "unknown"
}
ingestTotal.WithLabelValues(result).Inc()
ingestLatency.WithLabelValues(result).Observe(duration.Seconds())
}
func (observer) ObserveDelivery(result string, statusCode int, duration time.Duration) {
if result == "" {
result = "unknown"
}
deliveryTotal.WithLabelValues(result, strconv.Itoa(statusCode)).Inc()
deliveryLatency.WithLabelValues(result).Observe(duration.Seconds())
}

View File

@@ -0,0 +1,8 @@
package retry
import "time"
// Policy computes retry schedules.
type Policy interface {
NextAttempt(attempt int, now time.Time, minDelay, maxDelay time.Duration, jitterRatio float64) time.Time
}

View File

@@ -0,0 +1,59 @@
package retry
import (
"math"
"math/rand"
"sync"
"time"
)
type service struct {
mu sync.Mutex
rnd *rand.Rand
}
// New creates retry policy service.
func New() Policy {
return &service{rnd: rand.New(rand.NewSource(time.Now().UnixNano()))}
}
func (s *service) NextAttempt(attempt int, now time.Time, minDelay, maxDelay time.Duration, jitterRatio float64) time.Time {
if attempt < 1 {
attempt = 1
}
if minDelay <= 0 {
minDelay = time.Second
}
if maxDelay < minDelay {
maxDelay = minDelay
}
base := float64(minDelay)
delay := time.Duration(base * math.Pow(2, float64(attempt-1)))
if delay > maxDelay {
delay = maxDelay
}
if jitterRatio > 0 {
if jitterRatio > 1 {
jitterRatio = 1
}
maxJitter := int64(float64(delay) * jitterRatio)
if maxJitter > 0 {
s.mu.Lock()
jitter := s.rnd.Int63n((maxJitter * 2) + 1)
s.mu.Unlock()
delta := jitter - maxJitter
delay += time.Duration(delta)
}
}
if delay < minDelay {
delay = minDelay
}
if delay > maxDelay {
delay = maxDelay
}
return now.UTC().Add(delay)
}

View File

@@ -0,0 +1,33 @@
package secrets
import (
"context"
"time"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/vault/kv"
)
// Provider resolves secrets by reference.
type Provider interface {
GetSecret(ctx context.Context, ref string) (string, error)
}
// VaultOptions configure Vault KV secret resolution.
type VaultOptions struct {
Config kv.Config
DefaultField string
}
// Options configure secret lookup behavior.
type Options struct {
Logger mlogger.Logger
Static map[string]string
CacheTTL time.Duration
Vault VaultOptions
}
// New creates secrets provider.
func New(opts Options) (Provider, error) {
return newProvider(opts)
}

View File

@@ -0,0 +1,224 @@
package secrets
import (
"context"
"os"
"strings"
"sync"
"time"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/vault/kv"
"go.uber.org/zap"
)
const (
defaultVaultField = "value"
vaultRefPrefix = "vault:"
)
type cacheEntry struct {
value string
expiresAt time.Time
}
type provider struct {
logger mlogger.Logger
static map[string]string
ttl time.Duration
vault kv.Client
vaultEnabled bool
vaultDefField string
mu sync.RWMutex
cache map[string]cacheEntry
}
func newProvider(opts Options) (Provider, error) {
logger := opts.Logger
if logger == nil {
logger = zap.NewNop()
}
static := map[string]string{}
for k, v := range opts.Static {
key := strings.TrimSpace(k)
if key == "" {
continue
}
static[key] = v
}
vaultField := strings.TrimSpace(opts.Vault.DefaultField)
if vaultField == "" {
vaultField = defaultVaultField
}
var vaultClient kv.Client
vaultEnabled := false
hasVaultConfig := strings.TrimSpace(opts.Vault.Config.Address) != "" ||
strings.TrimSpace(opts.Vault.Config.TokenEnv) != "" ||
strings.TrimSpace(opts.Vault.Config.MountPath) != ""
if hasVaultConfig {
client, err := kv.New(kv.Options{
Logger: logger.Named("vault"),
Config: opts.Vault.Config,
Component: "callbacks secrets",
})
if err != nil {
return nil, err
}
vaultClient = client
vaultEnabled = true
}
return &provider{
logger: logger.Named("secrets"),
static: static,
ttl: opts.CacheTTL,
vault: vaultClient,
vaultEnabled: vaultEnabled,
vaultDefField: vaultField,
cache: map[string]cacheEntry{},
}, nil
}
func (p *provider) GetSecret(ctx context.Context, ref string) (string, error) {
key := strings.TrimSpace(ref)
if key == "" {
return "", merrors.InvalidArgument("secret reference is required", "secret_ref")
}
if ctx == nil {
ctx = context.Background()
}
if value, ok := p.fromCache(key); ok {
return value, nil
}
value, err := p.resolve(ctx, key)
if err != nil {
return "", err
}
if strings.TrimSpace(value) == "" {
return "", merrors.NoData("secret reference resolved to empty value")
}
p.toCache(key, value)
return value, nil
}
func (p *provider) resolve(ctx context.Context, key string) (string, error) {
if value, ok := p.static[key]; ok {
return value, nil
}
if strings.HasPrefix(key, "env:") {
envKey := strings.TrimSpace(strings.TrimPrefix(key, "env:"))
if envKey == "" {
return "", merrors.InvalidArgument("secret env reference is invalid", "secret_ref")
}
value := strings.TrimSpace(os.Getenv(envKey))
if value == "" {
return "", merrors.NoData("secret env variable not set: " + envKey)
}
return value, nil
}
if strings.HasPrefix(strings.ToLower(key), vaultRefPrefix) && !p.vaultEnabled {
return "", merrors.InvalidArgument("vault secret reference provided but vault is not configured", "secret_ref")
}
if p.vaultEnabled {
value, resolved, err := p.resolveVault(ctx, key)
if err != nil {
return "", err
}
if resolved {
return value, nil
}
}
return "", merrors.NoData("secret reference not found: " + key)
}
func (p *provider) resolveVault(ctx context.Context, ref string) (string, bool, error) {
path, field, resolved, err := parseVaultRef(ref, p.vaultDefField)
if err != nil {
return "", false, err
}
if !resolved {
return "", false, nil
}
value, err := p.vault.GetString(ctx, path, field)
if err != nil {
p.logger.Warn("Failed to resolve vault secret", zap.String("path", path), zap.String("field", field), zap.Error(err))
return "", true, err
}
return value, true, nil
}
func parseVaultRef(ref, defaultField string) (string, string, bool, error) {
raw := strings.TrimSpace(ref)
lowered := strings.ToLower(raw)
explicit := false
if strings.HasPrefix(lowered, vaultRefPrefix) {
explicit = true
raw = strings.TrimSpace(raw[len(vaultRefPrefix):])
}
if !explicit && !strings.Contains(raw, "/") && !strings.Contains(raw, "#") {
return "", "", false, nil
}
field := strings.TrimSpace(defaultField)
if field == "" {
field = defaultVaultField
}
if idx := strings.Index(raw, "#"); idx >= 0 {
field = strings.TrimSpace(raw[idx+1:])
raw = strings.TrimSpace(raw[:idx])
if field == "" {
return "", "", false, merrors.InvalidArgument("vault secret field is required", "secret_ref")
}
}
path := strings.Trim(strings.TrimSpace(raw), "/")
if path == "" {
return "", "", false, merrors.InvalidArgument("vault secret path is required", "secret_ref")
}
return path, field, true, nil
}
func (p *provider) fromCache(key string) (string, bool) {
if p.ttl <= 0 {
return "", false
}
p.mu.RLock()
entry, ok := p.cache[key]
p.mu.RUnlock()
if !ok {
return "", false
}
if time.Now().After(entry.expiresAt) {
p.mu.Lock()
delete(p.cache, key)
p.mu.Unlock()
return "", false
}
return entry.value, true
}
func (p *provider) toCache(key, value string) {
if p.ttl <= 0 {
return
}
p.mu.Lock()
p.cache[key] = cacheEntry{
value: value,
expiresAt: time.Now().Add(p.ttl),
}
p.mu.Unlock()
}

View File

@@ -0,0 +1,16 @@
package security
import "context"
// Config controls URL validation and SSRF checks.
type Config struct {
RequireHTTPS bool
AllowedHosts []string
AllowedPorts []int
DNSResolveTimeout int
}
// Validator validates outbound callback URLs.
type Validator interface {
ValidateURL(ctx context.Context, target string) error
}

View File

@@ -0,0 +1,163 @@
package security
import (
"context"
"net"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
)
type service struct {
requireHTTPS bool
allowedHosts map[string]struct{}
allowedPorts map[int]struct{}
dnsTimeout time.Duration
resolver *net.Resolver
}
// New creates URL validator.
func New(cfg Config) Validator {
hosts := make(map[string]struct{}, len(cfg.AllowedHosts))
for _, host := range cfg.AllowedHosts {
h := strings.ToLower(strings.TrimSpace(host))
if h == "" {
continue
}
hosts[h] = struct{}{}
}
ports := make(map[int]struct{}, len(cfg.AllowedPorts))
for _, port := range cfg.AllowedPorts {
if port > 0 {
ports[port] = struct{}{}
}
}
timeout := time.Duration(cfg.DNSResolveTimeout) * time.Millisecond
if timeout <= 0 {
timeout = 2 * time.Second
}
return &service{
requireHTTPS: cfg.RequireHTTPS,
allowedHosts: hosts,
allowedPorts: ports,
dnsTimeout: timeout,
resolver: net.DefaultResolver,
}
}
func (s *service) ValidateURL(ctx context.Context, target string) error {
parsed, err := url.Parse(strings.TrimSpace(target))
if err != nil {
return merrors.InvalidArgumentWrap(err, "invalid callback URL", "url")
}
if parsed == nil || parsed.Host == "" {
return merrors.InvalidArgument("callback URL host is required", "url")
}
if parsed.User != nil {
return merrors.InvalidArgument("callback URL credentials are not allowed", "url")
}
if s.requireHTTPS && !strings.EqualFold(parsed.Scheme, "https") {
return merrors.InvalidArgument("callback URL must use HTTPS", "url")
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" {
return merrors.InvalidArgument("callback URL host is empty", "url")
}
if len(s.allowedHosts) > 0 {
if _, ok := s.allowedHosts[host]; !ok {
return merrors.InvalidArgument("callback host is not in allowlist", "url.host")
}
}
port, err := resolvePort(parsed)
if err != nil {
return err
}
if len(s.allowedPorts) > 0 {
if _, ok := s.allowedPorts[port]; !ok {
return merrors.InvalidArgument("callback URL port is not allowed", "url.port")
}
}
if addr, addrErr := netip.ParseAddr(host); addrErr == nil {
if isBlocked(addr) {
return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url")
}
return nil
}
lookupCtx := ctx
if lookupCtx == nil {
lookupCtx = context.Background()
}
lookupCtx, cancel := context.WithTimeout(lookupCtx, s.dnsTimeout)
defer cancel()
ips, err := s.resolver.LookupIPAddr(lookupCtx, host)
if err != nil {
return merrors.InternalWrap(err, "failed to resolve callback host")
}
if len(ips) == 0 {
return merrors.InvalidArgument("callback host did not resolve", "url.host")
}
for _, ip := range ips {
if ip.IP == nil {
continue
}
addr, ok := netip.AddrFromSlice(ip.IP)
if ok && isBlocked(addr) {
return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url.host")
}
}
return nil
}
func resolvePort(parsed *url.URL) (int, error) {
if parsed == nil {
return 0, merrors.InvalidArgument("callback URL is required", "url")
}
portStr := strings.TrimSpace(parsed.Port())
if portStr == "" {
if strings.EqualFold(parsed.Scheme, "https") {
return 443, nil
}
if strings.EqualFold(parsed.Scheme, "http") {
return 80, nil
}
return 0, merrors.InvalidArgument("callback URL scheme is not supported", "url.scheme")
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
return 0, merrors.InvalidArgument("callback URL port is invalid", "url.port")
}
return port, nil
}
func isBlocked(ip netip.Addr) bool {
if !ip.IsValid() {
return true
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsMulticast() || ip.IsUnspecified() {
return true
}
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
// Block common cloud metadata endpoint.
if ip.Is4() && ip.String() == "169.254.169.254" {
return true
}
return false
}

View File

@@ -0,0 +1,271 @@
package serverimp
import (
"context"
"time"
"github.com/nats-io/nats.go"
"github.com/tech/sendico/edge/callbacks/internal/config"
"github.com/tech/sendico/edge/callbacks/internal/delivery"
"github.com/tech/sendico/edge/callbacks/internal/events"
"github.com/tech/sendico/edge/callbacks/internal/ingest"
"github.com/tech/sendico/edge/callbacks/internal/ops"
"github.com/tech/sendico/edge/callbacks/internal/retry"
"github.com/tech/sendico/edge/callbacks/internal/secrets"
"github.com/tech/sendico/edge/callbacks/internal/security"
"github.com/tech/sendico/edge/callbacks/internal/signing"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/edge/callbacks/internal/subscriptions"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/vault/kv"
"go.uber.org/zap"
)
const defaultShutdownTimeout = 15 * time.Second
type jetStreamProvider interface {
JetStream() nats.JetStreamContext
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Start() error {
i.initStopChannels()
defer i.closeDone()
loader := config.New(i.logger)
cfg, err := loader.Load(i.file)
if err != nil {
return err
}
i.config = cfg
observer := ops.NewObserver()
metricsSrv, err := ops.NewHTTPServer(i.logger, ops.HTTPServerConfig{Address: cfg.Metrics.ListenAddress()})
if err != nil {
return err
}
i.opServer = metricsSrv
i.opServer.SetStatus(health.SSStarting)
conn, err := db.ConnectMongo(i.logger.Named("mongo"), cfg.Database)
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
i.mongoConn = conn
repo, err := storage.New(i.logger, conn)
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
resolver, err := subscriptions.New(subscriptions.Dependencies{EndpointRepo: repo.Endpoints()})
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
securityValidator := security.New(security.Config{
RequireHTTPS: cfg.Security.RequireHTTPS,
AllowedHosts: cfg.Security.AllowedHosts,
AllowedPorts: cfg.Security.AllowedPorts,
DNSResolveTimeout: int(cfg.Security.DNSResolveTimeoutMS() / time.Millisecond),
})
secretProvider, err := secrets.New(secrets.Options{
Logger: i.logger,
Static: cfg.Secrets.Static,
CacheTTL: cfg.Secrets.CacheTTL(),
Vault: secrets.VaultOptions{
Config: kv.Config{
Address: cfg.Secrets.Vault.Address,
TokenEnv: cfg.Secrets.Vault.TokenEnv,
Namespace: cfg.Secrets.Vault.Namespace,
MountPath: cfg.Secrets.Vault.MountPath,
},
DefaultField: cfg.Secrets.Vault.DefaultField,
},
})
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
signer, err := signing.New(signing.Dependencies{Logger: i.logger, Provider: secretProvider})
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
retryPolicy := retry.New()
eventSvc := events.New(i.logger)
broker, err := msg.CreateMessagingBroker(i.logger.Named("messaging"), cfg.Messaging)
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
i.broker = broker
jsProvider, ok := broker.(jetStreamProvider)
if !ok || jsProvider.JetStream() == nil {
i.shutdownRuntime(context.Background())
return merrors.Internal("callbacks: messaging broker does not provide JetStream")
}
ingestSvc, err := ingest.New(ingest.Dependencies{
Logger: i.logger,
JetStream: jsProvider.JetStream(),
Config: ingest.Config{
Stream: cfg.Ingest.Stream,
Subject: cfg.Ingest.Subject,
Durable: cfg.Ingest.Durable,
BatchSize: cfg.Ingest.BatchSize,
FetchTimeout: cfg.Ingest.FetchTimeout(),
IdleSleep: cfg.Ingest.IdleSleep(),
},
Events: eventSvc,
Resolver: resolver,
InboxRepo: repo.Inbox(),
TaskRepo: repo.Tasks(),
TaskDefaults: deliveryTaskDefaults(cfg),
Observer: observer,
})
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
i.ingest = ingestSvc
deliverySvc, err := delivery.New(delivery.Dependencies{
Logger: i.logger,
Config: delivery.Config{
WorkerConcurrency: cfg.Delivery.WorkerConcurrency,
WorkerPoll: cfg.Delivery.WorkerPollInterval(),
LockTTL: cfg.Delivery.LockTTL(),
RequestTimeout: cfg.Delivery.RequestTimeout(),
JitterRatio: cfg.Delivery.JitterRatio,
},
Tasks: repo.Tasks(),
Retry: retryPolicy,
Security: securityValidator,
Signer: signer,
Observer: observer,
})
if err != nil {
i.shutdownRuntime(context.Background())
return err
}
i.delivery = deliverySvc
runCtx, cancel := context.WithCancel(context.Background())
i.runCancel = cancel
i.ingest.Start(runCtx)
i.delivery.Start(runCtx)
i.opServer.SetStatus(health.SSRunning)
i.logger.Info("Callbacks service ready",
zap.String("subject", cfg.Ingest.Subject),
zap.String("stream", cfg.Ingest.Stream),
zap.Int("workers", cfg.Delivery.WorkerConcurrency),
)
<-i.stopCh
i.logger.Info("Callbacks service stop signal received")
i.shutdownRuntime(context.Background())
return nil
}
func (i *Imp) Shutdown() {
i.signalStop()
if i.doneCh != nil {
<-i.doneCh
}
}
func (i *Imp) initStopChannels() {
if i.stopCh == nil {
i.stopCh = make(chan struct{})
}
if i.doneCh == nil {
i.doneCh = make(chan struct{})
}
}
func (i *Imp) signalStop() {
i.stopOnce.Do(func() {
if i.stopCh != nil {
close(i.stopCh)
}
})
}
func (i *Imp) closeDone() {
i.doneOnce.Do(func() {
if i.doneCh != nil {
close(i.doneCh)
}
})
}
func (i *Imp) shutdownRuntime(ctx context.Context) {
i.shutdown.Do(func() {
if i.opServer != nil {
i.opServer.SetStatus(health.SSTerminating)
}
if i.runCancel != nil {
i.runCancel()
}
if i.ingest != nil {
i.ingest.Stop()
}
if i.delivery != nil {
i.delivery.Stop()
}
if i.opServer != nil {
i.opServer.Close(ctx)
i.opServer = nil
}
if i.mongoConn != nil {
timeout := i.shutdownTimeout()
shutdownCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := i.mongoConn.Disconnect(shutdownCtx); err != nil {
i.logger.Warn("Failed to close MongoDB connection", zap.Error(err))
}
i.mongoConn = nil
}
})
}
func (i *Imp) shutdownTimeout() time.Duration {
if i.config != nil && i.config.Runtime != nil {
return i.config.Runtime.ShutdownTimeout()
}
return defaultShutdownTimeout
}
func deliveryTaskDefaults(cfg *config.Config) storage.TaskDefaults {
if cfg == nil {
return storage.TaskDefaults{}
}
return storage.TaskDefaults{
MaxAttempts: cfg.Delivery.MaxAttempts,
MinDelay: cfg.Delivery.MinDelay(),
MaxDelay: cfg.Delivery.MaxDelay(),
RequestTimeout: cfg.Delivery.RequestTimeout(),
}
}

View File

@@ -0,0 +1,37 @@
package serverimp
import (
"context"
"sync"
"github.com/tech/sendico/edge/callbacks/internal/config"
"github.com/tech/sendico/edge/callbacks/internal/delivery"
"github.com/tech/sendico/edge/callbacks/internal/ingest"
"github.com/tech/sendico/edge/callbacks/internal/ops"
"github.com/tech/sendico/pkg/db"
mb "github.com/tech/sendico/pkg/messaging/broker"
"github.com/tech/sendico/pkg/mlogger"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config.Config
mongoConn *db.MongoConnection
broker mb.Broker
ingest ingest.Service
delivery delivery.Service
opServer ops.HTTPServer
runCancel context.CancelFunc
shutdown sync.Once
stopOnce sync.Once
doneOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/edge/callbacks/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,36 @@
package signing
import (
"context"
"time"
"github.com/tech/sendico/edge/callbacks/internal/secrets"
"github.com/tech/sendico/pkg/mlogger"
)
const (
ModeNone = "none"
ModeHMACSHA256 = "hmac_sha256"
)
// SignedPayload is what gets sent over HTTP.
type SignedPayload struct {
Body []byte
Headers map[string]string
}
// Signer signs callback payloads.
type Signer interface {
Sign(ctx context.Context, mode, secretRef string, payload []byte, now time.Time) (*SignedPayload, error)
}
// Dependencies configures signer service.
type Dependencies struct {
Logger mlogger.Logger
Provider secrets.Provider
}
// New creates signer service.
func New(deps Dependencies) (Signer, error) {
return newService(deps)
}

View File

@@ -0,0 +1,80 @@
package signing
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
"github.com/tech/sendico/edge/callbacks/internal/secrets"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type service struct {
logger mlogger.Logger
provider secrets.Provider
}
func newService(deps Dependencies) (Signer, error) {
if deps.Provider == nil {
return nil, merrors.InvalidArgument("signing: secrets provider is required", "provider")
}
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
return &service{
logger: logger.Named("signing"),
provider: deps.Provider,
}, nil
}
func (s *service) Sign(ctx context.Context, mode, secretRef string, payload []byte, now time.Time) (*SignedPayload, error) {
normalizedMode := strings.ToLower(strings.TrimSpace(mode))
if normalizedMode == "" {
normalizedMode = ModeNone
}
switch normalizedMode {
case ModeNone:
return &SignedPayload{
Body: append([]byte(nil), payload...),
Headers: map[string]string{},
}, nil
case ModeHMACSHA256:
if strings.TrimSpace(secretRef) == "" {
return nil, merrors.InvalidArgument("signing: secret reference is required for hmac", "secret_ref")
}
secret, err := s.provider.GetSecret(ctx, secretRef)
if err != nil {
s.logger.Warn("Failed to load signing secret", zap.String("secret_ref", secretRef), zap.Error(err))
return nil, err
}
ts := now.UTC().Format(time.RFC3339Nano)
mac := hmac.New(sha256.New, []byte(secret))
message := append([]byte(ts+"."), payload...)
if _, err := mac.Write(message); err != nil {
return nil, merrors.InternalWrap(err, "signing: failed to compute hmac")
}
signature := hex.EncodeToString(mac.Sum(nil))
return &SignedPayload{
Body: append([]byte(nil), payload...),
Headers: map[string]string{
"X-Callback-Timestamp": ts,
"X-Callback-Signature": signature,
"X-Callback-Algorithm": "hmac-sha256",
"Content-Length": strconv.Itoa(len(payload)),
},
}, nil
default:
return nil, merrors.InvalidArgument("signing: unsupported mode", "mode")
}
}

View File

@@ -0,0 +1,99 @@
package storage
import (
"context"
"time"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskStatus tracks delivery task lifecycle.
type TaskStatus string
const (
TaskStatusPending TaskStatus = "PENDING"
TaskStatusRetry TaskStatus = "RETRY"
TaskStatusDelivered TaskStatus = "DELIVERED"
TaskStatusFailed TaskStatus = "FAILED"
)
// Endpoint describes one target callback endpoint.
type Endpoint struct {
ID bson.ObjectID
ClientID string
URL string
SigningMode string
SecretRef string
Headers map[string]string
MaxAttempts int
MinDelay time.Duration
MaxDelay time.Duration
RequestTimeout time.Duration
}
// Task is one callback delivery job.
type Task struct {
ID bson.ObjectID
EventID string
EndpointID bson.ObjectID
EndpointURL string
SigningMode string
SecretRef string
Headers map[string]string
Payload []byte
Attempt int
MaxAttempts int
MinDelay time.Duration
MaxDelay time.Duration
RequestTimeout time.Duration
Status TaskStatus
NextAttemptAt time.Time
}
// TaskDefaults are applied when creating tasks.
type TaskDefaults struct {
MaxAttempts int
MinDelay time.Duration
MaxDelay time.Duration
RequestTimeout time.Duration
}
// Options configures mongo collections.
type Options struct {
InboxCollection string
TasksCollection string
EndpointsCollection string
}
// InboxRepo controls event dedupe state.
type InboxRepo interface {
TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error)
}
// EndpointRepo resolves endpoints for events.
type EndpointRepo interface {
FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error)
}
// TaskRepo manages callback tasks.
type TaskRepo interface {
UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error
LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error)
MarkDelivered(ctx context.Context, taskID bson.ObjectID, httpCode int, latency time.Duration, at time.Time) error
MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt int, nextAttemptAt time.Time, lastError string, httpCode int, at time.Time) error
MarkFailed(ctx context.Context, taskID bson.ObjectID, attempt int, lastError string, httpCode int, at time.Time) error
}
// Repository is the callbacks persistence contract.
type Repository interface {
Inbox() InboxRepo
Endpoints() EndpointRepo
Tasks() TaskRepo
}
// New creates a Mongo-backed callbacks repository.
func New(logger mlogger.Logger, conn *db.MongoConnection) (Repository, error) {
return newMongoRepository(logger, conn)
}

View File

@@ -0,0 +1,513 @@
package storage
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
mutil "github.com/tech/sendico/pkg/mutil/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
const (
inboxCollection string = "inbox"
tasksCollection string = "tasks"
endpointsCollection string = "endpoints"
)
type mongoRepository struct {
logger mlogger.Logger
inboxRepo repository.Repository
tasksRepo repository.Repository
endpointsRepo repository.Repository
inbox InboxRepo
endpoints EndpointRepo
tasks TaskRepo
}
type inboxDoc struct {
storable.Base `bson:",inline"`
EventID string `bson:"event_id"`
ClientID string `bson:"client_id"`
EventType string `bson:"event_type"`
}
func (d *inboxDoc) Collection() string {
return inboxCollection
}
type delayConfig struct {
MinDelayMS int `bson:"min_ms"`
MaxDelayMS int `bson:"max_ms"`
}
type deliveryPolicy struct {
delayConfig `bson:",inline"`
SigningMode string `bson:"signing_mode"`
SecretRef string `bson:"secret_ref"`
Headers map[string]string `bson:"headers"`
MaxAttempts int `bson:"max_attempts"`
RequestTimeoutMS int `bson:"request_timeout_ms"`
}
type endpointDoc struct {
storable.Base `bson:",inline"`
deliveryPolicy `bson:"retry_policy"`
ClientID string `bson:"client_id"`
Status string `bson:"status"`
URL string `bson:"url"`
EventTypes []string `bson:"event_types"`
}
func (d *endpointDoc) Collection() string {
return endpointsCollection
}
type taskDoc struct {
storable.Base `bson:",inline"`
deliveryPolicy `bson:"retry_policy"`
EventID string `bson:"event_id"`
EndpointID bson.ObjectID `bson:"endpoint_id"`
EndpointURL string `bson:"endpoint_url"`
Payload []byte `bson:"payload"`
Status TaskStatus `bson:"status"`
Attempt int `bson:"attempt"`
LastError string `bson:"last_error,omitempty"`
LastHTTPCode int `bson:"last_http_code,omitempty"`
NextAttemptAt time.Time `bson:"next_attempt_at"`
LockedUntil *time.Time `bson:"locked_until,omitempty"`
WorkerID string `bson:"worker_id,omitempty"`
DeliveredAt *time.Time `bson:"delivered_at,omitempty"`
}
func (d *taskDoc) Collection() string {
return tasksCollection
}
func newMongoRepository(logger mlogger.Logger, conn *db.MongoConnection) (Repository, error) {
if logger == nil {
logger = zap.NewNop()
}
if conn == nil {
return nil, merrors.InvalidArgument("callbacks storage: mongo connection is required", "conn")
}
repo := &mongoRepository{
logger: logger.Named("storage"),
inboxRepo: repository.CreateMongoRepository(conn.Database(), inboxCollection),
tasksRepo: repository.CreateMongoRepository(conn.Database(), tasksCollection),
endpointsRepo: repository.CreateMongoRepository(conn.Database(), endpointsCollection),
}
if err := repo.ensureIndexes(); err != nil {
return nil, err
}
repo.inbox = &inboxStore{logger: repo.logger.Named(repo.inboxRepo.Collection()), repo: repo.inboxRepo}
repo.endpoints = &endpointStore{logger: repo.logger.Named(repo.endpointsRepo.Collection()), repo: repo.endpointsRepo}
repo.tasks = &taskStore{logger: repo.logger.Named(repo.tasksRepo.Collection()), repo: repo.tasksRepo}
return repo, nil
}
func (m *mongoRepository) Inbox() InboxRepo {
return m.inbox
}
func (m *mongoRepository) Endpoints() EndpointRepo {
return m.endpoints
}
func (m *mongoRepository) Tasks() TaskRepo {
return m.tasks
}
func (m *mongoRepository) ensureIndexes() error {
if err := m.inboxRepo.CreateIndex(&ri.Definition{
Name: "uq_event_id",
Unique: true,
Keys: []ri.Key{
{Field: "event_id", Sort: ri.Asc},
},
}); err != nil {
return merrors.InternalWrap(err, "callbacks storage: failed to create inbox indexes")
}
for _, def := range []*ri.Definition{
{
Name: "uq_event_endpoint",
Unique: true,
Keys: []ri.Key{
{Field: "event_id", Sort: ri.Asc},
{Field: "endpoint_id", Sort: ri.Asc},
},
},
{
Name: "idx_dispatch_scan",
Keys: []ri.Key{
{Field: "status", Sort: ri.Asc},
{Field: "next_attempt_at", Sort: ri.Asc},
{Field: "locked_until", Sort: ri.Asc},
},
},
} {
if err := m.tasksRepo.CreateIndex(def); err != nil {
return merrors.InternalWrap(err, "callbacks storage: failed to create tasks indexes")
}
}
if err := m.endpointsRepo.CreateIndex(&ri.Definition{
Name: "idx_client_event",
Keys: []ri.Key{
{Field: "client_id", Sort: ri.Asc},
{Field: "status", Sort: ri.Asc},
{Field: "event_types", Sort: ri.Asc},
},
}); err != nil {
return merrors.InternalWrap(err, "callbacks storage: failed to create endpoint indexes")
}
return nil
}
type inboxStore struct {
logger mlogger.Logger
repo repository.Repository
}
func (r *inboxStore) TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error) {
doc := &inboxDoc{
EventID: strings.TrimSpace(eventID),
ClientID: strings.TrimSpace(clientID),
EventType: strings.TrimSpace(eventType),
}
filter := repository.Filter("event_id", doc.EventID)
if err := r.repo.Insert(ctx, doc, filter); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return false, nil
}
r.logger.Warn("Failed to insert inbox dedupe marker", zap.String("event_id", eventID), zap.Error(err))
return false, merrors.InternalWrap(err, "callbacks inbox insert failed")
}
return true, nil
}
type endpointStore struct {
logger mlogger.Logger
repo repository.Repository
}
func (r *endpointStore) FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error) {
clientID = strings.TrimSpace(clientID)
eventType = strings.TrimSpace(eventType)
if clientID == "" {
return nil, merrors.InvalidArgument("client_id is required", "client_id")
}
if eventType == "" {
return nil, merrors.InvalidArgument("event type is required", "event_type")
}
query := repository.Query().
Filter(repository.Field("client_id"), clientID).
In(repository.Field("status"), "active", "enabled")
out := make([]Endpoint, 0)
err := r.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &endpointDoc{}
if err := cur.Decode(doc); err != nil {
return err
}
if strings.TrimSpace(doc.URL) == "" {
return nil
}
if !supportsEventType(doc.EventTypes, eventType) {
return nil
}
out = append(out, Endpoint{
ID: doc.ID,
ClientID: doc.ClientID,
URL: strings.TrimSpace(doc.URL),
SigningMode: strings.TrimSpace(doc.SigningMode),
SecretRef: strings.TrimSpace(doc.SecretRef),
Headers: cloneHeaders(doc.Headers),
MaxAttempts: doc.MaxAttempts,
MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond,
MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond,
RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond,
})
return nil
})
if err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, merrors.InternalWrap(err, "callbacks endpoint lookup failed")
}
return out, nil
}
func supportsEventType(eventTypes []string, eventType string) bool {
if len(eventTypes) == 0 {
return true
}
eventType = strings.TrimSpace(eventType)
for _, t := range eventTypes {
current := strings.TrimSpace(t)
if current == "" {
continue
}
if current == "*" || current == eventType {
return true
}
}
return false
}
type taskStore struct {
logger mlogger.Logger
repo repository.Repository
}
func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error {
eventID = strings.TrimSpace(eventID)
if eventID == "" {
return merrors.InvalidArgument("event id is required", "event_id")
}
if len(endpoints) == 0 {
return nil
}
now := at.UTC()
for _, endpoint := range endpoints {
if endpoint.ID == bson.NilObjectID {
continue
}
maxAttempts := endpoint.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = defaults.MaxAttempts
}
if maxAttempts <= 0 {
maxAttempts = 1
}
minDelay := endpoint.MinDelay
if minDelay <= 0 {
minDelay = defaults.MinDelay
}
if minDelay <= 0 {
minDelay = time.Second
}
maxDelay := endpoint.MaxDelay
if maxDelay <= 0 {
maxDelay = defaults.MaxDelay
}
if maxDelay < minDelay {
maxDelay = minDelay
}
requestTimeout := endpoint.RequestTimeout
if requestTimeout <= 0 {
requestTimeout = defaults.RequestTimeout
}
doc := &taskDoc{}
doc.EventID = eventID
doc.EndpointID = endpoint.ID
doc.EndpointURL = strings.TrimSpace(endpoint.URL)
doc.SigningMode = strings.TrimSpace(endpoint.SigningMode)
doc.SecretRef = strings.TrimSpace(endpoint.SecretRef)
doc.Headers = cloneHeaders(endpoint.Headers)
doc.Payload = append([]byte(nil), payload...)
doc.Status = TaskStatusPending
doc.Attempt = 0
doc.MaxAttempts = maxAttempts
doc.MinDelayMS = int(minDelay / time.Millisecond)
doc.MaxDelayMS = int(maxDelay / time.Millisecond)
doc.RequestTimeoutMS = int(requestTimeout / time.Millisecond)
doc.NextAttemptAt = now
filter := repository.Filter("event_id", eventID).And(repository.Filter("endpoint_id", endpoint.ID))
if err := r.repo.Insert(ctx, doc, filter); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
continue
}
return merrors.InternalWrap(err, "callbacks task upsert failed")
}
}
return nil
}
func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return nil, merrors.InvalidArgument("worker id is required", "worker_id")
}
now = now.UTC()
limit := int64(32)
lockFilter := repository.Query().Or(
repository.Query().Comparison(repository.Field("locked_until"), builder.Exists, false),
repository.Query().Filter(repository.Field("locked_until"), nil),
repository.Query().Comparison(repository.Field("locked_until"), builder.Lte, now),
)
query := repository.Query().
In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)).
Comparison(repository.Field("next_attempt_at"), builder.Lte, now).
And(lockFilter).
Sort(repository.Field("next_attempt_at"), true).
Sort(repository.Field("created_at"), true).
Limit(&limit)
candidates, err := mutil.GetObjects[taskDoc](ctx, r.logger, query, nil, r.repo)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
return nil, merrors.InternalWrap(err, "callbacks task query failed")
}
lockedUntil := now.Add(lockTTL)
for _, candidate := range candidates {
patch := repository.Patch().
Set(repository.Field("locked_until"), lockedUntil).
Set(repository.Field("worker_id"), workerID)
conditional := repository.IDFilter(candidate.ID).And(
repository.Query().In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)),
repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now),
lockFilter,
)
updated, err := r.repo.PatchMany(ctx, conditional, patch)
if err != nil {
return nil, merrors.InternalWrap(err, "callbacks task lock update failed")
}
if updated == 0 {
continue
}
locked := &taskDoc{}
if err := r.repo.Get(ctx, candidate.ID, locked); err != nil {
if errors.Is(err, merrors.ErrNoData) {
continue
}
return nil, merrors.InternalWrap(err, "callbacks task lock reload failed")
}
if strings.TrimSpace(locked.WorkerID) != workerID {
continue
}
return mapTaskDoc(locked), nil
}
return nil, nil
}
func (r *taskStore) MarkDelivered(ctx context.Context, taskID bson.ObjectID, httpCode int, latency time.Duration, at time.Time) error {
_ = latency
if taskID == bson.NilObjectID {
return merrors.InvalidArgument("task id is required", "task_id")
}
patch := repository.Patch().
Set(repository.Field("status"), TaskStatusDelivered).
Set(repository.Field("last_http_code"), httpCode).
Set(repository.Field("delivered_at"), time.Now()).
Set(repository.Field("locked_until"), nil).
Set(repository.Field("worker_id"), "").
Set(repository.Field("last_error"), "")
if err := r.repo.Patch(ctx, taskID, patch); err != nil {
return merrors.InternalWrap(err, "callbacks task mark delivered failed")
}
return nil
}
func (r *taskStore) MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt int, nextAttemptAt time.Time, lastError string, httpCode int, at time.Time) error {
if taskID == bson.NilObjectID {
return merrors.InvalidArgument("task id is required", "task_id")
}
patch := repository.Patch().
Set(repository.Field("status"), TaskStatusRetry).
Set(repository.Field("attempt"), attempt).
Set(repository.Field("next_attempt_at"), nextAttemptAt.UTC()).
Set(repository.Field("last_error"), strings.TrimSpace(lastError)).
Set(repository.Field("last_http_code"), httpCode).
Set(repository.Field("locked_until"), nil).
Set(repository.Field("worker_id"), "")
if err := r.repo.Patch(ctx, taskID, patch); err != nil {
return merrors.InternalWrap(err, "callbacks task mark retry failed")
}
return nil
}
func (r *taskStore) MarkFailed(ctx context.Context, taskID bson.ObjectID, attempt int, lastError string, httpCode int, at time.Time) error {
if taskID == bson.NilObjectID {
return merrors.InvalidArgument("task id is required", "task_id")
}
patch := repository.Patch().
Set(repository.Field("status"), TaskStatusFailed).
Set(repository.Field("attempt"), attempt).
Set(repository.Field("last_error"), strings.TrimSpace(lastError)).
Set(repository.Field("last_http_code"), httpCode).
Set(repository.Field("locked_until"), nil).
Set(repository.Field("worker_id"), "")
if err := r.repo.Patch(ctx, taskID, patch); err != nil {
return merrors.InternalWrap(err, "callbacks task mark failed failed")
}
return nil
}
func mapTaskDoc(doc *taskDoc) *Task {
if doc == nil {
return nil
}
return &Task{
ID: doc.ID,
EventID: doc.EventID,
EndpointID: doc.EndpointID,
EndpointURL: doc.EndpointURL,
SigningMode: doc.SigningMode,
SecretRef: doc.SecretRef,
Headers: cloneHeaders(doc.Headers),
Payload: append([]byte(nil), doc.Payload...),
Attempt: doc.Attempt,
MaxAttempts: doc.MaxAttempts,
MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond,
MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond,
RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond,
Status: doc.Status,
NextAttemptAt: doc.NextAttemptAt,
}
}
func cloneHeaders(in map[string]string) map[string]string {
if len(in) == 0 {
return map[string]string{}
}
out := make(map[string]string, len(in))
for key, val := range in {
out[key] = val
}
return out
}

View File

@@ -0,0 +1,17 @@
package subscriptions
import (
"context"
"github.com/tech/sendico/edge/callbacks/internal/storage"
)
// Resolver resolves active webhook endpoints for an event.
type Resolver interface {
Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error)
}
// Dependencies defines subscriptions resolver dependencies.
type Dependencies struct {
EndpointRepo storage.EndpointRepo
}

View File

@@ -0,0 +1,38 @@
package subscriptions
import (
"context"
"strings"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/pkg/merrors"
)
type service struct {
repo storage.EndpointRepo
}
// New creates endpoint resolver service.
func New(deps Dependencies) (Resolver, error) {
if deps.EndpointRepo == nil {
return nil, merrors.InvalidArgument("subscriptions: endpoint repo is required", "endpointRepo")
}
return &service{repo: deps.EndpointRepo}, nil
}
func (s *service) Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error) {
if strings.TrimSpace(clientID) == "" {
return nil, merrors.InvalidArgument("subscriptions: client id is required", "clientID")
}
if strings.TrimSpace(eventType) == "" {
return nil, merrors.InvalidArgument("subscriptions: event type is required", "eventType")
}
endpoints, err := s.repo.FindActiveByClientAndType(ctx, clientID, eventType)
if err != nil {
return nil, err
}
return endpoints, nil
}

View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/edge/callbacks/internal/appversion"
si "github.com/tech/sendico/edge/callbacks/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("callbacks", appversion.Create(), factory)
}