2 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
clawbot 5168db69d9 Read the architecture at run time instead of a Buildarch ldflag (closes #259)
Check / check (pull_request) Successful in 5m19s
The Makefile no longer passes the architecture to the linker. The
Buildarch variable in main and the field and setter in globals are
gone; the startup log line reports runtime.GOARCH as `arch`.
CONVENTIONS.md drops Buildarch from its main, globals, logger and
Makefile examples, as the sneak/prompts conventions do.

Model: opus-5-5
2026-10-02 03:21:51 +02:00
15 changed files with 181 additions and 64 deletions
+12 -20
View File
@@ -115,15 +115,13 @@ import (
) )
var ( var (
Appname string = "CHANGEME" Appname string = "CHANGEME"
Version string Version string
Buildarch string
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
fx.New( fx.New(
fx.Provide( fx.Provide(
@@ -823,7 +821,7 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch, "arch", runtime.GOARCH,
) )
} }
``` ```
@@ -943,23 +941,20 @@ import "go.uber.org/fx"
// Package-level variables (set from main) // Package-level variables (set from main)
var ( var (
Appname string Appname string
Version string Version string
Buildarch string
) )
// Struct for DI // Struct for DI
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
func New(lc fx.Lifecycle) (*Globals, error) { func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Buildarch: Buildarch, Version: Version,
Version: Version,
} }
return n, nil return n, nil
} }
@@ -970,15 +965,13 @@ func New(lc fx.Lifecycle) (*Globals, error) {
```go ```go
// cmd/httpd/main.go // cmd/httpd/main.go
var ( var (
Appname string = "CHANGEME" // Default, overridden by build Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time Version string // Set at build time
Buildarch string // Set at build time
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
// ... // ...
} }
``` ```
@@ -989,10 +982,9 @@ Use ldflags to inject version information at build time:
```makefile ```makefile
VERSION := $(shell git describe --tags --always) VERSION := $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build: build:
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
``` ```
--- ---
+1 -2
View File
@@ -2,8 +2,7 @@
BINARY := upaasd BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILDARCH := $(shell go env GOARCH) LDFLAGS := -X main.Version=$(VERSION)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build all: check build
+12
View File
@@ -20,6 +20,18 @@ 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.
Docker accepts every such name in the image name `upaas-<name>`; 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`
(#259).
- 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
+2 -4
View File
@@ -25,15 +25,13 @@ import (
// Build-time variables injected by linker flags (-ldflags). // Build-time variables injected by linker flags (-ldflags).
// These must be exported package-level variables for the build system. // These must be exported package-level variables for the build system.
var ( var (
Appname = "upaas" //nolint:gochecknoglobals // build-time variable Appname = "upaas" //nolint:gochecknoglobals // build-time variable
Version string //nolint:gochecknoglobals // build-time variable Version string //nolint:gochecknoglobals // build-time variable
Buildarch string //nolint:gochecknoglobals // build-time variable
) )
func main() { func main() {
globals.SetAppname(Appname) globals.SetAppname(Appname)
globals.SetVersion(Version) globals.SetVersion(Version)
globals.SetBuildarch(Buildarch)
fx.New( fx.New(
fx.Provide( fx.Provide(
+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
+7 -18
View File
@@ -12,17 +12,15 @@ import (
// //
//nolint:gochecknoglobals // Required for ldflags injection at build time //nolint:gochecknoglobals // Required for ldflags injection at build time
var ( var (
mu sync.RWMutex mu sync.RWMutex
appname string appname string
version string version string
buildarch string
) )
// Globals holds build-time variables for dependency injection. // Globals holds build-time variables for dependency injection.
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
// New creates a new Globals instance from package-level variables. // New creates a new Globals instance from package-level variables.
@@ -31,9 +29,8 @@ func New(_ fx.Lifecycle) (*Globals, error) {
defer mu.RUnlock() defer mu.RUnlock()
return &Globals{ return &Globals{
Appname: appname, Appname: appname,
Version: version, Version: version,
Buildarch: buildarch,
}, nil }, nil
} }
@@ -52,11 +49,3 @@ func SetVersion(ver string) {
version = ver version = ver
} }
// SetBuildarch sets the build architecture (used for testing and main init).
func SetBuildarch(arch string) {
mu.Lock()
defer mu.Unlock()
buildarch = arch
}
+13 -7
View File
@@ -13,12 +13,15 @@ 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". Docker
var validAppNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) // accepts every name it allows as the app's image name, upaas-<name>; 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 ( var (
errAppNameLength = errors.New( errAppNameLength = errors.New(
"app name must be between " + "app name must be between " +
@@ -26,11 +29,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},
+2 -1
View File
@@ -4,6 +4,7 @@ package logger
import ( import (
"log/slog" "log/slog"
"os" "os"
"runtime"
"go.uber.org/fx" "go.uber.org/fx"
@@ -81,6 +82,6 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch, "arch", runtime.GOARCH,
) )
} }
+34
View File
@@ -0,0 +1,34 @@
package logger //nolint:testpackage // sets the unexported log and params fields
import (
"bytes"
"encoding/json"
"log/slog"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/globals"
)
func TestIdentifyLogsArchitectureFromRuntime(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
l := &Logger{
log: slog.New(slog.NewJSONHandler(&buf, nil)),
params: Params{
Globals: &globals.Globals{Appname: "upaas-test", Version: "test"},
},
}
l.Identify()
var line map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &line))
assert.Equal(t, runtime.GOARCH, line["arch"])
}
@@ -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">