Browse documentation
DURPDEPLOY DOCUMENTATION

Contributor conventions

Follow the repository rules for generated code, migrations, testing, and documentation.

On this page

Single-binary Go app: deploy Bash, PowerShell, and Python scripts against environments with live SSE logs, using server containers or remote agents. Stack: Go 1.26 + chi + SQLite (modernc.org/sqlite, pure Go, no CGO), PostgreSQL, or SQL Server + sqlc + goose + templ + HTMX + Alpine + Tailwind/DaisyUI.

Dependency licenses

All packages used by this project must be open source. Prefer permissive licenses such as MIT, BSD, ISC, or Apache-2.0. Check the license of the exact version before adding or upgrading a dependency, including its transitive dependencies. Do not add proprietary, source-available, revenue-limited, or commercial-license-only packages. Keep required license notices with bundled assets.

Critical build gotcha

*_templ.go are gitignored (see .gitignore). A fresh checkout cannot go vet/go test/go build until templ runs:

templ generate        # required before any go command on a fresh checkout
make swagger-ui-copy  # installs locked npm dependencies and copies embedded UI assets
make build            # generates templates, CSS/JS, and Swagger UI, then builds Go

CI generates templ output before checks that import the application. Mirror that for local go vet/go test/go build after pulling or after editing views/*.templ. make templ-generate uses the pinned Go tool.

static/swagger-ui/ is also gitignored (generated by make swagger-ui-copy from node_modules/swagger-ui-dist). The //go:embed swagger-ui pattern in static/static.go fails on a fresh checkout with pattern swagger-ui: no matching files found. The Dockerfile and CI jobs that build the application materialize it; mirror that locally with make swagger-ui-copy before any go vet/go test/go build on a fresh clone. This target requires npm.

Generated code — do not hand-edit

  • Generated internal/db/*.sql.go, models.go, and db.go are sqlc output; tests in that directory are hand-written. Edit queries/*.sql, then sqlc generate (config in sqlc.yaml). internal/repository/repository.go is hand-written and wraps *db.Queries.
  • *_templ.go is templ output. Edit views/*.templ, run templ generate.
  • static/css/tailwind.min.css, static/js/app.bundle.js, and static/js/charts.bundle.js are built by make tailwind-build / make js-build and are committed, along with their generated license notices. Once templ and Swagger UI output exists, go build works without npm. Only regenerate bundles when CSS/JS source changes.
  • static/swagger-ui/* is built by make swagger-ui-copy from node_modules/swagger-ui-dist and is gitignored. Never hand-edit those files.

Migrations

migrations/*.sql are goose-formatted (-- +goose Up / -- +goose Down) and embedded via migrations/embed.go. SQL Server uses its separate migrations/mssql/ sequence. Choose the next number in each current sequence rather than reusing an existing migration number. Migrations auto-run on startup via internal/migrate/migrate.Run — no manual step. SQLite tests use :memory: + migrate.Run; backend parity tests use PostgreSQL or SQL Server fixtures.

Running / testing

For a coding thread, read isolated development stacks. Run make dev-stack-thread from the thread’s worktree. Use its connection record for API and browser tests. Use the worktree’s .local/dev-stack.json registration to find that record. Import the scripts from t3.json into the T3 project. The runOnSettle script deletes the registered stack and database when the thread settles. Keep the thread unsettled during manual review. Use separate browser profiles and scoped certificate trust. Do not register retained regression fixtures with the thread wrapper. If cleanup fails, keep the registration and retry make dev-stack-thread ACTION=settle.

For populated demos, follow the demo lifecycle. Run make demo, with the selected engine’s local Unix socket active first. On Citadel, use systemctl --user start podman.socket. Verify agent/3, the selected runtime, container mode, and Bash/Python/PowerShell container execution from the retained deployment and agent-capabilities.json before reporting readiness. Return the URL, generated admin@durp.info login, data directory, certificate path, and stop command. Leave the server, agent, and proxy running.

  • Server/template/asset changes: make demo-refresh DEMO_DIR=/absolute/demo/path. Finish script edits first. Never edit a Bash script while it is running. Refresh preserves the demo settings and refuses unfinished or unconfirmed work. Inspect its logs and fix failures before reporting readiness.
  • Database, agent, or startup configuration changes: make demo-refresh-full DEMO_DIR=/absolute/old/demo/path. Return the new details; old deployment history stays on disk.
  • Teardown: wait for completion or API/UI cancellation with confirmed cleanup, then make demo-stop DEMO_DIR=/absolute/demo/path. Keep data and identities. Do not stop the shared engine/socket, another demo, or remove state to clear warnings.

The supervisor receives the operator’s runtime socket; container-mode workloads receive none. Host-mode scripts inherit the operator’s runtime authority. Use trusted scripts. Do not change host socket permissions. Follow the linked runbook for rootless Podman mapping, refresh barriers, rollback, and firewall leases.

All applicable Go and end-to-end tests must pass before work is complete. Run the full relevant suites only at final verification; use focused tests while working. Do not treat focused tests as a substitute for final verification.

Test-owned database fixtures must register testutil.CleanupContainer(t, container) immediately after creation, before checking its error: Testcontainers can return a live container together with a startup error. Cleanup uses its own bounded context and fails the test if removal fails. Use make test or bash scripts/go_test.sh [go test arguments] for local Go tests. The runner assigns each package binary a separate Testcontainers session. It removes only owned sessions on success, failure, timeout, or HUP/INT/TERM. Cleanup also runs when Ryuk is disabled on rootless Podman. Plain go test relies on Ryuk for process-level cleanup; SIGKILL or machine failure cannot run shell traps. Running-server E2E examples and demos remain retained.

For package-owned fixtures, register each container with the package owner before checking its startup error. Remove package-owned containers in TestMain with testutil.TerminateContainer. Fail the package if removal fails. Give each test a fresh database and close its connections before database removal.

Every scenario in make e2e-test must remain available for manual checks after the command exits. Retain its projects, environments, release snapshots, deployment history, templates, and test accounts. For destructive checks, retain or recreate a disposable example. Print links to the retained examples and keep docs/manual-e2e.md current with repeat steps and expected results. Schedules must remain disabled after testing; document how to enable them. Pending approvals can expire; retain the release so a fresh plan can be made. This rule applies to the running-server suite, not isolated test databases.

Every new or changed functionality must be available through a corresponding public API endpoint and have end-to-end coverage through that API. Validation is not complete until the API contract and its E2E test both pass. New or changed web functionality must also have a web E2E test and pass it before validation is complete.

skills/durpdeploy/SKILL.md is the shipped agent skill documenting how external agents operate a running instance (served publicly at /.well-known/skills/ and /.well-known/agent-skills/). When API behavior documented there changes, update that file in the same change.

./durpdeploy                       # defaults to :8080 and durpdeploy.db in CWD; requires a secret key
bash scripts/go_test.sh -v -count=1 -timeout=20m ./...
bash scripts/go_test.sh -run TestName ./internal/handler/...
./scripts/e2e_test.sh              # bash end-to-end: builds, runs server, curl happy/cancel/validation paths (~10s+)

GitLab merge request checks run lint before Go tests and E2E checks. Run make lint for the pinned golangci-lint checks across packages and tests. The target generates required assets and installs a checksum-verified tool in bin/lint/. CI also requires go vet ./..., clean gofmt and golines output, and hadolint. Other security, browser, and backend checks run in separate jobs. Image/chart publication runs on protected pushes or manual pipelines after its dependencies. See docs/gitlab-ci.md for the AMD64 Kubernetes runner and credential setup.

SonarCloud and Codex

SonarCloud and the main Go/E2E suites run on merge requests. Security scans also run on main pushes, tags, configured schedules, and manual pipelines. The targeted API-token test runs on merge requests. SonarCloud reports Go coverage and waits for the Quality Gate. A failed gate fails the GitLab pipeline. Before declaring merge request work complete, inspect SonarCloud and address valid new-code findings. With a local SONAR_TOKEN, use:

make sonar-issues MR=<merge-request-number>

The command is read-only and must never print or persist SONAR_TOKEN. SonarCloud does not replace the required Go and end-to-end verification above.

Conventions agents get wrong

  • Pre-commit: run make install-hooks once per clone. It copies scripts/pre-commit to the repository’s common Git directory under hooks/pre-commit, shared by linked worktrees. It preserves global core.hooksPath dispatchers, which must forward project hooks to enforce this check. The hook runs golines -m 80 --ignore-generated -l . on staged Go files and blocks the commit if any line exceeds 80 cols. The Makefile target golines-check does the same dry-run for the whole tree. CI re-runs the same check in the lint stage, so --no-verify doesn’t bypass the gate.

  • Add routes in internal/server/server.go only. All chi routes are registered there; handlers live in internal/handler/*.

  • Use migrated test schemas. internal/handler/logs_test.go uses migrate.Run, as does internal/db/smoke_test.go. Do not duplicate the schema inline.

  • DSN is configurable through DURPDEPLOY_DB in cmd/server/main.go; default: durpdeploy.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000). DURPDEPLOY_ADDR overrides the default :8080 listener. Database file, WAL, and SHM are gitignored.

  • Deployment execution uses sequential steps, with server steps in Docker/Podman containers and remote steps dispatched to matching agents. Bash, PowerShell, and Python are supported when available in the selected runtime. Remote fan-out can execute a step on multiple agents. internal/runner/runner.go streams logs through LogBroker (SSE) and tracks cancels by deployment ID. Auth is at the HTTP boundary. Server steps require a container image and have no host fallback; see docs/deploy.md and docs/agents.md for execution boundaries.

  • Releases snapshot steps and variables (steps_json and release_variables); later project edits do not change them automatically. Refresh re-snapshots for future deployments; existing deployment-step snapshots remain frozen.

  • Templ tags render to HTMX swaps; handlers return 303 for POST redirects, 422 for validation failures (see scripts/e2e_test.sh for the contract).

Viewer role — UI gating pattern

Viewers (role = "viewer") are read-only. Defense is two layers, both required:

  1. CSRF middleware (internal/auth/csrf.go) — rejects application writes from a viewer at the protocol layer. Logout and the viewer’s own security settings remain allowed with CSRF protection; session-authenticated /api/lint is read-only and exempt.
    • HTMX requests: returns 200 + HX-Trigger carrying a makeToast event (so the same red toast the rest of the app uses fires, and the page stays put — no error overlay, no full reload).
    • Non-HTMX form submits: returns 403 + a self-contained styled page (back link, no broken browser error screen).
  2. CanWrite templ guard — hides write affordances so viewers never have a reason to hit the middleware.
    • Helper: pages.CanWrite(ctx) (and the parallel components.canWrite(ctx) for the components package). Returns false for viewers.
    • Buttons: wrap each write affordance in if CanWrite(ctx) { <button>...</button> }. Affects New / Edit / Delete / Reorder / Save Template / Approve / Cancel / Re-run / Toggle buttons across every page.
    • Form pages: wrap the entire form in if CanWrite(ctx) { @FormBody(...) } else { @ViewerForbiddenMessage(...) }. The ViewerForbiddenMessage is in views/pages/permissions.templ and takes (title, action, what, backHref, currentPath).

Apply both layers even on admin/project-gated routes: middleware enforces writes, and templ guards prevent dead-end forms. See roles for permissions.

Pattern for new form pages: the UserFormPage, ProjectFormPage, EnvironmentForm, LifecycleFormPage, TemplateForm, ScheduledDeploymentFormPage, DeployFormPage, and the StepForm/StepEditRow components are the reference. Each follows the same shape: outer if !CanWrite(ctx) { @ViewerForbiddenMessage(...) } else { @Base + @FormBody }.

Tests: TestAuth_ViewerHTMXReturnsToast (HTMX toast path), TestAuth_ViewerCannotPost (non-HTMX 403 path), TestUsers_ViewerSeesForbiddenOnFormPages (templ guard renders the message on /environments/new, /lifecycles/new, /templates/new).

Security model

The multi-user model is not optional. Session-authenticated browser routes in internal/server/server.go use this protected-group middleware order:

  1. auth.AuthMiddleware(repo) — session cookie → user in context. Redirects to /login on miss.
  2. webRequestBodyLimit — bounds and validates forms before CSRF parsing.
  3. auth.CSRFMiddleware() — checks CSRF tokens and blocks viewer application writes, with the exceptions described above. Viewer rejections are surfaced as a toast (HTMX) or a styled 403 page (non-HTMX).
  4. audit.Middleware(repo) — records successful state changes unless the handler suppresses or records them directly. This middleware does not audit CSRF rejections or 4xx responses; authentication handlers can emit separate security audit events.

The /api/v1 group instead uses bearer-token authentication, WriteBlockMiddleware, api.RequestBodyLimit, and audit middleware. The separate mTLS agent listener uses agent authentication and claim checks. Project and admin gates apply inside the corresponding route groups.

New state-changing routes must be added to the actionMap in internal/audit/routes.go so the audit log gets a stable action name. The fallback heuristic (method + first path segment) is lossy.

Use docs/roles.md for current project and global authorization, and docs/security.md for execution boundaries. Agent host-mode scripts run with the agent operator’s authority; server and agent containers share the runtime host’s kernel. Trust scripts accordingly.

The server owner is responsible for deployment scripts and their effects. Staging cleanup covers managed staging and normal job descendants. Deliberate supervisor sabotage and script-created copies outside managed staging are outside this cleanup guarantee. Do not add per-job cgroups or cgroup delegation requirements for staging transfer. Keep polling blocked when process or staging cleanup cannot be confirmed.

Do not “simplify” by: removing the CSRF check, inlining VerifyPassword, replacing argon2id with bcrypt/SHA, putting the session token in a URL parameter, skipping the audit_log insert on a new route, or merging audit.Record into a handler without going through the middleware. Each of these is a documented attack vector in docs/attack-drill.md.

UI design — table layout conventions

All pages with data tables must follow this pattern. Tables scale with the browser window and have consistent column widths across pages.

Table base class: table table-zebra table-fixed w-full

  • table-fixed makes columns respect percentage widths (without it, browser auto-sizes to content)
  • w-full makes the table fill its container
  • Custom CSS in static/css/input.css enforces width: 100% !important; table-layout: fixed on all .table elements

Column width pattern — use percentage widths on <th> so columns scale with window:

Columns Pattern
3 cols w-1/4 + w-auto + w-1/4 (or fixed for last)
4 cols w-1/5 + w-2/5 + w-1/5 + w-1/5
6 cols w-1/6 × 6
Steps (Order, Name, Script) w-16 + w-1/5 + w-auto
Steps (Order, Name, Script, Actions) w-16 + w-1/5 + w-auto + w-96

Actions column: w-96 (24rem) for tables with 5 buttons (↑, ↓, Edit, Save Template, Delete), w-48 (12rem) for tables with 2 buttons.

Cell overflow: Add truncate to <td> cells with text content. Add whitespace-nowrap to date/timestamp cells. Buttons in actions cells use whitespace-nowrap to stay in one row.

Layout container: Use w-full px-4 sm:px-6 lg:px-8 (no max-w-screen-* — tables fill the full viewport). Never use Tailwind’s container class for the main content area.

Button text in tables: Keep short to fit one row. “Save as Template” → “Save Template”. Use btn-xs for table row buttons.

Ponytail (lazy senior) rules — active by default

Lazy = efficient, not careless. Read the whole flow first, then pick the highest rung that holds:

  1. Does this need to exist at all? Speculative need = skip it, say so in one line. (YAGNI)
  2. Already in this codebase? Reuse the helper/util/type/pattern a few files over. Re-implementing what’s here is the most common slop.
  3. Stdlib does it? Use it. (database/sql.NullString, slog, embed, os/exec, net/http)
  4. Native platform feature covers it? DB constraint over app code; chi middleware over hand-rolled; HTMX attributes over JS.
  5. Already-installed dependency solves it? Use it. Never add a new dep for what a few lines can do.
  6. Can it be one line? One line.
  7. Only then: the minimum code that works.

Rules:

  • No unrequested abstractions: no interface with one impl, no factory for one product, no config for a value that never changes.
  • No boilerplate, no scaffolding “for later” — later can scaffold for itself.
  • Deletion over addition. Boring over clever (clever is what someone decodes at 3am).
  • Fewest files possible. Shortest working diff wins — but only after understanding the problem. The smallest change in the wrong place isn’t lazy, it’s a second bug.
  • Bug fix = root cause, not symptom. Before editing, grep every caller of the function. One guard in the shared function beats a guard in every caller; patching only the path named in the ticket leaves siblings broken.
  • Mark deliberate simplifications with a // ponytail: comment naming the ceiling and upgrade path, e.g. // ponytail: global lock, per-deployment locks if throughput matters.
  • Complex request? Ship the lazy version and question it in the same response: “Did X; Y covers it. Need full X? Say so.” Never stall on an answer you can default.
  • Two stdlib options, same size? Take the one correct on edge cases. Lazy = less code, not flimsier algorithm.
  • Output: code first, then at most three short lines — what was skipped, when to add it. No essays, no feature tours.

Never lazy away: input validation at trust boundaries (HTTP handlers, scripts/e2e_test.sh contract), error handling that prevents data loss (release snapshots, deployment cancel), security, accessibility basics, anything explicitly requested.

Never lazy about understanding the problem — the ladder shortens the solution, never the reading. Trace the whole flow end to end first, then climb.

Documentation

Use Simplified Technical English for new and changed documentation. Code, commands, identifiers, paths, and UI labels retain their exact spelling.

  • Use American English spelling.
  • Use the active voice when the actor is important.
  • Write one instruction in each sentence.
  • Start an instruction with an imperative verb.
  • Put a condition before the instruction that it controls.
  • Use no more than 20 words in an instruction.
  • Use no more than 25 words in a descriptive sentence.
  • Use one topic in each paragraph.
  • Use no more than six sentences in a paragraph.
  • Do not use contractions or semicolons.
  • Use the same term for the same item or operation.
  • Do not change commands, code, identifiers, or user-interface text.