Files
bot-bottle/docs/prds/0048-ssh-deploy-key-provisioning.md
T
didericis-claude 24f770458b docs(prd): add SSH deploy-key provisioning plan (PRD 0048)
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
2026-06-03 11:33:30 -04:00

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.repos entries accept deploy_key: as an alternative to identity:. The parser rejects entries that have both, or neither.
  • deploy_key.provider: gitea provisions 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 cover deploy_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):

  1. 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).
  2. Read the private key bytes and the .pub file.
  3. POST /api/v1/repos/{owner}/{repo}/keys with the public key, title, and read_only: false (deploy keys always need push access for git-gate).
  4. Return (str(response["id"]), private_key_bytes).

delete(owner_repo, key_id):

  1. DELETE /api/v1/repos/{owner}/{repo}/keys/{id}.
  2. Treat HTTP 404 as success (key already gone).
  3. 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 DeployKeyConfig dataclass:

    @dataclass(frozen=True)
    class DeployKeyConfig:
        provider: str
        token_env: str
        api_url: str  # empty string means "derive from UpstreamHost"
    
  • GitEntry:

    • IdentityFile: strIdentityFile: str (unchanged internally; empty string when deploy_key is used; set at provision time, not parse time)
    • New field: DeployKey: DeployKeyConfig | None = None
    • from_repos_entry validates the mutually-exclusive constraint and parses the deploy_key block 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:

  1. Resolve token = os.environ[entry.DeployKey.token_env]. Missing key raises RuntimeError with a clear message naming the env var.
  2. Resolve api_url = entry.DeployKey.api_url or f"https://{entry.UpstreamHost}".
  3. Instantiate get_provisioner(entry.DeployKey.provider, token, api_url).
  4. Call provisioner.create(entry.UpstreamPath.lstrip("/"), title) where title = f"bot-bottle:{bottle_name}:{entry.Name}".
  5. Write private key to stage_dir / f"{entry.Name}-key" (mode 0o600).
  6. Write key ID to stage_dir / f"{entry.Name}-deploy-key-id" (plain text).
  7. Return the key file path; caller sets GitGateUpstream.IdentityFile to it.

owner_repo is extracted from entry.UpstreamPath (the path component of the ssh:// URL, e.g. /didericis/bot-bottle.gitdidericis/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:

  1. Check that stage_dir / f"{entry.Name}-deploy-key-id" exists; skip if absent (provision never ran or already cleaned up).
  2. Resolve token and API URL as above.
  3. Instantiate provisioner and call provisioner.delete(owner_repo, key_id).
  4. 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 valid provider, token_env, optional api_url
    • Both identity and deploy_key present → ManifestError
    • Neither identity nor deploy_key present → ManifestError
    • Unknown key inside deploy_key block → ManifestError
    • Missing provider or token_env inside deploy_keyManifestError
  • tests/unit/test_deploy_key_provisioner.py — new:

    • get_provisioner("gitea", ...) returns GiteaDeployKeyProvisioner
    • get_provisioner("unknown", ...) raises ManifestError
  • tests/unit/test_contrib_gitea_deploy_key.py — new (using unittest.mock to stub urllib.request.urlopen and subprocess.run):

    • create() calls ssh-keygen, POSTs to correct endpoint, returns key ID
    • delete() DELETEs to correct endpoint
    • delete() tolerates HTTP 404 (already-deleted key)
    • delete() logs warning (does not raise) on other HTTP errors

Open questions

None.