Skip to main content

SQL plugin

A worked setup for the sql plugin — read-only query and schema introspection against a PostgreSQL or MySQL/MariaDB database. This page walks through the exact sequence using only the flux CLI. For the general plugin mechanics (capability grants, trust model, everyday commands), see Using plugins.

sql is the strictest plugin in the pack, in two directions at once. It never receives the password: the host speaks the database's startup and authentication handshake itself and hands the plugin a post-authentication connection. And it cannot write: every query passes a read-only statement whitelist before a socket is dialed.

1. Install

flux plugin install sql

This resolves the newest signed plugins-v* pack release, verifies the index signature and the archive's SHA-256, and unpacks the binary into the versioned store. Confirm it landed:

flux plugin status sql

status prints the installed version and operation total from the live manifest rather than from a fixed documentation snapshot. For setup, the stable declarations to check are the password auth purpose (SQL_PASSWORD, then MYSQL_PASSWORD), the sql.endpoint endpoint (SQL_DSN, then SQL_URL), and connection capabilities limited to the declared database ports.

Those connection entries are the whole capability set: raw TCP to port 5432 and port 3306, and nothing else. There is no http, no process, and — notably — no secret(…). The plugin holds a credential location, never a value.

2. Configure the endpoint

Configure the endpoint and password through environment variables read by the host, never by the plugin:

WhatEnv vars (first one set wins)Read by
Connection DSNSQL_DSN, SQL_URLthe host, to resolve the sql.endpoint reference and to hand the plugin non-secret metadata
PasswordSQL_PASSWORD, MYSQL_PASSWORDthe host only, for the handshake it terminates
export SQL_DSN="postgres://app@db.example.com:5432/warehouse"
export SQL_PASSWORD="…"
caution

Keep the password out of the DSN. The DSN doubles as a declared non-secret config value the plugin reads for connection metadata (dialect, database, username), and the host refuses credential-bearing values on that path. A DSN with an inline password will not resolve.

Re-run flux plugin status sql and both lines flip to :

auth: ✓ password — env $SQL_PASSWORD
endpoint: ✓ sql.endpoint — postgres://app@db.example.com:5432/warehouse (env $SQL_DSN)

On this static-endpoint path, the host resolves the handshake credential from the declared environment keys only. Stored plugin tokens are not consulted for conn.authenticate, so set SQL_PASSWORD or MYSQL_PASSWORD in the environment that starts Flux.

The plugin never dials a URL it parsed. It asks the host to dial sql.endpoint by name; the host resolves the reference, applies the egress guard, opens the socket, speaks the PostgreSQL StartupMessage and SCRAM-SHA-256 or MD5 authentication (or MySQL Handshake v10), and returns a connection that is already authenticated. See Plugin capability sandbox for why this path exists and what it replaced.

3. Database on a private network? Grant egress

A database on an internal or loopback address is refused by the SSRF guard by default — you'll see this if you skip this step:

error: plugin `sql` op `sql.test`: refusing to fetch private/loopback/link-local address 10.1.2.3 …

Grant the specific host in .flux/config.toml (project) or ~/.flux/config.toml (user default):

[private_net.plugins]
sql = ["db.internal.example", "127.0.0.1"]

The grant is intersected with what the plugin declares, and every admitted private-address call is audited. A managed database on a public address needs no grant. See Private-network egress for the full mechanism.

4. Verify

flux plugin call sql sql.test

sql.test opens a connection and runs SELECT 1 — the cheapest end-to-end check that the DSN, password, egress grant, and server version all line up:

{
"status": "ok",
"endpoint_url": "postgres://app@db.example.com:5432/warehouse",
"driver": "postgres",
"database": "warehouse",
"server_version": "16.2"
}

endpoint_url is always password-redacted. A missing DSN fails with endpoint has no DSN configured (set SQL_DSN or SQL_URL); a wrong password surfaces as the server's own authentication error, which tells you the wiring is fine and the credential is not.

5. Query and introspect

The current manifest exposes the following read-only operations. Every one accepts the shared connection fields {endpoint?, endpoint_ref?, driver?, database?, timeout?} in addition to its own.

