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
387 lines
12 KiB
Go
387 lines
12 KiB
Go
package deploy_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/docker/docker/api/types/image"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx/fxtest"
|
|
|
|
"sneak.berlin/go/upaas/internal/config"
|
|
"sneak.berlin/go/upaas/internal/database"
|
|
"sneak.berlin/go/upaas/internal/docker"
|
|
"sneak.berlin/go/upaas/internal/logger"
|
|
"sneak.berlin/go/upaas/internal/models"
|
|
"sneak.berlin/go/upaas/internal/service/deploy"
|
|
)
|
|
|
|
// fakeImageAPI is a fake Docker API that keeps images and their tags as
|
|
// Docker does: a build gives its tag to the image it builds, and removing
|
|
// 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
|
|
containers []string // name of each named container created
|
|
}
|
|
|
|
func (api *fakeImageAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
api.mu.Lock()
|
|
defer api.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
_, name, isImage := strings.Cut(r.URL.Path, "/images/")
|
|
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/images/json"):
|
|
dangling := strings.Contains(r.URL.Query().Get("filters"), "dangling")
|
|
api.listImages(w, dangling)
|
|
case isImage && r.Method == http.MethodDelete:
|
|
api.forced = api.forced || r.URL.Query().Get("force") != ""
|
|
api.removeImage(w, name)
|
|
case isImage && strings.HasSuffix(name, "/json"):
|
|
api.inspectImage(w, strings.TrimSuffix(name, "/json"))
|
|
case strings.HasSuffix(r.URL.Path, "/build"):
|
|
tag := r.URL.Query().Get("t")
|
|
api.untag(tag)
|
|
api.images[api.nextID] = append(api.images[api.nextID], tag)
|
|
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)
|
|
default:
|
|
// The other steps of the git clone, which succeeds.
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
}
|
|
|
|
// listImages lists the untagged images, or else the tagged ones.
|
|
func (api *fakeImageAPI) listImages(w http.ResponseWriter, dangling bool) {
|
|
list := []image.Summary{}
|
|
|
|
for _, id := range slices.Sorted(maps.Keys(api.images)) {
|
|
if (len(api.images[id]) == 0) == dangling {
|
|
list = append(list, image.Summary{ID: id, RepoTags: api.images[id]})
|
|
}
|
|
}
|
|
|
|
data, err := json.Marshal(list)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
func (api *fakeImageAPI) inspectImage(w http.ResponseWriter, name string) {
|
|
for id, tags := range api.images {
|
|
if id == name || slices.Contains(tags, name) {
|
|
_, _ = fmt.Fprintf(w, `{"Id":%q}`, id)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message":"No such image"}`))
|
|
}
|
|
|
|
func (api *fakeImageAPI) removeImage(w http.ResponseWriter, name string) {
|
|
api.removed = append(api.removed, name)
|
|
|
|
id := name
|
|
if _, isID := api.images[name]; !isID {
|
|
id = api.untag(name)
|
|
}
|
|
|
|
if len(api.images[id]) == 0 {
|
|
delete(api.images, id)
|
|
}
|
|
|
|
_, _ = w.Write([]byte(`[]`))
|
|
}
|
|
|
|
// untag removes tag from the image that has it, leaving the image, and
|
|
// returns the image's ID.
|
|
func (api *fakeImageAPI) untag(tag string) string {
|
|
for id, tags := range api.images {
|
|
if slices.Contains(tags, tag) {
|
|
api.images[id] = slices.DeleteFunc(tags, func(t string) bool { return t == tag })
|
|
|
|
return id
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// state returns each image's tags and each tag or ID removed.
|
|
func (api *fakeImageAPI) state() (map[string][]string, []string) {
|
|
api.mu.Lock()
|
|
defer api.mu.Unlock()
|
|
|
|
return maps.Clone(api.images), slices.Clone(api.removed)
|
|
}
|
|
|
|
// writeCloneOutput writes the line of a git clone's output that gives the
|
|
// commit's short hash, as Docker sends a container's log: after a header.
|
|
func writeCloneOutput(w io.Writer, shortSHA string) {
|
|
out := stdcopy.NewStdWriter(w, stdcopy.Stdout)
|
|
|
|
_, _ = fmt.Fprintf(out, "SHORT_SHA:%s\n", shortSHA)
|
|
}
|
|
|
|
// newImageTestService returns a deploy service that uses api as its
|
|
// Docker API, and its database.
|
|
func newImageTestService(
|
|
t *testing.T,
|
|
api *fakeImageAPI,
|
|
) (*deploy.Service, *database.Database) {
|
|
t.Helper()
|
|
|
|
srv := httptest.NewServer(api)
|
|
t.Cleanup(srv.Close)
|
|
|
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
lifecycle := fxtest.NewLifecycle(t)
|
|
|
|
dockerClient, err := docker.New(lifecycle, docker.Params{
|
|
Logger: logger.NewForTest(log),
|
|
Config: &config.Config{DockerHost: "tcp://" + srv.Listener.Addr().String()},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
lifecycle.RequireStart()
|
|
t.Cleanup(lifecycle.RequireStop)
|
|
|
|
db := database.NewTestDatabase(t)
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{DataDir: dataDir, HostDataDir: dataDir}
|
|
|
|
return deploy.NewTestServiceWithConfig(log, cfg, db, dockerClient), db
|
|
}
|
|
|
|
// saveApp saves an app with the given current and previous image.
|
|
func saveApp(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
name, imageID, previousImageID string,
|
|
) *models.App {
|
|
t.Helper()
|
|
|
|
app := models.NewApp(db)
|
|
app.ID = name + "-id"
|
|
app.Name = name
|
|
app.ImageID = sql.NullString{String: imageID, Valid: true}
|
|
app.PreviousImageID = sql.NullString{String: previousImageID, Valid: true}
|
|
require.NoError(t, app.Save(context.Background()))
|
|
|
|
return app
|
|
}
|
|
|
|
// TestRecordDeployedImageRemovesOldImages runs the step after a deploy
|
|
// against a fake Docker API. Image one has a tag from before images were
|
|
// tagged with their commit, and is also tagged for another app. Image two
|
|
// was the previous image, three the current one, four is new. Image five is
|
|
// the other app's previous image, which a redeploy of its commit left
|
|
// without the other app's tag.
|
|
func TestRecordDeployedImageRemovesOldImages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
api := &fakeImageAPI{images: map[string][]string{
|
|
"sha256:one": {"upaas-myapp:140", "upaas-otherapp:1a2b3c4"},
|
|
"sha256:two": {"upaas-myapp:2b3c4d5"},
|
|
"sha256:three": {"upaas-myapp:3c4d5e6"},
|
|
"sha256:four": {"upaas-myapp:4d5e6f7"},
|
|
"sha256:five": {"upaas-myapp:5e6f7a8"},
|
|
}}
|
|
svc, db := newImageTestService(t, api)
|
|
ctx := context.Background()
|
|
|
|
app := saveApp(t, db, "myapp", "sha256:three", "sha256:two")
|
|
saveApp(t, db, "otherapp", "sha256:other", "sha256:five")
|
|
|
|
deployment := models.NewDeployment(db)
|
|
deployment.AppID = app.ID
|
|
require.NoError(t, deployment.Save(ctx))
|
|
|
|
err := svc.RecordDeployedImage(ctx, app, deployment, "sha256:four")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "sha256:four", app.ImageID.String)
|
|
assert.Equal(t, "sha256:three", app.PreviousImageID.String)
|
|
|
|
images, removed := api.state()
|
|
|
|
assert.Equal(t, []string{"upaas-myapp:140", "upaas-myapp:2b3c4d5"}, removed)
|
|
assert.Equal(t, map[string][]string{
|
|
"sha256:one": {"upaas-otherapp:1a2b3c4"},
|
|
"sha256:three": {"upaas-myapp:3c4d5e6"},
|
|
"sha256:four": {"upaas-myapp:4d5e6f7"},
|
|
"sha256:five": {"upaas-myapp:5e6f7a8"},
|
|
}, images)
|
|
assert.False(t, api.forced, "old images must be removed without force")
|
|
}
|
|
|
|
// TestRecordDeployedImageRemovesOnlyTheAppsUntaggedImages runs the step after
|
|
// a deploy against a fake Docker API that holds three untagged images: one an
|
|
// earlier deployment of the app recorded, one a deployment of another app
|
|
// recorded, and one no deployment recorded, such as an image upaas never
|
|
// built. Only the app's own is removed.
|
|
func TestRecordDeployedImageRemovesOnlyTheAppsUntaggedImages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
api := &fakeImageAPI{images: map[string][]string{
|
|
"sha256:new": {"upaas-myapp:1a2b3c4"},
|
|
"sha256:myapp": {},
|
|
"sha256:otherapp": {},
|
|
"sha256:unknown": {},
|
|
}}
|
|
svc, db := newImageTestService(t, api)
|
|
ctx := context.Background()
|
|
|
|
app := saveApp(t, db, "myapp", "", "")
|
|
otherApp := saveApp(t, db, "otherapp", "", "")
|
|
|
|
saveDeployment := func(appID, imageID string) *models.Deployment {
|
|
t.Helper()
|
|
|
|
deployment := models.NewDeployment(db)
|
|
deployment.AppID = appID
|
|
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
|
|
require.NoError(t, deployment.Save(ctx))
|
|
|
|
return deployment
|
|
}
|
|
|
|
saveDeployment(app.ID, "sha256:myapp")
|
|
saveDeployment(otherApp.ID, "sha256:otherapp")
|
|
deployment := saveDeployment(app.ID, "sha256:new")
|
|
|
|
err := svc.RecordDeployedImage(ctx, app, deployment, "sha256:new")
|
|
require.NoError(t, err)
|
|
|
|
images, removed := api.state()
|
|
|
|
assert.Equal(t, []string{"sha256:myapp"}, removed)
|
|
assert.Equal(t, map[string][]string{
|
|
"sha256:new": {"upaas-myapp:1a2b3c4"},
|
|
"sha256:otherapp": {},
|
|
"sha256:unknown": {},
|
|
}, images)
|
|
}
|
|
|
|
// TestRedeployRemovesImagesLeftWithoutTag deploys commits against a fake
|
|
// Docker API, some of them again. A build takes the commit's tag from the
|
|
// image an earlier build of it made. That image is kept, without a tag,
|
|
// while the app runs it or Rollback would start it, and is removed by its
|
|
// ID once neither does.
|
|
func TestRedeployRemovesImagesLeftWithoutTag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
tagABC1234 = "upaas-myapp:abc1234"
|
|
tag0123ABC = "upaas-myapp:0123abc"
|
|
retriedImage = "sha256:retried-0123abc"
|
|
)
|
|
|
|
// The app runs commit abc1234 and would roll back to def5678. The last
|
|
// deploy, of commit 0123abc, failed after its build.
|
|
api := &fakeImageAPI{images: map[string][]string{
|
|
"sha256:built-abc1234": {tagABC1234},
|
|
"sha256:built-def5678": {"upaas-myapp:def5678"},
|
|
"sha256:failed-0123abc": {tag0123ABC},
|
|
}}
|
|
svc, db := newImageTestService(t, api)
|
|
ctx := context.Background()
|
|
|
|
app := saveApp(t, db, "myapp", "sha256:built-abc1234", "sha256:built-def5678")
|
|
|
|
for imageID := range api.images {
|
|
deployment := models.NewDeployment(db)
|
|
deployment.AppID = app.ID
|
|
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
|
|
require.NoError(t, deployment.Save(ctx))
|
|
}
|
|
|
|
deployCommit := func(shortSHA, imageID string) {
|
|
t.Helper()
|
|
|
|
api.mu.Lock()
|
|
api.shortSHA = shortSHA
|
|
api.nextID = imageID
|
|
api.mu.Unlock()
|
|
|
|
deployment := models.NewDeployment(db)
|
|
deployment.AppID = app.ID
|
|
require.NoError(t, deployment.Save(ctx))
|
|
|
|
built, err := svc.BuildImage(ctx, app, deployment)
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.RecordDeployedImage(ctx, app, deployment, built))
|
|
}
|
|
|
|
// The failed deploy's image loses its tag, and nothing uses it.
|
|
deployCommit("0123abc", retriedImage)
|
|
|
|
images, _ := api.state()
|
|
assert.Equal(t, map[string][]string{
|
|
"sha256:built-abc1234": {tagABC1234},
|
|
retriedImage: {tag0123ABC},
|
|
}, images)
|
|
|
|
// The running image loses its tag and becomes the one Rollback starts.
|
|
deployCommit("0123abc", "sha256:rebuilt-0123abc")
|
|
|
|
images, _ = api.state()
|
|
assert.Equal(t, map[string][]string{
|
|
"sha256:rebuilt-0123abc": {tag0123ABC},
|
|
retriedImage: {},
|
|
}, images)
|
|
assert.Equal(t, retriedImage, app.PreviousImageID.String)
|
|
|
|
// Once Rollback no longer needs it, the untagged image is removed.
|
|
deployCommit("4567def", "sha256:built-4567def")
|
|
|
|
images, removed := api.state()
|
|
assert.Equal(t, map[string][]string{
|
|
"sha256:built-4567def": {"upaas-myapp:4567def"},
|
|
"sha256:rebuilt-0123abc": {tag0123ABC},
|
|
}, images)
|
|
assert.Equal(t, []string{
|
|
"sha256:failed-0123abc", "upaas-myapp:def5678", // first deploy
|
|
tagABC1234, // second deploy
|
|
retriedImage, // third deploy
|
|
}, removed)
|
|
assert.False(t, api.forced, "old images must be removed without force")
|
|
}
|