From 5fb0b111fc5e22e3936a18c032d289cade7733bf Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 29 Dec 2025 16:06:40 +0700 Subject: [PATCH] Use ULID for app IDs and Docker label for container lookup - Replace UUID with ULID for app ID generation (lexicographically sortable) - Remove container_id column from apps table (migration 002) - Add upaas.id Docker label to identify containers by app ID - Implement FindContainerByAppID in Docker client to query by label - Update handlers and deploy service to use label-based container lookup - Show system-managed upaas.id label in UI with editing disabled Container association is now determined dynamically via Docker label rather than stored in the database, making the system more resilient to container recreation or external changes. --- go.mod | 1 + go.sum | 3 ++ .../migrations/002_remove_container_id.sql | 44 ++++++++++++++++++ internal/docker/client.go | 46 +++++++++++++++++++ internal/handlers/app.go | 16 ++++--- internal/models/app.go | 23 +++++----- internal/models/models_test.go | 4 +- internal/service/app/app.go | 24 ++-------- internal/service/app/app_test.go | 34 -------------- internal/service/deploy/deploy.go | 28 ++++++----- templates/app_detail.html | 10 +++- 11 files changed, 142 insertions(+), 91 deletions(-) create mode 100644 internal/database/migrations/002_remove_container_id.sql diff --git a/go.mod b/go.mod index 46da669..56eeacc 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/go.sum b/go.sum index 000ccad..0355283 100644 --- a/go.sum +++ b/go.sum @@ -86,10 +86,13 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/database/migrations/002_remove_container_id.sql b/internal/database/migrations/002_remove_container_id.sql new file mode 100644 index 0000000..7690f92 --- /dev/null +++ b/internal/database/migrations/002_remove_container_id.sql @@ -0,0 +1,44 @@ +-- Remove container_id from apps table +-- Container is now looked up via Docker label (upaas.id) instead of stored in database + +-- SQLite doesn't support DROP COLUMN before version 3.35.0 (2021-03-12) +-- Use table rebuild for broader compatibility + +-- Create new table without container_id +CREATE TABLE apps_new ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + repo_url TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + dockerfile_path TEXT DEFAULT 'Dockerfile', + webhook_secret TEXT NOT NULL, + ssh_private_key TEXT NOT NULL, + ssh_public_key TEXT NOT NULL, + image_id TEXT, + status TEXT DEFAULT 'pending', + docker_network TEXT, + ntfy_topic TEXT, + slack_webhook TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Copy data (excluding container_id) +INSERT INTO apps_new ( + id, name, repo_url, branch, dockerfile_path, webhook_secret, + ssh_private_key, ssh_public_key, image_id, status, + docker_network, ntfy_topic, slack_webhook, created_at, updated_at +) +SELECT + id, name, repo_url, branch, dockerfile_path, webhook_secret, + ssh_private_key, ssh_public_key, image_id, status, + docker_network, ntfy_topic, slack_webhook, created_at, updated_at +FROM apps; + +-- Drop old table and rename new one +DROP TABLE apps; +ALTER TABLE apps_new RENAME TO apps; + +-- Recreate indexes +CREATE INDEX idx_apps_status ON apps(status); +CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret); diff --git a/internal/docker/client.go b/internal/docker/client.go index 6390efb..cd7b20b 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -12,6 +12,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" @@ -309,6 +310,51 @@ func (c *Client) IsContainerHealthy( return inspect.State.Health.Status == "healthy", nil } +// LabelUpaasID is the Docker label key used to identify containers managed by upaas. +const LabelUpaasID = "upaas.id" + +// ContainerInfo contains basic information about a container. +type ContainerInfo struct { + ID string + Running bool +} + +// FindContainerByAppID finds a container by the upaas.id label. +// Returns nil if no container is found. +// +//nolint:nilnil // returning nil,nil is idiomatic for "not found" +func (c *Client) FindContainerByAppID( + ctx context.Context, + appID string, +) (*ContainerInfo, error) { + if c.docker == nil { + return nil, ErrNotConnected + } + + filterArgs := filters.NewArgs() + filterArgs.Add("label", LabelUpaasID+"="+appID) + + containers, err := c.docker.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + if len(containers) == 0 { + return nil, nil + } + + // Return the first matching container + ctr := containers[0] + + return &ContainerInfo{ + ID: ctr.ID, + Running: ctr.State == "running", + }, nil +} + // cloneConfig holds configuration for a git clone operation. type cloneConfig struct { repoURL string diff --git a/internal/handlers/app.go b/internal/handlers/app.go index a652d53..55fb4a5 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -348,7 +348,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc { writer.Header().Set("Content-Type", "text/plain; charset=utf-8") - if !application.ContainerID.Valid { + containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID) + if containerErr != nil || containerInfo == nil { _, _ = writer.Write([]byte("No container running\n")) return @@ -361,14 +362,14 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc { logs, logsErr := h.docker.ContainerLogs( request.Context(), - application.ContainerID.String, + containerInfo.ID, tail, ) if logsErr != nil { h.log.Error("failed to get container logs", "error", logsErr, "app", application.Name, - "container", application.ContainerID.String, + "container", containerInfo.ID, ) _, _ = writer.Write([]byte("Failed to fetch container logs\n")) @@ -396,22 +397,23 @@ func (h *Handlers) handleContainerAction( action containerAction, ) { appID := chi.URLParam(request, "id") + ctx := request.Context() - application, findErr := models.FindApp(request.Context(), h.db, appID) + application, findErr := models.FindApp(ctx, h.db, appID) if findErr != nil || application == nil { http.NotFound(writer, request) return } - if !application.ContainerID.Valid { + containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID) + if containerErr != nil || containerInfo == nil { http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) return } - containerID := application.ContainerID.String - ctx := request.Context() + containerID := containerInfo.ID var actionErr error diff --git a/internal/models/app.go b/internal/models/app.go index 1c8a35e..a607aba 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -34,7 +34,6 @@ type App struct { WebhookSecret string SSHPrivateKey string SSHPublicKey string - ContainerID sql.NullString ImageID sql.NullString Status AppStatus DockerNetwork sql.NullString @@ -73,7 +72,7 @@ func (a *App) Delete(ctx context.Context) error { func (a *App) Reload(ctx context.Context) error { row := a.db.QueryRow(ctx, ` SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, - ssh_private_key, ssh_public_key, container_id, image_id, status, + ssh_private_key, ssh_public_key, image_id, status, docker_network, ntfy_topic, slack_webhook, created_at, updated_at FROM apps WHERE id = ?`, a.ID, @@ -131,13 +130,13 @@ func (a *App) insert(ctx context.Context) error { query := ` INSERT INTO apps ( id, name, repo_url, branch, dockerfile_path, webhook_secret, - ssh_private_key, ssh_public_key, container_id, image_id, status, + ssh_private_key, ssh_public_key, image_id, status, docker_network, ntfy_topic, slack_webhook - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err := a.db.Exec(ctx, query, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, - a.SSHPrivateKey, a.SSHPublicKey, a.ContainerID, a.ImageID, a.Status, + a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, ) if err != nil { @@ -151,14 +150,14 @@ func (a *App) update(ctx context.Context) error { query := ` UPDATE apps SET name = ?, repo_url = ?, branch = ?, dockerfile_path = ?, - container_id = ?, image_id = ?, status = ?, + image_id = ?, status = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` _, err := a.db.Exec(ctx, query, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, - a.ContainerID, a.ImageID, a.Status, + a.ImageID, a.Status, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.ID, ) @@ -171,7 +170,7 @@ func (a *App) scan(row *sql.Row) error { &a.ID, &a.Name, &a.RepoURL, &a.Branch, &a.DockerfilePath, &a.WebhookSecret, &a.SSHPrivateKey, &a.SSHPublicKey, - &a.ContainerID, &a.ImageID, &a.Status, + &a.ImageID, &a.Status, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.CreatedAt, &a.UpdatedAt, ) @@ -187,7 +186,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) { &app.ID, &app.Name, &app.RepoURL, &app.Branch, &app.DockerfilePath, &app.WebhookSecret, &app.SSHPrivateKey, &app.SSHPublicKey, - &app.ContainerID, &app.ImageID, &app.Status, + &app.ImageID, &app.Status, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.CreatedAt, &app.UpdatedAt, ) @@ -219,7 +218,7 @@ func FindApp( row := appDB.QueryRow(ctx, ` SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, - ssh_private_key, ssh_public_key, container_id, image_id, status, + ssh_private_key, ssh_public_key, image_id, status, docker_network, ntfy_topic, slack_webhook, created_at, updated_at FROM apps WHERE id = ?`, appID, @@ -249,7 +248,7 @@ func FindAppByWebhookSecret( row := appDB.QueryRow(ctx, ` SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, - ssh_private_key, ssh_public_key, container_id, image_id, status, + ssh_private_key, ssh_public_key, image_id, status, docker_network, ntfy_topic, slack_webhook, created_at, updated_at FROM apps WHERE webhook_secret = ?`, secret, @@ -271,7 +270,7 @@ func FindAppByWebhookSecret( func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) { rows, err := appDB.Query(ctx, ` SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, - ssh_private_key, ssh_public_key, container_id, image_id, status, + ssh_private_key, ssh_public_key, image_id, status, docker_network, ntfy_topic, slack_webhook, created_at, updated_at FROM apps ORDER BY name`, ) diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 308df8f..bb3bee3 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -223,7 +223,7 @@ func TestAppUpdate(t *testing.T) { app.Name = "updated" app.Status = models.AppStatusRunning - app.ContainerID = sql.NullString{String: "container123", Valid: true} + app.ImageID = sql.NullString{String: "image123", Valid: true} err := app.Save(context.Background()) require.NoError(t, err) @@ -232,7 +232,7 @@ func TestAppUpdate(t *testing.T) { require.NoError(t, err) assert.Equal(t, "updated", found.Name) assert.Equal(t, models.AppStatusRunning, found.Status) - assert.Equal(t, "container123", found.ContainerID.String) + assert.Equal(t, "image123", found.ImageID.String) } func TestAppDelete(t *testing.T) { diff --git a/internal/service/app/app.go b/internal/service/app/app.go index 522f484..2790a74 100644 --- a/internal/service/app/app.go +++ b/internal/service/app/app.go @@ -3,11 +3,14 @@ package app import ( "context" + "crypto/rand" "database/sql" "fmt" "log/slog" + "time" "github.com/google/uuid" + "github.com/oklog/ulid/v2" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/database" @@ -62,9 +65,9 @@ func (svc *Service) CreateApp( return nil, fmt.Errorf("failed to generate SSH key pair: %w", err) } - // Create app + // Create app with ULID app := models.NewApp(svc.db) - app.ID = uuid.New().String() + app.ID = ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String() app.Name = input.Name app.RepoURL = input.RepoURL @@ -324,20 +327,3 @@ func (svc *Service) UpdateAppStatus( return nil } - -// UpdateAppContainer updates the container ID of an app. -func (svc *Service) UpdateAppContainer( - ctx context.Context, - app *models.App, - containerID, imageID string, -) error { - app.ContainerID = sql.NullString{String: containerID, Valid: containerID != ""} - app.ImageID = sql.NullString{String: imageID, Valid: imageID != ""} - - saveErr := app.Save(ctx) - if saveErr != nil { - return fmt.Errorf("failed to save app container: %w", saveErr) - } - - return nil -} diff --git a/internal/service/app/app_test.go b/internal/service/app/app_test.go index 53cdf72..0ab3178 100644 --- a/internal/service/app/app_test.go +++ b/internal/service/app/app_test.go @@ -600,37 +600,3 @@ func TestUpdateAppStatus(testingT *testing.T) { assert.Equal(t, models.AppStatusBuilding, reloaded.Status) }) } - -func TestUpdateAppContainer(testingT *testing.T) { - testingT.Parallel() - - testingT.Run("updates container and image IDs", func(t *testing.T) { - t.Parallel() - - svc, cleanup := setupTestService(t) - defer cleanup() - - createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{ - Name: "container-test", - RepoURL: "git@example.com:user/repo.git", - }) - require.NoError(t, err) - assert.False(t, createdApp.ContainerID.Valid) - assert.False(t, createdApp.ImageID.Valid) - - err = svc.UpdateAppContainer( - context.Background(), - createdApp, - "container123", - "image456", - ) - require.NoError(t, err) - - reloaded, err := svc.GetApp(context.Background(), createdApp.ID) - require.NoError(t, err) - assert.True(t, reloaded.ContainerID.Valid) - assert.Equal(t, "container123", reloaded.ContainerID.String) - assert.True(t, reloaded.ImageID.Valid) - assert.Equal(t, "image456", reloaded.ImageID.String) - }) -} diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index fd5ef18..79dd333 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -25,7 +25,7 @@ import ( const ( healthCheckDelaySeconds = 60 // upaasLabelCount is the number of upaas-specific labels added to containers. - upaasLabelCount = 2 + upaasLabelCount = 1 ) // Sentinel errors for deployment failures. @@ -104,12 +104,12 @@ func (svc *Service) Deploy( svc.removeOldContainer(ctx, app, deployment) - containerID, err := svc.createAndStartContainer(ctx, app, deployment, imageID) + _, err = svc.createAndStartContainer(ctx, app, deployment, imageID) if err != nil { return err } - err = svc.updateAppRunning(ctx, app, containerID, imageID) + err = svc.updateAppRunning(ctx, app, imageID) if err != nil { return err } @@ -243,13 +243,14 @@ func (svc *Service) removeOldContainer( app *models.App, deployment *models.Deployment, ) { - if !app.ContainerID.Valid || app.ContainerID.String == "" { + containerInfo, err := svc.docker.FindContainerByAppID(ctx, app.ID) + if err != nil || containerInfo == nil { return } - svc.log.Info("removing old container", "id", app.ContainerID.String) + svc.log.Info("removing old container", "id", containerInfo.ID) - removeErr := svc.docker.RemoveContainer(ctx, app.ContainerID.String, true) + removeErr := svc.docker.RemoveContainer(ctx, containerInfo.ID, true) if removeErr != nil { svc.log.Warn("failed to remove old container", "error", removeErr) } @@ -350,8 +351,8 @@ func buildLabelMap(app *models.App, labels []*models.Label) map[string]string { labelMap[label.Key] = label.Value } - labelMap["upaas.app.id"] = app.ID - labelMap["upaas.app.name"] = app.Name + // Add the upaas.id label to identify this container + labelMap[docker.LabelUpaasID] = app.ID return labelMap } @@ -372,9 +373,8 @@ func buildVolumeMounts(volumes []*models.Volume) []docker.VolumeMount { func (svc *Service) updateAppRunning( ctx context.Context, app *models.App, - containerID, imageID string, + imageID string, ) error { - app.ContainerID = sql.NullString{String: containerID, Valid: true} app.ImageID = sql.NullString{String: imageID, Valid: true} app.Status = models.AppStatusRunning @@ -405,14 +405,12 @@ func (svc *Service) checkHealthAfterDelay( return } - if !reloadedApp.ContainerID.Valid { + containerInfo, containerErr := svc.docker.FindContainerByAppID(ctx, app.ID) + if containerErr != nil || containerInfo == nil { return } - healthy, err := svc.docker.IsContainerHealthy( - ctx, - reloadedApp.ContainerID.String, - ) + healthy, err := svc.docker.IsContainerHealthy(ctx, containerInfo.ID) if err != nil { svc.log.Error("failed to check container health", "error", err) svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err) diff --git a/templates/app_detail.html b/templates/app_detail.html index 9cc6742..6dc053d 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -122,7 +122,6 @@

Docker Labels

- {{if .Labels}}
@@ -133,6 +132,14 @@ + + + + + + {{range .Labels}} @@ -147,7 +154,6 @@
upaas.id{{.App.ID}} + System +
{{.Key}}
- {{end}}