Author SHA1 Message Date
sneak 9cd50acd85 Add docker-compose.yml for deploying upaas (closes #223)
Check / check (pull_request) Skipped
The compose file builds the image from this repo, mounts the Docker
socket and HOST_DATA_DIR (passed to upaas as UPAAS_HOST_DATA_DIR),
reads settings from .env, and restarts unless stopped. The port is
published on 127.0.0.1 only, for a TLS-terminating proxy in front.
PORT and UPAAS_DATA_DIR are pinned so .env cannot move upaas off the
port mapping, the healthcheck (busybox wget) or the data mount.

upaas now refuses to start when UPAAS_HOST_DATA_DIR is set to a
relative path; when unset it still falls back to the data directory.

The README's plain-HTTP Compose example becomes a short deploy section
that points at the file. .env is added to .dockerignore.

Model: opus-5-5
2026-09-28 09:59:23 +00:00
42 changed files with 303 additions and 2331 deletions

No files matched your search

+11 -35
View File
@@ -1,35 +1,11 @@
# .git is sent so that `make build` in the Dockerfile can stamp the commit into
# upaas. List no tracked file here: git would see it as deleted in the build and
# the version would end in -dirty.
# The patterns of .gitignore; **/ makes Docker match them in every directory.
**/.DS_Store
**/Thumbs.db
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea/
**/.vscode/
**/*.sublime-*
**/node_modules/
**/.env
**/.env.*
**/*.pem
**/*.key
**/bin/
**/*.exe
**/*.exe~
**/*.dll
**/*.so
**/*.dylib
**/*.test
**/*.out
/data/
# Git never applies its ignore patterns inside .git; send all of it again.
!.git/**
# .git is sent without its config, because a remote URL there can carry a
# credential; `git describe` does not need it. Keep this after !.git/**.
.git/config
.git
.env
bin/
.editorconfig
.vscode/
.idea/
*.test
LICENSE
CONVENTIONS.md
REPO_POLICIES.md
README.md
-5
View File
@@ -1,5 +1,3 @@
# .dockerignore repeats these patterns; change both together.
# OS
.DS_Store
Thumbs.db
@@ -31,6 +29,3 @@ bin/
*.dylib
*.test
*.out
# upaasd's data directory when it runs from the checkout (UPAAS_DATA_DIR default)
/data/
+10 -2
View File
@@ -117,11 +117,13 @@ import (
var (
Appname string = "CHANGEME"
Version string
Buildarch string
)
func main() {
globals.Appname = Appname
globals.Version = Version
globals.Buildarch = Buildarch
fx.New(
fx.Provide(
@@ -821,7 +823,7 @@ func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
"arch", runtime.GOARCH,
"buildarch", l.params.Globals.Buildarch,
)
}
```
@@ -943,17 +945,20 @@ import "go.uber.org/fx"
var (
Appname string
Version string
Buildarch string
)
// Struct for DI
type Globals struct {
Appname string
Version string
Buildarch string
}
func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{
Appname: Appname,
Buildarch: Buildarch,
Version: Version,
}
return n, nil
@@ -967,11 +972,13 @@ func New(lc fx.Lifecycle) (*Globals, error) {
var (
Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time
Buildarch string // Set at build time
)
func main() {
globals.Appname = Appname
globals.Version = Version
globals.Buildarch = Buildarch
// ...
}
```
@@ -982,9 +989,10 @@ Use ldflags to inject version information at build time:
```makefile
VERSION := $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build:
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
```
---
-1
View File
@@ -31,7 +31,6 @@ RUN go mod download
COPY . .
RUN make test
# Takes the version from `git describe` on the .git copied in above.
RUN make build
# Runtime stage
+2 -1
View File
@@ -2,7 +2,8 @@
BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION)
BUILDARCH := $(shell go env GOARCH)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
+13 -45
View File
@@ -192,23 +192,16 @@ This ensures the main branch always contains clean, tested, working code.
Environment variables:
| Variable | Description | Default |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `PORT` | HTTP listen port. `UPAAS_PORT` is also read and wins when both are set. | 8080 |
| `UPAAS_DATA_DIR` | Directory for the SQLite database, session key, builds and deployment logs. Deploys need it to be an absolute path unless `UPAAS_HOST_DATA_DIR` is set. | `./data` (the Docker image sets `/var/lib/upaas`) |
| `UPAAS_HOST_DATA_DIR` | Host path of `UPAAS_DATA_DIR`, needed when upaas runs in a container so the bind mounts it passes to Docker point at the right host directory. When set, it must be absolute, or upaas refuses to start. | the value of `UPAAS_DATA_DIR` |
| `UPAAS_DOCKER_HOST` | Docker daemon address | unix:///var/run/docker.sock |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `PORT` | HTTP listen port | 8080 |
| `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_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_DEBUG` | Enable debug logging. Also sends the session cookie without the `Secure` flag. | false |
| `UPAAS_SENTRY_DSN` | Read but not used: upaas sends nothing to Sentry | "" |
| `UPAAS_METRICS_USERNAME` | When set, `/metrics` is served behind basic auth with this username. When unset, there is no `/metrics`. | "" |
| `UPAAS_METRICS_PASSWORD` | Basic auth password for `/metrics` | "" |
| `UPAAS_MAINTENANCE_MODE` | Only shown as `maintenanceMode` in the `/health` response; it blocks nothing | false |
| `UPAAS_SESSION_SECRET` | Key that signs the session and CSRF cookies. When unset, a random key is generated once and kept in `$UPAAS_DATA_DIR/session.key`. | "" |
| `UPAAS_CORS_ORIGINS` | Comma-separated origins allowed to make cross-origin requests with cookies. When unset, no CORS headers are sent. | "" |
The Docker client also reads the standard `DOCKER_API_VERSION`,
`DOCKER_CERT_PATH` and `DOCKER_TLS_VERIFY` variables; `UPAAS_DOCKER_HOST`, which
has a default, always overrides `DOCKER_HOST`.
| `DEBUG` | Enable debug logging | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
| `METRICS_USERNAME` | Basic auth for /metrics | "" |
| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
## Running with Docker
@@ -226,10 +219,6 @@ This recipe serves plain HTTP, so `UPAAS_PLAINTEXT_HTTP=true` is required for
setup and every other form to pass the CSRF origin check. Behind a
TLS-terminating reverse proxy, drop that line.
The image shows the commit it was built from (the `git describe` output) in the
page footer, the startup log and `/health`. Build it from a git clone: without
the `.git` directory it shows `dev`.
### Deploying with Docker Compose
[`docker-compose.yml`](docker-compose.yml) builds the image from this repo and
@@ -241,13 +230,10 @@ HOST_DATA_DIR=/srv/upaas/data
```
Other settings from [Configuration](#configuration) go in the same file, except
`PORT`, `UPAAS_PORT` and `UPAAS_DATA_DIR`: the compose file sets both port
settings to 8080 and `UPAAS_DATA_DIR` to `/var/lib/upaas`, overriding `.env`, to
match its port mapping, healthcheck and data directory mount. Then run
`docker compose up -d` from the repo root; `docker compose ps` shows the
container as healthy once `/health` answers. To update, run `git pull` and then
`docker compose up -d --build`: without `--build`, Compose keeps running the
image built from the old checkout.
`PORT` and `UPAAS_DATA_DIR`: the compose file sets them to 8080 and
`/var/lib/upaas`, overriding `.env`, to match its port mapping, healthcheck and
data directory mount. Then run `docker compose up -d` from the repo root;
`docker compose ps` shows the container as healthy once `/health` answers.
**Important**: `HOST_DATA_DIR` **must** be an **absolute path** on the host. It
is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR` so that
@@ -263,27 +249,9 @@ Docker's build cache rather than as untagged images. Docker Engine 28.2 and
later keeps that cache under a size limit by default; on older engines, set
`"builder": {"gc": {"enabled": true}}` in the host's `daemon.json`.
Building with BuildKit needs Docker Engine 18.09 or later; on an older engine,
upaas fails the deploy instead of building. A Dockerfile that uses
`RUN --network` needs Docker Engine 23.0 or later unless its `# syntax=` line
names Dockerfile frontend 1.3 or later, such as `docker/dockerfile:1`.
The build context leaves out the files the app's ignore file names, as
`docker build` does: `<Dockerfile>.dockerignore` next to the app's Dockerfile if
there is one, otherwise `.dockerignore` at the root of the repository. The
Dockerfile and `.dockerignore` are always sent, even when the ignore file names
them.
Session secrets are automatically generated on first startup and persisted to
`$UPAAS_DATA_DIR/session.key`.
### Volume mounts
An app's volume mounts are bind mounts of host paths. When a host path does not
exist yet, upaas has Docker create it as an empty directory, owned by root, when
the app's container starts; there is no need to create it first. An existing
host path is left as it is. This needs Docker Engine 23.0 or later.
## License
WTFPL
+80 -1
View File
@@ -10,13 +10,92 @@
# Status
1.0+.
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` is green
as of the golangci-lint v2.12.2 update.
# Next Step
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so main cannot
regress.
# Completed Steps
- 2026-09-28: Added `docker-compose.yml` for deploying upaas: settings from
`.env`, the port published on `127.0.0.1` only for a TLS proxy in front, and a
healthcheck against `/health`; the README's plain-HTTP Compose example is
replaced by a short deploy section. upaas now refuses to start when
`UPAAS_HOST_DATA_DIR` is set to a relative path (#223).
- 2026-09-23: Apps are now built with BuildKit, so the stages of a multi-stage
build stay in Docker's size-limited build cache instead of piling up as
untagged images; build progress is still written to the deployment log as
plain text (#220).
- 2026-09-23: After a successful deploy, upaas removes the app's images other
than the running one and the one rollback would use, together with the
untagged images they were built on (#216).
- 2026-09-23: The git clone container is now removed together with its anonymous
volume (the `alpine/git` image declares one at `/git`), so a deploy no longer
leaves a Docker volume behind (#215).
- 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
`internal/service/webhook` by tracking the async deployment goroutine in a
`sync.WaitGroup` and exposing `WaitForDeployments`; tests now synchronize on
completion instead of sleeping (#198).
- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint` (pinned
golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg so the linter
always executes), reduced `script/lint` to building it, dropped the
golangci-lint install from `script/bootstrap`, and switched the `Dockerfile`
lint stage to invoke `golangci-lint` directly instead of `make lint` to avoid
docker-in-docker (#188).
- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites the
vendored `static/js/alpine.min.js` bundle (#185).
- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy log
download handler by verifying the resolved path stays within the deploy log
directory before serving, returning 404 on escape (#177).
- 2026-09-22: `script/bootstrap` now installs a pinned `goimports`
(`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` succeeds
on a fresh machine after `make bootstrap` (#184).
- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin check
over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git image when
absent (#190), the env-var editor CSRF token lookup (#191), and the
port-mapping delete form's CSRF field (#192).
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical `.golangci.yml`,
`Dockerfile` lint stage pin, `script/bootstrap` release-archive pins) and
fixed all resulting lint findings (noctx, gosec, goconst, lll, dupl,
nolintlint); `make check` green.
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-03-11: Monolithic env var editing with bulk save (#158).
- 2026-03-10: Webhook event history UI page (#164); added missing Makefile
docker and hooks targets plus test timeout (#159); notification settings
passed from create form (#160).
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); Dockerfile
split into lint and build stages with forced lint execution (#152, #154).
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, deployment
log size cap, error path rendering, docker-compose bind mount, domain type
refactor.
- CI simplified to docker build only (#130).
- 2025-12-29 onward: core PaaS built out: deploys with real-time build log
streaming, container start/stop/restart and logs, TCP/UDP port mapping,
Alpine.js UI, Slack notifications, ULID app IDs, session handling.
# Future Steps
- Resume feature work only after main is green.
+3 -3
View File
@@ -27,11 +27,13 @@ import (
var (
Appname = "upaas" //nolint:gochecknoglobals // build-time variable
Version string //nolint:gochecknoglobals // build-time variable
Buildarch string //nolint:gochecknoglobals // build-time variable
)
func main() {
globals.SetAppname(Appname)
globals.SetVersion(Version)
globals.SetBuildarch(Buildarch)
fx.New(
fx.Provide(
@@ -50,8 +52,6 @@ func main() {
handlers.New,
server.New,
),
fx.Invoke(func(log *logger.Logger, _ *server.Server) {
log.Identify()
}),
fx.Invoke(func(*server.Server) {}),
).Run()
}
+2 -4
View File
@@ -7,11 +7,9 @@ services:
# Every line of .env is passed to upaas as an environment variable.
env_file: .env
environment:
# Override any PORT or UPAAS_PORT in .env, so upaas listens where the
# port mapping and healthcheck below expect it. Both are set because
# upaas reads UPAAS_PORT first.
# Overrides any PORT in .env, so upaas listens where the port mapping
# and healthcheck below expect it.
PORT: "8080"
UPAAS_PORT: "8080"
# Overrides any UPAAS_DATA_DIR in .env, so the database stays on the
# HOST_DATA_DIR mount below instead of inside the container.
UPAAS_DATA_DIR: /var/lib/upaas
+2 -2
View File
@@ -4,7 +4,6 @@ go 1.25
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/distribution/reference v0.6.0
github.com/docker/docker v27.3.1+incompatible
github.com/docker/go-connections v0.6.0
github.com/go-chi/chi/v5 v5.2.3
@@ -15,7 +14,6 @@ require (
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.32
github.com/moby/buildkit v0.16.0
github.com/moby/patternmatcher v0.6.0
github.com/oklog/ulid/v2 v2.1.1
github.com/prometheus/client_golang v1.23.2
github.com/spf13/viper v1.21.0
@@ -41,6 +39,7 @@ require (
github.com/containerd/ttrpc v1.2.5 // indirect
github.com/containerd/typeurl/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -61,6 +60,7 @@ require (
github.com/klauspost/compress v1.18.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.4.0 // indirect
+2 -5
View File
@@ -137,11 +137,8 @@ func (d *Database) connect(ctx context.Context) error {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Open database with WAL mode and foreign keys. Transactions take the
// write lock when they begin and wait up to 5s for another writer,
// instead of failing with "database is locked" when both write.
dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on" +
"&_txlock=immediate&_busy_timeout=5000"
// Open database with WAL mode and foreign keys
dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on"
database, err := sql.Open("sqlite3", dsn)
if err != nil {
-114
View File
@@ -1,114 +0,0 @@
package docker
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/docker/docker/pkg/archive"
"github.com/moby/patternmatcher"
"github.com/moby/patternmatcher/ignorefile"
)
// defaultDockerfileName is the Dockerfile Docker builds when none is named.
const defaultDockerfileName = "Dockerfile"
// defaultDockerignoreName is the ignore file at the root of a build context,
// read when the Dockerfile has no ignore file of its own.
const defaultDockerignoreName = ".dockerignore"
// tarBuildContext returns a tar of the build context in contextDir that leaves
// out the files the app's ignore file names, as docker build does; Docker does
// not apply the ignore file to a build context sent as a tar. dockerfile is
// the path of the Dockerfile inside contextDir.
func tarBuildContext(contextDir, dockerfile string) (io.ReadCloser, error) {
excludes, err := readDockerignore(contextDir, dockerfile)
if err != nil {
return nil, err
}
return archive.TarWithOptions(contextDir, &archive.TarOptions{
ExcludePatterns: excludes,
})
}
// readDockerignore returns the patterns of the files to leave out of the
// build context in contextDir, read as docker build reads them for the
// Dockerfile at dockerfile: from <dockerfile>.dockerignore next to the
// Dockerfile if there is one, otherwise from .dockerignore at the root of the
// context. Without either file there are no patterns.
func readDockerignore(contextDir, dockerfile string) ([]string, error) {
// Docker reads the Dockerfile path as a path inside the build context:
// cleaned, with a leading / and any .. that would lead out of the context
// dropped, so ./Dockerfile and /Dockerfile both name the Dockerfile at the
// root.
dockerfile = strings.TrimPrefix(filepath.Join("/", dockerfile), "/")
if dockerfile == "" {
dockerfile = defaultDockerfileName
}
// Reading through os.Root keeps the read inside the build context, even
// when the ignore file is a symlink.
root, err := os.OpenRoot(contextDir)
if err != nil {
return nil, err
}
defer func() { _ = root.Close() }()
// When a Dockerfile named Dockerfile is missing, Docker builds a
// lowercase dockerfile in the same directory instead, and docker build
// then reads dockerfile.dockerignore as its ignore file.
if filepath.Base(dockerfile) == defaultDockerfileName {
lowercase := filepath.Join(filepath.Dir(dockerfile), "dockerfile")
_, dockerfileErr := root.Lstat(dockerfile)
_, lowercaseErr := root.Lstat(lowercase)
if errors.Is(dockerfileErr, fs.ErrNotExist) && lowercaseErr == nil {
dockerfile = lowercase
}
}
name := dockerfile + defaultDockerignoreName
file, err := root.Open(name)
if errors.Is(err, fs.ErrNotExist) {
name = defaultDockerignoreName
file, err = root.Open(name)
}
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", name, err)
}
defer func() { _ = file.Close() }()
excludes, err := ignorefile.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", name, err)
}
// Like the docker command line, never leave out .dockerignore or the
// Dockerfile: Docker reads the Dockerfile from the context.
for _, keep := range []string{defaultDockerignoreName, filepath.ToSlash(dockerfile)} {
excluded, err := patternmatcher.MatchesOrParentMatches(keep, excludes)
if err != nil {
return nil, fmt.Errorf("invalid pattern in %s: %w", name, err)
}
if excluded {
excludes = append(excludes, "!"+keep)
}
}
return excludes, nil
}
-326
View File
@@ -1,326 +0,0 @@
package docker //nolint:testpackage // tests the unexported performBuild
import (
"archive/tar"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/docker/docker/client"
)
// File names used in more than one test build context, as constants to
// satisfy the goconst linter.
const (
testMainGo = "main.go"
testSecretFile = "secret.txt"
testDeployDockerfile = "deploy/Dockerfile"
testLowercaseDockerfile = "dockerfile"
)
// TestPerformBuildFollowsDockerignore runs builds against a fake Docker API
// and checks which files the build context sent to it holds.
func TestPerformBuildFollowsDockerignore(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dockerfile string
files map[string]string // path in the context: contents
want []string // files sent in the build context
}{
{
name: "no ignore file",
dockerfile: defaultDockerfileName,
files: map[string]string{defaultDockerfileName: "", testMainGo: ""},
want: []string{defaultDockerfileName, testMainGo},
},
{
name: "excludes and re-includes",
dockerfile: defaultDockerfileName,
files: map[string]string{
defaultDockerignoreName: "secret.txt\n*.md\n!README.md\n",
defaultDockerfileName: "",
"NOTES.md": "",
"README.md": "",
testMainGo: "",
testSecretFile: "",
},
want: []string{
defaultDockerignoreName, defaultDockerfileName, "README.md", testMainGo,
},
},
{
name: "keeps the Dockerfile and .dockerignore",
dockerfile: defaultDockerfileName,
files: map[string]string{
defaultDockerignoreName: "Dockerfile\n.dockerignore\nsecret.txt\n",
defaultDockerfileName: "",
testMainGo: "",
testSecretFile: "",
},
want: []string{defaultDockerignoreName, defaultDockerfileName, testMainGo},
},
{
name: "an ignore file next to the Dockerfile wins over .dockerignore",
dockerfile: testDeployDockerfile,
files: map[string]string{
defaultDockerignoreName: "main.go\n",
testDeployDockerfile: "",
"deploy/Dockerfile.dockerignore": "secret.txt\n",
testMainGo: "",
testSecretFile: "",
},
want: []string{
defaultDockerignoreName,
testDeployDockerfile,
"deploy/Dockerfile.dockerignore",
testMainGo,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
contextDir := t.TempDir()
writeFiles(t, contextDir, tt.files)
got, err := buildContextFiles(t, contextDir, tt.dockerfile)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(got, tt.want) {
t.Errorf("build context holds %q, want %q", got, tt.want)
}
})
}
}
// TestPerformBuildKeepsLowercaseDockerfile checks that when the Dockerfile
// named Dockerfile is missing, the lowercase dockerfile Docker builds instead
// stays in the build context, and its own ignore file is read.
func TestPerformBuildKeepsLowercaseDockerfile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string // path in the context: contents
want []string // files sent in the build context
}{
{
name: "kept when the ignore file names it",
files: map[string]string{
defaultDockerignoreName: "*\n",
testLowercaseDockerfile: "",
},
want: []string{defaultDockerignoreName, testLowercaseDockerfile},
},
{
name: "its own ignore file wins over .dockerignore",
files: map[string]string{
defaultDockerignoreName: "main.go\n",
testLowercaseDockerfile: "",
"dockerfile.dockerignore": "secret.txt\n",
testMainGo: "",
testSecretFile: "",
},
want: []string{
defaultDockerignoreName,
testLowercaseDockerfile,
"dockerfile.dockerignore",
testMainGo,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
contextDir := t.TempDir()
writeFiles(t, contextDir, tt.files)
got, err := buildContextFiles(t, contextDir, defaultDockerfileName)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(got, tt.want) {
t.Errorf("build context holds %q, want %q", got, tt.want)
}
})
}
}
// TestPerformBuildReadsDockerfilePathInsideContext checks that ./Dockerfile
// and /Dockerfile name the Dockerfile at the root of the context, as Docker
// reads them, so an ignore file that names the Dockerfile does not leave it
// out.
func TestPerformBuildReadsDockerfilePathInsideContext(t *testing.T) {
t.Parallel()
for _, dockerfile := range []string{"./Dockerfile", "/Dockerfile"} {
t.Run(dockerfile, func(t *testing.T) {
t.Parallel()
contextDir := t.TempDir()
writeFiles(t, contextDir, map[string]string{
defaultDockerignoreName: "Dockerfile\nsecret.txt\n",
defaultDockerfileName: "",
testMainGo: "",
testSecretFile: "",
})
got, err := buildContextFiles(t, contextDir, dockerfile)
if err != nil {
t.Fatal(err)
}
want := []string{defaultDockerignoreName, defaultDockerfileName, testMainGo}
if !slices.Equal(got, want) {
t.Errorf("build context holds %q, want %q", got, want)
}
})
}
}
// TestPerformBuildFailsOnMalformedDockerignore checks that a pattern the
// docker command line would reject fails the build.
func TestPerformBuildFailsOnMalformedDockerignore(t *testing.T) {
t.Parallel()
contextDir := t.TempDir()
writeFiles(t, contextDir, map[string]string{defaultDockerignoreName: "[\n"})
_, err := buildContextFiles(t, contextDir, defaultDockerfileName)
want := "failed to create build context: " +
"invalid pattern in .dockerignore: syntax error in pattern"
if err == nil || err.Error() != want {
t.Errorf("got error %v, want %q", err, want)
}
}
// TestPerformBuildFailsOnUnreadableDockerignore checks that an ignore file
// that cannot be read, here because it is a directory, fails the build.
func TestPerformBuildFailsOnUnreadableDockerignore(t *testing.T) {
t.Parallel()
contextDir := t.TempDir()
err := os.Mkdir(filepath.Join(contextDir, defaultDockerignoreName), 0o750)
if err != nil {
t.Fatal(err)
}
_, err = buildContextFiles(t, contextDir, defaultDockerfileName)
want := "failed to create build context: failed to read .dockerignore: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
t.Errorf("got error %v, want one starting %q", err, want)
}
}
// writeFiles writes files, a map of paths inside dir to their contents,
// creating the directories they are in.
func writeFiles(t *testing.T, dir string, files map[string]string) {
t.Helper()
for name, contents := range files {
path := filepath.Join(dir, name)
err := os.MkdirAll(filepath.Dir(path), 0o750)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(path, []byte(contents), 0o600)
if err != nil {
t.Fatal(err)
}
}
}
// buildContextFiles runs a build of contextDir against a fake Docker API and
// returns the names of the files in the build context sent to it, sorted.
func buildContextFiles(t *testing.T, contextDir, dockerfile string) ([]string, error) {
t.Helper()
sent := make(chan []string, 1)
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`))
case strings.HasSuffix(r.URL.Path, "/session"):
serveSession(t, w, r, make(chan string, 1))
case strings.HasSuffix(r.URL.Path, "/build"):
sent <- tarFileNames(t, r.Body)
default:
t.Errorf("unexpected request to %s", r.URL.Path)
}
},
))
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()}
_, err = c.performBuild(t.Context(), BuildImageOptions{
ContextDir: contextDir,
DockerfilePath: dockerfile,
})
if err != nil {
return nil, err
}
return <-sent, nil
}
// tarFileNames returns the names of the regular files in the tar read from r,
// sorted.
func tarFileNames(t *testing.T, r io.Reader) []string {
t.Helper()
var names []string
reader := tar.NewReader(r)
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Errorf("reading the build context: %v", err)
return nil
}
if header.Typeflag == tar.TypeReg {
names = append(names, header.Name)
}
}
slices.Sort(names)
return names
}
+25 -152
View File
@@ -3,14 +3,12 @@ package docker
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
"regexp"
@@ -23,14 +21,12 @@ import (
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
controlapi "github.com/moby/buildkit/api/services/control"
buildkitclient "github.com/moby/buildkit/client"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/util/progress/progressui"
"go.uber.org/fx"
@@ -65,15 +61,6 @@ var ErrInvalidBranch = errors.New("invalid branch name")
// ErrInvalidCommitSHA is returned when a commit SHA is not a valid hex string.
var ErrInvalidCommitSHA = errors.New("invalid commit SHA")
// ErrBuildKitUnavailable is returned when the Docker daemon is too old to
// build with BuildKit.
var ErrBuildKitUnavailable = errors.New("BuildKit is unavailable on the Docker daemon")
// minBuildKitAPIVersion is the API version of Docker Engine 18.09, the first
// that builds with BuildKit when asked to without experimental mode. Older
// daemons refuse the request or silently use the legacy builder.
const minBuildKitAPIVersion = "1.39"
// validBranchRe matches safe git branch names.
var validBranchRe = regexp.MustCompile(`^[a-zA-Z0-9._/\-]+$`)
@@ -225,9 +212,7 @@ func buildEnvSlice(env map[string]string) []string {
return envSlice
}
// buildMounts converts volume mounts to Docker mount configuration. Docker,
// not upaas, creates a missing host path, because upaas runs in a container
// and cannot see the host's filesystem. An existing path is left alone.
// buildMounts converts volume mounts to Docker mount configuration.
func buildMounts(volumes []VolumeMount) []mount.Mount {
mounts := make([]mount.Mount, 0, len(volumes))
@@ -237,9 +222,6 @@ func buildMounts(volumes []VolumeMount) []mount.Mount {
Source: vol.HostPath,
Target: vol.ContainerPath,
ReadOnly: vol.ReadOnly,
BindOptions: &mount.BindOptions{
CreateMountpoint: true,
},
})
}
@@ -391,17 +373,12 @@ func (c *Client) ContainerLogs(
}
}()
// A container without a terminal, as all of upaas's are, sends its
// output in frames, each with a header naming stdout or stderr. Both
// go to one buffer, in the order they were written.
var logs bytes.Buffer
_, err = stdcopy.StdCopy(&logs, &logs, reader)
logs, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("failed to read container logs: %w", err)
}
return logs.String(), nil
return string(logs), nil
}
// IsContainerRunning checks if a container is running.
@@ -504,7 +481,6 @@ type cloneConfig struct {
type CloneResult struct {
Output string // Combined stdout/stderr from git clone
CommitSHA string // The HEAD commit SHA after clone/checkout
ShortSHA string // git's short form of CommitSHA (git rev-parse --short)
}
// CloneRepo clones a git repository using SSH and optionally checks out a
@@ -567,7 +543,7 @@ func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
}
// ListImageTags returns the tags in the given repository, such as
// "upaas-myapp:1a2b3c4" in "upaas-myapp", each with the ID of its image.
// "upaas-myapp:12" in "upaas-myapp", each with the ID of its image.
// Tags the same image has in other repositories are left out.
func (c *Client) ListImageTags(
ctx context.Context,
@@ -597,44 +573,19 @@ func (c *Client) ListImageTags(
return tags, nil
}
// ListUntaggedImages returns the IDs of the images that have no tag, such
// as one whose tag a later build gave to the image it built.
func (c *Client) ListUntaggedImages(ctx context.Context) ([]ImageID, error) {
if c.docker == nil {
return nil, ErrNotConnected
}
images, err := c.docker.ImageList(ctx, image.ListOptions{
Filters: filters.NewArgs(filters.Arg("dangling", "true")),
})
if err != nil {
return nil, fmt.Errorf("failed to list untagged images: %w", err)
}
imageIDs := make([]ImageID, 0, len(images))
for _, img := range images {
imageIDs = append(imageIDs, ImageID(img.ID))
}
return imageIDs, nil
}
// RemoveImageTag removes the tag name, such as "upaas-myapp:1a2b3c4",
// without force. Docker then deletes the image, and the untagged images it
// was built on, only if no other tag and no container still uses it. If name
// is instead the ID of an untagged image, it removes that image unless a
// container uses it.
func (c *Client) RemoveImageTag(ctx context.Context, name string) error {
// RemoveImageTag removes a tag such as "upaas-myapp:12", without force.
// Docker then deletes the image, and the untagged images it was built on,
// only if no other tag and no container still uses it.
func (c *Client) RemoveImageTag(ctx context.Context, tag string) error {
if c.docker == nil {
return ErrNotConnected
}
_, err := c.docker.ImageRemove(ctx, name, image.RemoveOptions{
_, err := c.docker.ImageRemove(ctx, tag, image.RemoveOptions{
PruneChildren: true,
})
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("failed to remove image %s: %w", name, err)
return fmt.Errorf("failed to remove image tag %s: %w", tag, err)
}
return nil
@@ -644,22 +595,8 @@ func (c *Client) performBuild(
ctx context.Context,
opts BuildImageOptions,
) (ImageID, error) {
server, err := c.docker.ServerVersion(ctx)
if err != nil {
return "", fmt.Errorf("failed to get Docker version: %w", err)
}
if versions.LessThan(server.APIVersion, minBuildKitAPIVersion) {
return "", fmt.Errorf(
"%w: Docker Engine %s (API %s) is older than 18.09 (API %s); "+
"upgrade Docker Engine",
ErrBuildKitUnavailable, server.Version, server.APIVersion,
minBuildKitAPIVersion,
)
}
// Create tar archive of build context
tarArchive, err := tarBuildContext(opts.ContextDir, opts.DockerfilePath)
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
if err != nil {
return "", fmt.Errorf("failed to create build context: %w", err)
}
@@ -671,24 +608,11 @@ func (c *Client) performBuild(
}
}()
buildSession, err := c.startBuildSession(ctx)
if err != nil {
return "", err
}
defer func() {
closeErr := buildSession.Close()
if closeErr != nil {
c.log.Error("failed to close build session", "error", closeErr)
}
}()
// Build with BuildKit: the stages of a multi-stage build are kept in
// its build cache, which Docker limits on its own, instead of being
// left behind as untagged images.
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
Version: dockertypes.BuilderBuildKit,
SessionID: buildSession.ID(),
Dockerfile: opts.DockerfilePath,
Tags: opts.Tags,
Remove: true,
@@ -724,34 +648,6 @@ func (c *Client) performBuild(
return "", nil
}
// startBuildSession attaches a BuildKit session to the daemon, as the docker
// command line does for a build. BuildKit asks the client, over the session,
// for registry access to fetch a base image that is not on the host; without
// a session, Docker Engine 27 fails the build with "no active sessions". The
// shared key is only used for a build context sent over the session; upaas
// sends the context with the build request. The caller closes the session.
func (c *Client) startBuildSession(ctx context.Context) (*session.Session, error) {
buildSession, err := session.NewSession(ctx, "")
if err != nil {
return nil, fmt.Errorf("failed to create build session: %w", err)
}
go func() {
runErr := buildSession.Run(ctx, func(
ctx context.Context,
proto string,
meta map[string][]string,
) (net.Conn, error) {
return c.docker.DialHijack(ctx, "/session", proto, meta)
})
if runErr != nil {
c.log.Error("build session failed", "error", runErr)
}
}()
return buildSession, nil
}
// scannerInitialBufferSize is the initial buffer size for the build log scanner.
const scannerInitialBufferSize = 64 * 1024 // 64KB
@@ -764,8 +660,7 @@ const scannerMaxBufferSize = 1024 * 1024 // 1MB
// newline-delimited JSON. BuildKit's progress arrives encoded in
// "moby.buildkit.trace" messages; these are decoded and written as plain
// text, as "docker build --progress=plain" shows it. Other lines, such as
// build errors, are written unchanged. Docker ends a failed build with a line
// carrying the error; it is returned once the output is written.
// build errors, are written unchanged.
func (c *Client) streamBuildOutput(
ctx context.Context,
body io.Reader,
@@ -795,8 +690,6 @@ func (c *Client) streamBuildOutput(
buf := make([]byte, 0, scannerInitialBufferSize)
scanner.Buffer(buf, scannerMaxBufferSize)
var buildErr error
for scanner.Scan() {
line := scanner.Bytes()
@@ -815,10 +708,6 @@ func (c *Client) streamBuildOutput(
continue
}
if err == nil && msg.Error != nil {
buildErr = msg.Error
}
// One write per line, so it is not split by the display's output.
_, _ = fmt.Fprintf(out, "%s\n", line)
}
@@ -831,7 +720,7 @@ func (c *Client) streamBuildOutput(
return fmt.Errorf("failed to read build output: %w", scanErr)
}
return buildErr
return nil
}
func (c *Client) performClone(
@@ -930,13 +819,11 @@ func (c *Client) createGitContainer(
// Clone without depth limit so we can checkout any commit, then checkout specific SHA
script = `git clone --branch "$CLONE_BRANCH" "$CLONE_URL" /repo` +
` && cd /repo && git checkout "$CLONE_SHA"` +
` && echo COMMIT:$(git rev-parse HEAD)` +
` && echo SHORT_SHA:$(git rev-parse --short HEAD)`
` && echo COMMIT:$(git rev-parse HEAD)`
} else {
// Shallow clone of branch HEAD, then output commit SHA
script = `git clone --depth 1 --branch "$CLONE_BRANCH" "$CLONE_URL" /repo` +
` && cd /repo && echo COMMIT:$(git rev-parse HEAD)` +
` && echo SHORT_SHA:$(git rev-parse --short HEAD)`
` && cd /repo && echo COMMIT:$(git rev-parse HEAD)`
}
env := []string{
@@ -1014,37 +901,23 @@ func (c *Client) runGitClone(
)
}
// Parse the commit from the "COMMIT:" and "SHORT_SHA:" lines.
result := &CloneResult{
Output: logs,
CommitSHA: parseCommitSHA(logs, commitMarker),
ShortSHA: parseCommitSHA(logs, shortSHAMarker),
}
// Parse commit SHA from output (looks for "COMMIT:<sha>" line)
commitSHA := parseCommitSHA(logs)
// The short hash names the image the deploy builds.
if result.ShortSHA == "" {
return nil, fmt.Errorf("%w: no short commit hash in its output: %s",
ErrGitCloneFailed, logs)
}
return result, nil
return &CloneResult{Output: logs, CommitSHA: commitSHA}, nil
}
}
// Prefixes of the lines in the clone output that carry the commit checked
// out, in full and in git's short form.
const (
commitMarker = "COMMIT:"
shortSHAMarker = "SHORT_SHA:"
)
// commitMarker is the prefix used to identify commit SHA in clone output.
const commitMarker = "COMMIT:"
// parseCommitSHA extracts a commit SHA from git clone output.
// It looks for a line starting with marker and returns the SHA after it.
func parseCommitSHA(output, marker string) string {
// parseCommitSHA extracts the commit SHA from git clone output.
// It looks for a line starting with "COMMIT:" and returns the SHA after it.
func parseCommitSHA(output string) string {
for line := range strings.SplitSeq(output, "\n") {
line = strings.TrimSpace(line)
sha, found := strings.CutPrefix(line, marker)
sha, found := strings.CutPrefix(line, commitMarker)
if found {
return strings.TrimSpace(sha)
}
-39
View File
@@ -1,39 +0,0 @@
package docker //nolint:testpackage // tests unexported buildMounts
import (
"testing"
"github.com/docker/docker/api/types/mount"
"github.com/stretchr/testify/assert"
)
// TestBuildMountsCreatesMissingHostPath checks that every mount asks Docker
// to create its host path if it is missing, and keeps the rest of the volume
// as configured.
func TestBuildMountsCreatesMissingHostPath(t *testing.T) {
t.Parallel()
volumes := []VolumeMount{
{HostPath: "/srv/app/data", ContainerPath: "/data", ReadOnly: false},
{HostPath: "/srv/app/config", ContainerPath: "/etc/app", ReadOnly: true},
}
want := []mount.Mount{
{
Type: mount.TypeBind,
Source: "/srv/app/data",
Target: "/data",
ReadOnly: false,
BindOptions: &mount.BindOptions{CreateMountpoint: true},
},
{
Type: mount.TypeBind,
Source: "/srv/app/config",
Target: "/etc/app",
ReadOnly: true,
BindOptions: &mount.BindOptions{CreateMountpoint: true},
},
}
assert.Equal(t, want, buildMounts(volumes))
}
+12 -378
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -16,9 +15,7 @@ import (
"testing"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
controlapi "github.com/moby/buildkit/api/services/control"
)
@@ -206,8 +203,6 @@ func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
<-r.Context().Done()
case strings.HasSuffix(r.URL.Path, "/wait"):
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, tt.exitCode)
case strings.HasSuffix(r.URL.Path, "/logs"):
writeCloneOutput(w)
default:
_, _ = w.Write([]byte(`{}`))
}
@@ -224,7 +219,18 @@ func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
c := &Client{docker: dockerAPI, log: slog.Default()}
_, _ = c.performClone(ctx, testCloneConfig(t))
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:
@@ -238,38 +244,6 @@ func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
}
}
// testCloneConfig returns the settings of a clone in these tests, with its
// files in a temporary directory.
func testCloneConfig(t *testing.T) *cloneConfig {
t.Helper()
dir := t.TempDir()
return &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"),
}
}
// cloneCommit is the commit the fake clones in these tests check out.
const cloneCommit = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
// writeCloneOutput writes the output of a git clone of cloneCommit as
// Docker sends a container's log: each line after a header.
func writeCloneOutput(w io.Writer) {
stdout := stdcopy.NewStdWriter(w, stdcopy.Stdout)
stderr := stdcopy.NewStdWriter(w, stdcopy.Stderr)
_, _ = stderr.Write([]byte("Cloning into '/repo'...\n"))
_, _ = stdout.Write([]byte("COMMIT:" + cloneCommit + "\n"))
_, _ = stdout.Write([]byte("SHORT_SHA:1a2b3c4\n"))
}
// TestPerformBuildUsesBuildKit runs a build against a fake Docker API and
// checks that it asks for BuildKit and that BuildKit's progress reaches the
// build log as plain text.
@@ -297,10 +271,6 @@ func TestPerformBuildUsesBuildKit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`))
case strings.HasSuffix(r.URL.Path, "/session"):
serveSession(t, w, r, make(chan string, 1))
case strings.HasSuffix(r.URL.Path, "/build"):
if r.URL.Query().Get("version") != "2" {
http.Error(w, "not a BuildKit build", http.StatusBadRequest)
@@ -344,339 +314,3 @@ func TestPerformBuildUsesBuildKit(t *testing.T) {
t.Errorf("build log is missing the build step:\n%s", buildLog.String())
}
}
// TestPerformBuildFails runs builds that fail against a fake Docker API and
// checks that each returns its own error and that no image is inspected
// afterwards.
func TestPerformBuildFails(t *testing.T) {
t.Parallel()
tests := []struct {
name string
engine string // Docker Engine version the fake daemon reports
apiVersion string // API version the fake daemon reports
buildOutput string
wantErr string
}{
{
name: "build step fails",
engine: "27.3.1",
apiVersion: "1.47",
buildOutput: `{"stream":"Step 1/1 : RUN false\n"}` + "\n" +
`{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`,
wantErr: "exit code: 1",
},
{
name: "daemon too old for BuildKit",
engine: "18.06.3-ce",
apiVersion: "1.38",
wantErr: "BuildKit is unavailable on the Docker daemon: " +
"Docker Engine 18.06.3-ce (API 1.38) is older than 18.09 (API 1.39); " +
"upgrade Docker Engine",
},
{
// The build step's own error shows the build went ahead.
name: "daemon at API 1.39 builds",
engine: "18.09.9",
apiVersion: "1.39",
buildOutput: `{"stream":"Step 1/1 : RUN false\n"}` + "\n" +
`{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`,
wantErr: "exit code: 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = fmt.Fprintf(w, `{"Version":%q,"ApiVersion":%q}`,
tt.engine, tt.apiVersion)
case strings.HasSuffix(r.URL.Path, "/session"):
serveSession(t, w, r, make(chan string, 1))
case strings.HasSuffix(r.URL.Path, "/build"):
_, _ = w.Write([]byte(tt.buildOutput))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
}
},
))
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()}
_, err = c.performBuild(t.Context(), BuildImageOptions{
ContextDir: t.TempDir(),
Tags: []string{"upaas-test:1"},
})
if err == nil || err.Error() != tt.wantErr {
t.Errorf("got error %v, want %q", err, tt.wantErr)
}
})
}
}
// TestPerformBuildAttachesSession runs a build against a fake Docker API
// that, like the real daemon, fails the build with "no active sessions"
// unless the build names a session the client attached over the session
// endpoint. It also checks that the session is closed when the build ends.
func TestPerformBuildAttachesSession(t *testing.T) {
t.Parallel()
attached := make(chan string, 1)
sessionClosed := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`))
case strings.HasSuffix(r.URL.Path, "/session"):
serveSession(t, w, r, attached)
close(sessionClosed)
case strings.HasSuffix(r.URL.Path, "/build"):
id := r.URL.Query().Get("session")
attachedID := ""
// The daemon waits a few seconds for the build's session
// to attach.
if id != "" {
select {
case attachedID = <-attached:
case <-time.After(5 * time.Second):
}
}
if id == "" || attachedID != id {
_, _ = w.Write([]byte(`{"errorDetail":{"message":"no active sessions"},` +
`"error":"no active sessions"}`))
}
default:
t.Errorf("unexpected request to %s", r.URL.Path)
}
},
))
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()}
_, err = c.performBuild(t.Context(), BuildImageOptions{ContextDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
select {
case <-sessionClosed:
case <-time.After(5 * time.Second):
t.Error("the build's session was not closed when the build ended")
}
}
// serveSession answers a request to attach a session as the Docker daemon
// does: it switches the connection over to the session, sends the session's
// ID on attached, and holds the connection until the client closes it.
func serveSession(
t *testing.T,
w http.ResponseWriter,
r *http.Request,
attached chan<- string,
) {
t.Helper()
conn, _, err := http.NewResponseController(w).Hijack()
if err != nil {
t.Error(err)
return
}
defer func() { _ = conn.Close() }()
_, err = io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
"Connection: Upgrade\r\nUpgrade: h2c\r\n\r\n")
if err != nil {
t.Error(err)
return
}
attached <- r.Header.Get("X-Docker-Expose-Session-Uuid")
_, _ = io.Copy(io.Discard, conn)
}
// TestPerformCloneReadsFramedLogs runs a clone against a fake Docker API that
// sends the clone container's output in frames, as Docker does for a
// container without a terminal, and checks that the output comes back as
// plain text and that the commit, in full and in git's short form, is read
// from it.
func TestPerformCloneReadsFramedLogs(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/containers/create"):
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
case strings.HasSuffix(r.URL.Path, "/wait"):
_, _ = w.Write([]byte(`{"StatusCode":0}`))
case strings.HasSuffix(r.URL.Path, "/logs"):
writeCloneOutput(w)
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()}
result, err := c.performClone(t.Context(), testCloneConfig(t))
if err != nil {
t.Fatal(err)
}
want := "Cloning into '/repo'...\nCOMMIT:" + cloneCommit + "\nSHORT_SHA:1a2b3c4\n"
if result.Output != want {
t.Errorf("got clone output %q, want %q", result.Output, want)
}
if result.CommitSHA != cloneCommit || result.ShortSHA != "1a2b3c4" {
t.Errorf("got commit %q, short %q", result.CommitSHA, result.ShortSHA)
}
}
// TestPerformCloneFailsWithoutShortSHA runs a clone against a fake Docker API
// whose clone succeeds but prints no "SHORT_SHA:" line, and checks that the
// clone fails, since the short hash names the image the deploy builds.
func TestPerformCloneFailsWithoutShortSHA(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/containers/create"):
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
case strings.HasSuffix(r.URL.Path, "/wait"):
_, _ = w.Write([]byte(`{"StatusCode":0}`))
case strings.HasSuffix(r.URL.Path, "/logs"):
_, _ = stdcopy.NewStdWriter(w, stdcopy.Stdout).
Write([]byte("COMMIT:" + cloneCommit + "\n"))
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()}
_, err = c.performClone(t.Context(), testCloneConfig(t))
if !errors.Is(err, ErrGitCloneFailed) {
t.Errorf("got error %v, want %v", err, ErrGitCloneFailed)
}
}
// TestPerformCloneAsksGitForShortSHA runs a clone of a branch's last commit
// and a clone of a given commit against a fake Docker API, and checks that
// the command each clone container is created with prints git's own short
// form of the commit checked out.
func TestPerformCloneAsksGitForShortSHA(t *testing.T) {
t.Parallel()
tests := []struct {
name string
commitSHA string
}{
{name: "branch", commitSHA: ""},
{name: "commit", commitSHA: cloneCommit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
created := make(chan container.Config, 1)
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/containers/create"):
var cfg container.Config
_ = json.NewDecoder(r.Body).Decode(&cfg)
created <- cfg
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
case strings.HasSuffix(r.URL.Path, "/wait"):
_, _ = w.Write([]byte(`{"StatusCode":0}`))
case strings.HasSuffix(r.URL.Path, "/logs"):
writeCloneOutput(w)
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()}
cfg := testCloneConfig(t)
cfg.commitSHA = tt.commitSHA
_, err = c.performClone(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
cmd := strings.Join((<-created).Cmd, " ")
if !strings.Contains(cmd, "echo SHORT_SHA:$(git rev-parse --short HEAD)") {
t.Errorf("clone command %q does not print git's short hash", cmd)
}
})
}
}
+11
View File
@@ -15,12 +15,14 @@ var (
mu sync.RWMutex
appname string
version string
buildarch string
)
// Globals holds build-time variables for dependency injection.
type Globals struct {
Appname string
Version string
Buildarch string
}
// New creates a new Globals instance from package-level variables.
@@ -31,6 +33,7 @@ func New(_ fx.Lifecycle) (*Globals, error) {
return &Globals{
Appname: appname,
Version: version,
Buildarch: buildarch,
}, nil
}
@@ -49,3 +52,11 @@ func SetVersion(ver string) {
version = ver
}
// SetBuildarch sets the build architecture (used for testing and main init).
func SetBuildarch(arch string) {
mu.Lock()
defer mu.Unlock()
buildarch = arch
}
+1 -1
View File
@@ -28,7 +28,7 @@ const (
// recentDeploymentsLimit is the number of recent deployments to show.
recentDeploymentsLimit = 5
// deploymentsHistoryLimit is the number of deployments to show in history.
deploymentsHistoryLimit = 10
deploymentsHistoryLimit = 50
)
// redirectToApp issues a SeeOther redirect to the page for the given
-46
View File
@@ -1,46 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app"
)
// TestAppPageTitleShowsBranch checks that an app's branch can be read from
// the app page title, next to the status badge, without opening the edit page.
func TestAppPageTitleShowsBranch(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "branch-shown-app",
RepoURL: "git@example.com:user/branch-shown-app.git",
Branch: "staging",
})
require.NoError(t, err)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/apps/"+createdApp.ID, nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
testCtx.handlers.HandleAppDetail().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
// The title row runs from the app name heading to the end of its div.
_, afterHeading, found := strings.Cut(recorder.Body.String(), "<h1")
require.True(t, found, "app page has no heading")
titleRow, _, _ := strings.Cut(afterHeading, "</div>")
assert.Contains(t, titleRow, `x-text="statusLabel"`)
assert.Contains(t, titleRow, ">staging</span>")
}
+7 -13
View File
@@ -13,15 +13,12 @@ const (
appNameMaxLength = 63
)
// validAppNameRe matches runs of lowercase letters and digits joined by
// single dots or by hyphens, such as "my-app" or "sneak.berlin". Docker
// accepts every name it allows as the app's image name, upaas-<name>; a
// dot needs a letter or digit on both sides because Docker requires it.
// It also keeps the name from being "." or ".." or starting or ending
// with a dot, so it is safe as a directory and file name. The pattern
// attribute of the name field on the new and edit app forms is the same.
var validAppNameRe = regexp.MustCompile(`^[a-z0-9]+((\.|-+)[a-z0-9]+)*$`)
// validAppNameRe matches names containing only lowercase alphanumeric characters and
// hyphens, starting and ending with an alphanumeric character.
var validAppNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
// validateAppName checks that the given app name is safe for use in Docker
// container names, image tags, and file system paths.
var (
errAppNameLength = errors.New(
"app name must be between " +
@@ -29,14 +26,11 @@ var (
strconv.Itoa(appNameMaxLength) + " characters",
)
errAppNamePattern = errors.New(
"app name must contain only lowercase letters, numbers, hyphens, " +
"and dots, must start and end with a letter or number, " +
"and must have a letter or number on both sides of each dot",
"app name must contain only lowercase letters, numbers, " +
"and hyphens, and must start and end with a letter or number",
)
)
// validateAppName checks that the given app name is safe for use in Docker
// container names, image tags, and file system paths.
func validateAppName(name string) error {
if len(name) < appNameMinLength || len(name) > appNameMaxLength {
return errAppNameLength
+1 -58
View File
@@ -1,16 +1,7 @@
package handlers //nolint:testpackage // testing unexported validateAppName
import (
"bytes"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
)
func TestValidateAppName(t *testing.T) {
@@ -27,10 +18,6 @@ func TestValidateAppName(t *testing.T) {
{"valid two chars", "ab", false},
{"valid complex", "my-cool-app-v2", false},
{"valid all numbers", "123", false},
{"valid double hyphen", "my--app", false},
{"valid domain", "sneak.berlin", false},
{"valid two dots", "www.sneak.berlin", false},
{"valid dot and hyphen", "my-app.example.com", false},
{"empty", "", true},
{"single char", "a", true},
{"too long", "a" + string(make([]byte, 63)), true},
@@ -49,12 +36,7 @@ func TestValidateAppName(t *testing.T) {
{"starts with hyphen", "-myapp", true},
{"ends with hyphen", "myapp-", true},
{"underscore", "my_app", true},
{"two dots in a row", "a..b", true},
{"starts with dot", ".a", true},
{"ends with dot", "a.", true},
{"only dots", "..", true},
{"hyphen before dot", "a-.b", true},
{"hyphen after dot", "a.-b", true},
{"dot", "my.app", true},
{"slash", "my/app", true},
{"path traversal", "../etc/passwd", true},
{"special chars", "app@name!", true},
@@ -72,42 +54,3 @@ func TestValidateAppName(t *testing.T) {
})
}
}
func TestValidateAppNameErrorMentionsDots(t *testing.T) {
t.Parallel()
err := validateAppName("a..b")
require.ErrorIs(t, err, errAppNamePattern)
assert.Contains(t, err.Error(), "dots")
}
// TestAppFormsCheckAppNameLikeServer checks that the name field on the new
// and edit app forms has the server's pattern and length limits, and that
// its hint mentions dots.
func TestAppFormsCheckAppNameLikeServer(t *testing.T) {
t.Parallel()
pattern := strings.TrimSuffix(strings.TrimPrefix(validAppNameRe.String(), "^"), "$")
pages := map[string]map[string]any{
"app_new.html": {},
"app_edit.html": {dataKeyApp: &models.App{}},
}
for page, data := range pages {
var out bytes.Buffer
require.NoError(t, templates.GetParsed().ExecuteTemplate(&out, page, data))
// The name field and its hint, up to the end of their div.
_, field, found := strings.Cut(out.String(), `id="name"`)
require.True(t, found, page)
field, _, _ = strings.Cut(field, "</div>")
assert.Contains(t, field, `pattern="`+pattern+`"`, page)
assert.Contains(t, field, `minlength="`+strconv.Itoa(appNameMinLength)+`"`, page)
assert.Contains(t, field, `maxlength="`+strconv.Itoa(appNameMaxLength)+`"`, page)
assert.Contains(t, field, "hyphens, and dots", page)
}
}
@@ -1,39 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app"
)
// TestAppPageEnvVarHint checks that the environment variable editor says
// changes take effect at the next deploy or rollback, in the warning style.
func TestAppPageEnvVarHint(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "env-hint-app",
RepoURL: "git@example.com:user/env-hint-app.git",
})
require.NoError(t, err)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/apps/"+createdApp.ID, nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
testCtx.handlers.HandleAppDetail().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(),
`<p class="alert-warning mt-1">`+
"Environment variable changes take effect at the next deploy or rollback.</p>")
}
-76
View File
@@ -1,76 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app"
)
// TestAppPageLayout checks the app page's width, the order of its sections,
// and the height of its two log boxes.
func TestAppPageLayout(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "layout-app",
RepoURL: "git@example.com:user/layout-app.git",
})
require.NoError(t, err)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/apps/"+createdApp.ID, nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
testCtx.handlers.HandleAppDetail().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
_, afterMain, found := strings.Cut(body, "<main")
require.True(t, found, "app page has no main element")
mainTag, _, _ := strings.Cut(afterMain, ">")
assert.Contains(t, mainTag, "max-width: 84rem;")
sectionTitles := []string{
"Container Logs",
"Deploy Key",
"Webhook URL",
"Last Deployment Build Logs",
"Environment Variables",
"Docker Labels",
"Volume Mounts",
"Port Mappings",
"Recent Deployments",
"Danger Zone",
}
previousIndex := -1
for _, title := range sectionTitles {
index := strings.Index(body, ">"+title+"</h2>")
require.NotEqual(t, -1, index, "app page has no %q section", title)
assert.Greater(t, index, previousIndex, "%q section is out of order", title)
previousIndex = index
}
for _, logBox := range []string{"containerLogsWrapper", "buildLogsWrapper"} {
_, afterRef, found := strings.Cut(body, `x-ref="`+logBox+`"`)
require.True(t, found, "app page has no %s", logBox)
logBoxTag, _, _ := strings.Cut(afterRef, ">")
assert.Contains(t, logBoxTag, "max-height: 800px;", logBox)
}
}
@@ -1,77 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app"
)
// TestDashboardTableFitsCard checks that the card around the app table
// scrolls sideways instead of hiding what does not fit, and that a long
// repository URL wraps instead of pushing the action buttons out.
func TestDashboardTableFitsCard(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
repoURL := "https://git.example.com/user/" +
"a-repository-name-long-enough-to-push-the-action-buttons-out.git"
_, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "long-url-app",
RepoURL: repoURL,
})
require.NoError(t, err)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
testCtx.handlers.HandleDashboard().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
beforeTable, _, found := strings.Cut(body, `<table class="table">`)
require.True(t, found, "dashboard has no app table")
cardTag := beforeTable[strings.LastIndex(beforeTable, "<div"):]
assert.Contains(t, cardTag, "overflow-x-auto")
assert.NotContains(t, cardTag, "overflow-hidden")
beforeURL, _, found := strings.Cut(body, ">"+repoURL+"</td>")
require.True(t, found, "dashboard has no repository cell")
cellTag := beforeURL[strings.LastIndex(beforeURL, "<td"):]
assert.Contains(t, cellTag, "whitespace-normal")
assert.Contains(t, cellTag, "break-all")
}
// TestTopBarWrapsWhenNarrow checks that the top bar keeps a gap between the
// µPaaS title and the New App and Logout buttons, and puts the buttons on a
// second row in a window too narrow for one, instead of letting them meet.
func TestTopBarWrapsWhenNarrow(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
testCtx.handlers.HandleDashboard().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
_, afterNav, found := strings.Cut(recorder.Body.String(), `<nav class="app-bar">`)
require.True(t, found, "dashboard has no top bar")
rowTag, _, _ := strings.Cut(strings.TrimSpace(afterNav), ">")
assert.Contains(t, rowTag, "flex-wrap")
assert.Contains(t, rowTag, "gap-")
}
-71
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
@@ -109,9 +108,6 @@ func createAppServices(
})
require.NoError(t, authErr)
// 1 MiB per password hash instead of 64 MiB; see auth.Service.ArgonMemory.
authSvc.ArgonMemory = 1024
appSvc, appErr := app.New(fx.Lifecycle(nil), app.ServiceParams{
Logger: logInstance,
Database: dbInstance,
@@ -1152,73 +1148,6 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
// TestHandleAppDeploymentsShowsTenNewest verifies the deployments page
// lists only the 10 most recent deployments, newest first.
func TestHandleAppDeploymentsShowsTenNewest(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "deployments-page-app")
// Create 12 deployments, each started one minute after the one before.
firstStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
ids := make([]int64, 0, 12)
for idx := range 12 {
deployment := models.NewDeployment(testCtx.database)
deployment.AppID = createdApp.ID
deployment.Status = models.DeploymentStatusSuccess
require.NoError(t, deployment.Save(context.Background()))
_, err := testCtx.database.Exec(
context.Background(),
"UPDATE deployments SET started_at = ? WHERE id = ?",
firstStart.Add(time.Duration(idx)*time.Minute),
deployment.ID,
)
require.NoError(t, err)
ids = append(ids, deployment.ID)
}
request := httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
"/apps/"+createdApp.ID+"/deployments",
nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleAppDeployments()
handler.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
card := func(id int64) string {
return `data-deployment-id="` + strconv.FormatInt(id, 10) + `"`
}
assert.Equal(t, 10, strings.Count(body, `data-deployment-id="`))
// The two oldest are left out.
assert.NotContains(t, body, card(ids[0]))
assert.NotContains(t, body, card(ids[1]))
// The ten newest are shown, newest first.
previous := -1
for idx := len(ids) - 1; idx >= 2; idx-- {
position := strings.Index(body, card(ids[idx]))
require.Greater(t, position, previous,
"deployment %d missing or out of order", ids[idx])
previous = position
}
}
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel()
+1 -2
View File
@@ -4,7 +4,6 @@ package logger
import (
"log/slog"
"os"
"runtime"
"go.uber.org/fx"
@@ -82,6 +81,6 @@ func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
"arch", runtime.GOARCH,
"buildarch", l.params.Globals.Buildarch,
)
}
-34
View File
@@ -1,34 +0,0 @@
package logger //nolint:testpackage // sets the unexported log and params fields
import (
"bytes"
"encoding/json"
"log/slog"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/globals"
)
func TestIdentifyLogsArchitectureFromRuntime(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
l := &Logger{
log: slog.New(slog.NewJSONHandler(&buf, nil)),
params: Params{
Globals: &globals.Globals{Appname: "upaas-test", Version: "test"},
},
}
l.Identify()
var line map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &line))
assert.Equal(t, runtime.GOARCH, line["arch"])
}
+2 -42
View File
@@ -203,12 +203,11 @@ func (d *Deployment) insert(ctx context.Context) error {
func (d *Deployment) update(ctx context.Context) error {
query := `
UPDATE deployments SET
commit_sha = ?, image_id = ?, container_id = ?, status = ?, logs = ?,
finished_at = ?
image_id = ?, container_id = ?, status = ?, logs = ?, finished_at = ?
WHERE id = ?`
_, err := d.db.Exec(ctx, query,
d.CommitSHA, d.ImageID, d.ContainerID, d.Status, d.Logs, d.FinishedAt, d.ID,
d.ImageID, d.ContainerID, d.Status, d.Logs, d.FinishedAt, d.ID,
)
return err
@@ -296,45 +295,6 @@ func FindDeploymentsByAppID(
return deployments, nil
}
// FindDeploymentImageIDs returns the IDs of the images an app's
// deployments built or rolled back to.
func FindDeploymentImageIDs(
ctx context.Context,
deployDB *database.Database,
appID string,
) ([]string, error) {
rows, err := deployDB.Query(ctx, `
SELECT DISTINCT image_id FROM deployments
WHERE app_id = ? AND image_id IS NOT NULL`,
appID,
)
if err != nil {
return nil, fmt.Errorf("querying deployment image IDs: %w", err)
}
defer func() { _ = rows.Close() }()
var imageIDs []string
for rows.Next() {
var imageID string
scanErr := rows.Scan(&imageID)
if scanErr != nil {
return nil, fmt.Errorf("scanning deployment image ID: %w", scanErr)
}
imageIDs = append(imageIDs, imageID)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating deployment image IDs: %w", rowsErr)
}
return imageIDs, nil
}
// LatestDeploymentForApp finds the most recent deployment for an app.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
-27
View File
@@ -564,33 +564,6 @@ func TestDeploymentMarkFinished(t *testing.T) {
assert.True(t, found.FinishedAt.Valid)
}
// TestDeploymentSaveStoresCommitSetAfterInsert checks that a commit set on a
// deployment after it was first saved, as a manual deploy does once the clone
// reads it, is stored by the next save.
func TestDeploymentSaveStoresCommitSetAfterInsert(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
err := deployment.Save(context.Background())
require.NoError(t, err)
deployment.CommitSHA = sql.NullString{String: "abc123def456", Valid: true}
err = deployment.Save(context.Background())
require.NoError(t, err)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
assert.Equal(t, "abc123def456", found.CommitSHA.String)
}
func TestDeploymentFindByAppID(t *testing.T) {
t.Parallel()
+2 -10
View File
@@ -59,13 +59,6 @@ type ServiceParams struct {
// Service provides authentication functionality.
type Service struct {
// ArgonMemory is the memory each argon2id hash takes, in KiB. New sets
// argonMemory, 64 MiB, and upaasd never changes it. Tests lower it, since
// many 64 MiB hashes at once under the race detector need more memory
// than a 4 GiB build machine has. A hash verifies only with the value it
// was made with.
ArgonMemory uint32
log *slog.Logger
db *database.Database
store *sessions.CookieStore
@@ -84,7 +77,6 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
}
return &Service{
ArgonMemory: argonMemory,
log: params.Logger.Get(),
db: params.Database,
store: store,
@@ -105,7 +97,7 @@ func (svc *Service) HashPassword(password string) (string, error) {
[]byte(password),
salt,
argonTime,
svc.ArgonMemory,
argonMemory,
argonThreads,
argonKeyLen,
)
@@ -140,7 +132,7 @@ func (svc *Service) VerifyPassword(hashedPassword, password string) bool {
[]byte(password),
salt,
argonTime,
svc.ArgonMemory,
argonMemory,
argonThreads,
argonKeyLen,
)
-19
View File
@@ -65,10 +65,6 @@ func setupTestService(t *testing.T) (*auth.Service, func()) {
})
require.NoError(t, err)
// 1 MiB per hash instead of 64 MiB; see Service.ArgonMemory. The tests
// that use setupAuthService keep 64 MiB.
svc.ArgonMemory = 1024
// t.TempDir() automatically cleans up after test
cleanup := func() {}
@@ -241,21 +237,6 @@ func TestVerifyPassword(testingT *testing.T) {
})
}
// TestHashPasswordWithUpaasdMemory hashes and verifies a password with the
// memory New sets, which upaasd uses. setupTestService lowers it.
func TestHashPasswordWithUpaasdMemory(t *testing.T) {
t.Parallel()
svc := setupAuthService(t, false)
require.Equal(t, uint32(64*1024), svc.ArgonMemory)
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
assert.True(t, svc.VerifyPassword(hash, "correctpassword"))
assert.False(t, svc.VerifyPassword(hash, "wrongpassword"))
}
func TestIsSetupRequired(testingT *testing.T) {
testingT.Parallel()
+20 -85
View File
@@ -735,103 +735,44 @@ func (svc *Service) recordDeployedImage(
return nil
}
// removeUnusedImages removes the app's images except those an app's running
// container uses or its Rollback would start. Docker deletes a tagged image
// only once no other tag, such as another app's, and no container still
// uses it.
// removeUnusedImages removes the app's tags (upaas-<app>:<deployment>, set by
// buildImage) except those of the image the running container uses and the
// one Rollback would start. Docker deletes an image only once no other tag,
// such as another app's, and no container still uses it.
func (svc *Service) removeUnusedImages(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) {
images, err := svc.findAppImages(ctx, app)
tags, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
if err != nil {
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
return
}
keep, err := svc.imagesToKeep(ctx)
if err != nil {
svc.log.Error("failed to list the images apps use", "error", err, "app", app.Name)
return
}
for _, name := range slices.Sorted(maps.Keys(images)) {
if keep[images[name].String()] {
for _, tag := range slices.Sorted(maps.Keys(tags)) {
imageID := tags[tag].String()
if imageID == app.ImageID.String || imageID == app.PreviousImageID.String {
continue
}
removeErr := svc.docker.RemoveImageTag(ctx, name)
removeErr := svc.docker.RemoveImageTag(ctx, tag)
if removeErr != nil {
svc.log.Error("failed to remove old image",
"error", removeErr, "app", app.Name, "image", name)
"error", removeErr, "app", app.Name, "tag", tag)
_ = deployment.AppendLog(
ctx,
"WARNING: failed to remove old image "+name+": "+removeErr.Error(),
"WARNING: failed to remove old image "+tag+": "+removeErr.Error(),
)
continue
}
_ = deployment.AppendLog(ctx, "Removed old image: "+name)
_ = deployment.AppendLog(ctx, "Removed old image: "+tag)
}
}
// findAppImages returns the app's images, each under the name it is removed
// by: its tag, upaas-<app>:<short hash> as set by buildImage, or its ID if
// it has none. A redeploy of a commit gives the commit's tag to the new
// image, so the old one is found among the images the app's deployments
// recorded.
func (svc *Service) findAppImages(
ctx context.Context,
app *models.App,
) (map[string]docker.ImageID, error) {
images, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
if err != nil {
return nil, fmt.Errorf("failed to list image tags: %w", err)
}
untagged, err := svc.docker.ListUntaggedImages(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list untagged images: %w", err)
}
recorded, err := models.FindDeploymentImageIDs(ctx, svc.db, app.ID)
if err != nil {
return nil, fmt.Errorf("failed to find deployment images: %w", err)
}
for _, imageID := range untagged {
if slices.Contains(recorded, imageID.String()) {
images[imageID.String()] = imageID
}
}
return images, nil
}
// imagesToKeep returns the IDs of the image each app's running container
// uses and the one its Rollback would start. It covers every app because
// apps that build the same commit can share an image, and a redeploy can
// take the tag that kept the image for one of them.
func (svc *Service) imagesToKeep(ctx context.Context) (map[string]bool, error) {
apps, err := models.AllApps(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("failed to list apps: %w", err)
}
keep := make(map[string]bool)
for _, app := range apps {
keep[app.ImageID.String] = true
keep[app.PreviousImageID.String] = true
}
return keep, nil
}
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
func (svc *Service) cleanupCancelledDeploy(
ctx context.Context,
@@ -971,17 +912,18 @@ func (svc *Service) buildImage(
app *models.App,
deployment *models.Deployment,
) (docker.ImageID, error) {
workDir, shortSHA, cleanup, err := svc.cloneRepository(ctx, app, deployment)
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
if err != nil {
return "", err
}
defer cleanup()
imageTag := "upaas-" + app.Name + ":" + shortSHA
imageTag := fmt.Sprintf("upaas-%s:%d", app.Name, deployment.ID)
// Create log writer that flushes build output to deployment logs every second
logWriter := newDeploymentLogWriter(ctx, deployment)
defer logWriter.Close()
// BuildImage creates a tar archive from the local filesystem,
// so it needs the container path where files exist, not the host path.
@@ -991,10 +933,6 @@ func (svc *Service) buildImage(
Tags: []string{imageTag},
LogWriter: logWriter,
})
// Write the rest of the build output to the log before the result.
logWriter.Close()
if err != nil {
svc.notify.NotifyBuildFailed(ctx, app, deployment, err)
svc.failDeployment(
@@ -1013,14 +951,11 @@ func (svc *Service) buildImage(
return imageID, nil
}
// cloneRepository clones the app's repository for a build. It returns the
// directory of the clone, git's short form of the commit checked out, and a
// function that removes the clone.
func (svc *Service) cloneRepository(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, string, func(), error) {
) (string, func(), error) {
// Use a subdirectory of DataDir for builds since it's mounted from the host
// and accessible to Docker for bind mounts (unlike /tmp inside the container).
// Structure: builds/<appname>/<deployment-id>-<random>/
@@ -1037,7 +972,7 @@ func (svc *Service) cloneRepository(
fmt.Errorf("failed to create builds dir: %w", err),
)
return "", "", nil, fmt.Errorf("failed to create builds dir: %w", err)
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
}
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
@@ -1049,7 +984,7 @@ func (svc *Service) cloneRepository(
fmt.Errorf("failed to create temp dir: %w", err),
)
return "", "", nil, fmt.Errorf("failed to create temp dir: %w", err)
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
cleanup := func() { _ = os.RemoveAll(buildDir) }
@@ -1085,7 +1020,7 @@ func (svc *Service) cloneRepository(
fmt.Errorf("failed to clone repo: %w", cloneErr),
)
return "", "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
}
svc.processCloneResult(ctx, app, deployment, cloneResult, commitSHA)
@@ -1093,7 +1028,7 @@ func (svc *Service) cloneRepository(
// Return the 'work' subdirectory where the repo was cloned
workDir := filepath.Join(buildDir, "work")
return workDir, cloneResult.ShortSHA, cleanup, nil
return workDir, cleanup, nil
}
// processCloneResult handles the result of a git clone operation.
@@ -1,121 +0,0 @@
package deploy_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices"
"strings"
"testing"
"github.com/distribution/reference"
"github.com/docker/docker/daemon/names"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
)
// TestDeployAppWithDotInName creates an app named sneak.berlin through the
// new app form, as a user does, then builds and deploys it against a fake
// Docker API and checks that Docker accepts the names of the image it
// builds and of the container it runs, using Docker's own rules for each.
func TestDeployAppWithDotInName(t *testing.T) {
t.Parallel()
api := &fakeImageAPI{
images: map[string][]string{},
shortSHA: "abc1234",
nextID: "sha256:built",
}
svc, db := newImageTestService(t, api)
ctx := context.Background()
createdApp := createAppWithForm(t, db, "sneak.berlin")
deployment := models.NewDeployment(db)
deployment.AppID = createdApp.ID
require.NoError(t, deployment.Save(ctx))
imageID, err := svc.BuildImage(ctx, createdApp, deployment)
require.NoError(t, err)
require.NoError(t, svc.DeployContainer(ctx, createdApp, deployment, imageID))
images, _ := api.state()
api.mu.Lock()
containers := slices.Clone(api.containers)
api.mu.Unlock()
require.Equal(t, map[string][]string{
"sha256:built": {"upaas-sneak.berlin:abc1234"},
}, images)
_, err = reference.ParseNormalizedNamed("upaas-sneak.berlin:abc1234")
require.NoError(t, err)
require.Equal(t, []string{"upaas-sneak.berlin"}, containers)
assert.Regexp(t, names.RestrictedNamePattern, containers[0])
assert.DirExists(t, svc.GetBuildDirExported(createdApp.Name))
}
// createAppWithForm posts the new app form with the given name, which
// checks the name as it does for a user, and returns the app it created.
func createAppWithForm(
t *testing.T,
db *database.Database,
name string,
) *models.App {
t.Helper()
log := logger.NewForTest(slog.New(slog.NewTextHandler(os.Stderr, nil)))
appSvc, err := app.New(nil, app.ServiceParams{Logger: log, Database: db})
require.NoError(t, err)
globalInstance, err := globals.New(nil)
require.NoError(t, err)
handlersInstance, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: globalInstance,
Database: db,
App: appSvc,
})
require.NoError(t, err)
form := url.Values{
"name": {name},
"repo_url": {"git@example.com:sneak/" + name + ".git"},
}
request := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/apps", strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
recorder := httptest.NewRecorder()
handlersInstance.HandleAppCreate().ServeHTTP(recorder, request)
// The form redirects to the new app's page; it shows the form again,
// with the error, when it refuses the name.
require.Equal(t, http.StatusSeeOther, recorder.Code, recorder.Body.String())
appID, found := strings.CutPrefix(recorder.Header().Get("Location"), "/apps/")
require.True(t, found)
createdApp, err := models.FindApp(t.Context(), db, appID)
require.NoError(t, err)
require.NotNil(t, createdApp)
return createdApp
}
@@ -1,93 +0,0 @@
package deploy_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy"
)
// TestBuildImageLogsBuildErrorBeforeDeployError runs a build that fails
// against a fake Docker API and checks that the deploy fails with the
// build's own error, which the deployment log shows before the deploy's.
func TestBuildImageLogsBuildErrorBeforeDeployError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/containers/create"):
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
case strings.HasSuffix(r.URL.Path, "/logs"):
writeCloneOutput(w, "abc1234")
case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`))
case strings.HasSuffix(r.URL.Path, "/build"):
_, _ = w.Write([]byte(`{"stream":"Step 1/1 : RUN false\n"}` + "\n" +
`{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`))
default:
// The steps of the git clone, which succeeds.
_, _ = w.Write([]byte(`{}`))
}
},
))
t.Cleanup(srv.Close)
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
lifecycle := fxtest.NewLifecycle(t)
dockerClient, err := docker.New(lifecycle, docker.Params{
Logger: logger.NewForTest(log),
Config: &config.Config{DockerHost: "tcp://" + srv.Listener.Addr().String()},
})
require.NoError(t, err)
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
db := database.NewTestDatabase(t)
ctx := context.Background()
app := models.NewApp(db)
app.ID = "buildapp-id"
app.Name = "buildapp"
app.Branch = "main"
require.NoError(t, app.Save(ctx))
deployment := models.NewDeployment(db)
deployment.AppID = app.ID
require.NoError(t, deployment.Save(ctx))
dataDir := t.TempDir()
cfg := &config.Config{DataDir: dataDir, HostDataDir: dataDir}
// The service has no notify service: the app has no ntfy topic and no
// Slack webhook, so the build failure notification sends nothing.
svc := deploy.NewTestServiceWithConfig(log, cfg, db, dockerClient)
_, err = svc.BuildImage(ctx, app, deployment)
require.EqualError(t, err, "failed to build image: exit code: 1")
logs := deployment.Logs.String
buildError := strings.Index(logs, "ERROR: exit code: 1")
deployError := strings.Index(logs, "ERROR: failed to build image: exit code: 1")
require.NotEqual(t, -1, buildError, logs)
assert.Less(t, buildError, deployError, logs)
}
@@ -20,7 +20,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{DataDir: tmpDir}
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil, nil)
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
// Create a fake build directory matching the deployment pattern
appName := "test-app"
@@ -59,7 +59,7 @@ func TestCleanupCancelledDeploy_NoBuildDir(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{DataDir: tmpDir}
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil, nil)
svc := deploy.NewTestServiceWithConfig(slog.Default(), cfg, nil)
// Should not panic when build dir doesn't exist
svc.CleanupCancelledDeploy(context.Background(), "nonexistent-app", 1, "")
+42 -322
View File
@@ -3,21 +3,14 @@ package deploy_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"sync"
"testing"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stdcopy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
@@ -30,145 +23,45 @@ import (
"sneak.berlin/go/upaas/internal/service/deploy"
)
// fakeImageAPI is a fake Docker API that keeps images and their tags as
// Docker does: a build gives its tag to the image it builds, and removing
// an image's last tag, or an untagged image by its ID, deletes the image.
// It also answers the steps of a git clone that reports shortSHA.
type fakeImageAPI struct {
// TestRecordDeployedImageRemovesOldImages runs the step after a deploy
// against a fake Docker API. Image one is also tagged for another app,
// image two was the previous image, three the current one, four is new.
func TestRecordDeployedImageRemovesOldImages(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
images map[string][]string // image ID -> tags
shortSHA string // the commit's short hash the clone reports
nextID string // ID of the image the next build creates
removed []string // each tag or ID removed
forced bool // whether a removal was forced
containers []string // name of each named container created
}
func (api *fakeImageAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
api.mu.Lock()
defer api.mu.Unlock()
removed []string
forced bool
)
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, name, isImage := strings.Cut(r.URL.Path, "/images/")
switch {
case strings.HasSuffix(r.URL.Path, "/images/json"):
dangling := strings.Contains(r.URL.Query().Get("filters"), "dangling")
api.listImages(w, dangling)
case isImage && r.Method == http.MethodDelete:
api.forced = api.forced || r.URL.Query().Get("force") != ""
api.removeImage(w, name)
case isImage && strings.HasSuffix(name, "/json"):
api.inspectImage(w, strings.TrimSuffix(name, "/json"))
case strings.HasSuffix(r.URL.Path, "/build"):
tag := r.URL.Query().Get("t")
api.untag(tag)
api.images[api.nextID] = append(api.images[api.nextID], tag)
case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`))
case strings.HasSuffix(r.URL.Path, "/containers/create"):
// The git clone's container has no name; the app's has.
if containerName := r.URL.Query().Get("name"); containerName != "" {
api.containers = append(api.containers, containerName)
}
case r.Method == http.MethodDelete:
_, name, _ := strings.Cut(r.URL.Path, "/images/")
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
case strings.HasSuffix(r.URL.Path, "/logs"):
writeCloneOutput(w, api.shortSHA)
default:
// The other steps of the git clone, which succeeds.
_, _ = w.Write([]byte(`{}`))
}
}
mu.Lock()
// listImages lists the untagged images, or else the tagged ones.
func (api *fakeImageAPI) listImages(w http.ResponseWriter, dangling bool) {
list := []image.Summary{}
for _, id := range slices.Sorted(maps.Keys(api.images)) {
if (len(api.images[id]) == 0) == dangling {
list = append(list, image.Summary{ID: id, RepoTags: api.images[id]})
}
}
data, err := json.Marshal(list)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data)
}
func (api *fakeImageAPI) inspectImage(w http.ResponseWriter, name string) {
for id, tags := range api.images {
if id == name || slices.Contains(tags, name) {
_, _ = fmt.Fprintf(w, `{"Id":%q}`, id)
return
}
}
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"No such image"}`))
}
func (api *fakeImageAPI) removeImage(w http.ResponseWriter, name string) {
api.removed = append(api.removed, name)
id := name
if _, isID := api.images[name]; !isID {
id = api.untag(name)
}
if len(api.images[id]) == 0 {
delete(api.images, id)
}
removed = append(removed, name)
forced = forced || r.URL.Query().Get("force") != ""
mu.Unlock()
_, _ = w.Write([]byte(`[]`))
}
// untag removes tag from the image that has it, leaving the image, and
// returns the image's ID.
func (api *fakeImageAPI) untag(tag string) string {
for id, tags := range api.images {
if slices.Contains(tags, tag) {
api.images[id] = slices.DeleteFunc(tags, func(t string) bool { return t == tag })
return id
case strings.HasSuffix(r.URL.Path, "/images/json"):
_, _ = w.Write([]byte(`[
{"Id":"sha256:one","RepoTags":["upaas-myapp:1","upaas-otherapp:7"]},
{"Id":"sha256:two","RepoTags":["upaas-myapp:2"]},
{"Id":"sha256:three","RepoTags":["upaas-myapp:3"]},
{"Id":"sha256:four","RepoTags":["upaas-myapp:4"]}
]`))
default:
_, _ = w.Write([]byte(`{}`))
}
}
return ""
}
// state returns each image's tags and each tag or ID removed.
func (api *fakeImageAPI) state() (map[string][]string, []string) {
api.mu.Lock()
defer api.mu.Unlock()
return maps.Clone(api.images), slices.Clone(api.removed)
}
// writeCloneOutput writes the line of a git clone's output that gives the
// commit's short hash, as Docker sends a container's log: after a header.
func writeCloneOutput(w io.Writer, shortSHA string) {
out := stdcopy.NewStdWriter(w, stdcopy.Stdout)
_, _ = fmt.Fprintf(out, "SHORT_SHA:%s\n", shortSHA)
}
// newImageTestService returns a deploy service that uses api as its
// Docker API, and its database.
func newImageTestService(
t *testing.T,
api *fakeImageAPI,
) (*deploy.Service, *database.Database) {
t.Helper()
srv := httptest.NewServer(api)
},
))
t.Cleanup(srv.Close)
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
@@ -184,203 +77,30 @@ func newImageTestService(
t.Cleanup(lifecycle.RequireStop)
db := database.NewTestDatabase(t)
dataDir := t.TempDir()
cfg := &config.Config{DataDir: dataDir, HostDataDir: dataDir}
return deploy.NewTestServiceWithConfig(log, cfg, db, dockerClient), db
}
// saveApp saves an app with the given current and previous image.
func saveApp(
t *testing.T,
db *database.Database,
name, imageID, previousImageID string,
) *models.App {
t.Helper()
app := models.NewApp(db)
app.ID = name + "-id"
app.Name = name
app.ImageID = sql.NullString{String: imageID, Valid: true}
app.PreviousImageID = sql.NullString{String: previousImageID, Valid: true}
require.NoError(t, app.Save(context.Background()))
return app
}
// TestRecordDeployedImageRemovesOldImages runs the step after a deploy
// against a fake Docker API. Image one has a tag from before images were
// tagged with their commit, and is also tagged for another app. Image two
// was the previous image, three the current one, four is new. Image five is
// the other app's previous image, which a redeploy of its commit left
// without the other app's tag.
func TestRecordDeployedImageRemovesOldImages(t *testing.T) {
t.Parallel()
api := &fakeImageAPI{images: map[string][]string{
"sha256:one": {"upaas-myapp:140", "upaas-otherapp:1a2b3c4"},
"sha256:two": {"upaas-myapp:2b3c4d5"},
"sha256:three": {"upaas-myapp:3c4d5e6"},
"sha256:four": {"upaas-myapp:4d5e6f7"},
"sha256:five": {"upaas-myapp:5e6f7a8"},
}}
svc, db := newImageTestService(t, api)
ctx := context.Background()
app := saveApp(t, db, "myapp", "sha256:three", "sha256:two")
saveApp(t, db, "otherapp", "sha256:other", "sha256:five")
app := models.NewApp(db)
app.ID = "myapp-id"
app.Name = "myapp"
app.ImageID = sql.NullString{String: "sha256:three", Valid: true}
app.PreviousImageID = sql.NullString{String: "sha256:two", Valid: true}
require.NoError(t, app.Save(ctx))
deployment := models.NewDeployment(db)
deployment.AppID = app.ID
require.NoError(t, deployment.Save(ctx))
err := svc.RecordDeployedImage(ctx, app, deployment, "sha256:four")
svc := deploy.NewTestServiceWithConfig(log, &config.Config{}, dockerClient)
err = svc.RecordDeployedImage(ctx, app, deployment, "sha256:four")
require.NoError(t, err)
assert.Equal(t, "sha256:four", app.ImageID.String)
assert.Equal(t, "sha256:three", app.PreviousImageID.String)
images, removed := api.state()
mu.Lock()
defer mu.Unlock()
assert.Equal(t, []string{"upaas-myapp:140", "upaas-myapp:2b3c4d5"}, removed)
assert.Equal(t, map[string][]string{
"sha256:one": {"upaas-otherapp:1a2b3c4"},
"sha256:three": {"upaas-myapp:3c4d5e6"},
"sha256:four": {"upaas-myapp:4d5e6f7"},
"sha256:five": {"upaas-myapp:5e6f7a8"},
}, images)
assert.False(t, api.forced, "old images must be removed without force")
}
// TestRecordDeployedImageRemovesOnlyTheAppsUntaggedImages runs the step after
// a deploy against a fake Docker API that holds three untagged images: one an
// earlier deployment of the app recorded, one a deployment of another app
// recorded, and one no deployment recorded, such as an image upaas never
// built. Only the app's own is removed.
func TestRecordDeployedImageRemovesOnlyTheAppsUntaggedImages(t *testing.T) {
t.Parallel()
api := &fakeImageAPI{images: map[string][]string{
"sha256:new": {"upaas-myapp:1a2b3c4"},
"sha256:myapp": {},
"sha256:otherapp": {},
"sha256:unknown": {},
}}
svc, db := newImageTestService(t, api)
ctx := context.Background()
app := saveApp(t, db, "myapp", "", "")
otherApp := saveApp(t, db, "otherapp", "", "")
saveDeployment := func(appID, imageID string) *models.Deployment {
t.Helper()
deployment := models.NewDeployment(db)
deployment.AppID = appID
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
require.NoError(t, deployment.Save(ctx))
return deployment
}
saveDeployment(app.ID, "sha256:myapp")
saveDeployment(otherApp.ID, "sha256:otherapp")
deployment := saveDeployment(app.ID, "sha256:new")
err := svc.RecordDeployedImage(ctx, app, deployment, "sha256:new")
require.NoError(t, err)
images, removed := api.state()
assert.Equal(t, []string{"sha256:myapp"}, removed)
assert.Equal(t, map[string][]string{
"sha256:new": {"upaas-myapp:1a2b3c4"},
"sha256:otherapp": {},
"sha256:unknown": {},
}, images)
}
// TestRedeployRemovesImagesLeftWithoutTag deploys commits against a fake
// Docker API, some of them again. A build takes the commit's tag from the
// image an earlier build of it made. That image is kept, without a tag,
// while the app runs it or Rollback would start it, and is removed by its
// ID once neither does.
func TestRedeployRemovesImagesLeftWithoutTag(t *testing.T) {
t.Parallel()
const (
tagABC1234 = "upaas-myapp:abc1234"
tag0123ABC = "upaas-myapp:0123abc"
retriedImage = "sha256:retried-0123abc"
)
// The app runs commit abc1234 and would roll back to def5678. The last
// deploy, of commit 0123abc, failed after its build.
api := &fakeImageAPI{images: map[string][]string{
"sha256:built-abc1234": {tagABC1234},
"sha256:built-def5678": {"upaas-myapp:def5678"},
"sha256:failed-0123abc": {tag0123ABC},
}}
svc, db := newImageTestService(t, api)
ctx := context.Background()
app := saveApp(t, db, "myapp", "sha256:built-abc1234", "sha256:built-def5678")
for imageID := range api.images {
deployment := models.NewDeployment(db)
deployment.AppID = app.ID
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
require.NoError(t, deployment.Save(ctx))
}
deployCommit := func(shortSHA, imageID string) {
t.Helper()
api.mu.Lock()
api.shortSHA = shortSHA
api.nextID = imageID
api.mu.Unlock()
deployment := models.NewDeployment(db)
deployment.AppID = app.ID
require.NoError(t, deployment.Save(ctx))
built, err := svc.BuildImage(ctx, app, deployment)
require.NoError(t, err)
require.NoError(t, svc.RecordDeployedImage(ctx, app, deployment, built))
}
// The failed deploy's image loses its tag, and nothing uses it.
deployCommit("0123abc", retriedImage)
images, _ := api.state()
assert.Equal(t, map[string][]string{
"sha256:built-abc1234": {tagABC1234},
retriedImage: {tag0123ABC},
}, images)
// The running image loses its tag and becomes the one Rollback starts.
deployCommit("0123abc", "sha256:rebuilt-0123abc")
images, _ = api.state()
assert.Equal(t, map[string][]string{
"sha256:rebuilt-0123abc": {tag0123ABC},
retriedImage: {},
}, images)
assert.Equal(t, retriedImage, app.PreviousImageID.String)
// Once Rollback no longer needs it, the untagged image is removed.
deployCommit("4567def", "sha256:built-4567def")
images, removed := api.state()
assert.Equal(t, map[string][]string{
"sha256:built-4567def": {"upaas-myapp:4567def"},
"sha256:rebuilt-0123abc": {tag0123ABC},
}, images)
assert.Equal(t, []string{
"sha256:failed-0123abc", "upaas-myapp:def5678", // first deploy
tagABC1234, // second deploy
retriedImage, // third deploy
}, removed)
assert.False(t, api.forced, "old images must be removed without force")
assert.Equal(t, []string{"upaas-myapp:1", "upaas-myapp:2"}, removed)
assert.False(t, forced, "old image tags must be removed without force")
}
+1 -24
View File
@@ -9,7 +9,6 @@ import (
"strings"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/models"
)
@@ -45,18 +44,15 @@ func (svc *Service) UnlockApp(appID string) {
svc.unlockApp(appID)
}
// NewTestServiceWithConfig creates a Service with config, database and
// docker client for testing.
// NewTestServiceWithConfig creates a Service with config and docker client for testing.
func NewTestServiceWithConfig(
log *slog.Logger,
cfg *config.Config,
db *database.Database,
dockerClient *docker.Client,
) *Service {
return &Service{
log: log,
config: cfg,
db: db,
docker: dockerClient,
}
}
@@ -104,25 +100,6 @@ func (svc *Service) RecordDeployedImage(
return svc.recordDeployedImage(ctx, app, deployment, imageID)
}
// BuildImage exposes buildImage for testing.
func (svc *Service) BuildImage(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (docker.ImageID, error) {
return svc.buildImage(ctx, app, deployment)
}
// DeployContainer exposes deployContainerWithTimeout for testing.
func (svc *Service) DeployContainer(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID docker.ImageID,
) error {
return svc.deployContainerWithTimeout(ctx, app, deployment, imageID)
}
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
func (svc *Service) BuildContainerOptionsExported(
ctx context.Context,
+44 -45
View File
@@ -5,7 +5,7 @@
{{define "content"}}
{{template "nav" .}}
<main class="mx-auto px-4 py-8" style="max-width: 84rem;" x-data="appDetail({
<main class="max-w-4xl mx-auto px-4 py-8" x-data="appDetail({
appId: '{{.App.ID}}',
initialDeploymentId: {{if .LatestDeployment}}{{.LatestDeployment.ID}}{{else}}null{{end}},
initialStatus: '{{.App.Status}}',
@@ -26,12 +26,11 @@
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
<span x-bind:class="statusBadgeClass" x-text="statusLabel"></span>
<span class="badge-neutral font-mono break-all" title="Branch">{{.App.Branch}}</span>
</div>
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}</p>
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}}@{{.App.Branch}}</p>
</div>
<div class="flex gap-3">
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
@@ -54,26 +53,6 @@
</div>
</div>
<!-- Container Logs -->
<div class="card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Container Logs</h2>
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
</div>
<div class="relative">
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 800px;">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="containerLogs"></pre>
</div>
<button
x-show="!_containerAutoScroll"
x-transition
@click="_containerAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.containerLogsWrapper)"
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
title="Scroll to bottom"
>↓ Follow</button>
</div>
</div>
<!-- Deploy Key -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Deploy Key</h2>
@@ -121,26 +100,6 @@
</div>
</div>
<!-- Last Deployment Build Logs -->
<div class="card p-6 mb-6" x-show="showBuildLogs" x-cloak>
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Last Deployment Build Logs</h2>
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
</div>
<div class="relative">
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 800px;">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="buildLogs"></pre>
</div>
<button
x-show="!_buildAutoScroll"
x-transition
@click="_buildAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.buildLogsWrapper)"
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
title="Scroll to bottom"
>↓ Follow</button>
</div>
</div>
<!-- Environment Variables -->
<div class="card p-6 mb-6" x-data="envVarEditor('{{.App.ID}}')">
<h2 class="section-title mb-4">Environment Variables</h2>
@@ -178,7 +137,7 @@
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editIdx = -1" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
<p class="alert-warning mt-1">Environment variable changes take effect at the next deploy or rollback.</p>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td>
</template>
</tr>
@@ -394,6 +353,26 @@
</form>
</div>
<!-- Container Logs -->
<div class="card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Container Logs</h2>
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
</div>
<div class="relative">
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="containerLogs"></pre>
</div>
<button
x-show="!_containerAutoScroll"
x-transition
@click="_containerAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.containerLogsWrapper)"
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
title="Scroll to bottom"
>↓ Follow</button>
</div>
</div>
<!-- Recent Deployments -->
<div class="card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
@@ -433,6 +412,26 @@
</template>
</div>
<!-- Last Deployment Build Logs -->
<div class="card p-6 mb-6" x-show="showBuildLogs" x-cloak>
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Last Deployment Build Logs</h2>
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
</div>
<div class="relative">
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="buildLogs"></pre>
</div>
<button
x-show="!_buildAutoScroll"
x-transition
@click="_buildAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.buildLogsWrapper)"
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
title="Scroll to bottom"
>↓ Follow</button>
</div>
</div>
<!-- Danger Zone -->
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
+2 -4
View File
@@ -30,12 +30,10 @@
name="name"
value="{{.App.Name}}"
required
minlength="2"
maxlength="63"
pattern="[a-z0-9]+((\.|-+)[a-z0-9]+)*"
pattern="[a-z0-9-]+"
class="input"
>
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, hyphens, and dots, such as my-app or example.com</p>
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
</div>
<div class="form-group">
+2 -4
View File
@@ -30,13 +30,11 @@
name="name"
value="{{.Name}}"
required
minlength="2"
maxlength="63"
pattern="[a-z0-9]+((\.|-+)[a-z0-9]+)*"
pattern="[a-z0-9-]+"
class="input"
placeholder="my-app"
>
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, hyphens, and dots, such as my-app or example.com</p>
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
</div>
<div class="form-group">
+1 -1
View File
@@ -26,7 +26,7 @@
{{define "nav"}}
<nav class="app-bar">
<div class="max-w-6xl mx-auto flex flex-wrap justify-between items-center gap-3">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-3">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">µPaaS</a>
<span class="text-sm text-gray-500">by <a href="https://sneak.berlin" class="text-primary-600 hover:text-primary-800">@sneak</a></span>
+2 -2
View File
@@ -20,7 +20,7 @@
</div>
{{if .AppStats}}
<div class="card overflow-x-auto">
<div class="card overflow-hidden">
<table class="table">
<thead class="table-header">
<tr>
@@ -41,7 +41,7 @@
{{.App.Name}}
</a>
</td>
<td class="text-gray-500 font-mono text-xs whitespace-normal break-all">{{.App.RepoURL}}</td>
<td class="text-gray-500 font-mono text-xs">{{.App.RepoURL}}</td>
<td class="text-gray-500">{{.App.Branch}}</td>
<td>
{{if eq .App.Status "running"}}