Compare commits

..

1 Commits

Author SHA1 Message Date
user
e2522f2017 feat: add custom health check commands per app
All checks were successful
Check / check (pull_request) Successful in 1m53s
Add configurable health check commands per app via a new
'healthcheck_command' field. When set, the command is passed
to Docker as a CMD-SHELL health check on the container.
When empty, the image's default health check is used.

Changes:
- Add migration 007 for healthcheck_command column on apps table
- Add HealthcheckCommand field to App model with full CRUD support
- Add buildHealthcheck() to docker client for CMD-SHELL config
- Pass health check command through CreateContainerOptions
- Add health check command input to app create/edit UI forms
- Extract optionalNullString helper to reduce handler complexity
- Update README features list

closes #81
2026-03-17 02:11:08 -07:00
17 changed files with 543 additions and 1211 deletions

View File

@@ -1,14 +1,14 @@
# µPaaS by [@sneak](https://sneak.berlin) # µPaaS by [@sneak](https://sneak.berlin)
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab. A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
## Features ## Features
- Single admin user with argon2id password hashing - Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys - Per-app SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab - Per-app UUID-based webhook URLs for Gitea integration
- Branch filtering - only deploy on configured branch changes - Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app - Environment variables, labels, volume mounts, and custom health checks per app
- Docker builds via socket access - Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks - Notifications via ntfy and Slack-compatible webhooks
- Simple server-rendered UI with Tailwind CSS - Simple server-rendered UI with Tailwind CSS
@@ -19,7 +19,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
- Complex CI pipelines - Complex CI pipelines
- Multiple container orchestration - Multiple container orchestration
- SPA/API-first design - SPA/API-first design
- Support for non-push webhook events (e.g. issues, merge requests) - Support for non-Gitea webhooks
## Architecture ## Architecture
@@ -44,7 +44,7 @@ upaas/
│ │ ├── auth/ # Authentication service │ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration │ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack) │ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab) │ │ └── webhook/ # Gitea webhook processing
│ └── ssh/ # SSH key generation │ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets ├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates └── templates/ # Embedded HTML templates

View File

@@ -0,0 +1,2 @@
-- Add custom health check command per app
ALTER TABLE apps ADD COLUMN healthcheck_command TEXT;

View File

@@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
dockertypes "github.com/docker/docker/api/types" dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -138,13 +139,14 @@ func (c *Client) BuildImage(
// CreateContainerOptions contains options for creating a container. // CreateContainerOptions contains options for creating a container.
type CreateContainerOptions struct { type CreateContainerOptions struct {
Name string Name string
Image string Image string
Env map[string]string Env map[string]string
Labels map[string]string Labels map[string]string
Volumes []VolumeMount Volumes []VolumeMount
Ports []PortMapping Ports []PortMapping
Network string Network string
HealthcheckCommand string // Custom health check shell command (empty = use image default)
} }
// VolumeMount represents a volume mount. // VolumeMount represents a volume mount.
@@ -185,6 +187,29 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
return exposedPorts, portBindings return exposedPorts, portBindings
} }
// healthcheckInterval is the time between health check attempts.
const healthcheckInterval = 30 * time.Second
// healthcheckTimeout is the maximum time a single health check can take.
const healthcheckTimeout = 10 * time.Second
// healthcheckStartPeriod is the grace period before health checks start counting failures.
const healthcheckStartPeriod = 15 * time.Second
// healthcheckRetries is the number of consecutive failures needed to mark unhealthy.
const healthcheckRetries = 3
// buildHealthcheck creates a Docker health check config from a shell command string.
func buildHealthcheck(command string) *container.HealthConfig {
return &container.HealthConfig{
Test: []string{"CMD-SHELL", command},
Interval: healthcheckInterval,
Timeout: healthcheckTimeout,
StartPeriod: healthcheckStartPeriod,
Retries: healthcheckRetries,
}
}
// CreateContainer creates a new container. // CreateContainer creates a new container.
func (c *Client) CreateContainer( func (c *Client) CreateContainer(
ctx context.Context, ctx context.Context,
@@ -218,14 +243,22 @@ func (c *Client) CreateContainer(
// Convert ports to exposed ports and port bindings // Convert ports to exposed ports and port bindings
exposedPorts, portBindings := buildPortConfig(opts.Ports) exposedPorts, portBindings := buildPortConfig(opts.Ports)
// Build container config
containerConfig := &container.Config{
Image: opts.Image,
Env: envSlice,
Labels: opts.Labels,
ExposedPorts: exposedPorts,
}
// Apply custom health check if configured
if opts.HealthcheckCommand != "" {
containerConfig.Healthcheck = buildHealthcheck(opts.HealthcheckCommand)
}
// Create container // Create container
resp, err := c.docker.ContainerCreate(ctx, resp, err := c.docker.ContainerCreate(ctx,
&container.Config{ containerConfig,
Image: opts.Image,
Env: envSlice,
Labels: opts.Labels,
ExposedPorts: exposedPorts,
},
&container.HostConfig{ &container.HostConfig{
Mounts: mounts, Mounts: mounts,
PortBindings: portBindings, PortBindings: portBindings,

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"testing" "testing"
"time"
) )
func TestValidBranchRegex(t *testing.T) { func TestValidBranchRegex(t *testing.T) {
@@ -146,3 +147,52 @@ func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driv
}) })
} }
} }
func TestBuildHealthcheck(t *testing.T) {
t.Parallel()
t.Run("creates CMD-SHELL health check", func(t *testing.T) {
t.Parallel()
cmd := "curl -f http://localhost:8080/healthz || exit 1"
hc := buildHealthcheck(cmd)
if len(hc.Test) != 2 {
t.Fatalf("expected 2 test elements, got %d", len(hc.Test))
}
if hc.Test[0] != "CMD-SHELL" {
t.Errorf("expected Test[0]=%q, got %q", "CMD-SHELL", hc.Test[0])
}
if hc.Test[1] != cmd {
t.Errorf("expected Test[1]=%q, got %q", cmd, hc.Test[1])
}
})
t.Run("sets expected intervals", func(t *testing.T) {
t.Parallel()
hc := buildHealthcheck("true")
expectedInterval := 30 * time.Second
if hc.Interval != expectedInterval {
t.Errorf("expected Interval=%v, got %v", expectedInterval, hc.Interval)
}
expectedTimeout := 10 * time.Second
if hc.Timeout != expectedTimeout {
t.Errorf("expected Timeout=%v, got %v", expectedTimeout, hc.Timeout)
}
expectedStartPeriod := 15 * time.Second
if hc.StartPeriod != expectedStartPeriod {
t.Errorf("expected StartPeriod=%v, got %v", expectedStartPeriod, hc.StartPeriod)
}
expectedRetries := 3
if hc.Retries != expectedRetries {
t.Errorf("expected Retries=%d, got %d", expectedRetries, hc.Retries)
}
})
}

