Compare commits

..

1 Commits

Author SHA1 Message Date
user
0c5c727e01 feat: add GitHub and GitLab webhook support
All checks were successful
Check / check (pull_request) Successful in 1m50s
Add auto-detection of webhook source (Gitea, GitHub, GitLab) by
examining HTTP headers (X-Gitea-Event, X-GitHub-Event, X-Gitlab-Event).

Parse push webhook payloads from all three platforms into a normalized
PushEvent type for unified processing. Each platform's payload format
is handled by dedicated parser functions with correct field mapping
and commit URL extraction.

The webhook handler now detects the source automatically — existing
Gitea webhooks continue to work unchanged, while GitHub and GitLab
webhooks are parsed with their respective payload formats.

Includes comprehensive tests for source detection, event type
extraction, payload parsing for all three platforms, commit URL
fallback logic, and integration tests via HandleWebhook.
2026-03-17 02:37:29 -07:00
24 changed files with 1158 additions and 1631 deletions

View File

@@ -1,12 +1,12 @@
# µ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 Gitea webhooks. A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab.
## 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 for Gitea integration - Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab
- 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, and volume mounts per app
- Docker builds via socket access - Docker builds via socket access
@@ -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-Gitea webhooks - Support for non-push webhook events (e.g. issues, merge requests)
## Architecture ## Architecture
@@ -36,17 +36,15 @@ upaas/
│ ├── handlers/ # HTTP request handlers │ ├── handlers/ # HTTP request handlers
│ ├── healthcheck/ # Health status service │ ├── healthcheck/ # Health status service
│ ├── logger/ # Structured logging (slog) │ ├── logger/ # Structured logging (slog)
│ ├── metrics/ # Prometheus metrics registration │ ├── middleware/ # HTTP middleware (auth, logging, CORS)
│ ├── middleware/ # HTTP middleware (auth, logging, CORS, metrics)
│ ├── models/ # Active Record style database models │ ├── models/ # Active Record style database models
│ ├── server/ # HTTP server and routes │ ├── server/ # HTTP server and routes
│ ├── service/ │ ├── service/
│ │ ├── app/ # App management service │ │ ├── app/ # App management service
│ │ ├── audit/ # Audit logging service
│ │ ├── auth/ # Authentication service │ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration │ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack) │ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Gitea webhook processing │ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab)
│ └── 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
@@ -60,18 +58,16 @@ Uses Uber fx for dependency injection. Components are wired in this order:
2. `logger` - Structured logging 2. `logger` - Structured logging
3. `config` - Configuration loading 3. `config` - Configuration loading
4. `database` - SQLite connection + migrations 4. `database` - SQLite connection + migrations
5. `metrics` - Prometheus metrics registration 5. `healthcheck` - Health status
6. `healthcheck` - Health status 6. `auth` - Authentication service
7. `auth` - Authentication service 7. `app` - App management
8. `app` - App management 8. `docker` - Docker client
9. `docker` - Docker client 9. `notify` - Notification service
10. `notify` - Notification service 10. `deploy` - Deployment service
11. `audit` - Audit logging service 11. `webhook` - Webhook processing
12. `deploy` - Deployment service 12. `middleware` - HTTP middleware
13. `webhook` - Webhook processing 13. `handlers` - HTTP handlers
14. `middleware` - HTTP middleware 14. `server` - HTTP server
15. `handlers` - HTTP handlers
16. `server` - HTTP server
### Request Flow ### Request Flow
@@ -215,48 +211,6 @@ Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.
## Observability
### Prometheus Metrics
All custom metrics are exposed under the `upaas_` namespace at `/metrics`. The
endpoint is always available and can be optionally protected with basic auth via
`METRICS_USERNAME` and `METRICS_PASSWORD`.
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `upaas_deployments_total` | Counter | `app`, `status` | Total deployments (success/failed/cancelled) |
| `upaas_deployments_duration_seconds` | Histogram | `app`, `status` | Deployment duration |
| `upaas_deployments_in_flight` | Gauge | `app` | Currently running deployments |
| `upaas_container_healthy` | Gauge | `app` | Container health (1=healthy, 0=unhealthy) |
| `upaas_webhook_events_total` | Counter | `app`, `event_type`, `matched` | Webhook events received |
| `upaas_http_requests_total` | Counter | `method`, `status_code` | HTTP requests |
| `upaas_http_request_duration_seconds` | Histogram | `method` | HTTP request latency |
| `upaas_http_response_size_bytes` | Histogram | `method` | HTTP response sizes |
| `upaas_audit_events_total` | Counter | `action` | Audit log events |
### Audit Log
All user-facing actions are recorded in an `audit_log` SQLite table with:
- **Who**: user ID and username
- **What**: action type and affected resource (app, deployment, session, etc.)
- **Where**: client IP (via X-Real-IP/X-Forwarded-For/RemoteAddr)
- **When**: timestamp
Audited actions include login/logout, app CRUD, deployments, container
start/stop/restart, rollbacks, deployment cancellation, and webhook receipt.
The audit log is available via the API at `GET /api/v1/audit?limit=N` (max 500,
default 50).
### Structured Logging
All operations use Go's `slog` structured logger. HTTP requests are logged with
method, URL, status code, response size, latency, user agent, and client IP.
Deployment events are logged with app name, status, and duration. Audit events
are also logged to stdout for correlation with external log aggregators.
## License ## License
WTFPL WTFPL

View File

