Go to file
Paul Trowbridge 7921ebb7e1 Add README
Summary, install, then a fuller description starting with the tech stack.
Points at SPEC.md for architectural rationale rather than restating it, so
the two don't drift.

Also documents `pipekit export` / `pipekit apply`, which round-trip the
driver/connection/module/group config to text files and were missing from
CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:31:04 -04:00
.archive/pre-rewrite Initial commit: Pipekit rewrite. 2026-04-22 00:38:26 -04:00
bin bin/pipekit auto-detects venv; stop rewriting it in deploy.sh. 2026-04-23 00:34:32 -04:00
config updates 2026-08-06 15:39:22 -04:00
docs Add CMS→pipekit migration plan doc 2026-08-05 15:48:45 -04:00
pipekit Make reconcile.py work against SQL Server / linked-server sources 2026-08-07 02:25:24 -04:00
systemd Add scheduling, harden deploy, and update docs 2026-06-03 21:18:13 -04:00
.gitignore drivers: make staging DDL dialect-aware; stop tracking pipekit.db 2026-06-10 23:33:36 -04:00
CLAUDE.md jrunner: use bulk copy (-b) for Postgres dests too (COPY) 2026-06-18 23:14:15 -04:00
config.yaml web: add session-cookie login for web UI 2026-06-04 13:21:50 -04:00
deploy.sh deploy/cli: fix /etc/pipekit permissions so non-root group members can write secrets 2026-06-04 13:35:06 -04:00
README.md Add README 2026-08-07 02:31:04 -04:00
reconcile.py Make reconcile.py work against SQL Server / linked-server sources 2026-08-07 02:25:24 -04:00
requirements.txt web: add session-cookie login for web UI 2026-06-04 13:21:50 -04:00
SPEC.md feat: version-control pipeline definitions via export/apply 2026-07-22 10:56:55 -04:00

Pipekit

A database sync tool with a web UI. A module defines one job — a source query against one database, merged into a destination table on another. Pipekit resolves watermarks, runs the extract, stages the rows, merges them, and records what happened. Modules can be grouped, ordered, and scheduled with cron.

It is an orchestration layer over jrunner, an external Java CLI that does all the actual JDBC work. Pipekit never talks to a remote database directly — it shells out. What it adds is the state, history, and UI that a directory of shell scripts can't provide: run logs with timings and row counts, live output while a job runs, a wizard for authoring new modules, and a scheduler.


Install

Requirements

  • Python 3.10+
  • jrunner on PATH, with JAVA_HOME set — the Java CLI that performs every JDBC read and write. Install /opt/jrunner first; deploy.sh refuses to run without it and injects the detected JAVA_HOME into the systemd unit.
  • sudo — the deploy creates a system user, writes to /etc/pipekit, and installs a systemd unit.

Deploy

./deploy.sh

Idempotent — re-run it after any code update. It creates the pipekit system user and group, adds you to that group, builds the venv at .venv, installs the /usr/local/bin/pipekit launcher, creates /etc/pipekit/secrets.env (mode 0640), initialises the SQLite schema, registers a JDBC driver row for every jar shipped with jrunner, and installs the systemd unit. It does not start the service.

Then add your connection passwords and start it:

sudo pipekit secrets set MYDB_PW          # prompts if the value is omitted
sudo systemctl start pipekit

The web UI is on api_port from config.yaml8200 by default.

Service management

sudo systemctl status pipekit
sudo systemctl restart pipekit      # after any code change
sudo journalctl -u pipekit -f       # follow logs

Running outside the service

pipekit serve                                        # host/port from config.yaml
pipekit serve --host 0.0.0.0 --port 8080 --reload    # dev, auto-reload

CLI

pipekit init                    # create/upgrade the SQLite schema
pipekit doctor                  # health check: config, jrunner, DB
pipekit run <module_name>       # run one module synchronously
pipekit set-password <user>     # HTTP Basic Auth credentials for /api/*
pipekit secrets set KEY [VALUE] # add/update a secret
pipekit secrets list            # show stored keys, never values
pipekit drivers list            # registered driver kinds

Configuration also round-trips to plain text, so modules can be diffed and version-controlled rather than living only in the database:

pipekit export                  # dump drivers/connections/modules/groups to <repo>/config
pipekit apply --dry-run         # print the change plan without touching the DB
pipekit apply                   # rehydrate from those files, matching by name
pipekit apply --prune           # also delete rows absent from the files

Configuration

