- 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.
290 lines
6.9 KiB
Go
290 lines
6.9 KiB
Go
package models
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/database"
|
|
)
|
|
|
|
// AppStatus represents the status of an app.
|
|
type AppStatus string
|
|
|
|
// App status constants.
|
|
const (
|
|
AppStatusPending AppStatus = "pending"
|
|
AppStatusBuilding AppStatus = "building"
|
|
AppStatusRunning AppStatus = "running"
|
|
AppStatusStopped AppStatus = "stopped"
|
|
AppStatusError AppStatus = "error"
|
|
)
|
|
|
|
// App represents an application managed by upaas.
|
|
type App struct {
|
|
db *database.Database
|
|
|
|
ID string
|
|
Name string
|
|
RepoURL string
|
|
Branch string
|
|
DockerfilePath string
|
|
WebhookSecret string
|
|
SSHPrivateKey string
|
|
SSHPublicKey string
|
|
ImageID sql.NullString
|
|
Status AppStatus
|
|
DockerNetwork sql.NullString
|
|
NtfyTopic sql.NullString
|
|
SlackWebhook sql.NullString
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// NewApp creates a new App with a database reference.
|
|
func NewApp(db *database.Database) *App {
|
|
return &App{
|
|
db: db,
|
|
Status: AppStatusPending,
|
|
Branch: "main",
|
|
}
|
|
}
|
|
|
|
// Save inserts or updates the app in the database.
|
|
func (a *App) Save(ctx context.Context) error {
|
|
if a.exists(ctx) {
|
|
return a.update(ctx)
|
|
}
|
|
|
|
return a.insert(ctx)
|
|
}
|
|
|
|
// Delete removes the app from the database.
|
|
func (a *App) Delete(ctx context.Context) error {
|
|
_, err := a.db.Exec(ctx, "DELETE FROM apps WHERE id = ?", a.ID)
|
|
|
|
return err
|
|
}
|
|
|
|
// Reload refreshes the app from the database.
|
|
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, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
FROM apps WHERE id = ?`,
|
|
a.ID,
|
|
)
|
|
|
|
return a.scan(row)
|
|
}
|
|
|
|
// GetEnvVars returns all environment variables for this app.
|
|
func (a *App) GetEnvVars(ctx context.Context) ([]*EnvVar, error) {
|
|
return FindEnvVarsByAppID(ctx, a.db, a.ID)
|
|
}
|
|
|
|
// GetLabels returns all labels for this app.
|
|
func (a *App) GetLabels(ctx context.Context) ([]*Label, error) {
|
|
return FindLabelsByAppID(ctx, a.db, a.ID)
|
|
}
|
|
|
|
// GetVolumes returns all volume mounts for this app.
|
|
func (a *App) GetVolumes(ctx context.Context) ([]*Volume, error) {
|
|
return FindVolumesByAppID(ctx, a.db, a.ID)
|
|
}
|
|
|
|
// GetDeployments returns recent deployments for this app.
|
|
func (a *App) GetDeployments(ctx context.Context, limit int) ([]*Deployment, error) {
|
|
return FindDeploymentsByAppID(ctx, a.db, a.ID, limit)
|
|
}
|
|
|
|
// GetWebhookEvents returns recent webhook events for this app.
|
|
func (a *App) GetWebhookEvents(
|
|
ctx context.Context,
|
|
limit int,
|
|
) ([]*WebhookEvent, error) {
|
|
return FindWebhookEventsByAppID(ctx, a.db, a.ID, limit)
|
|
}
|
|
|
|
func (a *App) exists(ctx context.Context) bool {
|
|
if a.ID == "" {
|
|
return false
|
|
}
|
|
|
|
var count int
|
|
|
|
row := a.db.QueryRow(ctx, "SELECT COUNT(*) FROM apps WHERE id = ?", a.ID)
|
|
|
|
err := row.Scan(&count)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return count > 0
|
|
}
|
|
|
|
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, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
|
|
_, err := a.db.Exec(ctx, query,
|
|
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
|
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
|
|
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return a.Reload(ctx)
|
|
}
|
|
|
|
func (a *App) update(ctx context.Context) error {
|
|
query := `
|
|
UPDATE apps SET
|
|
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
|
|
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.ImageID, a.Status,
|
|
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
|
a.ID,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
func (a *App) scan(row *sql.Row) error {
|
|
return row.Scan(
|
|
&a.ID, &a.Name, &a.RepoURL, &a.Branch,
|
|
&a.DockerfilePath, &a.WebhookSecret,
|
|
&a.SSHPrivateKey, &a.SSHPublicKey,
|
|
&a.ImageID, &a.Status,
|
|
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
|
&a.CreatedAt, &a.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
|
var apps []*App
|
|
|
|
for rows.Next() {
|
|
app := NewApp(appDB)
|
|
|
|
scanErr := rows.Scan(
|
|
&app.ID, &app.Name, &app.RepoURL, &app.Branch,
|
|
&app.DockerfilePath, &app.WebhookSecret,
|
|
&app.SSHPrivateKey, &app.SSHPublicKey,
|
|
&app.ImageID, &app.Status,
|
|
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
|
&app.CreatedAt, &app.UpdatedAt,
|
|
)
|
|
if scanErr != nil {
|
|
return nil, fmt.Errorf("scanning app row: %w", scanErr)
|
|
}
|
|
|
|
apps = append(apps, app)
|
|
}
|
|
|
|
rowsErr := rows.Err()
|
|
if rowsErr != nil {
|
|
return nil, fmt.Errorf("iterating app rows: %w", rowsErr)
|
|
}
|
|
|
|
return apps, nil
|
|
}
|
|
|
|
// FindApp finds an app by ID.
|
|
//
|
|
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
|
|
func FindApp(
|
|
ctx context.Context,
|
|
appDB *database.Database,
|
|
appID string,
|
|
) (*App, error) {
|
|
app := NewApp(appDB)
|
|
app.ID = appID
|
|
|
|
row := appDB.QueryRow(ctx, `
|
|
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 WHERE id = ?`,
|
|
appID,
|
|
)
|
|
|
|
err := app.scan(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("scanning app: %w", err)
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// FindAppByWebhookSecret finds an app by webhook secret.
|
|
//
|
|
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
|
|
func FindAppByWebhookSecret(
|
|
ctx context.Context,
|
|
appDB *database.Database,
|
|
secret string,
|
|
) (*App, error) {
|
|
app := NewApp(appDB)
|
|
|
|
row := appDB.QueryRow(ctx, `
|
|
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 WHERE webhook_secret = ?`,
|
|
secret,
|
|
)
|
|
|
|
err := app.scan(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("scanning app by webhook secret: %w", err)
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// AllApps returns all apps ordered by name.
|
|
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, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
FROM apps ORDER BY name`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying all apps: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
result, scanErr := scanApps(appDB, rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
|
|
return result, nil
|
|
}
|