@@ -11,11 +11,9 @@ import (
"sneak.berlin/go/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/middleware" "sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/server" "sneak.berlin/go/upaas/internal/server"
"sneak.berlin/go/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/audit"
"sneak.berlin/go/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/notify"
@@ -43,7 +41,6 @@ func main() {
logger.New, logger.New,
config.New, config.New,
database.New, database.New,
metrics.New,
healthcheck.New, healthcheck.New,
auth.New, auth.New,
app.New, app.New,
@@ -51,7 +48,6 @@ func main() {
notify.New, notify.New,
deploy.New, deploy.New,
webhook.New, webhook.New,
audit.New,
middleware.New, middleware.New,
handlers.New, handlers.New,
server.New, server.New,

View File

@@ -1,16 +0,0 @@
-- Audit log table for tracking user actions.
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
user_id INTEGER,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
detail TEXT,
remote_ip TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX idx_audit_log_action ON audit_log(action);
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
@@ -121,9 +120,6 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionLogin,
models.AuditResourceSession, "", "api login")
h.respondJSON(writer, request, loginResponse{ h.respondJSON(writer, request, loginResponse{
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,
@@ -247,79 +243,3 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
}, http.StatusOK) }, http.StatusOK)
} }
} }
// auditLogDefaultLimit is the default number of audit entries returned.
const auditLogDefaultLimit = 50
// auditLogMaxLimit is the maximum number of audit entries returned.
const auditLogMaxLimit = 500
// HandleAPIAuditLog returns a handler that lists recent audit log entries.
func (h *Handlers) HandleAPIAuditLog() http.HandlerFunc {
type auditEntryResponse struct {
ID int64 `json:"id"`
UserID *int64 `json:"userId,omitempty"`
Username string `json:"username"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceID string `json:"resourceId,omitempty"`
Detail string `json:"detail,omitempty"`
RemoteIP string `json:"remoteIp,omitempty"`
CreatedAt string `json:"createdAt"`
}
return func(writer http.ResponseWriter, request *http.Request) {
limit := auditLogDefaultLimit
if limitStr := request.URL.Query().Get("limit"); limitStr != "" {
parsed, parseErr := strconv.Atoi(limitStr)
if parseErr == nil && parsed > 0 && parsed <= auditLogMaxLimit {
limit = parsed
}
}
entries, err := h.audit.Recent(request.Context(), limit)
if err != nil {
h.log.Error("failed to fetch audit log", "error", err)
h.respondJSON(writer, request,
map[string]string{"error": "failed to fetch audit log"},
http.StatusInternalServerError)
return
}
result := make([]auditEntryResponse, 0, len(entries))
for _, e := range entries {
entry := auditEntryResponse{
ID: e.ID,
Username: e.Username,
Action: string(e.Action),
ResourceType: string(e.ResourceType),
CreatedAt: e.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if e.UserID.Valid {
id := e.UserID.Int64
entry.UserID = &id
}
entry.ResourceID = nullStringValue(e.ResourceID)
entry.Detail = nullStringValue(e.Detail)
entry.RemoteIP = nullStringValue(e.RemoteIP)
result = append(result, entry)
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// nullStringValue returns the string value if valid, empty string otherwise.
func nullStringValue(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}

View File

@@ -119,9 +119,6 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
h.auditLog(request, models.AuditActionAppCreate,
models.AuditResourceApp, createdApp.ID, "created app: "+createdApp.Name)
http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther)
} }
} }
@@ -292,9 +289,6 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
h.auditLog(request, models.AuditActionAppUpdate,
models.AuditResourceApp, application.ID, "updated app: "+application.Name)
redirectURL := "/apps/" + application.ID + "?success=updated" redirectURL := "/apps/" + application.ID + "?success=updated"
http.Redirect(writer, request, redirectURL, http.StatusSeeOther) http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
} }
@@ -350,9 +344,6 @@ func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionAppDelete,
models.AuditResourceApp, appID, "deleted app: "+application.Name)
http.Redirect(writer, request, "/", http.StatusSeeOther) http.Redirect(writer, request, "/", http.StatusSeeOther)
} }
} }
@@ -369,9 +360,6 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionAppDeploy,
models.AuditResourceApp, application.ID, "manual deploy: "+application.Name)
// Trigger deployment in background with a detached context // Trigger deployment in background with a detached context
// so the deployment continues even if the HTTP request is cancelled // so the deployment continues even if the HTTP request is cancelled
deployCtx := context.WithoutCancel(request.Context()) deployCtx := context.WithoutCancel(request.Context())
@@ -411,8 +399,6 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
cancelled := h.deploy.CancelDeploy(application.ID) cancelled := h.deploy.CancelDeploy(application.ID)
if cancelled { if cancelled {
h.log.Info("deployment cancelled by user", "app", application.Name) h.log.Info("deployment cancelled by user", "app", application.Name)
h.auditLog(request, models.AuditActionDeployCancel,
models.AuditResourceDeployment, application.ID, "cancelled deploy: "+application.Name)
} }
http.Redirect( http.Redirect(
@@ -444,9 +430,6 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionAppRollback,
models.AuditResourceApp, application.ID, "rolled back: "+application.Name)
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
} }
} }
@@ -851,29 +834,11 @@ func (h *Handlers) handleContainerAction(
} else { } else {
h.log.Info("container action completed", h.log.Info("container action completed",
"action", action, "app", application.Name, "container", containerID) "action", action, "app", application.Name, "container", containerID)
auditAction := containerActionToAuditAction(action)
h.auditLog(request, auditAction,
models.AuditResourceApp, appID, string(action)+" container: "+application.Name)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
} }
// containerActionToAuditAction maps container actions to audit actions.
func containerActionToAuditAction(action containerAction) models.AuditAction {
switch action {
case actionRestart:
return models.AuditActionAppRestart
case actionStop:
return models.AuditActionAppStop
case actionStart:
return models.AuditActionAppStart
default:
return models.AuditAction("app." + string(action))
}
}
// HandleAppRestart handles restarting an app's container. // HandleAppRestart handles restarting an app's container.
func (h *Handlers) HandleAppRestart() http.HandlerFunc { func (h *Handlers) HandleAppRestart() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -1023,10 +988,6 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionEnvVarSave,
models.AuditResourceEnvVar, application.ID,
fmt.Sprintf("saved %d env vars", len(modelPairs)))
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK) h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
} }
} }
@@ -1043,13 +1004,7 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
label.Key = key label.Key = key
label.Value = value label.Value = value
err := label.Save(ctx) return label.Save(ctx)
if err == nil {
h.auditLog(request, models.AuditActionLabelAdd,
models.AuditResourceLabel, application.ID, "added label: "+key)
}
return err
}, },
) )
} }
@@ -1078,9 +1033,6 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
deleteErr := label.Delete(request.Context()) deleteErr := label.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete label", "error", deleteErr) h.log.Error("failed to delete label", "error", deleteErr)
} else {
h.auditLog(request, models.AuditActionLabelDelete,
models.AuditResourceLabel, appID, "deleted label: "+label.Key)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1138,10 +1090,6 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
saveErr := volume.Save(request.Context()) saveErr := volume.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to add volume", "error", saveErr) h.log.Error("failed to add volume", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionVolumeAdd,
models.AuditResourceVolume, application.ID,
"added volume: "+hostPath+":"+containerPath)
} }
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
@@ -1171,10 +1119,6 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
deleteErr := volume.Delete(request.Context()) deleteErr := volume.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete volume", "error", deleteErr) h.log.Error("failed to delete volume", "error", deleteErr)
} else {
h.auditLog(request, models.AuditActionVolumeDelete,
models.AuditResourceVolume, appID,
"deleted volume: "+volume.HostPath+":"+volume.ContainerPath)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1224,10 +1168,6 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
saveErr := port.Save(request.Context()) saveErr := port.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to save port", "error", saveErr) h.log.Error("failed to save port", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionPortAdd,
models.AuditResourcePort, application.ID,
fmt.Sprintf("added port: %d:%d/%s", hostPort, containerPort, protocol))
} }
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
@@ -1274,10 +1214,6 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
deleteErr := port.Delete(request.Context()) deleteErr := port.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete port", "error", deleteErr) h.log.Error("failed to delete port", "error", deleteErr)
} else {
h.auditLog(request, models.AuditActionPortDelete,
models.AuditResourcePort, appID,
fmt.Sprintf("deleted port: %d:%d/%s", port.HostPort, port.ContainerPort, port.Protocol))
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1353,9 +1289,6 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
saveErr := label.Save(request.Context()) saveErr := label.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to update label", "error", saveErr) h.log.Error("failed to update label", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionLabelEdit,
models.AuditResourceLabel, appID, "edited label: "+key)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
@@ -1414,10 +1347,6 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
saveErr := volume.Save(request.Context()) saveErr := volume.Save(request.Context())
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to update volume", "error", saveErr) h.log.Error("failed to update volume", "error", saveErr)
} else {
h.auditLog(request, models.AuditActionVolumeEdit,
models.AuditResourceVolume, appID,
"edited volume: "+hostPath+":"+containerPath)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)

View File

@@ -3,7 +3,6 @@ package handlers
import ( import (
"net/http" "net/http"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates" "sneak.berlin/go/upaas/templates"
) )
@@ -62,9 +61,6 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionLogin,
models.AuditResourceSession, "", "user logged in")
http.Redirect(writer, request, "/", http.StatusSeeOther) http.Redirect(writer, request, "/", http.StatusSeeOther)
} }
} }
@@ -72,9 +68,6 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
// HandleLogout handles logout requests. // HandleLogout handles logout requests.
func (h *Handlers) HandleLogout() http.HandlerFunc { func (h *Handlers) HandleLogout() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
h.auditLog(request, models.AuditActionLogout,
models.AuditResourceSession, "", "user logged out")
destroyErr := h.auth.DestroySession(writer, request) destroyErr := h.auth.DestroySession(writer, request)
if destroyErr != nil { if destroyErr != nil {
h.log.Error("failed to destroy session", "error", destroyErr) h.log.Error("failed to destroy session", "error", destroyErr)

View File

@@ -15,9 +15,7 @@ import (
"sneak.berlin/go/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/audit"
"sneak.berlin/go/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/webhook"
@@ -37,7 +35,6 @@ type Params struct {
Deploy *deploy.Service Deploy *deploy.Service
Webhook *webhook.Service Webhook *webhook.Service
Docker *docker.Client Docker *docker.Client
Audit *audit.Service
} }
// Handlers provides HTTP request handlers. // Handlers provides HTTP request handlers.
@@ -51,7 +48,6 @@ type Handlers struct {
deploy *deploy.Service deploy *deploy.Service
webhook *webhook.Service webhook *webhook.Service
docker *docker.Client docker *docker.Client
audit *audit.Service
globals *globals.Globals globals *globals.Globals
} }
@@ -67,48 +63,10 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
deploy: params.Deploy, deploy: params.Deploy,
webhook: params.Webhook, webhook: params.Webhook,
docker: params.Docker, docker: params.Docker,
audit: params.Audit,
globals: params.Globals, globals: params.Globals,
}, nil }, nil
} }
// currentUser returns the currently authenticated user, or nil if not authenticated.
func (h *Handlers) currentUser(request *http.Request) *models.User {
user, err := h.auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
return nil
}
return user
}
// auditLog records an audit entry for the current request.
func (h *Handlers) auditLog(
request *http.Request,
action models.AuditAction,
resourceType models.AuditResourceType,
resourceID string,
detail string,
) {
user := h.currentUser(request)
entry := audit.LogEntry{
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Detail: detail,
}
if user != nil {
entry.UserID = user.ID
entry.Username = user.Username
} else {
entry.Username = "anonymous"
}
h.audit.LogFromRequest(request.Context(), request, entry)
}
// addGlobals adds version info and CSRF token to template data map. // addGlobals adds version info and CSRF token to template data map.
func (h *Handlers) addGlobals( func (h *Handlers) addGlobals(
data map[string]any, data map[string]any,

View File

@@ -11,7 +11,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
@@ -25,10 +24,8 @@ import (
"sneak.berlin/go/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/middleware" "sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/audit"
"sneak.berlin/go/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/notify"
@@ -95,8 +92,7 @@ func createAppServices(
logInstance *logger.Logger, logInstance *logger.Logger,
dbInstance *database.Database, dbInstance *database.Database,
cfg *config.Config, cfg *config.Config,
metricsInstance *metrics.Metrics, ) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service, *docker.Client) {
) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service, *docker.Client, *audit.Service) {
t.Helper() t.Helper()
authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{ authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
@@ -129,7 +125,6 @@ func createAppServices(
Database: dbInstance, Database: dbInstance,
Docker: dockerClient, Docker: dockerClient,
Notify: notifySvc, Notify: notifySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, deployErr) require.NoError(t, deployErr)
@@ -137,18 +132,10 @@ func createAppServices(
Logger: logInstance, Logger: logInstance,
Database: dbInstance, Database: dbInstance,
Deploy: deploySvc, Deploy: deploySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, webhookErr) require.NoError(t, webhookErr)
auditSvc, auditErr := audit.New(fx.Lifecycle(nil), audit.ServiceParams{ return authSvc, appSvc, deploySvc, webhookSvc, dockerClient
Logger: logInstance,
Database: dbInstance,
Metrics: metricsInstance,
})
require.NoError(t, auditErr)
return authSvc, appSvc, deploySvc, webhookSvc, dockerClient, auditSvc
} }
func setupTestHandlers(t *testing.T) *testContext { func setupTestHandlers(t *testing.T) *testContext {
@@ -158,14 +145,11 @@ func setupTestHandlers(t *testing.T) *testContext {
globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg) globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg)
metricsInstance := metrics.NewForTest(prometheus.NewRegistry()) authSvc, appSvc, deploySvc, webhookSvc, dockerClient := createAppServices(
authSvc, appSvc, deploySvc, webhookSvc, dockerClient, auditSvc := createAppServices(
t, t,
logInstance, logInstance,
dbInstance, dbInstance,
cfg, cfg,
metricsInstance,
) )
handlersInstance, handlerErr := handlers.New( handlersInstance, handlerErr := handlers.New(
@@ -180,7 +164,6 @@ func setupTestHandlers(t *testing.T) *testContext {
Deploy: deploySvc, Deploy: deploySvc,
Webhook: webhookSvc, Webhook: webhookSvc,
Docker: dockerClient, Docker: dockerClient,
Audit: auditSvc,
}, },
) )
require.NoError(t, handlerErr) require.NoError(t, handlerErr)
@@ -190,7 +173,6 @@ func setupTestHandlers(t *testing.T) *testContext {
Globals: globalInstance, Globals: globalInstance,
Config: cfg, Config: cfg,
Auth: authSvc, Auth: authSvc,
Metrics: metricsInstance,
}) })
require.NoError(t, mwErr) require.NoError(t, mwErr)

View File

@@ -3,7 +3,6 @@ package handlers
import ( import (
"net/http" "net/http"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates" "sneak.berlin/go/upaas/templates"
) )
@@ -112,9 +111,6 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
return return
} }
h.auditLog(request, models.AuditActionSetup,
models.AuditResourceUser, "", "initial setup completed")
http.Redirect(writer, request, "/", http.StatusSeeOther) http.Redirect(writer, request, "/", http.StatusSeeOther)
} }
} }

View File

@@ -7,14 +7,15 @@ 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/audit" "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 Gitea webhooks. // HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab.
func (h *Handlers) HandleWebhook() http.HandlerFunc { //nolint:funlen // audit logging adds necessary length // The webhook source is auto-detected from HTTP headers.
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")
if secret == "" { if secret == "" {
@@ -51,25 +52,17 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc { //nolint:funlen // audit l
return return
} }
// Get event type from header // Auto-detect webhook source from headers
eventType := request.Header.Get("X-Gitea-Event") source := webhook.DetectWebhookSource(request.Header)
if eventType == "" {
eventType = "push"
}
// Log webhook receipt // Extract event type based on detected source
h.audit.LogFromRequest(request.Context(), request, audit.LogEntry{ eventType := webhook.DetectEventType(request.Header, source)
Username: "webhook",
Action: models.AuditActionWebhookReceive,
ResourceType: models.AuditResourceWebhook,
ResourceID: application.ID,
Detail: "webhook from app: " + application.Name + ", event: " + eventType,
})
// 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

@@ -1,148 +0,0 @@
// Package metrics provides Prometheus metrics for upaas.
//
//nolint:revive // "metrics" matches the domain; runtime/metrics is rarely imported directly
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.uber.org/fx"
)
// Params contains dependencies for Metrics.
type Params struct {
fx.In
}
// Metrics holds all Prometheus metrics for the application.
type Metrics struct {
// Deployment metrics.
DeploymentsTotal *prometheus.CounterVec
DeploymentDuration *prometheus.HistogramVec
DeploymentsInFlight *prometheus.GaugeVec
// Container health metrics.
ContainerHealthy *prometheus.GaugeVec
// Webhook metrics.
WebhookEventsTotal *prometheus.CounterVec
// HTTP request metrics.
HTTPRequestsTotal *prometheus.CounterVec
HTTPRequestDuration *prometheus.HistogramVec
HTTPResponseSizeBytes *prometheus.HistogramVec
// Audit log metrics.
AuditEventsTotal *prometheus.CounterVec
}
// New creates a new Metrics instance with all Prometheus metrics registered
// in the default Prometheus registry.
func New(_ fx.Lifecycle, _ Params) (*Metrics, error) {
return newMetrics(promauto.With(prometheus.DefaultRegisterer)), nil
}
// NewForTest creates a Metrics instance with a custom registry for test isolation.
func NewForTest(reg prometheus.Registerer) *Metrics {
return newMetrics(promauto.With(reg))
}
// newMetrics creates a Metrics instance using the given factory.
func newMetrics(factory promauto.Factory) *Metrics {
return &Metrics{
DeploymentsTotal: newDeploymentsTotal(factory),
DeploymentDuration: newDeploymentDuration(factory),
DeploymentsInFlight: newDeploymentsInFlight(factory),
ContainerHealthy: newContainerHealthy(factory),
WebhookEventsTotal: newWebhookEventsTotal(factory),
HTTPRequestsTotal: newHTTPRequestsTotal(factory),
HTTPRequestDuration: newHTTPRequestDuration(factory),
HTTPResponseSizeBytes: newHTTPResponseSizeBytes(factory),
AuditEventsTotal: newAuditEventsTotal(factory),
}
}
func newDeploymentsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "deployments",
Name: "total",
Help: "Total number of deployments by app and status.",
}, []string{"app", "status"})
}
func newDeploymentDuration(f promauto.Factory) *prometheus.HistogramVec {
return f.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "upaas",
Subsystem: "deployments",
Name: "duration_seconds",
Help: "Duration of deployments in seconds by app and status.",
Buckets: []float64{10, 30, 60, 120, 300, 600, 1800},
}, []string{"app", "status"})
}
func newDeploymentsInFlight(f promauto.Factory) *prometheus.GaugeVec {
return f.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "upaas",
Subsystem: "deployments",
Name: "in_flight",
Help: "Number of deployments currently in progress by app.",
}, []string{"app"})
}
func newContainerHealthy(f promauto.Factory) *prometheus.GaugeVec {
return f.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "upaas",
Subsystem: "container",
Name: "healthy",
Help: "Whether the app container is healthy (1) or unhealthy (0).",
}, []string{"app"})
}
func newWebhookEventsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "webhook",
Name: "events_total",
Help: "Total number of webhook events by app, event type, and matched status.",
}, []string{"app", "event_type", "matched"})
}
func newHTTPRequestsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "http",
Name: "requests_total",
Help: "Total number of HTTP requests by method and status code.",
}, []string{"method", "status_code"})
}
func newHTTPRequestDuration(f promauto.Factory) *prometheus.HistogramVec {
return f.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "upaas",
Subsystem: "http",
Name: "request_duration_seconds",
Help: "Duration of HTTP requests in seconds by method.",
Buckets: prometheus.DefBuckets,
}, []string{"method"})
}
//nolint:mnd // bucket boundaries are domain-specific constants
func newHTTPResponseSizeBytes(f promauto.Factory) *prometheus.HistogramVec {
return f.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "upaas",
Subsystem: "http",
Name: "response_size_bytes",
Help: "Size of HTTP responses in bytes by method.",
Buckets: prometheus.ExponentialBuckets(100, 10, 7),
}, []string{"method"})
}
func newAuditEventsTotal(f promauto.Factory) *prometheus.CounterVec {
return f.NewCounterVec(prometheus.CounterOpts{
Namespace: "upaas",
Subsystem: "audit",
Name: "events_total",
Help: "Total number of audit log events by action.",
}, []string{"action"})
}

View File

@@ -1,158 +0,0 @@
package metrics_test
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/metrics"
)
func TestNewForTest(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
require.NotNil(t, m)
assert.NotNil(t, m.DeploymentsTotal)
assert.NotNil(t, m.DeploymentDuration)
assert.NotNil(t, m.DeploymentsInFlight)
assert.NotNil(t, m.ContainerHealthy)
assert.NotNil(t, m.WebhookEventsTotal)
assert.NotNil(t, m.HTTPRequestsTotal)
assert.NotNil(t, m.HTTPRequestDuration)
assert.NotNil(t, m.HTTPResponseSizeBytes)
assert.NotNil(t, m.AuditEventsTotal)
}
func TestNew(t *testing.T) {
t.Parallel()
m, err := metrics.New(fx.Lifecycle(nil), metrics.Params{})
require.NoError(t, err)
require.NotNil(t, m)
}
func TestDeploymentMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.DeploymentsTotal.WithLabelValues("test-app", "success").Inc()
m.DeploymentDuration.WithLabelValues("test-app", "success").Observe(42.5)
m.DeploymentsInFlight.WithLabelValues("test-app").Set(1)
families, err := reg.Gather()
require.NoError(t, err)
names := make(map[string]bool)
for _, f := range families {
names[f.GetName()] = true
}
assert.True(t, names["upaas_deployments_total"])
assert.True(t, names["upaas_deployments_duration_seconds"])
assert.True(t, names["upaas_deployments_in_flight"])
}
func TestContainerHealthMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.ContainerHealthy.WithLabelValues("my-app").Set(1)
families, err := reg.Gather()
require.NoError(t, err)
found := false
for _, f := range families {
if f.GetName() == "upaas_container_healthy" {
found = true
break
}
}
assert.True(t, found)
}
func TestWebhookMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.WebhookEventsTotal.WithLabelValues("test-app", "push", "true").Inc()
families, err := reg.Gather()
require.NoError(t, err)
found := false
for _, f := range families {
if f.GetName() == "upaas_webhook_events_total" {
found = true
break
}
}
assert.True(t, found)
}
func TestHTTPMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.HTTPRequestsTotal.WithLabelValues("GET", "200").Inc()
m.HTTPRequestDuration.WithLabelValues("GET").Observe(0.05)
m.HTTPResponseSizeBytes.WithLabelValues("GET").Observe(1024)
families, err := reg.Gather()
require.NoError(t, err)
names := make(map[string]bool)
for _, f := range families {
names[f.GetName()] = true
}
assert.True(t, names["upaas_http_requests_total"])
assert.True(t, names["upaas_http_request_duration_seconds"])
assert.True(t, names["upaas_http_response_size_bytes"])
}
func TestAuditMetrics(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
m := metrics.NewForTest(reg)
m.AuditEventsTotal.WithLabelValues("login").Inc()
families, err := reg.Gather()
require.NoError(t, err)
found := false
for _, f := range families {
if f.GetName() == "upaas_audit_events_total" {
found = true
break
}
}
assert.True(t, found)
}

View File

@@ -21,7 +21,6 @@ import (
"sneak.berlin/go/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/auth"
) )
@@ -36,13 +35,11 @@ type Params struct {
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Auth *auth.Service Auth *auth.Service
Metrics *metrics.Metrics
} }
// Middleware provides HTTP middleware. // Middleware provides HTTP middleware.
type Middleware struct { type Middleware struct {
log *slog.Logger log *slog.Logger
metrics *metrics.Metrics
params *Params params *Params
} }
@@ -50,23 +47,21 @@ type Middleware struct {
func New(_ fx.Lifecycle, params Params) (*Middleware, error) { func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
return &Middleware{ return &Middleware{
log: params.Logger.Get(), log: params.Logger.Get(),
metrics: params.Metrics,
params: &params, params: &params,
}, nil }, nil
} }
// loggingResponseWriter wraps http.ResponseWriter to capture status code and bytes written. // loggingResponseWriter wraps http.ResponseWriter to capture status code.
type loggingResponseWriter struct { type loggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int
bytesWritten int
} }
func newLoggingResponseWriter( func newLoggingResponseWriter(
writer http.ResponseWriter, writer http.ResponseWriter,
) *loggingResponseWriter { ) *loggingResponseWriter {
return &loggingResponseWriter{ResponseWriter: writer, statusCode: http.StatusOK} return &loggingResponseWriter{writer, http.StatusOK}
} }
func (lrw *loggingResponseWriter) WriteHeader(code int) { func (lrw *loggingResponseWriter) WriteHeader(code int) {
@@ -74,14 +69,7 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.ResponseWriter.WriteHeader(code) lrw.ResponseWriter.WriteHeader(code)
} }
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) { // Logging returns a request logging middleware.
n, err := lrw.ResponseWriter.Write(b)
lrw.bytesWritten += n
return n, err
}
// Logging returns a request logging middleware that also records HTTP metrics.
func (m *Middleware) Logging() func(http.Handler) http.Handler { func (m *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
@@ -95,8 +83,6 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler {
defer func() { defer func() {
latency := time.Since(start) latency := time.Since(start)
reqID := middleware.GetReqID(ctx) reqID := middleware.GetReqID(ctx)
statusStr := strconv.Itoa(lrw.statusCode)
m.log.InfoContext(ctx, "request", m.log.InfoContext(ctx, "request",
"request_start", start, "request_start", start,
"method", request.Method, "method", request.Method,
@@ -105,21 +91,10 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler {
"request_id", reqID, "request_id", reqID,
"referer", request.Referer(), "referer", request.Referer(),
"proto", request.Proto, "proto", request.Proto,
"remoteIP", RealIP(request), "remoteIP", realIP(request),
"status", lrw.statusCode, "status", lrw.statusCode,
"bytes", lrw.bytesWritten,
"latency_ms", latency.Milliseconds(), "latency_ms", latency.Milliseconds(),
) )
m.metrics.HTTPRequestsTotal.WithLabelValues(
request.Method, statusStr,
).Inc()
m.metrics.HTTPRequestDuration.WithLabelValues(
request.Method,
).Observe(latency.Seconds())
m.metrics.HTTPResponseSizeBytes.WithLabelValues(
request.Method,
).Observe(float64(lrw.bytesWritten))
}() }()
next.ServeHTTP(lrw, request) next.ServeHTTP(lrw, request)
@@ -170,11 +145,11 @@ func isTrustedProxy(ip net.IP) bool {
return false return false
} }
// RealIP extracts the client's real IP address from the request. // realIP extracts the client's real IP address from the request.
// Proxy headers (X-Real-IP, X-Forwarded-For) are only trusted when the // Proxy headers (X-Real-IP, X-Forwarded-For) are only trusted when the
// direct connection originates from an RFC1918/loopback address. // direct connection originates from an RFC1918/loopback address.
// Otherwise, headers are ignored and RemoteAddr is used (fail closed). // Otherwise, headers are ignored and RemoteAddr is used (fail closed).
func RealIP(r *http.Request) string { func realIP(r *http.Request) string {
addr := ipFromHostPort(r.RemoteAddr) addr := ipFromHostPort(r.RemoteAddr)
remoteIP := net.ParseIP(addr) remoteIP := net.ParseIP(addr)
@@ -365,7 +340,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
ip := RealIP(request) ip := realIP(request)
limiter := loginLimiter.getLimiter(ip) limiter := loginLimiter.getLimiter(ip)
if !limiter.Allow() { if !limiter.Allow() {

View File

@@ -1,4 +1,4 @@
package middleware //nolint:testpackage // tests RealIP via internal package access package middleware //nolint:testpackage // tests unexported realIP function
import ( import (
"context" "context"
@@ -126,9 +126,9 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
req.Header.Set("X-Forwarded-For", tt.xff) req.Header.Set("X-Forwarded-For", tt.xff)
} }
got := RealIP(req) got := realIP(req)
if got != tt.want { if got != tt.want {
t.Errorf("RealIP() = %q, want %q", got, tt.want) t.Errorf("realIP() = %q, want %q", got, tt.want)
} }
}) })
} }

View File

@@ -1,193 +0,0 @@
package models
import (
"context"
"database/sql"
"fmt"
"time"
"sneak.berlin/go/upaas/internal/database"
)
// AuditAction represents the type of audited user action.
type AuditAction string
// Audit action constants.
const (
AuditActionLogin AuditAction = "login"
AuditActionLogout AuditAction = "logout"
AuditActionAppCreate AuditAction = "app.create"
AuditActionAppUpdate AuditAction = "app.update"
AuditActionAppDelete AuditAction = "app.delete"
AuditActionAppDeploy AuditAction = "app.deploy"
AuditActionAppRollback AuditAction = "app.rollback"
AuditActionAppRestart AuditAction = "app.restart"
AuditActionAppStop AuditAction = "app.stop"
AuditActionAppStart AuditAction = "app.start"
AuditActionDeployCancel AuditAction = "deploy.cancel"
AuditActionEnvVarSave AuditAction = "env_var.save"
AuditActionLabelAdd AuditAction = "label.add"
AuditActionLabelEdit AuditAction = "label.edit"
AuditActionLabelDelete AuditAction = "label.delete"
AuditActionVolumeAdd AuditAction = "volume.add"
AuditActionVolumeEdit AuditAction = "volume.edit"
AuditActionVolumeDelete AuditAction = "volume.delete"
AuditActionPortAdd AuditAction = "port.add"
AuditActionPortDelete AuditAction = "port.delete"
AuditActionSetup AuditAction = "setup"
AuditActionWebhookReceive AuditAction = "webhook.receive"
)
// AuditResourceType represents the type of resource being acted on.
type AuditResourceType string
// Audit resource type constants.
const (
AuditResourceApp AuditResourceType = "app"
AuditResourceUser AuditResourceType = "user"
AuditResourceSession AuditResourceType = "session"
AuditResourceEnvVar AuditResourceType = "env_var"
AuditResourceLabel AuditResourceType = "label"
AuditResourceVolume AuditResourceType = "volume"
AuditResourcePort AuditResourceType = "port"
AuditResourceDeployment AuditResourceType = "deployment"
AuditResourceWebhook AuditResourceType = "webhook"
)
// AuditEntry represents a single audit log entry.
type AuditEntry struct {
db *database.Database
ID int64
UserID sql.NullInt64
Username string
Action AuditAction
ResourceType AuditResourceType
ResourceID sql.NullString
Detail sql.NullString
RemoteIP sql.NullString
CreatedAt time.Time
}
// NewAuditEntry creates a new AuditEntry with a database reference.
func NewAuditEntry(db *database.Database) *AuditEntry {
return &AuditEntry{db: db}
}
// Save inserts the audit entry into the database.
func (a *AuditEntry) Save(ctx context.Context) error {
query := `
INSERT INTO audit_log (
user_id, username, action, resource_type, resource_id,
detail, remote_ip
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := a.db.Exec(ctx, query,
a.UserID, a.Username, a.Action, a.ResourceType,
a.ResourceID, a.Detail, a.RemoteIP,
)
if err != nil {
return fmt.Errorf("inserting audit entry: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("getting audit entry id: %w", err)
}
a.ID = id
return nil
}
// FindAuditEntries returns recent audit log entries, newest first.
func FindAuditEntries(
ctx context.Context,
db *database.Database,
limit int,
) ([]*AuditEntry, error) {
query := `
SELECT id, user_id, username, action, resource_type, resource_id,
detail, remote_ip, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT ?`
rows, err := db.Query(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("querying audit entries: %w", err)
}
defer func() { _ = rows.Close() }()
return scanAuditRows(rows)
}
// FindAuditEntriesByResource returns audit log entries for a specific resource.
func FindAuditEntriesByResource(
ctx context.Context,
db *database.Database,
resourceType AuditResourceType,
resourceID string,
limit int,
) ([]*AuditEntry, error) {
query := `
SELECT id, user_id, username, action, resource_type, resource_id,
detail, remote_ip, created_at
FROM audit_log
WHERE resource_type = ? AND resource_id = ?
ORDER BY created_at DESC
LIMIT ?`
rows, err := db.Query(ctx, query, resourceType, resourceID, limit)
if err != nil {
return nil, fmt.Errorf("querying audit entries by resource: %w", err)
}
defer func() { _ = rows.Close() }()
return scanAuditRows(rows)
}
// CountAuditEntries returns the total number of audit log entries.
func CountAuditEntries(
ctx context.Context,
db *database.Database,
) (int, error) {
var count int
row := db.QueryRow(ctx, "SELECT COUNT(*) FROM audit_log")
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("counting audit entries: %w", err)
}
return count, nil
}
func scanAuditRows(rows *sql.Rows) ([]*AuditEntry, error) {
var entries []*AuditEntry
for rows.Next() {
entry := &AuditEntry{}
scanErr := rows.Scan(
&entry.ID, &entry.UserID, &entry.Username, &entry.Action,
&entry.ResourceType, &entry.ResourceID, &entry.Detail,
&entry.RemoteIP, &entry.CreatedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning audit entry: %w", scanErr)
}
entries = append(entries, entry)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating audit entries: %w", rowsErr)
}
return entries, nil
}

View File

@@ -23,7 +23,6 @@ const (
testBranch = "main" testBranch = "main"
testValue = "value" testValue = "value"
testEventType = "push" testEventType = "push"
testAdmin = "admin"
) )
func setupTestDB(t *testing.T) (*database.Database, func()) { func setupTestDB(t *testing.T) (*database.Database, func()) {
@@ -184,7 +183,7 @@ func TestUserExists(t *testing.T) {
defer cleanup() defer cleanup()
user := models.NewUser(testDB) user := models.NewUser(testDB)
user.Username = testAdmin user.Username = "admin"
user.PasswordHash = testHash user.PasswordHash = testHash
err := user.Save(context.Background()) err := user.Save(context.Background())
@@ -782,179 +781,6 @@ func TestCascadeDelete(t *testing.T) {
}) })
} }
// AuditEntry Tests.
func TestAuditEntryCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, entry.ID)
entries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, testAdmin, entries[0].Username)
assert.Equal(t, models.AuditActionLogin, entries[0].Action)
assert.Equal(t, models.AuditResourceSession, entries[0].ResourceType)
}
func TestAuditEntryWithAllFields(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
entry := models.NewAuditEntry(testDB)
entry.UserID = sql.NullInt64{Int64: 1, Valid: true}
entry.Username = testAdmin
entry.Action = models.AuditActionAppCreate
entry.ResourceType = models.AuditResourceApp
entry.ResourceID = sql.NullString{String: "app-123", Valid: true}
entry.Detail = sql.NullString{String: "created new app", Valid: true}
entry.RemoteIP = sql.NullString{String: "192.168.1.1", Valid: true}
err := entry.Save(context.Background())
require.NoError(t, err)
entries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, int64(1), entries[0].UserID.Int64)
assert.Equal(t, "app-123", entries[0].ResourceID.String)
assert.Equal(t, "created new app", entries[0].Detail.String)
assert.Equal(t, "192.168.1.1", entries[0].RemoteIP.String)
}
func TestAuditEntryFindByResource(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create entries for different resources.
for _, action := range []models.AuditAction{
models.AuditActionAppCreate,
models.AuditActionAppUpdate,
models.AuditActionAppDeploy,
} {
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = action
entry.ResourceType = models.AuditResourceApp
entry.ResourceID = sql.NullString{String: "app-1", Valid: true}
err := entry.Save(context.Background())
require.NoError(t, err)
}
// Create entry for a different resource.
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
// Find by resource.
appEntries, err := models.FindAuditEntriesByResource(
context.Background(), testDB,
models.AuditResourceApp, "app-1", 10,
)
require.NoError(t, err)
assert.Len(t, appEntries, 3)
// All entries.
allEntries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
assert.Len(t, allEntries, 4)
}
func TestAuditEntryCount(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
count, err := models.CountAuditEntries(context.Background(), testDB)
require.NoError(t, err)
assert.Equal(t, 0, count)
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err = entry.Save(context.Background())
require.NoError(t, err)
count, err = models.CountAuditEntries(context.Background(), testDB)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
func TestAuditEntryFindLimit(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
for range 5 {
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = models.AuditActionLogin
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
}
entries, err := models.FindAuditEntries(context.Background(), testDB, 3)
require.NoError(t, err)
assert.Len(t, entries, 3)
}
func TestAuditEntryOrderByCreatedAtDesc(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
actions := []models.AuditAction{
models.AuditActionLogin,
models.AuditActionAppCreate,
models.AuditActionLogout,
}
for _, action := range actions {
entry := models.NewAuditEntry(testDB)
entry.Username = testAdmin
entry.Action = action
entry.ResourceType = models.AuditResourceSession
err := entry.Save(context.Background())
require.NoError(t, err)
}
entries, err := models.FindAuditEntries(context.Background(), testDB, 10)
require.NoError(t, err)
require.Len(t, entries, 3)
// Newest first (logout was last inserted).
assert.Equal(t, models.AuditActionLogout, entries[0].Action)
assert.Equal(t, models.AuditActionAppCreate, entries[1].Action)
assert.Equal(t, models.AuditActionLogin, entries[2].Action)
}
// Helper function to create a test app. // Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App { func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper() t.Helper()

View File

@@ -115,13 +115,14 @@ func (s *Server) SetupRoutes() {
r.Get("/apps", s.handlers.HandleAPIListApps()) r.Get("/apps", s.handlers.HandleAPIListApps())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
r.Get("/audit", s.handlers.HandleAPIAuditLog())
}) })
}) })
// Metrics endpoint (always available, optionally protected with basic auth) // Metrics endpoint (optional, with basic auth)
if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth()) r.Use(s.mw.MetricsAuth())
r.Get("/metrics", promhttp.Handler().ServeHTTP) r.Get("/metrics", promhttp.Handler().ServeHTTP)
}) })
} }
}

