Metadata-Version: 2.4
Name: stario-orbit
Version: 0.1.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 the `ops/deploy.sh` script 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 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 create [<host> <slot>] [--ttl 6h]
orbit create <host> {}.example.com [--ttl 6h]
orbit deploy [<host> <slot>] [--dry-run]
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's `origin` remote and current branch from your user config.
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.sh` (see
[`examples/podman-slot`](examples/podman-slot)).

## First deployment

`orbit deploy` uploads every Git-selected file: tracked files, local
modifications, and untracked files that Git does not ignore. A local
`.env` that is not in `.gitignore` goes into the slot `src/` directory.
Put application secrets in the slot environment with `orbit env`, not
in the repository tree.

```sh
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
```

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 deploy.sh 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.sh     required and executable
ops/down.sh       optional
ops/status.sh     optional
```

A ready-to-adapt Podman blue/green example 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.sh` 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 `down.sh` during 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 `deploy.sh` 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.

`down.sh` stops the application and removes its runtime files. A failure stops
slot removal. When `ops/down.sh` 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.

`status.sh` 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 .
```
