1 Commits
Author SHA1 Message Date
clawbot 13d2619df3 Keep the Applications list's table inside its card (closes #262)
Check / check (pull_request) Skipped
Table cells never wrap, so a long repository URL made the table wider
than its card, which hides what does not fit and so cut off the Actions
column and the Deploy buttons. The repository URL now wraps within its
column, with a minimum width of 8rem so it stays readable in a narrow
window, and the card scrolls sideways when the table still does not
fit. A handler test checks both. Every class used was already in the
committed static/css/tailwind.css.

Model: opus-5-5
2026-10-02 00:47:42 +00:00
27 changed files with 82 additions and 865 deletions
+5 -32
View File
@@ -1,35 +1,8 @@
# .git is sent so that `make build` in the Dockerfile can stamp the commit into # .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 # upaas. List no tracked file here: git would see it as deleted in the build and
# the version would end in -dirty. # the version would end in -dirty.
.env
# The patterns of .gitignore; **/ makes Docker match them in every directory. bin/
**/.DS_Store .vscode/
**/Thumbs.db .idea/
**/*.swp *.test
**/*.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
-5
View File
@@ -1,5 +1,3 @@
# .dockerignore repeats these patterns; change both together.
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -31,6 +29,3 @@ bin/
*.dylib *.dylib
*.test *.test
*.out *.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 ( var (
Appname string = "CHANGEME" Appname string = "CHANGEME"
Version string Version string
Buildarch string
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
fx.New( fx.New(
fx.Provide( fx.Provide(
@@ -821,7 +823,7 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"arch", runtime.GOARCH, "buildarch", l.params.Globals.Buildarch,
) )
} }
``` ```
@@ -943,17 +945,20 @@ import "go.uber.org/fx"
var ( var (
Appname string Appname string
Version string Version string
Buildarch string
) )
// Struct for DI // Struct for DI
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
func New(lc fx.Lifecycle) (*Globals, error) { func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Buildarch: Buildarch,
Version: Version, Version: Version,
} }
return n, nil return n, nil
@@ -967,11 +972,13 @@ func New(lc fx.Lifecycle) (*Globals, error) {
var ( var (
Appname string = "CHANGEME" // Default, overridden by build Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time Version string // Set at build time
Buildarch string // Set at build time
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
// ... // ...
} }
``` ```
@@ -982,9 +989,10 @@ Use ldflags to inject version information at build time:
```makefile ```makefile
VERSION := $(shell git describe --tags --always) VERSION := $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build: build:
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
``` ```
--- ---
+2 -1
View File
@@ -2,7 +2,8 @@
BINARY := upaasd BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 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 all: check build
-6
View File
@@ -268,12 +268,6 @@ upaas fails the deploy instead of building. A Dockerfile that uses
`RUN --network` needs Docker Engine 23.0 or later unless its `# syntax=` line `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`. 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 Session secrets are automatically generated on first startup and persisted to
`$UPAAS_DATA_DIR/session.key`. `$UPAAS_DATA_DIR/session.key`.
-38
View File
@@ -20,49 +20,11 @@ regress.
# Completed Steps # Completed Steps
- 2026-10-03: An app's build context leaves out the files its `.dockerignore`
names, such as `.git/config`, as `docker build` does; before, every file in
the clone was sent. An ignore file next to the Dockerfile,
`<Dockerfile>.dockerignore`, is read instead when there is one. The Dockerfile
and `.dockerignore` are always sent. An ignore file that cannot be read, or
holds a pattern Docker rejects, fails the build (#274).
- 2026-10-03: `make test`, and so `docker build .`, fits a machine with 4 GiB of
memory: tests hash passwords with 1 MiB instead of upaasd's 64 MiB, so
`GOMAXPROCS=4 make test` peaks at about 1.4 GiB instead of 2.7 GiB. upaasd
still hashes with 64 MiB, and one test hashes and verifies a password at that
cost (#261).
- 2026-10-02: In a window too narrow for the top bar, such as 390 px, the New
App and Logout buttons move to a second row instead of running into "by
@sneak"; the bar keeps a gap between its two sides at every width (#272).
- 2026-10-02: App names may contain dots, such as `sneak.berlin`: lowercase
letters and numbers joined by single dots or by hyphens, 2 to 63 characters.
Docker accepts every such name in the image name `upaas-<name>`; a dot needs a
letter or number on both sides because Docker requires it. The new and edit
app forms check the same rule; browsers ignored their old pattern, which was
not a valid regular expression there (#260).
- 2026-10-02: On the Applications list, a long repository URL now wraps within - 2026-10-02: On the Applications list, a long repository URL now wraps within
its column instead of making the table wider than its card, which cut off the its column instead of making the table wider than its card, which cut off the
Actions column and the Deploy buttons. In a window too narrow for the table, Actions column and the Deploy buttons. In a window too narrow for the table,
the card scrolls sideways instead of cutting the table off (#262). the card scrolls sideways instead of cutting the table off (#262).
- 2026-10-02: `docker build .` no longer sends git-ignored files, such as
`.env.local`, `*.key` files or upaasd's `data/` directory with its session
key, into the build stages and the build cache: `.dockerignore` now leaves out
everything `.gitignore` does, and `data/` is git-ignored (#266).
- 2026-10-02: `.dockerignore` leaves out `.git/config`, so a remote URL there
that carries a credential no longer goes into the Docker build; the image
still shows the commit it was built from (#269).
- 2026-10-02: The build no longer passes the CPU architecture in: upaas reads it
from Go's `runtime.GOARCH` when it runs, and the startup log line reports it
as `arch`. `CONVENTIONS.md` follows the updated conventions in `sneak/prompts`
(#259).
- 2026-10-01: Built images are tagged `upaas-<app>:<short hash>`, git's short - 2026-10-01: Built images are tagged `upaas-<app>:<short hash>`, git's short
form of the commit built, instead of the deployment number. A redeploy of a form of the commit built, instead of the deployment number. A redeploy of a
commit gives its tag to the new image; the old one is kept while the app runs commit gives its tag to the new image; the old one is kept while the app runs
+2
View File
@@ -27,11 +27,13 @@ import (
var ( var (
Appname = "upaas" //nolint:gochecknoglobals // build-time variable Appname = "upaas" //nolint:gochecknoglobals // build-time variable
Version string //nolint:gochecknoglobals // build-time variable Version string //nolint:gochecknoglobals // build-time variable
Buildarch string //nolint:gochecknoglobals // build-time variable
) )
func main() { func main() {
globals.SetAppname(Appname) globals.SetAppname(Appname)
globals.SetVersion(Version) globals.SetVersion(Version)
globals.SetBuildarch(Buildarch)
fx.New( fx.New(
fx.Provide( fx.Provide(
+2 -2
View File
@@ -4,7 +4,6 @@ go 1.25
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 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/docker v27.3.1+incompatible
github.com/docker/go-connections v0.6.0 github.com/docker/go-connections v0.6.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
@@ -15,7 +14,6 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.32 github.com/mattn/go-sqlite3 v1.14.32
github.com/moby/buildkit v0.16.0 github.com/moby/buildkit v0.16.0
github.com/moby/patternmatcher v0.6.0
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
@@ -41,6 +39,7 @@ require (
github.com/containerd/ttrpc v1.2.5 // indirect github.com/containerd/ttrpc v1.2.5 // indirect
github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/containerd/typeurl/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -61,6 +60,7 @@ require (
github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.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/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect
-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
}
+2 -1
View File
@@ -25,6 +25,7 @@ import (
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/versions" "github.com/docker/docker/api/types/versions"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
@@ -659,7 +660,7 @@ func (c *Client) performBuild(
} }
// Create tar archive of build context // Create tar archive of build context
tarArchive, err := tarBuildContext(opts.ContextDir, opts.DockerfilePath) tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create build context: %w", err) return "", fmt.Errorf("failed to create build context: %w", err)
} }
+11
View File
@@ -15,12 +15,14 @@ var (
mu sync.RWMutex mu sync.RWMutex
appname string appname string
version string version string
buildarch string
) )
// Globals holds build-time variables for dependency injection. // Globals holds build-time variables for dependency injection.
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
// New creates a new Globals instance from package-level variables. // New creates a new Globals instance from package-level variables.
@@ -31,6 +33,7 @@ func New(_ fx.Lifecycle) (*Globals, error) {
return &Globals{ return &Globals{
Appname: appname, Appname: appname,
Version: version, Version: version,
Buildarch: buildarch,
}, nil }, nil
} }
@@ -49,3 +52,11 @@ func SetVersion(ver string) {
version = ver version = ver
} }
// SetBuildarch sets the build architecture (used for testing and main init).
func SetBuildarch(arch string) {
mu.Lock()
defer mu.Unlock()
buildarch = arch
}
+7 -13
View File
@@ -13,15 +13,12 @@ const (
appNameMaxLength = 63 appNameMaxLength = 63
) )
// validAppNameRe matches runs of lowercase letters and digits joined by // validAppNameRe matches names containing only lowercase alphanumeric characters and
// single dots or by hyphens, such as "my-app" or "sneak.berlin". Docker // hyphens, starting and ending with an alphanumeric character.
// accepts every name it allows as the app's image name, upaas-<name>; a var validAppNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
// 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]+)*$`)
// validateAppName checks that the given app name is safe for use in Docker
// container names, image tags, and file system paths.
var ( var (
errAppNameLength = errors.New( errAppNameLength = errors.New(
"app name must be between " + "app name must be between " +
@@ -29,14 +26,11 @@ var (
strconv.Itoa(appNameMaxLength) + " characters", strconv.Itoa(appNameMaxLength) + " characters",
) )
errAppNamePattern = errors.New( errAppNamePattern = errors.New(
"app name must contain only lowercase letters, numbers, hyphens, " + "app name must contain only lowercase letters, numbers, " +
"and dots, must start and end with a letter or number, " + "and hyphens, and must start and end with a letter or number",
"and must have a letter or number on both sides of each dot",
) )
) )
// 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 { func validateAppName(name string) error {
if len(name) < appNameMinLength || len(name) > appNameMaxLength { if len(name) < appNameMinLength || len(name) > appNameMaxLength {
return errAppNameLength return errAppNameLength
+1 -58
View File
@@ -1,16 +1,7 @@
package handlers //nolint:testpackage // testing unexported validateAppName package handlers //nolint:testpackage // testing unexported validateAppName
import ( import (
"bytes"
"strconv"
"strings"
"testing" "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) { func TestValidateAppName(t *testing.T) {
@@ -27,10 +18,6 @@ func TestValidateAppName(t *testing.T) {
{"valid two chars", "ab", false}, {"valid two chars", "ab", false},
{"valid complex", "my-cool-app-v2", false}, {"valid complex", "my-cool-app-v2", false},
{"valid all numbers", "123", 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}, {"empty", "", true},
{"single char", "a", true}, {"single char", "a", true},
{"too long", "a" + string(make([]byte, 63)), true}, {"too long", "a" + string(make([]byte, 63)), true},
@@ -49,12 +36,7 @@ func TestValidateAppName(t *testing.T) {
{"starts with hyphen", "-myapp", true}, {"starts with hyphen", "-myapp", true},
{"ends with hyphen", "myapp-", true}, {"ends with hyphen", "myapp-", true},
{"underscore", "my_app", true}, {"underscore", "my_app", true},
{"two dots in a row", "a..b", true}, {"dot", "my.app", 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},
{"slash", "my/app", true}, {"slash", "my/app", true},
{"path traversal", "../etc/passwd", true}, {"path traversal", "../etc/passwd", true},
{"special chars", "app@name!", 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)
}
}
+2 -23
View File
@@ -51,27 +51,6 @@ func TestDashboardTableFitsCard(t *testing.T) {
cellTag := beforeURL[strings.LastIndex(beforeURL, "<td"):] cellTag := beforeURL[strings.LastIndex(beforeURL, "<td"):]
assert.Contains(t, cellTag, "whitespace-normal") assert.Contains(t, cellTag, "whitespace-normal")
assert.Contains(t, cellTag, "break-all") assert.Contains(t, cellTag, "break-all")
} assert.Contains(t, cellTag, "min-w-[8rem]",
"without a minimum width the URL is one character wide in a narrow window")
// 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-")
} }
-3
View File
@@ -109,9 +109,6 @@ func createAppServices(
}) })
require.NoError(t, authErr) 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{ appSvc, appErr := app.New(fx.Lifecycle(nil), app.ServiceParams{
Logger: logInstance, Logger: logInstance,
Database: dbInstance, Database: dbInstance,
+1 -2
View File
@@ -4,7 +4,6 @@ package logger
import ( import (
"log/slog" "log/slog"
"os" "os"
"runtime"
"go.uber.org/fx" "go.uber.org/fx"
@@ -82,6 +81,6 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "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 -10
View File
@@ -59,13 +59,6 @@ type ServiceParams struct {
// Service provides authentication functionality. // Service provides authentication functionality.
type Service struct { 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 log *slog.Logger
db *database.Database db *database.Database
store *sessions.CookieStore store *sessions.CookieStore
@@ -84,7 +77,6 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
} }
return &Service{ return &Service{
ArgonMemory: argonMemory,
log: params.Logger.Get(), log: params.Logger.Get(),
db: params.Database, db: params.Database,
store: store, store: store,
@@ -105,7 +97,7 @@ func (svc *Service) HashPassword(password string) (string, error) {
[]byte(password), []byte(password),
salt, salt,
argonTime, argonTime,
svc.ArgonMemory, argonMemory,
argonThreads, argonThreads,
argonKeyLen, argonKeyLen,
) )
@@ -140,7 +132,7 @@ func (svc *Service) VerifyPassword(hashedPassword, password string) bool {
[]byte(password), []byte(password),
salt, salt,
argonTime, argonTime,
svc.ArgonMemory, argonMemory,
argonThreads, argonThreads,
argonKeyLen, argonKeyLen,
) )
-19
View File
@@ -65,10 +65,6 @@ func setupTestService(t *testing.T) (*auth.Service, func()) {
}) })
require.NoError(t, err) 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 // t.TempDir() automatically cleans up after test
cleanup := func() {} 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) { func TestIsSetupRequired(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -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
}
@@ -41,7 +41,6 @@ type fakeImageAPI struct {
nextID string // ID of the image the next build creates nextID string // ID of the image the next build creates
removed []string // each tag or ID removed removed []string // each tag or ID removed
forced bool // whether a removal was forced 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) { func (api *fakeImageAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -68,11 +67,6 @@ func (api *fakeImageAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasSuffix(r.URL.Path, "/version"): case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`)) _, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`))
case strings.HasSuffix(r.URL.Path, "/containers/create"): 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)
}
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`)) _, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
case strings.HasSuffix(r.URL.Path, "/logs"): case strings.HasSuffix(r.URL.Path, "/logs"):
writeCloneOutput(w, api.shortSHA) writeCloneOutput(w, api.shortSHA)
-10
View File
@@ -113,16 +113,6 @@ func (svc *Service) BuildImage(
return svc.buildImage(ctx, app, deployment) 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. // BuildContainerOptionsExported exposes buildContainerOptions for testing.
func (svc *Service) BuildContainerOptionsExported( func (svc *Service) BuildContainerOptionsExported(
ctx context.Context, ctx context.Context,
+2 -4
View File
@@ -30,12 +30,10 @@
name="name" name="name"
value="{{.App.Name}}" value="{{.App.Name}}"
required required
minlength="2" pattern="[a-z0-9-]+"
maxlength="63"
pattern="[a-z0-9]+((\.|-+)[a-z0-9]+)*"
class="input" 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>
<div class="form-group"> <div class="form-group">
+2 -4
View File
@@ -30,13 +30,11 @@
name="name" name="name"
value="{{.Name}}" value="{{.Name}}"
required required
minlength="2" pattern="[a-z0-9-]+"
maxlength="63"
pattern="[a-z0-9]+((\.|-+)[a-z0-9]+)*"
class="input" class="input"
placeholder="my-app" 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>
<div class="form-group"> <div class="form-group">
+1 -1
View File
@@ -26,7 +26,7 @@
{{define "nav"}} {{define "nav"}}
<nav class="app-bar"> <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"> <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> <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> <span class="text-sm text-gray-500">by <a href="https://sneak.berlin" class="text-primary-600 hover:text-primary-800">@sneak</a></span>
+1 -1
View File
@@ -41,7 +41,7 @@
{{.App.Name}} {{.App.Name}}
</a> </a>
</td> </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 whitespace-normal break-all min-w-[8rem]">{{.App.RepoURL}}</td>
<td class="text-gray-500">{{.App.Branch}}</td> <td class="text-gray-500">{{.App.Branch}}</td>
<td> <td>
{{if eq .App.Status "running"}} {{if eq .App.Status "running"}}