View File

@@ -1,126 +0,0 @@
// Package audit provides audit logging for user actions.
package audit
import (
"context"
"database/sql"
"log/slog"
"net/http"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/models"
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
Database *database.Database
Metrics *metrics.Metrics
}
// Service provides audit logging functionality.
type Service struct {
log *slog.Logger
db *database.Database
metrics *metrics.Metrics
}
// New creates a new audit Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
db: params.Database,
metrics: params.Metrics,
}, nil
}
// LogEntry records an audit event.
type LogEntry struct {
UserID int64
Username string
Action models.AuditAction
ResourceType models.AuditResourceType
ResourceID string
Detail string
RemoteIP string
}
// Log records an audit log entry and increments the audit metrics counter.
func (svc *Service) Log(ctx context.Context, entry LogEntry) {
auditEntry := models.NewAuditEntry(svc.db)
auditEntry.Username = entry.Username
auditEntry.Action = entry.Action
auditEntry.ResourceType = entry.ResourceType
if entry.UserID != 0 {
auditEntry.UserID = sql.NullInt64{Int64: entry.UserID, Valid: true}
}
if entry.ResourceID != "" {
auditEntry.ResourceID = sql.NullString{String: entry.ResourceID, Valid: true}
}
if entry.Detail != "" {
auditEntry.Detail = sql.NullString{String: entry.Detail, Valid: true}
}
if entry.RemoteIP != "" {
auditEntry.RemoteIP = sql.NullString{String: entry.RemoteIP, Valid: true}
}
err := auditEntry.Save(ctx)
if err != nil {
svc.log.Error("failed to save audit entry",
"error", err,
"action", entry.Action,
"username", entry.Username,
)
return
}
svc.metrics.AuditEventsTotal.WithLabelValues(string(entry.Action)).Inc()
svc.log.Info("audit",
"action", entry.Action,
"username", entry.Username,
"resource_type", entry.ResourceType,
"resource_id", entry.ResourceID,
)
}
// LogFromRequest records an audit log entry, extracting the remote IP from
// the HTTP request using the middleware's trusted-proxy-aware IP resolution.
func (svc *Service) LogFromRequest(
ctx context.Context,
request *http.Request,
entry LogEntry,
) {
entry.RemoteIP = middleware.RealIP(request)
svc.Log(ctx, entry)
}
// Recent returns the most recent audit log entries.
func (svc *Service) Recent(
ctx context.Context,
limit int,
) ([]*models.AuditEntry, error) {
return models.FindAuditEntries(ctx, svc.db, limit)
}
// ForResource returns audit log entries for a specific resource.
func (svc *Service) ForResource(
ctx context.Context,
resourceType models.AuditResourceType,
resourceID string,
limit int,
) ([]*models.AuditEntry, error) {
return models.FindAuditEntriesByResource(ctx, svc.db, resourceType, resourceID, limit)
}

