From e6a615bf70eee35e3d7e16bbc936a3dc1e7d88b9 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 22 Apr 2026 22:34:38 -0400 Subject: [PATCH] Add deploy.sh, systemd unit template, and `pipekit secrets` CLI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.sh is the idempotent rollout path: venv + deps, launcher, /etc/pipekit/secrets.env skeleton (mode 0600), schema init, and auto-register of every JDBC driver shipped with jrunner. systemd unit is a template, not auto-installed — user copies it when ready to cut over. `pipekit secrets {list,set,unset}` manages /etc/pipekit/secrets.env with atomic 0600 writes so passwords don't need sudoedit. Prompted input by default; positional value allowed for scripting. Co-Authored-By: Claude Opus 4.7 --- deploy.sh | 101 ++++++++++++++++++++++++++++++++++ pipekit/cli.py | 116 ++++++++++++++++++++++++++++++++++++++++ systemd/pipekit.service | 29 ++++++++++ 3 files changed, 246 insertions(+) create mode 100755 deploy.sh create mode 100644 systemd/pipekit.service diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..48adfba --- /dev/null +++ b/deploy.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Pipekit deployment — idempotent. Re-run any time. +# +# Steps: +# 1. Check prerequisites (python3, jrunner on PATH) +# 2. Create Python venv at $REPO/.venv and install requirements +# 3. Install launcher at /usr/local/bin/pipekit (wraps the venv python) +# 4. Ensure /etc/pipekit/secrets.env exists (mode 0600, placeholder body) +# 5. Run `pipekit init` to create/upgrade the SQLite schema +# 6. Register driver rows for every JDBC jar shipped with jrunner +# +# After running: +# - Set DB passwords with: sudo pipekit secrets set +# - See systemd/pipekit.service for a unit file template + +set -euo pipefail + +REPO_DIR="${PIPEKIT_REPO:-$(cd "$(dirname "$0")" && pwd)}" +VENV_DIR="$REPO_DIR/.venv" +LAUNCHER="/usr/local/bin/pipekit" +CONFIG_DIR="/etc/pipekit" +SECRETS_FILE="$CONFIG_DIR/secrets.env" + +if [ "$EUID" -ne 0 ]; then + exec sudo -E "$0" "$@" +fi + +echo "== pipekit deploy ==" +echo "repo: $REPO_DIR" +echo "venv: $VENV_DIR" +echo "secrets: $SECRETS_FILE" +echo "" + +command -v python3 >/dev/null || { echo "ERROR: python3 not on PATH"; exit 1; } +command -v jrunner >/dev/null || { echo "ERROR: jrunner not on PATH — install /opt/jrunner first"; exit 1; } + +if [ ! -d "$VENV_DIR" ]; then + echo "Creating venv at $VENV_DIR" + python3 -m venv "$VENV_DIR" +fi +"$VENV_DIR/bin/pip" install --quiet --upgrade pip +"$VENV_DIR/bin/pip" install --quiet -r "$REPO_DIR/requirements.txt" +echo "Python deps installed." + +cat > "$REPO_DIR/bin/pipekit" < $REPO_DIR/bin/pipekit" + +install -d -m 0755 "$CONFIG_DIR" +if [ ! -f "$SECRETS_FILE" ]; then + install -m 0600 /dev/null "$SECRETS_FILE" + cat > "$SECRETS_FILE" <<'EOF' +# pipekit secrets — sourced by the service process (EnvironmentFile=) +# or by the shell before `pipekit serve`. One KEY=VALUE per line. +# Connection rows reference these as $KEY (e.g. password: "$DB2PW"). +# +# This file must stay mode 0600 and out of version control. +# Use `sudo pipekit secrets set ` to add entries safely. +EOF + chmod 0600 "$SECRETS_FILE" + echo "Created $SECRETS_FILE" +else + echo "Keeping existing $SECRETS_FILE" +fi + +"$LAUNCHER" init + +# Register drivers for each JDBC jar jrunner ships with. +JR_LIB="$(dirname "$(readlink -f "$(command -v jrunner)")")/../lib" +register_jar() { + local kind="$1" pattern="$2" + local jar + jar="$(find "$JR_LIB" -maxdepth 1 -name "$pattern" 2>/dev/null | head -1)" + if [ -n "$jar" ]; then + "$LAUNCHER" drivers register "$kind" --jar "$jar" + else + echo " (no $pattern in $JR_LIB — skipping $kind)" + fi +} +register_jar db2 "jt400-*.jar" +register_jar pg "postgresql-*.jar" +register_jar mssql "mssql-jdbc-*.jar" + +echo "" +echo "pipekit deployed." +echo "" +echo "Next steps:" +echo " 1. Set passwords: sudo pipekit secrets set DB2PW" +echo " sudo pipekit secrets set PGPW" +echo " 2. Start the server manually:" +echo " set -a; source $SECRETS_FILE; set +a" +echo " pipekit serve" +echo " 3. Or install the systemd unit:" +echo " sudo cp $REPO_DIR/systemd/pipekit.service /etc/systemd/system/" +echo " sudo systemctl daemon-reload" +echo " sudo systemctl enable --now pipekit" diff --git a/pipekit/cli.py b/pipekit/cli.py index 66ddd30..2e4feca 100644 --- a/pipekit/cli.py +++ b/pipekit/cli.py @@ -155,6 +155,101 @@ def cmd_set_password(args) -> int: return 0 +_DEFAULT_SECRETS_FILE = "/etc/pipekit/secrets.env" + + +def _secrets_path(args) -> str: + import os + return (getattr(args, "file", None) + or os.environ.get("PIPEKIT_SECRETS") + or _DEFAULT_SECRETS_FILE) + + +def cmd_secrets_list(args) -> int: + import os + path = _secrets_path(args) + if not os.path.exists(path): + print(f"{path}: no such file") + return 0 + keys: list[str] = [] + with open(path) as f: + for line in f: + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + keys.append(s.split("=", 1)[0]) + print(f"{path} — {len(keys)} secret(s):") + for k in keys: + print(f" {k}") + return 0 + + +def cmd_secrets_set(args) -> int: + import getpass + import os + import stat + path = _secrets_path(args) + value = args.value if args.value is not None else getpass.getpass( + f"value for {args.key}: ") + if not value: + print("error: empty value") + return 1 + + lines: list[str] = [] + replaced = False + if os.path.exists(path): + with open(path) as f: + for line in f: + s = line.strip() + if s and not s.startswith("#") and "=" in s \ + and s.split("=", 1)[0] == args.key: + lines.append(f"{args.key}={value}\n") + replaced = True + else: + lines.append(line) + if not replaced: + if lines and not lines[-1].endswith("\n"): + lines.append("\n") + lines.append(f"{args.key}={value}\n") + + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w") as f: + f.writelines(lines) + os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0600 + os.replace(tmp, path) + print(f"{'updated' if replaced else 'added'} {args.key} in {path}") + return 0 + + +def cmd_secrets_unset(args) -> int: + import os + path = _secrets_path(args) + if not os.path.exists(path): + print(f"{path}: no such file") + return 1 + kept: list[str] = [] + removed = False + with open(path) as f: + for line in f: + s = line.strip() + if s and not s.startswith("#") and "=" in s \ + and s.split("=", 1)[0] == args.key: + removed = True + continue + kept.append(line) + if not removed: + print(f"{args.key} not set in {path}") + return 0 + tmp = path + ".tmp" + with open(tmp, "w") as f: + f.writelines(kept) + os.chmod(tmp, 0o600) + os.replace(tmp, path) + print(f"removed {args.key} from {path}") + return 0 + + def _report(checks) -> int: width = max(len(name) for name, _, _ in checks) failures = 0 @@ -224,6 +319,27 @@ def main(argv: list[str] | None = None) -> int: p_pw.add_argument("username") p_pw.set_defaults(func=cmd_set_password) + p_sec = sub.add_parser( + "secrets", + help=f"manage the env-var file for DB passwords (default {_DEFAULT_SECRETS_FILE})") + sec_sub = p_sec.add_subparsers(dest="sec_cmd", required=True) + + p_sec_list = sec_sub.add_parser("list", help="list keys in the secrets file") + p_sec_list.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})") + p_sec_list.set_defaults(func=cmd_secrets_list) + + p_sec_set = sec_sub.add_parser("set", help="add or update a KEY (prompted if value omitted)") + p_sec_set.add_argument("key") + p_sec_set.add_argument("value", nargs="?", default=None, + help="value; omit to be prompted (safer)") + p_sec_set.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})") + p_sec_set.set_defaults(func=cmd_secrets_set) + + p_sec_unset = sec_sub.add_parser("unset", help="remove a KEY from the secrets file") + p_sec_unset.add_argument("key") + p_sec_unset.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})") + p_sec_unset.set_defaults(func=cmd_secrets_unset) + args = p.parse_args(argv) return args.func(args) diff --git a/systemd/pipekit.service b/systemd/pipekit.service new file mode 100644 index 0000000..de1ffea --- /dev/null +++ b/systemd/pipekit.service @@ -0,0 +1,29 @@ +# Pipekit systemd unit template. +# +# Install: +# sudo cp pipekit.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now pipekit +# +# Runs as root by default. For a dedicated service account, create the +# user and uncomment User=/Group= below: +# sudo useradd --system --home-dir /opt/pipekit --shell /usr/sbin/nologin pipekit +# sudo chown -R pipekit:pipekit /opt/pipekit/pipekit.db /etc/pipekit + +[Unit] +Description=Pipekit sync engine +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# User=pipekit +# Group=pipekit +WorkingDirectory=/opt/pipekit +EnvironmentFile=/etc/pipekit/secrets.env +ExecStart=/usr/local/bin/pipekit serve --host 0.0.0.0 +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target