Initial commit with server startup infrastructure

Core infrastructure:
- Uber fx dependency injection
- Chi router with middleware stack
- SQLite database with embedded migrations
- Embedded templates and static assets
- Structured logging with slog

Features implemented:
- Authentication (login, logout, session management, argon2id hashing)
- App management (create, edit, delete, list)
- Deployment pipeline (clone, build, deploy, health check)
- Webhook processing for Gitea
- Notifications (ntfy, Slack)
- Environment variables, labels, volumes per app
- SSH key generation for deploy keys

Server startup:
- Server.Run() starts HTTP server on configured port
- Server.Shutdown() for graceful shutdown
- SetupRoutes() wires all handlers with chi router
This commit is contained in:
2025-12-29 15:46:03 +07:00
commit 3f9d83c436
59 changed files with 11707 additions and 0 deletions

290
internal/models/app.go Normal file
View File

@@ -0,0 +1,290 @@
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
ContainerID sql.NullString
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, container_id, 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, container_id, 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.ContainerID, 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 = ?,
container_id = ?, 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.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.ContainerID, &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.ContainerID, &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, container_id, 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, container_id, 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, container_id, 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
}

View File

@@ -0,0 +1,241 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// DeploymentStatus represents the status of a deployment.
type DeploymentStatus string
// Deployment status constants.
const (
DeploymentStatusBuilding DeploymentStatus = "building"
DeploymentStatusDeploying DeploymentStatus = "deploying"
DeploymentStatusSuccess DeploymentStatus = "success"
DeploymentStatusFailed DeploymentStatus = "failed"
)
// Deployment represents a deployment attempt for an app.
type Deployment struct {
db *database.Database
ID int64
AppID string
WebhookEventID sql.NullInt64
CommitSHA sql.NullString
ImageID sql.NullString
ContainerID sql.NullString
Status DeploymentStatus
Logs sql.NullString
StartedAt time.Time
FinishedAt sql.NullTime
}
// NewDeployment creates a new Deployment with a database reference.
func NewDeployment(db *database.Database) *Deployment {
return &Deployment{
db: db,
Status: DeploymentStatusBuilding,
}
}
// Save inserts or updates the deployment in the database.
func (d *Deployment) Save(ctx context.Context) error {
if d.ID == 0 {
return d.insert(ctx)
}
return d.update(ctx)
}
// Reload refreshes the deployment from the database.
func (d *Deployment) Reload(ctx context.Context) error {
query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?`
row := d.db.QueryRow(ctx, query, d.ID)
return d.scan(row)
}
// AppendLog appends a log line to the deployment logs.
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
var currentLogs string
if d.Logs.Valid {
currentLogs = d.Logs.String
}
d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
return d.Save(ctx)
}
// MarkFinished marks the deployment as finished with the given status.
func (d *Deployment) MarkFinished(
ctx context.Context,
status DeploymentStatus,
) error {
d.Status = status
d.FinishedAt = sql.NullTime{Time: time.Now(), Valid: true}
return d.Save(ctx)
}
func (d *Deployment) insert(ctx context.Context) error {
query := `
INSERT INTO deployments (
app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := d.db.Exec(ctx, query,
d.AppID, d.WebhookEventID, d.CommitSHA, d.ImageID,
d.ContainerID, d.Status, d.Logs,
)
if err != nil {
return err
}
insertID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("getting last insert id: %w", err)
}
d.ID = insertID
return d.Reload(ctx)
}
func (d *Deployment) update(ctx context.Context) error {
query := `
UPDATE deployments SET
image_id = ?, container_id = ?, status = ?, logs = ?, finished_at = ?
WHERE id = ?`
_, err := d.db.Exec(ctx, query,
d.ImageID, d.ContainerID, d.Status, d.Logs, d.FinishedAt, d.ID,
)
return err
}
func (d *Deployment) scan(row *sql.Row) error {
return row.Scan(
&d.ID, &d.AppID, &d.WebhookEventID, &d.CommitSHA, &d.ImageID,
&d.ContainerID, &d.Status, &d.Logs, &d.StartedAt, &d.FinishedAt,
)
}
// FindDeployment finds a deployment by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindDeployment(
ctx context.Context,
deployDB *database.Database,
deployID int64,
) (*Deployment, error) {
deploy := NewDeployment(deployDB)
deploy.ID = deployID
row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?`,
deployID,
)
err := deploy.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning deployment: %w", err)
}
return deploy, nil
}
// FindDeploymentsByAppID finds recent deployments for an app.
func FindDeploymentsByAppID(
ctx context.Context,
deployDB *database.Database,
appID string,
limit int,
) ([]*Deployment, error) {
query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT ?`
rows, err := deployDB.Query(ctx, query, appID, limit)
if err != nil {
return nil, fmt.Errorf("querying deployments by app: %w", err)
}
defer func() { _ = rows.Close() }()
var deployments []*Deployment
for rows.Next() {
deploy := NewDeployment(deployDB)
scanErr := rows.Scan(
&deploy.ID, &deploy.AppID, &deploy.WebhookEventID,
&deploy.CommitSHA, &deploy.ImageID, &deploy.ContainerID,
&deploy.Status, &deploy.Logs, &deploy.StartedAt, &deploy.FinishedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning deployment row: %w", scanErr)
}
deployments = append(deployments, deploy)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating deployment rows: %w", rowsErr)
}
return deployments, nil
}
// LatestDeploymentForApp finds the most recent deployment for an app.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func LatestDeploymentForApp(
ctx context.Context,
deployDB *database.Database,
appID string,
) (*Deployment, error) {
deploy := NewDeployment(deployDB)
row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT 1`,
appID,
)
err := deploy.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning latest deployment: %w", err)
}
return deploy, nil
}

141
internal/models/env_var.go Normal file
View File

@@ -0,0 +1,141 @@
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
)
// EnvVar represents an environment variable for an app.
type EnvVar struct {
db *database.Database
ID int64
AppID string
Key string
Value string
}
// NewEnvVar creates a new EnvVar with a database reference.
func NewEnvVar(db *database.Database) *EnvVar {
return &EnvVar{db: db}
}
// Save inserts or updates the env var in the database.
func (e *EnvVar) Save(ctx context.Context) error {
if e.ID == 0 {
return e.insert(ctx)
}
return e.update(ctx)
}
// Delete removes the env var from the database.
func (e *EnvVar) Delete(ctx context.Context) error {
_, err := e.db.Exec(ctx, "DELETE FROM app_env_vars WHERE id = ?", e.ID)
return err
}
func (e *EnvVar) insert(ctx context.Context) error {
query := "INSERT INTO app_env_vars (app_id, key, value) VALUES (?, ?, ?)"
result, err := e.db.Exec(ctx, query, e.AppID, e.Key, e.Value)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
e.ID = id
return nil
}
func (e *EnvVar) update(ctx context.Context) error {
query := "UPDATE app_env_vars SET key = ?, value = ? WHERE id = ?"
_, err := e.db.Exec(ctx, query, e.Key, e.Value, e.ID)
return err
}
// FindEnvVar finds an env var by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindEnvVar(
ctx context.Context,
db *database.Database,
id int64,
) (*EnvVar, error) {
envVar := NewEnvVar(db)
row := db.QueryRow(ctx,
"SELECT id, app_id, key, value FROM app_env_vars WHERE id = ?",
id,
)
err := row.Scan(&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning env var: %w", err)
}
return envVar, nil
}
// FindEnvVarsByAppID finds all env vars for an app.
func FindEnvVarsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) ([]*EnvVar, error) {
query := `
SELECT id, app_id, key, value FROM app_env_vars
WHERE app_id = ? ORDER BY key`
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying env vars by app: %w", err)
}
defer func() { _ = rows.Close() }()
var envVars []*EnvVar
for rows.Next() {
envVar := NewEnvVar(db)
scanErr := rows.Scan(
&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value,
)
if scanErr != nil {
return nil, scanErr
}
envVars = append(envVars, envVar)
}
return envVars, rows.Err()
}
// DeleteEnvVarsByAppID deletes all env vars for an app.
func DeleteEnvVarsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) error {
_, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
return err
}

139
internal/models/label.go Normal file
View File

@@ -0,0 +1,139 @@
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
)
// Label represents a Docker label for an app container.
type Label struct {
db *database.Database
ID int64
AppID string
Key string
Value string
}
// NewLabel creates a new Label with a database reference.
func NewLabel(db *database.Database) *Label {
return &Label{db: db}
}
// Save inserts or updates the label in the database.
func (l *Label) Save(ctx context.Context) error {
if l.ID == 0 {
return l.insert(ctx)
}
return l.update(ctx)
}
// Delete removes the label from the database.
func (l *Label) Delete(ctx context.Context) error {
_, err := l.db.Exec(ctx, "DELETE FROM app_labels WHERE id = ?", l.ID)
return err
}
func (l *Label) insert(ctx context.Context) error {
query := "INSERT INTO app_labels (app_id, key, value) VALUES (?, ?, ?)"
result, err := l.db.Exec(ctx, query, l.AppID, l.Key, l.Value)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
l.ID = id
return nil
}
func (l *Label) update(ctx context.Context) error {
query := "UPDATE app_labels SET key = ?, value = ? WHERE id = ?"
_, err := l.db.Exec(ctx, query, l.Key, l.Value, l.ID)
return err
}
// FindLabel finds a label by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindLabel(
ctx context.Context,
db *database.Database,
id int64,
) (*Label, error) {
label := NewLabel(db)
row := db.QueryRow(ctx,
"SELECT id, app_id, key, value FROM app_labels WHERE id = ?",
id,
)
err := row.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning label: %w", err)
}
return label, nil
}
// FindLabelsByAppID finds all labels for an app.
func FindLabelsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) ([]*Label, error) {
query := `
SELECT id, app_id, key, value FROM app_labels
WHERE app_id = ? ORDER BY key`
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying labels by app: %w", err)
}
defer func() { _ = rows.Close() }()
var labels []*Label
for rows.Next() {
label := NewLabel(db)
scanErr := rows.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
if scanErr != nil {
return nil, scanErr
}
labels = append(labels, label)
}
return labels, rows.Err()
}
// DeleteLabelsByAppID deletes all labels for an app.
func DeleteLabelsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) error {
_, err := db.Exec(ctx, "DELETE FROM app_labels WHERE app_id = ?", appID)
return err
}

View File

@@ -0,0 +1,801 @@
package models_test
import (
"context"
"database/sql"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
)
// Test constants to satisfy goconst linter.
const (
testHash = "hash"
testBranch = "main"
testValue = "value"
testEventType = "push"
)
func setupTestDB(t *testing.T) (*database.Database, func()) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalVars, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
logr, err := logger.New(fx.Lifecycle(nil), logger.Params{
Globals: globalVars,
})
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
testDB, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: logr,
Config: cfg,
})
require.NoError(t, err)
// t.TempDir() automatically cleans up after test
cleanup := func() {}
return testDB, cleanup
}
// User Tests.
func TestUserCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "testuser"
user.PasswordHash = "hashed_password"
err := user.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, user.ID)
assert.NotZero(t, user.CreatedAt)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, "testuser", found.Username)
}
func TestUserUpdate(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "original"
user.PasswordHash = "hash1"
err := user.Save(context.Background())
require.NoError(t, err)
user.Username = "updated"
user.PasswordHash = "hash2"
err = user.Save(context.Background())
require.NoError(t, err)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
assert.Equal(t, "updated", found.Username)
assert.Equal(t, "hash2", found.PasswordHash)
}
func TestUserDelete(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "todelete"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
err = user.Delete(context.Background())
require.NoError(t, err)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestUserFindByUsername(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "findme"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
found, err := models.FindUserByUsername(
context.Background(), testDB, "findme",
)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, user.ID, found.ID)
}
func TestUserFindByUsernameNotFound(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
found, err := models.FindUserByUsername(
context.Background(), testDB, "nonexistent",
)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestUserExists(t *testing.T) {
t.Parallel()
t.Run("returns false when no users", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
exists, err := models.UserExists(context.Background(), testDB)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("returns true when user exists", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "admin"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
exists, err := models.UserExists(context.Background(), testDB)
require.NoError(t, err)
assert.True(t, exists)
})
}
// App Tests.
func TestAppCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
assert.NotZero(t, app.CreatedAt)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, "test-app", found.Name)
assert.Equal(t, models.AppStatusPending, found.Status)
}
func TestAppUpdate(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.Name = "updated"
app.Status = models.AppStatusRunning
app.ContainerID = sql.NullString{String: "container123", Valid: true}
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
assert.Equal(t, "updated", found.Name)
assert.Equal(t, models.AppStatusRunning, found.Status)
assert.Equal(t, "container123", found.ContainerID.String)
}
func TestAppDelete(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
err := app.Delete(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestAppFindByWebhookSecret(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
found, err := models.FindAppByWebhookSecret(
context.Background(), testDB, app.WebhookSecret,
)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, app.ID, found.ID)
}
func TestAllApps(t *testing.T) {
t.Parallel()
t.Run("returns empty list when no apps", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
apps, err := models.AllApps(context.Background(), testDB)
require.NoError(t, err)
assert.Empty(t, apps)
})
t.Run("returns apps ordered by name", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
names := []string{"zebra", "alpha", "mike"}
for idx, name := range names {
app := models.NewApp(testDB)
app.ID = name + "-id"
app.Name = name
app.RepoURL = "git@example.com:user/" + name + ".git"
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"
err := app.Save(context.Background())
require.NoError(t, err)
}
apps, err := models.AllApps(context.Background(), testDB)
require.NoError(t, err)
require.Len(t, apps, 3)
assert.Equal(t, "alpha", apps[0].Name)
assert.Equal(t, "mike", apps[1].Name)
assert.Equal(t, "zebra", apps[2].Name)
})
}
// EnvVar Tests.
func TestEnvVarCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds env vars", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create app first.
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "DATABASE_URL"
envVar.Value = "postgres://localhost/db"
err := envVar.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, envVar.ID)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, envVars, 1)
assert.Equal(t, "DATABASE_URL", envVars[0].Key)
})
t.Run("deletes env var", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "TO_DELETE"
envVar.Value = testValue
err := envVar.Save(context.Background())
require.NoError(t, err)
err = envVar.Delete(context.Background())
require.NoError(t, err)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
assert.Empty(t, envVars)
})
}
// Label Tests.
func TestLabelCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds labels", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, label.ID)
labels, err := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, labels, 1)
assert.Equal(t, "traefik.enable", labels[0].Key)
})
}
// Volume Tests.
func TestVolumeCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds volumes", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
volume := models.NewVolume(testDB)
volume.AppID = app.ID
volume.HostPath = "/data/app"
volume.ContainerPath = "/app/data"
volume.ReadOnly = true
err := volume.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, volume.ID)
volumes, err := models.FindVolumesByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, volumes, 1)
assert.Equal(t, "/data/app", volumes[0].HostPath)
assert.True(t, volumes[0].ReadOnly)
})
}
// WebhookEvent Tests.
func TestWebhookEventCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds webhook events", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.CommitSHA = sql.NullString{String: "abc123", Valid: true}
event.Matched = true
err := event.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, event.ID)
events, err := models.FindWebhookEventsByAppID(
context.Background(), testDB, app.ID, 10,
)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "push", events[0].EventType)
assert.True(t, events[0].Matched)
})
}
// Deployment Tests.
func TestDeploymentCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.CommitSHA = sql.NullString{String: "abc123def456", Valid: true}
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, deployment.ID)
assert.NotZero(t, deployment.StartedAt)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, models.DeploymentStatusBuilding, found.Status)
}
func TestDeploymentAppendLog(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
err = deployment.AppendLog(context.Background(), "Building image...")
require.NoError(t, err)
err = deployment.AppendLog(context.Background(), "Image built successfully")
require.NoError(t, err)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
assert.Contains(t, found.Logs.String, "Building image...")
assert.Contains(t, found.Logs.String, "Image built successfully")
}
func TestDeploymentMarkFinished(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
err = deployment.MarkFinished(context.Background(), models.DeploymentStatusSuccess)
require.NoError(t, err)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
assert.Equal(t, models.DeploymentStatusSuccess, found.Status)
assert.True(t, found.FinishedAt.Valid)
}
func TestDeploymentFindByAppID(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
for idx := range 5 {
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
deploy.CommitSHA = sql.NullString{
String: "commit" + strconv.Itoa(idx),
Valid: true,
}
err := deploy.Save(context.Background())
require.NoError(t, err)
}
deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3)
require.NoError(t, err)
assert.Len(t, deployments, 3)
}
func TestDeploymentFindLatest(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
for idx := range 3 {
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.CommitSHA = sql.NullString{
String: "commit" + strconv.Itoa(idx),
Valid: true,
}
deploy.Status = models.DeploymentStatusSuccess
err := deploy.Save(context.Background())
require.NoError(t, err)
}
latest, err := models.LatestDeploymentForApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, "commit2", latest.CommitSHA.String)
}
// App Helper Methods Tests.
func TestAppGetEnvVars(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
env1 := models.NewEnvVar(testDB)
env1.AppID = app.ID
env1.Key = "KEY1"
env1.Value = "value1"
_ = env1.Save(context.Background())
env2 := models.NewEnvVar(testDB)
env2.AppID = app.ID
env2.Key = "KEY2"
env2.Value = "value2"
_ = env2.Save(context.Background())
envVars, err := app.GetEnvVars(context.Background())
require.NoError(t, err)
assert.Len(t, envVars, 2)
}
func TestAppGetLabels(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "label.key"
label.Value = "label.value"
_ = label.Save(context.Background())
labels, err := app.GetLabels(context.Background())
require.NoError(t, err)
assert.Len(t, labels, 1)
}
func TestAppGetVolumes(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
vol := models.NewVolume(testDB)
vol.AppID = app.ID
vol.HostPath = "/host"
vol.ContainerPath = "/container"
_ = vol.Save(context.Background())
volumes, err := app.GetVolumes(context.Background())
require.NoError(t, err)
assert.Len(t, volumes, 1)
}
func TestAppGetDeployments(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
_ = deploy.Save(context.Background())
deployments, err := app.GetDeployments(context.Background(), 10)
require.NoError(t, err)
assert.Len(t, deployments, 1)
}
func TestAppGetWebhookEvents(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.Matched = true
_ = event.Save(context.Background())
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
assert.Len(t, events, 1)
}
// Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests
func TestCascadeDelete(t *testing.T) {
t.Parallel()
t.Run("deleting app cascades to related records", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
// Create related records.
env := models.NewEnvVar(testDB)
env.AppID = app.ID
env.Key = "KEY"
env.Value = "value"
_ = env.Save(context.Background())
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "key"
label.Value = "value"
_ = label.Save(context.Background())
vol := models.NewVolume(testDB)
vol.AppID = app.ID
vol.HostPath = "/host"
vol.ContainerPath = "/container"
_ = vol.Save(context.Background())
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.Matched = true
_ = event.Save(context.Background())
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
_ = deploy.Save(context.Background())
// Delete app.
err := app.Delete(context.Background())
require.NoError(t, err)
// Verify cascades.
envVars, _ := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, envVars)
labels, _ := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, labels)
volumes, _ := models.FindVolumesByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, volumes)
events, _ := models.FindWebhookEventsByAppID(
context.Background(), testDB, app.ID, 10,
)
assert.Empty(t, events)
deployments, _ := models.FindDeploymentsByAppID(
context.Background(), testDB, app.ID, 10,
)
assert.Empty(t, deployments)
})
}
// Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper()
app := models.NewApp(testDB)
app.ID = "test-app-" + t.Name()
app.Name = "test-app"
app.RepoURL = "git@example.com:user/repo.git"
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + t.Name()
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"
err := app.Save(context.Background())
require.NoError(t, err)
return app
}

150
internal/models/user.go Normal file
View File

@@ -0,0 +1,150 @@
// Package models provides Active Record style database models.
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// User represents a user in the system.
type User struct {
db *database.Database
ID int64
Username string
PasswordHash string
CreatedAt time.Time
}
// NewUser creates a new User with a database reference.
func NewUser(db *database.Database) *User {
return &User{db: db}
}
// Save inserts or updates the user in the database.
func (u *User) Save(ctx context.Context) error {
if u.ID == 0 {
return u.insert(ctx)
}
return u.update(ctx)
}
// Delete removes the user from the database.
func (u *User) Delete(ctx context.Context) error {
_, err := u.db.Exec(ctx, "DELETE FROM users WHERE id = ?", u.ID)
return err
}
// Reload refreshes the user from the database.
func (u *User) Reload(ctx context.Context) error {
query := "SELECT id, username, password_hash, created_at FROM users WHERE id = ?"
row := u.db.QueryRow(ctx, query, u.ID)
return u.scan(row)
}
func (u *User) insert(ctx context.Context) error {
query := "INSERT INTO users (username, password_hash) VALUES (?, ?)"
result, err := u.db.Exec(ctx, query, u.Username, u.PasswordHash)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
u.ID = id
return u.Reload(ctx)
}
func (u *User) update(ctx context.Context) error {
query := "UPDATE users SET username = ?, password_hash = ? WHERE id = ?"
_, err := u.db.Exec(ctx, query, u.Username, u.PasswordHash, u.ID)
return err
}
func (u *User) scan(row *sql.Row) error {
return row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt)
}
// FindUser finds a user by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindUser(
ctx context.Context,
db *database.Database,
id int64,
) (*User, error) {
user := NewUser(db)
row := db.QueryRow(ctx,
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?",
id,
)
err := user.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning user: %w", err)
}
return user, nil
}
// FindUserByUsername finds a user by username.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindUserByUsername(
ctx context.Context,
db *database.Database,
username string,
) (*User, error) {
user := NewUser(db)
row := db.QueryRow(ctx,
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?",
username,
)
err := user.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning user by username: %w", err)
}
return user, nil
}
// UserExists checks if any user exists in the database.
func UserExists(ctx context.Context, db *database.Database) (bool, error) {
var count int
row := db.QueryRow(ctx, "SELECT COUNT(*) FROM users")
err := row.Scan(&count)
if err != nil {
return false, fmt.Errorf("counting users: %w", err)
}
return count > 0, nil
}

151
internal/models/volume.go Normal file
View File

@@ -0,0 +1,151 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
)
// Volume represents a volume mount for an app container.
type Volume struct {
db *database.Database
ID int64
AppID string
HostPath string
ContainerPath string
ReadOnly bool
}
// NewVolume creates a new Volume with a database reference.
func NewVolume(db *database.Database) *Volume {
return &Volume{db: db}
}
// Save inserts or updates the volume in the database.
func (v *Volume) Save(ctx context.Context) error {
if v.ID == 0 {
return v.insert(ctx)
}
return v.update(ctx)
}
// Delete removes the volume from the database.
func (v *Volume) Delete(ctx context.Context) error {
_, err := v.db.Exec(ctx, "DELETE FROM app_volumes WHERE id = ?", v.ID)
return err
}
func (v *Volume) insert(ctx context.Context) error {
query := `
INSERT INTO app_volumes (app_id, host_path, container_path, readonly)
VALUES (?, ?, ?, ?)`
result, err := v.db.Exec(ctx, query,
v.AppID, v.HostPath, v.ContainerPath, v.ReadOnly,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
v.ID = id
return nil
}
func (v *Volume) update(ctx context.Context) error {
query := `
UPDATE app_volumes SET host_path = ?, container_path = ?, readonly = ?
WHERE id = ?`
_, err := v.db.Exec(ctx, query, v.HostPath, v.ContainerPath, v.ReadOnly, v.ID)
return err
}
// FindVolume finds a volume by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindVolume(
ctx context.Context,
db *database.Database,
id int64,
) (*Volume, error) {
vol := NewVolume(db)
query := `
SELECT id, app_id, host_path, container_path, readonly
FROM app_volumes WHERE id = ?`
row := db.QueryRow(ctx, query, id)
err := row.Scan(
&vol.ID, &vol.AppID, &vol.HostPath, &vol.ContainerPath, &vol.ReadOnly,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning volume: %w", err)
}
return vol, nil
}
// FindVolumesByAppID finds all volumes for an app.
func FindVolumesByAppID(
ctx context.Context,
db *database.Database,
appID string,
) ([]*Volume, error) {
query := `
SELECT id, app_id, host_path, container_path, readonly
FROM app_volumes WHERE app_id = ? ORDER BY container_path`
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying volumes by app: %w", err)
}
defer func() { _ = rows.Close() }()
var volumes []*Volume
for rows.Next() {
vol := NewVolume(db)
scanErr := rows.Scan(
&vol.ID, &vol.AppID, &vol.HostPath,
&vol.ContainerPath, &vol.ReadOnly,
)
if scanErr != nil {
return nil, scanErr
}
volumes = append(volumes, vol)
}
return volumes, rows.Err()
}
// DeleteVolumesByAppID deletes all volumes for an app.
func DeleteVolumesByAppID(
ctx context.Context,
db *database.Database,
appID string,
) error {
_, err := db.Exec(ctx, "DELETE FROM app_volumes WHERE app_id = ?", appID)
return err
}

View File

@@ -0,0 +1,198 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// WebhookEvent represents a received webhook event.
type WebhookEvent struct {
db *database.Database
ID int64
AppID string
EventType string
Branch string
CommitSHA sql.NullString
Payload sql.NullString
Matched bool
Processed bool
CreatedAt time.Time
}
// NewWebhookEvent creates a new WebhookEvent with a database reference.
func NewWebhookEvent(db *database.Database) *WebhookEvent {
return &WebhookEvent{db: db}
}
// Save inserts or updates the webhook event in the database.
func (w *WebhookEvent) Save(ctx context.Context) error {
if w.ID == 0 {
return w.insert(ctx)
}
return w.update(ctx)
}
// Reload refreshes the webhook event from the database.
func (w *WebhookEvent) Reload(ctx context.Context) error {
query := `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events WHERE id = ?`
row := w.db.QueryRow(ctx, query, w.ID)
return w.scan(row)
}
func (w *WebhookEvent) insert(ctx context.Context) error {
query := `
INSERT INTO webhook_events (
app_id, event_type, branch, commit_sha, payload, matched, processed
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := w.db.Exec(ctx, query,
w.AppID, w.EventType, w.Branch, w.CommitSHA,
w.Payload, w.Matched, w.Processed,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
w.ID = id
return w.Reload(ctx)
}
func (w *WebhookEvent) update(ctx context.Context) error {
query := "UPDATE webhook_events SET processed = ? WHERE id = ?"
_, err := w.db.Exec(ctx, query, w.Processed, w.ID)
return err
}
func (w *WebhookEvent) scan(row *sql.Row) error {
return row.Scan(
&w.ID, &w.AppID, &w.EventType, &w.Branch, &w.CommitSHA,
&w.Payload, &w.Matched, &w.Processed, &w.CreatedAt,
)
}
// FindWebhookEvent finds a webhook event by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindWebhookEvent(
ctx context.Context,
db *database.Database,
id int64,
) (*WebhookEvent, error) {
event := NewWebhookEvent(db)
event.ID = id
row := db.QueryRow(ctx, `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events WHERE id = ?`,
id,
)
err := event.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning webhook event: %w", err)
}
return event, nil
}
// FindWebhookEventsByAppID finds recent webhook events for an app.
func FindWebhookEventsByAppID(
ctx context.Context,
db *database.Database,
appID string,
limit int,
) ([]*WebhookEvent, error) {
query := `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events WHERE app_id = ? ORDER BY created_at DESC LIMIT ?`
rows, err := db.Query(ctx, query, appID, limit)
if err != nil {
return nil, fmt.Errorf("querying webhook events by app: %w", err)
}
defer func() { _ = rows.Close() }()
var events []*WebhookEvent
for rows.Next() {
event := NewWebhookEvent(db)
scanErr := rows.Scan(
&event.ID, &event.AppID, &event.EventType, &event.Branch,
&event.CommitSHA, &event.Payload, &event.Matched,
&event.Processed, &event.CreatedAt,
)
if scanErr != nil {
return nil, scanErr
}
events = append(events, event)
}
return events, rows.Err()
}
// FindUnprocessedWebhookEvents finds unprocessed matched webhook events.
func FindUnprocessedWebhookEvents(
ctx context.Context,
db *database.Database,
) ([]*WebhookEvent, error) {
query := `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events
WHERE matched = 1 AND processed = 0 ORDER BY created_at ASC`
rows, err := db.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("querying unprocessed webhook events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []*WebhookEvent
for rows.Next() {
event := NewWebhookEvent(db)
scanErr := rows.Scan(
&event.ID, &event.AppID, &event.EventType, &event.Branch,
&event.CommitSHA, &event.Payload, &event.Matched,
&event.Processed, &event.CreatedAt,
)
if scanErr != nil {
return nil, scanErr
}
events = append(events, event)
}
return events, rows.Err()
}