View File

@@ -1,221 +0,0 @@
package audit_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/audit"
)
func setupTestAuditService(t *testing.T) (*audit.Service, *database.Database) {
t.Helper()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
tmpDir := t.TempDir()
cfg := &config.Config{
DataDir: tmpDir,
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
logWrapper := logger.NewForTest(log)
db, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: logWrapper,
Config: cfg,
})
require.NoError(t, err)
reg := prometheus.NewRegistry()
metricsInstance := metrics.NewForTest(reg)
svc, err := audit.New(fx.Lifecycle(nil), audit.ServiceParams{
Logger: logWrapper,
Database: db,
Metrics: metricsInstance,
})
require.NoError(t, err)
return svc, db
}
func TestAuditServiceLog(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
svc.Log(ctx, audit.LogEntry{
UserID: 1,
Username: "admin",
Action: models.AuditActionLogin,
ResourceType: models.AuditResourceSession,
Detail: "user logged in",
RemoteIP: "127.0.0.1",
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "admin", entries[0].Username)
assert.Equal(t, models.AuditActionLogin, entries[0].Action)
assert.Equal(t, "127.0.0.1", entries[0].RemoteIP.String)
}
func TestAuditServiceLogFromRequest(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
request := httptest.NewRequest(http.MethodPost, "/apps", nil)
request.RemoteAddr = "10.0.0.1:12345"
svc.LogFromRequest(ctx, request, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
ResourceID: "app-1",
Detail: "created app",
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "10.0.0.1", entries[0].RemoteIP.String)
assert.Equal(t, "app-1", entries[0].ResourceID.String)
}
func TestAuditServiceLogFromRequestWithXRealIPTrustedProxy(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
// When the request comes from a trusted proxy (RFC1918), X-Real-IP is honoured.
request := httptest.NewRequest(http.MethodPost, "/apps", nil)
request.RemoteAddr = "10.0.0.1:1234"
request.Header.Set("X-Real-IP", "203.0.113.50")
svc.LogFromRequest(ctx, request, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "203.0.113.50", entries[0].RemoteIP.String)
}
func TestAuditServiceLogFromRequestWithXRealIPUntrustedProxy(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
// When the request comes from a public IP, X-Real-IP is ignored (anti-spoof).
request := httptest.NewRequest(http.MethodPost, "/apps", nil)
request.RemoteAddr = "203.0.113.99:1234"
request.Header.Set("X-Real-IP", "10.0.0.1")
svc.LogFromRequest(ctx, request, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "203.0.113.99", entries[0].RemoteIP.String)
}
func TestAuditServiceRecent(t *testing.T) {
t.Parallel()
svc, _ := setupTestAuditService(t)
ctx := context.Background()
for range 5 {
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionLogin,
ResourceType: models.AuditResourceSession,
})
}
entries, err := svc.Recent(ctx, 3)
require.NoError(t, err)
assert.Len(t, entries, 3)
}
func TestAuditServiceForResource(t *testing.T) {
t.Parallel()
svc, _ := setupTestAuditService(t)
ctx := context.Background()
// Log entries for different resources.
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
ResourceID: "app-1",
})
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppDeploy,
ResourceType: models.AuditResourceApp,
ResourceID: "app-1",
})
svc.Log(ctx, audit.LogEntry{
Username: "admin",
Action: models.AuditActionAppCreate,
ResourceType: models.AuditResourceApp,
ResourceID: "app-2",
})
entries, err := svc.ForResource(ctx, models.AuditResourceApp, "app-1", 10)
require.NoError(t, err)
assert.Len(t, entries, 2)
}
func TestAuditServiceLogWithNoOptionalFields(t *testing.T) {
t.Parallel()
svc, db := setupTestAuditService(t)
ctx := context.Background()
svc.Log(ctx, audit.LogEntry{
Username: "system",
Action: models.AuditActionWebhookReceive,
ResourceType: models.AuditResourceWebhook,
})
entries, err := models.FindAuditEntries(ctx, db, 10)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.False(t, entries[0].UserID.Valid)
assert.False(t, entries[0].ResourceID.Valid)
assert.False(t, entries[0].Detail.Valid)
assert.False(t, entries[0].RemoteIP.Valid)
}

