AegisDB
中文GitHub
Deploy & integrate

From a bare machine to your own system talking to it

Written for two readers: the operator who has to stand the gateway up and later upgrade it, and the engineer wiring their own system into it. Commands, parameters, and the few places that look stuck but are simply how the flow works.

Local setup

Running it locally

The gateway keeps its own metadata in PostgreSQL 16 — the same store in development and in production. Since ADR 0018 collapsed it to one, you need a local PostgreSQL before anything else. What that buys is a single source of truth for the schema.

The lines below are everything it takes to get from a bare machine to an open console. You create the database; the server does the rest on startup — migrations, reference data, demo seed.

# create the database — add vela_test too if you run the backend suite
createdb vela_gateway

# backend on :8080 — it applies migrations itself at startup
cd backend && go run ./cmd/server

# frontend on :5173 — Vite proxies /api to :8080
cd frontend && npm install && npm run dev
  • The default DSN points at vela_gateway on 127.0.0.1:5432 and deliberately omits user=, so libpq falls back to the current OS user — which is exactly the shape a Homebrew PostgreSQL comes in.
  • If you would rather not install PostgreSQL, the docker compose file at the repository root starts one bound to 127.0.0.1 only; going that way you must pass VELA_PG_DSN explicitly.
  • The first boot of an empty database creates one platform administrator and no instances — add those from the Config page. A dev build pre-fills that account on the sign-in page; a production build does not. Instances with no credentials run in simulation under the dev profile; in production they always answer that the instance has no real execution credentials.
Production

Production deployment and the upgrade order

What you deploy is one binary: the API, the compiled frontend SPA and the SQL migrations are all inside it. Three subcommands — version prints the build string, migrate only creates and upgrades tables, init runs the migrations plus reference data and then creates the administrator.

The migrations are embedded, and the server applies them itself at startup, exiting if they fail. That makes the separate migrate step optional now — but it is worth keeping: it puts the outcome and the duration of the migration somewhere you can see them.

# version string: unset VERSION falls back to git describe — this is what the version subcommand prints
VERSION="${VERSION:-$(git describe --tags --always --dirty)}"
rm -rf dist && mkdir -p dist/web

