Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6d3f464d | ||
|
|
8c61b5ae77 | ||
|
|
56345bc6f6 |
@@ -23,6 +23,9 @@ regress.
|
|||||||
- 2026-09-23: After a successful deploy, upaas removes the app's images other
|
- 2026-09-23: After a successful deploy, upaas removes the app's images other
|
||||||
than the running one and the one rollback would use, together with the
|
than the running one and the one rollback would use, together with the
|
||||||
untagged images they were built on (#216).
|
untagged images they were built on (#216).
|
||||||
|
- 2026-09-23: The git clone container is now removed together with its anonymous
|
||||||
|
volume (the `alpine/git` image declares one at `/git`), so a deploy no longer
|
||||||
|
leaves a Docker volume behind (#215).
|
||||||
- 2026-09-23: Deployment log files are now stored under `logs/<appname>/`
|
- 2026-09-23: Deployment log files are now stored under `logs/<appname>/`
|
||||||
instead of `logs/<hostname>/<appname>/`, so downloads keep working after the
|
instead of `logs/<hostname>/<appname>/`, so downloads keep working after the
|
||||||
upaas container is recreated; logs written under an old hostname directory are
|
upaas container is recreated; logs written under an old hostname directory are
|
||||||
|
|||||||
@@ -537,12 +537,13 @@ func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListImageIDs returns the IDs of all images tagged in the given repository,
|
// ListImageTags returns the tags in the given repository, such as
|
||||||
// such as "upaas-myapp".
|
// "upaas-myapp:12" in "upaas-myapp", each with the ID of its image.
|
||||||
func (c *Client) ListImageIDs(
|
// Tags the same image has in other repositories are left out.
|
||||||
|
func (c *Client) ListImageTags(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
repository string,
|
repository string,
|
||||||
) ([]ImageID, error) {
|
) (map[string]ImageID, error) {
|
||||||
if c.docker == nil {
|
if c.docker == nil {
|
||||||
return nil, ErrNotConnected
|
return nil, ErrNotConnected
|
||||||
}
|
}
|
||||||
@@ -554,12 +555,35 @@ func (c *Client) ListImageIDs(
|
|||||||
return nil, fmt.Errorf("failed to list images: %w", err)
|
return nil, fmt.Errorf("failed to list images: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := make([]ImageID, 0, len(images))
|
tags := make(map[string]ImageID)
|
||||||
|
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
ids = append(ids, ImageID(img.ID))
|
for _, tag := range img.RepoTags {
|
||||||
|
if strings.HasPrefix(tag, repository+":") {
|
||||||
|
tags[tag] = ImageID(img.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ids, nil
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveImageTag removes a tag such as "upaas-myapp:12", without force.
|
||||||
|
// Docker then deletes the image, and the untagged images it was built on,
|
||||||
|
// only if no other tag and no container still uses it.
|
||||||
|
func (c *Client) RemoveImageTag(ctx context.Context, tag string) error {
|
||||||
|
if c.docker == nil {
|
||||||
|
return ErrNotConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.docker.ImageRemove(ctx, tag, image.RemoveOptions{
|
||||||
|
PruneChildren: true,
|
||||||
|
})
|
||||||
|
if err != nil && !client.IsErrNotFound(err) {
|
||||||
|
return fmt.Errorf("failed to remove image tag %s: %w", tag, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) performBuild(
|
func (c *Client) performBuild(
|
||||||
@@ -681,11 +705,14 @@ func (c *Client) performClone(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The git image declares a volume, so Docker gives each clone container
|
||||||
|
// an anonymous volume; remove it with the container. The removal must
|
||||||
|
// still run when the deploy is cancelled.
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = c.docker.ContainerRemove(
|
_ = c.docker.ContainerRemove(
|
||||||
ctx,
|
context.WithoutCancel(ctx),
|
||||||
gitContainerID.String(),
|
gitContainerID.String(),
|
||||||
container.RemoveOptions{Force: true},
|
container.RemoveOptions{Force: true, RemoveVolumes: true},
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mainBranch is the branch name used across validation tests.
|
// mainBranch is the branch name used across validation tests.
|
||||||
@@ -149,3 +158,84 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPerformCloneRemovesContainerVolumes runs a clone against a fake Docker
|
||||||
|
// API and checks that the clone container is removed together with its
|
||||||
|
// anonymous volumes, whether the clone succeeds, fails, or is cancelled.
|
||||||
|
func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exitCode int
|
||||||
|
cancel bool
|
||||||
|
}{
|
||||||
|
{name: "succeeds", exitCode: 0},
|
||||||
|
{name: "fails", exitCode: 1},
|
||||||
|
{name: "cancelled", cancel: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
removeQuery := make(chan url.Values, 1)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodDelete:
|
||||||
|
removeQuery <- r.URL.Query()
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
||||||
|
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/wait") && tt.cancel:
|
||||||
|
// Cancel the deploy while the clone is running.
|
||||||
|
cancel()
|
||||||
|
<-r.Context().Done()
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/wait"):
|
||||||
|
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, tt.exitCode)
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
dockerAPI, err := client.NewClientWithOpts(
|
||||||
|
client.WithHost("tcp://" + srv.Listener.Addr().String()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Client{docker: dockerAPI, log: slog.Default()}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &cloneConfig{
|
||||||
|
repoURL: "git@example.com:repo.git",
|
||||||
|
branch: mainBranch,
|
||||||
|
sshPrivateKey: "fake-key",
|
||||||
|
containerDir: filepath.Join(dir, "repo"),
|
||||||
|
hostDir: filepath.Join(dir, "repo"),
|
||||||
|
keyFile: filepath.Join(dir, "deploy_key"),
|
||||||
|
hostKeyFile: filepath.Join(dir, "deploy_key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = c.performClone(ctx, cfg)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case query := <-removeQuery:
|
||||||
|
if query.Get("v") != "1" {
|
||||||
|
t.Errorf("clone container removed without its volumes: %v", query)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Error("clone container was not removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -524,18 +526,11 @@ func (svc *Service) runBuildAndDeploy(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current image as previous before updating to new one
|
err = svc.recordDeployedImage(bgCtx, app, deployment, imageID)
|
||||||
if app.ImageID.Valid && app.ImageID.String != "" {
|
|
||||||
app.PreviousImageID = app.ImageID
|
|
||||||
}
|
|
||||||
|
|
||||||
err = svc.updateAppRunning(bgCtx, app, imageID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.removeUnusedImages(bgCtx, app, deployment)
|
|
||||||
|
|
||||||
// Use context.WithoutCancel to ensure health check completes even if
|
// Use context.WithoutCancel to ensure health check completes even if
|
||||||
// the parent context is cancelled (e.g., HTTP request ends).
|
// the parent context is cancelled (e.g., HTTP request ends).
|
||||||
go svc.checkHealthAfterDelay(bgCtx, app, deployment)
|
go svc.checkHealthAfterDelay(bgCtx, app, deployment)
|
||||||
@@ -716,57 +711,68 @@ func (svc *Service) checkCancelled(
|
|||||||
return ErrDeployCancelled
|
return ErrDeployCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeUnusedImages removes the app's images (tagged upaas-<app>:<deployment>
|
// recordDeployedImage runs once the new container has started: it makes
|
||||||
// by buildImage) except the one the running container uses and the one
|
// imageID the app's current image, keeps the replaced one as the previous
|
||||||
// Rollback would start. Removing an image also removes the untagged images
|
// image for Rollback, and then removes the app's other images.
|
||||||
// it was built on, unless another image still needs them.
|
func (svc *Service) recordDeployedImage(
|
||||||
|
ctx context.Context,
|
||||||
|
app *models.App,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
imageID docker.ImageID,
|
||||||
|
) error {
|
||||||
|
// Save current image as previous before updating to new one
|
||||||
|
if app.ImageID.Valid && app.ImageID.String != "" {
|
||||||
|
app.PreviousImageID = app.ImageID
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.updateAppRunning(ctx, app, imageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.removeUnusedImages(ctx, app, deployment)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUnusedImages removes the app's tags (upaas-<app>:<deployment>, set by
|
||||||
|
// buildImage) except those of the image the running container uses and the
|
||||||
|
// one Rollback would start. Docker deletes an image only once no other tag,
|
||||||
|
// such as another app's, and no container still uses it.
|
||||||
func (svc *Service) removeUnusedImages(
|
func (svc *Service) removeUnusedImages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
app *models.App,
|
app *models.App,
|
||||||
deployment *models.Deployment,
|
deployment *models.Deployment,
|
||||||
) {
|
) {
|
||||||
images, err := svc.docker.ListImageIDs(ctx, "upaas-"+app.Name)
|
tags, err := svc.docker.ListImageTags(ctx, "upaas-"+app.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
|
svc.log.Error("failed to list app images", "error", err, "app", app.Name)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, imageID := range unusedImages(images, app) {
|
for _, tag := range slices.Sorted(maps.Keys(tags)) {
|
||||||
removeErr := svc.docker.RemoveImage(ctx, imageID)
|
imageID := tags[tag].String()
|
||||||
|
if imageID == app.ImageID.String || imageID == app.PreviousImageID.String {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
removeErr := svc.docker.RemoveImageTag(ctx, tag)
|
||||||
if removeErr != nil {
|
if removeErr != nil {
|
||||||
svc.log.Error("failed to remove old image",
|
svc.log.Error("failed to remove old image",
|
||||||
"error", removeErr, "app", app.Name, "image", imageID)
|
"error", removeErr, "app", app.Name, "tag", tag)
|
||||||
_ = deployment.AppendLog(
|
_ = deployment.AppendLog(
|
||||||
ctx,
|
ctx,
|
||||||
"WARNING: failed to remove old image "+
|
"WARNING: failed to remove old image "+tag+": "+removeErr.Error(),
|
||||||
imageID.String()+": "+removeErr.Error(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = deployment.AppendLog(ctx, "Removed old image: "+imageID.String())
|
_ = deployment.AppendLog(ctx, "Removed old image: "+tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unusedImages returns the images that are neither the app's current image
|
|
||||||
// nor its previous image, which Rollback uses.
|
|
||||||
func unusedImages(images []docker.ImageID, app *models.App) []docker.ImageID {
|
|
||||||
var unused []docker.ImageID
|
|
||||||
|
|
||||||
for _, imageID := range images {
|
|
||||||
if imageID.String() == app.ImageID.String ||
|
|
||||||
imageID.String() == app.PreviousImageID.String {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unused = append(unused, imageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return unused
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
|
// cleanupCancelledDeploy removes orphan resources left by a cancelled deployment.
|
||||||
func (svc *Service) cleanupCancelledDeploy(
|
func (svc *Service) cleanupCancelledDeploy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -1,48 +1,106 @@
|
|||||||
package deploy_test
|
package deploy_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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/docker"
|
||||||
|
"sneak.berlin/go/upaas/internal/logger"
|
||||||
"sneak.berlin/go/upaas/internal/models"
|
"sneak.berlin/go/upaas/internal/models"
|
||||||
"sneak.berlin/go/upaas/internal/service/deploy"
|
"sneak.berlin/go/upaas/internal/service/deploy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const currentImage = "sha256:current"
|
// TestRecordDeployedImageRemovesOldImages runs the step after a deploy
|
||||||
|
// against a fake Docker API. Image one is also tagged for another app,
|
||||||
func TestUnusedImages_KeepsCurrentAndRollbackImage(t *testing.T) {
|
// image two was the previous image, three the current one, four is new.
|
||||||
|
func TestRecordDeployedImageRemovesOldImages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := &models.App{
|
var (
|
||||||
ImageID: sql.NullString{String: currentImage, Valid: true},
|
mu sync.Mutex
|
||||||
PreviousImageID: sql.NullString{String: "sha256:previous", Valid: true},
|
removed []string
|
||||||
}
|
forced bool
|
||||||
images := []docker.ImageID{
|
|
||||||
"sha256:oldest",
|
|
||||||
"sha256:previous",
|
|
||||||
"sha256:older",
|
|
||||||
currentImage,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]docker.ImageID{"sha256:oldest", "sha256:older"},
|
|
||||||
deploy.UnusedImages(images, app),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnusedImages_FirstDeployHasNoRollbackImage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := &models.App{
|
|
||||||
ImageID: sql.NullString{String: currentImage, Valid: true},
|
|
||||||
}
|
|
||||||
images := []docker.ImageID{currentImage, "sha256:failed"}
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]docker.ImageID{"sha256:failed"},
|
|
||||||
deploy.UnusedImages(images, app),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodDelete:
|
||||||
|
_, name, _ := strings.Cut(r.URL.Path, "/images/")
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
|
||||||
|
removed = append(removed, name)
|
||||||
|
forced = forced || r.URL.Query().Get("force") != ""
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/images/json"):
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"Id":"sha256:one","RepoTags":["upaas-myapp:1","upaas-otherapp:7"]},
|
||||||
|
{"Id":"sha256:two","RepoTags":["upaas-myapp:2"]},
|
||||||
|
{"Id":"sha256:three","RepoTags":["upaas-myapp:3"]},
|
||||||
|
{"Id":"sha256:four","RepoTags":["upaas-myapp:4"]}
|
||||||
|
]`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
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)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
app := models.NewApp(db)
|
||||||
|
app.ID = "myapp-id"
|
||||||
|
app.Name = "myapp"
|
||||||
|
app.ImageID = sql.NullString{String: "sha256:three", Valid: true}
|
||||||
|
app.PreviousImageID = sql.NullString{String: "sha256:two", Valid: true}
|
||||||
|
require.NoError(t, app.Save(ctx))
|
||||||
|
|
||||||
|
deployment := models.NewDeployment(db)
|
||||||
|
deployment.AppID = app.ID
|
||||||
|
require.NoError(t, deployment.Save(ctx))
|
||||||
|
|
||||||
|
svc := deploy.NewTestServiceWithConfig(log, &config.Config{}, dockerClient)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"upaas-myapp:1", "upaas-myapp:2"}, removed)
|
||||||
|
assert.False(t, forced, "old image tags must be removed without force")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,14 @@ func (svc *Service) GetBuildDirExported(appName string) string {
|
|||||||
return svc.GetBuildDir(appName)
|
return svc.GetBuildDir(appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnusedImages exposes unusedImages for testing.
|
// RecordDeployedImage exposes recordDeployedImage for testing.
|
||||||
func UnusedImages(images []docker.ImageID, app *models.App) []docker.ImageID {
|
func (svc *Service) RecordDeployedImage(
|
||||||
return unusedImages(images, app)
|
ctx context.Context,
|
||||||
|
app *models.App,
|
||||||
|
deployment *models.Deployment,
|
||||||
|
imageID docker.ImageID,
|
||||||
|
) error {
|
||||||
|
return svc.recordDeployedImage(ctx, app, deployment, imageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
|
||||||
|
|||||||
Reference in New Issue
Block a user