571 lines
25 KiB
Markdown
571 lines
25 KiB
Markdown
# boltage
|
|
|
|
[neo4j-browser](https://github.com/neo4j/neo4j-browser), cypher-shell and the Neo4j
|
|
drivers on top of PostgreSQL, using [Apache AGE](https://github.com/apache/age) as the
|
|
graph engine.
|
|
|
|
boltage is a small Python server that speaks Neo4j's Bolt protocol and translates Cypher
|
|
into AGE `cypher()` calls. It can also serve neo4j-browser. A [container
|
|
image](#container-images) bundles everything, together with
|
|
[pgAdmin](https://www.pgadmin.org/) for working with the same data in SQL.
|
|
|
|
```
|
|
neo4j-browser (static files) boltage (Python) PostgreSQL + AGE
|
|
┌──────────────────────┐ Bolt ┌────────────────────────-──┐ SQL ┌────────────────┐
|
|
│ in your web browser │───────▶│ Bolt server (TCP + WS) │──────▶│ cypher('g',$$ │
|
|
│ served on :7474 │ :7687 │ Cypher → AGE translation │ :5432 │ ... $$) │
|
|
└──────────────────────┘ │ emulated Neo4j procedures │ └────────────────┘
|
|
└─────────────────────────-─┘
|
|
```
|
|
|
|
## Installation
|
|
|
|
boltage needs Python 3.11 or later and a PostgreSQL server with the AGE extension installed
|
|
and loaded (`shared_preload_libraries=age`).
|
|
|
|
```sh
|
|
pip install 'boltage[binary]'
|
|
BOLTAGE_PG_HOST=localhost BOLTAGE_PG_DATABASE=postgres boltage
|
|
```
|
|
|
|
The `binary` extra pulls in psycopg's prebuilt wheels; leave it out to use a psycopg
|
|
built against your system's libpq. `python -m boltage` works the same as `boltage`.
|
|
|
|
Neo4j clients can now connect to `neo4j://localhost:7687` with a PostgreSQL user name
|
|
and password.
|
|
|
|
boltage does not include neo4j-browser itself. To serve it, install the `browser` extra
|
|
([Starlette](https://www.starlette.io/) and [uvicorn](https://www.uvicorn.org/)),
|
|
build neo4j-browser and point `BOLTAGE_BROWSER_DIR` at its `dist` directory; it is then
|
|
served on <http://localhost:7474/>. The [container images](#container-images) do all of
|
|
this for you.
|
|
|
|
```sh
|
|
pip install 'boltage[binary,browser]'
|
|
BOLTAGE_PG_HOST=localhost BOLTAGE_PG_DATABASE=postgres BOLTAGE_BROWSER_DIR=/path/to/neo4j-browser/dist boltage
|
|
```
|
|
|
|
## Configuration
|
|
|
|
boltage is configured through environment variables:
|
|
|
|
| Variable | Default | Purpose |
|
|
| --- | --- | --- |
|
|
| `BOLTAGE_PG_HOST` | `127.0.0.1` | PostgreSQL host. |
|
|
| `BOLTAGE_PG_PORT` | `5432` | PostgreSQL port. |
|
|
| `BOLTAGE_PG_DATABASE` | `$POSTGRES_DB`, else `$POSTGRES_USER`, else `postgres` | PostgreSQL database holding the graphs. |
|
|
| `BOLTAGE_DEFAULT_GRAPH` | `neo4j` | Graph used when a client does not pick a database. |
|
|
| `BOLTAGE_BOLT_PORT` | `7687` | Port the Bolt server listens on (TCP and WebSocket). |
|
|
| `BOLTAGE_HTTP_PORT` | `7474` | Port neo4j-browser is served on, when `BOLTAGE_BROWSER_DIR` is set. |
|
|
| `BOLTAGE_BROWSER_DIR` | (unset) | neo4j-browser `dist` directory to serve; needs the `browser` extra. |
|
|
| `BOLTAGE_ADVERTISED_BOLT_PORT` | `$BOLTAGE_BOLT_PORT` | Bolt port advertised to the browser; set it when clients reach the Bolt port on another port. |
|
|
| `BOLTAGE_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. |
|
|
| `BOLTAGE_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. |
|
|
| `BOLTAGE_TLS_CERT` | (unset) | PEM certificate (chain) for TLS, see [TLS](#tls). |
|
|
| `BOLTAGE_TLS_KEY` | (unset) | PEM private key of that certificate. |
|
|
| `BOLTAGE_BOLT_TLS` | `optional` with a certificate, else `disabled` | TLS on the Bolt port: `disabled`, `optional` or `required`. |
|
|
| `BOLTAGE_HTTPS` | `true` with a certificate, else `false` | Serve neo4j-browser over HTTPS instead of HTTP. |
|
|
|
|
### TLS
|
|
|
|
With `BOLTAGE_TLS_CERT` and `BOLTAGE_TLS_KEY` set, boltage accepts TLS on the Bolt
|
|
port and serves neo4j-browser over HTTPS:
|
|
|
|
```sh
|
|
BOLTAGE_TLS_CERT=/etc/boltage/cert.pem BOLTAGE_TLS_KEY=/etc/boltage/key.pem boltage
|
|
```
|
|
|
|
`BOLTAGE_BOLT_TLS` works like Neo4j's TLS level for Bolt. `optional`, the default
|
|
when a certificate is set, accepts TLS and plain connections on the same port, so
|
|
`bolt://` and `bolt+s://` both work. `required` only accepts TLS and makes the
|
|
discovery document advertise `neo4j+s://`. `disabled` ignores the certificate for
|
|
Bolt but still uses it for HTTPS unless `BOLTAGE_HTTPS=false`.
|
|
|
|
Clients connect with `bolt+s://` or `neo4j+s://` when they trust the certificate, and
|
|
with `bolt+ssc://` or `neo4j+ssc://` for a self-signed one. neo4j-browser served over
|
|
HTTPS connects to the Bolt port over a secure WebSocket (`wss://`), which needs the
|
|
Bolt port to accept TLS and the browser to trust its certificate; with a self-signed
|
|
certificate, open `https://localhost:7687/` once and accept it. The certificate is
|
|
read at start-up, so restart boltage after renewing it. For the container images,
|
|
see [TLS in the containers](#tls-in-the-containers).
|
|
|
|
The connection from boltage to PostgreSQL uses libpq, which takes its TLS settings
|
|
from [its environment
|
|
variables](https://www.postgresql.org/docs/current/libpq-envars.html), such as
|
|
`PGSSLMODE=verify-full` and `PGSSLROOTCERT`.
|
|
|
|
## How it works
|
|
|
|
- **Databases are AGE graphs.** Every AGE graph shows up as a Neo4j database, and
|
|
`:use <name>` switches between them. Clients that do not pick a database get `BOLTAGE_DEFAULT_GRAPH`
|
|
(`neo4j`); the full container image and the compose setup create that graph on first start. `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
|
|
database call `create_graph` / `drop_graph`; AGE requires names of at least 3 characters.
|
|
- **Queries** are sent to AGE as `SELECT * FROM cypher('<graph>', $$ <query> $$) AS (...)`.
|
|
boltage works out the result columns from the final `RETURN` clause and inlines
|
|
query parameters as Cypher literals.
|
|
- **Results** of read queries are streamed through a PostgreSQL cursor as clients pull
|
|
them, so boltage holds at most a thousand rows of a result at a time. The query
|
|
keeps a transaction open until its result has been read. Queries that can write
|
|
(`CREATE`, `MERGE`, `SET`, `DELETE`, `REMOVE`, or `CALL` of a procedure) run to
|
|
completion first and are buffered, because AGE performs writes while their rows are
|
|
fetched.
|
|
- **Neo4j procedures** that neo4j-browser relies on (`dbms.components`, `SHOW DATABASES`,
|
|
`db.labels`, `db.schema.visualization`, `SHOW PROCEDURES`, ...) are emulated from the
|
|
AGE catalog.
|
|
- **Workarounds for AGE**: neo4j-browser's "expand node" query uses a pattern
|
|
comprehension, and its relationship lookup uses `id(n) IN [...]`, which AGE rejects
|
|
for lists of more than one element. boltage rewrites both into equivalent Cypher
|
|
that AGE accepts.
|
|
- **Authentication** is passed straight through: every Bolt connection opens its own
|
|
PostgreSQL connection with the credentials you log in with.
|
|
|
|
## Querying the graph from SQL
|
|
|
|
AGE stores each node label and relationship type as a table in the graph's schema,
|
|
with all properties in one `agtype` column, `properties`; `properties ->> 'key'::text`
|
|
returns a property as text. For typed columns, define a view on top of the label table:
|
|
|
|
```sql
|
|
CREATE VIEW neo4j.movies AS
|
|
SELECT id,
|
|
properties ->> 'title'::text AS title,
|
|
(properties ->> 'released'::text)::integer AS released
|
|
FROM neo4j."Movie";
|
|
```
|
|
|
|
A plain view converts the properties every time it is read. To index the typed
|
|
columns, make it a materialized view and refresh it when the graph has changed; a
|
|
unique index on `id` lets the refresh run without blocking readers:
|
|
|
|
```sql
|
|
CREATE MATERIALIZED VIEW neo4j.movies AS
|
|
SELECT id,
|
|
properties ->> 'title'::text AS title,
|
|
(properties ->> 'released'::text)::integer AS released
|
|
FROM neo4j."Movie";
|
|
CREATE UNIQUE INDEX ON neo4j.movies (id);
|
|
CREATE INDEX ON neo4j.movies (released);
|
|
|
|
REFRESH MATERIALIZED VIEW CONCURRENTLY neo4j.movies;
|
|
```
|
|
|
|
Prefer (materialized) views over adding columns to the label tables themselves:
|
|
|
|
- The graph stays schema-free, like a Neo4j graph. A property value that does not fit
|
|
the view's column type, such as `CREATE (:Movie {released: 'soon'})`, is still
|
|
written; the view fails when it is read, and a refresh fails and keeps the
|
|
materialized view's previous contents.
|
|
- A view can be changed or dropped at any time, while AGE does not allow dropping
|
|
columns from its label tables.
|
|
- A view can use any SQL expression. A generated column only accepts immutable
|
|
expressions, which rules out some conversions, see [Dates and times](#dates-and-times).
|
|
- Creating a view does not rewrite or lock the label table.
|
|
|
|
### Dates and times
|
|
|
|
AGE has no date or time types, so store a point in time either as a Unix timestamp
|
|
(seconds since 1970-01-01 UTC) or as an ISO 8601 string with a UTC offset, and convert
|
|
it in the view:
|
|
|
|
```sql
|
|
CREATE VIEW neo4j.movie_dates AS
|
|
SELECT id,
|
|
to_timestamp((properties ->> 'updated'::text)::bigint) AT TIME ZONE 'UTC' AS updated,
|
|
(properties ->> 'premiere'::text)::timestamptz AS premiere
|
|
FROM neo4j."Movie";
|
|
```
|
|
|
|
Here `updated` is a Unix timestamp, such as `1729500000`, that becomes a `timestamp` in
|
|
UTC, and `premiere` is a string, such as `'1999-03-24T19:00:00-08:00'`, that becomes a
|
|
`timestamptz`. Casting a string without an offset to `timestamptz` reads it in the
|
|
`TimeZone` of the session that reads (or refreshes) the view, so always include one.
|
|
|
|
### Generated columns
|
|
|
|
If a typed column has to be in the label table itself, kept up to date on every Cypher
|
|
`CREATE`, `MERGE`, `SET` and `REMOVE`, add it as a stored generated column:
|
|
|
|
```sql
|
|
ALTER TABLE neo4j."Movie"
|
|
ADD COLUMN released integer GENERATED ALWAYS AS ((properties ->> 'released'::text)::integer) STORED;
|
|
```
|
|
|
|
Unlike a view, this makes Cypher writes fail when a value does not fit the column's
|
|
type, the column cannot be removed again, and adding it rewrites and locks the table.
|
|
|
|
Generated columns also only accept immutable expressions. The Unix timestamp
|
|
conversion above is immutable, but casting text to `timestamptz` is not: its result
|
|
depends on the `TimeZone` setting for strings without an offset, on `DateStyle` for
|
|
formats like `'01/02/2024'`, and on the clock for strings like `'now'`. So
|
|
`GENERATED ALWAYS AS ((properties ->> 'premiere'::text)::timestamptz)` is rejected with
|
|
`generation expression is not immutable`. It takes a function that only accepts strings
|
|
for which the cast always gives the same result, ISO 8601 with a UTC offset, and
|
|
declares itself immutable:
|
|
|
|
```sql
|
|
CREATE FUNCTION public.iso8601_timestamptz(value text) RETURNS timestamptz
|
|
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
|
|
SET TimeZone = 'UTC'
|
|
AS $$
|
|
BEGIN
|
|
IF value !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$' THEN
|
|
RAISE EXCEPTION 'not an ISO 8601 timestamp with a UTC offset: %', value;
|
|
END IF;
|
|
RETURN value::timestamptz;
|
|
END
|
|
$$;
|
|
|
|
ALTER TABLE neo4j."Movie"
|
|
ADD COLUMN premiere timestamptz GENERATED ALWAYS AS
|
|
(public.iso8601_timestamptz(properties ->> 'premiere'::text)) STORED;
|
|
```
|
|
|
|
PostgreSQL trusts the `IMMUTABLE` label without checking it, so the regular expression
|
|
is what makes it true. A view needs none of this.
|
|
|
|
## Exposing existing tables as a graph
|
|
|
|
AGE only queries its own label tables, so to query existing tables with Cypher, mirror
|
|
them into a graph with triggers. The graph then changes in the same transaction as the
|
|
tables. Take these tables:
|
|
|
|
```sql
|
|
CREATE TABLE public.customer (customer_id bigint PRIMARY KEY, name text, city text);
|
|
CREATE TABLE public.product (product_id bigint PRIMARY KEY, title text, price numeric);
|
|
CREATE TABLE public.purchase (
|
|
customer_id bigint REFERENCES public.customer,
|
|
product_id bigint REFERENCES public.product,
|
|
bought_at timestamptz,
|
|
quantity integer,
|
|
PRIMARY KEY (customer_id, product_id)
|
|
);
|
|
```
|
|
|
|
Create a graph with a label for each table. AGE needs `ag_catalog` in the `search_path`
|
|
to create them:
|
|
|
|
```sql
|
|
SET search_path = ag_catalog, "$user", public;
|
|
SELECT ag_catalog.create_graph('shop');
|
|
SELECT ag_catalog.create_vlabel('shop', 'Customer');
|
|
SELECT ag_catalog.create_vlabel('shop', 'Product');
|
|
SELECT ag_catalog.create_elabel('shop', 'BOUGHT');
|
|
```
|
|
|
|
A vertex id is a label id and a 48-bit number, so `_graphid` turns a primary key into a
|
|
vertex id. Edges can then be built straight from the foreign keys. Every change to a
|
|
row replaces its vertex:
|
|
|
|
```sql
|
|
CREATE FUNCTION public.customer_to_graph() RETURNS trigger
|
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ag_catalog, pg_catalog
|
|
AS $$
|
|
BEGIN
|
|
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
|
DELETE FROM shop."Customer"
|
|
WHERE id = _graphid(_label_id('shop', 'Customer'), OLD.customer_id);
|
|
END IF;
|
|
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
|
INSERT INTO shop."Customer" (id, properties)
|
|
VALUES (_graphid(_label_id('shop', 'Customer'), NEW.customer_id),
|
|
agtype_build_map('customer_id', NEW.customer_id, 'name', NEW.name, 'city', NEW.city));
|
|
END IF;
|
|
RETURN NULL;
|
|
END
|
|
$$;
|
|
|
|
CREATE TRIGGER to_graph AFTER INSERT OR UPDATE OR DELETE ON public.customer
|
|
FOR EACH ROW EXECUTE FUNCTION public.customer_to_graph();
|
|
```
|
|
|
|
`public.product` gets the same trigger for `Product`. Edges keep the ids AGE gives
|
|
them and are found by their endpoints:
|
|
|
|
```sql
|
|
CREATE FUNCTION public.purchase_to_graph() RETURNS trigger
|
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ag_catalog, pg_catalog
|
|
AS $$
|
|
BEGIN
|
|
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
|
DELETE FROM shop."BOUGHT"
|
|
WHERE start_id = _graphid(_label_id('shop', 'Customer'), OLD.customer_id)
|
|
AND end_id = _graphid(_label_id('shop', 'Product'), OLD.product_id);
|
|
END IF;
|
|
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
|
INSERT INTO shop."BOUGHT" (start_id, end_id, properties)
|
|
VALUES (_graphid(_label_id('shop', 'Customer'), NEW.customer_id),
|
|
_graphid(_label_id('shop', 'Product'), NEW.product_id),
|
|
agtype_build_map('bought_at', NEW.bought_at::text, 'quantity', NEW.quantity));
|
|
END IF;
|
|
RETURN NULL;
|
|
END
|
|
$$;
|
|
|
|
CREATE TRIGGER to_graph AFTER INSERT OR UPDATE OR DELETE ON public.purchase
|
|
FOR EACH ROW EXECUTE FUNCTION public.purchase_to_graph();
|
|
```
|
|
|
|
To copy the rows that are already there, update each of them once, vertices first:
|
|
|
|
```sql
|
|
UPDATE public.customer SET customer_id = customer_id;
|
|
UPDATE public.product SET product_id = product_id;
|
|
UPDATE public.purchase SET customer_id = customer_id;
|
|
```
|
|
|
|
This rewrites every row; for large tables, an `INSERT INTO shop."Customer" ... SELECT`
|
|
with the same expressions is faster. After that, `MATCH (c:Customer)-[:BOUGHT]->(p:Product)`
|
|
works on the `shop` database, including unlabelled and variable-length patterns. Some
|
|
things to keep in mind:
|
|
|
|
- This only works in one direction. AGE writes label tables without firing triggers, so
|
|
a Cypher `CREATE`, `SET` or `DELETE` on a mirrored label does not reach the tables,
|
|
and the next change to the row overwrites it. AGE does check table privileges, so
|
|
grant the users that log in through boltage only `SELECT` on the mirrored label tables.
|
|
The triggers are `SECURITY DEFINER`, so relational writes still reach the graph.
|
|
Superusers bypass privileges.
|
|
- Vertex ids need a `bigint` primary key below 2<sup>48</sup>. Other keys, such as
|
|
composite keys or UUIDs, need a table that maps each key to a number.
|
|
- Updating a row in `public.purchase` gives its edge a new id; vertex ids never change.
|
|
- The data is stored twice, and every write to the tables also writes to the graph.
|
|
|
|
## Container images
|
|
|
|
The [`Containerfile`](Containerfile) builds two images:
|
|
|
|
- **full** (the default): PostgreSQL with AGE, boltage, neo4j-browser and pgAdmin in one
|
|
container.
|
|
- **slim** (`--target slim`): only boltage and neo4j-browser, for a PostgreSQL server
|
|
with AGE that runs elsewhere. [`compose.yaml`](compose.yaml) runs it next to
|
|
separate PostgreSQL and pgAdmin containers.
|
|
|
|
Any OCI-compatible build tool works. Docker does not pick up a `Containerfile` by
|
|
itself, so pass it explicitly: `docker build -f Containerfile -t boltage .`. `docker`
|
|
takes the same options as `podman` in the commands below.
|
|
|
|
### Everything in one container
|
|
|
|
```sh
|
|
podman build -t boltage .
|
|
podman run -d --name boltage -e POSTGRES_PASSWORD=secret \
|
|
-p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \
|
|
-v boltage-data:/var/lib/postgresql -v boltage-pgadmin:/var/lib/pgadmin boltage
|
|
```
|
|
|
|
| Port | Service | Log in with |
|
|
| --- | --- | --- |
|
|
| 7474 | neo4j-browser: <http://localhost:7474/> | PostgreSQL user `postgres` and `POSTGRES_PASSWORD` |
|
|
| 7687 | Bolt, for Neo4j drivers and cypher-shell (`neo4j://localhost:7687`) | same as above |
|
|
| 5050 | pgAdmin: <http://localhost:5050/> | `admin@example.com` and `POSTGRES_PASSWORD` |
|
|
| 5432 | PostgreSQL, for `psql` and other SQL clients | same as neo4j-browser |
|
|
|
|
pgAdmin comes with the bundled PostgreSQL server already registered as "boltage";
|
|
it asks for the PostgreSQL password the first time you open it. AGE stores each
|
|
graph as a schema of the same name, with one table per label.
|
|
|
|
Besides boltage's own variables above, the image uses these:
|
|
|
|
| Variable | Default | Purpose |
|
|
| --- | --- | --- |
|
|
| `POSTGRES_PASSWORD` | (required) | Password of the PostgreSQL superuser, see the [postgres image docs](https://hub.docker.com/_/postgres). `POSTGRES_USER` and `POSTGRES_DB` work too. |
|
|
| `BOLTAGE_DEFAULT_GRAPH` | `neo4j` | Also the graph created when the database is initialised. |
|
|
| `PGADMIN_DEFAULT_EMAIL` | `admin@example.com` | pgAdmin login, created on first start. |
|
|
| `PGADMIN_DEFAULT_PASSWORD` | `$POSTGRES_PASSWORD` | pgAdmin login password, set on first start. |
|
|
| `BOLTAGE_PGADMIN` | `true` | Set to `false` to not start pgAdmin. |
|
|
| `BOLTAGE_PGADMIN_PORT` | `5050` | Port pgAdmin listens on inside the container. |
|
|
|
|
pgAdmin keeps its users and settings in `/var/lib/pgadmin`; changing
|
|
`PGADMIN_DEFAULT_*` after the first start has no effect unless that directory is empty.
|
|
|
|
### boltage with a separate PostgreSQL server
|
|
|
|
The slim image runs boltage and serves neo4j-browser, and is configured with the
|
|
variables in [Configuration](#configuration). Point it at any PostgreSQL server
|
|
that has AGE installed and loaded (`shared_preload_libraries=age`):
|
|
|
|
```sh
|
|
podman build --target slim -t boltage:slim .
|
|
podman run -d --name boltage -p 7474:7474 -p 7687:7687 \
|
|
-e BOLTAGE_PG_HOST=db.example.com -e BOLTAGE_PG_DATABASE=graphs boltage:slim
|
|
```
|
|
|
|
boltage creates no graphs of its own. If the database has none yet, run
|
|
`CREATE DATABASE neo4j` on the `system` database (`:use system` in neo4j-browser),
|
|
or `SELECT ag_catalog.create_graph('neo4j');` in SQL.
|
|
|
|
### Compose: boltage, PostgreSQL and pgAdmin as separate containers
|
|
|
|
[`compose.yaml`](compose.yaml) builds the slim image and runs it with
|
|
[`apache/age`](https://hub.docker.com/r/apache/age) for PostgreSQL and the official
|
|
[`dpage/pgadmin4`](https://hub.docker.com/r/dpage/pgadmin4) image:
|
|
|
|
```sh
|
|
echo POSTGRES_PASSWORD=secret > .env
|
|
podman compose up -d
|
|
```
|
|
|
|
It works with `podman compose` and `docker compose`, and publishes the same ports and
|
|
logins as the full image: neo4j-browser on 7474, Bolt on 7687, pgAdmin on 5050 and
|
|
PostgreSQL on 5432. Set `BOLTAGE_POSTGRES_PORT` to publish PostgreSQL on another host
|
|
port. The default graph (`BOLTAGE_DEFAULT_GRAPH`, `neo4j`) is created when the database
|
|
is initialised, and pgAdmin comes with the server already registered as "boltage".
|
|
`PGADMIN_DEFAULT_EMAIL`, `PGADMIN_DEFAULT_PASSWORD` and `BOLTAGE_LOG_LEVEL` can be set
|
|
in `.env` too. Data is kept in the `postgres-data` and `pgadmin-data` volumes;
|
|
`podman compose down -v` removes them.
|
|
|
|
### TLS in the containers
|
|
|
|
Both images serve plain Bolt and HTTP unless you turn on [TLS](#tls), either with a
|
|
self-signed certificate that the container generates, or with your own certificate.
|
|
In both cases the Bolt port keeps accepting plain connections as well, unless you
|
|
also set `BOLTAGE_BOLT_TLS=required`, and neo4j-browser moves to
|
|
<https://localhost:7474/>; set `BOLTAGE_HTTPS=false` to keep serving it over HTTP.
|
|
|
|
#### A self-signed certificate
|
|
|
|
Set `BOLTAGE_TLS_SELF_SIGNED=true`, and mount a volume on `/var/lib/boltage` to keep
|
|
the certificate when the container is re-created:
|
|
|
|
```sh
|
|
podman run -d --name boltage -e POSTGRES_PASSWORD=secret -e BOLTAGE_TLS_SELF_SIGNED=true \
|
|
-p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \
|
|
-v boltage-data:/var/lib/postgresql -v boltage-pgadmin:/var/lib/pgadmin \
|
|
-v boltage-tls:/var/lib/boltage boltage
|
|
```
|
|
|
|
With compose, put `BOLTAGE_TLS_SELF_SIGNED=true` in `.env`; the certificate is kept
|
|
in the `boltage-tls` volume.
|
|
|
|
The certificate is valid for `localhost`, `127.0.0.1`, `::1` and
|
|
`BOLTAGE_ADVERTISED_HOST`. List other host names and addresses, comma-separated, in
|
|
`BOLTAGE_TLS_HOSTNAMES`. A new certificate is generated when that list changes and
|
|
30 days before the old one expires (it is valid for 825 days). Its SHA-256
|
|
fingerprint is logged at every start, so you can compare it with the one your
|
|
browser shows.
|
|
|
|
Drivers connect with `neo4j+ssc://localhost:7687`. In a web browser, accept the
|
|
certificate at <https://localhost:7687/> once, then open <https://localhost:7474/> and
|
|
accept it there too.
|
|
|
|
#### Your own certificate
|
|
|
|
Mount the certificate and key, and point `BOLTAGE_TLS_CERT` and `BOLTAGE_TLS_KEY` at
|
|
them:
|
|
|
|
```sh
|
|
podman run -d --name boltage -e POSTGRES_PASSWORD=secret \
|
|
-v ./tls:/etc/boltage/tls:ro \
|
|
-e BOLTAGE_TLS_CERT=/etc/boltage/tls/cert.pem -e BOLTAGE_TLS_KEY=/etc/boltage/tls/key.pem \
|
|
-p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \
|
|
-v boltage-data:/var/lib/postgresql -v boltage-pgadmin:/var/lib/pgadmin boltage
|
|
```
|
|
|
|
Both images run boltage as user and group 999, which must be able to read the key.
|
|
Keep the key private on the host and give it to that user: with rootless Podman,
|
|
`podman unshare chown 999:999 tls/key.pem`, otherwise `sudo chown 999:999 tls/key.pem`;
|
|
then `chmod 0400 tls/key.pem`. On hosts with SELinux (Fedora, RHEL), mount with
|
|
`:ro,Z` instead of `:ro`.
|
|
|
|
#### Secrets
|
|
|
|
Podman secrets keep the key out of the host's file system and set its owner for you:
|
|
|
|
```sh
|
|
podman secret create boltage-tls-cert tls/cert.pem
|
|
podman secret create boltage-tls-key tls/key.pem
|
|
podman run -d --name boltage -e POSTGRES_PASSWORD=secret \
|
|
--secret boltage-tls-cert,uid=999,gid=999,mode=0444 \
|
|
--secret boltage-tls-key,uid=999,gid=999,mode=0400 \
|
|
-e BOLTAGE_TLS_CERT=/run/secrets/boltage-tls-cert \
|
|
-e BOLTAGE_TLS_KEY=/run/secrets/boltage-tls-key \
|
|
-p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \
|
|
-v boltage-data:/var/lib/postgresql -v boltage-pgadmin:/var/lib/pgadmin boltage
|
|
```
|
|
|
|
With compose, add the secrets in a `compose.override.yaml` next to `compose.yaml`,
|
|
which compose reads automatically:
|
|
|
|
```yaml
|
|
services:
|
|
boltage:
|
|
environment:
|
|
BOLTAGE_TLS_CERT: /run/secrets/boltage-tls-cert
|
|
BOLTAGE_TLS_KEY: /run/secrets/boltage-tls-key
|
|
secrets:
|
|
- boltage-tls-cert
|
|
- boltage-tls-key
|
|
|
|
secrets:
|
|
boltage-tls-cert:
|
|
file: ./tls/cert.pem
|
|
boltage-tls-key:
|
|
file: ./tls/key.pem
|
|
```
|
|
|
|
Compose mounts file secrets from the host as they are, so the key needs the same
|
|
owner as above.
|
|
|
|
### Build arguments
|
|
|
|
`AGE_IMAGE` (base of the full image, default `apache/age:release_PG18_1.8.0`),
|
|
`SLIM_BASE_IMAGE` (base of the slim image, default `debian:trixie-slim`),
|
|
`NEO4J_BROWSER_REF` (neo4j-browser commit to build) and `PGADMIN_VERSION` (pgAdmin in
|
|
the full image, default `9.18`).
|
|
|
|
## Limitations
|
|
|
|
This is a first version. Things that do not work (yet), each with a plan for
|
|
addressing it in [`plans/`](plans/README.md):
|
|
|
|
1. `RETURN *`, `EXPLAIN` and `PROFILE`
|
|
([plan](plans/01-return-star-explain-profile.md)).
|
|
2. Write statistics: write queries report "(no changes, no records)" even when they
|
|
changed the graph ([plan](plans/02-write-statistics.md)).
|
|
3. Anything AGE does not support, for example multiple labels per node, pattern
|
|
comprehensions, temporal and spatial types, indexes and constraints through Cypher
|
|
([plan](plans/03-age-unsupported-features.md)).
|
|
4. User and role management, `SHOW TRANSACTIONS`, `:sysinfo`
|
|
([plan](plans/04-users-transactions-sysinfo.md)).
|
|
5. [TLS](#tls) certificates are only read at start-up, so renewing one needs a
|
|
restart ([plan](plans/05-tls.md)).
|
|
6. The full result of a query that can write is fetched from PostgreSQL before it is
|
|
sent to the client, so very large results of such queries use memory in boltage
|
|
([plan](plans/06-streaming-results.md)).
|
|
|
|
## Development
|
|
|
|
boltage depends on `psycopg` 3 and `websockets`, plus Starlette and uvicorn for the
|
|
`browser` extra. To work on it, install it in editable mode; `python -m build` (or
|
|
`uv build`) builds an sdist and wheel:
|
|
|
|
```sh
|
|
pip install -e '.[binary,browser]'
|
|
```
|
|
|
|
[`tests/smoke_test.py`](tests/smoke_test.py) runs end-to-end checks through the official
|
|
Neo4j Python driver against a running instance:
|
|
|
|
```sh
|
|
pip install neo4j
|
|
python tests/smoke_test.py neo4j://localhost:7687 postgres secret
|
|
```
|
|
|
|
[`tests/streaming_test.py`](tests/streaming_test.py) checks result streaming by speaking
|
|
Bolt directly, which gives it control over `PULL`:
|
|
|
|
```sh
|
|
python tests/streaming_test.py localhost 7687 postgres secret neo4j
|
|
```
|
|
|
|
## License
|
|
|
|
boltage is free software, licensed under the GNU General Public License v3.0 or
|
|
later; see [LICENSE](LICENSE). The container images bundle third-party software
|
|
under their own licenses, including neo4j-browser (GPL-3.0), Apache AGE
|
|
(Apache-2.0), PostgreSQL and pgAdmin (PostgreSQL License). See
|
|
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for the full list and where
|
|
each license text and source archive is in the image.
|
|
|
|
Neo4j is a trademark of Neo4j, Inc.; boltage is not affiliated with or endorsed
|
|
by Neo4j.
|