# the frontend build goes to the backend web_dir
(cd frontend && npm ci && npm run build) && cp -r frontend/dist/* dist/web/

# cross-compile the backend — pure Go, no C toolchain
(cd backend && GOOS=linux GOARCH=amd64 go build -trimpath \
  -ldflags "-s -w -X main.version=$VERSION" -o ../dist/vela-gateway ./cmd/server)

A gate before you cut the build

The packaging script first runs the simulated-data self-check tests: in production mode anything that is implemented has to be really implemented, and the build does not ship if they fail.

One replica: same order, different reason

Back up the database, stop the old process, run migrate, start the new process, confirm with version. The order is worth following, but it is no longer a safety precondition: the server migrates on startup, so starting the new process first does not mean the new binary runs against the old schema — it means the new process changed the database itself.

Several replicas on one database: there is no "safe as long as I do not start it first"

The first replica to come up on the new binary takes the migration lock and applies every migration, while the remaining old replicas keep serving against a schema that has just changed underneath them. You have exactly three options:

  1. 01

    A maintenance window

    Stop all of the old replicas first, then start the new ones.

  2. 02

    Accept the overlap

    Confirm this batch of migrations is backwards compatible with the old code, then roll the restart.

  3. 03

    Start without migrating

    Bring every replica up with --no-migrate and run the migration once, by hand, at a moment you chose.

Rolling restarts have one more trap: migrations serialise on a database advisory lock and wait at most 60 seconds for it. Restart two nodes at once while this batch of migrations takes longer than 60 seconds and the second node exits on startup.

--no-migrate is not "skip the checks"

It still verifies that the schema is current, refuses to start if anything is missing, and names exactly which migrations are missing. If it merely did nothing, a gateway with an incomplete schema would go into service — and that process still turns /healthz green.

  • Production DSNs must spell out sslmode: the default mode quietly falls back to plaintext when the server does not offer TLS, and the credentials plus every audit record travel over that connection.
  • On PostgreSQL 15 and later, after creating the database you have to connect to that database and grant schema privileges there — running it while connected elsewhere only changes the wrong object. This is the most common stumble on a fresh install.
After you go live

The startup self-check: can anyone still approve these?

This check exists because of a real deadlock: the approving role had exactly one member, and every ticket was raised by that person. Every layer of validation passed, and then two-person control meant not one of them could be approved.

So every boot starts by answering one question: can anyone still approve these paths? Each of the three findings below logs a WARN, and each one carries a fix.

blockedNobody on the chain can approve at allTickets cannot even be raised. Two shapes: the role has no members, or every member is a service account — and the second shape is the one people read straight past.
deadlockExactly one person on the chain, self-approval offTickets can be raised, but the ones that person raises can never be approved by anyone.
deadlockTickets already stuck in the queue at bootThis one is not a prediction, it is the present: those tickets have nobody who can act on them right now.

The check reports; it does not block startup

It does not change configuration either: who approves what is an organisational decision. It logs at WARN rather than Error — none of these is a startup failure — and not at Info, because a line lost in the boot scroll is barely different from no line at all.

The boundary, stated plainly: it only ever sees the snapshot at boot. Tickets that pile up afterwards are invisible to it. This is a glance on the way past, not monitoring.

What does refuse to start is a different list

The staffing check never blocks. Each of these exits immediately:

  • An empty database DSN in production — and all three entry points refuse it, serve, migrate and init alike. libpq reads an empty DSN as "local socket, OS user, database named after that user" and genuinely connects, so forgetting it quietly points the gateway at an unrelated database.
  • A JWT signing key in production that is too weak, too short, or built from too few character classes.
  • An invalid reverse-proxy trust list — refused in every environment. The web framework silently falls back to trusting every proxy when that value does not parse, which would let a client forge its source address past the IP allowlists.
  • A failed migration, or --no-migrate on a schema that is behind.
  • A failed seed.

A handful of things only log a WARN: TLS not enabled in production, no static encryption key set, webhooks allowed to reach private networks in production, an unrecognised environment name, and a web_dir with no index.html or assets directory.

Integration

Integrating over the open API

One credential, one set of endpoints, idempotency backed by a unique index. Credentials are issued from system settings, always against an explicitly chosen service account. Issuing returns a token shaped key.secret, and only a bcrypt hash of the secret is stored, so the plaintext exists once.

Send the token as Authorization: Bearer, or split it across the X-Vela-Key and X-Vela-Secret headers. What gets injected afterwards is that service account — which is why the capability matrix, the label scope and audit attribution downstream all keep working untouched.

Endpoints

POST /api/v1/open/releasesrelease:createSubmit a SQL upgrade ticket (JSON or multipart)
GET /api/v1/open/releases/{relNo}release:readRead one ticket’s status and its stages
POST /api/v1/open/releases/{relNo}/abortrelease:createAbort a ticket you submitted
POST /api/v1/open/sql-reviewreview:checkStandards review with no ticket — a merge gate for CI
GET /api/v1/open/instancesrelease:readWhich instances this credential can reach (already filtered by the service account’s labels)
GET /api/v1/open/pipelinesrelease:readWhich release pipelines exist (informational — you cannot pick one in a request)

Standards review as a CI merge gate

The dialect is decided by the target instance: the same SQL trips different rules against an Oracle instance and a TiDB instance, so you give either the instance or the connection id.

# no ticket is created — this only returns the review
curl -X POST https://<gateway>/api/v1/open/sql-review \
  -H "Authorization: Bearer $VELA_TOKEN" \
  -F "instance=order-cluster" \
  -F "file=@migrations/V12__archive.sql"

The passed flag means "no error-level findings", not "no findings at all": a release that every warning could stop is a release nobody could ever ship. Each finding carries the rule code, its level, which statement it was and the line it starts on.

Submitting a ticket

A title, the target instance and database, the change type, the SQL or a script, and your own reference number. Scripts travel three ways: file as multipart, script inline in the JSON, or base64; the ceiling is 15MB, measured from the bytes actually read.

# submit one SQL upgrade ticket
curl -X POST https://<gateway>/api/v1/open/releases \
  -H "Authorization: Bearer $VELA_TOKEN" \
  -H "Content-Type: application/json" \
  -d @body.json
# body.json
{
  "title":       "Add a memo column to the order table",
  "externalRef": "CHG-2026-0001",
  "instance":    "order-cluster",
  "database":    "orders",
  "changeType":  "ddl",
  "sql":         "ALTER TABLE tbl_order ADD COLUMN memo VARCHAR(64) NOT NULL DEFAULT ''",
  "reason":      "Ticket #123"
}

What externalRef guarantees

Submit the same externalRef twice and you get back the ticket the first call created — no second ticket and no change applied twice. The guarantee comes from a unique index in the database rather than a check-then-insert, so two concurrent retries still produce exactly one ticket.

The execute stage stops for a confirmation — that is the flow, not a stuck ticket

Approval answers "may this be done". The execution confirmation answers "do it now". Your ticket reaches the execute stage and waits there until an operator on the gateway side presses Confirm execution in the console; only then does anything reach the database. Your system should render that state as "waiting for execution confirmation" rather than as a timeout — it is the single thing integrators trip over most.

Conventions worth knowing first

  • Every response is HTTP 200; success is the code field in the body being 0. The callback endpoint and the WebSocket handshake are the only exceptions.
  • Error codes: 40100 invalid credential, do not retry; 40300 not permitted; 40301 source IP outside this credential’s allowlist; 42800 second factor required; 40001 bad parameters; 50000 server error, safe to retry with backoff.
  • You cannot pick a release pipeline in the request: sending one rejects the whole ticket rather than ignoring it, because silently ignoring it would let the caller believe the choice took effect.
  • Polling every 10 to 30 seconds is plenty; after an approval the gateway takes at most 15 seconds to push the pipeline forward.
  • A ticket that is already executing refuses to be aborted — a false "aborted" is more dangerous than having no such button.
  • An instance name that matches more than one instance is an error rather than a guess: guessing wrong means the change landed on a different database.

The contract, online

GET /openapi.yaml returns the contract and GET /docs opens Swagger UI — vendored into the binary rather than loaded from a CDN, so it opens on a machine with no internet access. The contract covers 143 operations across 113 paths.

External approval

External Lark approval

This moves approval of high-risk commands out of the console: the external approval service pushes an interactive Lark card, a human decides on the card, and a callback marks the ticket approved or rejected. In-console approval stays as the fallback, and both paths converge on the same atomic decision core.

The callback does not execute anything. After approval someone with the right to do so still returns to the approvals page and presses execute — what the approver pressed was "I agree", not "run it now" (ADR 0010).

What you configure — all of it runtime settings, not deployment-time environment variables

Settings › Approval › External Lark approval in the console. The values live in the database and take effect without a restart.

approval.external.enabledThe master switch. While it is off the callback endpoint refuses too, so a leftover callback secret keeps nothing alive.
approval.external.baseURLAddress of the external approval service. Must be https.
approval.external.tokenBearer token for outbound calls. Encrypted at rest with AES-GCM; the settings endpoint never returns the plaintext.
approval.external.aiGroupThe AI group.
approval.external.callbackBaseURLRoot address the callback comes back to; the full path is that root plus /api/v1/approvals/lark/callback.
approval.external.callbackSecretThe callback secret, also encrypted at rest, and required — callbacks are refused outright while it is unset.
approval.external.callbackAllowIPsComma-separated IPs or CIDRs the callback may come from. Empty means any source: the secret is the control, the allowlist is an optional second one.

There is a separate Lark integration under Settings › Notifications that is easy to confuse with this one: it only pushes a card, receives no callbacks and changes no outcome.

Outbound: a card is pushed after the ticket is created

With the switch on and the address, the token and the callback root all filled in, the card goes out after the ticket is created — asynchronously and best-effort, so a failure to push never blocks ticket creation. The correlation key is our own approval number: it goes out as external_task_id. The command has its credential literals redacted before it leaves — this request hands the command to a third party that will store it.

Inbound: how a callback earns trust

The callback has its own path and carries no user token. Authentication is a constant-time comparison against the shared secret, plus the optional source-IP allowlist. Then it checks, one at a time: was this ticket ever sent out, is the card the callback names the card we sent, has it already been decided, and does the approval name an approver.

How to roll it out

Prove the loop on DEV and GLI first, and the thing you are proving is "the notification arrived and the ticket moved to awaiting execution" — not "the table is gone". Once the loop is clean, open it up on STAGING and PROD.

When it will not connect

No card was pushedCheck that the switch, the service address, the token and the callback root are all set — one missing value means nothing is sent; then look for the dispatch-failed line in the log.
The callback fails authenticationCompare the callback secret on both sides, and check the source IP against the allowlist.
The callback says the ticket does not existConfirm the vendor returned external_task_id exactly as it was sent.
Approved, but the command never ranThat is usually correct — approval does not execute. The two other possibilities: it hit the self-approval block, or the target connection has been deleted, which is recorded as a warning and executes nothing.

Read the originals in the repository

This page is the commands and the parameters; the long form lives in the repository.

  • README.mdFeature overview, tech stack, quick start.
  • DEPLOY.mdCreating and granting the database, why sslmode has to be spelled out, the upgrade section, and the startup log lines worth watching once you are live.
  • docs/adr/Architecture decision records — why each decision was made, and which alternative was rejected.
  • docs/开放接口对接文档.mdOpen API fields, error codes and integration conventions.
  • docs/external-approval-setup.mdConfiguring external Lark approval, how to roll it out, and how to debug it.
  • backend/docs/openapi.yamlThe source of the online contract — it sits under the backend, not in the repository-root docs directory.
Browse on GitHub