diff --git a/.github/workflows/build-binary.yml b/.github/workflows/build-binary.yml index 17c4de5bc..abb884742 100644 --- a/.github/workflows/build-binary.yml +++ b/.github/workflows/build-binary.yml @@ -23,6 +23,10 @@ name: Build Binaries # downloads its artifact and runs `node scripts/smoke-binary.ts`, so it needs # neither `npm ci` nor Bun. # +# Successful main builds publish the smoke-tested Linux x64 binary to the +# dedicated nibrun-latest prerelease. Pull requests keep binaries as short-lived +# workflow artifacts only. +# # The build job's `npm run build:binary` runs `npm run build:web`, which fetches # https://oomol.com/en/apps/catalog.json (the provider icon map) at build time, # so an outage there fails the build job. This is the first PR-gated path @@ -40,13 +44,14 @@ on: pull_request: workflow_dispatch: -# Cancel superseded runs for the same ref (e.g. new push to an open PR). +# Cancel superseded PR runs. Main builds update a rolling release asset, so one +# must finish before the next main build starts. concurrency: group: build-binary-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} -# Least privilege: these jobs only read the repository; the binaries are -# workflow artifacts, nothing is published. +# Least privilege by default. The nibrun release job grants itself write access +# only while updating its dedicated prerelease after a main build. permissions: contents: read @@ -121,6 +126,9 @@ jobs: - name: Smoke test linux-x64 (SQLite) run: node scripts/smoke-binary.ts dist/open-connector-linux-x64 + - name: Smoke test linux-x64 (nibrun) + run: node scripts/smoke-binary.ts dist/open-connector-linux-x64 --nibrun + # PostgreSQL mode: the binary's own `migrate` subcommand applies the embedded # postgresql/ migrations, then the server must pass its schema check. - name: Migrate PostgreSQL with linux-x64 @@ -245,3 +253,38 @@ jobs: - name: Smoke test ${{ matrix.target }} run: node scripts/smoke-binary.ts dist/${{ matrix.file }} + + publish-nibrun: + name: Publish nibrun binary + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [build, smoke] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download linux-x64 binary + uses: actions/download-artifact@v8 + with: + name: binary-linux-x64 + path: dist + + - name: Publish rolling prerelease + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + if gh release view nibrun-latest >/dev/null 2>&1; then + gh release upload nibrun-latest dist/open-connector-linux-x64 --clobber + gh api --method PATCH "repos/${GH_REPO}/git/refs/tags/nibrun-latest" \ + --field sha="$GITHUB_SHA" \ + --field force=true >/dev/null + gh release edit nibrun-latest \ + --notes "Built from ${GITHUB_SHA}. This asset is replaced after every successful build on main." + else + gh release create nibrun-latest dist/open-connector-linux-x64 \ + --target "$GITHUB_SHA" \ + --title "nibrun deployment" \ + --prerelease \ + --latest=false \ + --notes "Built from ${GITHUB_SHA}. This asset is replaced after every successful build on main." + fi diff --git a/README.md b/README.md index 3a179f2cf..eb36ee233 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ OpenConnector is an open-source connector gateway for AI agents and an alternati Connect user app accounts once, then expose a shared catalog of 1,000+ providers and 10,000+ prebuilt Actions to agents and applications. +[![Deploy on nibrun](https://nibrun.com/button.svg)](https://app.nibrun.com/deploy?name=open-connector&binary=https%3A%2F%2Fgithub.com%2Foomol-lab%2Fopen-connector%2Freleases%2Fdownload%2Fnibrun-latest%2Fopen-connector-linux-x64&port=3000&env=OOMOL_CONNECT_ENCRYPTION_KEY&env=OOMOL_CONNECT_ADMIN_TOKEN&env=OOMOL_CONNECT_RUNTIME_TOKEN) + diff --git a/docs/single-binary.md b/docs/single-binary.md index 5126a5393..dffbe5740 100644 --- a/docs/single-binary.md +++ b/docs/single-binary.md @@ -66,6 +66,39 @@ PORT=3000 \ With SQLite, migrations are applied automatically when the database opens, exactly as with `npm start`. +## Deploy on nibrun + +The README's **Deploy on nibrun** button creates an app from the latest successful `main` build of +the Linux x64 binary. It asks for the three secrets a public deployment should use: + +- `OOMOL_CONNECT_ENCRYPTION_KEY` +- `OOMOL_CONNECT_ADMIN_TOKEN` +- `OOMOL_CONNECT_RUNTIME_TOKEN` + +Generate long random values, save them in a password manager, and keep the encryption key stable +across redeployments. Losing that key makes encrypted credentials in the persistent database +unreadable. + +OpenConnector automatically follows nibrun's runtime contract: it listens on the assigned `PORT` +at `0.0.0.0`, stores SQLite and transit-file data under `NIBRUN_DATA_DIR`, and uses +`https://$NIBRUN_HOSTNAME` as its public origin. Explicit `HOST`, `OOMOL_CONNECT_DATA_DIR`, and +`OOMOL_CONNECT_ORIGIN` values still take precedence. + +To deploy with the CLI instead, install and authenticate `nib`, then run: + +```bash +nib run \ + https://github.com/oomol-lab/open-connector/releases/download/nibrun-latest/open-connector-linux-x64 \ + --name open-connector \ + --port 3000 \ + --env OOMOL_CONNECT_ENCRYPTION_KEY="$OOMOL_CONNECT_ENCRYPTION_KEY" \ + --env OOMOL_CONNECT_ADMIN_TOKEN="$OOMOL_CONNECT_ADMIN_TOKEN" \ + --env OOMOL_CONNECT_RUNTIME_TOKEN="$OOMOL_CONNECT_RUNTIME_TOKEN" +``` + +For later deployments, replace `--name open-connector` with `--app ` so nibrun updates +the existing app and preserves its environment and data. + ### PostgreSQL Migrations PostgreSQL migrations are explicit. The binary has a `migrate` subcommand that applies the embedded diff --git a/scripts/smoke-binary.ts b/scripts/smoke-binary.ts index 2e5d26180..18f008387 100644 --- a/scripts/smoke-binary.ts +++ b/scripts/smoke-binary.ts @@ -11,9 +11,9 @@ import { setTimeout as sleep } from "node:timers/promises"; // Start a built single-file executable against a fresh data directory and check that its embedded catalog, // web console, migrations and shutdown path work. // -// Usage: `node scripts/smoke-binary.ts `. Set OOMOL_CONNECT_DATABASE_URL to run the PostgreSQL -// mode; every other OOMOL_CONNECT_* variable is removed from the server's environment. Uses only Node built-ins so -// the smoke runners need no `npm ci`. +// Usage: `node scripts/smoke-binary.ts [--nibrun]`. Set OOMOL_CONNECT_DATABASE_URL to run the +// PostgreSQL mode; every other OOMOL_CONNECT_* and NIBRUN_* variable is removed from the server's environment. Uses +// only Node built-ins so the smoke runners need no `npm ci`. interface ProcessExit { code: number | null; @@ -25,12 +25,14 @@ interface ServerProcessOptions { dataDir: string; port: number; databaseUrl: string | undefined; + nibrun: boolean; } const healthTimeoutMs = 60_000; const healthPollIntervalMs = 500; const requestTimeoutMs = 10_000; const shutdownTimeoutMs = 10_000; +const nibrunHostname = "open-connector-smoke.nibrun.app"; /** The server binary with its captured output and exit state. */ class ServerProcess { @@ -107,6 +109,7 @@ class ServerProcess { } const binaryPath = await resolveBinaryPath(process.argv[2]); +const nibrun = parseNibrunFlag(process.argv.slice(3)); const databaseUrl = process.env.OOMOL_CONNECT_DATABASE_URL?.trim() || undefined; const mode = databaseUrl ? "postgresql" : "sqlite"; const startedAt = performance.now(); @@ -114,7 +117,7 @@ const startedAt = performance.now(); const port = await findFreePort(); const dataDir = await mkdtemp(join(tmpdir(), "open-connector-smoke-")); const baseUrl = `http://127.0.0.1:${port}`; -const server = new ServerProcess({ binaryPath, dataDir, port, databaseUrl }); +const server = new ServerProcess({ binaryPath, dataDir, port, databaseUrl, nibrun }); try { await waitForHealth(server, baseUrl); @@ -123,11 +126,14 @@ try { await checkConsoleAssets(baseUrl, indexHtml); await checkProviders(baseUrl); await checkApps(baseUrl); + if (nibrun) { + await checkNibrunRuntime(server, baseUrl, port); + } await checkDatabaseBackend(server, dataDir, databaseUrl); await checkGracefulShutdown(server); await removeDataDir(dataDir); console.log( - `PASS ${binaryPath} mode=${mode} startup=${formatMs(readyAt - startedAt)} total=${formatMs(performance.now() - startedAt)}`, + `PASS ${binaryPath} mode=${mode}${nibrun ? " deployment=nibrun" : ""} startup=${formatMs(readyAt - startedAt)} total=${formatMs(performance.now() - startedAt)}`, ); } catch (error) { console.error(`FAIL ${binaryPath}: ${error instanceof Error ? error.message : String(error)}`); @@ -141,7 +147,7 @@ try { /** Resolve the binary before spawning: spawn resolves relative paths against the child's cwd, which is the data dir. */ async function resolveBinaryPath(argument: string | undefined): Promise { if (!argument) { - console.error("Usage: node scripts/smoke-binary.ts "); + console.error("Usage: node scripts/smoke-binary.ts [--nibrun]"); process.exit(1); } @@ -156,18 +162,37 @@ async function resolveBinaryPath(argument: string | undefined): Promise return resolved; } -/** process.env without OOMOL_CONNECT_* so the caller's shell cannot leak configuration into the server under test. */ +function parseNibrunFlag(arguments_: string[]): boolean { + if (arguments_.length === 0) { + return false; + } + if (arguments_.length === 1 && arguments_[0] === "--nibrun") { + return true; + } + + console.error("Usage: node scripts/smoke-binary.ts [--nibrun]"); + process.exit(1); +} + +/** Remove app and platform configuration so the caller's shell cannot leak it into the server under test. */ function buildServerEnvironment(options: ServerProcessOptions): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { - if (!key.toUpperCase().startsWith("OOMOL_CONNECT_")) { + const upperKey = key.toUpperCase(); + if (!upperKey.startsWith("OOMOL_CONNECT_") && !upperKey.startsWith("NIBRUN_")) { env[key] = value; } } env.PORT = String(options.port); - env.HOST = "127.0.0.1"; - env.OOMOL_CONNECT_DATA_DIR = options.dataDir; + if (options.nibrun) { + env.NIBRUN_HTTP_PORT = String(options.port); + env.NIBRUN_HOSTNAME = nibrunHostname; + env.NIBRUN_DATA_DIR = options.dataDir; + } else { + env.HOST = "127.0.0.1"; + env.OOMOL_CONNECT_DATA_DIR = options.dataDir; + } if (options.databaseUrl) { env.OOMOL_CONNECT_DATABASE_URL = options.databaseUrl; } @@ -280,6 +305,28 @@ async function checkApps(baseUrl: string): Promise { assert(Array.isArray(data), "/v1/apps data is not an array"); } +async function checkNibrunRuntime(server: ServerProcess, baseUrl: string, port: number): Promise { + assert( + server.stdout.includes(`"url":"http://0.0.0.0:${port}"`), + "server did not bind to 0.0.0.0 under NIBRUN_HOSTNAME", + ); + + const form = new FormData(); + form.set("file", new File(["nibrun smoke"], "smoke.txt", { type: "text/plain" })); + const response = await fetch(`${baseUrl}/api/files`, { + method: "POST", + body: form, + signal: AbortSignal.timeout(requestTimeoutMs), + }); + const body: unknown = await response.json(); + assert(response.status === 200, `POST /api/files returned ${response.status}`); + assert(isRecord(body), "POST /api/files did not return a JSON object"); + assert( + typeof body.downloadUrl === "string" && body.downloadUrl.startsWith(`https://${nibrunHostname}/api/files/`), + `POST /api/files returned unexpected downloadUrl ${JSON.stringify(body.downloadUrl)}`, + ); +} + /** Return `data` of a `{ success: true, data }` envelope. */ async function fetchEnvelopeData(baseUrl: string, path: string): Promise { const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(requestTimeoutMs) }); diff --git a/src/server/index.ts b/src/server/index.ts index 0bc2337c2..163977c0d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -31,10 +31,12 @@ import { } from "./storage/node-runtime-database.ts"; import { DEFAULT_RUN_LIMIT } from "./storage/runtime-store.ts"; +const nibrunHostname = optionalEnv("NIBRUN_HOSTNAME"); const port = Number(process.env.PORT ?? 3000); -const hostname = process.env.HOST ?? "127.0.0.1"; -const publicOrigin = process.env.OOMOL_CONNECT_ORIGIN ?? `http://localhost:${port}`; -const dataDir = process.env.OOMOL_CONNECT_DATA_DIR ?? join(process.cwd(), "data"); +const hostname = process.env.HOST ?? (nibrunHostname ? "0.0.0.0" : "127.0.0.1"); +const publicOrigin = + process.env.OOMOL_CONNECT_ORIGIN ?? (nibrunHostname ? `https://${nibrunHostname}` : `http://localhost:${port}`); +const dataDir = process.env.OOMOL_CONNECT_DATA_DIR ?? optionalEnv("NIBRUN_DATA_DIR") ?? join(process.cwd(), "data"); const transitFileTtlSeconds = readPositiveIntegerEnv("OOMOL_CONNECT_TRANSIT_FILE_TTL_SECONDS", 86_400); const transitFileMaxBytes = readPositiveIntegerEnv("OOMOL_CONNECT_TRANSIT_FILE_MAX_BYTES", 100 * 1024 * 1024); const runLimit = readPositiveIntegerEnv("OOMOL_CONNECT_RUN_LIMIT", DEFAULT_RUN_LIMIT);
OOMOL