discovery service

This commit is contained in:
Stephan D
2026-01-02 02:44:01 +01:00
parent 97ba7500dc
commit ea1c69f14a
47 changed files with 2799 additions and 701 deletions

72
.woodpecker/discovery.yml Normal file
View File

@@ -0,0 +1,72 @@
matrix:
include:
- DISCOVERY_IMAGE_PATH: discovery/service
DISCOVERY_DOCKERFILE: ci/prod/compose/discovery.dockerfile
DISCOVERY_ENV: prod
when:
- event: push
branch: main
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: 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: [ proto, secrets ]
commands:
- sh ci/scripts/discovery/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/discovery/deploy.sh

3
api/discovery/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
app

17
api/discovery/config.yml Normal file
View File

@@ -0,0 +1,17 @@
runtime:
shutdown_timeout_seconds: 15
metrics:
address: ":9405"
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: Discovery Service
max_reconnects: 10
reconnect_wait: 5

51
api/discovery/go.mod Normal file
View File

@@ -0,0 +1,51 @@
module github.com/tech/sendico/discovery
go 1.25.3
replace github.com/tech/sendico/pkg => ../pkg
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/prometheus/client_golang v1.23.2
github.com/tech/sendico/pkg v0.1.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.9.1 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // 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.4 // indirect
github.com/prometheus/procfs v0.19.2 // 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.mongodb.org/mongo-driver v1.17.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

225
api/discovery/go.sum Normal file
View File

@@ -0,0 +1,225 @@
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.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/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/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
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/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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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/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/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
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/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/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/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/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
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.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
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/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 v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
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-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,27 @@
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 {
vi := version.Info{
Program: "Sendico Discovery Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,47 @@
package serverimp
import (
"os"
"strings"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
const defaultMetricsAddress = ":9405"
type config struct {
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
Messaging *msg.Config `yaml:"messaging"`
Metrics *metricsConfig `yaml:"metrics"`
}
type metricsConfig struct {
Address string `yaml:"address"`
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {
cfg.Metrics.Address = defaultMetricsAddress
}
return cfg, nil
}

View File

@@ -0,0 +1,58 @@
package serverimp
import (
"github.com/tech/sendico/discovery/internal/appversion"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func (i *Imp) startDiscovery(cfg *config) error {
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
}
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
if err != nil {
return err
}
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
registry := discovery.NewRegistry()
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery))
if err != nil {
return err
}
svc.Start()
i.registrySvc = svc
announce := discovery.Announcement{
Service: "DISCOVERY",
InstanceID: discovery.InstanceID(),
Operations: []string{"discovery.lookup"},
Version: appversion.Create().Short(),
}
i.announcer = discovery.NewAnnouncer(i.logger, producer, string(mservice.Discovery), announce)
i.announcer.Start()
i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
return nil
}
func (i *Imp) stopDiscovery() {
if i == nil {
return
}
if i.announcer != nil {
i.announcer.Stop()
i.announcer = nil
}
if i.registrySvc != nil {
i.registrySvc.Stop()
i.registrySvc = nil
}
}

View File

@@ -0,0 +1,85 @@
package serverimp
import (
"context"
"errors"
"net"
"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"
"go.uber.org/zap"
)
func (i *Imp) startMetrics(cfg *metricsConfig) {
if i == nil {
return
}
address := ""
if cfg != nil {
address = strings.TrimSpace(cfg.Address)
}
if address == "" {
i.logger.Info("Metrics endpoint disabled")
return
}
listener, err := net.Listen("tcp", address)
if err != nil {
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
return
}
router := chi.NewRouter()
router.Handle("/metrics", promhttp.Handler())
var healthRouter routers.Health
if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil {
i.logger.Warn("Failed to initialise health router", zap.Error(err))
} else {
hr.SetStatus(health.SSStarting)
healthRouter = hr
}
i.metricsHealth = healthRouter
i.metricsSrv = &http.Server{
Addr: address,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
if healthRouter != nil {
healthRouter.SetStatus(health.SSRunning)
}
go func() {
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
if healthRouter != nil {
healthRouter.SetStatus(health.SSTerminating)
}
}
}()
}
func (i *Imp) shutdownMetrics(ctx context.Context) {
if i.metricsHealth != nil {
i.metricsHealth.SetStatus(health.SSTerminating)
i.metricsHealth.Finish()
i.metricsHealth = nil
}
if i.metricsSrv == nil {
return
}
if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
} else {
i.logger.Info("Metrics server stopped")
}
i.metricsSrv = nil
}

View File

@@ -0,0 +1,109 @@
package serverimp
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
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()
i.logger.Info("Starting discovery service", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
messagingDriver := "none"
if cfg.Messaging != nil {
messagingDriver = string(cfg.Messaging.Driver)
}
metricsAddress := ""
if cfg.Metrics != nil {
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
}
if metricsAddress == "" {
metricsAddress = "disabled"
}
i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress))
i.startMetrics(cfg.Metrics)
if err := i.startDiscovery(cfg); err != nil {
i.stopDiscovery()
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
i.shutdownMetrics(ctx)
cancel()
return err
}
i.logger.Info("Discovery service ready", zap.String("messaging_driver", messagingDriver))
<-i.stopCh
i.logger.Info("Discovery service stop signal received")
return nil
}
func (i *Imp) Shutdown() {
timeout := i.shutdownTimeout()
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
i.stopDiscovery()
i.signalStop()
if i.doneCh != nil {
<-i.doneCh
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.shutdownMetrics(ctx)
cancel()
i.logger.Info("Discovery service stopped")
}
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) shutdownTimeout() time.Duration {
if i.config != nil && i.config.Runtime != nil {
return i.config.Runtime.ShutdownTimeout()
}
return 15 * time.Second
}

View File

@@ -0,0 +1,28 @@
package serverimp
import (
"net/http"
"sync"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/mlogger"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
registrySvc *discovery.RegistryService
announcer *discovery.Announcer
metricsSrv *http.Server
metricsHealth routers.Health
stopOnce sync.Once
doneOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/discovery/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)
}

17
api/discovery/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/discovery/internal/appversion"
si "github.com/tech/sendico/discovery/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("main", appversion.Create(), factory)
}

View File

@@ -49,6 +49,18 @@ metrics:
enabled: true
address: ":9102"
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: FX Ingestor
max_reconnects: 10
reconnect_wait: 5
database:
driver: mongodb
settings:

View File

