Allow dots in app names (closes #260)
Check / check (pull_request) Skipped

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. This is Docker's rule for an image name, and the
app's image is upaas-<name>; it 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
This commit is contained in:
2026-10-02 00:31:36 +00:00
parent 76858126e2
commit 112cbfae1b
9 changed files with 116 additions and 19 deletions
+6
View File
@@ -20,6 +20,12 @@ regress.
# Completed Steps # 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,
which is Docker's rule for the image name `upaas-<name>`. 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-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
+1 -1
View File
@@ -4,6 +4,7 @@ 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
@@ -39,7 +40,6 @@ 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
+12 -7
View File
@@ -13,12 +13,14 @@ const (
appNameMaxLength = 63 appNameMaxLength = 63
) )
// validAppNameRe matches names containing only lowercase alphanumeric characters and // validAppNameRe matches runs of lowercase letters and digits joined by
// hyphens, starting and ending with an alphanumeric character. // single dots or by hyphens, such as "my-app" or "sneak.berlin". This is
var validAppNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) // Docker's rule for an image name, and the app's image is upaas-<name>.
// 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 " +
@@ -26,11 +28,14 @@ var (
strconv.Itoa(appNameMaxLength) + " characters", strconv.Itoa(appNameMaxLength) + " characters",
) )
errAppNamePattern = errors.New( errAppNamePattern = errors.New(
"app name must contain only lowercase letters, numbers, " + "app name must contain only lowercase letters, numbers, hyphens, " +
"and hyphens, and must start and end with a letter or number", "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 { func validateAppName(name string) error {
if len(name) < appNameMinLength || len(name) > appNameMaxLength { if len(name) < appNameMinLength || len(name) > appNameMaxLength {
return errAppNameLength return errAppNameLength
+10 -1
View File
@@ -18,6 +18,10 @@ 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},
@@ -36,7 +40,12 @@ 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},
{"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}, {"slash", "my/app", true},
{"path traversal", "../etc/passwd", true}, {"path traversal", "../etc/passwd", true},
{"special chars", "app@name!", true}, {"special chars", "app@name!", true},
@@ -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))
}
+12 -6
View File
@@ -35,12 +35,13 @@ import (
// an image's last tag, or an untagged image by its ID, deletes the image. // 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. // It also answers the steps of a git clone that reports shortSHA.
type fakeImageAPI struct { type fakeImageAPI struct {
mu sync.Mutex mu sync.Mutex
images map[string][]string // image ID -> tags images map[string][]string // image ID -> tags
shortSHA string // the commit's short hash the clone reports shortSHA string // the commit's short hash the clone reports
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) {
@@ -67,6 +68,11 @@ 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,6 +113,16 @@ 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,
+4 -2
View File
@@ -30,10 +30,12 @@
name="name" name="name"
value="{{.App.Name}}" value="{{.App.Name}}"
required required
pattern="[a-z0-9-]+" minlength="2"
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, and hyphens only</p> <p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, hyphens, and dots, such as my-app or example.com</p>
</div> </div>
<div class="form-group"> <div class="form-group">
+4 -2
View File
@@ -30,11 +30,13 @@
name="name" name="name"
value="{{.Name}}" value="{{.Name}}"
required required
pattern="[a-z0-9-]+" minlength="2"
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, and hyphens only</p> <p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, hyphens, and dots, such as my-app or example.com</p>
</div> </div>
<div class="form-group"> <div class="form-group">