Reduce ingest scope and bootstrap app deploy

This commit is contained in:
philipp 2026-04-01 00:09:10 +02:00
parent 086ec01597
commit 24a5002d1d
18 changed files with 1134 additions and 187 deletions

View file

@ -1,6 +1,7 @@
# Local dev / container runtime values # Local dev / container runtime values
NEAR_INTENTS_API_KEY=replace_me NEAR_INTENTS_API_KEY=replace_me
NEAR_INTENTS_WS_URL=wss://solver-relay-v2.chaindefuser.com/ws NEAR_INTENTS_WS_URL=wss://solver-relay-v2.chaindefuser.com/ws
NEAR_INTENTS_PAIR_FILTER=nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omf
KAFKA_BROKERS=redpanda:9092 KAFKA_BROKERS=redpanda:9092
KAFKA_CLIENT_ID=unrip KAFKA_CLIENT_ID=unrip
KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE=raw.near_intents.quote KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE=raw.near_intents.quote
@ -11,29 +12,12 @@ KAFKA_CONSUMER_GROUP_DUMMY=dummy-reactor-v1
KAFKA_CONSUMER_GROUP_EXECUTOR=dummy-executor-v1 KAFKA_CONSUMER_GROUP_EXECUTOR=dummy-executor-v1
EXECUTOR_STATE_DIR=/var/lib/unrip/executor-state EXECUTOR_STATE_DIR=/var/lib/unrip/executor-state
# Repo-driven Hetzner bootstrap values live separately from the app .env. # Platform bootstrap values live in the separate infra/platform repo, not in
# Copy scripts/hetzner/bootstrap-secrets.env.example to # this application repo. In the current local split that repo is `../unrip3`.
# scripts/hetzner/bootstrap-secrets.env, configure non-secret values plus *_PASS # Configure and run bootstrap there, then deploy this repo using the app-side
# mappings to your pass store, then: # workflow described in `docs/deployment.md`.
# source scripts/hetzner/bootstrap-secrets.env
# bash scripts/hetzner/bootstrap.sh
#
# Canonical operator flow uses `pass` for sensitive values; explicit env vars still
# override pass-backed lookups for CI/testing.
#
# Expected bootstrap inputs now include:
# - HCLOUD_TOKEN_PASS or HCLOUD_TOKEN
# - SSH_PUBLIC_KEY_PATH
# - PUBLIC_DOMAIN
# - BASE_DOMAIN
# - LETSENCRYPT_EMAIL
# - REGISTRY_USERNAME
# - REGISTRY_PASSWORD_PASS or REGISTRY_PASSWORD
# - NEAR_INTENTS_API_KEY_PASS or NEAR_INTENTS_API_KEY
# - FORGEJO_ADMIN_USERNAME
# - FORGEJO_ADMIN_EMAIL
# - FORGEJO_ADMIN_PASSWORD_PASS or FORGEJO_ADMIN_PASSWORD
# - optional DNS provider creds via *_PASS or direct env vars
# #
# Future k3s deployment should source the app values from Kubernetes Secret/ConfigMap. # Future k3s deployment should source the app values from Kubernetes Secret/ConfigMap.
# Hetzner provisioning is workstation-driven after Terraform; cloud-init no longer clones this repo onto the node. # This repo expects app-side cluster secrets such as:
# - `unrip-secrets` for `NEAR_INTENTS_API_KEY`
# - `unrip-registry-creds` for image pulls and in-cluster Kaniko builds

View file

