2 Commits
Author SHA1 Message Date
sneak 8e3c73de09 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 creates an app named sneak.berlin through the new app form,
then builds and deploys it against the fake Docker API and checks the
image and container names with Docker's own rules.

Model: opus-5-5
2026-10-02 03:01:43 +00:00
clawbot 5c836c085d Keep .git/config out of the Docker build context (closes #269)
Check / check (pull_request) Successful in 5m3s
.git goes into the build so `make build` can stamp the version, and with
it went .git/config, where a remote URL can carry a credential that then
stays in the builder stage's layers on the build host. `git describe`
does not need it, so .dockerignore now leaves it out.

Model: opus-5-5
2026-10-02 04:43:03 +02:00
4 changed files with 125 additions and 7 deletions
+3
View File
@@ -1,6 +1,9 @@
# .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.
# .git is sent without its config, because a remote URL there can carry a
# credential; `git describe` does not need it.
.git/config
.env .env
bin/ bin/
.vscode/ .vscode/
+4
View File
@@ -27,6 +27,10 @@ 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: `.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,7 +1,16 @@
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) {
@@ -63,3 +72,42 @@ 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,7 +2,13 @@ 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"
@@ -10,11 +16,17 @@ 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 deploys an app named sneak.berlin against a // TestDeployAppWithDotInName creates an app named sneak.berlin through the
// fake Docker API and checks that Docker accepts the names of the image it // 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. // 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()
@@ -27,15 +39,15 @@ func TestDeployAppWithDotInName(t *testing.T) {
svc, db := newImageTestService(t, api) svc, db := newImageTestService(t, api)
ctx := context.Background() ctx := context.Background()
app := saveApp(t, db, "sneak.berlin", "", "") createdApp := createAppWithForm(t, db, "sneak.berlin")
deployment := models.NewDeployment(db) deployment := models.NewDeployment(db)
deployment.AppID = app.ID deployment.AppID = createdApp.ID
require.NoError(t, deployment.Save(ctx)) require.NoError(t, deployment.Save(ctx))
imageID, err := svc.BuildImage(ctx, app, deployment) imageID, err := svc.BuildImage(ctx, createdApp, deployment)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, svc.DeployContainer(ctx, app, deployment, imageID)) require.NoError(t, svc.DeployContainer(ctx, createdApp, deployment, imageID))
images, _ := api.state() images, _ := api.state()
@@ -53,5 +65,56 @@ 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(app.Name)) 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
} }