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/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 } 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"): _, _ = 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(`{}`)) } } // fakeImage is an image as the fake Docker API lists and inspects it. type fakeImage struct { ID string `json:"Id"` RepoTags []string } // listImages lists the untagged images, or else the tagged ones. func (api *fakeImageAPI) listImages(w http.ResponseWriter, dangling bool) { list := []fakeImage{} for _, id := range slices.Sorted(maps.Keys(api.images)) { if (len(api.images[id]) == 0) == dangling { list = append(list, fakeImage{ID: id, RepoTags: api.images[id]}) } } _ = json.NewEncoder(w).Encode(list) } func (api *fakeImageAPI) inspectImage(w http.ResponseWriter, name string) { for id, tags := range api.images { if id == name || slices.Contains(tags, name) { _ = json.NewEncoder(w).Encode(fakeImage{ID: id, RepoTags: tags}) 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") } // 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") }