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.
This commit is contained in:
Jeffrey Paul 2025-12-29 16:06:40 +07:00
parent c13fd8c746
commit 5fb0b111fc
11 changed files with 142 additions and 91 deletions

1
go.mod
View File

@ -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

3
go.sum
View File

@ -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=

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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`,
)

View File

@ -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) {

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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)

View File

@ -122,7 +122,6 @@
<!-- Labels -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Docker Labels</h2>
{{if .Labels}}
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
@ -133,6 +132,14 @@
</tr>
</thead>
<tbody class="table-body">
<!-- System-managed upaas.id label -->
<tr class="bg-gray-50">
<td class="font-mono font-medium text-gray-600">upaas.id</td>
<td class="font-mono text-gray-500">{{.App.ID}}</td>
<td class="text-right">
<span class="text-xs text-gray-400">System</span>
</td>
</tr>
{{range .Labels}}
<tr>
<td class="font-mono font-medium">{{.Key}}</td>
@ -147,7 +154,6 @@
</tbody>
</table>
</div>
{{end}}
<form method="POST" action="/apps/{{.App.ID}}/labels" class="flex flex-col sm:flex-row gap-2">
<input type="text" name="key" placeholder="label.key" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">