@ -20,7 +20,25 @@ jobs:
steps: steps:
- name: Install tooling - name: Install tooling
run: | run: |
if command -v git >/dev/null 2>&1 && command -v kubectl >/dev/null 2>&1; then
exit 0
fi
if command -v apk >/dev/null 2>&1; then
apk add --no-cache git kubectl apk add --no-cache git kubectl
exit 0
fi
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x /usr/local/bin/kubectl
exit 0
fi
echo "missing git/kubectl and no supported package manager found" >&2
exit 1
- name: Load kubeconfig - name: Load kubeconfig
run: | run: |
@ -40,7 +58,7 @@ jobs:
- name: Resolve deployment settings - name: Resolve deployment settings
run: | run: |
IMAGE="$REGISTRY_HOST/$PROJECT_NAME:$IMAGE_TAG" IMAGE="$REGISTRY_HOST/$PROJECT_NAME:$IMAGE_TAG"
BUILD_JOB="image-build-${GITHUB_SHA:0:12}" BUILD_JOB="image-build-$(printf '%s' "$GITHUB_SHA" | cut -c1-12)"
{ {
echo "IMAGE=$IMAGE" echo "IMAGE=$IMAGE"
echo "BUILD_JOB=$BUILD_JOB" echo "BUILD_JOB=$BUILD_JOB"
@ -115,9 +133,7 @@ jobs:
- name: Roll deployments to new image - name: Roll deployments to new image
run: | run: |
IFS=',' read -r -a DEPLOYMENTS <<< "$PROJECT_DEPLOYMENTS" printf '%s\n' "$PROJECT_DEPLOYMENTS" | tr ',' '\n' | while IFS= read -r deployment; do
for deployment in "${DEPLOYMENTS[@]}"; do
deployment="$(echo "$deployment" | xargs)" deployment="$(echo "$deployment" | xargs)"
[ -n "$deployment" ] || continue [ -n "$deployment" ] || continue

View file

@ -55,5 +55,51 @@ The shared cluster/platform resources live in the separate infra repository.
## Deployment ## Deployment
This repo includes `.forgejo/workflows/deploy.yml`. This repo is the app-side deployment repo. The shared Hetzner/k3s bootstrap,
On push to `main`, Forgejo builds the image from this repo root, pushes it to the shared registry, applies `deploy/k8s/base`, and rolls the app deployments in the `unrip` namespace. Forgejo runner, registry, and other platform services live in the separate
platform repo.
See `docs/deployment.md` for the full operator path.
### One-time app bootstrap
Bootstrap the app namespace, secrets, and Forgejo repo settings from this repo:
```bash
bash scripts/deploy/bootstrap.sh
```
By default, the script uses the adjacent platform checkout at `../unrip3` for:
- `kubeconfig.yaml`
- `kubeconfig.incluster.yaml`
- registry credentials
- the `NEAR_INTENTS_API_KEY` fallback from `../unrip3/.env`
If you are not using that local split, provide the values yourself via env vars
such as `KUBECONFIG_PATH`, `CI_KUBECONFIG_PATH`, `REGISTRY_HOST`,
`REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, `NEAR_INTENTS_API_KEY`, and either
`FORGEJO_TOKEN` or `FORGEJO_ADMIN_USERNAME` / `FORGEJO_ADMIN_PASSWORD`.
### Routine deploy
After bootstrap, deployment is just a push to Forgejo `main`:
```bash
git push forgejo main
```
`.forgejo/workflows/deploy.yml` then:
- applies `deploy/k8s/base`
- builds the image from this repo root inside the cluster with Kaniko
- pushes it to the shared registry
- rolls the `unrip` deployments
### Observe rollout
```bash
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip get deploy,pods,job
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/near-intents-ingest
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/dummy-reactor
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/dummy-executor
KUBECONFIG=../unrip3/.state/hetzner/kubeconfig.yaml kubectl -n unrip rollout status deploy/dummy-consumer
```

View file

@ -5,6 +5,7 @@ metadata:
namespace: unrip namespace: unrip
data: data:
NEAR_INTENTS_WS_URL: wss://solver-relay-v2.chaindefuser.com/ws NEAR_INTENTS_WS_URL: wss://solver-relay-v2.chaindefuser.com/ws
NEAR_INTENTS_PAIR_FILTER: nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omf
KAFKA_BROKERS: redpanda.unrip.svc.cluster.local:9092 KAFKA_BROKERS: redpanda.unrip.svc.cluster.local:9092
KAFKA_CLIENT_ID: unrip KAFKA_CLIENT_ID: unrip
KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE: raw.near_intents.quote KAFKA_TOPIC_RAW_NEAR_INTENTS_QUOTE: raw.near_intents.quote

188
docs/deployment.md Normal file
View file

@ -0,0 +1,188 @@
# Deployment
This repository owns the app-side deployment assets for `unrip`:
- application source
- Docker build inputs
- Kubernetes manifests under `deploy/k8s/base/`
- the Forgejo workflow under `.forgejo/workflows/deploy.yml`
The shared platform bootstrap lives in the separate infra/platform repository.
In the current local split, that repo is available as `../unrip3`.
## Ownership split
Platform repo responsibilities:
- Hetzner/OpenTofu provisioning
- cloud-init and k3s bootstrap
- shared registry
- Forgejo and the Forgejo runner
- cert-manager, Traefik, observability, and other cluster services
This repo responsibilities:
- app image build context
- app namespace manifests
- app runtime secret names
- app deployment workflow
## Deployment model
The intended production path is:
1. bootstrap the shared platform from the platform repo
2. create the `unrip` app secrets in the cluster
3. push this repo to Forgejo
4. let `.forgejo/workflows/deploy.yml` build and roll the app in-cluster
The image build happens inside Kubernetes via Kaniko. The operator pushes Git,
not Docker images.
## Platform prerequisite
Before deploying this repo, the platform repo should already have completed its
Hetzner bootstrap flow. From the current local split, the relevant operator docs
live in:
- `../unrip3/deploy/hetzner/README.md`
- `../unrip3/deploy/k8s/README.md`
- `../unrip3/docs/hetzner-self-hosted-ci-runbook.md`
After platform bootstrap, you should have:
- a reachable Forgejo instance
- a reachable shared registry
- a running Forgejo runner in the cluster
- `../unrip3/.state/hetzner/kubeconfig.yaml`
- `../unrip3/.state/hetzner/kubeconfig.incluster.yaml`
## One-time app namespace setup
Apply the namespace first:
```bash
kubectl apply -f deploy/k8s/base/namespace.yaml
```
Create the app runtime secret required by `near-intents-ingest`:
```bash
kubectl -n unrip create secret generic unrip-secrets \
--from-literal=NEAR_INTENTS_API_KEY=replace_me
```
Create the registry auth secret used both for image pulls and the in-cluster
Kaniko build job:
```bash
kubectl -n unrip create secret docker-registry unrip-registry-creds \
--docker-server=registry.<your-domain> \
--docker-username="$REGISTRY_USERNAME" \
--docker-password="$REGISTRY_PASSWORD"
```
Then apply the app manifests:
```bash
kubectl apply -k deploy/k8s/base
```
Notes:
- `deploy/k8s/base/unrip.yaml` references `unrip-secrets` and
`unrip-registry-creds`; they are not created by this repo.
- the checked-in image `ghcr.io/example/unrip:bootstrap` is only a placeholder.
The first real image tag is set by the Forgejo workflow or by a manual
`kubectl set image`.
## Required Forgejo repo settings
Required repo secret:
- `KUBECONFIG_B64`
Required repo variable:
- `REGISTRY_HOST`
Recommended repo variables when you do not want to rely on workflow defaults:
- `PROJECT_NAME`
- `PROJECT_NAMESPACE`
- `PROJECT_DEPLOYMENTS`
- `PROJECT_REGISTRY_SECRET_NAME`
Recommended values for this repo:
- `REGISTRY_HOST=registry.<your-domain>`
- `PROJECT_NAME=unrip`
- `PROJECT_NAMESPACE=unrip`
- `PROJECT_DEPLOYMENTS=near-intents-ingest,dummy-reactor,dummy-executor,dummy-consumer`
- `PROJECT_REGISTRY_SECRET_NAME=unrip-registry-creds`
`KUBECONFIG_B64` should be the base64-encoded contents of the in-cluster
kubeconfig produced by the platform repo, not the public workstation kubeconfig:
```bash
base64 -w0 ../unrip3/.state/hetzner/kubeconfig.incluster.yaml
```
The current workflow does not read `REGISTRY_USERNAME` or `REGISTRY_PASSWORD`
from Forgejo directly. Those values are still needed on the operator side when
creating the `unrip-registry-creds` Kubernetes secret.
## Seed the repo into Forgejo
Create the repository in Forgejo, add a `forgejo` remote for this repo, and
push `main`. For example:
```bash
git remote add forgejo https://git.<your-domain>/<owner>/$(basename "$PWD").git
git push forgejo HEAD:refs/heads/main
```
## Routine deploy flow
Once the repo exists in Forgejo and the repo settings above are configured:
```bash
git push forgejo main
```
On push to `main`, `.forgejo/workflows/deploy.yml` does the following:
1. loads kubeconfig from `KUBECONFIG_B64`
2. applies `deploy/k8s/base`
3. creates an in-cluster Kaniko Job in the `unrip` namespace
4. builds and pushes `REGISTRY_HOST/unrip:<git-sha>`
5. updates the four app deployments and waits for rollout
## Observe rollout
```bash
kubectl -n unrip get deploy,pods,pvc,job
kubectl -n unrip rollout status deploy/near-intents-ingest
kubectl -n unrip rollout status deploy/dummy-reactor
kubectl -n unrip rollout status deploy/dummy-executor
kubectl -n unrip rollout status deploy/dummy-consumer
```
Useful logs:
```bash
kubectl -n unrip logs deploy/near-intents-ingest -f
kubectl -n unrip logs deploy/dummy-reactor -f
kubectl -n unrip logs deploy/dummy-executor -f
kubectl -n unrip logs deploy/dummy-consumer -f
kubectl -n unrip logs job/redpanda-topic-bootstrap
```
## Local development
Local iteration remains separate from the production path:
```bash
cp .env.example .env
docker compose up -d --build
```
That path is for local testing. Production rollout is Forgejo + Kubernetes.

View file

@ -209,41 +209,46 @@ Target environment:
The first version may run on one machine, but deployment structure should already match a future distributed system. The first version may run on one machine, but deployment structure should already match a future distributed system.
### Current canonical operator path ### Current canonical operator path
The repo now documents and partially implements this path as the primary deployment workflow: After the repo split, the primary deployment path is shared across two repos:
- the separate platform repo owns Hetzner/OpenTofu, cloud-init, k3s bootstrap,
Forgejo, the runner, and the shared registry
- this repo owns the app image, app manifests, and the app rollout workflow
#### Phase 0: workstation bootstrap #### Phase 0: platform bootstrap (platform repo)
1. A local operator workstation prepares bootstrap secrets in `scripts/hetzner/bootstrap-secrets.env`. 1. A local operator workstation prepares platform bootstrap secrets in the
2. The operator runs `bash scripts/hetzner/bootstrap.sh`. platform repo.
2. The operator runs the platform repo bootstrap flow.
3. Terraform provisions the server, firewall, network, and cloud-init user-data. 3. Terraform provisions the server, firewall, network, and cloud-init user-data.
4. cloud-init installs k3s automatically and prepares persistence directories plus bootstrap artifacts. 4. cloud-init installs k3s automatically and prepares persistence directories
5. The workstation waits for the public k3s API endpoint to report ready. plus bootstrap artifacts.
6. The workstation writes `.state/hetzner/kubeconfig.yaml`. 5. The workstation writes the public and in-cluster kubeconfigs.
7. The workstation injects initial Kubernetes Secrets for app and Forgejo bootstrap. 6. The workstation injects the shared platform secrets and applies the shared
8. The workstation applies repo-managed Kubernetes manifests under `deploy/k8s/`. platform manifests.
9. The workstation performs the first image/bootstrap delivery attempt for the app workloads. 7. Forgejo and the runner come online in the cluster.
10. The workstation verifies rollout status.
#### Phase 1: self-hosted handoff #### Phase 1: app repo handoff (this repo)
1. Forgejo becomes reachable in-cluster. 1. The operator creates this app repo's Kubernetes secrets such as
2. The operator completes initial Forgejo admin/repo setup. `unrip-secrets` and `unrip-registry-creds`.
3. This repo is pushed or mirrored into Forgejo. 2. This repo is pushed or mirrored into Forgejo.
4. The Forgejo runner becomes the routine app deployment mechanism. 3. On push to `main`, the Forgejo runner applies `deploy/k8s/base`, builds the
5. Terraform remains the infra mutation entrypoint unless further automated later. app image in-cluster with Kaniko, and updates the `unrip` deployments.
4. Terraform remains the infra mutation entrypoint, but app rollout is owned by
this repo's workflow.
### Failure-recovery expectation ### Failure-recovery expectation
The bootstrap path must be rerunnable from the workstation. The overall bootstrap path must be rerunnable from the workstation.
Docs should keep treating recovery as: Docs should keep treating recovery as:
- fix local secrets/inputs - fix local secrets/inputs
- rerun the bootstrap script - rerun the platform repo bootstrap script
- inspect the cluster with the generated kubeconfig - inspect the cluster with the generated kubeconfig
- destroy/recreate infra with `scripts/hetzner/destroy.sh` only when required - destroy/recreate infra from the platform repo only when required
### Current repo-state caveats ### Current repo-state caveats
The direction is clear, but the implementation is still mid-transition: The direction is clear, but the implementation still has caveats:
- the bootstrap script currently applies `deploy/k8s/base` directly rather than the Hetzner overlay - this repo now assumes a pre-bootstrapped platform repo/cluster
- kubeconfig/auth handling is not yet fully production-hardened - kubeconfig/auth handling is only as strong as the external Forgejo secret management
- first image delivery is still a bootstrap workaround rather than a final registry-native CI path - base manifests still carry a placeholder bootstrap image until CI rolls the first real tag
- Forgejo admin bootstrap, repo creation, and Actions configuration still require operator steps - app secrets and registry pull credentials still require operator setup
- local Compose remains in the repo for development/testing, not as the canonical production path - local Compose remains in the repo for development/testing, not as the canonical production path
### Minimal repo layout target ### Minimal repo layout target

183
scripts/deploy/bootstrap.sh Executable file
View file

@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
PLATFORM_REPO_DIR="${PLATFORM_REPO_DIR:-$ROOT_DIR/../unrip3}"
BOOTSTRAP_ENV_FILE="${BOOTSTRAP_ENV_FILE:-$PLATFORM_REPO_DIR/.state/hetzner/bootstrap-secrets.resolved.env}"
PLATFORM_APP_ENV_FILE="${PLATFORM_APP_ENV_FILE:-$PLATFORM_REPO_DIR/.env}"
APP_ENV_FILE="${APP_ENV_FILE:-$ROOT_DIR/.env}"
FORGEJO_REMOTE_NAME="${FORGEJO_REMOTE_NAME:-forgejo}"
PROJECT_NAME="${PROJECT_NAME:-unrip}"
PROJECT_NAMESPACE="${PROJECT_NAMESPACE:-$PROJECT_NAME}"
PROJECT_DEPLOYMENTS="${PROJECT_DEPLOYMENTS:-near-intents-ingest,dummy-reactor,dummy-executor,dummy-consumer}"
PROJECT_REGISTRY_SECRET_NAME="${PROJECT_REGISTRY_SECRET_NAME:-${PROJECT_NAME}-registry-creds}"
APP_SECRET_NAME="${APP_SECRET_NAME:-${PROJECT_NAME}-secrets}"
require() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 1
}
}
load_env_defaults() {
local file="$1"
[[ -f "$file" ]] || return 0
eval "$(
python3 - "$file" <<'PY'
import os
import shlex
import sys
for raw in open(sys.argv[1], 'r', encoding='utf-8'):
line = raw.strip()
if not line or line.startswith('#'):
continue
if line.startswith('export '):
line = line[len('export '):]
if '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
if key in os.environ:
continue
print(f'export {key}={shlex.quote(value)}')
PY
)"
}
require git
require kubectl
require python3
require base64
load_env_defaults "$BOOTSTRAP_ENV_FILE"
load_env_defaults "$PLATFORM_APP_ENV_FILE"
load_env_defaults "$APP_ENV_FILE"
REMOTE_URL="${FORGEJO_REMOTE_URL:-$(git -C "$ROOT_DIR" remote get-url "$FORGEJO_REMOTE_NAME")}"
eval "$(
python3 - "$REMOTE_URL" <<'PY'
import sys
from urllib.parse import urlparse
remote = sys.argv[1]
parsed = urlparse(remote)
if parsed.scheme not in {'http', 'https'}:
raise SystemExit('forgejo remote must use http(s) for automatic bootstrap')
path = parsed.path.rstrip('/')
if path.endswith('.git'):
path = path[:-4]
parts = [part for part in path.split('/') if part]
if len(parts) < 2:
raise SystemExit('could not parse repo owner/name from forgejo remote')
base_url = f'{parsed.scheme}://{parsed.hostname}'
if parsed.port:
base_url += f':{parsed.port}'
if parsed.username and parsed.password:
print(f'export FORGEJO_API_PASSWORD={parsed.password}')
if parsed.username:
print(f'export FORGEJO_API_USERNAME={parsed.username}')
print(f'export FORGEJO_URL={base_url}')
print(f'export FORGEJO_REPO_OWNER={parts[-2]}')
print(f'export FORGEJO_REPO_NAME={parts[-1]}')
PY
)"
: "${KUBECONFIG_PATH:=${KUBECONFIG:-$PLATFORM_REPO_DIR/.state/hetzner/kubeconfig.yaml}}"
: "${CI_KUBECONFIG_PATH:=$PLATFORM_REPO_DIR/.state/hetzner/kubeconfig.incluster.yaml}"
: "${FORGEJO_URL:?set FORGEJO_URL or configure a forgejo remote}"
: "${FORGEJO_REPO_OWNER:?set FORGEJO_REPO_OWNER}"
: "${FORGEJO_REPO_NAME:?set FORGEJO_REPO_NAME}"
: "${FORGEJO_ADMIN_USERNAME:=${FORGEJO_API_USERNAME:-}}"
if [[ -z "${FORGEJO_TOKEN:-}" ]]; then
: "${FORGEJO_ADMIN_USERNAME:=${FORGEJO_API_USERNAME:-}}"
: "${FORGEJO_ADMIN_USERNAME:?set FORGEJO_TOKEN or FORGEJO_ADMIN_USERNAME}"
: "${FORGEJO_ADMIN_PASSWORD:=${FORGEJO_API_PASSWORD:-}}"
: "${FORGEJO_ADMIN_PASSWORD:?set FORGEJO_TOKEN or FORGEJO_ADMIN_PASSWORD}"
fi
if [[ ! -f "$KUBECONFIG_PATH" ]]; then
echo "missing kubeconfig: $KUBECONFIG_PATH" >&2
exit 1
fi
if [[ ! -f "$CI_KUBECONFIG_PATH" ]]; then
echo "missing in-cluster kubeconfig: $CI_KUBECONFIG_PATH" >&2
exit 1
fi
export KUBECONFIG="$KUBECONFIG_PATH"
if [[ -z "${REGISTRY_HOST:-}" ]]; then
REGISTRY_HOST="${REGISTRY_DOMAIN:-}"
fi
if [[ -z "${REGISTRY_HOST:-}" ]]; then
REGISTRY_HOST="$(kubectl get ingress -n registry registry -o jsonpath='{.spec.rules[0].host}' 2>/dev/null || true)"
fi
if [[ -z "${REGISTRY_USERNAME:-}" ]]; then
REGISTRY_USERNAME="$(kubectl get secret registry-secrets -n registry -o jsonpath='{.data.htpasswd}' 2>/dev/null | base64 -d | cut -d: -f1 || true)"
fi
: "${REGISTRY_HOST:?set REGISTRY_HOST or configure the registry ingress first}"
: "${REGISTRY_USERNAME:?set REGISTRY_USERNAME or bootstrap the shared registry first}"
: "${REGISTRY_PASSWORD:?set REGISTRY_PASSWORD}"
: "${NEAR_INTENTS_API_KEY:?set NEAR_INTENTS_API_KEY}"
echo "bootstrapping namespace $PROJECT_NAMESPACE"
kubectl apply -f "$ROOT_DIR/deploy/k8s/base/namespace.yaml"
echo "upserting runtime secret $APP_SECRET_NAME"
kubectl -n "$PROJECT_NAMESPACE" create secret generic "$APP_SECRET_NAME" \
--from-literal=NEAR_INTENTS_API_KEY="$NEAR_INTENTS_API_KEY" \
--dry-run=client -o yaml | kubectl apply -f -
echo "upserting registry pull/push secret $PROJECT_REGISTRY_SECRET_NAME"
kubectl -n "$PROJECT_NAMESPACE" create secret docker-registry "$PROJECT_REGISTRY_SECRET_NAME" \
--docker-server="$REGISTRY_HOST" \
--docker-username="$REGISTRY_USERNAME" \
--docker-password="$REGISTRY_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f -
echo "applying app manifests"
kubectl apply -k "$ROOT_DIR/deploy/k8s/base"
echo "upserting Forgejo repo settings"
forgejo_args=()
if [[ -n "${FORGEJO_TOKEN:-}" ]]; then
forgejo_args+=(--token "$FORGEJO_TOKEN")
fi
if [[ -n "${FORGEJO_ADMIN_USERNAME:-}" ]]; then
forgejo_args+=(--admin-username "$FORGEJO_ADMIN_USERNAME")
fi
if [[ -n "${FORGEJO_ADMIN_PASSWORD:-}" ]]; then
forgejo_args+=(--admin-password "$FORGEJO_ADMIN_PASSWORD")
fi
python3 "$ROOT_DIR/scripts/deploy/forgejo_repo_bootstrap.py" \
--forgejo-url "$FORGEJO_URL" \
--repo-owner "$FORGEJO_REPO_OWNER" \
--repo-name "$FORGEJO_REPO_NAME" \
--ci-kubeconfig "$CI_KUBECONFIG_PATH" \
--registry-host "$REGISTRY_HOST" \
--project-name "$PROJECT_NAME" \
--project-namespace "$PROJECT_NAMESPACE" \
--project-deployments "$PROJECT_DEPLOYMENTS" \
--project-registry-secret-name "$PROJECT_REGISTRY_SECRET_NAME" \
"${forgejo_args[@]}"
cat <<EOF
bootstrap complete
next:
git commit -am "..."
git push $FORGEJO_REMOTE_NAME main
EOF

View file

@ -0,0 +1,138 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import ssl
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
class ForgejoClient:
def __init__(self, base_url: str, username: str | None = None, password: str | None = None, token: str | None = None):
self.base_url = base_url.rstrip('/')
self.username = username or ''
self.headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
if token:
self.headers['Authorization'] = f'token {token}'
elif username is not None and password is not None:
credentials = base64.b64encode(f'{username}:{password}'.encode()).decode()
self.headers['Authorization'] = f'Basic {credentials}'
else:
raise ValueError('ForgejoClient requires either token auth or username/password auth')
self.ssl_context = ssl.create_default_context()
def request(self, method: str, path: str, payload=None, expected=(200, 201, 204)):
url = f'{self.base_url}{path}'
data = None
if payload is not None:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, method=method)
for key, value in self.headers.items():
req.add_header(key, value)
try:
with urllib.request.urlopen(req, context=self.ssl_context) as response:
body = response.read().decode() if response.length != 0 else ''
if response.status not in expected:
raise RuntimeError(f'{method} {path} returned {response.status}: {body}')
return json.loads(body) if body else None
except urllib.error.HTTPError as exc:
body = exc.read().decode()
if exc.code not in expected:
raise RuntimeError(f'{method} {path} returned {exc.code}: {body}') from exc
return json.loads(body) if body else None
def get_repo(self, owner: str, repo: str):
try:
return self.request('GET', f'/api/v1/repos/{owner}/{repo}')
except RuntimeError as exc:
if ' returned 404:' in str(exc):
return None
raise
def create_repo(self, owner: str, name: str, private: bool):
payload = {
'name': name,
'private': private,
'auto_init': False,
'default_branch': 'main',
}
if owner == self.username:
return self.request('POST', '/api/v1/user/repos', payload, expected=(201,))
return self.request('POST', f'/api/v1/orgs/{urllib.parse.quote(owner)}/repos', payload, expected=(201,))
def upsert_variable(self, owner: str, repo: str, name: str, value: str):
try:
self.request(
'POST',
f'/api/v1/repos/{owner}/{repo}/actions/variables/{urllib.parse.quote(name)}',
{'value': value},
expected=(201, 204),
)
except RuntimeError as exc:
if ' returned 409:' not in str(exc) and ' returned 422:' not in str(exc):
raise
self.request(
'PUT',
f'/api/v1/repos/{owner}/{repo}/actions/variables/{urllib.parse.quote(name)}',
{'value': value},
expected=(201, 204),
)
def upsert_secret(self, owner: str, repo: str, name: str, value: str):
self.request(
'PUT',
f'/api/v1/repos/{owner}/{repo}/actions/secrets/{urllib.parse.quote(name)}',
{'data': value},
expected=(201, 204),
)
def main():
parser = argparse.ArgumentParser(description='Bootstrap Forgejo repo secrets/variables for app deployment')
parser.add_argument('--forgejo-url', required=True)
parser.add_argument('--admin-username')
parser.add_argument('--admin-password')
parser.add_argument('--token')
parser.add_argument('--repo-owner', required=True)
parser.add_argument('--repo-name', required=True)
parser.add_argument('--repo-private', action='store_true', default=False)
parser.add_argument('--ci-kubeconfig', required=True)
parser.add_argument('--registry-host', required=True)
parser.add_argument('--project-name', required=True)
parser.add_argument('--project-namespace', required=True)
parser.add_argument('--project-deployments', required=True)
parser.add_argument('--project-registry-secret-name', required=True)
args = parser.parse_args()
client = ForgejoClient(args.forgejo_url, args.admin_username, args.admin_password, args.token)
repo = client.get_repo(args.repo_owner, args.repo_name)
if repo is None:
created = client.create_repo(args.repo_owner, args.repo_name, args.repo_private)
print(f'created repo {created["full_name"]}')
else:
print(f'repo already exists: {repo["full_name"]}')
kubeconfig_b64 = base64.b64encode(Path(args.ci_kubeconfig).read_bytes()).decode()
client.upsert_secret(args.repo_owner, args.repo_name, 'KUBECONFIG_B64', kubeconfig_b64)
print('upserted repo action secret KUBECONFIG_B64')
client.upsert_variable(args.repo_owner, args.repo_name, 'REGISTRY_HOST', args.registry_host)
client.upsert_variable(args.repo_owner, args.repo_name, 'PROJECT_NAME', args.project_name)
client.upsert_variable(args.repo_owner, args.repo_name, 'PROJECT_NAMESPACE', args.project_namespace)
client.upsert_variable(args.repo_owner, args.repo_name, 'PROJECT_DEPLOYMENTS', args.project_deployments)
client.upsert_variable(
args.repo_owner,
args.repo_name,
'PROJECT_REGISTRY_SECRET_NAME',
args.project_registry_secret_name,
)
print('upserted repo action variables')
if __name__ == '__main__':
main()

View file

@ -1,20 +1,25 @@
import process from 'node:process'; import process from 'node:process';
import { createConsumer } from '../bus/kafka/consumer.mjs'; import { createConsumer } from '../bus/kafka/consumer.mjs';
import { logStatus } from '../core/log.mjs'; import { createLogger, serializeError } from '../core/log.mjs';
import { parseEventMessage } from '../core/event-envelope.mjs'; import { parseEventMessage } from '../core/event-envelope.mjs';
import { assertTradeResult } from '../core/schemas.mjs'; import { assertTradeResult } from '../core/schemas.mjs';
import { loadConfig } from '../lib/config.mjs'; import { loadConfig } from '../lib/config.mjs';
const config = loadConfig(); const config = loadConfig();
const logger = createLogger({
service: 'dummy-consumer',
component: 'consumer',
namespace: config.projectNamespace,
});
const consumer = await createConsumer({ const consumer = await createConsumer({
groupId: `${config.kafkaConsumerGroupExecutor}-results-view`, groupId: `${config.kafkaConsumerGroupExecutor}-results-view`,
brokers: config.kafkaBrokers, brokers: config.kafkaBrokers,
clientId: config.kafkaClientId, clientId: config.kafkaClientId,
logger,
}); });
await consumer.subscribe({ topic: config.kafkaTopicExecTradeResult, fromBeginning: false }); await consumer.subscribe({ topic: config.kafkaTopicExecTradeResult, fromBeginning: false });
logStatus(`result consumer subscribed to ${config.kafkaTopicExecTradeResult}`);
process.on('SIGINT', async () => { process.on('SIGINT', async () => {
await consumer.disconnect(); await consumer.disconnect();
@ -28,15 +33,29 @@ process.on('SIGTERM', async () => {
await consumer.run({ await consumer.run({
eachMessage: async ({ message }) => { eachMessage: async ({ message }) => {
if (!message.value) return; if (!message.value) return;
let event; let event;
try { try {
event = parseEventMessage(message.value.toString()); event = parseEventMessage(message.value.toString());
} catch { } catch {
logStatus('result consumer received non-JSON message; skipping'); logger.warn('invalid_json_message', {
topic: config.kafkaTopicExecTradeResult,
});
return; return;
} }
try {
assertTradeResult(event); assertTradeResult(event);
const payload = event.payload; } catch (error) {
console.log(`[result] command_id=${payload.command_id} quote_id=${payload.quote_id} status=${payload.status} result_code=${payload.result_code || 'n/a'}`); logger.error('message_processing_failed', {
venue: event.venue || 'near-intents',
topic: config.kafkaTopicExecTradeResult,
details: {
error: serializeError(error),
command_id: event.payload?.command_id,
quote_id: event.payload?.quote_id,
},
});
}
}, },
}); });

View file

@ -4,27 +4,32 @@ import { createConsumer } from '../bus/kafka/consumer.mjs';
import { createProducer } from '../bus/kafka/producer.mjs'; import { createProducer } from '../bus/kafka/producer.mjs';
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs'; import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
import { createExecutorStateStore } from '../core/executor-state-store.mjs'; import { createExecutorStateStore } from '../core/executor-state-store.mjs';
import { logStatus } from '../core/log.mjs'; import { createLogger, serializeError } from '../core/log.mjs';
import { assertExecuteTradeCommand, assertTradeResult } from '../core/schemas.mjs'; import { assertExecuteTradeCommand, assertTradeResult } from '../core/schemas.mjs';
import { loadConfig } from '../lib/config.mjs'; import { loadConfig } from '../lib/config.mjs';
const config = loadConfig(); const config = loadConfig();
const logger = createLogger({
service: 'dummy-executor',
component: 'executor',
namespace: config.projectNamespace,
});
const consumer = await createConsumer({ const consumer = await createConsumer({
groupId: config.kafkaConsumerGroupExecutor, groupId: config.kafkaConsumerGroupExecutor,
brokers: config.kafkaBrokers, brokers: config.kafkaBrokers,
clientId: config.kafkaClientId, clientId: config.kafkaClientId,
logger,
}); });
const producer = await createProducer({ const producer = await createProducer({
brokers: config.kafkaBrokers, brokers: config.kafkaBrokers,
clientId: config.kafkaClientId, clientId: config.kafkaClientId,
logger,
}); });
const stateStore = createExecutorStateStore({ stateDir: config.executorStateDir }); const stateStore = createExecutorStateStore({ stateDir: config.executorStateDir });
await consumer.subscribe({ topic: config.kafkaTopicCmdExecuteTrade, fromBeginning: false }); await consumer.subscribe({ topic: config.kafkaTopicCmdExecuteTrade, fromBeginning: false });
logStatus(`dummy executor subscribed to ${config.kafkaTopicCmdExecuteTrade} as ${config.kafkaConsumerGroupExecutor}`);
logStatus(`dummy executor will publish results to ${config.kafkaTopicExecTradeResult}; state_dir=${config.executorStateDir}`);
async function shutdown() { async function shutdown() {
await consumer.disconnect(); await consumer.disconnect();
@ -42,17 +47,29 @@ await consumer.run({
try { try {
event = parseEventMessage(message.value.toString()); event = parseEventMessage(message.value.toString());
} catch { } catch {
logStatus('dummy executor received non-JSON message; skipping'); logger.warn('invalid_json_message', {
topic: config.kafkaTopicCmdExecuteTrade,
});
return; return;
} }
try {
assertExecuteTradeCommand(event); assertExecuteTradeCommand(event);
const payload = event.payload; const payload = event.payload;
const commandId = payload.command_id; const commandId = payload.command_id;
const pair = `${payload.asset_in}->${payload.asset_out}`;
const existing = stateStore.get(commandId); const existing = stateStore.get(commandId);
if (existing?.status === 'completed') { if (existing?.status === 'completed') {
logStatus(`dummy executor skipping duplicate command_id=${commandId}`); logger.warn('duplicate_command_skipped', {
venue: event.venue || 'near-intents',
topic: config.kafkaTopicCmdExecuteTrade,
pair,
details: {
command_id: commandId,
quote_id: payload.quote_id,
},
});
return; return;
} }
@ -62,7 +79,7 @@ await consumer.run({
quote_id: payload.quote_id, quote_id: payload.quote_id,
}); });
const pair = `${payload.asset_in} -> ${payload.asset_out}`; const recoveredInflight = existing?.status === 'processing';
const result = buildEventEnvelope({ const result = buildEventEnvelope({
source: 'dummy-executor', source: 'dummy-executor',
venue: event.venue || 'near-intents', venue: event.venue || 'near-intents',
@ -75,7 +92,7 @@ await consumer.run({
execution_key: payload.execution_key, execution_key: payload.execution_key,
quote_id: payload.quote_id, quote_id: payload.quote_id,
status: 'simulated_sent', status: 'simulated_sent',
result_code: existing?.status === 'processing' ? 'recovered_inflight' : 'sent', result_code: recoveredInflight ? 'recovered_inflight' : 'sent',
note: 'dummy executor placeholder result', note: 'dummy executor placeholder result',
}, },
}); });
@ -88,6 +105,31 @@ await consumer.run({
quote_id: payload.quote_id, quote_id: payload.quote_id,
result_event_id: result.event_id, result_event_id: result.event_id,
}); });
console.log(`[dummy-executor] result emitted ${pair} quote_id=${payload.quote_id} command_id=${commandId} status=simulated_sent`);
if (recoveredInflight) {
logger.warn('inflight_command_recovered', {
venue: event.venue || 'near-intents',
topic: config.kafkaTopicExecTradeResult,
pair,
details: {
command_id: commandId,
quote_id: payload.quote_id,
},
});
}
} catch (error) {
logger.error('message_processing_failed', {
venue: event.venue || 'near-intents',
topic: config.kafkaTopicCmdExecuteTrade,
pair: event.payload?.asset_in && event.payload?.asset_out
? `${event.payload.asset_in}->${event.payload.asset_out}`
: undefined,
details: {
error: serializeError(error),
command_id: event.payload?.command_id,
quote_id: event.payload?.quote_id,
},
});
}
}, },
}); });

View file

@ -2,26 +2,31 @@ import process from 'node:process';
import { createConsumer } from '../bus/kafka/consumer.mjs'; import { createConsumer } from '../bus/kafka/consumer.mjs';
import { createProducer } from '../bus/kafka/producer.mjs'; import { createProducer } from '../bus/kafka/producer.mjs';
import { logStatus } from '../core/log.mjs'; import { createLogger, serializeError } from '../core/log.mjs';
import { loadConfig } from '../lib/config.mjs'; import { loadConfig } from '../lib/config.mjs';
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs'; import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
import { assertExecuteTradeCommand, assertNormalizedSwapDemand } from '../core/schemas.mjs'; import { assertExecuteTradeCommand, assertNormalizedSwapDemand } from '../core/schemas.mjs';
const config = loadConfig(); const config = loadConfig();
const logger = createLogger({
service: 'dummy-reactor',
component: 'reactor',
namespace: config.projectNamespace,
});
const consumer = await createConsumer({ const consumer = await createConsumer({
groupId: config.kafkaConsumerGroupDummy, groupId: config.kafkaConsumerGroupDummy,
brokers: config.kafkaBrokers, brokers: config.kafkaBrokers,
clientId: config.kafkaClientId, clientId: config.kafkaClientId,
logger,
}); });
const producer = await createProducer({ const producer = await createProducer({
brokers: config.kafkaBrokers, brokers: config.kafkaBrokers,
clientId: config.kafkaClientId, clientId: config.kafkaClientId,
logger,
}); });
await consumer.subscribe({ topic: config.kafkaTopicNormSwapDemand, fromBeginning: false }); await consumer.subscribe({ topic: config.kafkaTopicNormSwapDemand, fromBeginning: false });
logStatus(`dummy reactor subscribed to ${config.kafkaTopicNormSwapDemand} as ${config.kafkaConsumerGroupDummy}`);
logStatus(`dummy reactor will publish commands to ${config.kafkaTopicCmdExecuteTrade}`);
async function shutdown() { async function shutdown() {
await consumer.disconnect(); await consumer.disconnect();
@ -39,14 +44,17 @@ await consumer.run({
try { try {
event = parseEventMessage(message.value.toString()); event = parseEventMessage(message.value.toString());
} catch { } catch {
logStatus('dummy reactor received non-JSON message; skipping'); logger.warn('invalid_json_message', {
topic: config.kafkaTopicNormSwapDemand,
});
return; return;
} }
try {
assertNormalizedSwapDemand(event); assertNormalizedSwapDemand(event);
const payload = event.payload; const payload = event.payload;
const pair = `${payload.asset_in} -> ${payload.asset_out}`; const pair = `${payload.asset_in}->${payload.asset_out}`;
const quoteId = payload.quote_id; const quoteId = payload.quote_id;
const commandId = `cmd-${quoteId}`; const commandId = `cmd-${quoteId}`;
const command = buildEventEnvelope({ const command = buildEventEnvelope({
@ -70,6 +78,19 @@ await consumer.run({
assertExecuteTradeCommand(command); assertExecuteTradeCommand(command);
await producer.sendJson(config.kafkaTopicCmdExecuteTrade, command, { key: command.payload.execution_key }); await producer.sendJson(config.kafkaTopicCmdExecuteTrade, command, { key: command.payload.execution_key });
console.log(`[dummy-reactor] command emitted ${pair} quote_id=${quoteId} command_id=${commandId}`); return;
} catch (error) {
logger.error('message_processing_failed', {
venue: event.venue || 'near-intents',
topic: config.kafkaTopicNormSwapDemand,
pair: event.payload?.asset_in && event.payload?.asset_out
? `${event.payload.asset_in}->${event.payload.asset_out}`
: undefined,
details: {
error: serializeError(error),
quote_id: event.payload?.quote_id,
},
});
}
}, },
}); });

View file

@ -1,32 +1,53 @@
import process from 'node:process'; import process from 'node:process';
import { createProducer } from '../bus/kafka/producer.mjs'; import { createProducer } from '../bus/kafka/producer.mjs';
import { logStatus } from '../core/log.mjs'; import { createLogger } from '../core/log.mjs';
import { parsePairFilter } from '../core/pair-filter.mjs'; import { createPairFilterController } from '../core/pair-filter.mjs';
import { loadConfig } from '../lib/config.mjs'; import { loadConfig } from '../lib/config.mjs';
import { startNearIntentsWs } from '../venues/near-intents/ws.mjs'; import { startNearIntentsWs } from '../venues/near-intents/ws.mjs';
const config = loadConfig(); const config = loadConfig();
const pairFilter = parsePairFilter(process.argv.slice(2)); const logger = createLogger({
service: 'near-intents-ingest',
component: 'ingest',
namespace: config.projectNamespace,
});
const pairFilterController = createPairFilterController({
argv: process.argv.slice(2),
env: process.env,
defaultPairFilter: config.nearIntentsPairFilter,
pairFilterFile: config.nearIntentsPairFilterFile,
reloadEveryMs: config.nearIntentsPairFilterReloadMs,
logger: logger.child({
component: 'filter',
venue: 'near-intents',
}),
});
if (!config.nearIntentsApiKey) { if (!config.nearIntentsApiKey) {
console.error('Missing NEAR_INTENTS_API_KEY in env or .env'); logger.error('missing_api_key', {
venue: 'near-intents',
details: {
variable: 'NEAR_INTENTS_API_KEY',
},
});
process.exit(1); process.exit(1);
} }
const producer = await createProducer({ const producer = await createProducer({
brokers: config.kafkaBrokers, brokers: config.kafkaBrokers,
clientId: config.kafkaClientId, clientId: config.kafkaClientId,
logger,
}); });
logStatus(`kafka producer connected; raw_topic=${config.kafkaTopicRawNearIntentsQuote}; normalized_topic=${config.kafkaTopicNormSwapDemand}`);
if (pairFilter) logStatus(`pair filter enabled: ${pairFilter[0]} <-> ${pairFilter[1]}`);
process.on('SIGINT', async () => { process.on('SIGINT', async () => {
pairFilterController.close();
await producer.disconnect(); await producer.disconnect();
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', async () => { process.on('SIGTERM', async () => {
pairFilterController.close();
await producer.disconnect(); await producer.disconnect();
process.exit(0); process.exit(0);
}); });
@ -34,8 +55,13 @@ process.on('SIGTERM', async () => {
await startNearIntentsWs({ await startNearIntentsWs({
apiKey: config.nearIntentsApiKey, apiKey: config.nearIntentsApiKey,
wsUrl: config.nearIntentsWsUrl, wsUrl: config.nearIntentsWsUrl,
pairFilter, getPairFilter: () => pairFilterController.getPairFilter(),
producer, producer,
rawTopic: config.kafkaTopicRawNearIntentsQuote, rawTopic: config.kafkaTopicRawNearIntentsQuote,
normalizedTopic: config.kafkaTopicNormSwapDemand, normalizedTopic: config.kafkaTopicNormSwapDemand,
namespace: config.projectNamespace,
logger: logger.child({
component: 'ws',
venue: 'near-intents',
}),
}); });

View file

@ -1,11 +1,43 @@
import { Kafka } from 'kafkajs'; import { Kafka } from 'kafkajs';
import { serializeError } from '../../core/log.mjs';
function createKafka({ brokers = ['127.0.0.1:9092'], clientId = 'unrip' } = {}) { function createKafka({ brokers = ['127.0.0.1:9092'], clientId = 'unrip' } = {}) {
return new Kafka({ clientId, brokers }); return new Kafka({ clientId, brokers });
} }
export async function createConsumer({ groupId, ...options }) { export async function createConsumer({ groupId, logger, ...options }) {
const consumer = createKafka(options).consumer({ groupId }); const consumer = createKafka(options).consumer({ groupId });
const kafkaLogger = logger ? logger.child({ component: 'kafka' }) : null;
consumer.on(consumer.events.CONNECT, () => {
kafkaLogger?.info('kafka_connected', {
details: {
client_type: 'consumer',
group_id: groupId,
},
});
});
consumer.on(consumer.events.DISCONNECT, () => {
kafkaLogger?.warn('kafka_disconnected', {
details: {
client_type: 'consumer',
group_id: groupId,
},
});
});
consumer.on(consumer.events.CRASH, ({ payload }) => {
kafkaLogger?.error('kafka_consumer_crashed', {
details: {
client_type: 'consumer',
group_id: groupId,
restart: payload?.restart ?? null,
error: serializeError(payload?.error),
},
});
});
await consumer.connect(); await consumer.connect();
return { return {

View file

@ -1,11 +1,41 @@
import { Kafka } from 'kafkajs'; import { Kafka } from 'kafkajs';
import { serializeError } from '../../core/log.mjs';
function createKafka({ brokers = ['127.0.0.1:9092'], clientId = 'unrip' } = {}) { function createKafka({ brokers = ['127.0.0.1:9092'], clientId = 'unrip' } = {}) {
return new Kafka({ clientId, brokers }); return new Kafka({ clientId, brokers });
} }
export async function createProducer(options = {}) { export async function createProducer({ logger, ...options } = {}) {
const producer = createKafka(options).producer(); const producer = createKafka(options).producer();
const kafkaLogger = logger ? logger.child({ component: 'kafka' }) : null;
producer.on(producer.events.CONNECT, () => {
kafkaLogger?.info('kafka_connected', {
details: {
client_type: 'producer',
},
});
});
producer.on(producer.events.DISCONNECT, () => {
kafkaLogger?.warn('kafka_disconnected', {
details: {
client_type: 'producer',
},
});
});
producer.on(producer.events.REQUEST_TIMEOUT, ({ payload }) => {
kafkaLogger?.error('kafka_request_timeout', {
details: {
client_type: 'producer',
broker: payload?.broker ?? null,
client_id: payload?.clientId ?? null,
error: serializeError(payload?.error),
},
});
});
await producer.connect(); await producer.connect();
return { return {
async sendJson(topic, event, { key = event?.event_id ?? event?.key ?? null } = {}) { async sendJson(topic, event, { key = event?.event_id ?? event?.key ?? null } = {}) {

View file

@ -1,31 +1,55 @@
export function logStatus(message) { import process from 'node:process';
const time = new Date().toISOString();
console.error(`[${time}] ${message}`); export function logJson(event) {
process.stdout.write(`${JSON.stringify(compact({
ts: new Date().toISOString(),
...event,
}))}\n`);
} }
export function startIdleHeartbeat({ export function createLogger(bindings = {}) {
label, const base = compact(bindings);
getLastActivityAt,
getStatus,
idleAfterMs = 30_000,
checkEveryMs = 5_000,
}) {
let lastHeartbeatAt = 0;
const timer = setInterval(() => { return {
const lastActivityAt = getLastActivityAt(); child(extraBindings = {}) {
const idleForMs = Date.now() - lastActivityAt; return createLogger({ ...base, ...extraBindings });
},
if (idleForMs < idleAfterMs) return; info(event, fields = {}) {
if (Date.now() - lastHeartbeatAt < idleAfterMs) return; logJson({ level: 'info', ...base, event, ...compact(fields) });
},
const seconds = Math.floor(idleForMs / 1000); warn(event, fields = {}) {
const suffix = getStatus ? `; ${getStatus()}` : ''; logJson({ level: 'warn', ...base, event, ...compact(fields) });
logStatus(`${label} idle ${seconds}s${suffix}`); },
lastHeartbeatAt = Date.now(); error(event, fields = {}) {
}, checkEveryMs); logJson({ level: 'error', ...base, event, ...compact(fields) });
},
if (typeof timer.unref === 'function') timer.unref(); };
}
return () => clearInterval(timer);
export function serializeError(error) {
if (!error) return null;
if (error instanceof Error) {
return compact({
name: error.name,
message: error.message,
stack: error.stack,
code: error.code,
});
}
if (typeof error === 'object') return compact(error);
return { message: String(error) };
}
function compact(value) {
if (Array.isArray(value)) {
return value.map((item) => compact(item));
}
if (!value || typeof value !== 'object') return value;
const entries = Object.entries(value)
.filter(([, entry]) => entry !== undefined)
.map(([key, entry]) => [key, compact(entry)]);
return Object.fromEntries(entries);
} }

View file

@ -1,17 +1,173 @@
import fs from 'node:fs';
export const DEFAULT_NEAR_INTENTS_PAIR_FILTER =
'nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omf';
export function parsePairFilter(argv) { export function parsePairFilter(argv) {
const idx = argv.indexOf('--pair'); const idx = argv.indexOf('--pair');
if (idx === -1) return null; if (idx === -1) return null;
const raw = argv[idx + 1]; return parsePairFilterValue(argv[idx + 1], { fieldName: '--pair' });
if (!raw || !raw.includes('->')) { }
throw new Error("Use --pair 'asset_a->asset_b'");
export function parsePairFilterValue(raw, { fieldName = 'pair filter' } = {}) {
const normalized = String(raw || '').trim();
if (!normalized) return null;
if (['all', '*', 'off', 'none', 'disabled'].includes(normalized.toLowerCase())) {
return null;
} }
const [a, b] = raw.split('->').map((x) => x.trim().toLowerCase());
if (!normalized.includes('->')) {
throw new Error(`Use ${fieldName} like 'asset_a->asset_b'`);
}
const [a, b] = normalized.split('->').map((value) => value.trim().toLowerCase());
if (!a || !b) throw new Error(`Use ${fieldName} like 'asset_a->asset_b'`);
return [a, b]; return [a, b];
} }
export function formatPairFilter(pairFilter) {
if (!pairFilter) return null;
return `${pairFilter[0]}->${pairFilter[1]}`;
}
export function resolvePairFilter({
argv = [],
env = process.env,
defaultPairFilter = DEFAULT_NEAR_INTENTS_PAIR_FILTER,
} = {}) {
const cliPairFilter = parsePairFilter(argv);
if (cliPairFilter) {
return {
pairFilter: cliPairFilter,
pair: formatPairFilter(cliPairFilter),
source: 'argv',
};
}
const envPairFilter = parsePairFilterValue(env.NEAR_INTENTS_PAIR_FILTER, {
fieldName: 'NEAR_INTENTS_PAIR_FILTER',
});
if (envPairFilter) {
return {
pairFilter: envPairFilter,
pair: formatPairFilter(envPairFilter),
source: 'env',
};
}
const defaultResolved = parsePairFilterValue(defaultPairFilter, {
fieldName: 'default pair filter',
});
return {
pairFilter: defaultResolved,
pair: formatPairFilter(defaultResolved),
source: 'default',
};
}
export function createPairFilterController({
argv = [],
env = process.env,
logger = null,
defaultPairFilter = DEFAULT_NEAR_INTENTS_PAIR_FILTER,
pairFilterFile = env.NEAR_INTENTS_PAIR_FILTER_FILE,
reloadEveryMs = env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS,
} = {}) {
const resolved = resolvePairFilter({ argv, env, defaultPairFilter });
let currentPairFilter = resolved.pairFilter;
let currentPair = resolved.pair;
let lastLoadedFileValue = null;
let source = resolved.source;
const normalizedPairFilterFile = String(pairFilterFile || '').trim() || null;
const normalizedReloadEveryMs = parseReloadMs(reloadEveryMs);
if (normalizedPairFilterFile) {
const initialFileValue = readPairFilterFile(normalizedPairFilterFile);
if (initialFileValue != null) {
const initialFilePairFilter = parsePairFilterValue(initialFileValue, {
fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE',
});
currentPairFilter = initialFilePairFilter;
currentPair = formatPairFilter(initialFilePairFilter);
lastLoadedFileValue = initialFileValue;
source = 'file';
}
}
logger?.info('pair_filter_configured', {
pair: currentPair,
details: {
source,
pair_filter_file: normalizedPairFilterFile,
},
});
const timer = normalizedPairFilterFile
? setInterval(() => {
const nextValue = readPairFilterFile(normalizedPairFilterFile);
if (nextValue == null || nextValue === lastLoadedFileValue) return;
try {
const nextPairFilter = parsePairFilterValue(nextValue, {
fieldName: 'NEAR_INTENTS_PAIR_FILTER_FILE',
});
currentPairFilter = nextPairFilter;
currentPair = formatPairFilter(nextPairFilter);
lastLoadedFileValue = nextValue;
logger?.info('pair_filter_reloaded', {
pair: currentPair,
details: {
pair_filter_file: normalizedPairFilterFile,
},
});
} catch (error) {
logger?.error('pair_filter_reload_failed', {
pair: currentPair,
details: {
pair_filter_file: normalizedPairFilterFile,
error: error.message,
},
});
}
}, normalizedReloadEveryMs)
: null;
if (timer && typeof timer.unref === 'function') timer.unref();
return {
getPairFilter() {
return currentPairFilter;
},
getPair() {
return currentPair;
},
close() {
if (timer) clearInterval(timer);
},
};
}
export function matchesPairFilter(assetIn, assetOut, pairFilter) { export function matchesPairFilter(assetIn, assetOut, pairFilter) {
if (!pairFilter) return true; if (!pairFilter) return true;
const x = assetIn.toLowerCase(); const x = assetIn.toLowerCase();
const y = assetOut.toLowerCase(); const y = assetOut.toLowerCase();
return (x === pairFilter[0] && y === pairFilter[1]) || (x === pairFilter[1] && y === pairFilter[0]); return (x === pairFilter[0] && y === pairFilter[1]) || (x === pairFilter[1] && y === pairFilter[0]);
} }
function readPairFilterFile(filePath) {
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line && !line.startsWith('#'));
return raw || null;
}
function parseReloadMs(raw) {
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 1_000 ? parsed : 5_000;
}

View file

@ -1,7 +1,10 @@
import { loadDotenv } from './env.mjs'; import { loadDotenv } from './env.mjs';
import { DEFAULT_NEAR_INTENTS_PAIR_FILTER } from '../core/pair-filter.mjs';
const DEFAULTS = { const DEFAULTS = {
nearIntentsWsUrl: 'wss://solver-relay-v2.chaindefuser.com/ws', nearIntentsWsUrl: 'wss://solver-relay-v2.chaindefuser.com/ws',
nearIntentsPairFilter: DEFAULT_NEAR_INTENTS_PAIR_FILTER,
nearIntentsPairFilterReloadMs: 5_000,
kafkaBrokers: ['127.0.0.1:9092'], kafkaBrokers: ['127.0.0.1:9092'],
kafkaClientId: 'unrip', kafkaClientId: 'unrip',
kafkaTopicRawNearIntentsQuote: 'raw.near_intents.quote', kafkaTopicRawNearIntentsQuote: 'raw.near_intents.quote',
@ -11,6 +14,8 @@ const DEFAULTS = {
kafkaConsumerGroupDummy: 'dummy-reactor-v1', kafkaConsumerGroupDummy: 'dummy-reactor-v1',
kafkaConsumerGroupExecutor: 'dummy-executor-v1', kafkaConsumerGroupExecutor: 'dummy-executor-v1',
executorStateDir: './var/executor-state', executorStateDir: './var/executor-state',
projectName: 'unrip',
projectNamespace: 'unrip',
}; };
function splitCsv(value) { function splitCsv(value) {
@ -32,6 +37,11 @@ export function loadConfig({ envPath = '.env' } = {}) {
return { return {
nearIntentsApiKey: process.env.NEAR_INTENTS_API_KEY || '', nearIntentsApiKey: process.env.NEAR_INTENTS_API_KEY || '',
nearIntentsWsUrl: process.env.NEAR_INTENTS_WS_URL || DEFAULTS.nearIntentsWsUrl, nearIntentsWsUrl: process.env.NEAR_INTENTS_WS_URL || DEFAULTS.nearIntentsWsUrl,
nearIntentsPairFilter:
process.env.NEAR_INTENTS_PAIR_FILTER || DEFAULTS.nearIntentsPairFilter,
nearIntentsPairFilterFile: process.env.NEAR_INTENTS_PAIR_FILTER_FILE || '',
nearIntentsPairFilterReloadMs:
parseNumber(process.env.NEAR_INTENTS_PAIR_FILTER_RELOAD_MS, DEFAULTS.nearIntentsPairFilterReloadMs),
kafkaBrokers: splitCsv(process.env.KAFKA_BROKERS).length kafkaBrokers: splitCsv(process.env.KAFKA_BROKERS).length
? splitCsv(process.env.KAFKA_BROKERS) ? splitCsv(process.env.KAFKA_BROKERS)
: DEFAULTS.kafkaBrokers, : DEFAULTS.kafkaBrokers,
@ -50,5 +60,13 @@ export function loadConfig({ envPath = '.env' } = {}) {
process.env.KAFKA_CONSUMER_GROUP_EXECUTOR || DEFAULTS.kafkaConsumerGroupExecutor, process.env.KAFKA_CONSUMER_GROUP_EXECUTOR || DEFAULTS.kafkaConsumerGroupExecutor,
executorStateDir: executorStateDir:
process.env.EXECUTOR_STATE_DIR || DEFAULTS.executorStateDir, process.env.EXECUTOR_STATE_DIR || DEFAULTS.executorStateDir,
projectName: process.env.PROJECT_NAME || DEFAULTS.projectName,
projectNamespace:
process.env.PROJECT_NAMESPACE || process.env.PROJECT_NAME || DEFAULTS.projectNamespace,
}; };
} }
function parseNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}

View file

@ -1,5 +1,5 @@
import { matchesPairFilter } from '../../core/pair-filter.mjs'; import { matchesPairFilter } from '../../core/pair-filter.mjs';
import { logStatus, startIdleHeartbeat } from '../../core/log.mjs'; import { serializeError } from '../../core/log.mjs';
import { assertNormalizedSwapDemand } from '../../core/schemas.mjs'; import { assertNormalizedSwapDemand } from '../../core/schemas.mjs';
import { buildNearIntentsQuoteEnvelope, buildNearIntentsRawEnvelope } from './normalize.mjs'; import { buildNearIntentsQuoteEnvelope, buildNearIntentsRawEnvelope } from './normalize.mjs';
@ -11,16 +11,18 @@ export async function startNearIntentsWs({
apiKey, apiKey,
wsUrl = DEFAULT_WS_URL, wsUrl = DEFAULT_WS_URL,
pairFilter, pairFilter,
getPairFilter = () => pairFilter,
producer, producer,
rawTopic, rawTopic,
normalizedTopic, normalizedTopic,
logger,
namespace = 'unrip',
onPublish = defaultOnPublish, onPublish = defaultOnPublish,
}) { }) {
if (!apiKey) throw new Error('Missing NEAR_INTENTS_API_KEY'); if (!apiKey) throw new Error('Missing NEAR_INTENTS_API_KEY');
let quoteSubscriptionId = null; let quoteSubscriptionId = null;
let quoteStatusSubscriptionId = null; let quoteStatusSubscriptionId = null;
let lastStatusAt = Date.now();
let publishedCount = 0; let publishedCount = 0;
let publishLocked = false; let publishLocked = false;
@ -30,13 +32,14 @@ export async function startNearIntentsWs({
}); });
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
logStatus('near-intents connected'); logger?.info('connection_established', {
namespace,
});
ws.send(JSON.stringify({ jsonrpc: '2.0', id: QUOTE_SUB_ID, method: 'subscribe', params: ['quote'] })); ws.send(JSON.stringify({ jsonrpc: '2.0', id: QUOTE_SUB_ID, method: 'subscribe', params: ['quote'] }));
ws.send(JSON.stringify({ jsonrpc: '2.0', id: QUOTE_STATUS_SUB_ID, method: 'subscribe', params: ['quote_status'] })); ws.send(JSON.stringify({ jsonrpc: '2.0', id: QUOTE_STATUS_SUB_ID, method: 'subscribe', params: ['quote_status'] }));
}); });
ws.addEventListener('message', async (event) => { ws.addEventListener('message', async (event) => {
lastStatusAt = Date.now();
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8'); const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
let payload; let payload;
@ -73,7 +76,9 @@ export async function startNearIntentsWs({
const assetIn = envelope.payload?.asset_in; const assetIn = envelope.payload?.asset_in;
const assetOut = envelope.payload?.asset_out; const assetOut = envelope.payload?.asset_out;
if (!assetIn || !assetOut) return; if (!assetIn || !assetOut) return;
if (!matchesPairFilter(assetIn, assetOut, pairFilter)) return;
const activePairFilter = getPairFilter();
if (!matchesPairFilter(assetIn, assetOut, activePairFilter)) return;
publishLocked = true; publishLocked = true;
try { try {
@ -82,28 +87,41 @@ export async function startNearIntentsWs({
publishedCount += 1; publishedCount += 1;
onPublish(envelope, publishedCount); onPublish(envelope, publishedCount);
} catch (error) { } catch (error) {
logStatus(`kafka publish failed: ${error.message || 'unknown error'}`); logger?.error('publish_failed', {
namespace,
topic: normalizedTopic,
pair: `${assetIn}->${assetOut}`,
details: {
raw_topic: rawTopic,
error: serializeError(error),
quote_id: envelope.payload?.quote_id,
},
});
} finally { } finally {
publishLocked = false; publishLocked = false;
} }
}); });
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
logStatus('near-intents disconnected; reconnecting in 2s'); logger?.warn('connection_lost', {
namespace,
details: {
reconnect_in_ms: 2_000,
},
});
setTimeout(connect, 2000); setTimeout(connect, 2000);
}); });
ws.addEventListener('error', (err) => { ws.addEventListener('error', (err) => {
logStatus(`near-intents socket error: ${err.message || 'unknown error'}`); logger?.error('socket_error', {
namespace,
details: {
error: serializeError(err),
},
});
}); });
} }
startIdleHeartbeat({
label: 'near-intents',
getLastActivityAt: () => lastStatusAt,
getStatus: () => `published=${publishedCount}`,
});
connect(); connect();
} }