Metadata-Version: 2.4
Name: datahenge-cairn
Version: 0.1.0
Summary: Wrapper around frappe_docker for reproducible ERPNext image builds and pull-based deploys on a single VPS.
Project-URL: Homepage, https://github.com/Datahenge/cairn
Project-URL: Repository, https://github.com/Datahenge/cairn
Project-URL: Issues, https://github.com/Datahenge/cairn/issues
Project-URL: Changelog, https://github.com/Datahenge/cairn/blob/main/docs/CHANGELOG.md
Author-email: Brian Pond <brian@datahenge.com>
License-Expression: MIT
License-File: LICENSE
Keywords: deployment,devops,docker,erpnext,frappe
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: System :: Installation/Setup
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.11
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: twine>=6.0; extra == 'dev'
Requires-Dist: ventwig<0.3,>=0.2; extra == 'dev'
Description-Content-Type: text/markdown

# cairn

A thin, opinionated wrapper around [`frappe/frappe_docker`](https://github.com/frappe/frappe_docker)
that makes running a custom ERPNext deployment (Frappe + ERPNext + custom apps) on a
single VPS **reproducible, immutable, and low-thought** — without ever modifying
upstream.

Distributed as **`datahenge-cairn`** on PyPI — installs the **`cairn`** command.

Two pillars: **reproducible custom image builds** and a **pull-based deploy lifecycle**
(git ref → image tag → running stack, with image-only rollback). A strict data-plane
boundary keeps cairn out of your databases and volumes entirely — it ships code, not
data.

> _Cairn: a trail marker of stacked stones. Each deploy drops a durable marker
> (ref → resolved commits → image tag → digest) you can navigate back to._

## Status

Working, unreleased. The build and deploy commands are implemented and tested;
`cairn build`, `push`, `images`, `prune`, `doctor`, and `vendor` have been exercised against
real images and a real registry, while the pointer verbs (`new-tag` / `retag` / `retire`) and
the target-side `reconcile` are written and tested but have not yet run against live
infrastructure.

## Two roles, one install

cairn is one tool with two roles, told apart by what's configured on a machine rather than
by what's installed:

| | **Builder** | **Target** |
| --- | --- | --- |
| Does | Builds images, pushes them, moves environment pointers | Polls for its pointer, pulls the image, converges the running stack |
| Reads | `cairn.toml` (the manifest) | `/etc/cairn/environment.toml` (a descriptor, generated by `cairn adopt`) |
| Needs | Docker Engine v23+ or podman v4+, `git` | Docker Engine + `docker compose` |
| Credential | push access to the registry | pull-only |

The same `pip install datahenge-cairn` gives you every command either role needs — a
target simply never has a reason to run the build-only commands, and its pull-only
registry credential means it couldn't push or retag even if asked.

## Installation

**For a machine anything else depends on** — a client's builder or target VPS, not a
laptop you alone operate — install system-wide rather than into your own account:

```
sudo pipx install --global datahenge-cairn
```

This matters specifically if you're a consultant rather than an in-house operator:
`pipx --global` installs to a shared system location (`/opt/pipx` by default), not under
your personal home directory. A plain personal `pip install`/`pipx install` works too and
is fine for a builder you alone use — but a client's production systemd timers shouldn't
depend on your individual account still existing.

```
pip install datahenge-cairn        # fine for a personal/experimental install
```

Either way, that's it for the `cairn` command itself, on either a builder or a target. It
carries everything a build needs, including the vendored image-build recipe.

**To actually build an image**, the machine additionally needs:

- **Docker Engine v23+** (with `buildx`) **or podman v4+** — auto-detected, docker
  preferred when both are present.
- **`git`** — every manifest ref is resolved to a commit at build time.

Run `cairn doctor` after installing to check all of this at once — it reports every
missing prerequisite in one pass rather than failing partway into a long build.

**To run a target** (the machine that actually serves ERPNext), you additionally need
Docker Engine and `docker compose` v2, and a Frappe/ERPNext stack already running via
`frappe_docker` — cairn converges an existing deployment, it doesn't create one from
nothing.

### Provisioning a whole machine

For standing up a fresh builder or target VPS — not just the `cairn` command, but the
registry, TLS certificate, systemd timers, and (for a target) a pre-install backup —
`cairn-provision` installs alongside `cairn` automatically; there's no separate download or
checkout needed. Run it once with root, from the directory that will hold (or already
holds) your deployment's `cairn.toml`:

```
sudo cairn-provision --role builder --dry-run   # see what it would do first
sudo cairn-provision --role builder
```

`--role` is `builder`, `target`, or `both` (one box doing each — the common starting
point). It needs root on **every** role: a builder writes a TLS certificate and trusts it
system-wide, and both roles install systemd units under `/etc/systemd/system`; a target
additionally writes its descriptor under `/etc/cairn`. `--dry-run` prints every command it
would run and writes nothing, so you can review it before handing over root. It is
idempotent — safe to re-run — and never silently overwrites a file it didn't create;
anything it would replace is kept alongside, renamed.

### For development

Contributing to cairn itself, or re-syncing the vendored upstream, needs a checkout:

```
git clone https://github.com/Datahenge/cairn.git
cd cairn
python3 -m venv .venv
.venv/bin/pip install --editable '.[dev]'
```

The `dev` extra adds `ventwig` (vendoring), `ruff`, and `pytest` — none of which a normal
install needs.

## Configuration

### The manifest — `cairn.toml`

One file declares one image: the Frappe source, the ordered list of apps, and build
knobs. It's meant to be committed and shared — it carries no machine- or registry-specific
settings.

```toml
[cairn]
image_name = "erpnext-btu-v16"
series = "v16"                      # the readable half of the image tag

[cairn.registry]
host      = "ghcr.io"
namespace = "your-org"

[cairn.frappe]
url = "https://github.com/frappe/frappe"
ref = "version-16"

# Order matters: apps install in this order, and cairn never reorders or resolves
# dependencies for you. List every app after the apps it depends on.
[[cairn.apps]]
name = "erpnext"
url = "https://github.com/frappe/erpnext"
ref = "version-16"

[[cairn.apps]]
name = "your_custom_app"
url = "https://github.com/your-org/your_custom_app"
ref = "version-16"

[cairn.build]
python_version = "3.14.2"
node_version = "24.13.0"
install_chromium = true

[cairn.environments]
production = "production"
staging    = "staging"
```

cairn discovers `cairn.toml` by walking upward from the current directory, or you can
point at one explicitly with `--manifest`.

### Machine-local settings

Registry coordinates live in the manifest above deliberately — they describe the
*deployment*, not the machine building it. What's genuinely machine-local (which build
engine to use, an alternate transcript directory) lives in two layers instead, lowest
precedence first:

