Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/adr/0000-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# NNNN. Title

- Status: Proposed | Accepted | Deprecated | Superseded by ADR-NNNN
- Date: YYYY-MM-DD

## Context

What is the problem, force, or constraint that motivates a decision? Describe the
situation as neutrally as possible — facts, requirements, and the options on the
table. A reader should be able to understand the tension without already knowing
the answer.

## Decision

The change we are making, stated in the active voice: "We will …". Be specific
enough that someone can act on it.

## Consequences

What becomes easier and what becomes harder as a result. Include the trade-offs
we accept, the things we explicitly rule out, and any follow-up work this
implies.
40 changes: 40 additions & 0 deletions docs/adr/0001-record-architecture-decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 0001. Record architecture decisions

- Status: Accepted
- Date: 2026-06-02

## Context

Lunar is a self-hosted FaaS platform with a number of deliberate, non-obvious
architectural choices: configuration through the environment, a build-step-free
Mithril frontend, no Node.js in the toolchain, `mise` for tasks, `uber/fx` for
wiring, a single embedded binary, and pure-Go SQLite. These decisions are easy
to misread as accidents when only the resulting code is visible. New
contributors (and our future selves) repeatedly ask "why is it done this way?",
and without a record the answer lives only in memory and scattered commit
messages.

We want a durable, low-ceremony way to capture the *reasoning* behind decisions
that shape the codebase, separate from the code that implements them and from the
user-facing README.

## Decision

We will keep Architecture Decision Records as Markdown files under `docs/adr/`,
one decision per numbered file, following the lightweight format in
[`0000-template.md`](0000-template.md) (Status, Context, Decision,
Consequences).

ADRs are append-only. Once a record is `Accepted` we do not edit its substance;
a decision that changes is recorded as a new ADR that supersedes the old one,
and the old one's status is updated to point at its replacement.

## Consequences

- The rationale behind a choice travels with the repository and is versioned
alongside the code, reviewable in the same pull requests.
- There is a small, well-understood cost to writing a record when a decision is
made. We accept this as cheaper than re-litigating decisions later.
- Reviewers gain a natural place to push back on direction before it is encoded
in the codebase.
- The README stays focused on *using* Lunar; the ADRs explain *building* it.
55 changes: 55 additions & 0 deletions docs/adr/0002-configuration-via-environment-variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 0002. Configuration via environment variables

- Status: Accepted
- Date: 2026-06-02

## Context

Lunar ships as a single self-hosted binary that people run on their own
machines, in Docker, and on platforms like Railway. These environments differ in
how they inject settings, but every one of them can set environment variables.
We need a configuration mechanism that:

- works identically across local, Docker, and PaaS deployments;
- requires no config file to exist for a first run (good defaults);
- keeps secrets such as the API key out of the source tree;
- is straightforward to test without mutating global process state.

The realistic alternatives were a config file format (YAML/TOML), command-line
flags, or environment variables. Config files add a parsing layer and a "where
does it live" question for a single-binary tool. Flags are awkward to thread
through container platforms and don't compose well with secret managers.

## Decision

