deeplo
Internals

Development

Project structure, coding conventions, testing, and release process for contributors.

Project structure

cmd/
  deeplo/   main.go — single binary entry point (daemon + CLI)

internal/
  bootstrap/      Bootstrap env config, config source resolution
  build/          Build-time version string and shared logger
  cli/            CLI command implementations (cobra commands)
  compose/        Docker Compose executor: upload, preflight, deploy, ps
  config/         YAML config types, loader, defaults, validation
  daemon/         Runtime wiring for the long-running daemon
  engine/         Deploy orchestration and event handling
  gitrepo/        Git mirror management, file extraction, SSH env helpers
  planner/        Maps repo events to (project, host) deploy targets
  poller/         Per-repo goroutines that poll git remotes
  runner/         Worker pool with global + per-host concurrency limits
  runlog/         Per-deploy plain-text run logs and HTTP serving
  state/          File-backed state: JSON snapshots, deployment records
  transport/      Transport interface
    ssh/          SSH client: upload files, run commands
  util/           Small shared helpers
  webhook/
    github/       HMAC validation, push event parsing

internal/testutil/ In-process SSH server for integration tests
examples/
  config/         Annotated example config
website/          Fumadocs documentation site

Local development

Prerequisites: Go 1.25+, Git, Make

make build     # compile deeplo binary into bin/
make test      # run all tests
make vet       # go vet
make fmt       # go fmt
make check     # build + validate examples/config/example.yml

Build with version string:

make build VERSION=v1.2.3

Run integration tests (requires Docker):

make integration-test

Integration tests start a real Docker daemon and test the compose executor end-to-end.


Testing

Tests use the standard library testing package. No test framework.

Conventions:

  • Table-driven tests: []struct{ name string; ... } with t.Run(tc.name, ...)
  • Temp directories: t.TempDir() — cleaned up automatically
  • No global state: functions receive their dependencies explicitly
  • Avoid time.Sleep in tests where possible; prefer channel-based synchronisation or real timeouts via select

Mocking: external dependencies (SSH, git) are abstracted behind interfaces or function types that tests replace with lightweight fakes. See poller_test.go for examples of injecting fake LSRemoteFunc and OpenIfExistsFunc.

Integration tests live alongside unit tests but are gated behind the integration build tag:

go test -tags integration ./internal/compose/...

Architecture principles

Interfaces near consumers, not implementations. The transport package defines the Connection interface; transport/ssh implements it. The poller package defines RepoOpener; gitrepo.Repo satisfies it. This keeps packages decoupled and testable.

No import cycles. transport and state must not import config. Use local adapter types (e.g. transport.DialConfig) to break potential cycles.

No global state. Loggers and configs are passed explicitly. No init() side effects.

Atomic writes. State files are written to a temp file and then renamed. Never written directly. This prevents partial writes from corrupting state.

Panic on unrecoverable conditions. crypto/rand unavailability panics rather than silently producing weak IDs. Reserve panic for conditions that indicate a broken environment, not runtime errors.


Adding a new feature

  1. Read the relevant packages first. Understand the existing abstractions before adding new ones.

  2. Add to existing packages where appropriate. Don't create a new package for one function.

  3. Keep config changes backward-compatible where possible. New fields should have sensible defaults applied in applyDefaults().

  4. Add validation for new config fields in validate.go. Follow the existing pattern: collect all issues, separate errors from warnings.

  5. Write table-driven tests for any logic that has multiple code paths.

  6. Document new config fields in the docs site.


Release

make release    # cross-compile: linux/darwin × amd64/arm64

Outputs land in bin/ named deeplo_<os>_<arch>.

The Docker image uses a multi-stage build:

  • Builder: golang:1.25-alpine — compiles the binary
  • Final: alpine:3 — adds git, openssh-client, and ca-certificates; default command is deeplo daemon
docker build -t deeplo:latest .

Known limitations

  • No rollback command. Push a prior commit to trigger a redeploy with the old files.

  • No history pruning. history/runs/ accumulates one JSON file per deploy indefinitely. A cleanup command or TTL-based rotation doesn't exist yet.

  • Single global SSH key. All hosts use the same DEEPLO_SSH_KEY_FILE. Per-host key overrides are not supported.

On this page