View File

@@ -57,15 +57,17 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
dockerNetwork := request.FormValue("docker_network") dockerNetwork := request.FormValue("docker_network")
ntfyTopic := request.FormValue("ntfy_topic") ntfyTopic := request.FormValue("ntfy_topic")
slackWebhook := request.FormValue("slack_webhook") slackWebhook := request.FormValue("slack_webhook")
healthcheckCommand := request.FormValue("healthcheck_command")
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Name": name, "Name": name,
"RepoURL": repoURL, "RepoURL": repoURL,
"Branch": branch, "Branch": branch,
"DockerfilePath": dockerfilePath, "DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork, "DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic, "NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook, "SlackWebhook": slackWebhook,
"HealthcheckCommand": healthcheckCommand,
}, request) }, request)
if name == "" || repoURL == "" { if name == "" || repoURL == "" {
@@ -102,13 +104,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
createdApp, createErr := h.appService.CreateApp( createdApp, createErr := h.appService.CreateApp(
request.Context(), request.Context(),
app.CreateAppInput{ app.CreateAppInput{
Name: name, Name: name,
RepoURL: repoURL, RepoURL: repoURL,
Branch: branch, Branch: branch,
DockerfilePath: dockerfilePath, DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork, DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic, NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook, SlackWebhook: slackWebhook,
HealthcheckCommand: healthcheckCommand,
}, },
) )
if createErr != nil { if createErr != nil {
@@ -208,6 +211,11 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
} }
} }
// optionalNullString returns a valid NullString if the value is non-empty, or an empty NullString.
func optionalNullString(value string) sql.NullString {
return sql.NullString{String: value, Valid: value != ""}
}
// HandleAppUpdate handles app updates. // HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
@@ -257,24 +265,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
application.RepoURL = request.FormValue("repo_url") application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch") application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path") application.DockerfilePath = request.FormValue("dockerfile_path")
application.DockerNetwork = optionalNullString(request.FormValue("docker_network"))
if network := request.FormValue("docker_network"); network != "" { application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic"))
application.DockerNetwork = sql.NullString{String: network, Valid: true} application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook"))
} else { application.HealthcheckCommand = optionalNullString(request.FormValue("healthcheck_command"))
application.DockerNetwork = sql.NullString{}
}
if ntfy := request.FormValue("ntfy_topic"); ntfy != "" {
application.NtfyTopic = sql.NullString{String: ntfy, Valid: true}
} else {
application.NtfyTopic = sql.NullString{}
}
if slack := request.FormValue("slack_webhook"); slack != "" {
application.SlackWebhook = sql.NullString{String: slack, Valid: true}
} else {
application.SlackWebhook = sql.NullString{}
}
saveErr := application.Save(request.Context()) saveErr := application.Save(request.Context())
if saveErr != nil { if saveErr != nil {

View File

@@ -7,14 +7,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/webhook"
) )
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB). // maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
const maxWebhookBodySize = 1 << 20 const maxWebhookBodySize = 1 << 20
// HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab. // HandleWebhook handles incoming Gitea webhooks.
// The webhook source is auto-detected from HTTP headers.
func (h *Handlers) HandleWebhook() http.HandlerFunc { func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
secret := chi.URLParam(request, "secret") secret := chi.URLParam(request, "secret")
@@ -52,17 +50,16 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return return
} }
// Auto-detect webhook source from headers // Get event type from header
source := webhook.DetectWebhookSource(request.Header) eventType := request.Header.Get("X-Gitea-Event")
if eventType == "" {
// Extract event type based on detected source eventType = "push"
eventType := webhook.DetectEventType(request.Header, source) }
// Process webhook // Process webhook
webhookErr := h.webhook.HandleWebhook( webhookErr := h.webhook.HandleWebhook(
request.Context(), request.Context(),
application, application,
source,
eventType, eventType,
body, body,
) )

View File