View File

@@ -21,7 +21,6 @@ import (
"sneak.berlin/go/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/notify"
) )
@@ -209,7 +208,6 @@ type ServiceParams struct {
Database *database.Database Database *database.Database
Docker *docker.Client Docker *docker.Client
Notify *notify.Service Notify *notify.Service
Metrics *metrics.Metrics
} }
// activeDeploy tracks a running deployment so it can be cancelled. // activeDeploy tracks a running deployment so it can be cancelled.
@@ -224,7 +222,6 @@ type Service struct {
db *database.Database db *database.Database
docker *docker.Client docker *docker.Client
notify *notify.Service notify *notify.Service
metrics *metrics.Metrics
config *config.Config config *config.Config
params *ServiceParams params *ServiceParams
activeDeploys sync.Map // map[string]*activeDeploy - per-app active deployment tracking activeDeploys sync.Map // map[string]*activeDeploy - per-app active deployment tracking
@@ -238,7 +235,6 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
db: params.Database, db: params.Database,
docker: params.Docker, docker: params.Docker,
notify: params.Notify, notify: params.Notify,
metrics: params.Metrics,
config: params.Config, config: params.Config,
params: &params, params: &params,
} }
@@ -331,11 +327,6 @@ func (svc *Service) Deploy(
} }
defer svc.unlockApp(app.ID) defer svc.unlockApp(app.ID)
// Track in-flight deployments
svc.metrics.DeploymentsInFlight.WithLabelValues(app.Name).Inc()
deployStart := time.Now()
// Set up cancellable context and register as active deploy // Set up cancellable context and register as active deploy
deployCtx, cancel := context.WithCancel(ctx) deployCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{}) done := make(chan struct{})
@@ -343,7 +334,6 @@ func (svc *Service) Deploy(
svc.activeDeploys.Store(app.ID, ad) svc.activeDeploys.Store(app.ID, ad)
defer func() { defer func() {
svc.metrics.DeploymentsInFlight.WithLabelValues(app.Name).Dec()
cancel() cancel()
close(done) close(done)
svc.activeDeploys.Delete(app.ID) svc.activeDeploys.Delete(app.ID)
@@ -369,7 +359,7 @@ func (svc *Service) Deploy(
svc.notify.NotifyBuildStart(bgCtx, app, deployment) svc.notify.NotifyBuildStart(bgCtx, app, deployment)
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment, deployStart) return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment)
} }
// Rollback rolls back an app to its previous image. // Rollback rolls back an app to its previous image.
@@ -477,20 +467,15 @@ func (svc *Service) runBuildAndDeploy(
bgCtx context.Context, bgCtx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
deployStart time.Time,
) error { ) error {
// Build phase with timeout // Build phase with timeout
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment) imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "") cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, "")
if cancelErr != nil { if cancelErr != nil {
svc.recordDeployMetrics(app.Name, "cancelled", deployStart)
return cancelErr return cancelErr
} }
svc.recordDeployMetrics(app.Name, "failed", deployStart)
return err return err
} }
@@ -501,13 +486,9 @@ func (svc *Service) runBuildAndDeploy(
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID) cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment, imageID)
if cancelErr != nil { if cancelErr != nil {
svc.recordDeployMetrics(app.Name, "cancelled", deployStart)
return cancelErr return cancelErr
} }
svc.recordDeployMetrics(app.Name, "failed", deployStart)
return err return err
} }
@@ -523,19 +504,11 @@ func (svc *Service) runBuildAndDeploy(
// Use context.WithoutCancel to ensure health check completes even if // Use context.WithoutCancel to ensure health check completes even if
// the parent context is cancelled (e.g., HTTP request ends). // the parent context is cancelled (e.g., HTTP request ends).
go svc.checkHealthAfterDelay(bgCtx, app, deployment, deployStart) go svc.checkHealthAfterDelay(bgCtx, app, deployment)
return nil return nil
} }
// recordDeployMetrics records deployment completion metrics.
func (svc *Service) recordDeployMetrics(appName, status string, start time.Time) {
duration := time.Since(start).Seconds()
svc.metrics.DeploymentsTotal.WithLabelValues(appName, status).Inc()
svc.metrics.DeploymentDuration.WithLabelValues(appName, status).Observe(duration)
}
// buildImageWithTimeout runs the build phase with a timeout. // buildImageWithTimeout runs the build phase with a timeout.
func (svc *Service) buildImageWithTimeout( func (svc *Service) buildImageWithTimeout(
ctx context.Context, ctx context.Context,
@@ -1190,7 +1163,6 @@ func (svc *Service) checkHealthAfterDelay(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
deployStart time.Time,
) { ) {
svc.log.Info( svc.log.Info(
"waiting 60 seconds to check container health", "waiting 60 seconds to check container health",
@@ -1217,8 +1189,6 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err) svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed) _ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment) svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "failed", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(0)
reloadedApp.Status = models.AppStatusError reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx) _ = reloadedApp.Save(ctx)
@@ -1230,8 +1200,6 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment) svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess) _ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess)
svc.writeLogsToFile(reloadedApp, deployment) svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "success", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(1)
} else { } else {
svc.log.Warn( svc.log.Warn(
"container unhealthy after 60 seconds", "container unhealthy after 60 seconds",
@@ -1240,8 +1208,6 @@ func (svc *Service) checkHealthAfterDelay(
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy) svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed) _ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment) svc.writeLogsToFile(reloadedApp, deployment)
svc.recordDeployMetrics(reloadedApp.Name, "failed", deployStart)
svc.metrics.ContainerHealthy.WithLabelValues(reloadedApp.Name).Set(0)
reloadedApp.Status = models.AppStatusError reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx) _ = reloadedApp.Save(ctx)
} }

