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:
290
internal/models/app.go
Normal file
290
internal/models/app.go
Normal 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
|
||||
}
|
||||
241
internal/models/deployment.go
Normal file
241
internal/models/deployment.go
Normal 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
141
internal/models/env_var.go
Normal 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
139
internal/models/label.go
Normal 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
|
||||
}
|
||||
801
internal/models/models_test.go
Normal file
801
internal/models/models_test.go
Normal 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
150
internal/models/user.go
Normal 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
151
internal/models/volume.go
Normal 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
|
||||
}
|
||||
198
internal/models/webhook_event.go
Normal file
198
internal/models/webhook_event.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user