@@ -14,7 +14,7 @@ import (
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret, const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, created_at, updated_at` previous_image_id, healthcheck_command, created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@@ -32,23 +32,24 @@ const (
type App struct { type App struct {
db *database.Database db *database.Database
ID string ID string
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
WebhookSecret string WebhookSecret string
WebhookSecretHash string WebhookSecretHash string
SSHPrivateKey string SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
PreviousImageID sql.NullString PreviousImageID sql.NullString
Status AppStatus Status AppStatus
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
SlackWebhook sql.NullString SlackWebhook sql.NullString
CreatedAt time.Time HealthcheckCommand sql.NullString
UpdatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
} }
// NewApp creates a new App with a database reference. // NewApp creates a new App with a database reference.
@@ -142,14 +143,14 @@ func (a *App) insert(ctx context.Context) error {
id, name, repo_url, branch, dockerfile_path, webhook_secret, id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id previous_image_id, healthcheck_command
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query, _, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
a.PreviousImageID, a.PreviousImageID, a.HealthcheckCommand,
) )
if err != nil { if err != nil {
return err return err
@@ -164,7 +165,7 @@ func (a *App) update(ctx context.Context) error {
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?, name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
image_id = ?, status = ?, image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
previous_image_id = ?, previous_image_id = ?, healthcheck_command = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
@@ -172,7 +173,7 @@ func (a *App) update(ctx context.Context) error {
a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
a.ImageID, a.Status, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID, a.PreviousImageID, a.HealthcheckCommand,
a.ID, a.ID,
) )
@@ -187,7 +188,7 @@ func (a *App) scan(row *sql.Row) error {
&a.ImageID, &a.Status, &a.ImageID, &a.Status,
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash, &a.WebhookSecretHash,
&a.PreviousImageID, &a.PreviousImageID, &a.HealthcheckCommand,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@@ -205,7 +206,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.ImageID, &app.Status, &app.ImageID, &app.Status,
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash, &app.WebhookSecretHash,
&app.PreviousImageID, &app.PreviousImageID, &app.HealthcheckCommand,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {

View File

@@ -704,6 +704,72 @@ func TestAppGetWebhookEvents(t *testing.T) {
assert.Len(t, events, 1) assert.Len(t, events, 1)
} }
// App HealthcheckCommand Tests.
func TestAppHealthcheckCommand(t *testing.T) {
t.Parallel()
t.Run("saves and loads healthcheck command", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.HealthcheckCommand = sql.NullString{
String: "curl -f http://localhost:8080/healthz || exit 1",
Valid: true,
}
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.True(t, found.HealthcheckCommand.Valid)
assert.Equal(t, "curl -f http://localhost:8080/healthz || exit 1", found.HealthcheckCommand.String)
})
t.Run("null when not set", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.False(t, found.HealthcheckCommand.Valid)
})
t.Run("can be cleared", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.HealthcheckCommand = sql.NullString{String: "true", Valid: true}
err := app.Save(context.Background())
require.NoError(t, err)
// Clear it
app.HealthcheckCommand = sql.NullString{}
err = app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.False(t, found.HealthcheckCommand.Valid)
})
}
// Cascade Delete Tests. // Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests //nolint:funlen // Test function with many assertions - acceptable for integration tests

View File

@@ -46,13 +46,14 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
// CreateAppInput contains the input for creating an app. // CreateAppInput contains the input for creating an app.
type CreateAppInput struct { type CreateAppInput struct {
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
DockerNetwork string DockerNetwork string
NtfyTopic string NtfyTopic string
SlackWebhook string SlackWebhook string
HealthcheckCommand string
} }
// CreateApp creates a new application with generated SSH keys and webhook secret. // CreateApp creates a new application with generated SSH keys and webhook secret.
@@ -100,6 +101,10 @@ func (svc *Service) CreateApp(
app.SlackWebhook = sql.NullString{String: input.SlackWebhook, Valid: true} app.SlackWebhook = sql.NullString{String: input.SlackWebhook, Valid: true}
} }
if input.HealthcheckCommand != "" {
app.HealthcheckCommand = sql.NullString{String: input.HealthcheckCommand, Valid: true}
}
saveErr := app.Save(ctx) saveErr := app.Save(ctx)
if saveErr != nil { if saveErr != nil {
return nil, fmt.Errorf("failed to save app: %w", saveErr) return nil, fmt.Errorf("failed to save app: %w", saveErr)
@@ -112,13 +117,14 @@ func (svc *Service) CreateApp(
// UpdateAppInput contains the input for updating an app. // UpdateAppInput contains the input for updating an app.
type UpdateAppInput struct { type UpdateAppInput struct {
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
DockerNetwork string DockerNetwork string
NtfyTopic string NtfyTopic string
SlackWebhook string SlackWebhook string
HealthcheckCommand string
} }
// UpdateApp updates an existing application. // UpdateApp updates an existing application.
@@ -144,6 +150,10 @@ func (svc *Service) UpdateApp(
String: input.SlackWebhook, String: input.SlackWebhook,
Valid: input.SlackWebhook != "", Valid: input.SlackWebhook != "",
} }
app.HealthcheckCommand = sql.NullString{
String: input.HealthcheckCommand,
Valid: input.HealthcheckCommand != "",
}
saveErr := app.Save(ctx) saveErr := app.Save(ctx)
if saveErr != nil { if saveErr != nil {

View File

@@ -1094,14 +1094,20 @@ func (svc *Service) buildContainerOptions(
network = app.DockerNetwork.String network = app.DockerNetwork.String
} }
healthcheckCmd := ""
if app.HealthcheckCommand.Valid {
healthcheckCmd = app.HealthcheckCommand.String
}
return docker.CreateContainerOptions{ return docker.CreateContainerOptions{
Name: "upaas-" + app.Name, Name: "upaas-" + app.Name,
Image: imageID.String(), Image: imageID.String(),
Env: envMap, Env: envMap,
Labels: buildLabelMap(app, labels), Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes), Volumes: buildVolumeMounts(volumes),
Ports: buildPortMappings(ports), Ports: buildPortMappings(ports),
Network: network, Network: network,
HealthcheckCommand: healthcheckCmd,
}, nil }, nil
} }

View File

@@ -2,6 +2,7 @@ package deploy_test
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"os" "os"
"testing" "testing"
@@ -43,3 +44,64 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name) t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name)
} }
} }
func TestBuildContainerOptionsHealthcheckSet(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "hc-app"
app.HealthcheckCommand = sql.NullString{
String: "curl -f http://localhost:8080/healthz || exit 1",
Valid: true,
}
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, "sha256:test",
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
expected := "curl -f http://localhost:8080/healthz || exit 1"
if opts.HealthcheckCommand != expected {
t.Errorf("expected HealthcheckCommand=%q, got %q", expected, opts.HealthcheckCommand)
}
}
func TestBuildContainerOptionsHealthcheckEmpty(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "no-hc-app"
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, "sha256:test",
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.HealthcheckCommand != "" {
t.Errorf("expected empty HealthcheckCommand, got %q", opts.HealthcheckCommand)
}
}

View File

@@ -1,248 +0,0 @@
package webhook
import "encoding/json"
// GiteaPushPayload represents a Gitea push webhook payload.
//
//nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitHubPushPayload represents a GitHub push webhook payload.
//
//nolint:tagliatelle // Field names match GitHub API (snake_case)
type GitHubPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"pusher"`
HeadCommit *struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
} `json:"head_commit"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitLabPushPayload represents a GitLab push webhook payload.
//
//nolint:tagliatelle // Field names match GitLab API (snake_case)
type GitLabPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
GitHTTPURL UnparsedURL `json:"git_http_url"`
GitSSHURL string `json:"git_ssh_url"`
WebURL UnparsedURL `json:"web_url"`
} `json:"project"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// ParsePushPayload parses a raw webhook payload into a normalized PushEvent
// based on the detected webhook source. Returns an error if JSON unmarshaling
// fails. For SourceUnknown, falls back to Gitea format for backward
// compatibility.
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
switch source {
case SourceGitHub:
return parseGitHubPush(payload)
case SourceGitLab:
return parseGitLabPush(payload)
case SourceGitea, SourceUnknown:
// Gitea and unknown both use Gitea format for backward compatibility.
return parseGiteaPush(payload)
}
// Unreachable for known source values, but satisfies exhaustive checker.
return parseGiteaPush(payload)
}
func parseGiteaPush(payload []byte) (*PushEvent, error) {
var p GiteaPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGiteaCommitURL(p)
return &PushEvent{
Source: SourceGitea,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Username,
}, nil
}
func parseGitHubPush(payload []byte) (*PushEvent, error) {
var p GitHubPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitHubCommitURL(p)
return &PushEvent{
Source: SourceGitHub,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Name,
}, nil
}
func parseGitLabPush(payload []byte) (*PushEvent, error) {
var p GitLabPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitLabCommitURL(p)
return &PushEvent{
Source: SourceGitLab,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Project.PathWithNamespace,
CloneURL: p.Project.GitHTTPURL,
HTMLURL: p.Project.WebURL,
CommitURL: commitURL,
Pusher: p.UserName,
}, nil
}
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractGiteaCommitURL extracts the commit URL from a Gitea push payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractGiteaCommitURL(payload GiteaPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitHubCommitURL extracts the commit URL from a GitHub push payload.
// Prefers head_commit.url, then searches commits, then constructs from repo URL.
func extractGitHubCommitURL(payload GitHubPushPayload) UnparsedURL {
if payload.HeadCommit != nil && payload.HeadCommit.URL != "" {
return payload.HeadCommit.URL
}
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitLabCommitURL extracts the commit URL from a GitLab push payload.
// Prefers commit URL from the commits list, falls back to constructing from
// project web URL.
func extractGitLabCommitURL(payload GitLabPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Project.WebURL != "" && payload.After != "" {
return UnparsedURL(payload.Project.WebURL.String() + "/-/commit/" + payload.After)
}
return ""
}

View File

@@ -1,7 +1,5 @@
package webhook package webhook
import "net/http"
// UnparsedURL is a URL stored as a plain string without parsing. // UnparsedURL is a URL stored as a plain string without parsing.
// Use this instead of string when the value is known to be a URL // Use this instead of string when the value is known to be a URL
// but should not be parsed into a net/url.URL (e.g. webhook URLs, // but should not be parsed into a net/url.URL (e.g. webhook URLs,
@@ -10,84 +8,3 @@ type UnparsedURL string
// String implements the fmt.Stringer interface. // String implements the fmt.Stringer interface.
func (u UnparsedURL) String() string { return string(u) } func (u UnparsedURL) String() string { return string(u) }
// Source identifies which git hosting platform sent the webhook.
type Source string
const (
// SourceGitea indicates the webhook was sent by a Gitea instance.
SourceGitea Source = "gitea"
// SourceGitHub indicates the webhook was sent by GitHub.
SourceGitHub Source = "github"
// SourceGitLab indicates the webhook was sent by a GitLab instance.
SourceGitLab Source = "gitlab"
// SourceUnknown indicates the webhook source could not be determined.
SourceUnknown Source = "unknown"
)
// String implements the fmt.Stringer interface.
func (s Source) String() string { return string(s) }
// DetectWebhookSource determines the webhook source from HTTP headers.
// It checks for platform-specific event headers in this order:
// Gitea (X-Gitea-Event), GitHub (X-GitHub-Event), GitLab (X-Gitlab-Event).
// Returns SourceUnknown if no recognized header is found.
func DetectWebhookSource(headers http.Header) Source {
if headers.Get("X-Gitea-Event") != "" {
return SourceGitea
}
if headers.Get("X-Github-Event") != "" {
return SourceGitHub
}
if headers.Get("X-Gitlab-Event") != "" {
return SourceGitLab
}
return SourceUnknown
}
// DetectEventType extracts the event type string from HTTP headers
// based on the detected webhook source. Returns "push" as a fallback
// when no event header is found.
func DetectEventType(headers http.Header, source Source) string {
switch source {
case SourceGitea:
if v := headers.Get("X-Gitea-Event"); v != "" {
return v
}
case SourceGitHub:
if v := headers.Get("X-Github-Event"); v != "" {
return v
}
case SourceGitLab:
if v := headers.Get("X-Gitlab-Event"); v != "" {
return v
}
case SourceUnknown:
// Fall through to default
}
return "push"
}
// PushEvent is a normalized representation of a push webhook payload
// from any supported source (Gitea, GitHub, GitLab). The webhook
// service converts source-specific payloads into this format before
// processing.
type PushEvent struct {
Source Source
Ref string
Before string
After string
Branch string
RepoName string
CloneURL UnparsedURL
HTMLURL UnparsedURL
CommitURL UnparsedURL
Pusher string
}

View File

@@ -4,6 +4,7 @@ package webhook
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -43,46 +44,68 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
}, nil }, nil
} }
// HandleWebhook processes a webhook request from any supported source // GiteaPushPayload represents a Gitea push webhook payload.
// (Gitea, GitHub, or GitLab). The source parameter determines which //
// payload format to use for parsing. //nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// HandleWebhook processes a webhook request.
func (svc *Service) HandleWebhook( func (svc *Service) HandleWebhook(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
source Source,
eventType string, eventType string,
payload []byte, payload []byte,
) error { ) error {
svc.log.Info("processing webhook", svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
"app", app.Name,
"source", source.String(),
"event", eventType,
)
// Parse payload into normalized push event // Parse payload
pushEvent, parseErr := ParsePushPayload(source, payload) var pushPayload GiteaPushPayload
if parseErr != nil {
svc.log.Warn("failed to parse webhook payload", unmarshalErr := json.Unmarshal(payload, &pushPayload)
"error", parseErr, if unmarshalErr != nil {
"source", source.String(), svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
) // Continue anyway to log the event
// Continue with empty push event to still log the webhook
pushEvent = &PushEvent{Source: source}
} }
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches // Check if branch matches
matched := pushEvent.Branch == app.Branch matched := branch == app.Branch
// Create webhook event record // Create webhook event record
event := models.NewWebhookEvent(svc.db) event := models.NewWebhookEvent(svc.db)
event.AppID = app.ID event.AppID = app.ID
event.EventType = eventType event.EventType = eventType
event.Branch = pushEvent.Branch event.Branch = branch
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""} event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{ event.CommitURL = sql.NullString{String: commitURL.String(), Valid: commitURL != ""}
String: pushEvent.CommitURL.String(),
Valid: pushEvent.CommitURL != "",
}
event.Payload = sql.NullString{String: string(payload), Valid: true} event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched event.Matched = matched
event.Processed = false event.Processed = false
@@ -94,10 +117,9 @@ func (svc *Service) HandleWebhook(
svc.log.Info("webhook event recorded", svc.log.Info("webhook event recorded",
"app", app.Name, "app", app.Name,
"source", source.String(), "branch", branch,
"branch", pushEvent.Branch,
"matched", matched, "matched", matched,
"commit", pushEvent.After, "commit", commitSHA,
) )
// If branch matches, trigger deployment // If branch matches, trigger deployment
@@ -132,3 +154,33 @@ func (svc *Service) triggerDeployment(
_ = event.Save(deployCtx) _ = event.Save(deployCtx)
}() }()
} }
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) UnparsedURL {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}

