Compare commits

..

2 Commits

Author SHA1 Message Date
clawbot
9627942573 fix: move writeLogsToFile doc comment to correct position
All checks were successful
Check / check (pull_request) Successful in 3m16s
The buildRegistryAuths function was inserted between the writeLogsToFile
doc comment and the writeLogsToFile function body, causing Go to treat
the writeLogsToFile comment as part of buildRegistryAuths godoc.

Move the writeLogsToFile comment to sit directly above its function,
and keep only the buildRegistryAuths comment above buildRegistryAuths.
2026-03-17 02:39:38 -07:00
user
0f4acb554e feat: add private Docker registry authentication for base images
All checks were successful
Check / check (pull_request) Successful in 3m34s
Add per-app registry credentials that are passed to Docker during image
builds, allowing apps to use base images from private registries.

- New registry_credentials table (migration 007)
- RegistryCredential model with full CRUD operations
- Docker client passes AuthConfigs to ImageBuild when credentials exist
- Deploy service fetches app registry credentials before builds
- Web UI section for managing registry credentials (add/edit/delete)
- Comprehensive unit tests for model and auth config builder
- README updated to list the feature
2026-03-17 02:14:39 -07:00
16 changed files with 850 additions and 1132 deletions

View File

@@ -1,14 +1,15 @@
# µ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
- Single admin user with argon2id password hashing
- 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
- Environment variables, labels, and volume mounts per app
- Private Docker registry authentication for base images
- Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks
- Simple server-rendered UI with Tailwind CSS
@@ -19,7 +20,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
- Complex CI pipelines
- Multiple container orchestration
- SPA/API-first design
- Support for non-push webhook events (e.g. issues, merge requests)
- Support for non-Gitea webhooks
## Architecture
@@ -44,7 +45,7 @@ upaas/
│ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab)
│ │ └── webhook/ # Gitea webhook processing
│ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates

View File

@@ -0,0 +1,11 @@
-- Add registry credentials for private Docker registry authentication during builds
CREATE TABLE registry_credentials (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
registry TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
UNIQUE(app_id, registry)
);
CREATE INDEX idx_registry_credentials_app_id ON registry_credentials(app_id);

View File

@@ -0,0 +1,96 @@
package docker //nolint:testpackage // tests unexported buildAuthConfigs
import (
"testing"
)
func TestBuildAuthConfigsEmpty(t *testing.T) {
t.Parallel()
result := buildAuthConfigs(nil)
if len(result) != 0 {
t.Errorf("expected empty map, got %d entries", len(result))
}
}
func TestBuildAuthConfigsSingle(t *testing.T) {
t.Parallel()
auths := []RegistryAuth{
{
Registry: "registry.example.com",
Username: "user",
Password: "pass",
},
}
result := buildAuthConfigs(auths)
if len(result) != 1 {
t.Fatalf("expected 1 entry, got %d", len(result))
}
cfg, ok := result["registry.example.com"]
if !ok {
t.Fatal("expected registry.example.com key")
}
if cfg.Username != "user" {
t.Errorf("expected username 'user', got %q", cfg.Username)
}
if cfg.Password != "pass" {
t.Errorf("expected password 'pass', got %q", cfg.Password)
}
if cfg.ServerAddress != "registry.example.com" {
t.Errorf("expected server address 'registry.example.com', got %q", cfg.ServerAddress)
}
}
func TestBuildAuthConfigsMultiple(t *testing.T) {
t.Parallel()
auths := []RegistryAuth{
{Registry: "ghcr.io", Username: "ghuser", Password: "ghtoken"},
{Registry: "docker.io", Username: "dkuser", Password: "dktoken"},
}
result := buildAuthConfigs(auths)
if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result))
}
ghcr := result["ghcr.io"]
if ghcr.Username != "ghuser" || ghcr.Password != "ghtoken" {
t.Errorf("unexpected ghcr.io config: %+v", ghcr)
}
dkr := result["docker.io"]
if dkr.Username != "dkuser" || dkr.Password != "dktoken" {
t.Errorf("unexpected docker.io config: %+v", dkr)
}
}
func TestRegistryAuthStruct(t *testing.T) {
t.Parallel()
auth := RegistryAuth{
Registry: "registry.example.com",
Username: "testuser",
Password: "testpass",
}
if auth.Registry != "registry.example.com" {
t.Errorf("expected registry 'registry.example.com', got %q", auth.Registry)
}
if auth.Username != "testuser" {
t.Errorf("expected username 'testuser', got %q", auth.Username)
}
if auth.Password != "testpass" {
t.Errorf("expected password 'testpass', got %q", auth.Password)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/go-connections/nat"
@@ -105,12 +106,20 @@ func (c *Client) IsConnected() bool {
return c.docker != nil
}
// RegistryAuth contains authentication credentials for a Docker registry.
type RegistryAuth struct {
Registry string
Username string
Password string //nolint:gosec // credential field required for registry auth
}
// BuildImageOptions contains options for building an image.
type BuildImageOptions struct {
ContextDir string
DockerfilePath string
Tags []string
LogWriter io.Writer // Optional writer for build output
RegistryAuths []RegistryAuth // Optional registry credentials for pulling private base images
}
// BuildImage builds a Docker image from a context directory.
@@ -161,6 +170,21 @@ type PortMapping struct {
Protocol string // "tcp" or "udp"
}
// buildAuthConfigs converts RegistryAuth slices into Docker's AuthConfigs map.
func buildAuthConfigs(auths []RegistryAuth) map[string]registry.AuthConfig {
configs := make(map[string]registry.AuthConfig, len(auths))
for _, auth := range auths {
configs[auth.Registry] = registry.AuthConfig{
Username: auth.Username,
Password: auth.Password,
ServerAddress: auth.Registry,
}
}
return configs
}
// buildPortConfig converts port mappings to Docker port configuration.
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
exposedPorts := make(nat.PortSet)
@@ -513,12 +537,18 @@ func (c *Client) performBuild(
}()
// Build image
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
buildOpts := dockertypes.ImageBuildOptions{
Dockerfile: opts.DockerfilePath,
Tags: opts.Tags,
Remove: true,
NoCache: false,
})
}
if len(opts.RegistryAuths) > 0 {
buildOpts.AuthConfigs = buildAuthConfigs(opts.RegistryAuths)
}
resp, err := c.docker.ImageBuild(ctx, tarArchive, buildOpts)
if err != nil {
return "", fmt.Errorf("failed to build image: %w", err)
}

