2 Commits
Author SHA1 Message Date
clawbot 7cf7059d3a Leave out of the build context what the app's .dockerignore names (closes #274)
Check / check (pull_request) Successful in 3m47s
Docker does not apply an app's `.dockerignore` to a build context sent as a tar, which is how upaas sends it, so every file in the clone, `.git/config` included, reached the build.

upaas now reads the ignore file as `docker build` does, with the `ignorefile` reader of `github.com/moby/patternmatcher`, and leaves those files out of the tar: an ignore file named after the Dockerfile and next to it, such as `Dockerfile.dockerignore`, otherwise `.dockerignore` at the root of the clone. The Dockerfile path is read as a path inside the clone, and the Dockerfile (or the lowercase `dockerfile` Docker builds when `Dockerfile` is missing) and the ignore file always stay in. An app without an ignore file builds as before.

Not handled: a Dockerfile that is a symlink to an ignored file fails the build.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-03 03:34:39 +02:00
clawbot 2a2c52074d Hash passwords with 1 MiB in tests so make test fits in 4 GiB (closes #261)
Check / check (pull_request) Successful in 3m44s
On a build machine with 4 GiB, `docker build .` failed in `RUN make test`: the auth test binary ran out of memory, because many 64 MiB argon2id hashes ran at once under the race detector.

The memory per hash is now the auth service's `ArgonMemory` field. `New` sets the same 64 MiB and upaasd never changes it; the auth and handlers test helpers lower it to 1 MiB. One test still hashes and verifies a password at 64 MiB. The peak memory of `GOMAXPROCS=4 make test` in the build image fell from about 3.1 GiB to 1.0 GiB.

Not run on a 4 GiB arm64 machine.

Model: opus-5-5
2026-10-03 03:01:59 +02:00
6 changed files with 154 additions and 9 deletions
+6
View File
@@ -27,6 +27,12 @@ regress.
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).
+20
View File
@@ -7,6 +7,7 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/docker/docker/pkg/archive"
"github.com/moby/patternmatcher"
@@ -41,6 +42,11 @@ func tarBuildContext(contextDir, dockerfile string) (io.ReadCloser, error) {
// 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
}
@@ -54,6 +60,20 @@ func readDockerignore(contextDir, dockerfile string) ([]string, error) {
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)
+92 -3
View File
@@ -19,9 +19,10 @@ import (
// 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"
testMainGo = "main.go"
testSecretFile = "secret.txt"
testDeployDockerfile = "deploy/Dockerfile"
testLowercaseDockerfile = "dockerfile"
)
// TestPerformBuildFollowsDockerignore runs builds against a fake Docker API
@@ -105,6 +106,94 @@ func TestPerformBuildFollowsDockerignore(t *testing.T) {
}
}
// 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) {
+3
View File
@@ -109,6 +109,9 @@ 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,
+14 -6
View File
@@ -59,6 +59,13 @@ 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
@@ -77,10 +84,11 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
}
return &Service{
log: params.Logger.Get(),
db: params.Database,
store: store,
params: &params,
ArgonMemory: argonMemory,
log: params.Logger.Get(),
db: params.Database,
store: store,
params: &params,
}, nil
}
@@ -97,7 +105,7 @@ func (svc *Service) HashPassword(password string) (string, error) {
[]byte(password),
salt,
argonTime,
argonMemory,
svc.ArgonMemory,
argonThreads,
argonKeyLen,
)
@@ -132,7 +140,7 @@ func (svc *Service) VerifyPassword(hashedPassword, password string) bool {
[]byte(password),
salt,
argonTime,
argonMemory,
svc.ArgonMemory,
argonThreads,
argonKeyLen,
)
+19
View File
@@ -65,6 +65,10 @@ 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() {}
@@ -237,6 +241,21 @@ 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()