View File

@@ -3,7 +3,6 @@ package webhook_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -103,114 +102,44 @@ func createTestApp(
return app return app
} }
// TestDetectWebhookSource tests auto-detection of webhook source from HTTP headers.
//
//nolint:funlen // table-driven test with comprehensive test cases //nolint:funlen // table-driven test with comprehensive test cases
func TestDetectWebhookSource(testingT *testing.T) { func TestExtractBranch(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
tests := []struct { tests := []struct {
name string name string
headers map[string]string ref string
expected webhook.Source
}{
{
name: "detects Gitea from X-Gitea-Event header",
headers: map[string]string{"X-Gitea-Event": "push"},
expected: webhook.SourceGitea,
},
{
name: "detects GitHub from X-GitHub-Event header",
headers: map[string]string{"X-GitHub-Event": "push"},
expected: webhook.SourceGitHub,
},
{
name: "detects GitLab from X-Gitlab-Event header",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
expected: webhook.SourceGitLab,
},
{
name: "returns unknown when no recognized header",
headers: map[string]string{"Content-Type": "application/json"},
expected: webhook.SourceUnknown,
},
{
name: "returns unknown for empty headers",
headers: map[string]string{},
expected: webhook.SourceUnknown,
},
{
name: "Gitea takes precedence over GitHub",
headers: map[string]string{
"X-Gitea-Event": "push",
"X-GitHub-Event": "push",
},
expected: webhook.SourceGitea,
},
{
name: "GitHub takes precedence over GitLab",
headers: map[string]string{
"X-GitHub-Event": "push",
"X-Gitlab-Event": "Push Hook",
},
expected: webhook.SourceGitHub,
},
}
for _, testCase := range tests {
testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel()
headers := http.Header{}
for key, value := range testCase.headers {
headers.Set(key, value)
}
result := webhook.DetectWebhookSource(headers)
assert.Equal(t, testCase.expected, result)
})
}
}
// TestDetectEventType tests event type extraction from HTTP headers.
func TestDetectEventType(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
headers map[string]string
source webhook.Source
expected string expected string
}{ }{
{ {
name: "extracts Gitea event type", name: "extracts main branch",
headers: map[string]string{"X-Gitea-Event": "push"}, ref: "refs/heads/main",
source: webhook.SourceGitea, expected: "main",
expected: "push",
}, },
{ {
name: "extracts GitHub event type", name: "extracts feature branch",
headers: map[string]string{"X-GitHub-Event": "push"}, ref: "refs/heads/feature/new-feature",
source: webhook.SourceGitHub, expected: "feature/new-feature",
expected: "push",
}, },
{ {
name: "extracts GitLab event type", name: "extracts develop branch",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"}, ref: "refs/heads/develop",
source: webhook.SourceGitLab, expected: "develop",
expected: "Push Hook",
}, },
{ {
name: "returns push for unknown source", name: "returns raw ref if no prefix",
headers: map[string]string{}, ref: "main",
source: webhook.SourceUnknown, expected: "main",
expected: "push",
}, },
{ {
name: "returns push when header missing for source", name: "handles empty ref",
headers: map[string]string{}, ref: "",
source: webhook.SourceGitea, expected: "",
expected: "push", },
{
name: "handles partial prefix",
ref: "refs/heads/",
expected: "",
}, },
} }
@@ -218,318 +147,123 @@ func TestDetectEventType(testingT *testing.T) {
testingT.Run(testCase.name, func(t *testing.T) { testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel() t.Parallel()
headers := http.Header{} // We test via HandleWebhook since extractBranch is not exported.
for key, value := range testCase.headers { // The test verifies behavior indirectly through the webhook event's branch.
headers.Set(key, value) svc, dbInst, cleanup := setupTestService(t)
} defer cleanup()
result := webhook.DetectEventType(headers, testCase.source) app := createTestApp(t, dbInst, testCase.expected)
assert.Equal(t, testCase.expected, result)
})
}
}
// TestWebhookSourceString tests the String method on WebhookSource. payload := []byte(`{"ref": "` + testCase.ref + `"}`)
func TestWebhookSourceString(t *testing.T) {
t.Parallel()
assert.Equal(t, "gitea", webhook.SourceGitea.String()) err := svc.HandleWebhook(context.Background(), app, "push", payload)
assert.Equal(t, "github", webhook.SourceGitHub.String())
assert.Equal(t, "gitlab", webhook.SourceGitLab.String())
assert.Equal(t, "unknown", webhook.SourceUnknown.String())
}
// TestUnparsedURLString tests the String method on UnparsedURL.
func TestUnparsedURLString(t *testing.T) {
t.Parallel()
u := webhook.UnparsedURL("https://example.com/test")
assert.Equal(t, "https://example.com/test", u.String())
empty := webhook.UnparsedURL("")
assert.Empty(t, empty.String())
}
// TestParsePushPayloadGitea tests parsing of Gitea push payloads.
func TestParsePushPayloadGitea(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"compare_url": "https://gitea.example.com/myorg/myrepo/compare/000...abc",
"repository": {
"full_name": "myorg/myrepo",
"clone_url": "https://gitea.example.com/myorg/myrepo.git",
"ssh_url": "git@gitea.example.com:myorg/myrepo.git",
"html_url": "https://gitea.example.com/myorg/myrepo"
},
"pusher": {"username": "developer", "email": "dev@example.com"},
"commits": [
{
"id": "abc123def456789",
"url": "https://gitea.example.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitea, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// TestParsePushPayloadGitHub tests parsing of GitHub push payloads.
func TestParsePushPayloadGitHub(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"compare": "https://github.com/myorg/myrepo/compare/000...abc",
"repository": {
"full_name": "myorg/myrepo",
"clone_url": "https://github.com/myorg/myrepo.git",
"ssh_url": "git@github.com:myorg/myrepo.git",
"html_url": "https://github.com/myorg/myrepo"
},
"pusher": {"name": "developer", "email": "dev@example.com"},
"head_commit": {
"id": "abc123def456789",
"url": "https://github.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug"
},
"commits": [
{
"id": "abc123def456789",
"url": "https://github.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// TestParsePushPayloadGitLab tests parsing of GitLab push payloads.
func TestParsePushPayloadGitLab(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/develop",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"user_name": "developer",
"user_email": "dev@example.com",
"project": {
"path_with_namespace": "mygroup/myproject",
"git_http_url": "https://gitlab.com/mygroup/myproject.git",
"git_ssh_url": "git@gitlab.com:mygroup/myproject.git",
"web_url": "https://gitlab.com/mygroup/myproject"
},
"commits": [
{
"id": "abc123def456789",
"url": "https://gitlab.com/mygroup/myproject/-/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitLab, event.Source)
assert.Equal(t, "refs/heads/develop", event.Ref)
assert.Equal(t, "develop", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "mygroup/myproject", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/mygroup/myproject/-/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source uses Gitea parser.
func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"repository": {"full_name": "user/repo"},
"pusher": {"username": "user"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceUnknown, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123", event.After)
}
// TestParsePushPayloadInvalidJSON tests that invalid JSON returns an error.
func TestParsePushPayloadInvalidJSON(t *testing.T) {
t.Parallel()
sources := []webhook.Source{
webhook.SourceGitea,
webhook.SourceGitHub,
webhook.SourceGitLab,
}
for _, source := range sources {
t.Run(source.String(), func(t *testing.T) {
t.Parallel()
_, err := webhook.ParsePushPayload(source, []byte(`{invalid json}`))
require.Error(t, err)
})
}
}
// TestParsePushPayloadEmptyPayload tests parsing of empty JSON objects.
func TestParsePushPayloadEmptyPayload(t *testing.T) {
t.Parallel()
sources := []webhook.Source{
webhook.SourceGitea,
webhook.SourceGitHub,
webhook.SourceGitLab,
}
for _, source := range sources {
t.Run(source.String(), func(t *testing.T) {
t.Parallel()
event, err := webhook.ParsePushPayload(source, []byte(`{}`))
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, event.Branch) // Allow async deployment goroutine to complete before test cleanup
assert.Empty(t, event.After) time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, testCase.expected, events[0].Branch)
}) })
} }
} }
// TestGitHubCommitURLFallback tests commit URL extraction fallback paths for GitHub. func TestHandleWebhookMatchingBranch(t *testing.T) {
func TestGitHubCommitURLFallback(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("uses head_commit URL when available", func(t *testing.T) { svc, dbInst, cleanup := setupTestService(t)
t.Parallel() defer cleanup()
payload := []byte(`{ app := createTestApp(t, dbInst, "main")
"ref": "refs/heads/main",
"after": "abc123",
"head_commit": {"id": "abc123", "url": "https://github.com/u/r/commit/abc123"},
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload) payload := []byte(`{
require.NoError(t, err) "ref": "refs/heads/main",
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL) "before": "0000000000000000000000000000000000000000",
}) "after": "abc123def456",
"repository": {
"full_name": "user/repo",
"clone_url": "https://gitea.example.com/user/repo.git",
"ssh_url": "git@gitea.example.com:user/repo.git"
},
"pusher": {"username": "testuser", "email": "test@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit",
"author": {"name": "Test User", "email": "test@example.com"}}]
}`)
t.Run("falls back to commits list", func(t *testing.T) { err := svc.HandleWebhook(context.Background(), app, "push", payload)
t.Parallel() require.NoError(t, err)
payload := []byte(`{ // Allow async deployment goroutine to complete before test cleanup
"ref": "refs/heads/main", time.Sleep(100 * time.Millisecond)
"after": "abc123",
"commits": [{"id": "abc123", "url": "https://github.com/u/r/commit/abc123"}],
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload) events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL) require.Len(t, events, 1)
})
t.Run("constructs URL from repo HTML URL", func(t *testing.T) { event := events[0]
t.Parallel() assert.Equal(t, "push", event.EventType)
assert.Equal(t, "main", event.Branch)
payload := []byte(`{ assert.True(t, event.Matched)
"ref": "refs/heads/main", assert.Equal(t, "abc123def456", event.CommitSHA.String)
"after": "abc123",
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
} }
// TestGitLabCommitURLFallback tests commit URL extraction fallback paths for GitLab. func TestHandleWebhookNonMatchingBranch(t *testing.T) {
func TestGitLabCommitURLFallback(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("uses commit URL from list", func(t *testing.T) { svc, dbInst, cleanup := setupTestService(t)
t.Parallel() defer cleanup()
payload := []byte(`{ app := createTestApp(t, dbInst, "main")
"ref": "refs/heads/main",
"after": "abc123",
"project": {"web_url": "https://gitlab.com/g/p"},
"commits": [{"id": "abc123", "url": "https://gitlab.com/g/p/-/commit/abc123"}]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload) payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL)
})
t.Run("constructs URL from project web URL", func(t *testing.T) { err := svc.HandleWebhook(context.Background(), app, "push", payload)
t.Parallel() require.NoError(t, err)
payload := []byte(`{ events, err := app.GetWebhookEvents(context.Background(), 10)
"ref": "refs/heads/main", require.NoError(t, err)
"after": "abc123", require.Len(t, events, 1)
"project": {"web_url": "https://gitlab.com/g/p"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload) assert.Equal(t, "develop", events[0].Branch)
require.NoError(t, err) assert.False(t, events[0].Matched)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL) }
})
func TestHandleWebhookInvalidJSON(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{invalid json}`))
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
}
func TestHandleWebhookEmptyPayload(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{}`))
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.False(t, events[0].Matched)
} }
// TestGiteaPushPayloadParsing tests direct deserialization of the Gitea payload struct.
func TestGiteaPushPayloadParsing(testingT *testing.T) { func TestGiteaPushPayloadParsing(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -588,354 +322,6 @@ func TestGiteaPushPayloadParsing(testingT *testing.T) {
}) })
} }
// TestGitHubPushPayloadParsing tests direct deserialization of the GitHub payload struct.
func TestGitHubPushPayloadParsing(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000",
"after": "abc123",
"compare": "https://github.com/o/r/compare/000...abc",
"repository": {
"full_name": "o/r",
"clone_url": "https://github.com/o/r.git",
"ssh_url": "git@github.com:o/r.git",
"html_url": "https://github.com/o/r"
},
"pusher": {"name": "octocat", "email": "octocat@github.com"},
"head_commit": {
"id": "abc123",
"url": "https://github.com/o/r/commit/abc123",
"message": "Update README"
},
"commits": [
{
"id": "abc123",
"url": "https://github.com/o/r/commit/abc123",
"message": "Update README",
"author": {"name": "Octocat", "email": "octocat@github.com"}
}
]
}`)
var p webhook.GitHubPushPayload
err := json.Unmarshal(payload, &p)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", p.Ref)
assert.Equal(t, "abc123", p.After)
assert.Equal(t, "o/r", p.Repository.FullName)
assert.Equal(t, "octocat", p.Pusher.Name)
assert.NotNil(t, p.HeadCommit)
assert.Equal(t, "abc123", p.HeadCommit.ID)
assert.Len(t, p.Commits, 1)
}
// TestGitLabPushPayloadParsing tests direct deserialization of the GitLab payload struct.
func TestGitLabPushPayloadParsing(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000",
"after": "abc123",
"user_name": "gitlab-user",
"user_email": "user@gitlab.com",
"project": {
"path_with_namespace": "group/project",
"git_http_url": "https://gitlab.com/group/project.git",
"git_ssh_url": "git@gitlab.com:group/project.git",
"web_url": "https://gitlab.com/group/project"
},
"commits": [
{
"id": "abc123",
"url": "https://gitlab.com/group/project/-/commit/abc123",
"message": "Fix pipeline",
"author": {"name": "GitLab User", "email": "user@gitlab.com"}
}
]
}`)
var p webhook.GitLabPushPayload
err := json.Unmarshal(payload, &p)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", p.Ref)
assert.Equal(t, "abc123", p.After)
assert.Equal(t, "group/project", p.Project.PathWithNamespace)
assert.Equal(t, "gitlab-user", p.UserName)
assert.Len(t, p.Commits, 1)
}
// TestExtractBranch tests branch extraction via HandleWebhook integration (extractBranch is unexported).
//
//nolint:funlen // table-driven test with comprehensive test cases
func TestExtractBranch(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
ref string
expected string
}{
{
name: "extracts main branch",
ref: "refs/heads/main",
expected: "main",
},
{
name: "extracts feature branch",
ref: "refs/heads/feature/new-feature",
expected: "feature/new-feature",
},
{
name: "extracts develop branch",
ref: "refs/heads/develop",
expected: "develop",
},
{
name: "returns raw ref if no prefix",
ref: "main",
expected: "main",
},
{
name: "handles empty ref",
ref: "",
expected: "",
},
{
name: "handles partial prefix",
ref: "refs/heads/",
expected: "",
},
}
for _, testCase := range tests {
testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel()
// We test via HandleWebhook since extractBranch is not exported.
// The test verifies behavior indirectly through the webhook event's branch.
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, testCase.expected)
payload := []byte(`{"ref": "` + testCase.ref + `"}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, testCase.expected, events[0].Branch)
})
}
}
func TestHandleWebhookMatchingBranch(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456",
"repository": {
"full_name": "user/repo",
"clone_url": "https://gitea.example.com/user/repo.git",
"ssh_url": "git@gitea.example.com:user/repo.git"
},
"pusher": {"username": "testuser", "email": "test@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit",
"author": {"name": "Test User", "email": "test@example.com"}}]
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "push", event.EventType)
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "abc123def456", event.CommitSHA.String)
}
func TestHandleWebhookNonMatchingBranch(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "develop", events[0].Branch)
assert.False(t, events[0].Matched)
}
func TestHandleWebhookInvalidJSON(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", []byte(`{invalid json}`),
)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
}
func TestHandleWebhookEmptyPayload(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, "push", []byte(`{}`),
)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.False(t, events[0].Matched)
}
// TestHandleWebhookGitHubSource tests HandleWebhook with a GitHub push payload.
func TestHandleWebhookGitHubSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"after": "github123",
"repository": {
"full_name": "org/repo",
"clone_url": "https://github.com/org/repo.git",
"html_url": "https://github.com/org/repo"
},
"pusher": {"name": "octocat", "email": "octocat@github.com"},
"head_commit": {
"id": "github123",
"url": "https://github.com/org/repo/commit/github123",
"message": "Update feature"
}
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitHub, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "github123", event.CommitSHA.String)
assert.Equal(t, "https://github.com/org/repo/commit/github123", event.CommitURL.String)
}
// TestHandleWebhookGitLabSource tests HandleWebhook with a GitLab push payload.
func TestHandleWebhookGitLabSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"after": "gitlab456",
"user_name": "gitlab-dev",
"user_email": "dev@gitlab.com",
"project": {
"path_with_namespace": "group/project",
"git_http_url": "https://gitlab.com/group/project.git",
"web_url": "https://gitlab.com/group/project"
},
"commits": [
{
"id": "gitlab456",
"url": "https://gitlab.com/group/project/-/commit/gitlab456",
"message": "Deploy fix"
}
]
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitLab, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "gitlab456", event.CommitSHA.String)
assert.Equal(t, "https://gitlab.com/group/project/-/commit/gitlab456", event.CommitURL.String)
}
// TestSetupTestService verifies the test helper creates a working test service. // TestSetupTestService verifies the test helper creates a working test service.
func TestSetupTestService(testingT *testing.T) { func TestSetupTestService(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -955,25 +341,3 @@ func TestSetupTestService(testingT *testing.T) {
require.NoError(t, err) require.NoError(t, err)
}) })
} }
// TestPushEventConstruction tests that PushEvent can be constructed directly.
func TestPushEventConstruction(t *testing.T) {
t.Parallel()
event := webhook.PushEvent{
Source: webhook.SourceGitHub,
Ref: "refs/heads/main",
Before: "000",
After: "abc",
Branch: "main",
RepoName: "org/repo",
CloneURL: webhook.UnparsedURL("https://github.com/org/repo.git"),
HTMLURL: webhook.UnparsedURL("https://github.com/org/repo"),
CommitURL: webhook.UnparsedURL("https://github.com/org/repo/commit/abc"),
Pusher: "user",
}
assert.Equal(t, "main", event.Branch)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "abc", event.After)
}

