cb-582 #583
90
.woodpecker/callbacks.yml
Normal file
90
.woodpecker/callbacks.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
matrix:
|
||||
include:
|
||||
- CALLBACKS_IMAGE_PATH: edge/callbacks
|
||||
CALLBACKS_DOCKERFILE: ci/prod/compose/callbacks.dockerfile
|
||||
CALLBACKS_MONGO_SECRET_PATH: sendico/db
|
||||
CALLBACKS_VAULT_SECRET_PATH: sendico/edge/callbacks/vault
|
||||
CALLBACKS_ENV: prod
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/edge/callbacks/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/callbacks.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
image: alpine:latest
|
||||
commands:
|
||||
- set -euo pipefail 2>/dev/null || set -eu
|
||||
- apk add --no-cache git
|
||||
- GIT_REV="$(git rev-parse --short HEAD)"
|
||||
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||
- APP_V="$(cat version)"
|
||||
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
|
||||
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
|
||||
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
|
||||
|
||||
- name: proto
|
||||
image: golang:alpine
|
||||
depends_on: [ version ]
|
||||
commands:
|
||||
- set -eu
|
||||
- apk add --no-cache bash git build-base protoc protobuf-dev
|
||||
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
- bash ci/scripts/proto/generate.sh
|
||||
|
||||
- name: backend-tests
|
||||
image: golang:alpine
|
||||
depends_on: [ proto ]
|
||||
commands:
|
||||
- set -eu
|
||||
- apk add --no-cache bash git build-base
|
||||
- sh ci/scripts/common/run_backend_tests.sh callbacks
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||
- mkdir -p secrets
|
||||
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||
- chmod 600 secrets/SSH_KEY
|
||||
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/callbacks/build-image.sh
|
||||
|
||||
- name: deploy
|
||||
image: alpine:latest
|
||||
depends_on: [ secrets, build-image ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
|
||||
- mkdir -p /root/.ssh
|
||||
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
|
||||
- sh ci/scripts/callbacks/deploy.sh
|
||||
6
Makefile
6
Makefile
@@ -38,7 +38,7 @@ help:
|
||||
@echo " make build-fx Build FX services (oracle, ingestor)"
|
||||
@echo " make build-payments Build payment orchestrator"
|
||||
@echo " make build-gateways Build gateway services (chain, tron, mntx, tgsettle)"
|
||||
@echo " make build-api Build API services (notification, bff)"
|
||||
@echo " make build-api Build API services (notification, callbacks, bff)"
|
||||
@echo " make build-frontend Build Flutter web frontend"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Development:$(NC)"
|
||||
@@ -225,6 +225,7 @@ services-up:
|
||||
dev-mntx-gateway \
|
||||
dev-tgsettle-gateway \
|
||||
dev-notification \
|
||||
dev-callbacks \
|
||||
dev-bff \
|
||||
dev-frontend
|
||||
|
||||
@@ -254,6 +255,7 @@ list-services:
|
||||
@echo " - dev-mntx-gateway :50075, :9405, :8084 (Card Payouts)"
|
||||
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
|
||||
@echo " - dev-notification :8081 (Notifications)"
|
||||
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
|
||||
@echo " - dev-bff :8080 (Backend for Frontend)"
|
||||
@echo " - dev-frontend :3000 (Flutter Web UI)"
|
||||
|
||||
@@ -285,7 +287,7 @@ build-gateways:
|
||||
|
||||
build-api:
|
||||
@echo "$(GREEN)Building API services...$(NC)"
|
||||
@$(COMPOSE) build dev-notification dev-bff
|
||||
@$(COMPOSE) build dev-notification dev-callbacks dev-bff
|
||||
|
||||
build-frontend:
|
||||
@echo "$(GREEN)Building frontend...$(NC)"
|
||||
|
||||
64
README.md
64
README.md
@@ -28,6 +28,7 @@ Financial services platform providing payment orchestration, ledger accounting,
|
||||
| Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
|
||||
| Notification | `api/notification/` | Notifications |
|
||||
| BFF | `api/edge/bff/` | Backend for frontend |
|
||||
| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery |
|
||||
| Frontend | `frontend/pweb/` | Flutter web UI |
|
||||
|
||||
## Development
|
||||
@@ -70,7 +71,7 @@ make build-core # discovery, ledger, fees, documents
|
||||
make build-fx # oracle, ingestor
|
||||
make build-payments # orchestrator
|
||||
make build-gateways # chain, tron, mntx, tgsettle
|
||||
make build-api # notification, bff
|
||||
make build-api # notification, callbacks, bff
|
||||
make build-frontend # Flutter web UI
|
||||
```
|
||||
|
||||
@@ -98,3 +99,64 @@ make update # Update all Go and Flutter dependencies
|
||||
make update-api # Update Go dependencies only
|
||||
make update-frontend # Update Flutter dependencies only
|
||||
```
|
||||
|
||||
### Callbacks Secret References
|
||||
|
||||
Callbacks (`api/edge/callbacks`) supports three secret reference formats:
|
||||
|
||||
- `env:MY_SECRET_ENV` to read from environment variables.
|
||||
- `vault:some/path#field` to read a field from Vault KV v2.
|
||||
- `some/path#field` to read from Vault KV v2 when `secrets.vault` is configured.
|
||||
|
||||
If `#field` is omitted, callbacks uses `secrets.vault.default_field` (default: `value`).
|
||||
|
||||
### Callbacks Vault Auth (Dev + Prod)
|
||||
|
||||
Callbacks now authenticates to Vault through a sidecar Vault Agent (AppRole), same pattern as chain/tron gateways.
|
||||
|
||||
- Dev compose:
|
||||
- service: `dev-callbacks-vault-agent`
|
||||
- shared token file: `/run/vault/token`
|
||||
- app reads token via `VAULT_TOKEN_FILE=/run/vault/token` and `token_env: VAULT_TOKEN`
|
||||
- Prod compose:
|
||||
- service: `sendico_callbacks_vault_agent`
|
||||
- same token sink and env flow
|
||||
- AppRole creds are injected at deploy from `CALLBACKS_VAULT_SECRET_PATH` (default `sendico/edge/callbacks/vault`)
|
||||
|
||||
Required Vault policy (minimal read-only for KV v2 mount `kv`):
|
||||
|
||||
```hcl
|
||||
path "kv/data/callbacks/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "kv/metadata/callbacks/*" {
|
||||
capabilities = ["read", "list"]
|
||||
}
|
||||
```
|
||||
|
||||
Create policy + role (example):
|
||||
|
||||
```bash
|
||||
vault policy write callbacks callbacks-policy.hcl
|
||||
vault write auth/approle/role/callbacks \
|
||||
token_policies="callbacks" \
|
||||
token_ttl="1h" \
|
||||
token_max_ttl="24h"
|
||||
vault read -field=role_id auth/approle/role/callbacks/role-id
|
||||
vault write -f -field=secret_id auth/approle/role/callbacks/secret-id
|
||||
```
|
||||
|
||||
Store AppRole creds for prod deploy pipeline:
|
||||
|
||||
```bash
|
||||
vault kv put kv/sendico/edge/callbacks/vault \
|
||||
role_id="<callbacks-role-id>" \
|
||||
secret_id="<callbacks-secret-id>"
|
||||
```
|
||||
|
||||
Store webhook signing secrets (example path consumed by `secret_ref`):
|
||||
|
||||
```bash
|
||||
vault kv put kv/callbacks/client-a/webhook secret="super-secret"
|
||||
```
|
||||
|
||||
@@ -34,7 +34,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
|
||||
github.com/aws/smithy-go v1.24.1 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
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
|
||||
|
||||
@@ -40,8 +40,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
|
||||
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
||||
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
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=
|
||||
|
||||
@@ -67,7 +67,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
|
||||
github.com/aws/smithy-go v1.24.1 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
@@ -135,11 +135,10 @@ require (
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // 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
|
||||
|
||||
@@ -42,8 +42,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
|
||||
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
||||
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
@@ -59,6 +59,8 @@ github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0l
|
||||
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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
@@ -119,8 +121,8 @@ 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/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
@@ -265,8 +267,8 @@ go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
|
||||
46
api/edge/callbacks/.air.toml
Normal file
46
api/edge/callbacks/.air.toml
Normal 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
3
api/edge/callbacks/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
app
|
||||
.gocache
|
||||
tmp
|
||||
64
api/edge/callbacks/config.dev.yml
Normal file
64
api/edge/callbacks/config.dev.yml
Normal 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
|
||||
63
api/edge/callbacks/config.yml
Normal file
63
api/edge/callbacks/config.yml
Normal 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
|
||||
15
api/edge/callbacks/entrypoint.sh
Executable file
15
api/edge/callbacks/entrypoint.sh
Executable 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
64
api/edge/callbacks/go.mod
Normal 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.14.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
256
api/edge/callbacks/go.sum
Normal 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.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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=
|
||||
28
api/edge/callbacks/internal/appversion/version.go
Normal file
28
api/edge/callbacks/internal/appversion/version.go
Normal 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)
|
||||
}
|
||||
182
api/edge/callbacks/internal/config/module.go
Normal file
182
api/edge/callbacks/internal/config/module.go
Normal 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
|
||||
}
|
||||
162
api/edge/callbacks/internal/config/service.go
Normal file
162
api/edge/callbacks/internal/config/service.go
Normal 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
|
||||
}
|
||||
27
api/edge/callbacks/internal/delivery/classifier.go
Normal file
27
api/edge/callbacks/internal/delivery/classifier.go
Normal 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
|
||||
}
|
||||
48
api/edge/callbacks/internal/delivery/module.go
Normal file
48
api/edge/callbacks/internal/delivery/module.go
Normal 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)
|
||||
}
|
||||
263
api/edge/callbacks/internal/delivery/service.go
Normal file
263
api/edge/callbacks/internal/delivery/service.go
Normal 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()
|
||||
}
|
||||
33
api/edge/callbacks/internal/events/module.go
Normal file
33
api/edge/callbacks/internal/events/module.go
Normal 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"`
|
||||
}
|
||||
86
api/edge/callbacks/internal/events/service.go
Normal file
86
api/edge/callbacks/internal/events/service.go
Normal 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
|
||||
}
|
||||
51
api/edge/callbacks/internal/ingest/module.go
Normal file
51
api/edge/callbacks/internal/ingest/module.go
Normal 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)
|
||||
}
|
||||
204
api/edge/callbacks/internal/ingest/service.go
Normal file
204
api/edge/callbacks/internal/ingest/service.go
Normal 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
|
||||
}
|
||||
}
|
||||
36
api/edge/callbacks/internal/ops/module.go
Normal file
36
api/edge/callbacks/internal/ops/module.go
Normal 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)
|
||||
}
|
||||
119
api/edge/callbacks/internal/ops/server.go
Normal file
119
api/edge/callbacks/internal/ops/server.go
Normal 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")
|
||||
}
|
||||
}
|
||||
75
api/edge/callbacks/internal/ops/service.go
Normal file
75
api/edge/callbacks/internal/ops/service.go
Normal 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())
|
||||
}
|
||||
8
api/edge/callbacks/internal/retry/module.go
Normal file
8
api/edge/callbacks/internal/retry/module.go
Normal 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
|
||||
}
|
||||
59
api/edge/callbacks/internal/retry/service.go
Normal file
59
api/edge/callbacks/internal/retry/service.go
Normal 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)
|
||||
}
|
||||
33
api/edge/callbacks/internal/secrets/module.go
Normal file
33
api/edge/callbacks/internal/secrets/module.go
Normal 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)
|
||||
}
|
||||
224
api/edge/callbacks/internal/secrets/service.go
Normal file
224
api/edge/callbacks/internal/secrets/service.go
Normal 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()
|
||||
}
|
||||
16
api/edge/callbacks/internal/security/module.go
Normal file
16
api/edge/callbacks/internal/security/module.go
Normal 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
|
||||
}
|
||||
163
api/edge/callbacks/internal/security/service.go
Normal file
163
api/edge/callbacks/internal/security/service.go
Normal 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
|
||||
}
|
||||
271
api/edge/callbacks/internal/server/internal/serverimp.go
Normal file
271
api/edge/callbacks/internal/server/internal/serverimp.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
37
api/edge/callbacks/internal/server/internal/types.go
Normal file
37
api/edge/callbacks/internal/server/internal/types.go
Normal 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{}
|
||||
}
|
||||
11
api/edge/callbacks/internal/server/server.go
Normal file
11
api/edge/callbacks/internal/server/server.go
Normal 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)
|
||||
}
|
||||
36
api/edge/callbacks/internal/signing/module.go
Normal file
36
api/edge/callbacks/internal/signing/module.go
Normal 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)
|
||||
}
|
||||
80
api/edge/callbacks/internal/signing/service.go
Normal file
80
api/edge/callbacks/internal/signing/service.go
Normal 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")
|
||||
}
|
||||
}
|
||||
99
api/edge/callbacks/internal/storage/module.go
Normal file
99
api/edge/callbacks/internal/storage/module.go
Normal 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)
|
||||
}
|
||||
513
api/edge/callbacks/internal/storage/service.go
Normal file
513
api/edge/callbacks/internal/storage/service.go
Normal 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
|
||||
}
|
||||
17
api/edge/callbacks/internal/subscriptions/module.go
Normal file
17
api/edge/callbacks/internal/subscriptions/module.go
Normal 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
|
||||
}
|
||||
38
api/edge/callbacks/internal/subscriptions/service.go
Normal file
38
api/edge/callbacks/internal/subscriptions/service.go
Normal 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
|
||||
}
|
||||
17
api/edge/callbacks/main.go
Normal file
17
api/edge/callbacks/main.go
Normal 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)
|
||||
}
|
||||
@@ -7,9 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg
|
||||
replace github.com/tech/sendico/gateway/common => ../common
|
||||
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||
github.com/ethereum/go-ethereum v1.17.0
|
||||
github.com/hashicorp/vault/api v1.22.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
@@ -38,6 +36,7 @@ require (
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -55,6 +54,7 @@ require (
|
||||
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/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
|
||||
@@ -2,18 +2,11 @@ package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/hashicorp/vault/api"
|
||||
"github.com/tech/sendico/pkg/vault/managedkey"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
@@ -23,20 +16,12 @@ import (
|
||||
)
|
||||
|
||||
// Config describes how to connect to Vault for managed wallet keys.
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address"`
|
||||
TokenEnv string `mapstructure:"token_env"`
|
||||
Namespace string `mapstructure:"namespace"`
|
||||
MountPath string `mapstructure:"mount_path"`
|
||||
KeyPrefix string `mapstructure:"key_prefix"`
|
||||
}
|
||||
type Config = managedkey.Config
|
||||
|
||||
// Manager implements the keymanager.Manager contract backed by HashiCorp Vault.
|
||||
type Manager struct {
|
||||
logger mlogger.Logger
|
||||
client *api.Client
|
||||
store *api.KVv2
|
||||
keyPrefix string
|
||||
logger mlogger.Logger
|
||||
keys managedkey.Service
|
||||
}
|
||||
|
||||
// New constructs a Vault-backed key manager.
|
||||
@@ -44,227 +29,56 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("vault key manager: logger is required")
|
||||
}
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
logger.Error("Vault address missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: address is required")
|
||||
}
|
||||
tokenEnv := strings.TrimSpace(cfg.TokenEnv)
|
||||
if tokenEnv == "" {
|
||||
logger.Error("Vault token env missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: token_env is required")
|
||||
}
|
||||
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
||||
if token == "" {
|
||||
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
||||
}
|
||||
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
|
||||
if mountPath == "" {
|
||||
logger.Error("Vault mount path missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
|
||||
}
|
||||
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = "gateway/chain/wallets"
|
||||
}
|
||||
|
||||
clientCfg := api.DefaultConfig()
|
||||
clientCfg.Address = address
|
||||
|
||||
client, err := api.NewClient(clientCfg)
|
||||
keys, err := managedkey.New(managedkey.Options{
|
||||
Logger: logger,
|
||||
Config: managedkey.Config(cfg),
|
||||
Component: "vault key manager",
|
||||
DefaultKeyPrefix: "gateway/chain/wallets",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to create vault client", zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
|
||||
logger.Error("Failed to initialise vault key manager", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
client.SetToken(token)
|
||||
if ns := strings.TrimSpace(cfg.Namespace); ns != "" {
|
||||
client.SetNamespace(ns)
|
||||
}
|
||||
|
||||
kv := client.KVv2(mountPath)
|
||||
|
||||
return &Manager{
|
||||
logger: logger.Named("vault"),
|
||||
client: client,
|
||||
store: kv,
|
||||
keyPrefix: keyPrefix,
|
||||
logger: logger.Named("vault"),
|
||||
keys: keys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
|
||||
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*keymanager.ManagedWalletKey, error) {
|
||||
if strings.TrimSpace(walletRef) == "" {
|
||||
m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", string(network)))
|
||||
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
|
||||
}
|
||||
if network == pmodel.ChainNetworkUnspecified {
|
||||
m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||
return nil, merrors.InvalidArgument("vault key manager: network is required")
|
||||
}
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", string(network)), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
|
||||
networkValue := strings.TrimSpace(string(network))
|
||||
if networkValue == "" {
|
||||
m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||
return nil, merrors.InvalidArgument("vault key manager: network is required")
|
||||
}
|
||||
privateKeyBytes := crypto.FromECDSA(privateKey)
|
||||
publicKey := privateKey.PublicKey
|
||||
publicKeyBytes := crypto.FromECDSAPub(&publicKey)
|
||||
publicKeyHex := hex.EncodeToString(publicKeyBytes)
|
||||
address := crypto.PubkeyToAddress(publicKey).Hex()
|
||||
|
||||
err = m.persistKey(ctx, walletRef, string(network), privateKeyBytes, publicKeyBytes, address)
|
||||
created, err := m.keys.CreateManagedWalletKey(ctx, walletRef, networkValue)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", string(network)), zap.Error(err))
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
m.logger.Warn("Failed to create managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", networkValue), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
|
||||
m.logger.Info("Managed wallet key created",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("network", string(network)),
|
||||
zap.String("address", strings.ToLower(address)),
|
||||
)
|
||||
|
||||
return &keymanager.ManagedWalletKey{
|
||||
KeyID: m.buildKeyID(string(network), walletRef),
|
||||
Address: strings.ToLower(address),
|
||||
PublicKey: publicKeyHex,
|
||||
KeyID: created.KeyID,
|
||||
Address: created.Address,
|
||||
PublicKey: created.PublicKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) persistKey(ctx context.Context, walletRef, network string, privateKey, publicKey []byte, address string) error {
|
||||
secretPath := m.buildKeyID(network, walletRef)
|
||||
payload := map[string]interface{}{
|
||||
"private_key": hex.EncodeToString(privateKey),
|
||||
"public_key": hex.EncodeToString(publicKey),
|
||||
"address": strings.ToLower(address),
|
||||
"network": strings.ToLower(network),
|
||||
}
|
||||
if _, err := m.store.Put(ctx, secretPath, payload); err != nil {
|
||||
return merrors.Internal("vault key manager: failed to write secret at " + secretPath + ": " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) buildKeyID(network, walletRef string) string {
|
||||
net := strings.Trim(strings.ToLower(network), "/")
|
||||
return path.Join(m.keyPrefix, net, walletRef)
|
||||
}
|
||||
|
||||
// SignTransaction loads the key material from Vault and signs the transaction.
|
||||
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
||||
if strings.TrimSpace(keyID) == "" {
|
||||
m.logger.Warn("Signing failed: empty key id")
|
||||
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
|
||||
}
|
||||
if tx == nil {
|
||||
m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
|
||||
}
|
||||
if chainID == nil {
|
||||
m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
|
||||
}
|
||||
|
||||
material, err := m.loadKey(ctx, keyID)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
||||
if err != nil {
|
||||
m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
|
||||
}
|
||||
defer zeroBytes(keyBytes)
|
||||
|
||||
privateKey, err := crypto.ToECDSA(keyBytes)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
|
||||
}
|
||||
|
||||
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
||||
signed, err := m.keys.SignEVMTransaction(ctx, keyID, tx, chainID)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
m.logger.Info("Transaction signed with managed key",
|
||||
zap.String("key_id", keyID),
|
||||
zap.String("network", material.Network),
|
||||
zap.String("tx_hash", signed.Hash().Hex()),
|
||||
)
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
type keyMaterial struct {
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
Address string
|
||||
Network string
|
||||
}
|
||||
|
||||
func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, error) {
|
||||
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
|
||||
secret, err := m.store.Get(ctx, secretPath)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
m.logger.Warn("Secret not found", zap.String("path", secretPath))
|
||||
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
|
||||
}
|
||||
|
||||
getString := func(key string) (string, error) {
|
||||
val, ok := secret.Data[key]
|
||||
if !ok {
|
||||
m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key))
|
||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
|
||||
}
|
||||
str, ok := val.(string)
|
||||
if !ok || strings.TrimSpace(str) == "" {
|
||||
m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key))
|
||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
privateKey, err := getString("private_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publicKey, err := getString("public_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address, err := getString("address")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
network, err := getString("network")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &keyMaterial{
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
Address: address,
|
||||
Network: network,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func zeroBytes(data []byte) {
|
||||
for i := range data {
|
||||
data[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
var _ keymanager.Manager = (*Manager)(nil)
|
||||
|
||||
@@ -9,8 +9,7 @@ replace github.com/tech/sendico/gateway/common => ../common
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||
github.com/ethereum/go-ethereum v1.17.0
|
||||
github.com/fbsobreira/gotron-sdk v0.24.1
|
||||
github.com/hashicorp/vault/api v1.22.0
|
||||
github.com/fbsobreira/gotron-sdk v0.24.2
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shengdoushi/base58 v1.0.0
|
||||
@@ -43,6 +42,7 @@ require (
|
||||
github.com/deckarep/golang-set v1.8.0 // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/fbsobreira/go-bip39 v1.2.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -59,6 +59,7 @@ require (
|
||||
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/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -80,7 +81,6 @@ require (
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tyler-smith/go-bip39 v1.1.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
|
||||
|
||||
@@ -84,8 +84,10 @@ github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kR
|
||||
github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o=
|
||||
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/fbsobreira/gotron-sdk v0.24.1 h1:YxvF26zyXNkho1GxywQeq/gRi70aQ6sbWYop6OTWL7E=
|
||||
github.com/fbsobreira/gotron-sdk v0.24.1/go.mod h1:6E0ac5F3fsVlw+HgfZRAUWl2AkIVuOKvYYtDp7pqbYw=
|
||||
github.com/fbsobreira/go-bip39 v1.2.0 h1:zp3VDGrQeGu8/iPB5wsHVSaOwQhBSLR71CE3nJVz4mY=
|
||||
github.com/fbsobreira/go-bip39 v1.2.0/go.mod h1:PRuO9kYh4Kn+tRALmXYtbizPeD8G2qm8FTVgxDaiXTM=
|
||||
github.com/fbsobreira/gotron-sdk v0.24.2 h1:E2kXEhn+b49D2eaBiv3x9FOHfzi3jcfBbqUFFGJmDxI=
|
||||
github.com/fbsobreira/gotron-sdk v0.24.2/go.mod h1:3YuSi4qc3lJ48uooeTaEawXJvuZQ/wZ/rQQo+X/PPxA=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
@@ -285,8 +287,6 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
@@ -327,7 +327,6 @@ 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
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=
|
||||
@@ -335,7 +334,6 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -347,7 +345,6 @@ 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-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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=
|
||||
|
||||
@@ -2,21 +2,16 @@ package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdecdsa "crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core"
|
||||
"github.com/hashicorp/vault/api"
|
||||
"github.com/tech/sendico/pkg/vault/managedkey"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
@@ -26,20 +21,12 @@ import (
|
||||
)
|
||||
|
||||
// Config describes how to connect to Vault for managed wallet keys.
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address"`
|
||||
TokenEnv string `mapstructure:"token_env"`
|
||||
Namespace string `mapstructure:"namespace"`
|
||||
MountPath string `mapstructure:"mount_path"`
|
||||
KeyPrefix string `mapstructure:"key_prefix"`
|
||||
}
|
||||
type Config = managedkey.Config
|
||||
|
||||
// Manager implements the keymanager.Manager contract backed by HashiCorp Vault.
|
||||
type Manager struct {
|
||||
logger mlogger.Logger
|
||||
client *api.Client
|
||||
store *api.KVv2
|
||||
keyPrefix string
|
||||
logger mlogger.Logger
|
||||
keys managedkey.Service
|
||||
}
|
||||
|
||||
// New constructs a Vault-backed key manager.
|
||||
@@ -47,162 +34,45 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("vault key manager: logger is required")
|
||||
}
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
logger.Error("Vault address missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: address is required")
|
||||
}
|
||||
tokenEnv := strings.TrimSpace(cfg.TokenEnv)
|
||||
if tokenEnv == "" {
|
||||
logger.Error("Vault token env missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: token_env is required")
|
||||
}
|
||||
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
||||
if token == "" {
|
||||
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
||||
}
|
||||
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
|
||||
if mountPath == "" {
|
||||
logger.Error("Vault mount path missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
|
||||
}
|
||||
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = "gateway/chain/wallets"
|
||||
}
|
||||
|
||||
clientCfg := api.DefaultConfig()
|
||||
clientCfg.Address = address
|
||||
|
||||
client, err := api.NewClient(clientCfg)
|
||||
keys, err := managedkey.New(managedkey.Options{
|
||||
Logger: logger,
|
||||
Config: managedkey.Config(cfg),
|
||||
Component: "vault key manager",
|
||||
DefaultKeyPrefix: "gateway/tron/wallets",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to create vault client", zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
|
||||
logger.Error("Failed to initialise vault key manager", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
client.SetToken(token)
|
||||
if ns := strings.TrimSpace(cfg.Namespace); ns != "" {
|
||||
client.SetNamespace(ns)
|
||||
}
|
||||
|
||||
kv := client.KVv2(mountPath)
|
||||
|
||||
return &Manager{
|
||||
logger: logger.Named("vault"),
|
||||
client: client,
|
||||
store: kv,
|
||||
keyPrefix: keyPrefix,
|
||||
logger: logger.Named("vault"),
|
||||
keys: keys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
|
||||
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
|
||||
if strings.TrimSpace(walletRef) == "" {
|
||||
m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network))
|
||||
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
|
||||
}
|
||||
if strings.TrimSpace(network) == "" {
|
||||
m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||
return nil, merrors.InvalidArgument("vault key manager: network is required")
|
||||
}
|
||||
|
||||
privateKey, err := stdecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
|
||||
created, err := m.keys.CreateManagedWalletKey(ctx, walletRef, network)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
|
||||
}
|
||||
privateKeyBytes := crypto.FromECDSA(privateKey)
|
||||
publicKey := privateKey.PublicKey
|
||||
publicKeyBytes := crypto.FromECDSAPub(&publicKey)
|
||||
publicKeyHex := hex.EncodeToString(publicKeyBytes)
|
||||
address := crypto.PubkeyToAddress(publicKey).Hex()
|
||||
|
||||
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
m.logger.Warn("Failed to create managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
|
||||
m.logger.Info("Managed wallet key created",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("network", network),
|
||||
zap.String("address", strings.ToLower(address)),
|
||||
)
|
||||
|
||||
return &keymanager.ManagedWalletKey{
|
||||
KeyID: m.buildKeyID(network, walletRef),
|
||||
Address: strings.ToLower(address),
|
||||
PublicKey: publicKeyHex,
|
||||
KeyID: created.KeyID,
|
||||
Address: created.Address,
|
||||
PublicKey: created.PublicKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) persistKey(ctx context.Context, walletRef, network string, privateKey, publicKey []byte, address string) error {
|
||||
secretPath := m.buildKeyID(network, walletRef)
|
||||
payload := map[string]interface{}{
|
||||
"private_key": hex.EncodeToString(privateKey),
|
||||
"public_key": hex.EncodeToString(publicKey),
|
||||
"address": strings.ToLower(address),
|
||||
"network": strings.ToLower(network),
|
||||
}
|
||||
if _, err := m.store.Put(ctx, secretPath, payload); err != nil {
|
||||
return merrors.Internal("vault key manager: failed to write secret at " + secretPath + ": " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) buildKeyID(network, walletRef string) string {
|
||||
net := strings.Trim(strings.ToLower(network), "/")
|
||||
return path.Join(m.keyPrefix, net, walletRef)
|
||||
}
|
||||
|
||||
// SignTransaction loads the key material from Vault and signs the transaction.
|
||||
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
||||
if strings.TrimSpace(keyID) == "" {
|
||||
m.logger.Warn("Signing failed: empty key id")
|
||||
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
|
||||
}
|
||||
if tx == nil {
|
||||
m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
|
||||
}
|
||||
if chainID == nil {
|
||||
m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
|
||||
}
|
||||
|
||||
material, err := m.loadKey(ctx, keyID)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
||||
if err != nil {
|
||||
m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
|
||||
}
|
||||
defer zeroBytes(keyBytes)
|
||||
|
||||
privateKey, err := crypto.ToECDSA(keyBytes)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
|
||||
}
|
||||
|
||||
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
||||
signed, err := m.keys.SignEVMTransaction(ctx, keyID, tx, chainID)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
m.logger.Info("Transaction signed with managed key",
|
||||
zap.String("key_id", keyID),
|
||||
zap.String("network", material.Network),
|
||||
zap.String("tx_hash", signed.Hash().Hex()),
|
||||
)
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
@@ -221,7 +91,7 @@ func (m *Manager) SignTronTransaction(ctx context.Context, keyID string, tx *tro
|
||||
return nil, merrors.InvalidArgument("vault key manager: transaction raw_data is nil")
|
||||
}
|
||||
|
||||
material, err := m.loadKey(ctx, keyID)
|
||||
material, err := m.keys.LoadKeyMaterial(ctx, keyID)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to load key material for TRON signing", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, err
|
||||
@@ -274,64 +144,6 @@ func (m *Manager) SignTronTransaction(ctx context.Context, keyID string, tx *tro
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
type keyMaterial struct {
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
Address string
|
||||
Network string
|
||||
}
|
||||
|
||||
func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, error) {
|
||||
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
|
||||
secret, err := m.store.Get(ctx, secretPath)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
m.logger.Warn("Secret not found", zap.String("path", secretPath))
|
||||
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
|
||||
}
|
||||
|
||||
getString := func(key string) (string, error) {
|
||||
val, ok := secret.Data[key]
|
||||
if !ok {
|
||||
m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key))
|
||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
|
||||
}
|
||||
str, ok := val.(string)
|
||||
if !ok || strings.TrimSpace(str) == "" {
|
||||
m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key))
|
||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
privateKey, err := getString("private_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publicKey, err := getString("public_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address, err := getString("address")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
network, err := getString("network")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &keyMaterial{
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
Address: address,
|
||||
Network: network,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func zeroBytes(data []byte) {
|
||||
for i := range data {
|
||||
data[i] = 0
|
||||
|
||||
@@ -58,7 +58,7 @@ func (i *Imp) Start() error {
|
||||
if broker != nil {
|
||||
opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker))
|
||||
}
|
||||
svc, err := orchestrator.NewService(logger, repo, opts...)
|
||||
svc, err := orchestrator.NewService(logger, repo, producer, opts...)
|
||||
i.service = svc
|
||||
return svc, err
|
||||
}
|
||||
|
||||
@@ -238,6 +238,18 @@ func (s *svc) recordPaymentCreated(ctx context.Context, payment *agg.Payment, gr
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.statuses.Publish(ctx, paymentStatusPublishInput{
|
||||
Payment: payment,
|
||||
PreviousState: agg.StateUnspecified,
|
||||
CurrentState: payment.State,
|
||||
OccurredAt: s.nowUTC(),
|
||||
Event: "created",
|
||||
}); err != nil {
|
||||
s.logger.Warn("Failed to publish created payment status update",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
for i := range payment.StepExecutions {
|
||||
if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
)
|
||||
@@ -64,6 +65,7 @@ type Dependencies struct {
|
||||
Query pquery.Service
|
||||
Mapper prmap.Mapper
|
||||
Observer oobs.Observer
|
||||
Producer msg.Producer
|
||||
|
||||
RetryPolicy ssched.RetryPolicy
|
||||
Now func() time.Time
|
||||
|
||||
@@ -87,6 +87,7 @@ func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Paymen
|
||||
func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) (*agg.Payment, bool, bool, error) {
|
||||
logger := s.logger
|
||||
expectedVersion := payment.Version
|
||||
previousAggregateState := payment.State
|
||||
|
||||
scheduled, err := s.scheduler.Schedule(ssched.Input{
|
||||
Steps: graph.Steps,
|
||||
@@ -146,6 +147,22 @@ func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Gr
|
||||
zap.Uint64("version", payment.Version),
|
||||
zap.String("state", string(payment.State)),
|
||||
)
|
||||
if aggChanged && payment.State != previousAggregateState {
|
||||
if err := s.statuses.Publish(ctx, paymentStatusPublishInput{
|
||||
Payment: payment,
|
||||
PreviousState: previousAggregateState,
|
||||
CurrentState: payment.State,
|
||||
OccurredAt: s.nowUTC(),
|
||||
Event: "state_changed",
|
||||
}); err != nil {
|
||||
logger.Warn("Failed to publish payment status update",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("from_state", string(previousAggregateState)),
|
||||
zap.String("to_state", string(payment.State)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
return payment, true, len(scheduled.Runnable) == 0, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ type svc struct {
|
||||
query pquery.Service
|
||||
mapper prmap.Mapper
|
||||
observer oobs.Observer
|
||||
statuses paymentStatusPublisher
|
||||
|
||||
retryPolicy ssched.RetryPolicy
|
||||
now func() time.Time
|
||||
@@ -106,6 +107,7 @@ func newService(deps Dependencies) (Service, error) {
|
||||
query: query,
|
||||
mapper: firstMapper(deps.Mapper, logger),
|
||||
observer: observer,
|
||||
statuses: newPaymentStatusPublisher(logger, deps.Producer),
|
||||
|
||||
retryPolicy: deps.RetryPolicy,
|
||||
now: deps.Now,
|
||||
|
||||
@@ -3,6 +3,7 @@ package psvc
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
menv "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
@@ -76,6 +78,83 @@ func TestExecutePayment_EndToEndSyncSettled(t *testing.T) {
|
||||
assertTimelineHasEvent(t, timeline.Items, "settled")
|
||||
}
|
||||
|
||||
func TestExecutePayment_PublishesStatusUpdates(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
})
|
||||
env.quotes.Put(newExecutableQuote(env.orgID, "quote-status", "intent-status", buildLedgerRoute()))
|
||||
|
||||
resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: testMeta(env.orgID, "idem-status"),
|
||||
QuotationRef: "quote-status",
|
||||
ClientPaymentRef: "client-status",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||
}
|
||||
if resp.GetPayment() == nil {
|
||||
t.Fatal("expected payment in response")
|
||||
}
|
||||
|
||||
type publishedEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
type publishedData struct {
|
||||
PaymentRef string `json:"payment_ref"`
|
||||
State string `json:"state"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
msgs := env.producer.Messages()
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("expected published status updates")
|
||||
}
|
||||
|
||||
seenCreated := false
|
||||
seenSettled := false
|
||||
for i := range msgs {
|
||||
if got, want := msgs[i].Subject, "payment_orchestrator_updated"; got != want {
|
||||
t.Fatalf("subject mismatch at %d: got=%q want=%q", i, got, want)
|
||||
}
|
||||
|
||||
var outer publishedEnvelope
|
||||
if err := json.Unmarshal(msgs[i].Data, &outer); err != nil {
|
||||
t.Fatalf("failed to unmarshal published envelope[%d]: %v", i, err)
|
||||
}
|
||||
if strings.TrimSpace(outer.EventID) == "" {
|
||||
t.Fatalf("expected non-empty event_id at %d", i)
|
||||
}
|
||||
if got, want := outer.Type, paymentStatusEventType; got != want {
|
||||
t.Fatalf("event type mismatch at %d: got=%q want=%q", i, got, want)
|
||||
}
|
||||
|
||||
var inner publishedData
|
||||
if err := json.Unmarshal(outer.Data, &inner); err != nil {
|
||||
t.Fatalf("failed to unmarshal published data[%d]: %v", i, err)
|
||||
}
|
||||
if got, want := inner.PaymentRef, resp.GetPayment().GetPaymentRef(); got != want {
|
||||
t.Fatalf("payment_ref mismatch at %d: got=%q want=%q", i, got, want)
|
||||
}
|
||||
if inner.Event == "created" && inner.State == string(agg.StateCreated) {
|
||||
seenCreated = true
|
||||
}
|
||||
if inner.State == string(agg.StateSettled) {
|
||||
seenSettled = true
|
||||
}
|
||||
}
|
||||
|
||||
if !seenCreated {
|
||||
t.Fatal("expected created payment status update")
|
||||
}
|
||||
if !seenSettled {
|
||||
t.Fatal("expected settled payment status update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePayment_IdempotencyMismatch(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
@@ -282,6 +361,7 @@ type testEnv struct {
|
||||
repo *memoryRepo
|
||||
quotes *memoryQuoteStore
|
||||
observer oobs.Observer
|
||||
producer *capturingProducer
|
||||
orgID bson.ObjectID
|
||||
}
|
||||
|
||||
@@ -306,11 +386,13 @@ func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) (
|
||||
Guard: script,
|
||||
})
|
||||
|
||||
producer := &capturingProducer{}
|
||||
svc, err := New(Dependencies{
|
||||
QuoteStore: quotes,
|
||||
Repository: repo,
|
||||
Executors: registry,
|
||||
Observer: observer,
|
||||
Producer: producer,
|
||||
RetryPolicy: ssched.RetryPolicy{MaxAttempts: 2},
|
||||
MaxTicks: 20,
|
||||
})
|
||||
@@ -322,10 +404,46 @@ func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) (
|
||||
repo: repo,
|
||||
quotes: quotes,
|
||||
observer: observer,
|
||||
producer: producer,
|
||||
orgID: bson.NewObjectID(),
|
||||
}
|
||||
}
|
||||
|
||||
type capturedMessage struct {
|
||||
Subject string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type capturingProducer struct {
|
||||
mu sync.Mutex
|
||||
items []capturedMessage
|
||||
}
|
||||
|
||||
func (p *capturingProducer) SendMessage(envelope menv.Envelope) error {
|
||||
if envelope == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := envelope.Serialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.items = append(p.items, capturedMessage{
|
||||
Subject: envelope.GetSignature().ToString(),
|
||||
Data: append([]byte(nil), data...),
|
||||
})
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *capturingProducer) Messages() []capturedMessage {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]capturedMessage, len(p.items))
|
||||
copy(out, p.items)
|
||||
return out
|
||||
}
|
||||
|
||||
type scriptedExecutors struct {
|
||||
handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
nm "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
paymentStatusEventType = "payment.status.updated"
|
||||
paymentStatusEventSender = "payments.orchestrator.v2"
|
||||
)
|
||||
|
||||
type paymentStatusPublisher interface {
|
||||
Publish(ctx context.Context, in paymentStatusPublishInput) error
|
||||
}
|
||||
|
||||
type paymentStatusPublishInput struct {
|
||||
Payment *agg.Payment
|
||||
PreviousState agg.State
|
||||
CurrentState agg.State
|
||||
OccurredAt time.Time
|
||||
Event string
|
||||
}
|
||||
|
||||
type noopPaymentStatusPublisher struct{}
|
||||
|
||||
func (noopPaymentStatusPublisher) Publish(_ context.Context, _ paymentStatusPublishInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type brokerPaymentStatusPublisher struct {
|
||||
logger mlogger.Logger
|
||||
producer msg.Producer
|
||||
}
|
||||
|
||||
type callbackEventEnvelope 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"`
|
||||
}
|
||||
|
||||
type paymentStatusEventData struct {
|
||||
OrganizationRef string `json:"organization_ref"`
|
||||
PaymentRef string `json:"payment_ref"`
|
||||
QuotationRef string `json:"quotation_ref"`
|
||||
ClientPaymentRef string `json:"client_payment_ref,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
State string `json:"state"`
|
||||
PreviousState string `json:"previous_state,omitempty"`
|
||||
Version uint64 `json:"version"`
|
||||
IsTerminal bool `json:"is_terminal"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
type rawEnvelope struct {
|
||||
timestamp time.Time
|
||||
messageID uuid.UUID
|
||||
data []byte
|
||||
sender string
|
||||
signature model.NotificationEvent
|
||||
}
|
||||
|
||||
func newPaymentStatusPublisher(logger mlogger.Logger, producer msg.Producer) paymentStatusPublisher {
|
||||
if producer == nil {
|
||||
return noopPaymentStatusPublisher{}
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &brokerPaymentStatusPublisher{
|
||||
logger: logger.Named("status_publisher"),
|
||||
producer: producer,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *brokerPaymentStatusPublisher) Publish(_ context.Context, in paymentStatusPublishInput) error {
|
||||
if in.Payment == nil {
|
||||
return nil
|
||||
}
|
||||
payment := in.Payment
|
||||
|
||||
paymentRef := strings.TrimSpace(payment.PaymentRef)
|
||||
if paymentRef == "" || payment.OrganizationRef.IsZero() {
|
||||
p.logger.Warn("Skipping payment status publish due to missing identifiers",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("organization_ref", payment.OrganizationRef.Hex()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
occurredAt := in.OccurredAt.UTC()
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now().UTC()
|
||||
}
|
||||
eventName := strings.TrimSpace(in.Event)
|
||||
if eventName == "" {
|
||||
eventName = "state_changed"
|
||||
}
|
||||
|
||||
body, err := json.Marshal(paymentStatusEventData{
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
PaymentRef: paymentRef,
|
||||
QuotationRef: strings.TrimSpace(payment.QuotationRef),
|
||||
ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef),
|
||||
IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey),
|
||||
State: string(in.CurrentState),
|
||||
PreviousState: normalizePreviousState(in.PreviousState, in.CurrentState),
|
||||
Version: payment.Version,
|
||||
IsTerminal: isTerminalState(in.CurrentState),
|
||||
Event: eventName,
|
||||
})
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "payment status publish: marshal body failed")
|
||||
}
|
||||
|
||||
message, err := json.Marshal(callbackEventEnvelope{
|
||||
EventID: buildPaymentStatusEventID(paymentRef, payment.Version, in.CurrentState),
|
||||
Type: paymentStatusEventType,
|
||||
ClientID: payment.OrganizationRef.Hex(),
|
||||
OccurredAt: occurredAt,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
Data: body,
|
||||
})
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "payment status publish: marshal envelope failed")
|
||||
}
|
||||
|
||||
signature := model.NewNotification(mservice.PaymentOrchestrator, nm.NAUpdated)
|
||||
envelope := &rawEnvelope{
|
||||
timestamp: occurredAt,
|
||||
messageID: uuid.New(),
|
||||
data: message,
|
||||
sender: paymentStatusEventSender,
|
||||
signature: signature,
|
||||
}
|
||||
|
||||
if err := p.producer.SendMessage(envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePreviousState(previous, current agg.State) string {
|
||||
if previous == current || previous == agg.StateUnspecified {
|
||||
return ""
|
||||
}
|
||||
return string(previous)
|
||||
}
|
||||
|
||||
func isTerminalState(state agg.State) bool {
|
||||
switch state {
|
||||
case agg.StateSettled, agg.StateNeedsAttention, agg.StateFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildPaymentStatusEventID(paymentRef string, version uint64, state agg.State) string {
|
||||
return paymentRef + ":" + strconv.FormatUint(version, 10) + ":" + string(state)
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) Serialize() ([]byte, error) {
|
||||
return append([]byte(nil), e.data...), nil
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetTimeStamp() time.Time {
|
||||
return e.timestamp
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetMessageId() uuid.UUID {
|
||||
return e.messageID
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetData() []byte {
|
||||
return append([]byte(nil), e.data...)
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetSender() string {
|
||||
return e.sender
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetSignature() model.NotificationEvent {
|
||||
return e.signature
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) Wrap(data []byte) ([]byte, error) {
|
||||
e.data = append([]byte(nil), data...)
|
||||
return e.Serialize()
|
||||
}
|
||||
|
||||
var _ me.Envelope = (*rawEnvelope)(nil)
|
||||
@@ -23,6 +23,7 @@ type Service struct {
|
||||
repo storage.Repository
|
||||
v2 psvc.Service
|
||||
paymentRepo prepo.Repository
|
||||
producer msg.Producer
|
||||
|
||||
ledgerClient ledgerclient.Client
|
||||
mntxClient mntxclient.Client
|
||||
@@ -35,14 +36,15 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService constructs the v2 orchestrator service.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) (*Service, error) {
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) (*Service, error) {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
logger: logger.Named("service"),
|
||||
repo: repo,
|
||||
logger: logger.Named("service"),
|
||||
repo: repo,
|
||||
producer: producer,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -58,6 +60,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
|
||||
GatewayInvokeResolver: svc.gatewayInvokeResolver,
|
||||
GatewayRegistry: svc.gatewayRegistry,
|
||||
CardGatewayRoutes: svc.cardGatewayRoutes,
|
||||
Producer: svc.producer,
|
||||
})
|
||||
svc.startExternalRuntime()
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
@@ -30,6 +31,7 @@ type v2RuntimeDeps struct {
|
||||
GatewayInvokeResolver GatewayInvokeResolver
|
||||
GatewayRegistry GatewayRegistry
|
||||
CardGatewayRoutes map[string]CardGatewayRoute
|
||||
Producer msg.Producer
|
||||
}
|
||||
|
||||
func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, runtimeDeps v2RuntimeDeps) (psvc.Service, prepo.Repository, error) {
|
||||
@@ -76,6 +78,7 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r
|
||||
Query: query,
|
||||
Observer: observer,
|
||||
Executors: executors,
|
||||
Producer: runtimeDeps.Producer,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Orchestration v2 disabled: service init failed", zap.Error(err))
|
||||
|
||||
@@ -5,8 +5,10 @@ go 1.25.0
|
||||
require (
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0
|
||||
github.com/ethereum/go-ethereum v1.17.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/vault/api v1.22.0
|
||||
github.com/mattn/go-colorable v0.1.14
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nats-io/nats.go v1.49.0
|
||||
@@ -26,29 +28,47 @@ require (
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/consensys/gnark-crypto v0.19.2 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v27.3.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/mock 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/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
@@ -67,10 +87,12 @@ require (
|
||||
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/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
@@ -82,7 +104,6 @@ require (
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
@@ -91,7 +112,7 @@ require (
|
||||
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.5.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -6,8 +6,14 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
||||
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/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc h1:1stW1OipdBj8Me+nj26SzT8yil7OYve0r3cWobzk1JQ=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
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=
|
||||
@@ -20,19 +26,29 @@ github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0l
|
||||
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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
|
||||
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
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/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
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=
|
||||
@@ -41,10 +57,22 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
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/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes=
|
||||
github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o=
|
||||
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/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -53,6 +81,10 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
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/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
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=
|
||||
@@ -66,18 +98,47 @@ 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/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
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/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
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/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
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=
|
||||
@@ -86,6 +147,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
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/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
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=
|
||||
@@ -132,6 +197,10 @@ github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/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.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
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=
|
||||
@@ -146,6 +215,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
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=
|
||||
@@ -178,10 +249,10 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuH
|
||||
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/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||
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=
|
||||
@@ -190,8 +261,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
|
||||
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.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
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=
|
||||
@@ -254,8 +325,8 @@ 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.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/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=
|
||||
@@ -269,8 +340,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||
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=
|
||||
@@ -280,6 +351,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -11,6 +11,7 @@ type Client struct {
|
||||
ClientID string `bson:"clientId"`
|
||||
ClientName string `bson:"clientName"`
|
||||
ClientSecret string `bson:"clientSecret,omitempty"`
|
||||
AllowedCIDRs []string `bson:"allowedCIDRs,omitempty"`
|
||||
AllowedScopes []string `bson:"allowedScopes"`
|
||||
RedirectURIs []string `bson:"redirectURIs"`
|
||||
GrantTypes []string `bson:"grantTypes"`
|
||||
|
||||
34
api/pkg/vault/kv/module.go
Normal file
34
api/pkg/vault/kv/module.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package kv
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Config describes Vault KV v2 connection settings.
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address" yaml:"address"`
|
||||
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
|
||||
Namespace string `mapstructure:"namespace" yaml:"namespace"`
|
||||
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
|
||||
}
|
||||
|
||||
// Client defines KV operations used by services.
|
||||
type Client interface {
|
||||
Put(ctx context.Context, secretPath string, payload map[string]interface{}) error
|
||||
Get(ctx context.Context, secretPath string) (map[string]interface{}, error)
|
||||
GetString(ctx context.Context, secretPath, field string) (string, error)
|
||||
}
|
||||
|
||||
// Options configure KV client creation.
|
||||
type Options struct {
|
||||
Logger mlogger.Logger
|
||||
Config Config
|
||||
Component string
|
||||
}
|
||||
|
||||
// New creates a Vault KV v2 client.
|
||||
func New(opts Options) (Client, error) {
|
||||
return newService(opts)
|
||||
}
|
||||
151
api/pkg/vault/kv/service.go
Normal file
151
api/pkg/vault/kv/service.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package kv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const defaultComponent = "vault kv"
|
||||
|
||||
type service struct {
|
||||
logger mlogger.Logger
|
||||
component string
|
||||
store *api.KVv2
|
||||
}
|
||||
|
||||
func newService(opts Options) (Client, error) {
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
component := strings.TrimSpace(opts.Component)
|
||||
if component == "" {
|
||||
component = defaultComponent
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(opts.Config.Address)
|
||||
if address == "" {
|
||||
logger.Error("Vault address missing")
|
||||
return nil, merrors.InvalidArgument(component + ": address is required")
|
||||
}
|
||||
|
||||
tokenEnv := strings.TrimSpace(opts.Config.TokenEnv)
|
||||
if tokenEnv == "" {
|
||||
logger.Error("Vault token env missing")
|
||||
return nil, merrors.InvalidArgument(component + ": token_env is required")
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
||||
if token == "" {
|
||||
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
return nil, merrors.InvalidArgument(component + ": token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
||||
}
|
||||
|
||||
mountPath := strings.Trim(strings.TrimSpace(opts.Config.MountPath), "/")
|
||||
if mountPath == "" {
|
||||
logger.Error("Vault mount path missing")
|
||||
return nil, merrors.InvalidArgument(component + ": mount_path is required")
|
||||
}
|
||||
|
||||
clientCfg := api.DefaultConfig()
|
||||
clientCfg.Address = address
|
||||
|
||||
client, err := api.NewClient(clientCfg)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create vault client", zap.Error(err))
|
||||
return nil, merrors.Internal(component + ": failed to create client: " + err.Error())
|
||||
}
|
||||
client.SetToken(token)
|
||||
|
||||
if ns := strings.TrimSpace(opts.Config.Namespace); ns != "" {
|
||||
client.SetNamespace(ns)
|
||||
}
|
||||
|
||||
return &service{
|
||||
logger: logger.Named("vault"),
|
||||
component: component,
|
||||
store: client.KVv2(mountPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Put(ctx context.Context, secretPath string, payload map[string]interface{}) error {
|
||||
if payload == nil {
|
||||
return merrors.InvalidArgument(s.component+": payload is required", "payload")
|
||||
}
|
||||
|
||||
normalizedPath, err := normalizePath(secretPath)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgumentWrap(err, s.component+": secret path is invalid", "secret_path")
|
||||
}
|
||||
|
||||
if _, err := s.store.Put(ctx, normalizedPath, payload); err != nil {
|
||||
s.logger.Warn("Failed to write secret", zap.String("path", normalizedPath), zap.Error(err))
|
||||
return merrors.Internal(s.component + ": failed to write secret at " + normalizedPath + ": " + err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Get(ctx context.Context, secretPath string) (map[string]interface{}, error) {
|
||||
normalizedPath, err := normalizePath(secretPath)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgumentWrap(err, s.component+": secret path is invalid", "secret_path")
|
||||
}
|
||||
|
||||
secret, err := s.store.Get(ctx, normalizedPath)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to read secret", zap.String("path", normalizedPath), zap.Error(err))
|
||||
return nil, merrors.Internal(s.component + ": failed to read secret at " + normalizedPath + ": " + err.Error())
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return nil, merrors.NoData(s.component + ": secret " + normalizedPath + " not found")
|
||||
}
|
||||
|
||||
data := make(map[string]interface{}, len(secret.Data))
|
||||
for k, v := range secret.Data {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *service) GetString(ctx context.Context, secretPath, field string) (string, error) {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
return "", merrors.InvalidArgument(s.component+": field is required", "field")
|
||||
}
|
||||
|
||||
data, err := s.Get(ctx, secretPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
val, ok := data[field]
|
||||
if !ok {
|
||||
return "", merrors.Internal(s.component + ": secret " + strings.Trim(strings.TrimPrefix(secretPath, "/"), "/") + " missing " + field)
|
||||
}
|
||||
|
||||
str, ok := val.(string)
|
||||
if !ok || strings.TrimSpace(str) == "" {
|
||||
return "", merrors.Internal(s.component + ": secret " + strings.Trim(strings.TrimPrefix(secretPath, "/"), "/") + " invalid " + field)
|
||||
}
|
||||
|
||||
return str, nil
|
||||
}
|
||||
|
||||
func normalizePath(secretPath string) (string, error) {
|
||||
normalizedPath := strings.Trim(strings.TrimPrefix(strings.TrimSpace(secretPath), "/"), "/")
|
||||
if normalizedPath == "" {
|
||||
return "", merrors.InvalidArgument("secret path is required", "secret_path")
|
||||
}
|
||||
return normalizedPath, nil
|
||||
}
|
||||
|
||||
var _ Client = (*service)(nil)
|
||||
54
api/pkg/vault/managedkey/module.go
Normal file
54
api/pkg/vault/managedkey/module.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package managedkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Config describes how to connect to Vault for managed wallet keys.
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address" yaml:"address"`
|
||||
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
|
||||
Namespace string `mapstructure:"namespace" yaml:"namespace"`
|
||||
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
|
||||
KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"`
|
||||
}
|
||||
|
||||
// ManagedWalletKey captures metadata returned after key provisioning.
|
||||
type ManagedWalletKey struct {
|
||||
KeyID string
|
||||
Address string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
// Material contains key material loaded from Vault.
|
||||
type Material struct {
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
Address string
|
||||
Network string
|
||||
}
|
||||
|
||||
// Service defines managed key operations shared by gateways.
|
||||
type Service interface {
|
||||
CreateManagedWalletKey(ctx context.Context, walletRef, network string) (*ManagedWalletKey, error)
|
||||
SignEVMTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error)
|
||||
LoadKeyMaterial(ctx context.Context, keyID string) (*Material, error)
|
||||
BuildKeyID(network, walletRef string) string
|
||||
}
|
||||
|
||||
// Options configure managed key service creation.
|
||||
type Options struct {
|
||||
Logger mlogger.Logger
|
||||
Config Config
|
||||
Component string
|
||||
DefaultKeyPrefix string
|
||||
}
|
||||
|
||||
// New creates a managed wallet key service backed by Vault KV.
|
||||
func New(opts Options) (Service, error) {
|
||||
return newService(opts)
|
||||
}
|
||||
218
api/pkg/vault/managedkey/service.go
Normal file
218
api/pkg/vault/managedkey/service.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package managedkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/vault/kv"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const defaultComponent = "vault key manager"
|
||||
|
||||
type service struct {
|
||||
logger mlogger.Logger
|
||||
component string
|
||||
store kv.Client
|
||||
keyPrefix string
|
||||
}
|
||||
|
||||
func newService(opts Options) (Service, error) {
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
component := strings.TrimSpace(opts.Component)
|
||||
if component == "" {
|
||||
component = defaultComponent
|
||||
}
|
||||
|
||||
store, err := kv.New(kv.Options{
|
||||
Logger: logger,
|
||||
Config: kv.Config{
|
||||
Address: opts.Config.Address,
|
||||
TokenEnv: opts.Config.TokenEnv,
|
||||
Namespace: opts.Config.Namespace,
|
||||
MountPath: opts.Config.MountPath,
|
||||
},
|
||||
Component: component,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPrefix := strings.Trim(strings.TrimSpace(opts.Config.KeyPrefix), "/")
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = strings.Trim(strings.TrimSpace(opts.DefaultKeyPrefix), "/")
|
||||
}
|
||||
if keyPrefix == "" {
|
||||
keyPrefix = "wallets"
|
||||
}
|
||||
|
||||
return &service{
|
||||
logger: logger.Named("vault"),
|
||||
component: component,
|
||||
store: store,
|
||||
keyPrefix: keyPrefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) CreateManagedWalletKey(ctx context.Context, walletRef, network string) (*ManagedWalletKey, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
network = strings.TrimSpace(network)
|
||||
if walletRef == "" {
|
||||
s.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network))
|
||||
return nil, merrors.InvalidArgument(s.component+": walletRef is required", "wallet_ref")
|
||||
}
|
||||
if network == "" {
|
||||
s.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||
return nil, merrors.InvalidArgument(s.component+": network is required", "network")
|
||||
}
|
||||
|
||||
privateKey, err := crypto.GenerateKey()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
return nil, merrors.Internal(s.component + ": failed to generate key: " + err.Error())
|
||||
}
|
||||
|
||||
privateKeyBytes := crypto.FromECDSA(privateKey)
|
||||
publicKey := privateKey.PublicKey
|
||||
publicKeyBytes := crypto.FromECDSAPub(&publicKey)
|
||||
publicKeyHex := hex.EncodeToString(publicKeyBytes)
|
||||
address := strings.ToLower(crypto.PubkeyToAddress(publicKey).Hex())
|
||||
keyID := s.BuildKeyID(network, walletRef)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"private_key": hex.EncodeToString(privateKeyBytes),
|
||||
"public_key": hex.EncodeToString(publicKeyBytes),
|
||||
"address": address,
|
||||
"network": strings.ToLower(network),
|
||||
}
|
||||
if err := s.store.Put(ctx, keyID, payload); err != nil {
|
||||
s.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
|
||||
s.logger.Info("Managed wallet key created", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.String("address", address))
|
||||
|
||||
return &ManagedWalletKey{
|
||||
KeyID: keyID,
|
||||
Address: address,
|
||||
PublicKey: publicKeyHex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) SignEVMTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
||||
if strings.TrimSpace(keyID) == "" {
|
||||
s.logger.Warn("Signing failed: empty key id")
|
||||
return nil, merrors.InvalidArgument(s.component+": keyID is required", "key_id")
|
||||
}
|
||||
if tx == nil {
|
||||
s.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument(s.component+": transaction is nil", "transaction")
|
||||
}
|
||||
if chainID == nil {
|
||||
s.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument(s.component+": chainID is nil", "chain_id")
|
||||
}
|
||||
|
||||
material, err := s.LoadKeyMaterial(ctx, keyID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
||||
if err != nil {
|
||||
s.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal(s.component + ": invalid key material: " + err.Error())
|
||||
}
|
||||
defer zeroBytes(keyBytes)
|
||||
|
||||
privateKey, err := crypto.ToECDSA(keyBytes)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal(s.component + ": failed to construct private key: " + err.Error())
|
||||
}
|
||||
|
||||
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal(s.component + ": failed to sign transaction: " + err.Error())
|
||||
}
|
||||
|
||||
s.logger.Info("Transaction signed with managed key", zap.String("key_id", keyID), zap.String("network", material.Network), zap.String("tx_hash", signed.Hash().Hex()))
|
||||
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (s *service) LoadKeyMaterial(ctx context.Context, keyID string) (*Material, error) {
|
||||
data, err := s.store.Get(ctx, keyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretPath := strings.Trim(strings.TrimPrefix(strings.TrimSpace(keyID), "/"), "/")
|
||||
privateKey, err := fieldAsString(data, "private_key", secretPath, s.component)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publicKey, err := fieldAsString(data, "public_key", secretPath, s.component)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address, err := fieldAsString(data, "address", secretPath, s.component)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
network, err := fieldAsString(data, "network", secretPath, s.component)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Material{
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
Address: address,
|
||||
Network: network,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) BuildKeyID(network, walletRef string) string {
|
||||
net := strings.Trim(strings.ToLower(strings.TrimSpace(network)), "/")
|
||||
ref := strings.Trim(strings.TrimSpace(walletRef), "/")
|
||||
return path.Join(s.keyPrefix, net, ref)
|
||||
}
|
||||
|
||||
func fieldAsString(data map[string]interface{}, field, secretPath, component string) (string, error) {
|
||||
value, ok := data[field]
|
||||
if !ok {
|
||||
return "", merrors.Internal(component + ": secret " + secretPath + " missing " + field)
|
||||
}
|
||||
str, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(str) == "" {
|
||||
return "", merrors.Internal(component + ": secret " + secretPath + " invalid " + field)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
func zeroBytes(data []byte) {
|
||||
for i := range data {
|
||||
data[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
var _ Service = (*service)(nil)
|
||||
@@ -12,7 +12,7 @@ Docker Compose + Makefile build system for local development.
|
||||
**Services:**
|
||||
- Discovery, Ledger, Billing Fees, Billing Documents, FX Oracle, Payments Orchestrator
|
||||
- Chain Gateway, MNTX Gateway, TGSettle Gateway
|
||||
- FX Ingestor, Notification, BFF (Server), Frontend
|
||||
- FX Ingestor, Notification, BFF (Server), Callbacks, Frontend
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -60,10 +60,14 @@ make status # Check service status
|
||||
Examples:
|
||||
- Blockchain private keys (Chain Gateway)
|
||||
- External API keys (MNTX, TGSettle)
|
||||
- Webhook signing secrets (Callbacks)
|
||||
- Production-like secrets
|
||||
|
||||
Infrastructure (MongoDB, NATS) uses plain `.env.dev` credentials.
|
||||
|
||||
Callbacks, Chain, and TRON run Vault Agent sidecars with AppRole.
|
||||
Set the corresponding `*_VAULT_ROLE_ID` and `*_VAULT_SECRET_ID` values in `.env.dev`.
|
||||
|
||||
## Network
|
||||
|
||||
All services on `sendico-dev` network. Vault also on `cicd` network to connect to infra Vault if needed.
|
||||
|
||||
39
ci/dev/callbacks.dockerfile
Normal file
39
ci/dev/callbacks.dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# Development Dockerfile for Callbacks Service with Air hot reload
|
||||
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
RUN apk add --no-cache bash git build-base protoc protobuf-dev && \
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
|
||||
go install github.com/air-verse/air@latest
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY api/proto ./api/proto
|
||||
COPY api/pkg ./api/pkg
|
||||
COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/
|
||||
RUN bash ci/scripts/proto/generate.sh
|
||||
|
||||
# Runtime stage for development with Air
|
||||
FROM golang:alpine
|
||||
|
||||
RUN apk add --no-cache bash git build-base && \
|
||||
go install github.com/air-verse/air@latest
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy generated proto and pkg from builder
|
||||
COPY --from=builder /src/api/proto ./api/proto
|
||||
COPY --from=builder /src/api/pkg ./api/pkg
|
||||
|
||||
# Copy vault-aware entrypoint wrapper
|
||||
COPY api/edge/callbacks/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Source code will be mounted at runtime
|
||||
WORKDIR /src/api/edge/callbacks
|
||||
|
||||
EXPOSE 9420
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["air", "-c", ".air.toml", "--", "-config.file", "/app/config.yml", "-debug"]
|
||||
20
ci/dev/vault-agent/callbacks.hcl
Normal file
20
ci/dev/vault-agent/callbacks.hcl
Normal file
@@ -0,0 +1,20 @@
|
||||
vault {
|
||||
address = "http://dev-vault:8200"
|
||||
}
|
||||
|
||||
auto_auth {
|
||||
method "approle" {
|
||||
mount_path = "auth/approle"
|
||||
config = {
|
||||
role_id_file_path = "/run/vault/role_id"
|
||||
secret_id_file_path = "/run/vault/secret_id"
|
||||
}
|
||||
}
|
||||
|
||||
sink "file" {
|
||||
config = {
|
||||
path = "/run/vault/token"
|
||||
mode = 0600
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,20 @@ BFF_COMPOSE_PROJECT=sendico-bff
|
||||
BFF_SERVICE_NAME=sendico_bff
|
||||
BFF_HTTP_PORT=8080
|
||||
|
||||
# Callbacks service
|
||||
CALLBACKS_DIR=callbacks
|
||||
CALLBACKS_COMPOSE_PROJECT=sendico-callbacks
|
||||
CALLBACKS_SERVICE_NAME=sendico_callbacks
|
||||
CALLBACKS_METRICS_PORT=9420
|
||||
CALLBACKS_VAULT_SECRET_PATH=sendico/edge/callbacks/vault
|
||||
|
||||
# Callbacks Mongo settings
|
||||
CALLBACKS_MONGO_HOST=sendico_db1
|
||||
CALLBACKS_MONGO_PORT=27017
|
||||
CALLBACKS_MONGO_DATABASE=callbacks
|
||||
CALLBACKS_MONGO_AUTH_SOURCE=admin
|
||||
CALLBACKS_MONGO_REPLICA_SET=sendico-rs
|
||||
|
||||
# Chain gateway stack
|
||||
CHAIN_GATEWAY_DIR=chain_gateway
|
||||
CHAIN_GATEWAY_COMPOSE_PROJECT=sendico-chain-gateway
|
||||
|
||||
42
ci/prod/compose/callbacks.dockerfile
Normal file
42
ci/prod/compose/callbacks.dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
FROM golang:alpine AS build
|
||||
ARG APP_VERSION=dev
|
||||
ARG GIT_REV=unknown
|
||||
ARG BUILD_BRANCH=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
ARG BUILD_USER=ci
|
||||
ENV GO111MODULE=on
|
||||
ENV PATH="/go/bin:${PATH}"
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN apk add --no-cache bash git build-base protoc protobuf-dev \
|
||||
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
|
||||
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \
|
||||
&& bash ci/scripts/proto/generate.sh
|
||||
WORKDIR /src/api/edge/callbacks
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags "\
|
||||
-s -w \
|
||||
-X github.com/tech/sendico/edge/callbacks/internal/appversion.Version=${APP_VERSION} \
|
||||
-X github.com/tech/sendico/edge/callbacks/internal/appversion.Revision=${GIT_REV} \
|
||||
-X github.com/tech/sendico/edge/callbacks/internal/appversion.Branch=${BUILD_BRANCH} \
|
||||
-X github.com/tech/sendico/edge/callbacks/internal/appversion.BuildUser=${BUILD_USER} \
|
||||
-X github.com/tech/sendico/edge/callbacks/internal/appversion.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/callbacks .
|
||||
|
||||
FROM alpine:latest AS runtime
|
||||
RUN apk add --no-cache ca-certificates tzdata wget
|
||||
WORKDIR /app
|
||||
COPY api/edge/callbacks/config.yml /app/config.yml
|
||||
COPY api/edge/callbacks/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --from=build /out/callbacks /app/callbacks
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
EXPOSE 9420
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["/app/callbacks","--config.file","/app/config.yml"]
|
||||
88
ci/prod/compose/callbacks.yml
Normal file
88
ci/prod/compose/callbacks.yml
Normal file
@@ -0,0 +1,88 @@
|
||||
# Compose v2 - Edge Callbacks
|
||||
|
||||
x-common-env: &common-env
|
||||
env_file:
|
||||
- ../env/.env.runtime
|
||||
- ../env/.env.version
|
||||
|
||||
volumes:
|
||||
callbacks-vault-run:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: tmpfs
|
||||
device: tmpfs
|
||||
o: size=8m,uid=0,gid=0,mode=0700
|
||||
|
||||
networks:
|
||||
sendico-net:
|
||||
external: true
|
||||
name: sendico-net
|
||||
|
||||
services:
|
||||
sendico_callbacks:
|
||||
<<: *common-env
|
||||
container_name: sendico-callbacks
|
||||
restart: unless-stopped
|
||||
image: ${REGISTRY_URL}/edge/callbacks:${APP_V}
|
||||
pull_policy: always
|
||||
environment:
|
||||
CALLBACKS_MONGO_HOST: ${CALLBACKS_MONGO_HOST}
|
||||
CALLBACKS_MONGO_PORT: ${CALLBACKS_MONGO_PORT}
|
||||
CALLBACKS_MONGO_DATABASE: ${CALLBACKS_MONGO_DATABASE}
|
||||
CALLBACKS_MONGO_USER: ${CALLBACKS_MONGO_USER}
|
||||
CALLBACKS_MONGO_PASSWORD: ${CALLBACKS_MONGO_PASSWORD}
|
||||
CALLBACKS_MONGO_AUTH_SOURCE: ${CALLBACKS_MONGO_AUTH_SOURCE}
|
||||
CALLBACKS_MONGO_REPLICA_SET: ${CALLBACKS_MONGO_REPLICA_SET}
|
||||
NATS_URL: ${NATS_URL}
|
||||
NATS_HOST: ${NATS_HOST}
|
||||
NATS_PORT: ${NATS_PORT}
|
||||
NATS_USER: ${NATS_USER}
|
||||
NATS_PASSWORD: ${NATS_PASSWORD}
|
||||
CALLBACKS_METRICS_PORT: ${CALLBACKS_METRICS_PORT}
|
||||
VAULT_ADDR: ${VAULT_ADDR}
|
||||
VAULT_TOKEN_FILE: /run/vault/token
|
||||
command: ["--config.file", "/app/config.yml"]
|
||||
ports:
|
||||
- "0.0.0.0:${CALLBACKS_METRICS_PORT}:${CALLBACKS_METRICS_PORT}"
|
||||
volumes:
|
||||
- callbacks-vault-run:/run/vault:ro
|
||||
depends_on:
|
||||
sendico_callbacks_vault_agent:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL","wget -qO- http://localhost:${CALLBACKS_METRICS_PORT}/health | grep -q '\"status\":\"ok\"'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- sendico-net
|
||||
|
||||
sendico_callbacks_vault_agent:
|
||||
<<: *common-env
|
||||
container_name: sendico-callbacks-vault-agent
|
||||
restart: unless-stopped
|
||||
image: hashicorp/vault:latest
|
||||
pull_policy: always
|
||||
cap_add: ["IPC_LOCK"]
|
||||
environment:
|
||||
VAULT_ADDR: ${VAULT_ADDR}
|
||||
CALLBACKS_VAULT_ROLE_ID: ${CALLBACKS_VAULT_ROLE_ID}
|
||||
CALLBACKS_VAULT_SECRET_ID: ${CALLBACKS_VAULT_SECRET_ID}
|
||||
command: >
|
||||
sh -lc 'set -euo pipefail; umask 077;
|
||||
: "${CALLBACKS_VAULT_ROLE_ID:?}"; : "${CALLBACKS_VAULT_SECRET_ID:?}";
|
||||
printf "%s" "$CALLBACKS_VAULT_ROLE_ID" > /run/vault/role_id;
|
||||
printf "%s" "$CALLBACKS_VAULT_SECRET_ID" > /run/vault/secret_id;
|
||||
unset CALLBACKS_VAULT_ROLE_ID CALLBACKS_VAULT_SECRET_ID;
|
||||
exec vault agent -config=/etc/vault/agent/callbacks.hcl'
|
||||
volumes:
|
||||
- ./vault-agent/callbacks.hcl:/etc/vault/agent/callbacks.hcl:ro
|
||||
- callbacks-vault-run:/run/vault
|
||||
healthcheck:
|
||||
test: ["CMD","test","-s","/run/vault/token"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
networks:
|
||||
- sendico-net
|
||||
20
ci/prod/compose/vault-agent/callbacks.hcl
Normal file
20
ci/prod/compose/vault-agent/callbacks.hcl
Normal file
@@ -0,0 +1,20 @@
|
||||
vault {
|
||||
address = "https://vault.sendico.io"
|
||||
}
|
||||
|
||||
auto_auth {
|
||||
method "approle" {
|
||||
mount_path = "auth/approle"
|
||||
config = {
|
||||
role_id_file_path = "/run/vault/role_id"
|
||||
secret_id_file_path = "/run/vault/secret_id"
|
||||
}
|
||||
}
|
||||
|
||||
sink "file" {
|
||||
config = {
|
||||
path = "/run/vault/token"
|
||||
mode = 0600
|
||||
}
|
||||
}
|
||||
}
|
||||
157
ci/prod/scripts/deploy/callbacks.sh
Executable file
157
ci/prod/scripts/deploy/callbacks.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x
|
||||
trap 'echo "[deploy-callbacks] error at line $LINENO" >&2' ERR
|
||||
|
||||
: "${REMOTE_BASE:?missing REMOTE_BASE}"
|
||||
: "${SSH_USER:?missing SSH_USER}"
|
||||
: "${SSH_HOST:?missing SSH_HOST}"
|
||||
: "${CALLBACKS_DIR:?missing CALLBACKS_DIR}"
|
||||
: "${CALLBACKS_COMPOSE_PROJECT:?missing CALLBACKS_COMPOSE_PROJECT}"
|
||||
: "${CALLBACKS_SERVICE_NAME:?missing CALLBACKS_SERVICE_NAME}"
|
||||
|
||||
REMOTE_DIR="${REMOTE_BASE%/}/${CALLBACKS_DIR}"
|
||||
REMOTE_TARGET="${SSH_USER}@${SSH_HOST}"
|
||||
COMPOSE_FILE="callbacks.yml"
|
||||
SERVICE_NAMES="${CALLBACKS_SERVICE_NAME}"
|
||||
|
||||
REQUIRED_SECRETS=(
|
||||
CALLBACKS_MONGO_USER
|
||||
CALLBACKS_MONGO_PASSWORD
|
||||
CALLBACKS_VAULT_ROLE_ID
|
||||
CALLBACKS_VAULT_SECRET_ID
|
||||
NATS_USER
|
||||
NATS_PASSWORD
|
||||
NATS_URL
|
||||
)
|
||||
|
||||
for var in "${REQUIRED_SECRETS[@]}"; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
echo "missing required secret env: ${var}" >&2
|
||||
exit 65
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -s .env.version ]]; then
|
||||
echo ".env.version is missing; run version step first" >&2
|
||||
exit 66
|
||||
fi
|
||||
|
||||
b64enc() {
|
||||
printf '%s' "$1" | base64 | tr -d '\n'
|
||||
}
|
||||
|
||||
CALLBACKS_MONGO_USER_B64="$(b64enc "${CALLBACKS_MONGO_USER}")"
|
||||
CALLBACKS_MONGO_PASSWORD_B64="$(b64enc "${CALLBACKS_MONGO_PASSWORD}")"
|
||||
CALLBACKS_VAULT_ROLE_ID_B64="$(b64enc "${CALLBACKS_VAULT_ROLE_ID}")"
|
||||
CALLBACKS_VAULT_SECRET_ID_B64="$(b64enc "${CALLBACKS_VAULT_SECRET_ID}")"
|
||||
NATS_USER_B64="$(b64enc "${NATS_USER}")"
|
||||
NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")"
|
||||
NATS_URL_B64="$(b64enc "${NATS_URL}")"
|
||||
|
||||
SSH_OPTS=(
|
||||
-i /root/.ssh/id_rsa
|
||||
-o StrictHostKeyChecking=no
|
||||
-o UserKnownHostsFile=/dev/null
|
||||
-o LogLevel=ERROR
|
||||
-q
|
||||
)
|
||||
if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then
|
||||
SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv)
|
||||
fi
|
||||
|
||||
RSYNC_FLAGS=(-az --delete)
|
||||
[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete)
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/{compose,env}"
|
||||
|
||||
rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/"
|
||||
rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime"
|
||||
rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version"
|
||||
|
||||
SERVICES_LINE="${SERVICE_NAMES}"
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \
|
||||
REMOTE_DIR="$REMOTE_DIR" \
|
||||
COMPOSE_FILE="$COMPOSE_FILE" \
|
||||
COMPOSE_PROJECT="$CALLBACKS_COMPOSE_PROJECT" \
|
||||
SERVICES_LINE="$SERVICES_LINE" \
|
||||
CALLBACKS_MONGO_USER_B64="$CALLBACKS_MONGO_USER_B64" \
|
||||
CALLBACKS_MONGO_PASSWORD_B64="$CALLBACKS_MONGO_PASSWORD_B64" \
|
||||
CALLBACKS_VAULT_ROLE_ID_B64="$CALLBACKS_VAULT_ROLE_ID_B64" \
|
||||
CALLBACKS_VAULT_SECRET_ID_B64="$CALLBACKS_VAULT_SECRET_ID_B64" \
|
||||
NATS_USER_B64="$NATS_USER_B64" \
|
||||
NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \
|
||||
NATS_URL_B64="$NATS_URL_B64" \
|
||||
bash -s <<'EOSSH'
|
||||
set -euo pipefail
|
||||
cd "${REMOTE_DIR}/compose"
|
||||
set -a
|
||||
. ../env/.env.runtime
|
||||
load_kv_file() {
|
||||
local file="$1"
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then
|
||||
local key="${line%%=*}"
|
||||
local value="${line#*=}"
|
||||
key="$(printf '%s' "$key" | tr -d '[:space:]')"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
if [[ -n "$key" ]]; then
|
||||
export "$key=$value"
|
||||
fi
|
||||
fi
|
||||
done <"$file"
|
||||
}
|
||||
load_kv_file ../env/.env.version
|
||||
set +a
|
||||
|
||||
if base64 -d >/dev/null 2>&1 <<<'AA=='; then
|
||||
BASE64_DECODE_FLAG='-d'
|
||||
else
|
||||
BASE64_DECODE_FLAG='--decode'
|
||||
fi
|
||||
|
||||
decode_b64() {
|
||||
val="$1"
|
||||
if [[ -z "$val" ]]; then
|
||||
printf ''
|
||||
return
|
||||
fi
|
||||
printf '%s' "$val" | base64 "${BASE64_DECODE_FLAG}"
|
||||
}
|
||||
|
||||
CALLBACKS_MONGO_USER="$(decode_b64 "$CALLBACKS_MONGO_USER_B64")"
|
||||
CALLBACKS_MONGO_PASSWORD="$(decode_b64 "$CALLBACKS_MONGO_PASSWORD_B64")"
|
||||
CALLBACKS_VAULT_ROLE_ID="$(decode_b64 "$CALLBACKS_VAULT_ROLE_ID_B64")"
|
||||
CALLBACKS_VAULT_SECRET_ID="$(decode_b64 "$CALLBACKS_VAULT_SECRET_ID_B64")"
|
||||
NATS_USER="$(decode_b64 "$NATS_USER_B64")"
|
||||
NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")"
|
||||
NATS_URL="$(decode_b64 "$NATS_URL_B64")"
|
||||
|
||||
export CALLBACKS_MONGO_USER CALLBACKS_MONGO_PASSWORD
|
||||
export CALLBACKS_VAULT_ROLE_ID CALLBACKS_VAULT_SECRET_ID
|
||||
export NATS_USER NATS_PASSWORD NATS_URL
|
||||
COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT"
|
||||
export COMPOSE_PROJECT_NAME
|
||||
read -r -a SERVICES <<<"${SERVICES_LINE}"
|
||||
|
||||
pull_cmd=(docker compose -f "$COMPOSE_FILE" pull)
|
||||
up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans)
|
||||
ps_cmd=(docker compose -f "$COMPOSE_FILE" ps)
|
||||
if [[ "${#SERVICES[@]}" -gt 0 ]]; then
|
||||
pull_cmd+=("${SERVICES[@]}")
|
||||
up_cmd+=("${SERVICES[@]}")
|
||||
ps_cmd+=("${SERVICES[@]}")
|
||||
fi
|
||||
|
||||
"${pull_cmd[@]}"
|
||||
"${up_cmd[@]}"
|
||||
"${ps_cmd[@]}"
|
||||
|
||||
date -Is > .last_deploy
|
||||
logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}"
|
||||
EOSSH
|
||||
85
ci/scripts/callbacks/build-image.sh
Executable file
85
ci/scripts/callbacks/build-image.sh
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if ! set -o pipefail 2>/dev/null; then
|
||||
:
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
sh ci/scripts/common/ensure_env_version.sh
|
||||
|
||||
normalize_env_file() {
|
||||
file="$1"
|
||||
tmp="${file}.tmp.$$"
|
||||
tr -d '\r' <"$file" >"$tmp"
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
load_env_file() {
|
||||
file="$1"
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
key="$(printf '%s' "$key" | tr -d '[:space:]')"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
export "$key=$value"
|
||||
done <"$file"
|
||||
}
|
||||
|
||||
CALLBACKS_ENV_NAME="${CALLBACKS_ENV:-prod}"
|
||||
RUNTIME_ENV_FILE="./ci/${CALLBACKS_ENV_NAME}/.env.runtime"
|
||||
|
||||
if [ ! -f "${RUNTIME_ENV_FILE}" ]; then
|
||||
echo "[callbacks-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
normalize_env_file "${RUNTIME_ENV_FILE}"
|
||||
normalize_env_file ./.env.version
|
||||
|
||||
load_env_file "${RUNTIME_ENV_FILE}"
|
||||
load_env_file ./.env.version
|
||||
|
||||
REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}"
|
||||
APP_V="${APP_V:?missing APP_V}"
|
||||
CALLBACKS_DOCKERFILE="${CALLBACKS_DOCKERFILE:?missing CALLBACKS_DOCKERFILE}"
|
||||
CALLBACKS_IMAGE_PATH="${CALLBACKS_IMAGE_PATH:?missing CALLBACKS_IMAGE_PATH}"
|
||||
|
||||
REGISTRY_HOST="${REGISTRY_URL#http://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
||||
REGISTRY_USER="$(cat secrets/REGISTRY_USER)"
|
||||
REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)"
|
||||
: "${REGISTRY_USER:?missing registry user}"
|
||||
: "${REGISTRY_PASSWORD:?missing registry password}"
|
||||
|
||||
mkdir -p /kaniko/.docker
|
||||
AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
||||
cat <<JSON >/kaniko/.docker/config.json
|
||||
{
|
||||
"auths": {
|
||||
"https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" }
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
BUILD_CONTEXT="${CALLBACKS_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}"
|
||||
if [ ! -d "${BUILD_CONTEXT}" ]; then
|
||||
BUILD_CONTEXT="/workspace"
|
||||
fi
|
||||
|
||||
/kaniko/executor \
|
||||
--context "${BUILD_CONTEXT}" \
|
||||
--dockerfile "${CALLBACKS_DOCKERFILE}" \
|
||||
--destination "${REGISTRY_URL}/${CALLBACKS_IMAGE_PATH}:${APP_V}" \
|
||||
--build-arg APP_VERSION="${APP_V}" \
|
||||
--build-arg GIT_REV="${GIT_REV}" \
|
||||
--build-arg BUILD_BRANCH="${BUILD_BRANCH}" \
|
||||
--build-arg BUILD_DATE="${BUILD_DATE}" \
|
||||
--build-arg BUILD_USER="${BUILD_USER}" \
|
||||
--single-snapshot
|
||||
66
ci/scripts/callbacks/deploy.sh
Executable file
66
ci/scripts/callbacks/deploy.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if ! set -o pipefail 2>/dev/null; then
|
||||
:
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
sh ci/scripts/common/ensure_env_version.sh
|
||||
|
||||
normalize_env_file() {
|
||||
file="$1"
|
||||
tmp="${file}.tmp.$$"
|
||||
tr -d '\r' <"$file" >"$tmp"
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
load_env_file() {
|
||||
file="$1"
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
key="$(printf '%s' "$key" | tr -d '[:space:]')"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
export "$key=$value"
|
||||
done <"$file"
|
||||
}
|
||||
|
||||
. ci/scripts/common/nats_env.sh
|
||||
|
||||
CALLBACKS_ENV_NAME="${CALLBACKS_ENV:-prod}"
|
||||
RUNTIME_ENV_FILE="./ci/${CALLBACKS_ENV_NAME}/.env.runtime"
|
||||
|
||||
if [ ! -f "${RUNTIME_ENV_FILE}" ]; then
|
||||
echo "[callbacks-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
normalize_env_file "${RUNTIME_ENV_FILE}"
|
||||
normalize_env_file ./.env.version
|
||||
|
||||
load_env_file "${RUNTIME_ENV_FILE}"
|
||||
load_env_file ./.env.version
|
||||
|
||||
CALLBACKS_MONGO_SECRET_PATH="${CALLBACKS_MONGO_SECRET_PATH:?missing CALLBACKS_MONGO_SECRET_PATH}"
|
||||
CALLBACKS_VAULT_SECRET_PATH="${CALLBACKS_VAULT_SECRET_PATH:?missing CALLBACKS_VAULT_SECRET_PATH}"
|
||||
|
||||
export CALLBACKS_MONGO_USER="$(./ci/vlt kv_get kv "${CALLBACKS_MONGO_SECRET_PATH}" user)"
|
||||
export CALLBACKS_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CALLBACKS_MONGO_SECRET_PATH}" password)"
|
||||
export CALLBACKS_VAULT_ROLE_ID="$(./ci/vlt kv_get kv "${CALLBACKS_VAULT_SECRET_PATH}" role_id)"
|
||||
export CALLBACKS_VAULT_SECRET_ID="$(./ci/vlt kv_get kv "${CALLBACKS_VAULT_SECRET_PATH}" secret_id)"
|
||||
if [ -z "${CALLBACKS_VAULT_ROLE_ID}" ] || [ -z "${CALLBACKS_VAULT_SECRET_ID}" ]; then
|
||||
echo "[callbacks-deploy] vault approle creds are empty for path ${CALLBACKS_VAULT_SECRET_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
load_nats_env
|
||||
|
||||
bash ci/prod/scripts/bootstrap/network.sh
|
||||
bash ci/prod/scripts/deploy/callbacks.sh
|
||||
@@ -33,6 +33,12 @@ case "${SERVICE}" in
|
||||
modules="
|
||||
api/pkg
|
||||
api/edge/bff
|
||||
"
|
||||
;;
|
||||
callbacks)
|
||||
modules="
|
||||
api/pkg
|
||||
api/edge/callbacks
|
||||
"
|
||||
;;
|
||||
billing_documents)
|
||||
|
||||
@@ -29,6 +29,12 @@ volumes:
|
||||
type: tmpfs
|
||||
device: tmpfs
|
||||
o: size=8m,uid=0,gid=0,mode=0700
|
||||
dev-callbacks-vault-run:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: tmpfs
|
||||
device: tmpfs
|
||||
o: size=8m,uid=0,gid=0,mode=0700
|
||||
|
||||
# ============================================================================
|
||||
# INFRASTRUCTURE SERVICES
|
||||
@@ -843,6 +849,80 @@ services:
|
||||
MONGO_AUTH_SOURCE: admin
|
||||
MONGO_REPLICA_SET: dev-rs
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Callbacks Vault Agent (sidecar for AppRole authentication)
|
||||
# --------------------------------------------------------------------------
|
||||
dev-callbacks-vault-agent:
|
||||
<<: *common-env
|
||||
image: hashicorp/vault:latest
|
||||
container_name: dev-callbacks-vault-agent
|
||||
restart: unless-stopped
|
||||
cap_add: ["IPC_LOCK"]
|
||||
environment:
|
||||
VAULT_ADDR: ${VAULT_ADDR}
|
||||
CALLBACKS_VAULT_ROLE_ID: ${CALLBACKS_VAULT_ROLE_ID}
|
||||
CALLBACKS_VAULT_SECRET_ID: ${CALLBACKS_VAULT_SECRET_ID}
|
||||
command: >
|
||||
sh -c 'set -eu; umask 077;
|
||||
: "$$CALLBACKS_VAULT_ROLE_ID"; : "$$CALLBACKS_VAULT_SECRET_ID";
|
||||
echo "$$CALLBACKS_VAULT_ROLE_ID" > /run/vault/role_id;
|
||||
echo "$$CALLBACKS_VAULT_SECRET_ID" > /run/vault/secret_id;
|
||||
unset CALLBACKS_VAULT_ROLE_ID CALLBACKS_VAULT_SECRET_ID;
|
||||
exec vault agent -config=/etc/vault/agent/callbacks.hcl'
|
||||
volumes:
|
||||
- ./ci/dev/vault-agent/callbacks.hcl:/etc/vault/agent/callbacks.hcl:ro
|
||||
- dev-callbacks-vault-run:/run/vault
|
||||
depends_on:
|
||||
dev-vault: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "test", "-s", "/run/vault/token"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
networks:
|
||||
- sendico-dev
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Callbacks Service
|
||||
# --------------------------------------------------------------------------
|
||||
dev-callbacks:
|
||||
<<: *common-env
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/dev/callbacks.dockerfile
|
||||
image: sendico-dev/callbacks:latest
|
||||
container_name: dev-callbacks
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
dev-mongo-init: { condition: service_completed_successfully }
|
||||
dev-nats: { condition: service_started }
|
||||
dev-vault: { condition: service_healthy }
|
||||
dev-callbacks-vault-agent: { condition: service_healthy }
|
||||
volumes:
|
||||
- ./api/edge/callbacks:/src/api/edge/callbacks
|
||||
- ./api/edge/callbacks/config.dev.yml:/app/config.yml:ro
|
||||
- dev-callbacks-vault-run:/run/vault:ro
|
||||
ports:
|
||||
- "9420:9420"
|
||||
networks:
|
||||
- sendico-dev
|
||||
environment:
|
||||
CALLBACKS_MONGO_HOST: dev-mongo-1
|
||||
CALLBACKS_MONGO_PORT: 27017
|
||||
CALLBACKS_MONGO_DATABASE: callbacks
|
||||
CALLBACKS_MONGO_USER: ${MONGO_USER}
|
||||
CALLBACKS_MONGO_PASSWORD: ${MONGO_PASSWORD}
|
||||
CALLBACKS_MONGO_AUTH_SOURCE: admin
|
||||
CALLBACKS_MONGO_REPLICA_SET: dev-rs
|
||||
NATS_HOST: dev-nats
|
||||
NATS_PORT: 4222
|
||||
NATS_USER: ${NATS_USER}
|
||||
NATS_PASSWORD: ${NATS_PASSWORD}
|
||||
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
|
||||
CALLBACKS_METRICS_PORT: 9420
|
||||
VAULT_ADDR: ${VAULT_ADDR}
|
||||
VAULT_TOKEN_FILE: /run/vault/token
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# BFF (Backend for Frontend / Server) Service
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user