diff --git a/.woodpecker/discovery.yml b/.woodpecker/discovery.yml new file mode 100644 index 0000000..18b69d8 --- /dev/null +++ b/.woodpecker/discovery.yml @@ -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 diff --git a/api/discovery/.gitignore b/api/discovery/.gitignore new file mode 100644 index 0000000..c62beb6 --- /dev/null +++ b/api/discovery/.gitignore @@ -0,0 +1,3 @@ +internal/generated +.gocache +app diff --git a/api/discovery/config.yml b/api/discovery/config.yml new file mode 100644 index 0000000..a022be6 --- /dev/null +++ b/api/discovery/config.yml @@ -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 diff --git a/api/discovery/go.mod b/api/discovery/go.mod new file mode 100644 index 0000000..76c6541 --- /dev/null +++ b/api/discovery/go.mod @@ -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 +) diff --git a/api/discovery/go.sum b/api/discovery/go.sum new file mode 100644 index 0000000..fbbbd30 --- /dev/null +++ b/api/discovery/go.sum @@ -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= diff --git a/api/discovery/internal/appversion/version.go b/api/discovery/internal/appversion/version.go new file mode 100644 index 0000000..57e404b --- /dev/null +++ b/api/discovery/internal/appversion/version.go @@ -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) +} diff --git a/api/discovery/internal/server/internal/config.go b/api/discovery/internal/server/internal/config.go new file mode 100644 index 0000000..60eaceb --- /dev/null +++ b/api/discovery/internal/server/internal/config.go @@ -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 +} diff --git a/api/discovery/internal/server/internal/discovery.go b/api/discovery/internal/server/internal/discovery.go new file mode 100644 index 0000000..eee5f76 --- /dev/null +++ b/api/discovery/internal/server/internal/discovery.go @@ -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 + } +} diff --git a/api/discovery/internal/server/internal/metrics.go b/api/discovery/internal/server/internal/metrics.go new file mode 100644 index 0000000..a4cfa45 --- /dev/null +++ b/api/discovery/internal/server/internal/metrics.go @@ -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 +} diff --git a/api/discovery/internal/server/internal/serverimp.go b/api/discovery/internal/server/internal/serverimp.go new file mode 100644 index 0000000..7806d87 --- /dev/null +++ b/api/discovery/internal/server/internal/serverimp.go @@ -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 +} diff --git a/api/discovery/internal/server/internal/types.go b/api/discovery/internal/server/internal/types.go new file mode 100644 index 0000000..171d338 --- /dev/null +++ b/api/discovery/internal/server/internal/types.go @@ -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{} +} diff --git a/api/discovery/internal/server/server.go b/api/discovery/internal/server/server.go new file mode 100644 index 0000000..cf24592 --- /dev/null +++ b/api/discovery/internal/server/server.go @@ -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) +} diff --git a/api/discovery/main.go b/api/discovery/main.go new file mode 100644 index 0000000..16d1242 --- /dev/null +++ b/api/discovery/main.go @@ -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) +} diff --git a/api/fx/ingestor/config.yml b/api/fx/ingestor/config.yml index f9b67dc..4f9459f 100644 --- a/api/fx/ingestor/config.yml +++ b/api/fx/ingestor/config.yml @@ -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: diff --git a/api/fx/ingestor/main.go b/api/fx/ingestor/main.go index ce51c38..7f47708 100644 --- a/api/fx/ingestor/main.go +++ b/api/fx/ingestor/main.go @@ -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() diff --git a/api/notification/config.yml b/api/notification/config.yml index 2277682..1c3a9b3 100755 --- a/api/notification/config.yml +++ b/api/notification/config.yml @@ -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 diff --git a/api/payments/orchestrator/internal/server/internal/builders.go b/api/payments/orchestrator/internal/server/internal/builders.go new file mode 100644 index 0000000..b2f8858 --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/builders.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/server/internal/clients.go b/api/payments/orchestrator/internal/server/internal/clients.go new file mode 100644 index 0000000..96c122e --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/clients.go @@ -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() + } +} diff --git a/api/payments/orchestrator/internal/server/internal/config.go b/api/payments/orchestrator/internal/server/internal/config.go new file mode 100644 index 0000000..8ae52aa --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/config.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go new file mode 100644 index 0000000..ae82f9d --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/server/internal/discovery.go b/api/payments/orchestrator/internal/server/internal/discovery.go new file mode 100644 index 0000000..acd506a --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/discovery.go @@ -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() + } +} diff --git a/api/payments/orchestrator/internal/server/internal/lifecycle.go b/api/payments/orchestrator/internal/server/internal/lifecycle.go new file mode 100644 index 0000000..583700b --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/lifecycle.go @@ -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() + } +} diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 888bc1a..81f3f3b 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -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 -} diff --git a/api/payments/orchestrator/internal/server/internal/types.go b/api/payments/orchestrator/internal/server/internal/types.go new file mode 100644 index 0000000..10b3a38 --- /dev/null +++ b/api/payments/orchestrator/internal/server/internal/types.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go index 4ebb90f..606ae52 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go @@ -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), diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 4d01956..88c33e7 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -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"` diff --git a/api/pkg/discovery/announcer.go b/api/pkg/discovery/announcer.go index 5480974..1ec9e31 100644 --- a/api/pkg/discovery/announcer.go +++ b/api/pkg/discovery/announcer.go @@ -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 == "" { diff --git a/api/pkg/discovery/client.go b/api/pkg/discovery/client.go index 40ff434..b82f56d 100644 --- a/api/pkg/discovery/client.go +++ b/api/pkg/discovery/client.go @@ -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) diff --git a/api/pkg/discovery/instanceid.go b/api/pkg/discovery/instanceid.go new file mode 100644 index 0000000..f219601 --- /dev/null +++ b/api/pkg/discovery/instanceid.go @@ -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 +} diff --git a/api/pkg/discovery/instanceid_test.go b/api/pkg/discovery/instanceid_test.go new file mode 100644 index 0000000..2948652 --- /dev/null +++ b/api/pkg/discovery/instanceid_test.go @@ -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) + } +} diff --git a/api/pkg/discovery/keys.go b/api/pkg/discovery/keys.go new file mode 100644 index 0000000..440fd42 --- /dev/null +++ b/api/pkg/discovery/keys.go @@ -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 +} diff --git a/api/pkg/discovery/kv.go b/api/pkg/discovery/kv.go new file mode 100644 index 0000000..21ba7a9 --- /dev/null +++ b/api/pkg/discovery/kv.go @@ -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 +} diff --git a/api/pkg/discovery/logging.go b/api/pkg/discovery/logging.go new file mode 100644 index 0000000..4a1a94b --- /dev/null +++ b/api/pkg/discovery/logging.go @@ -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 +} diff --git a/api/pkg/discovery/lookup.go b/api/pkg/discovery/lookup.go index 2658307..879e70f 100644 --- a/api/pkg/discovery/lookup.go +++ b/api/pkg/discovery/lookup.go @@ -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, }) } diff --git a/api/pkg/discovery/registry.go b/api/pkg/discovery/registry.go index 8644d35..4f6c0cd 100644 --- a/api/pkg/discovery/registry.go +++ b/api/pkg/discovery/registry.go @@ -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 diff --git a/api/pkg/discovery/service.go b/api/pkg/discovery/service.go index 491c203..ea28190 100644 --- a/api/pkg/discovery/service.go +++ b/api/pkg/discovery/service.go @@ -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...) +} diff --git a/api/pkg/discovery/types.go b/api/pkg/discovery/types.go index 8a0e19a..cb2fde7 100644 --- a/api/pkg/discovery/types.go +++ b/api/pkg/discovery/types.go @@ -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"` } diff --git a/api/pkg/discovery/watcher.go b/api/pkg/discovery/watcher.go new file mode 100644 index 0000000..559196b --- /dev/null +++ b/api/pkg/discovery/watcher.go @@ -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...) + } + } +} diff --git a/api/pkg/messaging/internal/natsb/broker.go b/api/pkg/messaging/internal/natsb/broker.go index b2e2b97..d23dd62 100644 --- a/api/pkg/messaging/internal/natsb/broker.go +++ b/api/pkg/messaging/internal/natsb/broker.go @@ -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 +} diff --git a/api/pkg/server/internal/server.go b/api/pkg/server/internal/server.go index 3160384..a31035b 100644 --- a/api/pkg/server/internal/server.go +++ b/api/pkg/server/internal/server.go @@ -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 diff --git a/api/server/config.yml b/api/server/config.yml index a3bff14..d35cb8b 100755 --- a/api/server/config.yml +++ b/api/server/config.yml @@ -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 diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index c8dbeb2..0042903 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -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 diff --git a/ci/prod/compose/discovery.dockerfile b/ci/prod/compose/discovery.dockerfile new file mode 100644 index 0000000..1596126 --- /dev/null +++ b/ci/prod/compose/discovery.dockerfile @@ -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"] diff --git a/ci/prod/compose/discovery.yml b/ci/prod/compose/discovery.yml new file mode 100644 index 0000000..7bc3341 --- /dev/null +++ b/ci/prod/compose/discovery.yml @@ -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 diff --git a/ci/prod/scripts/deploy/discovery.sh b/ci/prod/scripts/deploy/discovery.sh new file mode 100644 index 0000000..f9c2b4b --- /dev/null +++ b/ci/prod/scripts/deploy/discovery.sh @@ -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 diff --git a/ci/scripts/discovery/build-image.sh b/ci/scripts/discovery/build-image.sh new file mode 100644 index 0000000..20b9d62 --- /dev/null +++ b/ci/scripts/discovery/build-image.sh @@ -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 </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 diff --git a/ci/scripts/discovery/deploy.sh b/ci/scripts/discovery/deploy.sh new file mode 100644 index 0000000..f120e17 --- /dev/null +++ b/ci/scripts/discovery/deploy.sh @@ -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