{{.App.Name}}
+ {{.App.Branch}}{{.App.RepoURL}}@{{.App.Branch}}
+{{.App.RepoURL}}
diff --git a/.dockerignore b/.dockerignore index de5ff91..5b2701b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,8 @@ -.git +# .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. .env bin/ -.editorconfig .vscode/ .idea/ *.test -LICENSE -CONVENTIONS.md -REPO_POLICIES.md -README.md diff --git a/Dockerfile b/Dockerfile index 1a996e8..572ef99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ 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 diff --git a/README.md b/README.md index c7bce0f..3cc777e 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,10 @@ 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,7 +245,9 @@ Other settings from [Configuration](#configuration) go in the same file, except 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. +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. **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 @@ -257,6 +263,11 @@ 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`. + Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. diff --git a/TODO.md b/TODO.md index ff43640..7684cdc 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,30 @@ regress. # Completed Steps +- 2026-09-29: The app page is 50% wider on large screens (84rem instead of + 56rem), its build log and container log boxes are twice as tall, the build log + sits between the webhook URL and the environment variables, and the container + log sits above the deploy key (#246). + +- 2026-09-29: A failed build now fails the deploy with the build's own error + instead of a later "failed to inspect image", and the deployment log shows the + end of the build output before that error. upaas refuses to build on a Docker + Engine older than 18.09, which cannot build with BuildKit. The README's + Compose section says to update with `docker compose up -d --build` and names + the Docker Engine versions builds need (#234). + +- 2026-09-29: An image built from the `Dockerfile` now shows the commit it was + built from (the `git describe` output) in the footer and `/health` instead of + `dev`: `.dockerignore` no longer leaves out `.git`, nor any tracked file, + which git would count as deleted and mark `-dirty`. upaas now also logs its + version at startup; the logger's `Identify()` was never called (#236). + +- 2026-09-29: The app page shows the app's branch as a label in its title, next + to the status badge, instead of after the repository under it (#240). + +- 2026-09-29: An app's deployments page now lists only its 10 most recent + deployments, newest first, instead of 50 (#238). + - 2026-09-29: `docker-compose.yml` now sets `UPAAS_PORT` to 8080 as well as `PORT`, since upaas reads `UPAAS_PORT` first and a `UPAAS_PORT` in `.env` made it listen away from the port mapping and healthcheck; the README's Compose diff --git a/cmd/upaasd/main.go b/cmd/upaasd/main.go index 1d37fd3..29a4c29 100644 --- a/cmd/upaasd/main.go +++ b/cmd/upaasd/main.go @@ -52,6 +52,8 @@ func main() { handlers.New, server.New, ), - fx.Invoke(func(*server.Server) {}), + fx.Invoke(func(log *logger.Logger, _ *server.Server) { + log.Identify() + }), ).Run() } diff --git a/internal/docker/client.go b/internal/docker/client.go index 46d2d53..2af6f52 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -21,6 +21,7 @@ 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" @@ -61,6 +62,15 @@ 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._/\-]+$`) @@ -595,6 +605,20 @@ 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 := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{}) if err != nil { @@ -660,7 +684,8 @@ 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. +// build errors, are written unchanged. Docker ends a failed build with a line +// carrying the error; it is returned once the output is written. func (c *Client) streamBuildOutput( ctx context.Context, body io.Reader, @@ -690,6 +715,8 @@ func (c *Client) streamBuildOutput( buf := make([]byte, 0, scannerInitialBufferSize) scanner.Buffer(buf, scannerMaxBufferSize) + var buildErr error + for scanner.Scan() { line := scanner.Bytes() @@ -708,6 +735,10 @@ 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) } @@ -720,7 +751,7 @@ func (c *Client) streamBuildOutput( return fmt.Errorf("failed to read build output: %w", scanErr) } - return nil + return buildErr } func (c *Client) performClone( diff --git a/internal/docker/validation_test.go b/internal/docker/validation_test.go index 7257344..f496655 100644 --- a/internal/docker/validation_test.go +++ b/internal/docker/validation_test.go @@ -271,6 +271,8 @@ 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, "/build"): if r.URL.Query().Get("version") != "2" { http.Error(w, "not a BuildKit build", http.StatusBadRequest) @@ -314,3 +316,82 @@ 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, "/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) + } + }) + } +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 22d527c..e03faf8 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -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 = 50 + deploymentsHistoryLimit = 10 ) // redirectToApp issues a SeeOther redirect to the page for the given diff --git a/internal/handlers/app_branch_test.go b/internal/handlers/app_branch_test.go new file mode 100644 index 0000000..c529006 --- /dev/null +++ b/internal/handlers/app_branch_test.go @@ -0,0 +1,46 @@ +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(), "
{{.App.RepoURL}}@{{.App.Branch}}
+{{.App.RepoURL}}