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
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")
|
|
}
|