View File

@@ -148,6 +148,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
labels, _ := application.GetLabels(request.Context())
volumes, _ := application.GetVolumes(request.Context())
ports, _ := application.GetPorts(request.Context())
registryCreds, _ := application.GetRegistryCredentials(request.Context())
deployments, _ := application.GetDeployments(
request.Context(),
recentDeploymentsLimit,
@@ -168,6 +169,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
"Labels": labels,
"Volumes": volumes,
"Ports": ports,
"RegistryCredentials": registryCreds,
"Deployments": deployments,
"LatestDeployment": latestDeployment,
"WebhookURL": webhookURL,
@@ -1382,3 +1384,126 @@ func formatDeployKey(pubKey string, createdAt time.Time, appName string) string
return parts[0] + " " + parts[1] + " " + comment
}
// HandleRegistryCredentialAdd handles adding a registry credential.
func (h *Handlers) HandleRegistryCredentialAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
registryURL := strings.TrimSpace(request.FormValue("registry"))
username := strings.TrimSpace(request.FormValue("username"))
password := request.FormValue("password")
if registryURL == "" || username == "" || password == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
cred := models.NewRegistryCredential(h.db)
cred.AppID = appID
cred.Registry = registryURL
cred.Username = username
cred.Password = password
saveErr := cred.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to save registry credential", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleRegistryCredentialEdit handles editing an existing registry credential.
func (h *Handlers) HandleRegistryCredentialEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
credIDStr := chi.URLParam(request, "credID")
credID, parseErr := strconv.ParseInt(credIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
cred, findErr := models.FindRegistryCredential(request.Context(), h.db, credID)
if findErr != nil || cred == nil || cred.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
registryURL := strings.TrimSpace(request.FormValue("registry"))
username := strings.TrimSpace(request.FormValue("username"))
password := request.FormValue("password")
if registryURL == "" || username == "" || password == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
cred.Registry = registryURL
cred.Username = username
cred.Password = password
saveErr := cred.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update registry credential", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleRegistryCredentialDelete handles deleting a registry credential.
func (h *Handlers) HandleRegistryCredentialDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
credIDStr := chi.URLParam(request, "credID")
credID, parseErr := strconv.ParseInt(credIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
cred, findErr := models.FindRegistryCredential(request.Context(), h.db, credID)
if findErr != nil || cred == nil || cred.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := cred.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete registry credential", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}

View File

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

View File

@@ -119,6 +119,11 @@ func (a *App) GetWebhookEvents(
return FindWebhookEventsByAppID(ctx, a.db, a.ID, limit)
}
// GetRegistryCredentials returns all registry credentials for the app.
func (a *App) GetRegistryCredentials(ctx context.Context) ([]*RegistryCredential, error) {
return FindRegistryCredentialsByAppID(ctx, a.db, a.ID)
}
func (a *App) exists(ctx context.Context) bool {
if a.ID == "" {
return false

View File

@@ -23,6 +23,7 @@ const (
testBranch = "main"
testValue = "value"
testEventType = "push"
testUser = "user"
)
func setupTestDB(t *testing.T) (*database.Database, func()) {
@@ -704,6 +705,127 @@ func TestAppGetWebhookEvents(t *testing.T) {
assert.Len(t, events, 1)
}
// RegistryCredential Tests.
func TestRegistryCredentialCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
cred := models.NewRegistryCredential(testDB)
cred.AppID = app.ID
cred.Registry = "registry.example.com"
cred.Username = "myuser"
cred.Password = "mypassword"
err := cred.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, cred.ID)
creds, err := models.FindRegistryCredentialsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, creds, 1)
assert.Equal(t, "registry.example.com", creds[0].Registry)
assert.Equal(t, "myuser", creds[0].Username)
assert.Equal(t, "mypassword", creds[0].Password)
}
func TestRegistryCredentialUpdate(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
cred := models.NewRegistryCredential(testDB)
cred.AppID = app.ID
cred.Registry = "old.registry.com"
cred.Username = "olduser"
cred.Password = "oldpass"
err := cred.Save(context.Background())
require.NoError(t, err)
cred.Registry = "new.registry.com"
cred.Username = "newuser"
cred.Password = "newpass"
err = cred.Save(context.Background())
require.NoError(t, err)
found, err := models.FindRegistryCredential(context.Background(), testDB, cred.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, "new.registry.com", found.Registry)
assert.Equal(t, "newuser", found.Username)
assert.Equal(t, "newpass", found.Password)
}
func TestRegistryCredentialDelete(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
cred := models.NewRegistryCredential(testDB)
cred.AppID = app.ID
cred.Registry = "delete.registry.com"
cred.Username = testUser
cred.Password = "pass"
err := cred.Save(context.Background())
require.NoError(t, err)
err = cred.Delete(context.Background())
require.NoError(t, err)
creds, err := models.FindRegistryCredentialsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
assert.Empty(t, creds)
}
func TestRegistryCredentialFindByIDNotFound(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
found, err := models.FindRegistryCredential(context.Background(), testDB, 99999)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestAppGetRegistryCredentials(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
cred := models.NewRegistryCredential(testDB)
cred.AppID = app.ID
cred.Registry = "ghcr.io"
cred.Username = testUser
cred.Password = "token"
_ = cred.Save(context.Background())
creds, err := app.GetRegistryCredentials(context.Background())
require.NoError(t, err)
assert.Len(t, creds, 1)
assert.Equal(t, "ghcr.io", creds[0].Registry)
}
// Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests
@@ -749,6 +871,13 @@ func TestCascadeDelete(t *testing.T) {
deploy.Status = models.DeploymentStatusSuccess
_ = deploy.Save(context.Background())
regCred := models.NewRegistryCredential(testDB)
regCred.AppID = app.ID
regCred.Registry = "registry.example.com"
regCred.Username = testUser
regCred.Password = "pass"
_ = regCred.Save(context.Background())
// Delete app.
err := app.Delete(context.Background())
require.NoError(t, err)
@@ -778,6 +907,11 @@ func TestCascadeDelete(t *testing.T) {
context.Background(), testDB, app.ID, 10,
)
assert.Empty(t, deployments)
regCreds, _ := models.FindRegistryCredentialsByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, regCreds)
})
}

