Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e64d6f087 | ||
|
|
a44d4a2647 | ||
|
|
ddcd179841 | ||
|
|
19619b1cd2 | ||
|
|
567f982c3b | ||
|
|
1d769834de | ||
|
|
6026e3923d |
+66
-2
@@ -10,14 +10,20 @@ run:
|
|||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
enable:
|
||||||
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
|
# left to `default: all`, because it carries the module policy below.
|
||||||
|
- gomodguard_v2
|
||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- depguard # Dependency allow/block lists
|
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -28,6 +34,64 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
|
depguard:
|
||||||
|
# Test-support code must not be compiled into the shipped binary. A
|
||||||
|
# test-support package exists to hand a test privileges the program
|
||||||
|
# itself must never have, so a file that is not a test must not import
|
||||||
|
# one. Test files, and the files inside a package whose directory name
|
||||||
|
# ends in `test`, are where that code belongs, and are exempt.
|
||||||
|
#
|
||||||
|
# The deny list below is the one part of this file a repository is
|
||||||
|
# expected to extend, and the only part it may. depguard matches an
|
||||||
|
# import path against a list of prefixes, so it cannot be told "any path
|
||||||
|
# whose last segment ends in test"; a repository's own test-support
|
||||||
|
# packages have to be named here one at a time, by full import path,
|
||||||
|
# under a module path that differs from repository to repository. Add
|
||||||
|
# them; change nothing else.
|
||||||
|
rules:
|
||||||
|
test-support:
|
||||||
|
list-mode: lax
|
||||||
|
files:
|
||||||
|
- "$all"
|
||||||
|
- "!$test"
|
||||||
|
- "!**/*test/**"
|
||||||
|
deny:
|
||||||
|
- pkg: net/http/httptest
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
# Only decisions already recorded in the Go package defaults are
|
||||||
|
# listed here. Every entry matches the module path exactly.
|
||||||
|
gomodguard_v2:
|
||||||
|
blocked:
|
||||||
|
- module: github.com/rs/zerolog
|
||||||
|
recommendations:
|
||||||
|
- log/slog
|
||||||
|
reason: "Structured logging is stdlib log/slog."
|
||||||
|
# One entry per pre-fork module path, because the later releases
|
||||||
|
# are separate paths. A prefix match would be shorter but would
|
||||||
|
# also reach github.com/go-redis/redismock, the test double for
|
||||||
|
# the successor these entries recommend.
|
||||||
|
- module: github.com/go-redis/redis
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v7
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v8
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/sergi/go-diff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "No unified diff output; use go-udiff."
|
||||||
|
- module: github.com/hexops/gotextdiff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "Unmaintained fork; use go-udiff."
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
# Vendored, minified third-party bundles must never be reformatted.
|
# Vendored, minified third-party bundles must never be reformatted.
|
||||||
*.min.js
|
*.min.js
|
||||||
|
|||||||
+37
-31
@@ -1,6 +1,8 @@
|
|||||||
# Go HTTP Server Conventions
|
# Go HTTP Server Conventions
|
||||||
|
|
||||||
This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards.
|
This document defines the architectural patterns, design decisions, and
|
||||||
|
conventions for building Go HTTP servers. All new projects must follow these
|
||||||
|
standards.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -25,18 +27,18 @@ This document defines the architectural patterns, design decisions, and conventi
|
|||||||
|
|
||||||
These libraries are **mandatory** for all new projects:
|
These libraries are **mandatory** for all new projects:
|
||||||
|
|
||||||
| Purpose | Library | Import Path |
|
| Purpose | Library | Import Path |
|
||||||
|---------|---------|-------------|
|
| -------------------- | --------------- | ------------------------------------- |
|
||||||
| Dependency Injection | Uber fx | `go.uber.org/fx` |
|
| Dependency Injection | Uber fx | `go.uber.org/fx` |
|
||||||
| HTTP Router | go-chi | `github.com/go-chi/chi` |
|
| HTTP Router | go-chi | `github.com/go-chi/chi` |
|
||||||
| Logging | slog (stdlib) | `log/slog` |
|
| Logging | slog (stdlib) | `log/slog` |
|
||||||
| Configuration | Viper | `github.com/spf13/viper` |
|
| Configuration | Viper | `github.com/spf13/viper` |
|
||||||
| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
|
| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` |
|
||||||
| CORS | go-chi/cors | `github.com/go-chi/cors` |
|
| CORS | go-chi/cors | `github.com/go-chi/cors` |
|
||||||
| Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
|
| Error Reporting | Sentry | `github.com/getsentry/sentry-go` |
|
||||||
| Metrics | Prometheus | `github.com/prometheus/client_golang` |
|
| Metrics | Prometheus | `github.com/prometheus/client_golang` |
|
||||||
| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
|
| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` |
|
||||||
| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
|
| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,7 +87,8 @@ project-root/
|
|||||||
### Key Principles
|
### Key Principles
|
||||||
|
|
||||||
- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping.
|
- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping.
|
||||||
- **`internal/`**: All application packages. Not importable by external projects.
|
- **`internal/`**: All application packages. Not importable by external
|
||||||
|
projects.
|
||||||
- **One package per concern**: config, database, handlers, middleware, etc.
|
- **One package per concern**: config, database, handlers, middleware, etc.
|
||||||
- **Flat handler files**: One file per handler or logical group of handlers.
|
- **Flat handler files**: One file per handler or logical group of handlers.
|
||||||
|
|
||||||
@@ -190,7 +193,8 @@ Providers are resolved automatically by fx, but conceptually follow this order:
|
|||||||
2. `logger.New` - Logger (depends on Globals)
|
2. `logger.New` - Logger (depends on Globals)
|
||||||
3. `config.New` - Configuration (depends on Globals, Logger)
|
3. `config.New` - Configuration (depends on Globals, Logger)
|
||||||
4. `database.New` - Database (depends on Logger, Config)
|
4. `database.New` - Database (depends on Logger, Config)
|
||||||
5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database)
|
5. `healthcheck.New` - Health check (depends on Globals, Config, Logger,
|
||||||
|
Database)
|
||||||
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
|
6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
|
||||||
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
|
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
|
||||||
8. `server.New` - Server (depends on all above)
|
8. `server.New` - Server (depends on all above)
|
||||||
@@ -453,7 +457,8 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
|||||||
|
|
||||||
### Closure-Based Handler Pattern
|
### Closure-Based Handler Pattern
|
||||||
|
|
||||||
All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created:
|
All handlers return `http.HandlerFunc` using the closure pattern. This allows
|
||||||
|
initialization logic to run once when the handler is created:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// internal/handlers/index.go
|
// internal/handlers/index.go
|
||||||
@@ -510,7 +515,8 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac
|
|||||||
### Handler Naming Convention
|
### Handler Naming Convention
|
||||||
|
|
||||||
- `HandleIndex()` - Main page
|
- `HandleIndex()` - Main page
|
||||||
- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix
|
- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method
|
||||||
|
suffix
|
||||||
- `HandleNow()` - API endpoints
|
- `HandleNow()` - API endpoints
|
||||||
- `HandleHealthCheck()` - System endpoints
|
- `HandleHealthCheck()` - System endpoints
|
||||||
|
|
||||||
@@ -733,7 +739,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
|
|
||||||
1. **Environment variables** (highest priority via `AutomaticEnv()`)
|
1. **Environment variables** (highest priority via `AutomaticEnv()`)
|
||||||
2. **`.env` file** (loaded via `godotenv/autoload` import)
|
2. **`.env` file** (loaded via `godotenv/autoload` import)
|
||||||
3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml`
|
3. **Config files**: `/etc/{appname}/{appname}.yaml`,
|
||||||
|
`~/.config/{appname}/{appname}.yaml`
|
||||||
4. **Defaults** (lowest priority)
|
4. **Defaults** (lowest priority)
|
||||||
|
|
||||||
### Environment Loading
|
### Environment Loading
|
||||||
@@ -1005,6 +1012,7 @@ var Static embed.FS
|
|||||||
```
|
```
|
||||||
|
|
||||||
Directory structure:
|
Directory structure:
|
||||||
|
|
||||||
```
|
```
|
||||||
static/
|
static/
|
||||||
├── static.go
|
├── static.go
|
||||||
@@ -1045,15 +1053,13 @@ Templates use Go's template composition:
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- index.html -->
|
<!-- index.html -->
|
||||||
{{ template "htmlheader.html" . }}
|
{{ template "htmlheader.html" . }} {{ template "navbar.html" . }}
|
||||||
{{ template "navbar.html" . }}
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<!-- Page content -->
|
<!-- Page content -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{{ template "pagefooter.html" . }}
|
{{ template "pagefooter.html" . }} {{ template "htmlfooter.html" . }}
|
||||||
{{ template "htmlfooter.html" . }}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Static Asset References
|
### Static Asset References
|
||||||
@@ -1214,12 +1220,12 @@ if viper.GetString("METRICS_USERNAME") != "" {
|
|||||||
|
|
||||||
### Environment Variables Summary
|
### Environment Variables Summary
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
| ------------------ | -------------------------------- | ------- |
|
||||||
| `PORT` | HTTP listen port | 8080 |
|
| `PORT` | HTTP listen port | 8080 |
|
||||||
| `DEBUG` | Enable debug logging | false |
|
| `DEBUG` | Enable debug logging | false |
|
||||||
| `DBURL` | Database connection URL | "" |
|
| `DBURL` | Database connection URL | "" |
|
||||||
| `SENTRY_DSN` | Sentry DSN for error reporting | "" |
|
| `SENTRY_DSN` | Sentry DSN for error reporting | "" |
|
||||||
| `MAINTENANCE_MODE` | Enable maintenance mode | false |
|
| `MAINTENANCE_MODE` | Enable maintenance mode | false |
|
||||||
| `METRICS_USERNAME` | Basic auth username for /metrics | "" |
|
| `METRICS_USERNAME` | Basic auth username for /metrics | "" |
|
||||||
| `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
|
| `METRICS_PASSWORD` | Basic auth password for /metrics | "" |
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# µPaaS by [@sneak](https://sneak.berlin)
|
# µPaaS by [@sneak](https://sneak.berlin)
|
||||||
|
|
||||||
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab.
|
A simple self-hosted PaaS that auto-deploys Docker containers from Git
|
||||||
|
repositories via webhooks from Gitea, GitHub, or GitLab.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Single admin user with argon2id password hashing
|
- Single admin user with argon2id password hashing
|
||||||
- Per-app SSH keypairs for read-only deploy keys
|
- Per-app SSH keypairs for read-only deploy keys
|
||||||
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab
|
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and
|
||||||
|
GitLab
|
||||||
- Branch filtering - only deploy on configured branch changes
|
- Branch filtering - only deploy on configured branch changes
|
||||||
- Environment variables, labels, and volume mounts per app
|
- Environment variables, labels, and volume mounts per app
|
||||||
- CPU and memory resource limits per app
|
- CPU and memory resource limits per app
|
||||||
@@ -95,9 +97,12 @@ chi Router ──► Middleware Stack ──► Handler
|
|||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing one-time initialization
|
- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing
|
||||||
- **Active Record models**: Models encapsulate database operations (`Save()`, `Delete()`, `Reload()`)
|
one-time initialization
|
||||||
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
|
- **Active Record models**: Models encapsulate database operations (`Save()`,
|
||||||
|
`Delete()`, `Reload()`)
|
||||||
|
- **Async deployments**: Webhook triggers deploy via goroutine with
|
||||||
|
`context.WithoutCancel()`
|
||||||
- **Embedded assets**: Templates and static files embedded via `//go:embed`
|
- **Embedded assets**: Templates and static files embedded via `//go:embed`
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
@@ -105,12 +110,12 @@ chi Router ──► Middleware Stack ──► Handler
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call them. We
|
||||||
them. We provide:
|
provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (idempotent)
|
- `script/bootstrap` — install all dependencies (idempotent)
|
||||||
- `script/setup` — make a fresh clone ready for development
|
- `script/setup` — make a fresh clone ready for development (bootstrap, then
|
||||||
(bootstrap, then install-precommit)
|
install-precommit)
|
||||||
- `script/projectname` — output the project name ("upaas")
|
- `script/projectname` — output the project name ("upaas")
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/lint` — run golangci-lint
|
- `script/lint` — run golangci-lint
|
||||||
@@ -118,12 +123,12 @@ them. We provide:
|
|||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||||
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
|
||||||
runs the checks, so a green build implies a green repo)
|
checks, so a green build implies a green repo)
|
||||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
`script/check`)
|
`script/check`)
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||||
runs `script/precommit`
|
`script/precommit`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -156,11 +161,11 @@ Before every commit:
|
|||||||
|
|
||||||
1. **Format**: Run `make fmt` to format all code
|
1. **Format**: Run `make fmt` to format all code
|
||||||
2. **Lint**: Run `make lint` and fix all errors/warnings
|
2. **Lint**: Run `make lint` and fix all errors/warnings
|
||||||
- Do not disable linters or add nolint comments without good reason
|
- Do not disable linters or add nolint comments without good reason
|
||||||
- Fix the code, don't hide the problem
|
- Fix the code, don't hide the problem
|
||||||
3. **Test**: Run `make test` and ensure all tests pass
|
3. **Test**: Run `make test` and ensure all tests pass
|
||||||
- Fix failing tests by fixing the code, not by modifying tests to pass
|
- Fix failing tests by fixing the code, not by modifying tests to pass
|
||||||
- Add tests for new functionality
|
- Add tests for new functionality
|
||||||
4. **Verify**: Run `make check` to confirm everything passes
|
4. **Verify**: Run `make check` to confirm everything passes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -174,6 +179,7 @@ git commit -m "Your message"
|
|||||||
```
|
```
|
||||||
|
|
||||||
The Docker build runs `make check` and will fail if:
|
The Docker build runs `make check` and will fail if:
|
||||||
|
|
||||||
- Code is not formatted
|
- Code is not formatted
|
||||||
- Linting errors exist
|
- Linting errors exist
|
||||||
- Tests fail
|
- Tests fail
|
||||||
@@ -185,17 +191,17 @@ This ensures the main branch always contains clean, tested, working code.
|
|||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||||
| `PORT` | HTTP listen port | 8080 |
|
| `PORT` | HTTP listen port | 8080 |
|
||||||
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
|
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
|
||||||
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | *(none — must be set to an absolute path)* |
|
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | _(none — must be set to an absolute path)_ |
|
||||||
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
|
||||||
| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false |
|
| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false |
|
||||||
| `DEBUG` | Enable debug logging | false |
|
| `DEBUG` | Enable debug logging | false |
|
||||||
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
|
||||||
| `METRICS_USERNAME` | Basic auth for /metrics | "" |
|
| `METRICS_USERNAME` | Basic auth for /metrics | "" |
|
||||||
| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
|
| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
|
||||||
|
|
||||||
## Running with Docker
|
## Running with Docker
|
||||||
|
|
||||||
@@ -217,35 +223,38 @@ TLS-terminating reverse proxy, drop that line.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
upaas:
|
upaas:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ${HOST_DATA_DIR}:/var/lib/upaas
|
- ${HOST_DATA_DIR}:/var/lib/upaas
|
||||||
environment:
|
environment:
|
||||||
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
|
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
|
||||||
# Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
|
# Set when serving plain HTTP (no TLS-terminating proxy); drop behind one
|
||||||
- UPAAS_PLAINTEXT_HTTP=true
|
- UPAAS_PLAINTEXT_HTTP=true
|
||||||
# Optional: uncomment to enable debug logging
|
# Optional: uncomment to enable debug logging
|
||||||
# - DEBUG=true
|
# - DEBUG=true
|
||||||
# Optional: Sentry error reporting
|
# Optional: Sentry error reporting
|
||||||
# - SENTRY_DSN=https://...
|
# - SENTRY_DSN=https://...
|
||||||
# Optional: Prometheus metrics auth
|
# Optional: Prometheus metrics auth
|
||||||
# - METRICS_USERNAME=prometheus
|
# - METRICS_USERNAME=prometheus
|
||||||
# - METRICS_PASSWORD=secret
|
# - METRICS_PASSWORD=secret
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running
|
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the
|
||||||
`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR`
|
host before running `docker compose up`. This value is bind-mounted into the
|
||||||
so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break
|
container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during
|
||||||
container builds because the Docker daemon resolves paths relative to the host, not the container.
|
builds resolve correctly. Relative paths (e.g. `./data`) will break container
|
||||||
|
builds because the Docker daemon resolves paths relative to the host, not the
|
||||||
|
container.
|
||||||
|
|
||||||
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
|
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
|
||||||
|
|
||||||
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
|
Session secrets are automatically generated on first startup and persisted to
|
||||||
|
`$UPAAS_DATA_DIR/session.key`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,86 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
* branch (from `main`)
|
- branch (from `main`)
|
||||||
* do the work in Next Step
|
- do the work in Next Step
|
||||||
* move Next Step to the top of Completed Steps
|
- move Next Step to the top of Completed Steps
|
||||||
* move the top item of Future Steps into Next Step
|
- move the top item of Future Steps into Next Step
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
* push
|
- push
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check`
|
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` is green
|
||||||
is green as of the golangci-lint v2.12.2 update.
|
as of the golangci-lint v2.12.2 update.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so
|
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so main cannot
|
||||||
main cannot regress.
|
regress.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-22: Added a root `.prettierrc` (`tabWidth: 4`,
|
- 2026-09-23: The git clone container is now removed together with its anonymous
|
||||||
`proseWrap: always`) pinning the project prettier settings, and
|
volume (the `alpine/git` image declares one at `/git`), so a deploy no longer
|
||||||
dropped the now-redundant inline `--tab-width 4` from `script/fmt` so
|
leaves a Docker volume behind (#215).
|
||||||
the config file is the single source of truth (#197).
|
- 2026-09-23: Deployment log files are now stored under `logs/<appname>/`
|
||||||
|
instead of `logs/<hostname>/<appname>/`, so downloads keep working after the
|
||||||
|
upaas container is recreated; logs written under an old hostname directory are
|
||||||
|
still found (#214).
|
||||||
|
- 2026-09-23: Fixed the flaky `t.TempDir` cleanup race in `internal/handlers`
|
||||||
|
(the one fixed in `internal/service/webhook` by #198):
|
||||||
|
`TestHandleWebhookProcessesValidWebhook` now waits with the webhook service's
|
||||||
|
`WaitForDeployments` instead of sleeping (#211).
|
||||||
|
- 2026-09-22: Vendored the canonical prettier/format toolchain from the
|
||||||
|
`sneak/prompts` scaffold: added `.prettierrc` (tabWidth 4, proseWrap always),
|
||||||
|
pinned `package.json` + `yarn.lock` (prettier 3.8.1), taught
|
||||||
|
`script/bootstrap` to install a pinned node/yarn via a hash-verified nvm
|
||||||
|
archive, and switched `script/fmt` to the pinned prettier reading
|
||||||
|
`.prettierrc` (no inline flags) over `static/js/*.js` and `**/*.md`. Reflowed
|
||||||
|
all markdown to house style; `alpine.min.js` stays byte-identical (#203).
|
||||||
- 2026-09-22: Fixed the flaky `t.TempDir` cleanup race in
|
- 2026-09-22: Fixed the flaky `t.TempDir` cleanup race in
|
||||||
`internal/service/webhook` by tracking the async deployment goroutine
|
`internal/service/webhook` by tracking the async deployment goroutine in a
|
||||||
in a `sync.WaitGroup` and exposing `WaitForDeployments`; tests now
|
`sync.WaitGroup` and exposing `WaitForDeployments`; tests now synchronize on
|
||||||
synchronize on completion instead of sleeping (#198).
|
completion instead of sleeping (#198).
|
||||||
- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint`
|
- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint` (pinned
|
||||||
(pinned golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg
|
golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg so the linter
|
||||||
so the linter always executes), reduced `script/lint` to building it,
|
always executes), reduced `script/lint` to building it, dropped the
|
||||||
dropped the golangci-lint install from `script/bootstrap`, and switched
|
golangci-lint install from `script/bootstrap`, and switched the `Dockerfile`
|
||||||
the `Dockerfile` lint stage to invoke `golangci-lint` directly instead
|
lint stage to invoke `golangci-lint` directly instead of `make lint` to avoid
|
||||||
of `make lint` to avoid docker-in-docker (#188).
|
docker-in-docker (#188).
|
||||||
- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites
|
- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites the
|
||||||
the vendored `static/js/alpine.min.js` bundle (#185).
|
vendored `static/js/alpine.min.js` bundle (#185).
|
||||||
- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy
|
- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy log
|
||||||
log download handler by verifying the resolved path stays within the
|
download handler by verifying the resolved path stays within the deploy log
|
||||||
deploy log directory before serving, returning 404 on escape (#177).
|
directory before serving, returning 404 on escape (#177).
|
||||||
- 2026-09-22: `script/bootstrap` now installs a pinned `goimports`
|
- 2026-09-22: `script/bootstrap` now installs a pinned `goimports`
|
||||||
(`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt`
|
(`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` succeeds
|
||||||
succeeds on a fresh machine after `make bootstrap` (#184).
|
on a fresh machine after `make bootstrap` (#184).
|
||||||
- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin
|
- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin check
|
||||||
check over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git
|
over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git image when
|
||||||
image when absent (#190), the env-var editor CSRF token lookup (#191),
|
absent (#190), the env-var editor CSRF token lookup (#191), and the
|
||||||
and the port-mapping delete form's CSRF field (#192).
|
port-mapping delete form's CSRF field (#192).
|
||||||
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical
|
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical `.golangci.yml`,
|
||||||
`.golangci.yml`, `Dockerfile` lint stage pin, `script/bootstrap`
|
`Dockerfile` lint stage pin, `script/bootstrap` release-archive pins) and
|
||||||
release-archive pins) and fixed all resulting lint findings (noctx,
|
fixed all resulting lint findings (noctx, gosec, goconst, lll, dupl,
|
||||||
gosec, goconst, lll, dupl, nolintlint); `make check` green.
|
nolintlint); `make check` green.
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
Makefile shims, README Entrypoints section
|
shims, README Entrypoints section
|
||||||
- 2026-03-11: Monolithic env var editing with bulk save (#158).
|
- 2026-03-11: Monolithic env var editing with bulk save (#158).
|
||||||
- 2026-03-10: Webhook event history UI page (#164); added missing
|
- 2026-03-10: Webhook event history UI page (#164); added missing Makefile
|
||||||
Makefile docker and hooks targets plus test timeout (#159);
|
docker and hooks targets plus test timeout (#159); notification settings
|
||||||
notification settings passed from create form (#160).
|
passed from create form (#160).
|
||||||
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
|
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
|
||||||
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143);
|
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); Dockerfile
|
||||||
Dockerfile split into lint and build stages with forced lint
|
split into lint and build stages with forced lint execution (#152, #154).
|
||||||
execution (#152, #154).
|
|
||||||
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
|
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
|
||||||
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error,
|
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, deployment
|
||||||
deployment log size cap, error path rendering, docker-compose bind
|
log size cap, error path rendering, docker-compose bind mount, domain type
|
||||||
mount, domain type refactor.
|
refactor.
|
||||||
- CI simplified to docker build only (#130).
|
- CI simplified to docker build only (#130).
|
||||||
- 2025-12-29 onward: core PaaS built out: deploys with real-time build
|
- 2025-12-29 onward: core PaaS built out: deploys with real-time build log
|
||||||
log streaming, container start/stop/restart and logs, TCP/UDP port
|
streaming, container start/stop/restart and logs, TCP/UDP port mapping,
|
||||||
mapping, Alpine.js UI, Slack notifications, ULID app IDs, session
|
Alpine.js UI, Slack notifications, ULID app IDs, session handling.
|
||||||
handling.
|
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
|||||||
@@ -656,11 +656,14 @@ func (c *Client) performClone(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The git image declares a volume, so Docker gives each clone container
|
||||||
|
// an anonymous volume; remove it with the container. The removal must
|
||||||
|
// still run when the deploy is cancelled.
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = c.docker.ContainerRemove(
|
_ = c.docker.ContainerRemove(
|
||||||
ctx,
|
context.WithoutCancel(ctx),
|
||||||
gitContainerID.String(),
|
gitContainerID.String(),
|
||||||
container.RemoveOptions{Force: true},
|
container.RemoveOptions{Force: true, RemoveVolumes: true},
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mainBranch is the branch name used across validation tests.
|
// mainBranch is the branch name used across validation tests.
|
||||||
@@ -149,3 +158,84 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPerformCloneRemovesContainerVolumes runs a clone against a fake Docker
|
||||||
|
// API and checks that the clone container is removed together with its
|
||||||
|
// anonymous volumes, whether the clone succeeds, fails, or is cancelled.
|
||||||
|
func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exitCode int
|
||||||
|
cancel bool
|
||||||
|
}{
|
||||||
|
{name: "succeeds", exitCode: 0},
|
||||||
|
{name: "fails", exitCode: 1},
|
||||||
|
{name: "cancelled", cancel: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
removeQuery := make(chan url.Values, 1)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodDelete:
|
||||||
|
removeQuery <- r.URL.Query()
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
||||||
|
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/wait") && tt.cancel:
|
||||||
|
// Cancel the deploy while the clone is running.
|
||||||
|
cancel()
|
||||||
|
<-r.Context().Done()
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/wait"):
|
||||||
|
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, tt.exitCode)
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
dockerAPI, err := client.NewClientWithOpts(
|
||||||
|
client.WithHost("tcp://" + srv.Listener.Addr().String()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Client{docker: dockerAPI, log: slog.Default()}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &cloneConfig{
|
||||||
|
repoURL: "git@example.com:repo.git",
|
||||||
|
branch: mainBranch,
|
||||||
|
sshPrivateKey: "fake-key",
|
||||||
|
containerDir: filepath.Join(dir, "repo"),
|
||||||
|
hostDir: filepath.Join(dir, "repo"),
|
||||||
|
keyFile: filepath.Join(dir, "deploy_key"),
|
||||||
|
hostKeyFile: filepath.Join(dir, "deploy_key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = c.performClone(ctx, cfg)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case query := <-removeQuery:
|
||||||
|
if query.Get("v") != "1" {
|
||||||
|
t.Errorf("clone container removed without its volumes: %v", query)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Error("clone container was not removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -640,7 +642,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
|
|
||||||
file, openErr := root.Open(relPath)
|
file, openErr := openDeploymentLog(root, relPath)
|
||||||
if openErr != nil {
|
if openErr != nil {
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
@@ -665,6 +667,24 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openDeploymentLog opens a deployment log file inside the log root.
|
||||||
|
// Logs written by older versions sit one directory deeper, under the
|
||||||
|
// hostname of the container that wrote them, so when the file is not at
|
||||||
|
// relPath it is looked for under any directory directly below the root.
|
||||||
|
func openDeploymentLog(root *os.Root, relPath string) (*os.File, error) {
|
||||||
|
file, err := root.Open(relPath)
|
||||||
|
if err == nil {
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, globErr := fs.Glob(root.FS(), path.Join("*", filepath.ToSlash(relPath)))
|
||||||
|
if globErr != nil || len(matches) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.Open(matches[0])
|
||||||
|
}
|
||||||
|
|
||||||
// containerLogsAPITail is the default number of log lines for the container logs API.
|
// containerLogsAPITail is the default number of log lines for the container logs API.
|
||||||
const containerLogsAPITail = "100"
|
const containerLogsAPITail = "100"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -43,6 +42,7 @@ type testContext struct {
|
|||||||
authSvc *auth.Service
|
authSvc *auth.Service
|
||||||
appSvc *app.Service
|
appSvc *app.Service
|
||||||
deploySvc *deploy.Service
|
deploySvc *deploy.Service
|
||||||
|
webhookSvc *webhook.Service
|
||||||
middleware *middleware.Middleware
|
middleware *middleware.Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +188,7 @@ func setupTestHandlers(t *testing.T) *testContext {
|
|||||||
authSvc: authSvc,
|
authSvc: authSvc,
|
||||||
appSvc: appSvc,
|
appSvc: appSvc,
|
||||||
deploySvc: deploySvc,
|
deploySvc: deploySvc,
|
||||||
|
webhookSvc: webhookSvc,
|
||||||
middleware: mw,
|
middleware: mw,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1213,8 +1214,7 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
// Allow async deployment goroutine to complete before test cleanup.
|
// Wait for the async deployment goroutine to finish so its writes
|
||||||
// The deployment will fail quickly (docker not connected) but we need
|
// under the temp dir complete before test cleanup.
|
||||||
// to wait for it to finish to avoid temp directory cleanup race.
|
testCtx.webhookSvc.WaitForDeployments()
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,56 @@ func TestHandleDeploymentLogDownloadServesLegitimateFile(t *testing.T) {
|
|||||||
assert.Contains(t, recorder.Body.String(), "deploy log contents")
|
assert.Contains(t, recorder.Body.String(), "deploy log contents")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetLogFilePathHasNoHostname verifies a deployment log is stored
|
||||||
|
// directly under logs/<appname>/, with no hostname directory in between,
|
||||||
|
// so it is still found after the container is recreated with a new
|
||||||
|
// hostname.
|
||||||
|
func TestGetLogFilePathHasNoHostname(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCtx := setupTestHandlers(t)
|
||||||
|
createdApp := createTestApp(t, testCtx, "log-path-app")
|
||||||
|
|
||||||
|
deployment := models.NewDeployment(testCtx.database)
|
||||||
|
deployment.AppID = createdApp.ID
|
||||||
|
|
||||||
|
logPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
filepath.Join(testCtx.deploySvc.GetLogDir(), createdApp.Name),
|
||||||
|
filepath.Dir(logPath),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeploymentLogDownloadServesLogFromOldHostnameDir verifies a
|
||||||
|
// log written by an older version, under the hostname of a container that
|
||||||
|
// has since been recreated, can still be downloaded.
|
||||||
|
func TestHandleDeploymentLogDownloadServesLogFromOldHostnameDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCtx := setupTestHandlers(t)
|
||||||
|
createdApp := createTestApp(t, testCtx, "log-old-hostname-app")
|
||||||
|
|
||||||
|
deployment := models.NewDeployment(testCtx.database)
|
||||||
|
deployment.AppID = createdApp.ID
|
||||||
|
deployment.Status = models.DeploymentStatusSuccess
|
||||||
|
require.NoError(t, deployment.Save(context.Background()))
|
||||||
|
|
||||||
|
logDir := testCtx.deploySvc.GetLogDir()
|
||||||
|
newPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
|
||||||
|
relPath, relErr := filepath.Rel(logDir, newPath)
|
||||||
|
require.NoError(t, relErr)
|
||||||
|
|
||||||
|
oldPath := filepath.Join(logDir, "old-container-hostname", relPath)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o750))
|
||||||
|
require.NoError(t, os.WriteFile(oldPath, []byte("old deploy log contents"), 0o600))
|
||||||
|
|
||||||
|
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
assert.Contains(t, recorder.Body.String(), "old deploy log contents")
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleDeploymentLogDownloadRejectsPathTraversal verifies the
|
// TestHandleDeploymentLogDownloadRejectsPathTraversal verifies the
|
||||||
// os.Root containment guard. A traversal-shaped app name drives the
|
// os.Root containment guard. A traversal-shaped app name drives the
|
||||||
// resolved log path out of the deploy log directory onto a sentinel
|
// resolved log path out of the deploy log directory onto a sentinel
|
||||||
|
|||||||
@@ -261,15 +261,14 @@ func (svc *Service) GetBuildDir(appName string) string {
|
|||||||
|
|
||||||
// GetLogFilePath returns the path to the log file for a deployment.
|
// GetLogFilePath returns the path to the log file for a deployment.
|
||||||
// Returns empty string if the path cannot be determined.
|
// Returns empty string if the path cannot be determined.
|
||||||
|
//
|
||||||
|
// The path must not depend on the container's hostname: Docker assigns a
|
||||||
|
// new one whenever the container is recreated, and older logs would then
|
||||||
|
// no longer be found.
|
||||||
func (svc *Service) GetLogFilePath(
|
func (svc *Service) GetLogFilePath(
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) string {
|
) string {
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
hostname = "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get commit SHA
|
// Get commit SHA
|
||||||
sha := ""
|
sha := ""
|
||||||
if deployment.CommitSHA.Valid && deployment.CommitSHA.String != "" {
|
if deployment.CommitSHA.Valid && deployment.CommitSHA.String != "" {
|
||||||
@@ -291,7 +290,7 @@ func (svc *Service) GetLogFilePath(
|
|||||||
filename = fmt.Sprintf("%s_%s.log.txt", app.Name, timestamp)
|
filename = fmt.Sprintf("%s_%s.log.txt", app.Name, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename)
|
return filepath.Join(svc.config.DataDir, "logs", app.Name, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogDir returns the root directory under which all deployment log
|
// GetLogDir returns the root directory under which all deployment log
|
||||||
@@ -1294,7 +1293,7 @@ func (svc *Service) failDeployment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeLogsToFile writes the deployment logs to a file on disk.
|
// writeLogsToFile writes the deployment logs to a file on disk.
|
||||||
// Structure: DataDir/logs/<hostname>/<appname>/<appname>_<sha>_<timestamp>.log.txt
|
// Structure: DataDir/logs/<appname>/<appname>_<sha>_<timestamp>.log.txt
|
||||||
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
|
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
|
||||||
if !deployment.Logs.Valid || deployment.Logs.String == "" {
|
if !deployment.Logs.Valid || deployment.Logs.String == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "3.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-2
@@ -5,8 +5,12 @@
|
|||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). goimports is installed with `go install` at a pinned
|
# make, or go). goimports is installed with `go install` at a pinned
|
||||||
# version (integrity via the Go module checksum database) into
|
# version (integrity via the Go module checksum database) into
|
||||||
# /usr/local/bin so it is on PATH. The linter is not installed here: it
|
# /usr/local/bin so it is on PATH. Node is used directly if installed;
|
||||||
# runs only in Docker via script/lint, so docker is its sole prerequisite.
|
# otherwise it is installed at a pinned version via nvm (installing nvm
|
||||||
|
# itself first, from a hash-verified release archive, never curl | sh),
|
||||||
|
# then the pinned prettier from yarn.lock. The linter is not installed
|
||||||
|
# here: it runs only in Docker via script/lint, so docker is its sole
|
||||||
|
# prerequisite.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -15,6 +19,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
# golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches
|
# golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches
|
||||||
# go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database.
|
# go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database.
|
||||||
GOIMPORTS_VERSION="v0.49.0"
|
GOIMPORTS_VERSION="v0.49.0"
|
||||||
|
# Node/yarn toolchain, 2026-07-06.
|
||||||
|
NODE_VERSION="22.17.0"
|
||||||
|
NVM_VERSION="0.40.3"
|
||||||
|
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
|
||||||
|
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
|
||||||
|
YARN_VERSION="1.22.22"
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
@@ -56,6 +66,21 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# verify_sha256 <file> <expected-hash>
|
||||||
|
verify_sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# goimports is not packaged uniformly across nix/apt/brew/apk, so install it
|
# goimports is not packaged uniformly across nix/apt/brew/apk, so install it
|
||||||
# with `go install` at a pinned version and place the binary in /usr/local/bin
|
# with `go install` at a pinned version and place the binary in /usr/local/bin
|
||||||
# so it is on PATH regardless of shell config. Requires go, which main
|
# so it is on PATH regardless of shell config. Requires go, which main
|
||||||
@@ -69,6 +94,54 @@ ensure_goimports() {
|
|||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# nvm is a bash script; run a command in a bash with nvm loaded
|
||||||
|
nvm_sh() {
|
||||||
|
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_nvm() {
|
||||||
|
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
|
||||||
|
# nvm prerequisites; nvm itself requires bash
|
||||||
|
if missing bash; then pkg_install bash bash bash bash; fi
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
if missing git; then pkg_install git git git git; fi
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/nvm.tar.gz" \
|
||||||
|
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
|
||||||
|
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
|
||||||
|
mkdir -p "$HOME/.nvm"
|
||||||
|
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_node() {
|
||||||
|
if ! missing node; then return 0; fi
|
||||||
|
ensure_nvm
|
||||||
|
nvm_sh "nvm install $NODE_VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_yarn() {
|
||||||
|
if ! missing yarn; then return 0; fi
|
||||||
|
if ! missing corepack; then
|
||||||
|
corepack enable
|
||||||
|
corepack prepare "yarn@$YARN_VERSION" --activate
|
||||||
|
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
|
||||||
|
corepack prepare yarn@$YARN_VERSION --activate"
|
||||||
|
else
|
||||||
|
npm install -g "yarn@$YARN_VERSION"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_js_deps() {
|
||||||
|
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||||
|
yarn install --frozen-lockfile"
|
||||||
|
else
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -80,6 +153,11 @@ main() {
|
|||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
ensure_goimports
|
ensure_goimports
|
||||||
|
|
||||||
|
# Node toolchain and pinned prettier
|
||||||
|
ensure_node
|
||||||
|
ensure_yarn
|
||||||
|
install_js_deps
|
||||||
|
|
||||||
# The linter runs only in Docker (script/lint). Warn, don't fail: the
|
# The linter runs only in Docker (script/lint). Warn, don't fail: the
|
||||||
# rest of the repo works without it.
|
# rest of the repo works without it.
|
||||||
if missing docker; then
|
if missing docker; then
|
||||||
|
|||||||
+24
-1
@@ -4,11 +4,34 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Must match the pin in script/bootstrap.
|
||||||
|
NODE_VERSION="22.17.0"
|
||||||
|
|
||||||
|
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||||
|
# on the PATH of the shell that called it, so resolve the pinned
|
||||||
|
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||||
|
# bash script, hence the subshell.
|
||||||
|
run_yarn() {
|
||||||
|
if command -v yarn >/dev/null 2>&1; then
|
||||||
|
yarn "$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
echo "fmt: no yarn; run script/bootstrap first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||||
|
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
gofmt -s -w .
|
gofmt -s -w .
|
||||||
goimports -w .
|
goimports -w .
|
||||||
npx prettier --write static/js/*.js
|
# Pinned prettier reads settings from .prettierrc (tabWidth 4,
|
||||||
|
# proseWrap always); .prettierignore keeps alpine.min.js untouched.
|
||||||
|
# Globs are quoted so prettier expands them, not the shell.
|
||||||
|
run_yarn run prettier --write 'static/js/*.js' '**/*.md'
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
prettier@3.8.1:
|
||||||
|
version "3.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
||||||
|
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
||||||
Reference in New Issue
Block a user