View File

@@ -114,6 +114,19 @@
> >
</div> </div>
<div class="form-group">
<label for="healthcheck_command" class="label">Health Check Command</label>
<input
type="text"
id="healthcheck_command"
name="healthcheck_command"
value="{{if .App.HealthcheckCommand.Valid}}{{.App.HealthcheckCommand.String}}{{end}}"
class="input font-mono"
placeholder="curl -f http://localhost:8080/healthz || exit 1"
>
<p class="text-sm text-gray-500 mt-1">Custom shell command to check container health. Leave empty to use the image's default health check.</p>
</div>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<a href="/apps/{{.App.ID}}" class="btn-secondary">Cancel</a> <a href="/apps/{{.App.ID}}" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Save Changes</button> <button type="submit" class="btn-primary">Save Changes</button>

View File

@@ -117,6 +117,19 @@
> >
</div> </div>
<div class="form-group">
<label for="healthcheck_command" class="label">Health Check Command</label>
<input
type="text"
id="healthcheck_command"
name="healthcheck_command"
value="{{.HealthcheckCommand}}"
class="input font-mono"
placeholder="curl -f http://localhost:8080/healthz || exit 1"
>
<p class="text-sm text-gray-500 mt-1">Custom shell command to check container health. Leave empty to use the image's default health check.</p>
</div>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<a href="/" class="btn-secondary">Cancel</a> <a href="/" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Create App</button> <button type="submit" class="btn-primary">Create App</button>