script/fmt ran unpinned npx prettier with an inline --tab-width and no config, so formatting was not reproducible. This vendors the canonical format toolchain from the prompts scaffold: .prettierrc (tabWidth 4, proseWrap always), package.json and yarn.lock pinning prettier 3.8.1, and a script/bootstrap that installs node/yarn from a hash-verified nvm archive. script/fmt now reads the config over static/js and markdown; gofmt/goimports unchanged. .prettierignore keeps the *.min.js rule protecting the vendored alpine.min.js. Existing markdown was reflowed to house style.
Disclosure: make check does not gate prettier; the linter's pre-existing gomodguard deprecation warning is unrelated and left as-is.
Model: opus-4-8 (implementation and review)
HandleWebhook starts a deployment in a detached goroutine that writes
under the app data directory, which is the tests t.TempDir; the tests
slept 100ms and returned, racing Go automatic TempDir cleanup and
intermittently failing with RemoveAll: directory not empty. The webhook
Service now tracks those goroutines in a sync.WaitGroup and exposes
WaitForDeployments; the tests wait on it instead of sleeping. Production
behavior is unchanged apart from making completion observable.
Model: opus-4-8
Per the owner ruling, linting now runs only inside Docker with the
pinned golangci-lint (v2.12.2). A root Dockerfile.lint runs the linter as
a build step; script/lint just builds it. A GATE_RUN build arg forces the
lint layer to execute every run so a cached build cannot report a false
clean. script/bootstrap no longer installs golangci-lint (the goimports
install stays). The main Dockerfile lint stage calls golangci-lint
directly (no docker-in-docker) and still gates the build. config verify is
omitted because it fetches its schema over an unpinned HTTPS call.
Model: opus-4-8
script/fmt runs goimports, but script/bootstrap did not install it, so
make fmt failed with goimports: not found on a fresh machine. bootstrap
now installs goimports v0.49.0 (pinned; compatible with the repo Go 1.25,
so no toolchain download) into /usr/local/bin, guarded to skip when it is
already present. Node/prettier pinning is left to a separate issue; the
check gate runs only gofmt, so main is unaffected.
Model: opus-4-8
The deploy-log download handler passed a request-derived path to
http.ServeFile, which gosec flags as G703 (path traversal via taint).
The handler now opens the log through an os.Root confined to the deploy
log directory, so any escaping path is rejected at runtime (404) and the
file is streamed with http.ServeContent. A regression test plants a
sentinel outside the log dir and asserts the traversal is refused and its
contents never served; removing the guard makes that test fail. No
//nolint used.
Model: opus-4-8
script/fmt ran prettier over static/js/*.js, which rewrote the vendored
minified static/js/alpine.min.js. A root .prettierignore with *.min.js
excludes vendored bundles: make fmt on a clean tree now yields no changes
and alpine.min.js stays byte-identical, while first-party JS still formats.
Model: opus-4-8
Bumps golangci-lint from v2.10.1 to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then fixes every finding the new linter surfaces so `make check` is green.
## Version pins
- `Dockerfile` lint stage: `golangci/golangci-lint:v2.12.2` (Debian-based), tag plus digest pin
- `script/bootstrap`: `GOLANGCI_LINT_VERSION=2.12.2` with updated `linux-amd64`/`linux-arm64` release-archive sha256 pins
## Config
`.golangci.yml` replaced with the canonical config. Material change: the old file declared `version: "2"` but kept settings under the legacy top-level `linters-settings` key, which golangci-lint v2 ignores — so the intended thresholds (`lll` 88, `funlen` 80/50, `cyclop` 15, `dupl` 100) were not being applied. The canonical file moves them under `linters.settings` and drops `issues.exclude-use-default`.
## Lint fixes (216 findings)
- `lll` (96): wrapped lines to the 88-column limit
- `noctx` (46): `httptest.NewRequestWithContext` with `t.Context()` throughout the tests
- `goconst` (24): shared constants for template/JSON keys in `internal/handlers` and repeated test literals
- `gosec` (23): app-page redirects now go through a `redirectToApp` helper that path-escapes the app ID (G710 open redirect); `http.ServeFile` of the internally derived deployment log path annotated like the adjacent `os.Stat` (G703)
- `dupl` (22): extracted a generic `findAllByAppID` in `internal/models`, a `deleteAppResource` helper in `internal/handlers`, a shared `parsePush` in `internal/service/webhook`, and table-driven/helper-based dedup in tests
- `nolintlint` (5): removed `//nolint:funlen` directives made obsolete by the new limits (plus one more that became obsolete after refactoring)
- `nilerr` (3, surfaced during fixing): resource-delete lookups now propagate the find error to the caller
No behavior changes intended; all tests pass and `make check` is green.
Note: golangci-lint v2.12 warns that `gomodguard` is deprecated in favor of `gomodguard_v2` — a future canonical-config update should address this centrally.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #187
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
Closes#171
Refactors the migration system to follow the [pixa pattern](sneak/pixa#36):
## Changes
### New file: `internal/database/migrations/000.sql`
- Bootstrap migration that creates `schema_migrations` with `version INTEGER PRIMARY KEY`
- Self-contained: includes both `CREATE TABLE IF NOT EXISTS` and `INSERT OR IGNORE INTO schema_migrations (version) VALUES (0)`
### Refactored: `internal/database/migrations.go`
- **Go code no longer creates the migrations table inline** — it reads and executes `000.sql` as a bootstrap step before the normal migration loop
- **`ParseMigrationVersion(filename)`** — exported function that extracts integer version from filenames like `001_initial.sql` → `1`
- **`ApplyMigrations(ctx, db, log)`** — exported function for tests to apply schema without full fx lifecycle
- **`bootstrapMigrationsTable`** — checks if table exists; if missing, runs `000.sql`; if present, checks for legacy format
- **`convertLegacyMigrations`** — one-time conversion for existing databases that stored versions as TEXT filenames (e.g. `"001_initial.sql"`) to INTEGER versions (e.g. `1`)
- **Transaction-wrapped migration application** — each migration runs in a transaction for atomicity
- Sentinel error `ErrInvalidMigrationFilename` for static error wrapping
### New file: `internal/database/migrations_test.go`
- `TestParseMigrationVersion` — valid/invalid filename parsing
- `TestApplyMigrationsFreshDatabase` — verifies bootstrap creates table, all migrations apply, application tables exist
- `TestApplyMigrationsIdempotent` — double-apply is a no-op
- `TestApplyMigrationsLegacyConversion` — simulates old TEXT-based table with filename entries, verifies conversion to integer format
## Backward Compatibility
Existing databases with the old TEXT-based `schema_migrations` table are automatically converted on first run. The conversion:
1. Detects legacy entries (versions containing `_`)
2. Parses integer version from each legacy filename
3. Drops the old table
4. Creates the new INTEGER-based table via `000.sql`
5. Re-inserts all parsed integer versions
All existing migrations (001-007) continue to work unchanged.
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@sneak.berlin>
Reviewed-on: #172
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
## Summary
Adds GitHub and GitLab push webhook support alongside the existing Gitea support.
closes#68
## What Changed
### Auto-detection of webhook source
The webhook handler now auto-detects which platform sent the webhook by examining HTTP headers:
- **Gitea**: `X-Gitea-Event`
- **GitHub**: `X-GitHub-Event`
- **GitLab**: `X-Gitlab-Event`
Existing Gitea webhooks continue to work unchanged. Unknown sources fall back to Gitea format for backward compatibility.
### Normalized push event
All three payload formats are parsed into a unified `PushEvent` struct containing:
- Source platform, ref, branch, commit SHA
- Repository name, clone URL, HTML URL
- Commit URL (with per-platform fallback logic)
- Pusher username/name
### New files
- **`internal/service/webhook/payloads.go`**: Source-specific payload structs (`GiteaPushPayload`, `GitHubPushPayload`, `GitLabPushPayload`), `ParsePushPayload()` dispatcher, per-platform parsers, branch extraction, and commit URL extraction functions.
### Modified files
- **`internal/service/webhook/types.go`**: Added `Source` type (gitea/github/gitlab/unknown), `DetectWebhookSource()`, `DetectEventType()`, and `PushEvent` normalized type. Moved `GiteaPushPayload` to payloads.go.
- **`internal/service/webhook/webhook.go`**: `HandleWebhook` now accepts a `Source` parameter and uses `ParsePushPayload()` for unified parsing instead of directly unmarshaling Gitea payloads.
- **`internal/handlers/webhook.go`**: Calls `DetectWebhookSource()` and `DetectEventType()` to auto-detect the platform before delegating to the webhook service.
- **`internal/service/webhook/webhook_test.go`**: Comprehensive tests for source detection, event type extraction, payload parsing (all 3 platforms), commit URL fallback paths, and integration tests through `HandleWebhook` for GitHub and GitLab sources.
- **`README.md`**: Updated description, features, non-goals, and architecture to reflect multi-platform webhook support.
## Test coverage
Webhook package: **96.9%** statement coverage. Tests cover:
- `DetectWebhookSource` with all header combinations and precedence
- `DetectEventType` for each platform
- `ParsePushPayload` for Gitea, GitHub, GitLab, unknown source, invalid JSON, empty payloads
- Commit URL extraction fallback paths for GitHub and GitLab
- Direct struct deserialization for all three payload types
- Full `HandleWebhook` integration tests with GitHub and GitLab sources
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #170
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
## Summary
Adds configurable Docker CPU and memory resource constraints per app, closes #72.
## Changes
### Database
- Migration `007_add_resource_limits.sql`: adds `cpu_limit` (REAL, nullable) and `memory_limit` (INTEGER in bytes, nullable) columns to the `apps` table
### Model (`internal/models/app.go`)
- Added `CPULimit` (`sql.NullFloat64`) and `MemoryLimit` (`sql.NullInt64`) fields to `App` struct
- Updated insert, update, scan, and column list to include the new fields
### Docker Client (`internal/docker/client.go`)
- Added `CPULimit` (float64, CPU cores) and `MemoryLimit` (int64, bytes) to `CreateContainerOptions`
- Added `cpuLimitToNanoCPUs()` conversion helper and `buildResources()` to construct `container.Resources`
- Extracted `buildEnvSlice()` and `buildMounts()` helpers from `CreateContainer` for cleaner code
- Resource limits are passed to Docker's `HostConfig.Resources` (NanoCPUs / Memory)
### Deploy Service (`internal/service/deploy/deploy.go`)
- `buildContainerOptions` reads `CPULimit` and `MemoryLimit` from the app and passes them to `CreateContainerOptions`
### Handlers (`internal/handlers/app.go`)
- `HandleAppUpdate` reads and validates `cpu_limit` and `memory_limit` form fields
- Added `parseOptionalFloat64()` for CPU limit parsing (positive float or empty)
- Added `parseOptionalMemoryBytes()` for memory parsing with unit suffixes (k/m/g) or plain bytes
- Added `optionalNullString()` and `applyResourceLimits()` helpers to keep cyclomatic complexity in check
### Templates
- `app_edit.html`: Added "Resource Limits" section with CPU limit (cores) and memory limit (with unit suffix) fields
- `templates.go`: Added `formatMemoryBytes` template function for display (converts bytes → human-readable like `256m`, `1g`)
### Tests
- `internal/docker/resource_limits_test.go`: Tests for `cpuLimitToNanoCPUs` conversion
- `internal/handlers/resource_limits_test.go`: Tests for `parseOptionalFloat64` and `parseOptionalMemoryBytes` (happy paths, edge cases, validation)
- `internal/models/models_test.go`: Tests for App model resource limit persistence (save/load, null defaults, clearing)
- `internal/service/deploy/deploy_container_test.go`: Tests for container options with/without resource limits
- `templates/templates_test.go`: Tests for `formatMemoryBytes` formatting
### README
- Added "CPU and memory resource limits per app" to Features list
## Behavior
- **CPU limit**: Specified in cores (e.g. `0.5` = half a core, `2` = two cores). Converted to Docker NanoCPUs internally.
- **Memory limit**: Accepts plain bytes or suffixed values (`256m`, `1g`, `512k`). Stored as bytes in the database.
- Both fields are **optional** — empty/unset means unlimited (no Docker constraint applied).
- Limits are applied on every container creation: new deploys, rollbacks, and restarts that recreate the container.
closes #72
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #165
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This PR fixes env var handling by consolidating individual add/edit/delete operations into a single monolithic bulk save.
## Changes
- **Template**: Restored original table-based UI with key/value rows, edit/delete buttons, and add form. Uses Alpine.js to manage the env var list client-side. On form submit, all env vars are collected into a hidden textarea and POSTed as a single bulk request.
- **Handler**: `HandleEnvVarSave` atomically replaces all env vars (DELETE all + INSERT full set).
- **Routes**: Single `POST /apps/{id}/env` endpoint replaces individual env var CRUD routes.
- **Models**: Added `DeleteEnvVarsByAppID` and `FindEnvVarsByAppID` for bulk operations.
closes #156
closes #163
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #158
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
## Summary
Adds a per-app webhook event history page at `/apps/{id}/webhooks` showing received webhook events with match/no-match status.
## Changes
- **New template** `webhook_events.html` — displays webhook events in a table with time, event type, branch, commit SHA (linked when URL available), and match status badges
- **New handler** `HandleAppWebhookEvents()` in `webhook_events.go` — fetches app and its webhook events (limit 100)
- **New route** `GET /apps/{id}/webhooks` — registered in protected routes group
- **Template registration** — added `webhook_events.html` to the template cache in `templates.go`
- **Model enhancement** — added `ShortCommit()` method to `WebhookEvent` for truncated SHA display
- **App detail link** — added "Event History" link in the Webhook URL card on the app detail page
## UI
Follows the existing UI patterns (Tailwind CSS classes, Alpine.js `relativeTime`, badge styles, empty state, back-navigation). The page mirrors the deployments history page layout.
closes [#85](#85)
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #164
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
## Summary
`HandleAppCreate` was not reading `docker_network`, `ntfy_topic`, or `slack_webhook` form values from the create app form. These fields were silently dropped during app creation, even though:
- `app_new.html` had the form fields
- `CreateAppInput` had the corresponding struct fields
- `CreateApp` already handled them correctly
The edit/update flow was unaffected — the bug was exclusively in the create path.
## Changes
- Read `docker_network`, `ntfy_topic`, `slack_webhook` form values in `HandleAppCreate`
- Pass them to `CreateAppInput`
- Include them in template re-render data (preserves values on validation errors)
closes #157
<!-- session: agent:sdlc-manager:subagent:1fb3582d-1eff-4309-b166-df5046a1b885 -->
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #160
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
BuildKit skips unreferenced stages silently. The lint stage (added in PR [#152](#152)) was never referenced by the builder stage via `COPY --from`, so it was being skipped entirely during `docker build .`. Linting was not actually running in CI.
This adds `COPY --from=lint /src/go.sum /dev/null` to the builder stage, creating a stage dependency that forces the lint stage to complete before the build proceeds.
Verified: `docker build .` now runs the lint stage (fmt-check + lint) and passes.
closes #153
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #154
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
Changes the Go module path from `git.eeqj.de/sneak/upaas` to `sneak.berlin/go/upaas`.
All import paths in Go files updated accordingly. `go mod tidy` and `make check` pass cleanly.
fixes#143
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #149
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
## Summary
Splits the Dockerfile into separate lint and build stages to provide faster CI feedback on formatting and lint issues.
### Changes
**Dockerfile:**
- **Lint stage** (`golangci/golangci-lint:v2.10.1`, pinned by sha256): Runs `make fmt-check` and `make lint` using the official golangci-lint image which has the linter pre-installed. No more downloading golangci-lint on every build.
- **Build stage** (`golang:1.25-alpine`, pinned by sha256): Runs `make test` and `make build`. Same alpine image as before.
- **Runtime stage**: Unchanged.
**Makefile:**
- Added `fmt-check` target for standalone gofmt checking.
- Refactored `check` target to use `fmt-check`, `lint`, `test` as dependencies instead of inline commands. Still works identically for local use.
### Benefits
- Lint failures surface immediately without waiting for golangci-lint download
- Uses official pre-built golangci-lint image instead of manual binary download
- Cleaner separation of concerns between lint and build stages
- `make check` still runs everything sequentially for local development
closes #151
Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #152
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>