diff --git a/README.md b/README.md index 3cc777e..cc552e1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ repositories via webhooks from Gitea, GitHub, or GitLab. - Notifications via ntfy and Slack-compatible webhooks - Simple server-rendered UI with Tailwind CSS +A deploy or rollback replaces the app's container with a new one, which keeps +only the files the app wrote to its volume mounts. + ## Non-Goals - Multi-user support diff --git a/TODO.md b/TODO.md index 7684cdc..da31a69 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,11 @@ regress. # Completed Steps +- 2026-10-01: An app with no volume mounts says on its page, and in the log of + each deploy, that a deploy or rollback loses the files it writes and a restart + keeps them; the README says a deploy or rollback keeps only the files the app + wrote to its volume mounts (#248). + - 2026-09-29: The app page is 50% wider on large screens (84rem instead of 56rem), its build log and container log boxes are twice as tall, the build log sits between the webhook URL and the environment variables, and the container diff --git a/internal/handlers/app.go b/internal/handlers/app.go index e03faf8..7258327 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -21,6 +21,7 @@ import ( "sneak.berlin/go/upaas/internal/database" "sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/service/app" + "sneak.berlin/go/upaas/internal/service/deploy" "sneak.berlin/go/upaas/templates" ) @@ -194,6 +195,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc { "EnvVars": envVars, "Labels": labels, "Volumes": volumes, + "NoVolumesWarning": deploy.NoVolumesWarning, "Ports": ports, "Deployments": deployments, "LatestDeployment": latestDeployment, diff --git a/internal/handlers/app_no_volumes_test.go b/internal/handlers/app_no_volumes_test.go new file mode 100644 index 0000000..65b96bd --- /dev/null +++ b/internal/handlers/app_no_volumes_test.go @@ -0,0 +1,45 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/upaas/internal/models" + "sneak.berlin/go/upaas/internal/service/deploy" +) + +// TestAppPageSaysFilesAreLostOnlyWhenAppHasNoVolumes checks that the app page +// shows deploy.NoVolumesWarning until the app gets a volume mount. +func TestAppPageSaysFilesAreLostOnlyWhenAppHasNoVolumes(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + createdApp := createTestApp(t, testCtx, "no-volumes-app") + + renderAppPage := func() string { + request := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/apps/"+createdApp.ID, nil, + ) + request = addChiURLParams(request, map[string]string{"id": createdApp.ID}) + recorder := httptest.NewRecorder() + + testCtx.handlers.HandleAppDetail().ServeHTTP(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code) + + return recorder.Body.String() + } + + assert.Contains(t, renderAppPage(), deploy.NoVolumesWarning) + + volume := models.NewVolume(testCtx.database) + volume.AppID = createdApp.ID + volume.HostPath = "/srv/no-volumes-app" + volume.ContainerPath = "/data" + require.NoError(t, volume.Save(t.Context())) + + assert.NotContains(t, renderAppPage(), deploy.NoVolumesWarning) +} diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 4245be2..9af52d0 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -56,6 +56,12 @@ var ( ErrNoPreviousImage = errors.New("no previous image available for rollback") ) +// NoVolumesWarning is shown on the page of an app with no volume mounts and +// written into each of its deploy logs. +const NoVolumesWarning = "This app has no volume mounts, so the files it writes " + + "are lost whenever a deploy or rollback replaces its container; " + + "a restart of the container keeps them." + // logFlushInterval is how often to flush buffered logs to the database. const logFlushInterval = time.Second @@ -369,6 +375,11 @@ func (svc *Service) Deploy( svc.logWebhookPayload(bgCtx, deployment, webhookEvent) + volumes, err := app.GetVolumes(bgCtx) + if err == nil && len(volumes) == 0 { + _ = deployment.AppendLog(bgCtx, NoVolumesWarning) + } + err = svc.updateAppStatusBuilding(bgCtx, app) if err != nil { return err diff --git a/internal/service/deploy/deploy_volumes_test.go b/internal/service/deploy/deploy_volumes_test.go new file mode 100644 index 0000000..35aed12 --- /dev/null +++ b/internal/service/deploy/deploy_volumes_test.go @@ -0,0 +1,79 @@ +package deploy_test + +import ( + "context" + "log/slog" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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" + "sneak.berlin/go/upaas/internal/service/notify" +) + +// deployLog deploys a new app, with one volume mount when withVolume is set, +// and returns the deploy's log. Docker is not connected, so the deploy fails +// at the git clone, after the start of its log is written. +func deployLog(t *testing.T, withVolume bool) string { + t.Helper() + + log := logger.NewForTest(slog.New(slog.NewTextHandler(os.Stderr, nil))) + dataDir := t.TempDir() + cfg := &config.Config{DataDir: dataDir, HostDataDir: dataDir} + db := database.NewTestDatabase(t) + + dockerClient, err := docker.New(nil, docker.Params{Logger: log, Config: cfg}) + require.NoError(t, err) + + notifySvc, err := notify.New(nil, notify.ServiceParams{Logger: log}) + require.NoError(t, err) + + svc, err := deploy.New(nil, deploy.ServiceParams{ + Logger: log, Config: cfg, Database: db, + Docker: dockerClient, Notify: notifySvc, + }) + require.NoError(t, err) + + ctx := context.Background() + + app := models.NewApp(db) + app.ID = "volumesapp-id" + app.Name = "volumesapp" + app.Branch = "main" + require.NoError(t, app.Save(ctx)) + + if withVolume { + volume := models.NewVolume(db) + volume.AppID = app.ID + volume.HostPath = "/srv/volumesapp" + volume.ContainerPath = "/data" + require.NoError(t, volume.Save(ctx)) + } + + err = svc.Deploy(ctx, app, nil, false) + require.ErrorIs(t, err, docker.ErrNotConnected) + + deployments, err := app.GetDeployments(ctx, 1) + require.NoError(t, err) + require.Len(t, deployments, 1) + + return deployments[0].Logs.String +} + +func TestDeployLogSaysFilesAreLostOnlyWhenAppHasNoVolumes(t *testing.T) { + t.Parallel() + + logWithoutVolumes := deployLog(t, false) + assert.Equal(t, 1, strings.Count(logWithoutVolumes, deploy.NoVolumesWarning), + logWithoutVolumes) + + assert.NotContains(t, deployLog(t, true), deploy.NoVolumesWarning) +} diff --git a/templates/app_detail.html b/templates/app_detail.html index eb6f146..7166a69 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -318,6 +318,8 @@ + {{else}} +

{{.NoVolumesWarning}}

{{end}}
{{ .CSRFField }}