@@ -11,6 +11,7 @@ import (
"github.com/tech/sendico/fx/ingestor/internal/app"
"github.com/tech/sendico/fx/ingestor/internal/appversion"
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
"github.com/tech/sendico/pkg/discovery"
lf "github.com/tech/sendico/pkg/mlogger/factory"
"go.uber.org/zap"
)
@@ -25,6 +26,7 @@ func main() {
flag.Parse()
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
defer logger.Sync()
av := appversion.Create()

View File

@@ -36,6 +36,7 @@ api:
message_broker:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER

View File

@@ -0,0 +1,224 @@
package serverimp
import (
"strings"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/payments/rail"
"go.uber.org/zap"
)
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
if len(src) == 0 {
return nil
}
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
for key, route := range src {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
}
}
return result
}
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
result := make(map[string]string, len(src))
for key, account := range src {
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(account)
if k == "" || v == "" {
continue
}
result[k] = v
}
return result
}
func buildGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry {
static := buildGatewayInstances(logger, src)
staticRegistry := orchestrator.NewGatewayRegistry(logger, mntxClient, static)
discoveryRegistry := orchestrator.NewDiscoveryGatewayRegistry(logger, registry)
return orchestrator.NewCompositeGatewayRegistry(logger, staticRegistry, discoveryRegistry)
}
func buildRailGateways(chainClient chainclient.Client, src []gatewayInstanceConfig) map[string]rail.RailGateway {
if chainClient == nil || len(src) == 0 {
return nil
}
instances := buildGatewayInstances(nil, src)
if len(instances) == 0 {
return nil
}
result := map[string]rail.RailGateway{}
for _, inst := range instances {
if inst == nil || !inst.IsEnabled {
continue
}
if inst.Rail != model.RailCrypto {
continue
}
cfg := chainclient.RailGatewayConfig{
Rail: string(inst.Rail),
Network: inst.Network,
Capabilities: rail.RailCapabilities{
CanPayIn: inst.Capabilities.CanPayIn,
CanPayOut: inst.Capabilities.CanPayOut,
CanReadBalance: inst.Capabilities.CanReadBalance,
CanSendFee: inst.Capabilities.CanSendFee,
RequiresObserveConfirm: inst.Capabilities.RequiresObserveConfirm,
},
}
result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg)
}
if len(result) == 0 {
return nil
}
return result
}
func buildGatewayInstances(logger mlogger.Logger, src []gatewayInstanceConfig) []*model.GatewayInstanceDescriptor {
if len(src) == 0 {
return nil
}
if logger != nil {
logger = logger.Named("gateway_instances")
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(src))
for _, cfg := range src {
id := strings.TrimSpace(cfg.ID)
if id == "" {
if logger != nil {
logger.Warn("Gateway instance skipped: missing id")
}
continue
}
rail := parseRail(cfg.Rail)
if rail == model.RailUnspecified {
if logger != nil {
logger.Warn("Gateway instance skipped: invalid rail", zap.String("id", id), zap.String("rail", cfg.Rail))
}
continue
}
enabled := true
if cfg.IsEnabled != nil {
enabled = *cfg.IsEnabled
}
result = append(result, &model.GatewayInstanceDescriptor{
ID: id,
Rail: rail,
Network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
Currencies: normalizeCurrencies(cfg.Currencies),
Capabilities: model.RailCapabilities{
CanPayIn: cfg.Capabilities.CanPayIn,
CanPayOut: cfg.Capabilities.CanPayOut,
CanReadBalance: cfg.Capabilities.CanReadBalance,
CanSendFee: cfg.Capabilities.CanSendFee,
RequiresObserveConfirm: cfg.Capabilities.RequiresObserveConfirm,
},
Limits: buildGatewayLimits(cfg.Limits),
Version: strings.TrimSpace(cfg.Version),
IsEnabled: enabled,
})
}
return result
}
func parseRail(value string) model.Rail {
switch strings.ToUpper(strings.TrimSpace(value)) {
case string(model.RailCrypto):
return model.RailCrypto
case string(model.RailProviderSettlement):
return model.RailProviderSettlement
case string(model.RailLedger):
return model.RailLedger
case string(model.RailCardPayout):
return model.RailCardPayout
case string(model.RailFiatOnRamp):
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func normalizeCurrencies(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" || seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
return result
}
func buildGatewayLimits(cfg limitsConfig) model.Limits {
limits := model.Limits{
MinAmount: strings.TrimSpace(cfg.MinAmount),
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
}
if len(cfg.VolumeLimit) > 0 {
limits.VolumeLimit = map[string]string{}
for key, value := range cfg.VolumeLimit {
bucket := strings.TrimSpace(key)
amount := strings.TrimSpace(value)
if bucket == "" || amount == "" {
continue
}
limits.VolumeLimit[bucket] = amount
}
}
if len(cfg.VelocityLimit) > 0 {
limits.VelocityLimit = map[string]int{}
for key, value := range cfg.VelocityLimit {
bucket := strings.TrimSpace(key)
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = value
}
}
if len(cfg.CurrencyLimits) > 0 {
limits.CurrencyLimits = map[string]model.LimitsOverride{}
for key, override := range cfg.CurrencyLimits {
currency := strings.ToUpper(strings.TrimSpace(key))
if currency == "" {
continue
}
limits.CurrencyLimits[currency] = model.LimitsOverride{
MaxVolume: strings.TrimSpace(override.MaxVolume),
MinAmount: strings.TrimSpace(override.MinAmount),
MaxAmount: strings.TrimSpace(override.MaxAmount),
MaxFee: strings.TrimSpace(override.MaxFee),
MaxOps: override.MaxOps,
}
}
}
return limits
}

View File

@@ -0,0 +1,150 @@
package serverimp
import (
"context"
"crypto/tls"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.ClientConn) {
addr := cfg.address()
if addr == "" {
return nil, nil
}
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
creds := credentials.NewTLS(&tls.Config{})
if cfg.InsecureTransport {
creds = insecure.NewCredentials()
}
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
if err != nil {
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
return nil, nil
}
i.logger.Info("Connected to fees service", zap.String("address", addr))
return feesv1.NewFeeEngineClient(conn), conn
}
func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := ledgerclient.New(ctx, ledgerclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to ledger service", zap.String("address", addr))
return client
}
func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := chainclient.New(ctx, chainclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("failed to connect to chain gateway service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("connected to chain gateway service", zap.String("address", addr))
return client
}
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := mntxclient.New(ctx, mntxclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Logger: i.logger.Named("client.mntx"),
})
if err != nil {
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
return client
}
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := oracleclient.New(ctx, oracleclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to oracle service", zap.String("address", addr))
return client
}
func (i *Imp) closeClients() {
if i.ledgerClient != nil {
_ = i.ledgerClient.Close()
}
if i.gatewayClient != nil {
_ = i.gatewayClient.Close()
}
if i.mntxClient != nil {
_ = i.mntxClient.Close()
}
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
if i.feesConn != nil {
_ = i.feesConn.Close()
}
}

View File

@@ -0,0 +1,135 @@
package serverimp
import (
"os"
"strings"
"time"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type config struct {
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"`
}
type clientConfig struct {
Address string `yaml:"address"`
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
InsecureTransport bool `yaml:"insecure"`
}
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
FeeWalletRef string `yaml:"fee_wallet_ref"`
}
type gatewayInstanceConfig struct {
ID string `yaml:"id"`
Rail string `yaml:"rail"`
Network string `yaml:"network"`
Currencies []string `yaml:"currencies"`
Capabilities gatewayCapabilitiesConfig `yaml:"capabilities"`
Limits limitsConfig `yaml:"limits"`
Version string `yaml:"version"`
IsEnabled *bool `yaml:"is_enabled"`
}
type gatewayCapabilitiesConfig struct {
CanPayIn bool `yaml:"can_pay_in"`
CanPayOut bool `yaml:"can_pay_out"`
CanReadBalance bool `yaml:"can_read_balance"`
CanSendFee bool `yaml:"can_send_fee"`
RequiresObserveConfirm bool `yaml:"requires_observe_confirm"`
}
type limitsConfig struct {
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
PerTxMaxFee string `yaml:"per_tx_max_fee"`
PerTxMinAmount string `yaml:"per_tx_min_amount"`
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
VolumeLimit map[string]string `yaml:"volume_limit"`
VelocityLimit map[string]int `yaml:"velocity_limit"`
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
}
type limitsOverrideCfg struct {
MaxVolume string `yaml:"max_volume"`
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
MaxFee string `yaml:"max_fee"`
MaxOps int `yaml:"max_ops"`
}
func (c clientConfig) address() string {
return strings.TrimSpace(c.Address)
}
func (c clientConfig) dialTimeout() time.Duration {
if c.DialTimeoutSecs <= 0 {
return 5 * time.Second
}
return time.Duration(c.DialTimeoutSecs) * time.Second
}
func (c clientConfig) callTimeout() time.Duration {
if c.CallTimeoutSecs <= 0 {
return 3 * time.Second
}
return time.Duration(c.CallTimeoutSecs) * time.Second
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{Config: &grpcapp.Config{}}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50062",
EnableReflection: true,
EnableHealth: true,
}
} else {
if strings.TrimSpace(cfg.GRPC.Address) == "" {
cfg.GRPC.Address = ":50062"
}
if strings.TrimSpace(cfg.GRPC.Network) == "" {
cfg.GRPC.Network = "tcp"
}
}
if cfg.Metrics == nil {
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9403"}
} else if strings.TrimSpace(cfg.Metrics.Address) == "" {
cfg.Metrics.Address = ":9403"
}
return cfg, nil
}

View File

@@ -0,0 +1,84 @@
package serverimp
import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
)
type orchestratorDeps struct {
feesClient feesv1.FeeEngineClient
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
}
func (i *Imp) initDependencies(cfg *config) *orchestratorDeps {
deps := &orchestratorDeps{}
if cfg == nil {
return deps
}
deps.feesClient, i.feesConn = i.initFeesClient(cfg.Fees)
deps.ledgerClient = i.initLedgerClient(cfg.Ledger)
if deps.ledgerClient != nil {
i.ledgerClient = deps.ledgerClient
}
deps.gatewayClient = i.initGatewayClient(cfg.Gateway)
if deps.gatewayClient != nil {
i.gatewayClient = deps.gatewayClient
}
deps.mntxClient = i.initMntxClient(cfg.Mntx)
if deps.mntxClient != nil {
i.mntxClient = deps.mntxClient
}
deps.oracleClient = i.initOracleClient(cfg.Oracle)
if deps.oracleClient != nil {
i.oracleClient = deps.oracleClient
}
return deps
}
func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchestrator.Option {
if cfg == nil || deps == nil {
return nil
}
opts := []orchestrator.Option{}
if deps.feesClient != nil {
opts = append(opts, orchestrator.WithFeeEngine(deps.feesClient, cfg.Fees.callTimeout()))
}
if deps.ledgerClient != nil {
opts = append(opts, orchestrator.WithLedgerClient(deps.ledgerClient))
}
if deps.gatewayClient != nil {
opts = append(opts, orchestrator.WithChainGatewayClient(deps.gatewayClient))
}
if railGateways := buildRailGateways(deps.gatewayClient, cfg.GatewayInstances); len(railGateways) > 0 {
opts = append(opts, orchestrator.WithRailGateways(railGateways))
}
if deps.mntxClient != nil {
opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient))
}
if deps.oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(deps.oracleClient))
}
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
}
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
if registry := buildGatewayRegistry(i.logger, deps.mntxClient, cfg.GatewayInstances, i.discoveryReg); registry != nil {
opts = append(opts, orchestrator.WithGatewayRegistry(registry))
}
return opts
}

View File

@@ -0,0 +1,50 @@
package serverimp
import (
"github.com/tech/sendico/payments/orchestrator/internal/appversion"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func (i *Imp) initDiscovery(cfg *config) {
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
return
}
logger := i.logger.Named("discovery")
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), cfg.Messaging)
if err != nil {
i.logger.Warn("Failed to initialise discovery broker", zap.Error(err))
return
}
producer := msgproducer.NewProducer(logger.Named("producer"), broker)
registry := discovery.NewRegistry()
watcher, err := discovery.NewRegistryWatcher(i.logger, broker, registry)
if err != nil {
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(err))
} else if err := watcher.Start(); err != nil {
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(err))
} else {
i.discoveryWatcher = watcher
i.discoveryReg = registry
i.logger.Info("Discovery registry watcher started")
}
announce := discovery.Announcement{
Service: "PAYMENTS_ORCHESTRATOR",
Operations: []string{"payment.quote", "payment.initiate"},
Version: appversion.Create().Short(),
}
i.discoveryAnnouncer = discovery.NewAnnouncer(i.logger, producer, string(mservice.PaymentOrchestrator), announce)
i.discoveryAnnouncer.Start()
}
func (i *Imp) stopDiscovery() {
if i.discoveryAnnouncer != nil {
i.discoveryAnnouncer.Stop()
}
if i.discoveryWatcher != nil {
i.discoveryWatcher.Stop()
}
}

View File

@@ -0,0 +1,18 @@
package serverimp
import (
"context"
"time"
)
func (i *Imp) shutdownApp() {
if i.app != nil {
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
}
}

