1 Commits
Author SHA1 Message Date
sneak 487b77d9d6 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. Docker accepts every such name in the app's image
name, upaas-<name>; 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
2026-10-02 01:23:04 +00:00
8 changed files with 15 additions and 254 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/
-18
View File
@@ -20,10 +20,6 @@ regress.
# Completed Steps # Completed Steps
- 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 - 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. 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 Docker accepts every such name in the image name `upaas-<name>`; a dot needs a
@@ -31,20 +27,6 @@ regress.
app forms check the same rule; browsers ignored their old pattern, which was app forms check the same rule; browsers ignored their old pattern, which was
not a valid regular expression there (#260). not a valid regular expression there (#260).
- 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
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).
- 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 - 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 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` as `arch`. `CONVENTIONS.md` follows the updated conventions in `sneak/prompts`
@@ -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) {
@@ -72,42 +63,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)
}
}
@@ -1,77 +0,0 @@
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"
)
// TestDashboardTableFitsCard checks that the card around the app table
// scrolls sideways instead of hiding what does not fit, and that a long
// repository URL wraps instead of pushing the action buttons out.
func TestDashboardTableFitsCard(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
repoURL := "https://git.example.com/user/" +
"a-repository-name-long-enough-to-push-the-action-buttons-out.git"
_, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "long-url-app",
RepoURL: repoURL,
})
require.NoError(t, err)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
testCtx.handlers.HandleDashboard().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
beforeTable, _, found := strings.Cut(body, `<table class="table">`)
require.True(t, found, "dashboard has no app table")
cardTag := beforeTable[strings.LastIndex(beforeTable, "<div"):]
assert.Contains(t, cardTag, "overflow-x-auto")
assert.NotContains(t, cardTag, "overflow-hidden")
beforeURL, _, found := strings.Cut(body, ">"+repoURL+"</td>")
require.True(t, found, "dashboard has no repository cell")
cellTag := beforeURL[strings.LastIndex(beforeURL, "<td"):]
assert.Contains(t, cellTag, "whitespace-normal")
assert.Contains(t, cellTag, "break-all")
}
// 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-")
}
@@ -2,13 +2,7 @@ package deploy_test
import ( import (
"context" "context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices" "slices"
"strings"
"testing" "testing"
"github.com/distribution/reference" "github.com/distribution/reference"
@@ -16,17 +10,11 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/models"
"sneak.berlin/go/upaas/internal/service/app"
) )
// TestDeployAppWithDotInName creates an app named sneak.berlin through the // TestDeployAppWithDotInName deploys an app named sneak.berlin against a
// new app form, as a user does, then builds and deploys it against a fake // fake Docker API and checks that Docker accepts the names of the image it
// 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. // builds and of the container it runs, using Docker's own rules for each.
func TestDeployAppWithDotInName(t *testing.T) { func TestDeployAppWithDotInName(t *testing.T) {
t.Parallel() t.Parallel()
@@ -39,15 +27,15 @@ func TestDeployAppWithDotInName(t *testing.T) {
svc, db := newImageTestService(t, api) svc, db := newImageTestService(t, api)
ctx := context.Background() ctx := context.Background()
createdApp := createAppWithForm(t, db, "sneak.berlin") app := saveApp(t, db, "sneak.berlin", "", "")
deployment := models.NewDeployment(db) deployment := models.NewDeployment(db)
deployment.AppID = createdApp.ID deployment.AppID = app.ID
require.NoError(t, deployment.Save(ctx)) require.NoError(t, deployment.Save(ctx))
imageID, err := svc.BuildImage(ctx, createdApp, deployment) imageID, err := svc.BuildImage(ctx, app, deployment)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, svc.DeployContainer(ctx, createdApp, deployment, imageID)) require.NoError(t, svc.DeployContainer(ctx, app, deployment, imageID))
images, _ := api.state() images, _ := api.state()
@@ -65,57 +53,5 @@ func TestDeployAppWithDotInName(t *testing.T) {
require.Equal(t, []string{"upaas-sneak.berlin"}, containers) require.Equal(t, []string{"upaas-sneak.berlin"}, containers)
assert.Regexp(t, names.RestrictedNamePattern, containers[0]) assert.Regexp(t, names.RestrictedNamePattern, containers[0])
assert.DirExists(t, svc.GetBuildDirExported(createdApp.Name)) assert.DirExists(t, svc.GetBuildDirExported(app.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
} }
+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>
+2 -2
View File
@@ -20,7 +20,7 @@
</div> </div>
{{if .AppStats}} {{if .AppStats}}
<div class="card overflow-x-auto"> <div class="card overflow-hidden">
<table class="table"> <table class="table">
<thead class="table-header"> <thead class="table-header">
<tr> <tr>
@@ -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">{{.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"}}