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 @@
| upaas.id | +{{.App.ID}} | ++ System + | +
| {{.Key}} | @@ -147,7 +154,6 @@