config.yaml in the repo root holds the database path, jrunner path, driver directory, API host/port, and an optional SMTP block. Two environment variables override the defaults: PIPEKIT_CONFIG for the config file and PIPEKIT_SECRETS for the secrets file.


About the project

Tech stack

  • Python 3.10+ — no ORM, no migrations framework, no task queue
  • FastAPI + Uvicorn for both the JSON API and the HTML pages
  • Jinja2 templates; HTMX (from CDN) plus vanilla JS for interactivity. There is no build step and no frontend toolchain. Alpine.js is named in some older docs but is not loaded.
  • SQLite (stdlib sqlite3) — a single pipekit.db file holds all state
  • PyYAML for config, croniter for schedule evaluation
  • python-multipart — required for HTML form POSTs; it is not a transitive dependency of the FastAPI base wheel, and form posts return 500 without it
  • jrunner — a separate Java tool, the only thing that speaks JDBC

Deployed as the pipekit system user, venv at /opt/pipekit/.venv, secrets at /etc/pipekit/secrets.env.

Architecture

Five layers, bottom to top:

  1. SQLite — one file, all state
  2. repo.py — every CRUD operation for every table; the only module that touches the database
  3. engine/ — runs a module: acquires the lock, resolves watermarks, calls jrunner, builds and runs the merge, fires hooks, writes the run log, releases the lock
  4. api/ — JSON REST under /api/*, HTTP Basic Auth (except /health)
  5. web/ — HTML pages at /, currently unauthenticated

Data model

  • driver — a registered JDBC driver (kind, jar, class, URL template)
  • connection — a named database (JDBC URL, username, password)
  • module — one sync job: source and dest connection, source query, merge strategy and key, column map, enabled flag, running lock
  • watermark — a named placeholder with its own resolver SQL. The first cell of the first row is substituted for {watermark_name} in the source query.
  • hook — post-merge SQL, ordered, fired on success / failure / always
  • run_log — immutable history: resolved SQL, merge SQL, watermark values, stdout/stderr, timings, and the live log streamed during the run
  • grp / group_member / group_run — named, ordered sets of modules and their executions
  • schedule — a cron expression bound to a group, evaluated in local server time

How a run works

run_module(module_id)
  → atomic UPDATE module SET running=1 WHERE running=0   # fails if already locked
  → resolve each watermark via jrunner                   # always live, even on dry runs
  → substitute {name} tokens into the source query
  → build the merge SQL
  → if dry_run: write the run_log and stop — no data moves
  → DROP + CREATE pipekit_staging.{module_name}          # self-heals schema drift
  → jrunner migrate: source → staging                    # stdout streamed to live_log
  → run the merge, then the hooks in order
  → write run_log; UPDATE module SET running=0 (in finally)

A group run creates a group_run row and calls run_module for each enabled member in order. It continues past individual failures — every member runs — and the group's final status reflects the worst outcome.

Run statuses: running, success, dry_run, error, cancelled.

Merge strategies

  • fullTRUNCATE the dest, then insert everything
  • incremental — delete the rows matching merge_key, then insert. The key is stored comma-separated and expands to a multi-column predicate.
  • append — insert only

The staging table is recreated on every run and exists only for the duration of that run.

Credentials

Connection passwords are stored as $VAR_NAME references, never as values. They resolve at run time from /etc/pipekit/secrets.env. Secrets never reach the database, and never appear on a command line.

Performance note

When the destination is SQL Server or Postgres, jrunner is invoked with -b so the load uses the native bulk path — SQLServerBulkCopy over TDS, or COPY … FROM STDIN. This is automatic, per-destination, with nothing to configure, and the difference is large: one 1.27M-row SQL Server load went from ~111 minutes to ~4. DB2 destinations keep the ordinary INSERT path.

Reconciliation

reconcile.py compares a module's live source against its synced destination using column-wise aggregates — counts, sums, min/max, summed lengths. These rely only on arithmetic and ordering, which every backend computes identically, so no shared hash function or byte-identical row serialisation is needed.

PIPEKIT_SECRETS=/etc/pipekit/secrets.env .venv/bin/python reconcile.py <module> [--quick]

Three depths: the default compares every column; --quick drops min/max and length sums; --super-quick compares only row counts, which takes seconds even on millions of rows and still catches missing or duplicated rows. Exit status is non-zero when anything diverges. Note that it compares whole tables against a live source, so an incremental module will always show drift proportional to the time since its last run.


Further reading

  • SPEC.md — the authoritative design document, and the rationale behind every architectural decision here
  • CLAUDE.md — orientation for AI coding agents working in this repo