Initial commit with server startup infrastructure
Core infrastructure: - Uber fx dependency injection - Chi router with middleware stack - SQLite database with embedded migrations - Embedded templates and static assets - Structured logging with slog Features implemented: - Authentication (login, logout, session management, argon2id hashing) - App management (create, edit, delete, list) - Deployment pipeline (clone, build, deploy, health check) - Webhook processing for Gitea - Notifications (ntfy, Slack) - Environment variables, labels, volumes per app - SSH key generation for deploy keys Server startup: - Server.Run() starts HTTP server on configured port - Server.Shutdown() for graceful shutdown - SetupRoutes() wires all handlers with chi router
This commit is contained in:
162
internal/service/webhook/webhook.go
Normal file
162
internal/service/webhook/webhook.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Package webhook provides webhook handling services.
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
type ServiceParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Database *database.Database
|
||||
Deploy *deploy.Service
|
||||
}
|
||||
|
||||
// Service provides webhook handling functionality.
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
db *database.Database
|
||||
deploy *deploy.Service
|
||||
params *ServiceParams
|
||||
}
|
||||
|
||||
// New creates a new webhook Service.
|
||||
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
return &Service{
|
||||
log: params.Logger.Get(),
|
||||
db: params.Database,
|
||||
deploy: params.Deploy,
|
||||
params: ¶ms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
} `json:"repository"`
|
||||
Pusher struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
} `json:"pusher"`
|
||||
Commits []struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
} `json:"author"`
|
||||
} `json:"commits"`
|
||||
}
|
||||
|
||||
// HandleWebhook processes a webhook request.
|
||||
func (svc *Service) HandleWebhook(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
eventType string,
|
||||
payload []byte,
|
||||
) error {
|
||||
svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
|
||||
|
||||
// Parse payload
|
||||
var pushPayload GiteaPushPayload
|
||||
|
||||
unmarshalErr := json.Unmarshal(payload, &pushPayload)
|
||||
if unmarshalErr != nil {
|
||||
svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
|
||||
// Continue anyway to log the event
|
||||
}
|
||||
|
||||
// Extract branch from ref
|
||||
branch := extractBranch(pushPayload.Ref)
|
||||
commitSHA := pushPayload.After
|
||||
|
||||
// Check if branch matches
|
||||
matched := branch == app.Branch
|
||||
|
||||
// Create webhook event record
|
||||
event := models.NewWebhookEvent(svc.db)
|
||||
event.AppID = app.ID
|
||||
event.EventType = eventType
|
||||
event.Branch = branch
|
||||
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
|
||||
event.Payload = sql.NullString{String: string(payload), Valid: true}
|
||||
event.Matched = matched
|
||||
event.Processed = false
|
||||
|
||||
saveErr := event.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save webhook event: %w", saveErr)
|
||||
}
|
||||
|
||||
svc.log.Info("webhook event recorded",
|
||||
"app", app.Name,
|
||||
"branch", branch,
|
||||
"matched", matched,
|
||||
"commit", commitSHA,
|
||||
)
|
||||
|
||||
// If branch matches, trigger deployment
|
||||
if matched {
|
||||
svc.triggerDeployment(ctx, app, event)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) triggerDeployment(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
event *models.WebhookEvent,
|
||||
) {
|
||||
// Capture values for goroutine
|
||||
eventID := event.ID
|
||||
appName := app.Name
|
||||
|
||||
go func() {
|
||||
// Use context.WithoutCancel to ensure deployment completes
|
||||
// even if the HTTP request context is cancelled.
|
||||
deployCtx := context.WithoutCancel(ctx)
|
||||
|
||||
deployErr := svc.deploy.Deploy(deployCtx, app, &eventID)
|
||||
if deployErr != nil {
|
||||
svc.log.Error("deployment failed", "error", deployErr, "app", appName)
|
||||
}
|
||||
|
||||
// Mark event as processed
|
||||
event.Processed = true
|
||||
_ = 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
|
||||
}
|
||||
334
internal/service/webhook/webhook_test.go
Normal file
334
internal/service/webhook/webhook_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package webhook_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||
)
|
||||
|
||||
type testDeps struct {
|
||||
logger *logger.Logger
|
||||
config *config.Config
|
||||
db *database.Database
|
||||
tmpDir string
|
||||
}
|
||||
|
||||
func setupTestDeps(t *testing.T) *testDeps {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
globals.SetAppname("upaas-test")
|
||||
globals.SetVersion("test")
|
||||
|
||||
globalsInst, err := globals.New(fx.Lifecycle(nil))
|
||||
require.NoError(t, err)
|
||||
|
||||
loggerInst, err := logger.New(fx.Lifecycle(nil), logger.Params{Globals: globalsInst})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{Port: 8080, DataDir: tmpDir, SessionSecret: "test-secret-key-at-least-32-chars"}
|
||||
|
||||
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg})
|
||||
require.NoError(t, err)
|
||||
|
||||
return &testDeps{logger: loggerInst, config: cfg, db: dbInst, tmpDir: tmpDir}
|
||||
}
|
||||
|
||||
func setupTestService(t *testing.T) (*webhook.Service, *database.Database, func()) {
|
||||
t.Helper()
|
||||
|
||||
deps := setupTestDeps(t)
|
||||
|
||||
dockerClient, err := docker.New(fx.Lifecycle(nil), docker.Params{Logger: deps.logger, Config: deps.config})
|
||||
require.NoError(t, err)
|
||||
|
||||
notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger})
|
||||
require.NoError(t, err)
|
||||
|
||||
deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
|
||||
Logger: deps.logger, Database: deps.db, Docker: dockerClient, Notify: notifySvc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
|
||||
Logger: deps.logger, Database: deps.db, Deploy: deploySvc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// t.TempDir() automatically cleans up after test
|
||||
return svc, deps.db, func() {}
|
||||
}
|
||||
|
||||
func createTestApp(
|
||||
t *testing.T,
|
||||
dbInst *database.Database,
|
||||
branch string,
|
||||
) *models.App {
|
||||
t.Helper()
|
||||
|
||||
app := models.NewApp(dbInst)
|
||||
app.ID = "test-app-id"
|
||||
app.Name = "test-app"
|
||||
app.RepoURL = "git@gitea.example.com:user/repo.git"
|
||||
app.Branch = branch
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
app.WebhookSecret = "webhook-secret-123"
|
||||
app.SSHPrivateKey = "private-key"
|
||||
app.SSHPublicKey = "public-key"
|
||||
app.Status = models.AppStatusPending
|
||||
|
||||
err := app.Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
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, "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, 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, "push", payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
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, "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, "push", []byte(`{invalid json}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
events, err := app.GetWebhookEvents(context.Background(), 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
}
|
||||
|
||||
func TestHandleWebhookEmptyPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, dbInst, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
app := createTestApp(t, dbInst, "main")
|
||||
|
||||
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
events, err := app.GetWebhookEvents(context.Background(), 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
assert.False(t, events[0].Matched)
|
||||
}
|
||||
|
||||
func TestGiteaPushPayloadParsing(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("parses full payload", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{
|
||||
"ref": "refs/heads/main",
|
||||
"before": "0000000000000000000000000000000000000000",
|
||||
"after": "abc123def456789",
|
||||
"repository": {
|
||||
"full_name": "myorg/myrepo",
|
||||
"clone_url": "https://gitea.example.com/myorg/myrepo.git",
|
||||
"ssh_url": "git@gitea.example.com:myorg/myrepo.git"
|
||||
},
|
||||
"pusher": {
|
||||
"username": "developer",
|
||||
"email": "dev@example.com"
|
||||
},
|
||||
"commits": [
|
||||
{
|
||||
"id": "abc123def456789",
|
||||
"message": "Fix bug in feature",
|
||||
"author": {
|
||||
"name": "Developer",
|
||||
"email": "dev@example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "def456789abc123",
|
||||
"message": "Add tests",
|
||||
"author": {
|
||||
"name": "Developer",
|
||||
"email": "dev@example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
var pushPayload webhook.GiteaPushPayload
|
||||
|
||||
err := json.Unmarshal(payload, &pushPayload)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "refs/heads/main", pushPayload.Ref)
|
||||
assert.Equal(t, "abc123def456789", pushPayload.After)
|
||||
assert.Equal(t, "myorg/myrepo", pushPayload.Repository.FullName)
|
||||
assert.Equal(
|
||||
t,
|
||||
"git@gitea.example.com:myorg/myrepo.git",
|
||||
pushPayload.Repository.SSHURL,
|
||||
)
|
||||
assert.Equal(t, "developer", pushPayload.Pusher.Username)
|
||||
assert.Len(t, pushPayload.Commits, 2)
|
||||
assert.Equal(t, "Fix bug in feature", pushPayload.Commits[0].Message)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupTestService verifies the test helper creates a working test service.
|
||||
func TestSetupTestService(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("creates working test service", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, dbInst, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
require.NotNil(t, svc)
|
||||
require.NotNil(t, dbInst)
|
||||
|
||||
// Verify database is working
|
||||
tmpDir := filepath.Dir(dbInst.Path())
|
||||
_, err := os.Stat(tmpDir)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user