View File

@@ -0,0 +1,248 @@
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,5 +1,7 @@
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,
@@ -8,3 +10,84 @@ 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,17 +4,14 @@ package webhook
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
) )
@@ -26,7 +23,6 @@ type ServiceParams struct {
Logger *logger.Logger Logger *logger.Logger
Database *database.Database Database *database.Database
Deploy *deploy.Service Deploy *deploy.Service
Metrics *metrics.Metrics
} }
// Service provides webhook handling functionality. // Service provides webhook handling functionality.
@@ -34,7 +30,6 @@ type Service struct {
log *slog.Logger log *slog.Logger
db *database.Database db *database.Database
deploy *deploy.Service deploy *deploy.Service
metrics *metrics.Metrics
params *ServiceParams params *ServiceParams
} }
@@ -44,73 +39,50 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
log: params.Logger.Get(), log: params.Logger.Get(),
db: params.Database, db: params.Database,
deploy: params.Deploy, deploy: params.Deploy,
metrics: params.Metrics,
params: &params, params: &params,
}, nil }, nil
} }
// GiteaPushPayload represents a Gitea push webhook payload. // HandleWebhook processes a webhook request from any supported source
// // (Gitea, GitHub, or GitLab). The source parameter determines which
//nolint:tagliatelle // Field names match Gitea API (snake_case) // payload format to use for parsing.
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", "app", app.Name, "event", eventType) svc.log.Info("processing webhook",
"app", app.Name,
"source", source.String(),
"event", eventType,
)
// Parse payload // Parse payload into normalized push event
var pushPayload GiteaPushPayload pushEvent, parseErr := ParsePushPayload(source, payload)
if parseErr != nil {
unmarshalErr := json.Unmarshal(payload, &pushPayload) svc.log.Warn("failed to parse webhook payload",
if unmarshalErr != nil { "error", parseErr,
svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr) "source", source.String(),
// 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 := branch == app.Branch matched := pushEvent.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 = branch event.Branch = pushEvent.Branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""} event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""}
event.CommitURL = sql.NullString{String: commitURL.String(), Valid: commitURL != ""} event.CommitURL = sql.NullString{
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
@@ -122,15 +94,12 @@ func (svc *Service) HandleWebhook(
svc.log.Info("webhook event recorded", svc.log.Info("webhook event recorded",
"app", app.Name, "app", app.Name,
"branch", branch, "source", source.String(),
"branch", pushEvent.Branch,
"matched", matched, "matched", matched,
"commit", commitSHA, "commit", pushEvent.After,
) )
svc.metrics.WebhookEventsTotal.WithLabelValues(
app.Name, eventType, strconv.FormatBool(matched),
).Inc()
// If branch matches, trigger deployment // If branch matches, trigger deployment
if matched { if matched {
svc.triggerDeployment(ctx, app, event) svc.triggerDeployment(ctx, app, event)
@@ -163,33 +132,3 @@ 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,12 +3,12 @@ package webhook_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
@@ -18,7 +18,6 @@ import (
"sneak.berlin/go/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/metrics"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/notify"
@@ -65,17 +64,13 @@ func setupTestService(t *testing.T) (*webhook.Service, *database.Database, func(
notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger}) notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger})
require.NoError(t, err) require.NoError(t, err)
metricsInstance := metrics.NewForTest(prometheus.NewRegistry())
deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{ deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: deps.logger, Config: deps.config, Database: deps.db, Docker: dockerClient, Notify: notifySvc, Logger: deps.logger, Config: deps.config, Database: deps.db, Docker: dockerClient, Notify: notifySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, err) require.NoError(t, err)
svc, err := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{ svc, err := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
Logger: deps.logger, Database: deps.db, Deploy: deploySvc, Logger: deps.logger, Database: deps.db, Deploy: deploySvc,
Metrics: metricsInstance,
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -108,44 +103,57 @@ 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 TestExtractBranch(testingT *testing.T) { func TestDetectWebhookSource(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
tests := []struct { tests := []struct {
name string name string
ref string headers map[string]string
expected string expected webhook.Source
}{ }{
{ {
name: "extracts main branch", name: "detects Gitea from X-Gitea-Event header",
ref: "refs/heads/main", headers: map[string]string{"X-Gitea-Event": "push"},
expected: "main", expected: webhook.SourceGitea,
}, },
{ {
name: "extracts feature branch", name: "detects GitHub from X-GitHub-Event header",
ref: "refs/heads/feature/new-feature", headers: map[string]string{"X-GitHub-Event": "push"},
expected: "feature/new-feature", expected: webhook.SourceGitHub,
}, },
{ {
name: "extracts develop branch", name: "detects GitLab from X-Gitlab-Event header",
ref: "refs/heads/develop", headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
expected: "develop", expected: webhook.SourceGitLab,
}, },
{ {
name: "returns raw ref if no prefix", name: "returns unknown when no recognized header",
ref: "main", headers: map[string]string{"Content-Type": "application/json"},
expected: "main", expected: webhook.SourceUnknown,
}, },
{ {
name: "handles empty ref", name: "returns unknown for empty headers",
ref: "", headers: map[string]string{},
expected: "", expected: webhook.SourceUnknown,
}, },
{ {
name: "handles partial prefix", name: "Gitea takes precedence over GitHub",
ref: "refs/heads/", headers: map[string]string{
expected: "", "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,
}, },
} }
@@ -153,123 +161,375 @@ func TestExtractBranch(testingT *testing.T) {
testingT.Run(testCase.name, func(t *testing.T) { testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel() t.Parallel()
// We test via HandleWebhook since extractBranch is not exported. headers := http.Header{}
// The test verifies behavior indirectly through the webhook event's branch. for key, value := range testCase.headers {
svc, dbInst, cleanup := setupTestService(t) headers.Set(key, value)
defer cleanup() }
app := createTestApp(t, dbInst, testCase.expected) result := webhook.DetectWebhookSource(headers)
assert.Equal(t, testCase.expected, result)
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)
}) })
} }
} }
func TestHandleWebhookMatchingBranch(t *testing.T) { // 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
}{
{
name: "extracts Gitea event type",
headers: map[string]string{"X-Gitea-Event": "push"},
source: webhook.SourceGitea,
expected: "push",
},
{
name: "extracts GitHub event type",
headers: map[string]string{"X-GitHub-Event": "push"},
source: webhook.SourceGitHub,
expected: "push",
},
{
name: "extracts GitLab event type",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
source: webhook.SourceGitLab,
expected: "Push Hook",
},
{
name: "returns push for unknown source",
headers: map[string]string{},
source: webhook.SourceUnknown,
expected: "push",
},
{
name: "returns push when header missing for source",
headers: map[string]string{},
source: webhook.SourceGitea,
expected: "push",
},
}
for _, testCase := range tests {
testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel() t.Parallel()
svc, dbInst, cleanup := setupTestService(t) headers := http.Header{}
defer cleanup() for key, value := range testCase.headers {
headers.Set(key, value)
}
app := createTestApp(t, dbInst, "main") result := webhook.DetectEventType(headers, testCase.source)
assert.Equal(t, testCase.expected, result)
})
}
}
// TestWebhookSourceString tests the String method on WebhookSource.
func TestWebhookSourceString(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())
}
// 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(`{ payload := []byte(`{
"ref": "refs/heads/main", "ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000", "before": "0000000000000000000000000000000000000000",
"after": "abc123def456", "after": "abc123def456789",
"compare_url": "https://gitea.example.com/myorg/myrepo/compare/000...abc",
"repository": { "repository": {
"full_name": "user/repo", "full_name": "myorg/myrepo",
"clone_url": "https://gitea.example.com/user/repo.git", "clone_url": "https://gitea.example.com/myorg/myrepo.git",
"ssh_url": "git@gitea.example.com:user/repo.git" "ssh_url": "git@gitea.example.com:myorg/myrepo.git",
"html_url": "https://gitea.example.com/myorg/myrepo"
}, },
"pusher": {"username": "testuser", "email": "test@example.com"}, "pusher": {"username": "developer", "email": "dev@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit", "commits": [
"author": {"name": "Test User", "email": "test@example.com"}}] {
"id": "abc123def456789",
"url": "https://gitea.example.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`) }`)
err := svc.HandleWebhook(context.Background(), app, "push", payload) event, err := webhook.ParsePushPayload(webhook.SourceGitea, payload)
require.NoError(t, err) require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup assert.Equal(t, webhook.SourceGitea, event.Source)
time.Sleep(100 * time.Millisecond) assert.Equal(t, "refs/heads/main", event.Ref)
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, "main", event.Branch)
assert.True(t, event.Matched) assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "abc123def456", event.CommitSHA.String) 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)
} }
func TestHandleWebhookNonMatchingBranch(t *testing.T) { // TestParsePushPayloadGitHub tests parsing of GitHub push payloads.
func TestParsePushPayloadGitHub(t *testing.T) {
t.Parallel() t.Parallel()
svc, dbInst, cleanup := setupTestService(t) payload := []byte(`{
defer cleanup() "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"}
}
]
}`)
app := createTestApp(t, dbInst, "main") event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
require.NoError(t, err) require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10) assert.Equal(t, webhook.SourceGitHub, event.Source)
require.NoError(t, err) assert.Equal(t, "refs/heads/main", event.Ref)
require.Len(t, events, 1) assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "develop", events[0].Branch) assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.False(t, events[0].Matched) 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)
} }
func TestHandleWebhookInvalidJSON(t *testing.T) { // TestParsePushPayloadGitLab tests parsing of GitLab push payloads.
func TestParsePushPayloadGitLab(t *testing.T) {
t.Parallel() t.Parallel()
svc, dbInst, cleanup := setupTestService(t) payload := []byte(`{
defer cleanup() "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"}
}
]
}`)
app := createTestApp(t, dbInst, "main") event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{invalid json}`))
require.NoError(t, err) require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10) assert.Equal(t, webhook.SourceGitLab, event.Source)
require.NoError(t, err) assert.Equal(t, "refs/heads/develop", event.Ref)
require.Len(t, events, 1) 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)
} }
func TestHandleWebhookEmptyPayload(t *testing.T) { // TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source uses Gitea parser.
func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
t.Parallel() t.Parallel()
svc, dbInst, cleanup := setupTestService(t) payload := []byte(`{
defer cleanup() "ref": "refs/heads/main",
"after": "abc123",
"repository": {"full_name": "user/repo"},
"pusher": {"username": "user"}
}`)
app := createTestApp(t, dbInst, "main") event, err := webhook.ParsePushPayload(webhook.SourceUnknown, payload)
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{}`))
require.NoError(t, err) require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10) assert.Equal(t, webhook.SourceGitea, event.Source)
require.NoError(t, err) assert.Equal(t, "main", event.Branch)
require.Len(t, events, 1) assert.Equal(t, "abc123", event.After)
assert.False(t, events[0].Matched)
} }
// 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)
assert.Empty(t, event.Branch)
assert.Empty(t, event.After)
})
}
}
// 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) { func TestGiteaPushPayloadParsing(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -328,6 +588,354 @@ 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()
@@ -347,3 +955,25 @@ 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)
}