From 487b77d9d63325bbaee2402c301ea2f8121db576 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 2 Oct 2026 00:31:36 +0000 Subject: [PATCH] Allow dots in app names (closes #260) App names may now contain dots, such as sneak.berlin: runs of lowercase letters and numbers joined by single dots or by hyphens, 2 to 63 characters. Docker accepts every such name in the app's image name, upaas-; a dot needs a letter or number on both sides because Docker requires it. The rule also keeps a dot off either end, so the name is safe as a directory and log file name. The new and edit app forms use the same pattern. Their old one was not a valid regular expression under the flag browsers compile it with, so browsers ignored it. A test deploys an app named sneak.berlin against the fake Docker API and checks the image and container names with Docker's own rules. Model: opus-5-5 --- TODO.md | 7 +++ go.mod | 2 +- internal/handlers/app_name_validation.go | 20 ++++--- internal/handlers/app_name_validation_test.go | 11 +++- .../service/deploy/deploy_app_name_test.go | 57 +++++++++++++++++++ internal/service/deploy/deploy_images_test.go | 18 ++++-- internal/service/deploy/export_test.go | 10 ++++ templates/app_edit.html | 6 +- templates/app_new.html | 6 +- 9 files changed, 118 insertions(+), 19 deletions(-) create mode 100644 internal/service/deploy/deploy_app_name_test.go diff --git a/TODO.md b/TODO.md index d6bca11..3dbc3ac 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,13 @@ regress. # Completed Steps +- 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-`; 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: 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` diff --git a/go.mod b/go.mod index c85cc4d..b2de996 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ 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 @@ -39,7 +40,6 @@ 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 diff --git a/internal/handlers/app_name_validation.go b/internal/handlers/app_name_validation.go index d1d1abb..f6bf1e9 100644 --- a/internal/handlers/app_name_validation.go +++ b/internal/handlers/app_name_validation.go @@ -13,12 +13,15 @@ const ( appNameMaxLength = 63 ) -// 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]$`) +// 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-; 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]+)*$`) -// 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 " + @@ -26,11 +29,14 @@ var ( strconv.Itoa(appNameMaxLength) + " characters", ) errAppNamePattern = errors.New( - "app name must contain only lowercase letters, numbers, " + - "and hyphens, and must start and end with a letter or number", + "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", ) ) +// 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 diff --git a/internal/handlers/app_name_validation_test.go b/internal/handlers/app_name_validation_test.go index b4120fb..a757453 100644 --- a/internal/handlers/app_name_validation_test.go +++ b/internal/handlers/app_name_validation_test.go @@ -18,6 +18,10 @@ 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}, @@ -36,7 +40,12 @@ func TestValidateAppName(t *testing.T) { {"starts with hyphen", "-myapp", true}, {"ends with hyphen", "myapp-", true}, {"underscore", "my_app", true}, - {"dot", "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}, {"slash", "my/app", true}, {"path traversal", "../etc/passwd", true}, {"special chars", "app@name!", true}, diff --git a/internal/service/deploy/deploy_app_name_test.go b/internal/service/deploy/deploy_app_name_test.go new file mode 100644 index 0000000..967d692 --- /dev/null +++ b/internal/service/deploy/deploy_app_name_test.go @@ -0,0 +1,57 @@ +package deploy_test + +import ( + "context" + "slices" + "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/models" +) + +// TestDeployAppWithDotInName deploys an app named sneak.berlin 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() + + app := saveApp(t, db, "sneak.berlin", "", "") + + deployment := models.NewDeployment(db) + deployment.AppID = app.ID + require.NoError(t, deployment.Save(ctx)) + + imageID, err := svc.BuildImage(ctx, app, deployment) + require.NoError(t, err) + require.NoError(t, svc.DeployContainer(ctx, app, 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(app.Name)) +} diff --git a/internal/service/deploy/deploy_images_test.go b/internal/service/deploy/deploy_images_test.go index 7e93849..7141c25 100644 --- a/internal/service/deploy/deploy_images_test.go +++ b/internal/service/deploy/deploy_images_test.go @@ -35,12 +35,13 @@ import ( // 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 { - 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 + 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) { @@ -67,6 +68,11 @@ func (api *fakeImageAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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) + } + _, _ = w.Write([]byte(`{"Id":"gitcontainer"}`)) case strings.HasSuffix(r.URL.Path, "/logs"): writeCloneOutput(w, api.shortSHA) diff --git a/internal/service/deploy/export_test.go b/internal/service/deploy/export_test.go index 8abfc34..0ea6119 100644 --- a/internal/service/deploy/export_test.go +++ b/internal/service/deploy/export_test.go @@ -113,6 +113,16 @@ func (svc *Service) BuildImage( 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, diff --git a/templates/app_edit.html b/templates/app_edit.html index 3bb3852..eece07f 100644 --- a/templates/app_edit.html +++ b/templates/app_edit.html @@ -30,10 +30,12 @@ name="name" value="{{.App.Name}}" required - pattern="[a-z0-9-]+" + minlength="2" + maxlength="63" + pattern="[a-z0-9]+((\.|-+)[a-z0-9]+)*" class="input" > -

Lowercase letters, numbers, and hyphens only

+

Lowercase letters, numbers, hyphens, and dots, such as my-app or example.com

diff --git a/templates/app_new.html b/templates/app_new.html index 101fc6a..8a32a92 100644 --- a/templates/app_new.html +++ b/templates/app_new.html @@ -30,11 +30,13 @@ name="name" value="{{.Name}}" required - pattern="[a-z0-9-]+" + minlength="2" + maxlength="63" + pattern="[a-z0-9]+((\.|-+)[a-z0-9]+)*" class="input" placeholder="my-app" > -

Lowercase letters, numbers, and hyphens only

+

Lowercase letters, numbers, hyphens, and dots, such as my-app or example.com