deploy

Getting a web app onto a VM: a Dockerfile, a tunnel, a server, and the keys CI needs.

This module puts a web app on a virtual private server (VPS). It follows the pattern of lego, the project it was taken from: a Docker image described by dockeasy, a Hetzner server provisioned by vpseasy, and a Cloudflare tunnel opened by cfeasy, so the app is reachable without opening a public port. The work is done by two scripts in the project, setup.py for one-time setup and deploy.py for every deploy. This module writes both, and Deploy is the pipeline that runs deploy.py.


source

generator

def generator():

The tool name that generated scripts say wrote them.


source

use_generator

def use_generator(
    name
):

Set the tool name that generated setup.py and deploy.py files say wrote them.

Each generated script says in its docstring which tool wrote it. use_generator sets that name, which is Leela until you change it. A host application that bundles pullup calls it with its own name. The templates read the name each time they build their text, so a call made after import still takes effect.

DeployError is another name for PipelineError, so catching either one catches both.

DEPLOY_KEYS lists the settings a deploy needs. A value of None marks a secret: it has no default and goes to GitHub as an encrypted secret. A string marks an ordinary variable, and the string is its default. MODE, PORT, SERVER_USER and RSYNC_FORCE have defaults; the domain, the server name and the three tokens do not.

{k: ('secret' if v is None else f'variable, default {v!r}') for k, v in DEPLOY_KEYS.items()}
{'MODE': "variable, default 'prod'",
 'PORT': "variable, default '5001'",
 'DOMAIN': 'secret',
 'SERVER_NAME': 'secret',
 'SERVER_USER': "variable, default 'deploy'",
 'HCLOUD_TOKEN': 'secret',
 'CLOUDFLARE_API_TOKEN': 'secret',
 'CF_TUNNEL_TOKEN': 'secret',
 'RSYNC_FORCE': "variable, default 'false'"}

DEPLOY_STEPS is the default deploy plan. Each step runs one subcommand of the project’s deploy.py. env writes the .env file the container reads. compose writes the Dockerfile, docker-compose.yml and the Caddyfile. deploy opens the tunnel, provisions the server if it does not exist yet and copies the project to it with rsync. Only the last step needs tokens.

[(s.id, s.cmd, s.needs) for s in DEPLOY_STEPS]
[('env', 'python deploy.py env', []),
 ('compose', 'python deploy.py compose', []),
 ('deploy',
  'python deploy.py deploy',
  ['HCLOUD_TOKEN', 'CLOUDFLARE_API_TOKEN'])]

source

setup_template

def setup_template(
    app, domain, subdomain:str='', port:int=5001, lfs:bool=True
):

The text of a one-time setup.py that prepares a project for deploying the lego way.

setup_template returns the text of a setup.py for the project, without writing it. Run once after scaffolding, that script records the keys in gheasy’s config, tracks images and audio with Git LFS, writes .env.example with an empty value for each secret, writes the GitHub deploy workflow, and copies each installed library’s SKILL.md for coding agents. Its subcommands push your local values to GitHub (push), leaving out keys with no value so a secret is never set to the text None, upload the server’s SSH key as the DEPLOY_KEY secret (ssh-key), and redo single parts (mkenv, workflow, skills).

ENV_KEYS in the generated file lists every key the script handles, with the domain filled in. It is DEPLOY_KEYS plus SERVER_PASSWORD:

src = setup_template('lego', 'example.com', subdomain='app')
literal(src, 'ENV_KEYS')
{'MODE': 'prod',
 'PORT': 5001,
 'DOMAIN': 'app.example.com',
 'SERVER_NAME': None,
 'SERVER_USER': 'deploy',
 'SERVER_PASSWORD': None,
 'HCLOUD_TOKEN': None,
 'CLOUDFLARE_API_TOKEN': None,
 'CF_TUNNEL_TOKEN': None,
 'RSYNC_FORCE': 'false'}

source

deploy_template

def deploy_template(
    app, domain, subdomain:str='', port:int=5001, second_host:str=''
):

The text of a deploy.py that builds, tunnels and deploys the app; second_host adds another hostname.

deploy_template returns the text of the deploy.py itself. Its subcommands are env, compose and deploy, which the pipeline’s steps call, plus status, which lists the servers and tunnels your tokens can see, rollback, which runs the image from before the last deploy or a kept image you name, and nuke, which deletes the server once you type back a random confirmation word. nuke keeps the Cloudflare tunnel unless you add --tunnel, because the tunnel follows whichever server runs its connector and survives a move to a new box. After a deploy, after_deploy waits until the site answers and purges Cloudflare’s cache for the host; vpseasy’s deploy has already kept the replaced image for rollback and pruned dangling images. The tunnel is named <subdomain or app>_<domain>. second_host adds another hostname served by the same tunnel and container. The file is meant to be edited, and the pipeline runs whatever it says.

dsrc = deploy_template('lego', 'example.com', subdomain='app')
literal(dsrc, 'host'), literal(dsrc, 'tunnel_name'), literal(dsrc, 'inc')[:4]
('app.example.com',
 'app_example.com',
 ['lego/', 'static/', 'pyproject.toml', 'uv.lock'])

source

Deploy

def Deploy(
    root, # the project the commands run in
    python:NoneType=None, # the interpreter whose virtual environment they run in; None uses this process's
    env:NoneType=None, # an `EnvStore`, or anything with `.get(key)` and `.values(keys)`
    dir:str='.pullup', # where the plan is written, relative to `root`
):

The deploy pipeline, one step per subcommand of the project’s deploy.py.

Deploy is the pipeline for all of this. Its default plan is DEPLOY_STEPS, saved to deploy.json in the plan directory.

scaffold sets a project up for deploying. It writes deploy.py, writes setup.py if there is none, adds the deploy keys to .gheasy/config.json, and adds the deploy workflow through Workflows. It needs a domain, and it refuses to replace an existing deploy.py unless you pass force=True. It returns the paths it wrote along with the new state:

d = Deploy(root)
[str(Path(p).relative_to(root)) for p in d.scaffold(domain='example.com', subdomain='app')['written']]
['deploy.py',
 'setup.py',
 '.gheasy/config.json',
 '.github/workflows/deploy.yml']

target reports where the deploy goes and what is still missing, read from the same store the steps will use: the app, domain, server, user and port, the secret keys with no value yet, and which of the generated files exist. A user interface can tell from it whether a project has been scaffolded and what to ask for before the first deploy, and state includes it under target. Each key is also read in lower case, such as server_name, for projects whose .env and GitHub variables predate the upper-case names. The values come from the default store, so they reflect the machine that ran this page.

{k: v for k, v in d.target().items() if k != 'keys'}
{'app': 'lego',
 'domain': 'app.example.com',
 'server': '',
 'user': 'deploy',
 'port': '5001',
 'missing': ['SERVER_NAME',
  'HCLOUD_TOKEN',
  'CLOUDFLARE_API_TOKEN',
  'CF_TUNNEL_TOKEN'],
 'script': True,
 'setup': True,
 'compose': False,
 'dockerfile': False,
 'caddyfile': False}