Introduces the design for short-lived deploy keys provisioned at spin-up and revoked at teardown, plus the contrib package structure for platform-specific provisioner implementations. First contrib provider targets the Gitea deploy-key API. Closes #169
11 KiB
PRD 0048: SSH Deploy-Key Provisioning
- Status: Draft
- Author: didericis-claude
- Created: 2026-06-03
- Issue: #169
Summary
Replace per-repo static SSH identity files with short-lived ed25519 deploy
keys that are generated at spin-up and revoked at teardown. Introduce
bot_bottle/contrib/ as the package for platform-specific provisioners and
ship the first contrib sub-package: bot_bottle/contrib/gitea/ with
GiteaDeployKeyProvisioner. A new deploy_key: block in git-gate.repos
entries opts a repo into automatic key lifecycle management; identity: stays
valid for operators who supply their own key material.
Problem
The current git-gate.repos entries require an identity: field pointing to
a host-side SSH private key (PRD 0047). Keys are static: the operator generates
them once, registers them with the upstream forge, and the same key is reused
across every bottle spin-up. This has several consequences:
- No automatic revocation. If a bottle misbehaves or a key leaks, the operator must notice and manually delete the key from the forge. There is no teardown hook that does it.
- Broad blast radius. A forge deploy key typically grants write access for the lifetime of the key. A static key that survives bottle teardown continues to grant that access.
- Manual rotation burden. Operators must manage key files on disk, keeping
them secure, rotating them on a schedule, and distributing them across hosts
that run
./cli.py start.
Goals / Success Criteria
git-gate.reposentries acceptdeploy_key:as an alternative toidentity:. The parser rejects entries that have both, or neither.deploy_key.provider: giteaprovisions and revokes deploy keys via the Gitea HTTP API.- At prepare time the provisioner generates a fresh ed25519 keypair, registers the public half as a repo-scoped deploy key, and makes the private key available to git-gate at the path it expects — the rest of the pipeline is unchanged.
- At teardown the provisioner deletes the registered deploy key. Failure to delete is logged as a warning and does not abort teardown.
bot_bottle/contrib/is introduced as the package for platform-specific implementations; the core defines the abstract interface; contrib sub-packages provide concrete implementations.- Existing
identity:-based repos continue to work without change. - The unit test suite passes unchanged for
identity:paths; new tests coverdeploy_key:parse, validation, and provisioner dispatch.
Non-goals
- GitHub, GitLab, or other forge providers (a future contrib sub-package each).
- Dashboard UI for listing or revoking orphaned deploy keys.
- SSH CA certificate approach (rejected in the issue thread in favour of per-repo deploy keys for simpler revocation, smaller blast radius, and forge compatibility).
- Key rotation mid-session (keys live for exactly one spin-up / teardown cycle).
- Any change to how
identity:repos are provisioned.
Design
Manifest changes (builds on PRD 0047)
git-gate.repos.<name> currently accepts exactly:
url (required string)
identity (required string)
host_key (optional string)
After this PRD:
url (required string)
identity (optional string — mutually exclusive with deploy_key)
deploy_key (optional object — mutually exclusive with identity)
host_key (optional string)
Exactly one of identity or deploy_key must be present. The parser emits a
targeted error for each violation:
bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of
'identity' or 'deploy_key'; got neither.
bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of
'identity' or 'deploy_key'; got both.
deploy_key object schema:
deploy_key:
provider: gitea # required; names the contrib module to load
token_env: GITEA_TOKEN # required; name of a host env var holding the API token
api_url: https://... # optional; defaults to https://<host from url>
| Field | Type | Notes |
|---|---|---|
provider |
required string | Must match a sub-package under bot_bottle/contrib/ |
token_env |
required string | Resolved at provision time via os.environ; never stored in plan |
api_url |
optional string | Override when the API endpoint differs from the git host |
Example bottle manifest:
git-gate:
user:
name: implementer-bot
email: eric+implementer@dideric.is
repos:
bot-bottle:
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
deploy_key:
provider: gitea
token_env: GITEA_DEPLOY_TOKEN
host_key: "ssh-rsa AAAA..."
contrib package structure
bot_bottle/
contrib/
__init__.py # empty; no core symbols
gitea/
__init__.py # empty
deploy_key_provisioner.py
contrib is a flat namespace of forge/platform sub-packages. Each sub-package
is self-contained; the core imports from contrib lazily (inside factory
functions) so that missing optional dependencies in a contrib sub-package don't
break unrelated features.
Core interface
New file: bot_bottle/deploy_key_provisioner.py
from abc import ABC, abstractmethod
class DeployKeyProvisioner(ABC):
@abstractmethod
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
"""Generate a keypair and register the public half.
owner_repo: '<owner>/<repo>' portion of the git upstream URL.
title: human-readable label shown in the forge key list.
Returns (key_id, private_key_pem) where key_id is opaque to
the caller and is only passed back to delete()."""
@abstractmethod
def delete(self, owner_repo: str, key_id: str) -> None:
"""Delete the registered deploy key. Best-effort; should not
raise if the key is already absent."""
def get_provisioner(provider: str, token: str, api_url: str) -> DeployKeyProvisioner:
"""Instantiate the named contrib provisioner.
Raises ManifestError for unknown providers so the error is caught
at parse time rather than at runtime."""
if provider == "gitea":
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
GiteaDeployKeyProvisioner,
)
return GiteaDeployKeyProvisioner(token=token, api_url=api_url)
from .manifest_util import ManifestError
raise ManifestError(f"unknown deploy_key provider: {provider!r}")
Gitea contrib implementation
bot_bottle/contrib/gitea/deploy_key_provisioner.py:
create(owner_repo, title):
- Generate an ed25519 keypair via
ssh-keygen -t ed25519 -f <tmpfile> -N ''(uses the SSH tooling already required by git-gate; no new Python dependency). - Read the private key bytes and the
.pubfile. POST /api/v1/repos/{owner}/{repo}/keyswith the public key,title, andread_only: false(deploy keys always need push access for git-gate).- Return
(str(response["id"]), private_key_bytes).
delete(owner_repo, key_id):
DELETE /api/v1/repos/{owner}/{repo}/keys/{id}.- Treat HTTP 404 as success (key already gone).
- Log a warning and return (do not raise) for any other error.
HTTP calls use urllib.request from the stdlib; no new runtime dependency.
GitEntry dataclass changes
bot_bottle/manifest_git.py:
-
Add
DeployKeyConfigdataclass:@dataclass(frozen=True) class DeployKeyConfig: provider: str token_env: str api_url: str # empty string means "derive from UpstreamHost" -
GitEntry:IdentityFile: str→IdentityFile: str(unchanged internally; empty string whendeploy_keyis used; set at provision time, not parse time)- New field:
DeployKey: DeployKeyConfig | None = None from_repos_entryvalidates the mutually-exclusive constraint and parses thedeploy_keyblock when present.
GitGateUpstream / prepare-time changes
bot_bottle/git_gate.py and bot_bottle/backend/docker/provision/git.py:
The existing path writes the identity file path into GitGateUpstream.IdentityFile
and docker-cp's it into /git-gate/creds/<name>-key. That path stays unchanged
for identity: repos.
For deploy_key: repos, a new helper provision_deploy_key(entry, stage_dir, bottle_name) runs before the git-gate sidecar starts:
- Resolve
token = os.environ[entry.DeployKey.token_env]. Missing key raisesRuntimeErrorwith a clear message naming the env var. - Resolve
api_url = entry.DeployKey.api_url or f"https://{entry.UpstreamHost}". - Instantiate
get_provisioner(entry.DeployKey.provider, token, api_url). - Call
provisioner.create(entry.UpstreamPath.lstrip("/"), title)wheretitle = f"bot-bottle:{bottle_name}:{entry.Name}". - Write private key to
stage_dir / f"{entry.Name}-key"(mode 0o600). - Write key ID to
stage_dir / f"{entry.Name}-deploy-key-id"(plain text). - Return the key file path; caller sets
GitGateUpstream.IdentityFileto it.
owner_repo is extracted from entry.UpstreamPath (the path component of the
ssh:// URL, e.g. /didericis/bot-bottle.git → didericis/bot-bottle).
Teardown changes
bot_bottle/backend/docker/cleanup.py (or the equivalent teardown path):
After the git-gate sidecar stops, for each GitEntry with DeployKey set:
- Check that
stage_dir / f"{entry.Name}-deploy-key-id"exists; skip if absent (provision never ran or already cleaned up). - Resolve token and API URL as above.
- Instantiate provisioner and call
provisioner.delete(owner_repo, key_id). - Log result at INFO (success) or WARNING (failure); continue regardless.
The private key file in stage_dir is cleaned up as part of normal stage-dir
teardown (no extra step needed).
Testing strategy
python3 -m unittest discover -s tests/unit
New / modified test files:
-
tests/unit/test_manifest_git.py— add cases for:deploy_key:accepted with validprovider,token_env, optionalapi_url- Both
identityanddeploy_keypresent →ManifestError - Neither
identitynordeploy_keypresent →ManifestError - Unknown key inside
deploy_keyblock →ManifestError - Missing
providerortoken_envinsidedeploy_key→ManifestError
-
tests/unit/test_deploy_key_provisioner.py— new:get_provisioner("gitea", ...)returnsGiteaDeployKeyProvisionerget_provisioner("unknown", ...)raisesManifestError
-
tests/unit/test_contrib_gitea_deploy_key.py— new (usingunittest.mockto stuburllib.request.urlopenandsubprocess.run):create()callsssh-keygen, POSTs to correct endpoint, returns key IDdelete()DELETEs to correct endpointdelete()tolerates HTTP 404 (already-deleted key)delete()logs warning (does not raise) on other HTTP errors
Open questions
None.