View File

@@ -1,136 +1,15 @@
package serverimp
import (
"context"
"crypto/tls"
"os"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/appversion"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/payments/rail"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
discoverySvc *discovery.RegistryService
discoveryReg *discovery.Registry
discoveryAnnouncer *discovery.Announcer
feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"`
}
type clientConfig struct {
Address string `yaml:"address"`
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
InsecureTransport bool `yaml:"insecure"`
}
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
FeeWalletRef string `yaml:"fee_wallet_ref"`
}
type gatewayInstanceConfig struct {
ID string `yaml:"id"`
Rail string `yaml:"rail"`
Network string `yaml:"network"`
Currencies []string `yaml:"currencies"`
Capabilities gatewayCapabilitiesConfig `yaml:"capabilities"`
Limits limitsConfig `yaml:"limits"`
Version string `yaml:"version"`
IsEnabled *bool `yaml:"is_enabled"`
}
type gatewayCapabilitiesConfig struct {
CanPayIn bool `yaml:"can_pay_in"`
CanPayOut bool `yaml:"can_pay_out"`
CanReadBalance bool `yaml:"can_read_balance"`
CanSendFee bool `yaml:"can_send_fee"`
RequiresObserveConfirm bool `yaml:"requires_observe_confirm"`
}
type limitsConfig struct {
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
PerTxMaxFee string `yaml:"per_tx_max_fee"`
PerTxMinAmount string `yaml:"per_tx_min_amount"`
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
VolumeLimit map[string]string `yaml:"volume_limit"`
VelocityLimit map[string]int `yaml:"velocity_limit"`
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
}
type limitsOverrideCfg struct {
MaxVolume string `yaml:"max_volume"`
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
MaxFee string `yaml:"max_fee"`
MaxOps int `yaml:"max_ops"`
}
func (c clientConfig) address() string {
return strings.TrimSpace(c.Address)
}
func (c clientConfig) dialTimeout() time.Duration {
if c.DialTimeoutSecs <= 0 {
return 5 * time.Second
}
return time.Duration(c.DialTimeoutSecs) * time.Second
}
func (c clientConfig) callTimeout() time.Duration {
if c.CallTimeoutSecs <= 0 {
return 3 * time.Second
}
return time.Duration(c.CallTimeoutSecs) * time.Second
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
@@ -140,37 +19,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
}
func (i *Imp) Shutdown() {
if i.discoveryAnnouncer != nil {
i.discoveryAnnouncer.Stop()
}
if i.discoverySvc != nil {
i.discoverySvc.Stop()
}
if i.app != nil {
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
}
if i.ledgerClient != nil {
_ = i.ledgerClient.Close()
}
if i.gatewayClient != nil {
_ = i.gatewayClient.Close()
}
if i.mntxClient != nil {
_ = i.mntxClient.Close()
}
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
if i.feesConn != nil {
_ = i.feesConn.Close()
}
i.stopDiscovery()
i.shutdownApp()
i.closeClients()
}
func (i *Imp) Start() error {
@@ -180,90 +31,16 @@ func (i *Imp) Start() error {
}
i.config = cfg
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
if err != nil {
i.logger.Warn("Failed to initialise discovery broker", zap.Error(err))
} else {
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
registry := discovery.NewRegistry()
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.PaymentOrchestrator))
if err != nil {
i.logger.Warn("Failed to start discovery registry service", zap.Error(err))
} else {
svc.Start()
i.discoverySvc = svc
i.discoveryReg = registry
i.logger.Info("Discovery registry service started")
}
announce := discovery.Announcement{
Service: "PAYMENTS_ORCHESTRATOR",
Operations: []string{"payment.quote", "payment.initiate"},
Version: appversion.Create().Short(),
}
i.discoveryAnnouncer = discovery.NewAnnouncer(i.logger, producer, string(mservice.PaymentOrchestrator), announce)
i.discoveryAnnouncer.Start()
}
}
i.initDiscovery(cfg)
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
feesClient, feesConn := i.initFeesClient(cfg.Fees)
if feesConn != nil {
i.feesConn = feesConn
}
ledgerClient := i.initLedgerClient(cfg.Ledger)
if ledgerClient != nil {
i.ledgerClient = ledgerClient
}
gatewayClient := i.initGatewayClient(cfg.Gateway)
if gatewayClient != nil {
i.gatewayClient = gatewayClient
}
mntxClient := i.initMntxClient(cfg.Mntx)
if mntxClient != nil {
i.mntxClient = mntxClient
}
oracleClient := i.initOracleClient(cfg.Oracle)
if oracleClient != nil {
i.oracleClient = oracleClient
}
deps := i.initDependencies(cfg)
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
opts := []orchestrator.Option{}
if feesClient != nil {
opts = append(opts, orchestrator.WithFeeEngine(feesClient, cfg.Fees.callTimeout()))
}
if ledgerClient != nil {
opts = append(opts, orchestrator.WithLedgerClient(ledgerClient))
}
if gatewayClient != nil {
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
}
if railGateways := buildRailGateways(gatewayClient, cfg.GatewayInstances); len(railGateways) > 0 {
opts = append(opts, orchestrator.WithRailGateways(railGateways))
}
if mntxClient != nil {
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
}
if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
}
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
}
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
if registry := buildGatewayRegistry(i.logger, mntxClient, cfg.GatewayInstances, i.discoveryReg); registry != nil {
opts = append(opts, orchestrator.WithGatewayRegistry(registry))
}
opts := i.buildServiceOptions(cfg, deps)
return orchestrator.NewService(logger, repo, opts...), nil
}
@@ -275,371 +52,3 @@ func (i *Imp) Start() error {
return i.app.Start()
}
func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.ClientConn) {
addr := cfg.address()
if addr == "" {
return nil, nil
}
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
creds := credentials.NewTLS(&tls.Config{})
if cfg.InsecureTransport {
creds = insecure.NewCredentials()
}
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
if err != nil {
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
return nil, nil
}
i.logger.Info("Connected to fees service", zap.String("address", addr))
return feesv1.NewFeeEngineClient(conn), conn
}
func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := ledgerclient.New(ctx, ledgerclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to ledger service", zap.String("address", addr))
return client
}
func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := chainclient.New(ctx, chainclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("failed to connect to chain gateway service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("connected to chain gateway service", zap.String("address", addr))
return client
}
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := mntxclient.New(ctx, mntxclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Logger: i.logger.Named("client.mntx"),
})
if err != nil {
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
return client
}
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := oracleclient.New(ctx, oracleclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
Insecure: cfg.InsecureTransport,
})
if err != nil {
i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to oracle service", zap.String("address", addr))
return client
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{Config: &grpcapp.Config{}}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50062",
EnableReflection: true,
EnableHealth: true,
}
} else {
if strings.TrimSpace(cfg.GRPC.Address) == "" {
cfg.GRPC.Address = ":50062"
}
if strings.TrimSpace(cfg.GRPC.Network) == "" {
cfg.GRPC.Network = "tcp"
}
}
if cfg.Metrics == nil {
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9403"}
} else if strings.TrimSpace(cfg.Metrics.Address) == "" {
cfg.Metrics.Address = ":9403"
}
return cfg, nil
}
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
if len(src) == 0 {
return nil
}
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
for key, route := range src {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
}
}
return result
}
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
result := make(map[string]string, len(src))
for key, account := range src {
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(account)
if k == "" || v == "" {
continue
}
result[k] = v
}
return result
}
func buildGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry {
static := buildGatewayInstances(logger, src)
staticRegistry := orchestrator.NewGatewayRegistry(logger, mntxClient, static)
discoveryRegistry := orchestrator.NewDiscoveryGatewayRegistry(logger, registry)
return orchestrator.NewCompositeGatewayRegistry(logger, staticRegistry, discoveryRegistry)
}
func buildRailGateways(chainClient chainclient.Client, src []gatewayInstanceConfig) map[string]rail.RailGateway {
if chainClient == nil || len(src) == 0 {
return nil
}
instances := buildGatewayInstances(nil, src)
if len(instances) == 0 {
return nil
}
result := map[string]rail.RailGateway{}
for _, inst := range instances {
if inst == nil || !inst.IsEnabled {
continue
}
if inst.Rail != model.RailCrypto {
continue
}
cfg := chainclient.RailGatewayConfig{
Rail: string(inst.Rail),
Network: inst.Network,
Capabilities: rail.RailCapabilities{
CanPayIn: inst.Capabilities.CanPayIn,
CanPayOut: inst.Capabilities.CanPayOut,
CanReadBalance: inst.Capabilities.CanReadBalance,
CanSendFee: inst.Capabilities.CanSendFee,
RequiresObserveConfirm: inst.Capabilities.RequiresObserveConfirm,
},
}
result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg)
}
if len(result) == 0 {
return nil
}
return result
}
func buildGatewayInstances(logger mlogger.Logger, src []gatewayInstanceConfig) []*model.GatewayInstanceDescriptor {
if len(src) == 0 {
return nil
}
if logger != nil {
logger = logger.Named("gateway_instances")
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(src))
for _, cfg := range src {
id := strings.TrimSpace(cfg.ID)
if id == "" {
if logger != nil {
logger.Warn("Gateway instance skipped: missing id")
}
continue
}
rail := parseRail(cfg.Rail)
if rail == model.RailUnspecified {
if logger != nil {
logger.Warn("Gateway instance skipped: invalid rail", zap.String("id", id), zap.String("rail", cfg.Rail))
}
continue
}
enabled := true
if cfg.IsEnabled != nil {
enabled = *cfg.IsEnabled
}
result = append(result, &model.GatewayInstanceDescriptor{
ID: id,
Rail: rail,
Network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
Currencies: normalizeCurrencies(cfg.Currencies),
Capabilities: model.RailCapabilities{
CanPayIn: cfg.Capabilities.CanPayIn,
CanPayOut: cfg.Capabilities.CanPayOut,
CanReadBalance: cfg.Capabilities.CanReadBalance,
CanSendFee: cfg.Capabilities.CanSendFee,
RequiresObserveConfirm: cfg.Capabilities.RequiresObserveConfirm,
},
Limits: buildGatewayLimits(cfg.Limits),
Version: strings.TrimSpace(cfg.Version),
IsEnabled: enabled,
})
}
return result
}
func parseRail(value string) model.Rail {
switch strings.ToUpper(strings.TrimSpace(value)) {
case string(model.RailCrypto):
return model.RailCrypto
case string(model.RailProviderSettlement):
return model.RailProviderSettlement
case string(model.RailLedger):
return model.RailLedger
case string(model.RailCardPayout):
return model.RailCardPayout
case string(model.RailFiatOnRamp):
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func normalizeCurrencies(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" || seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
return result
}
func buildGatewayLimits(cfg limitsConfig) model.Limits {
limits := model.Limits{
MinAmount: strings.TrimSpace(cfg.MinAmount),
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
}
if len(cfg.VolumeLimit) > 0 {
limits.VolumeLimit = map[string]string{}
for key, value := range cfg.VolumeLimit {
bucket := strings.TrimSpace(key)
amount := strings.TrimSpace(value)
if bucket == "" || amount == "" {
continue
}
limits.VolumeLimit[bucket] = amount
}
}
if len(cfg.VelocityLimit) > 0 {
limits.VelocityLimit = map[string]int{}
for key, value := range cfg.VelocityLimit {
bucket := strings.TrimSpace(key)
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = value
}
}
if len(cfg.CurrencyLimits) > 0 {
limits.CurrencyLimits = map[string]model.LimitsOverride{}
for key, override := range cfg.CurrencyLimits {
currency := strings.ToUpper(strings.TrimSpace(key))
if currency == "" {
continue
}
limits.CurrencyLimits[currency] = model.LimitsOverride{
MaxVolume: strings.TrimSpace(override.MaxVolume),
MinAmount: strings.TrimSpace(override.MinAmount),
MaxAmount: strings.TrimSpace(override.MaxAmount),
MaxFee: strings.TrimSpace(override.MaxFee),
MaxOps: override.MaxOps,
}
}
}
return limits
}

View File

@@ -0,0 +1,30 @@
package serverimp
import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server/grpcapp"
"google.golang.org/grpc"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
discoveryWatcher *discovery.RegistryWatcher
discoveryReg *discovery.Registry
discoveryAnnouncer *discovery.Announcer
feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
}

View File

@@ -45,6 +45,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst
}
items = append(items, &model.GatewayInstanceDescriptor{
ID: entry.ID,
InstanceID: entry.InstanceID,
Rail: rail,
Network: entry.Network,
Currencies: normalizeCurrencies(entry.Currencies),

View File

@@ -113,6 +113,7 @@ type Limits struct {
// GatewayInstanceDescriptor standardizes gateway instance self-declaration.
type GatewayInstanceDescriptor struct {
ID string `bson:"id" json:"id"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
Rail Rail `bson:"rail" json:"rail"`
Network string `bson:"network,omitempty" json:"network,omitempty"`
Currencies []string `bson:"currencies,omitempty" json:"currencies,omitempty"`

View File

@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Announcer struct {
@@ -31,8 +32,11 @@ func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, a
if announce.Service == "" {
announce.Service = strings.TrimSpace(sender)
}
if announce.InstanceID == "" {
announce.InstanceID = InstanceID()
}
if announce.ID == "" {
announce.ID = DefaultInstanceID(announce.Service)
announce.ID = DefaultEntryID(announce.Service)
}
if announce.InvokeURI == "" && announce.Service != "" {
announce.InvokeURI = DefaultInvokeURI(announce.Service)
@@ -53,15 +57,16 @@ func (a *Announcer) Start() {
}
a.startOnce.Do(func() {
if a.producer == nil {
a.logWarn("Discovery announce skipped: producer not configured")
a.logWarn("Discovery announce skipped: producer not configured", announcementFields(a.announce)...)
close(a.doneCh)
return
}
if strings.TrimSpace(a.announce.ID) == "" {
a.logWarn("Discovery announce skipped: missing instance id")
a.logWarn("Discovery announce skipped: missing instance id", announcementFields(a.announce)...)
close(a.doneCh)
return
}
a.logInfo("Discovery announcer starting", announcementFields(a.announce)...)
a.sendAnnouncement()
a.sendHeartbeat()
go a.heartbeatLoop()
@@ -75,6 +80,7 @@ func (a *Announcer) Stop() {
a.stopOnce.Do(func() {
close(a.stopCh)
<-a.doneCh
a.logInfo("Discovery announcer stopped", announcementFields(a.announce)...)
})
}
@@ -99,42 +105,47 @@ func (a *Announcer) heartbeatLoop() {
func (a *Announcer) sendAnnouncement() {
env := NewServiceAnnounceEnvelope(a.sender, a.announce)
event := ServiceAnnounceEvent()
if a.announce.Rail != "" {
env = NewGatewayAnnounceEnvelope(a.sender, a.announce)
event = GatewayAnnounceEvent()
}
if err := a.producer.SendMessage(env); err != nil {
a.logWarn("Failed to publish discovery announce: " + err.Error())
fields := append(announcementFields(a.announce), zap.String("event", event.ToString()), zap.Error(err))
a.logWarn("Failed to publish discovery announce", fields...)
return
}
a.logInfo("Discovery announce published")
a.logInfo("Discovery announce published", append(announcementFields(a.announce), zap.String("event", event.ToString()))...)
}
func (a *Announcer) sendHeartbeat() {
hb := Heartbeat{
ID: a.announce.ID,
Status: "ok",
TS: time.Now().Unix(),
ID: a.announce.ID,
InstanceID: a.announce.InstanceID,
Status: "ok",
TS: time.Now().Unix(),
}
if err := a.producer.SendMessage(NewHeartbeatEnvelope(a.sender, hb)); err != nil {
a.logWarn("Failed to publish discovery heartbeat: " + err.Error())
fields := append(announcementFields(a.announce), zap.String("event", HeartbeatEvent().ToString()), zap.Error(err))
a.logWarn("Failed to publish discovery heartbeat", fields...)
}
}
func (a *Announcer) logInfo(message string) {
func (a *Announcer) logInfo(message string, fields ...zap.Field) {
if a.logger == nil {
return
}
a.logger.Info(message)
a.logger.Info(message, fields...)
}
func (a *Announcer) logWarn(message string) {
func (a *Announcer) logWarn(message string, fields ...zap.Field) {
if a.logger == nil {
return
}
a.logger.Warn(message)
a.logger.Warn(message, fields...)
}
func DefaultInstanceID(service string) string {
func DefaultEntryID(service string) string {
clean := strings.ToLower(strings.TrimSpace(service))
if clean == "" {
clean = "service"
@@ -148,6 +159,10 @@ func DefaultInstanceID(service string) string {
return clean + "_" + host + "_" + uid
}
func DefaultInstanceID(service string) string {
return DefaultEntryID(service)
}
func DefaultInvokeURI(service string) string {
clean := strings.ToLower(strings.TrimSpace(service))
if clean == "" {

View File

@@ -9,7 +9,7 @@ import (
"github.com/google/uuid"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/messaging/broker"
mb "github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
@@ -27,22 +27,22 @@ type Client struct {
pending map[string]chan LookupResponse
}
func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, sender string) (*Client, error) {
if broker == nil {
func NewClient(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, sender string) (*Client, error) {
if msgBroker == nil {
return nil, errors.New("discovery client: broker is nil")
}
if logger != nil {
logger = logger.Named("discovery_client")
}
if producer == nil {
producer = msgproducer.NewProducer(logger, broker)
producer = msgproducer.NewProducer(logger, msgBroker)
}
sender = strings.TrimSpace(sender)
if sender == "" {
sender = "discovery_client"
}
consumer, err := cons.NewConsumer(logger, broker, LookupResponseEvent())
consumer, err := cons.NewConsumer(logger, msgBroker, LookupResponseEvent())
if err != nil {
return nil, err
}
@@ -57,7 +57,7 @@ func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Produce
go func() {
if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil {
client.logger.Warn("Discovery lookup consumer stopped", zap.Error(err))
client.logger.Warn("Discovery lookup consumer stopped", zap.String("event", LookupResponseEvent().ToString()), zap.Error(err))
}
}()
@@ -112,7 +112,8 @@ func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) {
func (c *Client) handleLookupResponse(_ context.Context, env me.Envelope) error {
var payload LookupResponse
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
c.logWarn("Failed to decode discovery lookup response", zap.Error(err))
fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err))
c.logWarn("Failed to decode discovery lookup response", fields...)
return err
}
requestID := strings.TrimSpace(payload.RequestID)

View File

@@ -0,0 +1,27 @@
package discovery
import (
"strings"
"sync"
"github.com/google/uuid"
)
var (
instanceID string
instanceOnce sync.Once
instanceIDGenerator = func() string {
return uuid.NewString()
}
)
// InstanceID returns a unique, process-stable identifier for the running service instance.
func InstanceID() string {
instanceOnce.Do(func() {
instanceID = strings.TrimSpace(instanceIDGenerator())
if instanceID == "" {
instanceID = uuid.NewString()
}
})
return instanceID
}

View File

@@ -0,0 +1,53 @@
package discovery
import (
"fmt"
"sync"
"testing"
)
func resetInstanceIDForTest() {
instanceID = ""
instanceOnce = sync.Once{}
}
func TestInstanceIDStable(t *testing.T) {
resetInstanceIDForTest()
original := instanceIDGenerator
defer func() {
instanceIDGenerator = original
resetInstanceIDForTest()
}()
instanceIDGenerator = func() string {
return "fixed-id"
}
first := InstanceID()
second := InstanceID()
if first != "fixed-id" || second != "fixed-id" {
t.Fatalf("expected stable instance id, got %q and %q", first, second)
}
}
func TestInstanceIDRegeneratesAfterReset(t *testing.T) {
resetInstanceIDForTest()
original := instanceIDGenerator
defer func() {
instanceIDGenerator = original
resetInstanceIDForTest()
}()
counter := 0
instanceIDGenerator = func() string {
counter++
return fmt.Sprintf("id-%d", counter)
}
first := InstanceID()
resetInstanceIDForTest()
second := InstanceID()
if first == second {
t.Fatalf("expected new instance id after reset, got %q", first)
}
}

99
api/pkg/discovery/keys.go Normal file
View File

@@ -0,0 +1,99 @@
package discovery
import "strings"
const kvEntryPrefix = "entry."
func registryEntryKey(entry RegistryEntry) string {
return registryKey(entry.Service, entry.Rail, entry.Network, entry.Operations, entry.Version, entry.InstanceID)
}
func registryKey(service, rail, network string, operations []string, version, instanceID string) string {
service = normalizeKeyPart(service)
rail = normalizeKeyPart(rail)
op := normalizeKeyPart(firstOperation(operations))
version = normalizeKeyPart(version)
instanceID = normalizeKeyPart(instanceID)
if instanceID == "" {
return ""
}
if service == "" {
service = "service"
}
if rail == "" {
rail = "none"
}
if op == "" {
op = "none"
}
if version == "" {
version = "unknown"
}
parts := []string{service, rail, op, version, instanceID}
if network != "" {
netPart := normalizeKeyPart(network)
if netPart != "" {
parts = append(parts, netPart)
}
}
return strings.Join(parts, ".")
}
func kvKeyFromRegistryKey(key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
if strings.HasPrefix(key, kvEntryPrefix) {
return key
}
return kvEntryPrefix + key
}
func registryKeyFromKVKey(key string) string {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, kvEntryPrefix) {
return strings.TrimPrefix(key, kvEntryPrefix)
}
return key
}
func firstOperation(ops []string) string {
for _, op := range ops {
op = strings.TrimSpace(op)
if op != "" {
return op
}
}
return ""
}
func normalizeKeyPart(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
var b strings.Builder
b.Grow(len(value))
lastDash := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if r == '-' || r == '_' {
if !lastDash {
b.WriteByte('-')
lastDash = true
}
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
return out
}

103
api/pkg/discovery/kv.go Normal file
View File

@@ -0,0 +1,103 @@
package discovery
import (
"encoding/json"
"errors"
"strings"
"github.com/nats-io/nats.go"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
const DefaultKVBucket = "discovery_registry"
type KVStore struct {
logger mlogger.Logger
kv nats.KeyValue
bucket string
}
func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string) (*KVStore, error) {
if js == nil {
return nil, errors.New("discovery kv: jetstream is nil")
}
if logger != nil {
logger = logger.Named("discovery_kv")
}
bucket = strings.TrimSpace(bucket)
if bucket == "" {
bucket = DefaultKVBucket
}
kv, err := js.KeyValue(bucket)
if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) {
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: bucket,
Description: "service discovery registry",
History: 1,
})
if err == nil && logger != nil {
logger.Info("Discovery KV bucket created", zap.String("bucket", bucket))
}
}
if err != nil {
return nil, err
}
}
return &KVStore{
logger: logger,
kv: kv,
bucket: bucket,
}, nil
}
func (s *KVStore) Put(entry RegistryEntry) error {
if s == nil || s.kv == nil {
return errors.New("discovery kv: not configured")
}
key := registryEntryKey(normalizeEntry(entry))
if key == "" {
return errors.New("discovery kv: entry key is empty")
}
payload, err := json.Marshal(entry)
if err != nil {
return err
}
_, err = s.kv.Put(kvKeyFromRegistryKey(key), payload)
if err != nil && s.logger != nil {
fields := append(entryFields(entry), zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err))
s.logger.Warn("Failed to persist discovery entry", fields...)
}
return err
}
func (s *KVStore) Delete(id string) error {
if s == nil || s.kv == nil {
return errors.New("discovery kv: not configured")
}
key := kvKeyFromRegistryKey(id)
if key == "" {
return nil
}
if err := s.kv.Delete(key); err != nil && s.logger != nil {
s.logger.Warn("Failed to delete discovery entry", zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err))
return err
}
return nil
}
func (s *KVStore) WatchAll() (nats.KeyWatcher, error) {
if s == nil || s.kv == nil {
return nil, errors.New("discovery kv: not configured")
}
return s.kv.WatchAll()
}
func (s *KVStore) Bucket() string {
if s == nil {
return ""
}
return s.bucket
}

View File

@@ -0,0 +1,108 @@
package discovery
import (
"strings"
me "github.com/tech/sendico/pkg/messaging/envelope"
"go.uber.org/zap"
)
func announcementFields(announce Announcement) []zap.Field {
fields := make([]zap.Field, 0, 10)
if announce.ID != "" {
fields = append(fields, zap.String("id", announce.ID))
}
if announce.InstanceID != "" {
fields = append(fields, zap.String("instance_id", announce.InstanceID))
}
if announce.Service != "" {
fields = append(fields, zap.String("service", announce.Service))
}
if announce.Rail != "" {
fields = append(fields, zap.String("rail", announce.Rail))
}
if announce.Network != "" {
fields = append(fields, zap.String("network", announce.Network))
}
if announce.InvokeURI != "" {
fields = append(fields, zap.String("invoke_uri", announce.InvokeURI))
}
if announce.Version != "" {
fields = append(fields, zap.String("version", announce.Version))
}
if announce.RoutingPriority != 0 {
fields = append(fields, zap.Int("routing_priority", announce.RoutingPriority))
}
if len(announce.Operations) > 0 {
fields = append(fields, zap.Int("ops", len(announce.Operations)))
}
if len(announce.Currencies) > 0 {
fields = append(fields, zap.Int("currencies", len(announce.Currencies)))
}
if announce.Health.IntervalSec > 0 {
fields = append(fields, zap.Int("interval_sec", announce.Health.IntervalSec))
}
if announce.Health.TimeoutSec > 0 {
fields = append(fields, zap.Int("timeout_sec", announce.Health.TimeoutSec))
}
return fields
}
func entryFields(entry RegistryEntry) []zap.Field {
fields := make([]zap.Field, 0, 12)
if entry.ID != "" {
fields = append(fields, zap.String("id", entry.ID))
}
if entry.InstanceID != "" {
fields = append(fields, zap.String("instance_id", entry.InstanceID))
}
if entry.Service != "" {
fields = append(fields, zap.String("service", entry.Service))
}
if entry.Rail != "" {
fields = append(fields, zap.String("rail", entry.Rail))
}
if entry.Network != "" {
fields = append(fields, zap.String("network", entry.Network))
}
if entry.Version != "" {
fields = append(fields, zap.String("version", entry.Version))
}
if entry.InvokeURI != "" {
fields = append(fields, zap.String("invoke_uri", entry.InvokeURI))
}
if entry.Status != "" {
fields = append(fields, zap.String("status", entry.Status))
}
if !entry.LastHeartbeat.IsZero() {
fields = append(fields, zap.Time("last_heartbeat", entry.LastHeartbeat))
}
fields = append(fields, zap.Bool("healthy", entry.Healthy))
if entry.RoutingPriority != 0 {
fields = append(fields, zap.Int("routing_priority", entry.RoutingPriority))
}
if len(entry.Operations) > 0 {
fields = append(fields, zap.Int("ops", len(entry.Operations)))
}
if len(entry.Currencies) > 0 {
fields = append(fields, zap.Int("currencies", len(entry.Currencies)))
}
return fields
}
func envelopeFields(env me.Envelope) []zap.Field {
if env == nil {
return nil
}
fields := make([]zap.Field, 0, 4)
sender := strings.TrimSpace(env.GetSender())
if sender != "" {
fields = append(fields, zap.String("sender", sender))
}
if signature := env.GetSignature(); signature != nil {
fields = append(fields, zap.String("event", signature.ToString()))
}
fields = append(fields, zap.String("message_id", env.GetMessageId().String()))
fields = append(fields, zap.Time("timestamp", env.GetTimeStamp()))
return fields
}

View File

@@ -1,5 +1,7 @@
package discovery
import "time"
type LookupRequest struct {
RequestID string `json:"requestId,omitempty"`
}
@@ -11,16 +13,18 @@ type LookupResponse struct {
}
type ServiceSummary struct {
ID string `json:"id"`
Service string `json:"service"`
Ops []string `json:"ops,omitempty"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
ID string `json:"id"`
InstanceID string `json:"instanceId"`
Service string `json:"service"`
Ops []string `json:"ops,omitempty"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
}
type GatewaySummary struct {
ID string `json:"id"`
InstanceID string `json:"instanceId"`
Rail string `json:"rail"`
Network string `json:"network,omitempty"`
Currencies []string `json:"currencies,omitempty"`
@@ -43,6 +47,7 @@ func (r *Registry) Lookup(now time.Time) LookupResponse {
if entry.Rail != "" {
resp.Gateways = append(resp.Gateways, GatewaySummary{
ID: entry.ID,
InstanceID: entry.InstanceID,
Rail: entry.Rail,
Network: entry.Network,
Currencies: cloneStrings(entry.Currencies),
@@ -56,12 +61,13 @@ func (r *Registry) Lookup(now time.Time) LookupResponse {
continue
}
resp.Services = append(resp.Services, ServiceSummary{
ID: entry.ID,
Service: entry.Service,
Ops: cloneStrings(entry.Operations),
Version: entry.Version,
Healthy: entry.Healthy,
InvokeURI: entry.InvokeURI,
ID: entry.ID,
InstanceID: entry.InstanceID,
Service: entry.Service,
Ops: cloneStrings(entry.Operations),
Version: entry.Version,
Healthy: entry.Healthy,
InvokeURI: entry.InvokeURI,
})
}

View File

@@ -13,6 +13,7 @@ const (
type RegistryEntry struct {
ID string `json:"id"`
InstanceID string `bson:"instanceId" json:"instanceId"`
Service string `json:"service"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
@@ -29,8 +30,10 @@ type RegistryEntry struct {
}
type Registry struct {
mu sync.RWMutex
entries map[string]*RegistryEntry
mu sync.RWMutex
entries map[string]*RegistryEntry
byID map[string]map[string]struct{}
byInstance map[string]map[string]struct{}
}
type UpdateResult struct {
@@ -42,23 +45,31 @@ type UpdateResult struct {
func NewRegistry() *Registry {
return &Registry{
entries: map[string]*RegistryEntry{},
entries: map[string]*RegistryEntry{},
byID: map[string]map[string]struct{}{},
byInstance: map[string]map[string]struct{}{},
}
}
func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) UpdateResult {
entry := registryEntryFromAnnouncement(normalizeAnnouncement(announce), now)
key := registryEntryKey(entry)
if key == "" {
return UpdateResult{Entry: entry}
}
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.entries[entry.ID]
existing, ok := r.entries[key]
wasHealthy := false
if ok && existing != nil {
wasHealthy = existing.isHealthyAt(now)
r.unindexEntry(key, existing)
}
entry.Healthy = entry.isHealthyAt(now)
r.entries[entry.ID] = &entry
r.entries[key] = &entry
r.indexEntry(key, &entry)
return UpdateResult{
Entry: entry,
@@ -68,10 +79,45 @@ func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time)
}
}
func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now time.Time) (UpdateResult, bool) {
func (r *Registry) UpsertEntry(entry RegistryEntry, now time.Time) UpdateResult {
entry = normalizeEntry(entry)
key := registryEntryKey(entry)
if key == "" {
return UpdateResult{Entry: entry}
}
if entry.LastHeartbeat.IsZero() {
entry.LastHeartbeat = now
}
if strings.TrimSpace(entry.Status) == "" {
entry.Status = "ok"
}
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.entries[key]
wasHealthy := false
if ok && existing != nil {
wasHealthy = existing.isHealthyAt(now)
r.unindexEntry(key, existing)
}
entry.Healthy = entry.isHealthyAt(now)
r.entries[key] = &entry
r.indexEntry(key, &entry)
return UpdateResult{
Entry: entry,
IsNew: !ok,
WasHealthy: wasHealthy,
BecameHealthy: !wasHealthy && entry.Healthy,
}
}
func (r *Registry) UpdateHeartbeat(id string, instanceID string, status string, ts time.Time, now time.Time) []UpdateResult {
id = strings.TrimSpace(id)
if id == "" {
return UpdateResult{}, false
instanceID = strings.TrimSpace(instanceID)
if id == "" && instanceID == "" {
return nil
}
if status == "" {
status = "ok"
@@ -83,21 +129,54 @@ func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now t
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.entries[id]
if !ok || entry == nil {
return UpdateResult{}, false
keys := keysFromIndex(r.byInstance[instanceID])
if len(keys) == 0 && id != "" {
keys = keysFromIndex(r.byID[id])
}
wasHealthy := entry.isHealthyAt(now)
entry.Status = status
entry.LastHeartbeat = ts
entry.Healthy = entry.isHealthyAt(now)
if len(keys) == 0 {
return nil
}
results := make([]UpdateResult, 0, len(keys))
for _, key := range keys {
entry := r.entries[key]
if entry == nil {
continue
}
if id != "" && entry.ID != id {
continue
}
if instanceID != "" && entry.InstanceID != instanceID {
continue
}
wasHealthy := entry.isHealthyAt(now)
entry.Status = status
entry.LastHeartbeat = ts
entry.Healthy = entry.isHealthyAt(now)
return UpdateResult{
Entry: *entry,
IsNew: false,
WasHealthy: wasHealthy,
BecameHealthy: !wasHealthy && entry.Healthy,
}, true
results = append(results, UpdateResult{
Entry: *entry,
IsNew: false,
WasHealthy: wasHealthy,
BecameHealthy: !wasHealthy && entry.Healthy,
})
}
return results
}
func (r *Registry) Delete(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.entries[key]
if !ok {
return false
}
delete(r.entries, key)
r.unindexEntry(key, entry)
return true
}
func (r *Registry) List(now time.Time, onlyHealthy bool) []RegistryEntry {
@@ -123,6 +202,7 @@ func registryEntryFromAnnouncement(announce Announcement, now time.Time) Registr
status := "ok"
return RegistryEntry{
ID: strings.TrimSpace(announce.ID),
InstanceID: strings.TrimSpace(announce.InstanceID),
Service: strings.TrimSpace(announce.Service),
Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)),
Network: strings.ToUpper(strings.TrimSpace(announce.Network)),
@@ -138,8 +218,33 @@ func registryEntryFromAnnouncement(announce Announcement, now time.Time) Registr
}
}
func normalizeEntry(entry RegistryEntry) RegistryEntry {
entry.ID = strings.TrimSpace(entry.ID)
entry.InstanceID = strings.TrimSpace(entry.InstanceID)
if entry.InstanceID == "" {
entry.InstanceID = entry.ID
}
entry.Service = strings.TrimSpace(entry.Service)
entry.Rail = strings.ToUpper(strings.TrimSpace(entry.Rail))
entry.Network = strings.ToUpper(strings.TrimSpace(entry.Network))
entry.Operations = normalizeStrings(entry.Operations, false)
entry.Currencies = normalizeStrings(entry.Currencies, true)
entry.InvokeURI = strings.TrimSpace(entry.InvokeURI)
entry.Version = strings.TrimSpace(entry.Version)
entry.Status = strings.TrimSpace(entry.Status)
entry.Health = normalizeHealth(entry.Health)
if entry.Limits != nil {
entry.Limits = normalizeLimits(*entry.Limits)
}
return entry
}
func normalizeAnnouncement(announce Announcement) Announcement {
announce.ID = strings.TrimSpace(announce.ID)
announce.InstanceID = strings.TrimSpace(announce.InstanceID)
if announce.InstanceID == "" {
announce.InstanceID = announce.ID
}
announce.Service = strings.TrimSpace(announce.Service)
announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail))
announce.Network = strings.ToUpper(strings.TrimSpace(announce.Network))
@@ -239,6 +344,67 @@ func cloneStrings(values []string) []string {
return out
}
func (r *Registry) indexEntry(key string, entry *RegistryEntry) {
if r == nil || entry == nil || key == "" {
return
}
if entry.ID != "" {
addIndex(r.byID, entry.ID, key)
}
if entry.InstanceID != "" {
addIndex(r.byInstance, entry.InstanceID, key)
}
}
func (r *Registry) unindexEntry(key string, entry *RegistryEntry) {
if r == nil || entry == nil || key == "" {
return
}
if entry.ID != "" {
removeIndex(r.byID, entry.ID, key)
}
if entry.InstanceID != "" {
removeIndex(r.byInstance, entry.InstanceID, key)
}
}
func addIndex(index map[string]map[string]struct{}, id string, key string) {
if id == "" || key == "" {
return
}
set := index[id]
if set == nil {
set = map[string]struct{}{}
index[id] = set
}
set[key] = struct{}{}
}
func removeIndex(index map[string]map[string]struct{}, id string, key string) {
if id == "" || key == "" {
return
}
set := index[id]
if set == nil {
return
}
delete(set, key)
if len(set) == 0 {
delete(index, id)
}
}
func keysFromIndex(index map[string]struct{}) []string {
if len(index) == 0 {
return nil
}
keys := make([]string, 0, len(index))
for key := range index {
keys = append(keys, key)
}
return keys
}
func (e *RegistryEntry) isHealthyAt(now time.Time) bool {
if e == nil {
return false

View File

@@ -8,8 +8,9 @@ import (
"sync"
"time"
"github.com/nats-io/nats.go"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/messaging/broker"
mb "github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
@@ -22,6 +23,8 @@ type RegistryService struct {
producer msg.Producer
sender string
consumers []consumerHandler
kv *KVStore
kvWatcher nats.KeyWatcher
startOnce sync.Once
stopOnce sync.Once
@@ -30,10 +33,11 @@ type RegistryService struct {
type consumerHandler struct {
consumer msg.Consumer
handler msg.MessageHandlerT
event string
}
func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) {
if broker == nil {
func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) {
if msgBroker == nil {
return nil, errors.New("discovery registry: broker is nil")
}
if registry == nil {
@@ -47,19 +51,19 @@ func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer ms
sender = "discovery"
}
serviceConsumer, err := cons.NewConsumer(logger, broker, ServiceAnnounceEvent())
serviceConsumer, err := cons.NewConsumer(logger, msgBroker, ServiceAnnounceEvent())
if err != nil {
return nil, err
}
gatewayConsumer, err := cons.NewConsumer(logger, broker, GatewayAnnounceEvent())
gatewayConsumer, err := cons.NewConsumer(logger, msgBroker, GatewayAnnounceEvent())
if err != nil {
return nil, err
}
heartbeatConsumer, err := cons.NewConsumer(logger, broker, HeartbeatEvent())
heartbeatConsumer, err := cons.NewConsumer(logger, msgBroker, HeartbeatEvent())
if err != nil {
return nil, err
}
lookupConsumer, err := cons.NewConsumer(logger, broker, LookupRequestEvent())
lookupConsumer, err := cons.NewConsumer(logger, msgBroker, LookupRequestEvent())
if err != nil {
return nil, err
}
@@ -69,17 +73,18 @@ func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer ms
registry: registry,
producer: producer,
sender: sender,
consumers: []consumerHandler{
{consumer: serviceConsumer, handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: gatewayConsumer, handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: heartbeatConsumer, handler: svc.handleHeartbeat},
{consumer: lookupConsumer, handler: svc.handleLookup},
},
}
svc.consumers = []consumerHandler{
{consumer: serviceConsumer, event: ServiceAnnounceEvent().ToString(), handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: gatewayConsumer, event: GatewayAnnounceEvent().ToString(), handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: heartbeatConsumer, event: HeartbeatEvent().ToString(), handler: svc.handleHeartbeat},
{consumer: lookupConsumer, event: LookupRequestEvent().ToString(), handler: svc.handleLookup},
}
svc.initKV(msgBroker)
return svc, nil
}
@@ -88,14 +93,16 @@ func (s *RegistryService) Start() {
return
}
s.startOnce.Do(func() {
s.logInfo("Discovery registry service starting", zap.Int("consumers", len(s.consumers)), zap.Bool("kv_enabled", s.kv != nil))
for _, ch := range s.consumers {
ch := ch
go func() {
if err := ch.consumer.ConsumeMessages(ch.handler); err != nil && s.logger != nil {
s.logger.Warn("Discovery consumer stopped with error", zap.Error(err))
s.logger.Warn("Discovery consumer stopped with error", zap.String("event", ch.event), zap.Error(err))
}
}()
}
s.startKVWatch()
})
}
@@ -109,18 +116,29 @@ func (s *RegistryService) Stop() {
ch.consumer.Close()
}
}
if s.kvWatcher != nil {
_ = s.kvWatcher.Stop()
}
s.logInfo("Discovery registry service stopped")
})
}
func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) error {
var payload Announcement
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery announce payload", zap.Error(err))
fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err))
s.logWarn("Failed to decode discovery announce payload", fields...)
return err
}
if strings.TrimSpace(payload.InstanceID) == "" {
fields := append(envelopeFields(env), announcementFields(payload)...)
s.logWarn("Discovery announce missing instance id", fields...)
}
now := time.Now()
result := s.registry.UpsertFromAnnouncement(payload, now)
s.persistEntry(result.Entry)
if result.IsNew || result.BecameHealthy {
s.logInfo("Discovery registry entry updated", append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy))...)
s.publishRefresh(result.Entry)
}
return nil
@@ -129,37 +147,48 @@ func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) err
func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) error {
var payload Heartbeat
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery heartbeat payload", zap.Error(err))
fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err))
s.logWarn("Failed to decode discovery heartbeat payload", fields...)
return err
}
if payload.ID == "" {
if strings.TrimSpace(payload.InstanceID) == "" && strings.TrimSpace(payload.ID) == "" {
return nil
}
if strings.TrimSpace(payload.InstanceID) == "" {
fields := append(envelopeFields(env), zap.String("id", payload.ID))
s.logWarn("Discovery heartbeat missing instance id", fields...)
}
ts := time.Unix(payload.TS, 0)
if ts.Unix() <= 0 {
ts = time.Now()
}
result, ok := s.registry.UpdateHeartbeat(payload.ID, strings.TrimSpace(payload.Status), ts, time.Now())
if ok && result.BecameHealthy {
s.publishRefresh(result.Entry)
results := s.registry.UpdateHeartbeat(payload.ID, payload.InstanceID, strings.TrimSpace(payload.Status), ts, time.Now())
for _, result := range results {
if result.BecameHealthy {
s.logInfo("Discovery registry entry became healthy", append(entryFields(result.Entry), zap.String("status", result.Entry.Status))...)
s.publishRefresh(result.Entry)
}
s.persistEntry(result.Entry)
}
return nil
}
func (s *RegistryService) handleLookup(_ context.Context, env me.Envelope) error {
if s.producer == nil {
s.logWarn("Discovery lookup request ignored: producer not configured")
s.logWarn("Discovery lookup request ignored: producer not configured", envelopeFields(env)...)
return nil
}
var payload LookupRequest
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery lookup payload", zap.Error(err))
fields := append(envelopeFields(env), zap.Int("data_len", len(env.GetData())), zap.Error(err))
s.logWarn("Failed to decode discovery lookup payload", fields...)
return err
}
resp := s.registry.Lookup(time.Now())
resp.RequestID = strings.TrimSpace(payload.RequestID)
if err := s.producer.SendMessage(NewLookupResponseEnvelope(s.sender, resp)); err != nil {
s.logWarn("Failed to publish discovery lookup response", zap.Error(err))
fields := []zap.Field{zap.String("request_id", resp.RequestID), zap.Error(err)}
s.logWarn("Failed to publish discovery lookup response", fields...)
return err
}
return nil
@@ -170,13 +199,99 @@ func (s *RegistryService) publishRefresh(entry RegistryEntry) {
return
}
payload := RefreshEvent{
Service: entry.Service,
Rail: entry.Rail,
Network: entry.Network,
Message: "new module available",
InstanceID: entry.InstanceID,
Service: entry.Service,
Rail: entry.Rail,
Network: entry.Network,
Message: "new module available",
}
if err := s.producer.SendMessage(NewRefreshUIEnvelope(s.sender, payload)); err != nil {
s.logWarn("Failed to publish discovery refresh event", zap.Error(err))
fields := append(entryFields(entry), zap.Error(err))
s.logWarn("Failed to publish discovery refresh event", fields...)
}
}
type jetStreamProvider interface {
JetStream() nats.JetStreamContext
}
func (s *RegistryService) initKV(msgBroker mb.Broker) {
if s == nil || msgBroker == nil {
return
}
provider, ok := msgBroker.(jetStreamProvider)
if !ok {
return
}
js := provider.JetStream()
if js == nil {
return
}
store, err := NewKVStore(s.logger, js, "")
if err != nil {
s.logWarn("Failed to initialise discovery KV store", zap.Error(err))
return
}
s.kv = store
}
func (s *RegistryService) startKVWatch() {
if s == nil || s.kv == nil {
return
}
watcher, err := s.kv.WatchAll()
if err != nil {
s.logWarn("Failed to start discovery KV watch", zap.Error(err))
return
}
s.kvWatcher = watcher
if bucket := s.kv.Bucket(); bucket != "" {
s.logInfo("Discovery KV watch started", zap.String("bucket", bucket))
}
go s.consumeKVUpdates(watcher)
}
func (s *RegistryService) consumeKVUpdates(watcher nats.KeyWatcher) {
if s == nil || watcher == nil {
return
}
for entry := range watcher.Updates() {
if entry == nil {
continue
}
switch entry.Operation() {
case nats.KeyValueDelete, nats.KeyValuePurge:
key := registryKeyFromKVKey(entry.Key())
if key != "" {
if s.registry.Delete(key) {
s.logInfo("Discovery registry entry removed", zap.String("key", key))
}
}
continue
case nats.KeyValuePut:
default:
continue
}
var payload RegistryEntry
if err := json.Unmarshal(entry.Value(), &payload); err != nil {
s.logWarn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err))
continue
}
result := s.registry.UpsertEntry(payload, time.Now())
if result.IsNew || result.BecameHealthy {
s.logInfo("Discovery registry entry updated from KV", append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy))...)
s.publishRefresh(result.Entry)
}
}
}
func (s *RegistryService) persistEntry(entry RegistryEntry) {
if s == nil || s.kv == nil {
return
}
if err := s.kv.Put(entry); err != nil {
s.logWarn("Failed to persist discovery entry", append(entryFields(entry), zap.Error(err))...)
}
}
@@ -186,3 +301,10 @@ func (s *RegistryService) logWarn(message string, fields ...zap.Field) {
}
s.logger.Warn(message, fields...)
}
func (s *RegistryService) logInfo(message string, fields ...zap.Field) {
if s.logger == nil {
return
}
s.logger.Info(message, fields...)
}