1. `~/.config/cairn/config.toml` — machine-wide defaults.
2. `cairn.local.toml`, beside the manifest, **never committed** — a per-checkout override,
   e.g. to build with `podman` on a laptop that has no Docker daemon.

```toml
# cairn.local.toml
engine = "podman"
```

### Where images are pushed

Which registry you use, and who owns the credential, is worth thinking about deliberately
— especially when you're building images for a client. See
**[ABOUT_REGISTRIES.md](ABOUT_REGISTRIES.md)** for the tradeoffs, and
**[ABOUT_GHCR.md](ABOUT_GHCR.md)** for GitHub's registry specifically. cairn itself is
registry-agnostic and stores no credentials — authenticate with `docker login` or `podman
login` before pushing.

### The target's descriptor

A target doesn't hand-author configuration. Run `cairn adopt` against its running
`frappe_docker` stack, review the descriptor it prints, and install it yourself at
`/etc/cairn/environment.toml`. That descriptor — not the manifest — is what `cairn
reconcile` reads, and its presence is what marks a machine as a target at all.

## How to use

**On a builder:**

```
cairn doctor                 # confirm the machine can actually build
cairn build                  # build the image declared by cairn.toml
cairn build --push           # ...and upload it
cairn images --local         # what's on this machine, and which builds are superseded
cairn prune                  # remove superseded local images (keeps build-cache layers)
```

Moving an environment's pointer — which is how you deploy, promote, or roll back — never
rebuilds or re-pulls anything; it just writes a tag in the registry:

```
cairn new-tag staging --latest       # point staging at the newest build
cairn retag production --from staging --yes   # promote staging's image to production
cairn retag production --previous            # roll back production one image
cairn images                                 # what the registry holds, and which tags point where
```

**On a target:**

```
cairn adopt                     # describe this host's running stack (one-time, or after a manual change)
cairn systemd-units             # print the reconcile service + timer; review, then install them
cairn reconcile --dry-run       # see what would change
cairn reconcile                 # converge to whatever the environment's pointer says
```

`reconcile` is idempotent and meant to run on a timer — with nothing to do, it does
nothing. It never rolls back on failure; it stops and reports, because a failed `bench
migrate` is not something to silently reverse.

## Where your images live

cairn builds an image and puts it in a container registry; your deployment targets pull from
there. cairn is registry-agnostic and assumes nothing — but **which** registry is not a neutral
choice when you build software for clients:

📦 **[ABOUT_REGISTRIES.md](ABOUT_REGISTRIES.md)** — start here. The image belongs in the account
that owns the source; your credential should reach the engagement's images and nothing else; and
what each option costs at ERPNext image sizes. Includes what to ask a client for.

🐙 **[ABOUT_GHCR.md](ABOUT_GHCR.md)** — GitHub's registry in detail: tokens, scopes, how narrow
access can be, visibility, and the deletion rule that is genuinely surprising. One option among
several, not the default.

> **You should never be the sole owner of a client's image.** If the relationship ends, they must
> still be able to deploy and roll back software they own. cairn is built so the registry can be
> an account you do not control, and so your push credential can be scoped to one repository.

## Design docs

| Doc | Contents |
| --- | --- |
| [docs/00-project-scope.md](docs/00-project-scope.md) | Purpose, pillars, what it is/isn't, principles |
| [docs/01-decisions-closed.md](docs/01-decisions-closed.md) | Settled decisions with rationale |
| [docs/02-decisions-open.md](docs/02-decisions-open.md) | Unresolved questions + current leans |
| [docs/03-discussion-log.md](docs/03-discussion-log.md) | Chronological design reasoning |

## Vendored upstream

`src/cairn/vendored/frappe_docker/` is a pinned, read-only copy of upstream, vendored as
plain committed files via [`ventwig`](https://github.com/brian-pond/ventwig) and never
edited by hand. It lives inside the `cairn` package itself, so it's part of every install
— pip or checkout alike. It's pinned to upstream release tag **`v3.2.1`**.

```
cairn vendor status   # verify the vendored tree is clean / unmodified
cairn vendor sync      # re-materialize from the pinned ref (a checkout only)
```

To upgrade upstream: bump `ref` in `pyproject.toml`, `cairn vendor sync`, review the diff,
test-build, then commit — including the regenerated `frappe_docker.pin.toml` alongside it.