View File

@@ -0,0 +1,130 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"sneak.berlin/go/upaas/internal/database"
)
// RegistryCredential represents authentication credentials for a private Docker registry.
type RegistryCredential struct {
db *database.Database
ID int64
AppID string
Registry string
Username string
Password string //nolint:gosec // credential field required for registry auth
}
// NewRegistryCredential creates a new RegistryCredential with a database reference.
func NewRegistryCredential(db *database.Database) *RegistryCredential {
return &RegistryCredential{db: db}
}
// Save inserts or updates the registry credential in the database.
func (r *RegistryCredential) Save(ctx context.Context) error {
if r.ID == 0 {
return r.insert(ctx)
}
return r.update(ctx)
}
// Delete removes the registry credential from the database.
func (r *RegistryCredential) Delete(ctx context.Context) error {
_, err := r.db.Exec(ctx, "DELETE FROM registry_credentials WHERE id = ?", r.ID)
return err
}
func (r *RegistryCredential) insert(ctx context.Context) error {
query := "INSERT INTO registry_credentials (app_id, registry, username, password) VALUES (?, ?, ?, ?)"
result, err := r.db.Exec(ctx, query, r.AppID, r.Registry, r.Username, r.Password)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
r.ID = id
return nil
}
func (r *RegistryCredential) update(ctx context.Context) error {
query := "UPDATE registry_credentials SET registry = ?, username = ?, password = ? WHERE id = ?"
_, err := r.db.Exec(ctx, query, r.Registry, r.Username, r.Password, r.ID)
return err
}
// FindRegistryCredential finds a registry credential by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindRegistryCredential(
ctx context.Context,
db *database.Database,
id int64,
) (*RegistryCredential, error) {
cred := NewRegistryCredential(db)
row := db.QueryRow(ctx,
"SELECT id, app_id, registry, username, password FROM registry_credentials WHERE id = ?",
id,
)
err := row.Scan(&cred.ID, &cred.AppID, &cred.Registry, &cred.Username, &cred.Password)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning registry credential: %w", err)
}
return cred, nil
}
// FindRegistryCredentialsByAppID finds all registry credentials for an app.
func FindRegistryCredentialsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) ([]*RegistryCredential, error) {
query := `
SELECT id, app_id, registry, username, password FROM registry_credentials
WHERE app_id = ? ORDER BY registry`
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying registry credentials by app: %w", err)
}
defer func() { _ = rows.Close() }()
var creds []*RegistryCredential
for rows.Next() {
cred := NewRegistryCredential(db)
scanErr := rows.Scan(
&cred.ID, &cred.AppID, &cred.Registry, &cred.Username, &cred.Password,
)
if scanErr != nil {
return nil, scanErr
}
creds = append(creds, cred)
}
return creds, rows.Err()
}