We will load all runtime configuration from the process environment, following
the [12-factor](https://12factor.net/config) approach, and bind it to a typed
`config.Config` struct using [`caarlos0/env`](https://github.com/caarlos0/env)
struct tags (`env:"..."`, `envDefault:"..."`).

Configuration lives in its own `internal/config` package (not in `cmd`) so that
the per-feature `fx` modules can depend on it directly — see
[ADR-0006](0006-dependency-injection-with-fx.md).

Concerns that a struct tag can't express are handled in code right after parsing:
a custom parser for `EXECUTION_TIMEOUT` (an integer count of seconds), a
computed default for `BASE_URL` (`http://localhost:<PORT>`), creation of the data
directory, and an API-key fallback chain of env var → on-disk file → freshly
generated key.

`config.parse` accepts an explicit environment map so loading can be unit-tested
without touching `os.Environ`.

## Consequences

- The same binary configures itself the same way everywhere; deployment docs are
just a list of variables with defaults.
- A fresh run works with zero configuration — sensible defaults plus a
self-generated, persisted API key.
- Secrets are supplied at runtime and never committed.
- Standardising on `caarlos0/env` keeps loading declarative; the few exceptions
are localised and documented in the package doc comment.
- Trade-off: deeply nested or list-of-object configuration is clumsy as flat
environment variables. This is acceptable given Lunar's small, flat config
surface; if that changes we will revisit with a new ADR rather than bolt on a
file format ad hoc.
46 changes: 46 additions & 0 deletions docs/adr/0003-frontend-with-mithril.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 0003. Frontend with Mithril.js

- Status: Accepted
- Date: 2026-06-02

## Context

Lunar's dashboard is a genuine single-page application: a code editor (Monaco),
routing across functions/versions/executions/logs, i18n, a command palette, and
many reusable components. We need a client-side framework, but the project has
two strong constraints that rule out the mainstream React/Vue/Svelte path:

- **No Node.js toolchain** (see [ADR-0004](0004-no-nodejs-vendored-js.md)). That
removes JSX/TSX, bundlers, and the npm-based ecosystem those frameworks assume.
- **Ship inside a single Go binary** (see
[ADR-0007](0007-single-binary-embedded-frontend.md)). The frontend has to be a
set of static files we can `go:embed`, with no build artifact pipeline.

We need a framework that is small, works as a single `<script>` with no build
step, has a built-in router and XHR layer, and renders via plain function calls
rather than a compiler-dependent template syntax.

## Decision

We will build the dashboard with [Mithril.js](https://mithril.js.org/), vendored
as a single minified file and loaded via a `<script>` tag in `index.html`.

Views are authored as plain ES modules using Mithril's hyperscript (`m(...)`)
API, organised under `frontend/js` into `components/`, `views/`, `routes.js`,
`api.js`, and an `i18n/` layer. No JSX, no transpilation: the `.js` files we
write are the `.js` files the browser runs.

## Consequences

- The frontend has zero build step. Editing a `.js` file and reloading is the
whole dev loop; the embedded files are exactly what we authored.
- Mithril is tiny and batteries-included (routing + `m.request` for XHR), so we
avoid pulling a constellation of micro-dependencies to fill gaps.
- Hyperscript instead of JSX means component code is a little more verbose and
there's no template-level type checking. We accept this; it's the cost of
having no compiler.
- We pin the Mithril version in `mise.toml` and vendor it via the `vendor-js`
task, so upgrades are explicit and reproducible.
- Trade-off: we step outside the dominant React ecosystem, so off-the-shelf
component libraries don't apply. In return we keep the whole frontend
inspectable, buildless, and embeddable.
47 changes: 47 additions & 0 deletions docs/adr/0004-no-nodejs-vendored-js.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 0004. No Node.js: vendored JS, Deno for tooling

- Status: Accepted
- Date: 2026-06-02

## Context

Lunar is a Go project that happens to have a rich browser frontend. The default
path for that frontend would drag in the Node.js world: a `package.json`, an
`node_modules/` tree of transitive dependencies, a lockfile, and a bundler. For a
small self-hosted tool that wants to stay auditable and ship as one Go binary,
that ecosystem brings real costs — supply-chain surface area, churn, a second
language runtime in every contributor's environment and in CI, and a build step
between the source and what runs.

At the same time we still want *some* JS tooling: a formatter for the frontend
code, and a way to run the browser test suite.

## Decision

We will not use Node.js, npm, or a bundler anywhere in the project.

Browser dependencies (Mithril, Monaco, highlight.js, Jasmine) are **vendored**
into `frontend/vendor/` by the `vendor-js` `mise` task, which downloads pinned
versions straight from CDNs/registries (`unpkg`, `cdnjs`, the npm registry
tarball for Monaco) using `curl`/`tar`. Versions are pinned as variables in
`mise.toml`, so vendoring is reproducible and upgrades are a deliberate diff.

For tooling that genuinely needs a JS runtime we use [Deno](https://deno.com/)
(itself installed via `mise`): `deno fmt` formats the frontend, excluding the
vendored tree. The frontend test suite runs through a small Go `testserver`
binary that serves Jasmine in a browser — no Node test runner involved.

## Consequences

- There is no `node_modules/`, no `package.json`, and no lockfile to manage or
audit; the vendored files in git *are* the dependency manifest.
- Every dependency is a concrete, reviewable file at a pinned version. Upgrades
show up as explicit diffs from re-running `vendor-js`.
- Contributors need only the tools `mise` installs (Go, Deno, etc.); there's no
separate Node version to match.
- The vendored files are committed, which adds some weight to the repository. We
accept this in exchange for reproducibility and a buildless frontend.
- Trade-off: we forgo npm's convenience and the framework ecosystems that assume
a bundler. This is the deliberate counterpart to
[ADR-0003](0003-frontend-with-mithril.md) and
[ADR-0007](0007-single-binary-embedded-frontend.md).
47 changes: 47 additions & 0 deletions docs/adr/0005-mise-for-toolchain-and-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 0005. mise for toolchain and task management

- Status: Accepted
- Date: 2026-06-02

## Context

The project depends on several pinned tools — Go, golangci-lint, Deno (see
[ADR-0004](0004-no-nodejs-vendored-js.md)), GoReleaser, and Air — and on a
collection of repeatable commands: build server/CLI, run tests at several levels,
lint, format, vendor JS, run dev mode, tag releases. Previously this lived in a
`Makefile` plus prose instructions for installing tools, which drifts: a
contributor's locally-installed Go or linter rarely matches CI, and "how do I run
X" lives outside version control.

We want one declarative file that both **pins the toolchain** (so every machine
and CI run uses identical versions) and **defines the tasks**, replacing the
"install these tools yourself + Makefile" split.

## Decision

We will use [`mise`](https://mise.jdx.dev/) as the single entry point for both
the toolchain and project tasks, configured in `mise.toml`.

- `[tools]` pins exact versions of Go, golangci-lint, Deno, GoReleaser, and Air;
`mise install` provisions them and `mise up` bumps them.
- `[env]` holds shared variables, including the pinned frontend dependency
versions consumed by the `vendor-js` task.
- `[tasks.*]` defines every project command (`build`, `test`, `test-e2e`,
`lint`, `fmt-frontend`, `run`, `dev`, `vendor-js`, `tag`, …) with `depends`,
`sources`/`outputs` for incremental runs, and `usage` for arguments.

CI and contributors invoke work through `mise run <task>` rather than ad-hoc
commands or a Makefile.

## Consequences

- Local and CI environments use byte-identical tool versions; "works on my
machine" toolchain drift largely disappears.
- Onboarding is `mise install` followed by `mise run <task>`; the task list is
self-documenting via each task's `description`.
- Tasks and tool versions live in one version-controlled file, reviewed like any
other change.
- `sources`/`outputs` give make-style incremental builds without a Makefile.
- Trade-off: contributors must install `mise` first, and we depend on a
relatively young tool. We judge the reproducibility win worth it, and tasks
remain plain shell that could be lifted out if needed.
44 changes: 44 additions & 0 deletions docs/adr/0006-dependency-injection-with-fx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 0006. Dependency injection with uber/fx

- Status: Accepted
- Date: 2026-06-02

## Context

Lunar is composed of many cooperating subsystems — the Lua runner, the engine,
the API layer, housekeeping, and a set of built-in services (KV, logger, env,
HTTP, AI, email). These have a non-trivial dependency graph and a real lifecycle:
the database must open before services that use it, the HTTP server must start
after handlers are registered, and everything must shut down in the right order.

Wiring this by hand in `cmd` means a large, brittle constructor-call sequence
where ordering is implicit and adding a dependency ripples through the call
chain. We wanted construction and lifecycle to be declared locally by each
subsystem rather than centralised and manually ordered.

## Decision

We will use [`uber/fx`](https://github.com/uber-go/fx) as the dependency
injection and lifecycle framework. Each subsystem exposes an `fx.Module` (e.g.
`internal/api/module.go`, `internal/runner/module.go`, the `internal/services/*`
modules) that provides its own constructors and registers any
`fx.Lifecycle` start/stop hooks. `cmd/app.go` assembles the application by
composing these modules in `fx.New`.

Configuration is provided into the graph as the `config.Config` value (see
[ADR-0002](0002-configuration-via-environment-variables.md)), so modules depend
on settings by type rather than reaching for globals.

## Consequences

- Each subsystem owns its construction and lifecycle locally; `cmd` just lists
the modules to include.
- Start/stop ordering is derived from the dependency graph instead of maintained
by hand, which removes a common class of shutdown bugs.
- Adding a dependency is "ask for it in a constructor signature" rather than
threading it through intermediate calls.
- Trade-off: `fx` introduces runtime (rather than compile-time) wiring, a graph
that's resolved via reflection, and a learning curve for contributors new to
it. For an application of this size with real lifecycle needs we judge the
structure worth the indirection. This decision was implemented in commit
`fc1c3e8` ("build the dependency graph with uber/fx").
41 changes: 41 additions & 0 deletions docs/adr/0007-single-binary-embedded-frontend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 0007. Single binary with embedded frontend

- Status: Accepted
- Date: 2026-06-02

## Context

Lunar's value proposition includes being lightweight and self-hosted: "single
binary, no external dependencies". A FaaS dashboard, though, is made of static
assets — HTML, CSS, JS, the Monaco editor, vendored libraries. If those assets
ship separately from the binary, operators must deploy and path-configure a web
root, versions can skew between binary and assets, and "just run the binary"
stops being true.

We need the compiled server and its frontend to be one indivisible artifact.

## Decision

We will embed the entire frontend into the Go binary using `go:embed`. The
`frontend` package embeds `css`, `js`, `vendor`, `index.html`, and `llms.txt`
into an `embed.FS` and exposes a `Handler()` that serves them via
`http.FileServer`.

This is the constraint that drives the buildless, vendored frontend: because the
embedded files must be the literal files we ship, there can be no bundler output
step (see [ADR-0003](0003-frontend-with-mithril.md) and
[ADR-0004](0004-no-nodejs-vendored-js.md)). Combined with pure-Go SQLite (see
[ADR-0008](0008-sqlite-as-the-datastore.md)), the result is a CGo-free,
dependency-free single binary, released via GoReleaser.

## Consequences

- Deployment is copying one binary; there is no asset directory to manage and no
binary/frontend version skew.
- The frontend is served straight from memory with no filesystem layout
assumptions.
- Builds are reproducible and the release artifact is self-contained.
- Trade-off: changing a frontend file requires recompiling to embed it for a
production build (dev mode via Air rebuilds automatically, so the day-to-day
loop is unaffected). The binary also carries the weight of all assets,
including Monaco — acceptable for a self-hosted dashboard.
41 changes: 41 additions & 0 deletions docs/adr/0008-sqlite-as-the-datastore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 0008. Pure-Go SQLite as the datastore

- Status: Accepted
- Date: 2026-06-02

## Context

Lunar stores functions, versions, execution history, logs, KV data, and related
state. As a self-hosted, single-binary tool (see
[ADR-0007](0007-single-binary-embedded-frontend.md)), requiring operators to
stand up and connect a separate database server (Postgres, MySQL) would
contradict the "no external dependencies, just run the binary" promise. We need
durable, transactional, SQL-capable storage that lives in the same process and on
the local filesystem.

The classic embedded choice is SQLite, but the most common Go driver
(`mattn/go-sqlite3`) requires CGo. CGo complicates cross-compilation, slows
builds, and undercuts the goal of a clean, portable single binary.

## Decision

We will use SQLite as the datastore, via the **pure-Go**
[`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) driver — no CGo.
The database is a file under the configured `DATA_DIR`, and we run it in
**WAL** mode for better read/write concurrency under the server's workload.

## Consequences

- Storage is embedded: no database server to provision, secure, or back up
separately — backups are file copies.
- Staying CGo-free keeps cross-compilation simple and builds fast, preserving the
portable single-binary story end to end.
- WAL mode lets readers proceed concurrently with a writer, which suits the
dashboard-plus-execution workload. (WAL behaviour was tightened in commit
`ba3e33a`.)
- Trade-off: SQLite is single-writer and local to one host, so this design does
not target multi-node horizontal scaling. That is consistent with Lunar's
self-hosted, lightweight positioning; a different topology would warrant a new
ADR.
- The pure-Go driver historically trails the C library slightly on raw
performance, which is an acceptable price for portability at this scale.
Loading
Loading