OperationOwn argumentsReturns
sql.teststatus, driver, database, server_version
sql.queryquery, max_rows?columns[], rows[], row_count, truncated
sql.database.listdatabases[] with name, kind, current
sql.table.listschema?, include_views?, max_results?tables with a cheap row estimate
sql.table.showtable, schema?columns[], primary_key[], foreign_keys[]
sql.index.listtable?, schema?indexes[] with columns, unique, primary
flux plugin call sql sql.database.list
flux plugin call sql sql.table.list --arg schema=public --arg include_views=true
flux plugin call sql sql.table.show --arg table=orders
flux plugin call sql sql.index.list --arg table=users
flux plugin call sql sql.query '{"query": "SELECT id, email FROM users ORDER BY id", "max_rows": 50}'
{
"columns": ["id", "email"],
"rows": [{"id": "1", "email": "ada@example.com"}, {"id": "2", "email": null}],
"row_count": 2,
"truncated": false
}

max_rows defaults to 100 and is capped at 1000; truncated tells you when the cap bit. Result rows are also contributed to the sql.query_rows datasource, so an agent can search them later.

The read-only guard. sql.query accepts a single statement beginning SELECT, SHOW, DESCRIBE, EXPLAIN, or WITH, and rejects anything containing a write keyword outside a function call. Multi-statement input, write CTEs, and INTO OUTFILE / DUMPFILE are refused. The check runs before any connection is dialed:

SQL query must be read-only; allowed statements are SELECT, SHOW, DESCRIBE, EXPLAIN, and WITH

This is the plugin's own guard, on top of whatever the database grants the connecting role. Point it at a read-only role anyway.

6. Dialects

  • PostgreSQL — the primary target; all listed operations work. Only sql.query runs SQL you supplied, behind the read-only statement guard above. The introspection operations (sql.database.list, sql.table.list, sql.table.show, sql.index.list) run per-dialect SQL the plugin wrote, and sql.test runs SELECT 1.
  • MySQL / MariaDB — supported, with per-dialect introspection SQL. Note that sql.database.list means something different here: MySQL treats schema and database as one object, so every entry is kind: "database" and no kind: "schema" entries are returned, where PostgreSQL returns both levels. Authentication is mysql_native_password; caching_sha2_password (the MySQL 8.0+ default), ed25519, and parsec are not implemented and fail with an error naming the workaround.
  • SQLite — unsupported by design. SQLite is a local file, and this plugin's only IO capability is a socket.

7. Discovered endpoints, no configuration

The DSN above is the static path. The other path is a discovered endpoint: an agent asks endpoint.discover for a postgres product, a provider plugin such as kubernetes returns a credential-free weak reference, and endpoint.select hands the whole reference object to sql.query:

flow inspect-database(endpoint_id: String)
selected = endpoint.select(endpoint_id)
rows = sql.query(endpoint: selected, max_rows: 1, query: "SELECT current_database() AS name")
return rows

Pass the reference as the endpoint object. A bare @endpoint/<id> string in endpoint_ref is rejected with an explicit error — endpoint_ref names a static manifest endpoint, and the id-only lookup is retired.

When the discovered credential is owned by a different plugin, resolution is deny-by-default and needs an operator grant:

[endpoint]
cross_plugin_credentials = ["sql:kubernetes"]

First use still crosses approval and produces an audit event. The plugin receives the reference's credential_ref — a location such as kubernetes/team/db-creds/password — and passes it back to the host, which resolves it for the handshake it terminates. The value never enters the plugin.

Recap

StepCommandFailure mode if skipped
Installflux plugin install sqlno such plugin `sql`
DSN + passwordexport SQL_DSN=… SQL_PASSWORD=…endpoint has no DSN configured (set SQL_DSN or SQL_URL)
Private-net grant (internal DB only)[private_net.plugins] in configrefusing to fetch private/loopback/link-local address …
Verifyflux plugin call sql sql.test(this is the verification step)
Cross-plugin grant (discovered endpoints)[endpoint] cross_plugin_credentialsthe discovered credential is refused to sql
  • Using plugins — install, pin, capability grants, and the trust model shared by every plugin.
  • Endpoints — weak references, discovery, and the operator CLI that owns the worked example this page's discovered path continues.
  • Kubernetes plugin — the provider that discovers in-cluster database endpoints.
  • Plugin capability sandboxconn.authenticate and the references-only IO model.
  • Configuration[private_net.plugins] and [endpoint] cross_plugin_credentials.