Add deploy.sh, systemd unit template, and pipekit secrets CLI.

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 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-22 22:34:38 -04:00
parent e27167a4a3
commit e6a615bf70
3 changed files with 246 additions and 0 deletions

101
deploy.sh Executable file
View File

@ -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 <KEY>
# - 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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "$VENV_DIR/bin/python3" -m pipekit "\$@"
EOF
chmod +x "$REPO_DIR/bin/pipekit"
ln -sf "$REPO_DIR/bin/pipekit" "$LAUNCHER"
echo "Launcher: $LAUNCHER -> $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 <KEY>` 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"

View File

@ -155,6 +155,101 @@ def cmd_set_password(args) -> int:
return 0 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: def _report(checks) -> int:
width = max(len(name) for name, _, _ in checks) width = max(len(name) for name, _, _ in checks)
failures = 0 failures = 0
@ -224,6 +319,27 @@ def main(argv: list[str] | None = None) -> int:
p_pw.add_argument("username") p_pw.add_argument("username")
p_pw.set_defaults(func=cmd_set_password) 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) args = p.parse_args(argv)
return args.func(args) return args.func(args)

29
systemd/pipekit.service Normal file
View File

@ -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