Metadata-Version: 2.4
Name: stario-orbit
Version: 0.2.0
Summary: A small remote deployment runner: rsync a Git work tree to a named slot, then run your hook.
Keywords: deploy,caddy,ssh,rsync,self-hosted,stario
Author: Adam Bobowski
Author-email: Adam Bobowski <adam.bobowski@wratilabs.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.14
Project-URL: Changelog, https://github.com/bobowski/stario-orbit/blob/main/CHANGELOG.md
Project-URL: Documentation, https://stario.dev/orbit
Project-URL: Homepage, https://stario.dev/orbit
Project-URL: Issues, https://github.com/bobowski/stario-orbit/issues
Project-URL: Repository, https://github.com/bobowski/stario-orbit
Description-Content-Type: text/markdown

<p align="center">
  <img alt="Orbit" src="https://raw.githubusercontent.com/bobowski/stario-orbit/main/assets/logo-tug-512.png" height="160">
</p>

# Orbit

Orbit is a small remote deployment runner. It adds a repeatable structure to
the familiar `rsync` plus remote shell script approach.

Guides and reference: [stario.dev/orbit](https://stario.dev/orbit)

When you run `orbit deploy`, Orbit:

1. Selects the tracked and non-ignored files in your local Git work tree.
2. Mirrors those files over SSH into a named slot on a host you control.
3. Runs `ops/deploy` from your repository on that host.

That is the deployment contract. Orbit does not decide how to build, run, or
reload your application. Your script contains those steps.

The Orbit CLI is written in Python, but the application does not have to be.
The remote contract uses Bash. You can deploy a Python service, a Go binary, a
static site, or another application. You can run it with Podman, Docker,
systemd, or your own process manager.

Orbit provides the convention around those steps:

- named deployment slots on one or more SSH hosts
- an exact synchronized source tree for each slot
- separate paths for source, durable data, environment values, and runtime files
- repository-owned deploy, stop, and status hooks
- environment management, status checks, removal, and optional preview expiry

You keep the deployment decisions in short scripts beside the application.
There is no remote Orbit daemon, hidden build pipeline, telemetry service, or
MCP service to understand.

A slot is one named deployment target under `/srv/orbit/slots/`. It keeps the
synchronized source, environment values, durable data, and runtime files in
separate paths.

```sh
orbit init
orbit setup ship --email you@example.com
orbit create ship app.example.com
orbit env ship app.example.com --from .env.production
orbit deploy ship app.example.com
orbit status ship
```

`ship` is the SSH destination. In these examples it is a `Host` alias
from `~/.ssh/config`. Orbit passes that value to `ssh` and `rsync`.
You can also pass `user@hostname` or a hostname. See [Commands](#commands)
for the shapes.

After a successful `create` or `deploy`, Orbit stores the git remote + branch →
host + slot mapping in `~/.config/orbit/config.json` (or `$XDG_CONFIG_HOME`).
Later you can run `orbit deploy` with no host/slot and Orbit uses that mapping.

## Requirements

Local:

- Python 3.14 or newer
- Git
- OpenSSH
- rsync

Ubuntu host:

- bash
- OpenSSH
- rsync
- a user systemd manager (for optional TTL expiry)
- Caddy with a system service
- `sudo` access during setup

## Install

The PyPI name is `stario-orbit`. The command and the Python import stay
`orbit`. `pip install orbit` installs a different, unrelated package.

```sh
uv tool install stario-orbit
# or: uv add --dev stario-orbit
# or: pip install stario-orbit
orbit --help
```

From a checkout:

```sh
uv sync
uv run orbit --help
```

## Host setup

`orbit setup` opens an SSH session and runs a script that uses `sudo`.
Use a dedicated Ubuntu host, or a disposable one. Do not run setup on a
machine that already serves sites you need to keep.

The current host setup is opinionated about the slot layout and Caddy
integration. It is not opinionated about the application runtime. Deployment
hooks can use the prepared Caddy paths, or ignore them and manage the
application with another runtime or service configuration.

Setup does this:

- Creates `/srv/orbit` and the system group `orbit`
- Adds the deploy user and the `caddy` user to that group
- Writes `/srv/orbit/Caddyfile` (ACME email + slot imports)
- Installs a systemd drop-in so the **system Caddy service** loads only
  that file — not `/etc/caddy/Caddyfile`
- Restarts Caddy
- Enables user lingering for TTL expiry
- Checks that the deploy user can reload Caddy

Existing Caddy sites on that host stop unless you move them into Orbit
slots. Re-running setup repairs permissions from older Orbit versions and
writes the drop-in again.

Pass `--email`, or setup prompts for it. Setup uses a TTY so `sudo` can
prompt. Open a new SSH session after setup so your shell sees group
`orbit`. Later SSH and rsync calls use `BatchMode=yes`.

Anyone who can SSH as the deploy user can read slot environment files
and run hooks. See [SECURITY.md](SECURITY.md).

## Commands

```text
orbit setup <host> [--email you@example.com]
orbit init [--recipe NAME] [--force] [--list]
orbit create [<host> <slot>] [--ttl 6h]
orbit create <host> {}.example.com [--ttl 6h]
orbit deploy [<host> <slot>] [--dry-run] [--force]
orbit status [<host> [<slot>]]
orbit env [<host> <slot>] [--set KEY=VALUE | --from FILE | --edit]
orbit remove [<host> <slot>] [--purge] [--yes]
```

Provide **both** host and slot, or **neither**. Neither means: read the mapping
for this repository and current branch from your user config. When `origin`
is configured, Orbit keys config by that remote URL; otherwise by the repo
path on disk.
A single argument is only valid for `status <host>` (list every slot on that
host).

`<host>` is the SSH destination. Orbit does not parse it further. Typical
shapes:

```text
ship                      Host alias in ~/.ssh/config (recommended)
deploy@host.example.com   user and hostname
deploy@203.0.113.10       user and address
host.example.com          hostname (OpenSSH default user)
```

A `Host` alias keeps the real address and user in SSH config:

```text
Host ship
    HostName host.example.com
    User deploy
```

The value must not contain `/` or begin with `-`. The remote `/srv/orbit`
tree owns the slots. Host aliases are personal, so the mapping lives in
your user config — not in the repository.

`create` accepts one `{}` in the slot name. Orbit replaces it with a short
readable token such as `calm-otter`. Generated names treat any remnant slot
tree as taken, so a non-purge remove does not hand durable `data` or `env` to
the next preview. An explicit create of the same named slot may reuse retained
`data` and `env` when `src` is gone.

`--ttl` schedules an automatic purge on the host after a duration such as
`30m`, `6h`, or `2d` (maximum `30d`). Manual `remove` cancels that timer.
Transient TTL timers do not survive a host reboot. Orbit exports `SLOT_TTL` to
hooks; preview basic auth is optional and belongs in `ops/deploy` (see
[`examples/podman-slot`](examples/podman-slot)).

## First deployment

`orbit deploy` uploads the Git-selected files for a clean work tree —
the same tree as `HEAD`. Local modifications and untracked files stop
the deploy unless you pass `--force`. A local `.env` that is not in
`.gitignore` is included only with `--force`. Put application secrets
in the slot environment with `orbit env`, not in the repository tree.

```sh
orbit init
git add ops && git commit -m 'Add Orbit hooks'
orbit setup ship --email you@example.com
orbit create ship app.example.com
orbit env ship app.example.com --from .env.production
orbit deploy ship app.example.com
orbit status ship
# later, on the same branch:
orbit deploy
```

`orbit init` walks bundled recipes and writes `ops/` for the first match
(Stario, then a Python HTTP app, then static files). Pass `--recipe` to
choose. `orbit deploy` uploads a clean Git revision. Pass `--force` to
include uncommitted files.

Preview with a generated name and automatic cleanup:

```sh
orbit create --ttl 6h ship {}.example.com
# created ship calm-otter.example.com
# expires after 6h
orbit deploy ship calm-otter.example.com
# example ops/deploy may print preview basic auth on first deploy
```

## User config

Path: `~/.config/orbit/config.json` (or `$XDG_CONFIG_HOME/orbit/config.json`).

Orbit keys entries by a normalized git remote URL and branch name:

```json
{
  "version": 1,
  "remotes": {
    "github.com/acme/app": {
      "branches": {
        "main": {
          "host": "ship",
          "slot": "app.example.com"
        },
        "preview": {
          "host": "ship",
          "slot": "calm-otter.example.com",
          "expires_at": 1773500000
        }
      }
    }
  }
}
```

Successful `create` and `deploy` create or update that entry and print a short
note. TTL creates also store `expires_at`; Orbit removes those entries once the
time passes (on the next config read). Detached HEAD has no branch mapping —
pass host and slot explicitly.

`deploy` needs a resolved host and slot. It works with a detached HEAD when you
pass them explicitly. It still requires a Git work tree because Orbit gets the
upload list from Git. Orbit always copies the complete selected Git work tree
into the slot `src/` directory, including tracked modifications and non-ignored
untracked files.

On a TTY, Orbit prints local status in cyan and streams remote hook output as
dim lines. When deploy finishes, it reports success or failure with the elapsed
time.

## Host layout

```text
/srv/orbit/
  Caddyfile
  slots/                 group orbit, mode 2750
  slots/<slot>/
    src/      synchronized repository, deploy user only
    data/     durable application data, deploy user only
    env       application environment, mode 0600
    run/      group orbit, sockets and generated Caddy configuration
```

Group `orbit` is shared by the deploy user and Caddy only for path traversal and
the `run/` tree. Caddy cannot read or change synchronized source, durable data,
or the environment file. Default ACLs on `run/` keep new listen sockets
reachable by Caddy.

The main Caddyfile is only:

```caddy
{
	email you@example.com
}
import /srv/orbit/slots/*/run/Caddyfile
```

Environment changes use an atomic same-directory replacement and set mode
`0600`. Removing a slot keeps its data and environment. `remove --purge`
deletes the whole slot tree.

## Synchronization

Orbit finds the Git root and selects tracked files and non-ignored untracked
files with `git ls-files`. It builds an exact local staging tree and mirrors it
into `$SLOT_SRC` with rsync. Files that are absent from the local Git selection
are deleted from the remote source tree.

The rsync operation uses delayed updates, but it is not a transactional release
swap. A failed or interrupted transfer can leave a mixed source tree. Do not
start concurrent deploy or remove operations for one slot.

Persistent data, secrets, and runtime files are outside the synchronized tree.

## Repository hooks

Hooks run from `$SLOT_SRC`. They are non-interactive and receive no arguments.

```text
ops/deploy     required
ops/status     optional
ops/remove     optional
```

Orbit runs each file with `/bin/bash`. The files do not need a `.sh`
suffix or the execute bit.

`orbit init` writes these hooks from a recipe. A ready-to-adapt Podman
blue/green example also lives in
[`examples/podman-slot`](examples/podman-slot).

Orbit does not inspect these scripts or infer deployment steps from the
application language. Write the commands that your application needs. A
deploy hook can, for example:

- build and replace a Podman or Docker container
- compile a Go binary and restart a systemd service
- create a Python virtual environment, install dependencies, and reload a process
- copy static files into place
- run database migrations and health checks

The script must exit with `0` when deployment succeeds and with a non-zero
status when it fails. Make it idempotent so the same source and environment can
be deployed again safely.

Orbit supplies:

```text
ORBIT_ROOT
ORBIT_CADDY
ORBIT_PURGE

SLOT_NAME
SLOT_ROOT
SLOT_SRC
SLOT_DATA
SLOT_ENV
SLOT_RUN
SLOT_TTL
SLOT_DEPLOY_ID
SLOT_GIT_REVISION
```

`SLOT_NAME` is the concrete slot address after `{}` expansion. `SLOT_TTL` is the
configured lifetime in seconds, or `0` when the slot has no expiry.
`SLOT_DEPLOY_ID` uniquely identifies this deploy attempt. `SLOT_GIT_REVISION`
is the local `HEAD` commit and is informational when the work tree is dirty.

`ORBIT_PURGE` is set only for `ops/remove`. It is `1` when Orbit will
delete durable `data` and `env` (`remove --purge` or TTL expiry). It is
`0` when Orbit keeps those paths and only deletes `src` and `run`. Use it
when the hook must decide whether to wipe application-owned durable state
before Orbit deletes the files.

Orbit streams the output from `ops/deploy` and returns its exit status.
For an HTTP application that uses the prepared Caddy service, the hook
can write `$SLOT_RUN/Caddyfile`, switch a stable socket, and reload
Caddy. These are deployment-script choices, not requirements of the
runner.

`ops/remove` stops the application and removes its runtime files. A
failure stops slot removal. When `ops/remove` is missing, Orbit still
deletes `src` and `run` (and the whole tree on purge), but prints
warnings that containers, systemd units, or Caddy routes may remain
active.

`ops/status` prints one short status line. Exit `0` means healthy. Exit
`1` means stopped or unhealthy. Exit `2` or greater means that the check
failed. A missing script reports `unknown`.

Short actions such as create, status, list, environment updates, deploy, and
remove run while SSH remains connected. TTL expiry uses a transient user
systemd timer. Multiple slots can have independent timers.

## What Orbit is not

Orbit is not a Kubernetes platform, a PaaS, a CI service, or a daemon on the
host. It does not detect the application stack, generate a deployment plan,
observe production, or expose MCP.

The contract is the synchronized slot, the supplied environment variables, and
your shell hooks. A Unix socket and Caddy are one supported pattern, not the
application contract. The Podman example uses that pattern and defaults to
Stario, but Orbit itself does not require Stario or Python applications.

## Disposable Ubuntu integration test

The integration test changes Caddy service configuration and creates
`/srv/orbit`. Use only a disposable Ubuntu host where this is acceptable. The
test creates a unique disposable slot and removes it with `--purge`.

The test has no default host. Both environment variables are required:

```sh
ORBIT_INTEGRATION_SSH=user@disposable-host \
ORBIT_INTEGRATION_DISPOSABLE=YES \
uv run pytest tests/test_integration_ubuntu.py -v -s
```

The SSH user must have interactive `sudo` access for setup. Normal test runs
skip this test.

## Releases

Version history lives in [`CHANGELOG.md`](CHANGELOG.md).

## Contributing

From this directory:

```sh
uv sync
uv run ruff check .
uv run ruff format --check .
uv run pyright
uv run pytest
```

Before committing:

```sh
uv run ruff check . --fix
uv run ruff format .
```