View File

@@ -14,6 +14,7 @@ type Limits struct {
type Announcement struct {
ID string `json:"id"`
InstanceID string `bson:"instanceId" json:"instanceId"`
Service string `json:"service"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
@@ -27,14 +28,16 @@ type Announcement struct {
}
type Heartbeat struct {
ID string `json:"id"`
Status string `json:"status"`
TS int64 `json:"ts"`
ID string `json:"id"`
InstanceID string `json:"instanceId"`
Status string `json:"status"`
TS int64 `json:"ts"`
}
type RefreshEvent struct {
Service string `json:"service,omitempty"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Message string `json:"message,omitempty"`
InstanceID string `json:"instanceId,omitempty"`
Service string `json:"service,omitempty"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Message string `json:"message,omitempty"`
}

View File

@@ -0,0 +1,126 @@
package discovery
import (
"encoding/json"
"errors"
"sync"
"time"
"github.com/nats-io/nats.go"
mb "github.com/tech/sendico/pkg/messaging/broker"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type RegistryWatcher struct {
logger mlogger.Logger
registry *Registry
kv *KVStore
watcher nats.KeyWatcher
stopOnce sync.Once
}
func NewRegistryWatcher(logger mlogger.Logger, msgBroker mb.Broker, registry *Registry) (*RegistryWatcher, error) {
if msgBroker == nil {
return nil, errors.New("discovery watcher: broker is nil")
}
if registry == nil {
registry = NewRegistry()
}
if logger != nil {
logger = logger.Named("discovery_watcher")
}
provider, ok := msgBroker.(jetStreamProvider)
if !ok {
return nil, errors.New("discovery watcher: jetstream not available")
}
js := provider.JetStream()
if js == nil {
return nil, errors.New("discovery watcher: jetstream not configured")
}
store, err := NewKVStore(logger, js, "")
if err != nil {
return nil, err
}
return &RegistryWatcher{
logger: logger,
registry: registry,
kv: store,
}, nil
}
func (w *RegistryWatcher) Start() error {
if w == nil || w.kv == nil {
return errors.New("discovery watcher: not configured")
}
watcher, err := w.kv.WatchAll()
if err != nil {
return err
}
w.watcher = watcher
if w.logger != nil {
w.logger.Info("Discovery registry watcher started", zap.String("bucket", w.kv.Bucket()))
}
go w.consume(watcher)
return nil
}
func (w *RegistryWatcher) Stop() {
if w == nil {
return
}
w.stopOnce.Do(func() {
if w.watcher != nil {
_ = w.watcher.Stop()
}
if w.logger != nil {
w.logger.Info("Discovery registry watcher stopped")
}
})
}
func (w *RegistryWatcher) Registry() *Registry {
if w == nil {
return nil
}
return w.registry
}
func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) {
if w == nil || watcher == nil {
return
}
for entry := range watcher.Updates() {
if entry == nil {
continue
}
switch entry.Operation() {
case nats.KeyValueDelete, nats.KeyValuePurge:
key := registryKeyFromKVKey(entry.Key())
if key != "" {
if w.registry.Delete(key) && w.logger != nil {
w.logger.Info("Discovery registry entry removed", zap.String("key", key))
}
}
continue
case nats.KeyValuePut:
default:
continue
}
var payload RegistryEntry
if err := json.Unmarshal(entry.Value(), &payload); err != nil {
if w.logger != nil {
w.logger.Warn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err))
}
continue
}
result := w.registry.UpsertEntry(payload, time.Now())
if w.logger != nil && (result.IsNew || result.BecameHealthy) {
fields := append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy))
w.logger.Info("Discovery registry entry updated from KV", fields...)
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -20,6 +21,7 @@ type natsSubscriotions = map[string]*TopicSubscription
type NatsBroker struct {
nc *nats.Conn
js nats.JetStreamContext
logger *zap.Logger
topicSubs natsSubscriotions
mu sync.Mutex
@@ -78,23 +80,46 @@ func loadEnv(settings *nc.Settings, l *zap.Logger) (*envConfig, error) {
func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, error) {
l := logger.Named("broker")
// Helper function to get environment variables
cfg, err := loadEnv(settings, l)
if err != nil {
return nil, err
var err error
var cfg *envConfig
var natsURL string
if settings != nil && strings.TrimSpace(settings.URLEnv) != "" {
urlVal := strings.TrimSpace(os.Getenv(settings.URLEnv))
if urlVal != "" {
natsURL = urlVal
}
}
if natsURL == "" {
// Helper function to get environment variables
cfg, err = loadEnv(settings, l)
if err != nil {
return nil, err
}
u := &url.URL{
Scheme: "nats",
Host: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
u := &url.URL{
Scheme: "nats",
Host: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
}
natsURL = u.String()
}
natsURL := u.String()
opts := []nats.Option{
nats.Name(settings.NATSName),
nats.MaxReconnects(settings.MaxReconnects),
nats.ReconnectWait(time.Duration(settings.ReconnectWait) * time.Second),
nats.UserInfo(cfg.User, cfg.Password),
}
if cfg != nil {
opts = append(opts, nats.UserInfo(cfg.User, cfg.Password))
} else if settings != nil {
userEnv := strings.TrimSpace(settings.UsernameEnv)
passEnv := strings.TrimSpace(settings.PasswordEnv)
if userEnv != "" && passEnv != "" {
user := strings.TrimSpace(os.Getenv(userEnv))
pass := strings.TrimSpace(os.Getenv(passEnv))
if user != "" || pass != "" {
opts = append(opts, nats.UserInfo(user, pass))
}
}
}
res := &NatsBroker{
@@ -106,8 +131,18 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
l.Error("Failed to connect to NATS", zap.String("url", natsURL), zap.Error(err))
return nil, err
}
if res.js, err = res.nc.JetStream(); err != nil {
l.Warn("Failed to initialise JetStream context", zap.Error(err))
}
logger.Info("Connected to NATS", zap.String("broker", settings.NATSName),
zap.String("url", fmt.Sprintf("nats://%s@%s", cfg.User, net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)))))
zap.String("url", natsURL))
return res, nil
}
func (b *NatsBroker) JetStream() nats.JetStreamContext {
if b == nil {
return nil
}
return b.js
}

View File

@@ -7,6 +7,7 @@ import (
"os/signal"
"syscall"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/mlogger"
lf "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/server"
@@ -28,6 +29,7 @@ func prepareLogger() mlogger.Logger {
func RunServer(rootLoggerName string, av version.Printer, factory server.ServerFactoryT) {
logger := prepareLogger().Named(rootLoggerName)
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
defer logger.Sync()
// Show version information

View File

@@ -37,6 +37,7 @@ api:
message_broker:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
@@ -54,7 +55,7 @@ api:
length: 32
password:
token_length: 32
checks:
check:
min_length: 8
digit: true
upper: true

View File

@@ -47,6 +47,12 @@ NATS_MONITORING_PORT=8222
NATS_PROMETHEUS_PORT=7777
NATS_COMPOSE_PROJECT=sendico-nats
# Discovery service
DISCOVERY_DIR=discovery
DISCOVERY_COMPOSE_PROJECT=sendico-discovery
DISCOVERY_SERVICE_NAME=sendico_discovery
DISCOVERY_METRICS_PORT=9405
# Shared Mongo settings for FX services
FX_MONGO_HOST=sendico_db1

View File

@@ -0,0 +1,40 @@
# 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/discovery
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/discovery/internal/appversion.Version=${APP_VERSION} \
-X github.com/tech/sendico/discovery/internal/appversion.Revision=${GIT_REV} \
-X github.com/tech/sendico/discovery/internal/appversion.Branch=${BUILD_BRANCH} \
-X github.com/tech/sendico/discovery/internal/appversion.BuildUser=${BUILD_USER} \
-X github.com/tech/sendico/discovery/internal/appversion.BuildDate=${BUILD_DATE}" \
-o /out/discovery .
FROM alpine:latest AS runtime
RUN apk add --no-cache ca-certificates tzdata wget
WORKDIR /app
COPY api/discovery/config.yml /app/config.yml
COPY --from=build /out/discovery /app/discovery
EXPOSE 9405
ENTRYPOINT ["/app/discovery"]
CMD ["--config.file", "/app/config.yml"]

View File

@@ -0,0 +1,37 @@
# Compose v2 - Discovery service
x-common-env: &common-env
env_file:
- ../env/.env.runtime
- ../env/.env.version
networks:
sendico-net:
external: true
name: sendico-net
services:
sendico_discovery:
<<: *common-env
container_name: sendico-discovery
restart: unless-stopped
image: ${REGISTRY_URL}/discovery/service:${APP_V}
pull_policy: always
environment:
NATS_URL: ${NATS_URL}
NATS_HOST: ${NATS_HOST}
NATS_PORT: ${NATS_PORT}
NATS_USER: ${NATS_USER}
NATS_PASSWORD: ${NATS_PASSWORD}
DISCOVERY_METRICS_PORT: ${DISCOVERY_METRICS_PORT}
command: ["--config.file", "/app/config.yml"]
ports:
- "0.0.0.0:${DISCOVERY_METRICS_PORT}:${DISCOVERY_METRICS_PORT}"
healthcheck:
test: ["CMD-SHELL","wget -qO- http://localhost:${DISCOVERY_METRICS_PORT}/health | grep -q '\"status\":\"ok\"'"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- sendico-net

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x
trap 'echo "[deploy-discovery] error at line $LINENO" >&2' ERR
: "${REMOTE_BASE:?missing REMOTE_BASE}"
: "${SSH_USER:?missing SSH_USER}"
: "${SSH_HOST:?missing SSH_HOST}"
: "${DISCOVERY_DIR:?missing DISCOVERY_DIR}"
: "${DISCOVERY_COMPOSE_PROJECT:?missing DISCOVERY_COMPOSE_PROJECT}"
: "${DISCOVERY_SERVICE_NAME:?missing DISCOVERY_SERVICE_NAME}"
REMOTE_DIR="${REMOTE_BASE%/}/${DISCOVERY_DIR}"
REMOTE_TARGET="${SSH_USER}@${SSH_HOST}"
COMPOSE_FILE="discovery.yml"
SERVICE_NAMES="${DISCOVERY_SERVICE_NAME}"
REQUIRED_SECRETS=(
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'
}
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="$DISCOVERY_COMPOSE_PROJECT" \
SERVICES_LINE="$SERVICES_LINE" \
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}"
}
NATS_USER="$(decode_b64 "$NATS_USER_B64")"
NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")"
NATS_URL="$(decode_b64 "$NATS_URL_B64")"
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

View 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"
}
DISCOVERY_ENV_NAME="${DISCOVERY_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${DISCOVERY_ENV_NAME}/.env.runtime"
if [ ! -f "${RUNTIME_ENV_FILE}" ]; then
echo "[discovery-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}"
DISCOVERY_DOCKERFILE="${DISCOVERY_DOCKERFILE:?missing DISCOVERY_DOCKERFILE}"
DISCOVERY_IMAGE_PATH="${DISCOVERY_IMAGE_PATH:?missing DISCOVERY_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="${DISCOVERY_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}"
if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace"
fi
/kaniko/executor \
--context "${BUILD_CONTEXT}" \
--dockerfile "${DISCOVERY_DOCKERFILE}" \
--destination "${REGISTRY_URL}/${DISCOVERY_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

View File

@@ -0,0 +1,57 @@
#!/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"
}
DISCOVERY_ENV_NAME="${DISCOVERY_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${DISCOVERY_ENV_NAME}/.env.runtime"
if [ ! -f "${RUNTIME_ENV_FILE}" ]; then
echo "[discovery-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
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)"
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/discovery.sh