View File

@@ -98,6 +98,11 @@ func (s *Server) SetupRoutes() {
// Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
// Registry Credentials
r.Post("/apps/{id}/registry-credentials", s.handlers.HandleRegistryCredentialAdd())
r.Post("/apps/{id}/registry-credentials/{credID}/edit", s.handlers.HandleRegistryCredentialEdit())
r.Post("/apps/{id}/registry-credentials/{credID}/delete", s.handlers.HandleRegistryCredentialDelete())
})
})

View File

@@ -830,6 +830,13 @@ func (svc *Service) buildImage(
logWriter := newDeploymentLogWriter(ctx, deployment)
defer logWriter.Close()
// Fetch registry credentials for private base images
registryAuths, err := svc.buildRegistryAuths(ctx, app)
if err != nil {
svc.log.Warn("failed to fetch registry credentials", "error", err, "app", app.Name)
// Continue without auth — public images will still work
}
// BuildImage creates a tar archive from the local filesystem,
// so it needs the container path where files exist, not the host path.
imageID, err := svc.docker.BuildImage(ctx, docker.BuildImageOptions{
@@ -837,6 +844,7 @@ func (svc *Service) buildImage(
DockerfilePath: app.DockerfilePath,
Tags: []string{imageTag},
LogWriter: logWriter,
RegistryAuths: registryAuths,
})
if err != nil {
svc.notify.NotifyBuildFailed(ctx, app, deployment, err)
@@ -1227,6 +1235,34 @@ func (svc *Service) failDeployment(
_ = app.Save(ctx)
}
// buildRegistryAuths fetches registry credentials for an app and converts them
// to Docker RegistryAuth objects for use during image builds.
func (svc *Service) buildRegistryAuths(
ctx context.Context,
app *models.App,
) ([]docker.RegistryAuth, error) {
creds, err := app.GetRegistryCredentials(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get registry credentials: %w", err)
}
if len(creds) == 0 {
return nil, nil
}
auths := make([]docker.RegistryAuth, 0, len(creds))
for _, cred := range creds {
auths = append(auths, docker.RegistryAuth{
Registry: cred.Registry,
Username: cred.Username,
Password: cred.Password,
})
}
return auths, nil
}
// writeLogsToFile writes the deployment logs to a file on disk.
// Structure: DataDir/logs/<hostname>/<appname>/<appname>_<sha>_<timestamp>.log.txt
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {

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
import "net/http"
// 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
// 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.
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 (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
@@ -43,46 +44,68 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
}, nil
}
// HandleWebhook processes a webhook request from any supported source
// (Gitea, GitHub, or GitLab). The source parameter determines which
// payload format to use for parsing.
// 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"`
}
// HandleWebhook processes a webhook request.
func (svc *Service) HandleWebhook(
ctx context.Context,
app *models.App,
source Source,
eventType string,
payload []byte,
) error {
svc.log.Info("processing webhook",
"app", app.Name,
"source", source.String(),
"event", eventType,
)
svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
// Parse payload into normalized push event
pushEvent, parseErr := ParsePushPayload(source, payload)
if parseErr != nil {
svc.log.Warn("failed to parse webhook payload",
"error", parseErr,
"source", source.String(),
)
// Continue with empty push event to still log the webhook
pushEvent = &PushEvent{Source: source}
// Parse payload
var pushPayload GiteaPushPayload
unmarshalErr := json.Unmarshal(payload, &pushPayload)
if unmarshalErr != nil {
svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
// Continue anyway to log the event
}
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches
matched := pushEvent.Branch == app.Branch
matched := branch == app.Branch
// Create webhook event record
event := models.NewWebhookEvent(svc.db)
event.AppID = app.ID
event.EventType = eventType
event.Branch = pushEvent.Branch
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""}
event.CommitURL = sql.NullString{
String: pushEvent.CommitURL.String(),
Valid: pushEvent.CommitURL != "",
}
event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{String: commitURL.String(), Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
@@ -94,10 +117,9 @@ func (svc *Service) HandleWebhook(
svc.log.Info("webhook event recorded",
"app", app.Name,
"source", source.String(),
"branch", pushEvent.Branch,
"branch", branch,
"matched", matched,
"commit", pushEvent.After,
"commit", commitSHA,
)
// If branch matches, trigger deployment
@@ -132,3 +154,33 @@ func (svc *Service) triggerDeployment(
_ = 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 (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
@@ -103,114 +102,44 @@ func createTestApp(
return app
}
// TestDetectWebhookSource tests auto-detection of webhook source from HTTP headers.
//
//nolint:funlen // table-driven test with comprehensive test cases
func TestDetectWebhookSource(testingT *testing.T) {
func TestExtractBranch(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
headers map[string]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
ref string
expected string
}{
{
name: "extracts Gitea event type",
headers: map[string]string{"X-Gitea-Event": "push"},
source: webhook.SourceGitea,
expected: "push",
name: "extracts main branch",
ref: "refs/heads/main",
expected: "main",
},
{
name: "extracts GitHub event type",
headers: map[string]string{"X-GitHub-Event": "push"},
source: webhook.SourceGitHub,
expected: "push",
name: "extracts feature branch",
ref: "refs/heads/feature/new-feature",
expected: "feature/new-feature",
},
{
name: "extracts GitLab event type",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
source: webhook.SourceGitLab,
expected: "Push Hook",
name: "extracts develop branch",
ref: "refs/heads/develop",
expected: "develop",
},
{
name: "returns push for unknown source",
headers: map[string]string{},
source: webhook.SourceUnknown,
expected: "push",
name: "returns raw ref if no prefix",
ref: "main",
expected: "main",
},
{
name: "returns push when header missing for source",
headers: map[string]string{},
source: webhook.SourceGitea,
expected: "push",
name: "handles empty ref",
ref: "",
expected: "",
},
{
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) {
t.Parallel()
headers := http.Header{}
for key, value := range testCase.headers {
headers.Set(key, value)
}
// 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()
result := webhook.DetectEventType(headers, testCase.source)
assert.Equal(t, testCase.expected, result)
app := createTestApp(t, dbInst, testCase.expected)
payload := []byte(`{"ref": "` + testCase.ref + `"}`)
err := svc.HandleWebhook(context.Background(), app, "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)
})
}
}
// TestWebhookSourceString tests the String method on WebhookSource.
func TestWebhookSourceString(t *testing.T) {
func TestHandleWebhookMatchingBranch(t *testing.T) {
t.Parallel()
assert.Equal(t, "gitea", webhook.SourceGitea.String())
assert.Equal(t, "github", webhook.SourceGitHub.String())
assert.Equal(t, "gitlab", webhook.SourceGitLab.String())
assert.Equal(t, "unknown", webhook.SourceUnknown.String())
}
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
// 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()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"compare_url": "https://gitea.example.com/myorg/myrepo/compare/000...abc",
"after": "abc123def456",
"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"
"full_name": "user/repo",
"clone_url": "https://gitea.example.com/user/repo.git",
"ssh_url": "git@gitea.example.com:user/repo.git"
},
"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"}
}
]
"pusher": {"username": "testuser", "email": "test@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit",
"author": {"name": "Test User", "email": "test@example.com"}}]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitea, payload)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
// 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.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)
assert.True(t, event.Matched)
assert.Equal(t, "abc123def456", event.CommitSHA.String)
}
// TestParsePushPayloadGitHub tests parsing of GitHub push payloads.
func TestParsePushPayloadGitHub(t *testing.T) {
func TestHandleWebhookNonMatchingBranch(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"}
}
]
}`)
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
app := createTestApp(t, dbInst, "main")
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook(context.Background(), app, "push", 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)
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)
}
// TestParsePushPayloadGitLab tests parsing of GitLab push payloads.
func TestParsePushPayloadGitLab(t *testing.T) {
func TestHandleWebhookInvalidJSON(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"}
}
]
}`)
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{invalid json}`))
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)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
}
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source uses Gitea parser.
func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
func TestHandleWebhookEmptyPayload(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"repository": {"full_name": "user/repo"},
"pusher": {"username": "user"}
}`)
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
event, err := webhook.ParsePushPayload(webhook.SourceUnknown, payload)
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{}`))
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(`{}`))
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
assert.Empty(t, event.Branch)
assert.Empty(t, event.After)
})
}
require.Len(t, events, 1)
assert.False(t, events[0].Matched)
}
// TestGitHubCommitURLFallback tests commit URL extraction fallback paths for GitHub.
func TestGitHubCommitURLFallback(t *testing.T) {
t.Parallel()
t.Run("uses head_commit URL when available", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"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)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
t.Run("falls back to commits list", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"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)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
t.Run("constructs URL from repo HTML URL", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"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 TestGitLabCommitURLFallback(t *testing.T) {
t.Parallel()
t.Run("uses commit URL from list", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"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)
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) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"project": {"web_url": "https://gitlab.com/g/p"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL)
})
}
// TestGiteaPushPayloadParsing tests direct deserialization of the Gitea payload struct.
func TestGiteaPushPayloadParsing(testingT *testing.T) {
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.
func TestSetupTestService(testingT *testing.T) {
testingT.Parallel()
@@ -955,25 +341,3 @@ func TestSetupTestService(testingT *testing.T) {
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

@@ -154,6 +154,69 @@
<div class="hidden">{{ .CSRFField }}</div>
</div>
<!-- Registry Credentials -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Registry Credentials</h2>
<p class="text-sm text-gray-500 mb-3">Authenticate to private Docker registries when pulling base images during builds.</p>
{{if .RegistryCredentials}}
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
<tr>
<th>Registry</th>
<th>Username</th>
<th>Password</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="table-body">
{{range .RegistryCredentials}}
<tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono">{{.Registry}}</td>
</template>
<template x-if="!editing">
<td class="font-mono">{{.Username}}</td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-400">••••••••</td>
</template>
<template x-if="!editing">
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/registry-credentials/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this registry credential?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</template>
<template x-if="editing">
<td colspan="4">
<form method="POST" action="/apps/{{$.App.ID}}/registry-credentials/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="registry" value="{{.Registry}}" required class="input flex-1 font-mono text-sm" placeholder="registry.example.com">
<input type="text" name="username" value="{{.Username}}" required class="input flex-1 font-mono text-sm" placeholder="username">
<input type="password" name="password" required class="input flex-1 font-mono text-sm" placeholder="password">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="POST" action="/apps/{{.App.ID}}/registry-credentials" class="flex flex-col sm:flex-row gap-2">
{{ .CSRFField }}
<input type="text" name="registry" placeholder="registry.example.com" required class="input flex-1 font-mono text-sm">
<input type="text" name="username" placeholder="username" required class="input flex-1 font-mono text-sm">
<input type="password" name="password" placeholder="password" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button>
</form>
</div>
<!-- Labels -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Docker Labels</h2>