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:
343
internal/service/app/app.go
Normal file
343
internal/service/app/app.go
Normal file
@@ -0,0 +1,343 @@
|
||||
// Package app provides application management services.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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/ssh"
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
type ServiceParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Database *database.Database
|
||||
}
|
||||
|
||||
// Service provides app management functionality.
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
db *database.Database
|
||||
params *ServiceParams
|
||||
}
|
||||
|
||||
// New creates a new app Service.
|
||||
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
return &Service{
|
||||
log: params.Logger.Get(),
|
||||
db: params.Database,
|
||||
params: ¶ms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAppInput contains the input for creating an app.
|
||||
type CreateAppInput struct {
|
||||
Name string
|
||||
RepoURL string
|
||||
Branch string
|
||||
DockerfilePath string
|
||||
DockerNetwork string
|
||||
NtfyTopic string
|
||||
SlackWebhook string
|
||||
}
|
||||
|
||||
// CreateApp creates a new application with generated SSH keys and webhook secret.
|
||||
func (svc *Service) CreateApp(
|
||||
ctx context.Context,
|
||||
input CreateAppInput,
|
||||
) (*models.App, error) {
|
||||
// Generate SSH key pair
|
||||
keyPair, err := ssh.GenerateKeyPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate SSH key pair: %w", err)
|
||||
}
|
||||
|
||||
// Create app
|
||||
app := models.NewApp(svc.db)
|
||||
app.ID = uuid.New().String()
|
||||
app.Name = input.Name
|
||||
app.RepoURL = input.RepoURL
|
||||
|
||||
app.Branch = input.Branch
|
||||
if app.Branch == "" {
|
||||
app.Branch = "main"
|
||||
}
|
||||
|
||||
app.DockerfilePath = input.DockerfilePath
|
||||
if app.DockerfilePath == "" {
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
}
|
||||
|
||||
app.WebhookSecret = uuid.New().String()
|
||||
app.SSHPrivateKey = keyPair.PrivateKey
|
||||
app.SSHPublicKey = keyPair.PublicKey
|
||||
app.Status = models.AppStatusPending
|
||||
|
||||
if input.DockerNetwork != "" {
|
||||
app.DockerNetwork = sql.NullString{String: input.DockerNetwork, Valid: true}
|
||||
}
|
||||
|
||||
if input.NtfyTopic != "" {
|
||||
app.NtfyTopic = sql.NullString{String: input.NtfyTopic, Valid: true}
|
||||
}
|
||||
|
||||
if input.SlackWebhook != "" {
|
||||
app.SlackWebhook = sql.NullString{String: input.SlackWebhook, Valid: true}
|
||||
}
|
||||
|
||||
saveErr := app.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return nil, fmt.Errorf("failed to save app: %w", saveErr)
|
||||
}
|
||||
|
||||
svc.log.Info("app created", "id", app.ID, "name", app.Name)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// UpdateAppInput contains the input for updating an app.
|
||||
type UpdateAppInput struct {
|
||||
Name string
|
||||
RepoURL string
|
||||
Branch string
|
||||
DockerfilePath string
|
||||
DockerNetwork string
|
||||
NtfyTopic string
|
||||
SlackWebhook string
|
||||
}
|
||||
|
||||
// UpdateApp updates an existing application.
|
||||
func (svc *Service) UpdateApp(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
input UpdateAppInput,
|
||||
) error {
|
||||
app.Name = input.Name
|
||||
app.RepoURL = input.RepoURL
|
||||
app.Branch = input.Branch
|
||||
app.DockerfilePath = input.DockerfilePath
|
||||
|
||||
app.DockerNetwork = sql.NullString{
|
||||
String: input.DockerNetwork,
|
||||
Valid: input.DockerNetwork != "",
|
||||
}
|
||||
app.NtfyTopic = sql.NullString{
|
||||
String: input.NtfyTopic,
|
||||
Valid: input.NtfyTopic != "",
|
||||
}
|
||||
app.SlackWebhook = sql.NullString{
|
||||
String: input.SlackWebhook,
|
||||
Valid: input.SlackWebhook != "",
|
||||
}
|
||||
|
||||
saveErr := app.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save app: %w", saveErr)
|
||||
}
|
||||
|
||||
svc.log.Info("app updated", "id", app.ID, "name", app.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteApp deletes an application and its related data.
|
||||
func (svc *Service) DeleteApp(ctx context.Context, app *models.App) error {
|
||||
// Related data is deleted by CASCADE
|
||||
deleteErr := app.Delete(ctx)
|
||||
if deleteErr != nil {
|
||||
return fmt.Errorf("failed to delete app: %w", deleteErr)
|
||||
}
|
||||
|
||||
svc.log.Info("app deleted", "id", app.ID, "name", app.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetApp retrieves an app by ID.
|
||||
func (svc *Service) GetApp(ctx context.Context, appID string) (*models.App, error) {
|
||||
app, err := models.FindApp(ctx, svc.db, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find app: %w", err)
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// GetAppByWebhookSecret retrieves an app by webhook secret.
|
||||
func (svc *Service) GetAppByWebhookSecret(
|
||||
ctx context.Context,
|
||||
secret string,
|
||||
) (*models.App, error) {
|
||||
app, err := models.FindAppByWebhookSecret(ctx, svc.db, secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find app by webhook secret: %w", err)
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// ListApps returns all apps.
|
||||
func (svc *Service) ListApps(ctx context.Context) ([]*models.App, error) {
|
||||
apps, err := models.AllApps(ctx, svc.db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list apps: %w", err)
|
||||
}
|
||||
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// AddEnvVar adds an environment variable to an app.
|
||||
func (svc *Service) AddEnvVar(
|
||||
ctx context.Context,
|
||||
appID, key, value string,
|
||||
) error {
|
||||
envVar := models.NewEnvVar(svc.db)
|
||||
envVar.AppID = appID
|
||||
envVar.Key = key
|
||||
envVar.Value = value
|
||||
|
||||
saveErr := envVar.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save env var: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEnvVar deletes an environment variable.
|
||||
func (svc *Service) DeleteEnvVar(ctx context.Context, envVarID int64) error {
|
||||
envVar, err := models.FindEnvVar(ctx, svc.db, envVarID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find env var: %w", err)
|
||||
}
|
||||
|
||||
if envVar == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deleteErr := envVar.Delete(ctx)
|
||||
if deleteErr != nil {
|
||||
return fmt.Errorf("failed to delete env var: %w", deleteErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddLabel adds a label to an app.
|
||||
func (svc *Service) AddLabel(
|
||||
ctx context.Context,
|
||||
appID, key, value string,
|
||||
) error {
|
||||
label := models.NewLabel(svc.db)
|
||||
label.AppID = appID
|
||||
label.Key = key
|
||||
label.Value = value
|
||||
|
||||
saveErr := label.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save label: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLabel deletes a label.
|
||||
func (svc *Service) DeleteLabel(ctx context.Context, labelID int64) error {
|
||||
label, err := models.FindLabel(ctx, svc.db, labelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find label: %w", err)
|
||||
}
|
||||
|
||||
if label == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deleteErr := label.Delete(ctx)
|
||||
if deleteErr != nil {
|
||||
return fmt.Errorf("failed to delete label: %w", deleteErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddVolume adds a volume mount to an app.
|
||||
func (svc *Service) AddVolume(
|
||||
ctx context.Context,
|
||||
appID, hostPath, containerPath string,
|
||||
readonly bool,
|
||||
) error {
|
||||
volume := models.NewVolume(svc.db)
|
||||
volume.AppID = appID
|
||||
volume.HostPath = hostPath
|
||||
volume.ContainerPath = containerPath
|
||||
volume.ReadOnly = readonly
|
||||
|
||||
saveErr := volume.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save volume: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteVolume deletes a volume mount.
|
||||
func (svc *Service) DeleteVolume(ctx context.Context, volumeID int64) error {
|
||||
volume, err := models.FindVolume(ctx, svc.db, volumeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find volume: %w", err)
|
||||
}
|
||||
|
||||
if volume == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deleteErr := volume.Delete(ctx)
|
||||
if deleteErr != nil {
|
||||
return fmt.Errorf("failed to delete volume: %w", deleteErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAppStatus updates the status of an app.
|
||||
func (svc *Service) UpdateAppStatus(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
status models.AppStatus,
|
||||
) error {
|
||||
app.Status = status
|
||||
|
||||
saveErr := app.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save app status: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAppContainer updates the container ID of an app.
|
||||
func (svc *Service) UpdateAppContainer(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
containerID, imageID string,
|
||||
) error {
|
||||
app.ContainerID = sql.NullString{String: containerID, Valid: containerID != ""}
|
||||
app.ImageID = sql.NullString{String: imageID, Valid: imageID != ""}
|
||||
|
||||
saveErr := app.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save app container: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
636
internal/service/app/app_test.go
Normal file
636
internal/service/app/app_test.go
Normal file
@@ -0,0 +1,636 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) (*app.Service, func()) {
|
||||
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)
|
||||
|
||||
svc, err := app.New(fx.Lifecycle(nil), app.ServiceParams{
|
||||
Logger: loggerInst,
|
||||
Database: dbInst,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// t.TempDir() automatically cleans up after test
|
||||
cleanup := func() {}
|
||||
|
||||
return svc, cleanup
|
||||
}
|
||||
|
||||
// deleteItemTestHelper is a generic helper for testing delete operations.
|
||||
// It creates an app, adds an item, verifies it exists, deletes it, and verifies it's gone.
|
||||
func deleteItemTestHelper(
|
||||
t *testing.T,
|
||||
appName string,
|
||||
addItem func(ctx context.Context, svc *app.Service, appID string) error,
|
||||
getCount func(ctx context.Context, application *models.App) (int, error),
|
||||
deleteItem func(ctx context.Context, svc *app.Service, application *models.App) error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: appName,
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = addItem(context.Background(), svc, createdApp.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := getCount(context.Background(), createdApp)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
err = deleteItem(context.Background(), svc, createdApp)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err = getCount(context.Background(), createdApp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestCreateAppWithGeneratedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
input := app.CreateAppInput{
|
||||
Name: "test-app",
|
||||
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||
Branch: "main",
|
||||
DockerfilePath: "Dockerfile",
|
||||
}
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdApp)
|
||||
|
||||
assert.Equal(t, "test-app", createdApp.Name)
|
||||
assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
|
||||
assert.Equal(t, "main", createdApp.Branch)
|
||||
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
|
||||
assert.NotEmpty(t, createdApp.ID)
|
||||
assert.NotEmpty(t, createdApp.WebhookSecret)
|
||||
assert.NotEmpty(t, createdApp.SSHPrivateKey)
|
||||
assert.NotEmpty(t, createdApp.SSHPublicKey)
|
||||
assert.Contains(t, createdApp.SSHPrivateKey, "-----BEGIN OPENSSH PRIVATE KEY-----")
|
||||
assert.Contains(t, createdApp.SSHPublicKey, "ssh-ed25519")
|
||||
assert.Equal(t, models.AppStatusPending, createdApp.Status)
|
||||
}
|
||||
|
||||
func TestCreateAppDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
input := app.CreateAppInput{
|
||||
Name: "test-app-defaults",
|
||||
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||
}
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "main", createdApp.Branch)
|
||||
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
|
||||
}
|
||||
|
||||
func TestCreateAppOptionalFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
input := app.CreateAppInput{
|
||||
Name: "test-app-full",
|
||||
RepoURL: "git@gitea.example.com:user/repo.git",
|
||||
Branch: "develop",
|
||||
DockerNetwork: "my-network",
|
||||
NtfyTopic: "https://ntfy.sh/my-topic",
|
||||
SlackWebhook: "https://hooks.slack.com/services/xxx",
|
||||
}
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, createdApp.DockerNetwork.Valid)
|
||||
assert.Equal(t, "my-network", createdApp.DockerNetwork.String)
|
||||
assert.True(t, createdApp.NtfyTopic.Valid)
|
||||
assert.Equal(t, "https://ntfy.sh/my-topic", createdApp.NtfyTopic.String)
|
||||
assert.True(t, createdApp.SlackWebhook.Valid)
|
||||
}
|
||||
|
||||
func TestUpdateApp(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("updates app fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "original-name",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
|
||||
Name: "updated-name",
|
||||
RepoURL: "git@example.com:user/new-repo.git",
|
||||
Branch: "develop",
|
||||
DockerfilePath: "docker/Dockerfile",
|
||||
DockerNetwork: "prod-network",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reload and verify
|
||||
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "updated-name", reloaded.Name)
|
||||
assert.Equal(t, "git@example.com:user/new-repo.git", reloaded.RepoURL)
|
||||
assert.Equal(t, "develop", reloaded.Branch)
|
||||
assert.Equal(t, "docker/Dockerfile", reloaded.DockerfilePath)
|
||||
assert.Equal(t, "prod-network", reloaded.DockerNetwork.String)
|
||||
})
|
||||
|
||||
testingT.Run("clears optional fields when empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "test-clear",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
NtfyTopic: "https://ntfy.sh/topic",
|
||||
SlackWebhook: "https://slack.com/hook",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
|
||||
Name: "test-clear",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
Branch: "main",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, reloaded.NtfyTopic.Valid)
|
||||
assert.False(t, reloaded.SlackWebhook.Valid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteApp(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("deletes app and returns nil on lookup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "to-delete",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.DeleteApp(context.Background(), createdApp)
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted, err := svc.GetApp(context.Background(), createdApp.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, deleted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetApp(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("finds existing app", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "findable-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := svc.GetApp(context.Background(), created.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
|
||||
assert.Equal(t, created.ID, found.ID)
|
||||
assert.Equal(t, "findable-app", found.Name)
|
||||
})
|
||||
|
||||
testingT.Run("returns nil for non-existent app", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
found, err := svc.GetApp(context.Background(), "non-existent-id")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAppByWebhookSecret(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("finds app by webhook secret", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "webhook-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := svc.GetAppByWebhookSecret(context.Background(), created.WebhookSecret)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
|
||||
assert.Equal(t, created.ID, found.ID)
|
||||
})
|
||||
|
||||
testingT.Run("returns nil for invalid secret", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
found, err := svc.GetAppByWebhookSecret(context.Background(), "invalid-secret")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListApps(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("returns empty list when no apps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
apps, err := svc.ListApps(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, apps)
|
||||
})
|
||||
|
||||
testingT.Run("returns all apps ordered by name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "charlie",
|
||||
RepoURL: "git@example.com:user/c.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "alpha",
|
||||
RepoURL: "git@example.com:user/a.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "bravo",
|
||||
RepoURL: "git@example.com:user/b.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
apps, err := svc.ListApps(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apps, 3)
|
||||
|
||||
assert.Equal(t, "alpha", apps[0].Name)
|
||||
assert.Equal(t, "bravo", apps[1].Name)
|
||||
assert.Equal(t, "charlie", apps[2].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnvVarsAddAndRetrieve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "env-test",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AddEnvVar(
|
||||
context.Background(),
|
||||
createdApp.ID,
|
||||
"DATABASE_URL",
|
||||
"postgres://localhost/db",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AddEnvVar(
|
||||
context.Background(),
|
||||
createdApp.ID,
|
||||
"API_KEY",
|
||||
"secret123",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
envVars, err := createdApp.GetEnvVars(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, envVars, 2)
|
||||
|
||||
keys := make(map[string]string)
|
||||
for _, envVar := range envVars {
|
||||
keys[envVar.Key] = envVar.Value
|
||||
}
|
||||
|
||||
assert.Equal(t, "postgres://localhost/db", keys["DATABASE_URL"])
|
||||
assert.Equal(t, "secret123", keys["API_KEY"])
|
||||
}
|
||||
|
||||
func TestEnvVarsDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deleteItemTestHelper(t, "env-delete-test",
|
||||
func(ctx context.Context, svc *app.Service, appID string) error {
|
||||
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
|
||||
},
|
||||
func(ctx context.Context, application *models.App) (int, error) {
|
||||
envVars, err := application.GetEnvVars(ctx)
|
||||
|
||||
return len(envVars), err
|
||||
},
|
||||
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
||||
envVars, err := application.GetEnvVars(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.DeleteEnvVar(ctx, envVars[0].ID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestLabels(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("adds and retrieves labels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "label-test",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AddLabel(context.Background(), createdApp.ID, "traefik.enable", "true")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AddLabel(
|
||||
context.Background(),
|
||||
createdApp.ID,
|
||||
"com.example.env",
|
||||
"production",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
labels, err := createdApp.GetLabels(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, labels, 2)
|
||||
})
|
||||
|
||||
testingT.Run("deletes label", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deleteItemTestHelper(t, "label-delete-test",
|
||||
func(ctx context.Context, svc *app.Service, appID string) error {
|
||||
return svc.AddLabel(ctx, appID, "to.delete", "value")
|
||||
},
|
||||
func(ctx context.Context, application *models.App) (int, error) {
|
||||
labels, err := application.GetLabels(ctx)
|
||||
|
||||
return len(labels), err
|
||||
},
|
||||
func(ctx context.Context, svc *app.Service, application *models.App) error {
|
||||
labels, err := application.GetLabels(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.DeleteLabel(ctx, labels[0].ID)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVolumesAddAndRetrieve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "volume-test",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AddVolume(
|
||||
context.Background(),
|
||||
createdApp.ID,
|
||||
"/host/data",
|
||||
"/app/data",
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AddVolume(
|
||||
context.Background(),
|
||||
createdApp.ID,
|
||||
"/host/config",
|
||||
"/app/config",
|
||||
true,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
volumes, err := createdApp.GetVolumes(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, volumes, 2)
|
||||
|
||||
// Find readonly volume
|
||||
var readonlyVolume *models.Volume
|
||||
|
||||
for _, vol := range volumes {
|
||||
if vol.ReadOnly {
|
||||
readonlyVolume = vol
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, readonlyVolume)
|
||||
assert.Equal(t, "/host/config", readonlyVolume.HostPath)
|
||||
assert.Equal(t, "/app/config", readonlyVolume.ContainerPath)
|
||||
}
|
||||
|
||||
func TestVolumesDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "volume-delete-test",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.AddVolume(
|
||||
context.Background(),
|
||||
createdApp.ID,
|
||||
"/host/path",
|
||||
"/container/path",
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
volumes, err := createdApp.GetVolumes(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, volumes, 1)
|
||||
|
||||
err = svc.DeleteVolume(context.Background(), volumes[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
volumes, err = createdApp.GetVolumes(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, volumes)
|
||||
}
|
||||
|
||||
func TestUpdateAppStatus(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("updates app status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "status-test",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.AppStatusPending, createdApp.Status)
|
||||
|
||||
err = svc.UpdateAppStatus(
|
||||
context.Background(),
|
||||
createdApp,
|
||||
models.AppStatusBuilding,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.AppStatusBuilding, reloaded.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAppContainer(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("updates container and image IDs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
|
||||
Name: "container-test",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, createdApp.ContainerID.Valid)
|
||||
assert.False(t, createdApp.ImageID.Valid)
|
||||
|
||||
err = svc.UpdateAppContainer(
|
||||
context.Background(),
|
||||
createdApp,
|
||||
"container123",
|
||||
"image456",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reloaded.ContainerID.Valid)
|
||||
assert.Equal(t, "container123", reloaded.ContainerID.String)
|
||||
assert.True(t, reloaded.ImageID.Valid)
|
||||
assert.Equal(t, "image456", reloaded.ImageID.String)
|
||||
})
|
||||
}
|
||||
286
internal/service/auth/auth.go
Normal file
286
internal/service/auth/auth.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Package auth provides authentication services.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionName = "upaas_session"
|
||||
sessionUserID = "user_id"
|
||||
)
|
||||
|
||||
// Argon2 parameters.
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// Session duration constants.
|
||||
const (
|
||||
sessionMaxAgeDays = 7
|
||||
sessionMaxAgeSeconds = 86400 * sessionMaxAgeDays
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidCredentials is returned when username/password is incorrect.
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
// ErrUserExists is returned when trying to create a user that already exists.
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
type ServiceParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
}
|
||||
|
||||
// Service provides authentication functionality.
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
db *database.Database
|
||||
store *sessions.CookieStore
|
||||
params *ServiceParams
|
||||
}
|
||||
|
||||
// New creates a new auth Service.
|
||||
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
store := sessions.NewCookieStore([]byte(params.Config.SessionSecret))
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: sessionMaxAgeSeconds,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
return &Service{
|
||||
log: params.Logger.Get(),
|
||||
db: params.Database,
|
||||
store: store,
|
||||
params: ¶ms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using Argon2id.
|
||||
func (svc *Service) HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
argonTime,
|
||||
argonMemory,
|
||||
argonThreads,
|
||||
argonKeyLen,
|
||||
)
|
||||
|
||||
// Encode as base64: salt$hash
|
||||
saltB64 := base64.StdEncoding.EncodeToString(salt)
|
||||
hashB64 := base64.StdEncoding.EncodeToString(hash)
|
||||
|
||||
return saltB64 + "$" + hashB64, nil
|
||||
}
|
||||
|
||||
// VerifyPassword verifies a password against a hash.
|
||||
func (svc *Service) VerifyPassword(hashedPassword, password string) bool {
|
||||
// Parse salt$hash format using strings.Cut (more reliable than fmt.Sscanf)
|
||||
saltB64, hashB64, found := strings.Cut(hashedPassword, "$")
|
||||
if !found || saltB64 == "" || hashB64 == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.StdEncoding.DecodeString(saltB64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expectedHash, err := base64.StdEncoding.DecodeString(hashB64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute hash with same parameters
|
||||
computedHash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
argonTime,
|
||||
argonMemory,
|
||||
argonThreads,
|
||||
argonKeyLen,
|
||||
)
|
||||
|
||||
// Constant-time comparison
|
||||
if len(computedHash) != len(expectedHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
var result byte
|
||||
|
||||
for idx := range computedHash {
|
||||
result |= computedHash[idx] ^ expectedHash[idx]
|
||||
}
|
||||
|
||||
return result == 0
|
||||
}
|
||||
|
||||
// IsSetupRequired checks if initial setup is needed (no users exist).
|
||||
func (svc *Service) IsSetupRequired(ctx context.Context) (bool, error) {
|
||||
exists, err := models.UserExists(ctx, svc.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
|
||||
return !exists, nil
|
||||
}
|
||||
|
||||
// CreateUser creates the initial admin user.
|
||||
func (svc *Service) CreateUser(
|
||||
ctx context.Context,
|
||||
username, password string,
|
||||
) (*models.User, error) {
|
||||
// Check if user already exists
|
||||
exists, err := models.UserExists(ctx, svc.db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := svc.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Create user
|
||||
user := models.NewUser(svc.db)
|
||||
user.Username = username
|
||||
user.PasswordHash = hash
|
||||
|
||||
err = user.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save user: %w", err)
|
||||
}
|
||||
|
||||
svc.log.Info("user created", "username", username)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Authenticate validates credentials and returns the user.
|
||||
func (svc *Service) Authenticate(
|
||||
ctx context.Context,
|
||||
username, password string,
|
||||
) (*models.User, error) {
|
||||
user, err := models.FindUserByUsername(ctx, svc.db, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !svc.VerifyPassword(user.PasswordHash, password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateSession creates a session for the user.
|
||||
func (svc *Service) CreateSession(
|
||||
respWriter http.ResponseWriter,
|
||||
request *http.Request,
|
||||
user *models.User,
|
||||
) error {
|
||||
session, err := svc.store.Get(request, sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
session.Values[sessionUserID] = user.ID
|
||||
|
||||
saveErr := session.Save(request, respWriter)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save session: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the currently logged-in user, or nil if not logged in.
|
||||
//
|
||||
//nolint:nilerr // Session errors are not propagated - they indicate no user
|
||||
func (svc *Service) GetCurrentUser(
|
||||
ctx context.Context,
|
||||
request *http.Request,
|
||||
) (*models.User, error) {
|
||||
session, sessionErr := svc.store.Get(request, sessionName)
|
||||
if sessionErr != nil {
|
||||
// Session error means no user - this is not an error condition
|
||||
return nil, nil //nolint:nilnil // Expected behavior for no session
|
||||
}
|
||||
|
||||
userID, ok := session.Values[sessionUserID].(int64)
|
||||
if !ok {
|
||||
return nil, nil //nolint:nilnil // No user ID in session is valid
|
||||
}
|
||||
|
||||
user, err := models.FindUser(ctx, svc.db, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// DestroySession destroys the current session.
|
||||
func (svc *Service) DestroySession(
|
||||
respWriter http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) error {
|
||||
session, err := svc.store.Get(request, sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
session.Options.MaxAge = -1 * int(time.Second)
|
||||
|
||||
saveErr := session.Save(request, respWriter)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to save session: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
243
internal/service/auth/auth_test.go
Normal file
243
internal/service/auth/auth_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) (*auth.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Create temp directory
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Set up globals
|
||||
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)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Port: 8080,
|
||||
DataDir: tmpDir,
|
||||
SessionSecret: "test-secret-key-at-least-32-chars",
|
||||
}
|
||||
|
||||
// Create database
|
||||
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
|
||||
Logger: loggerInst,
|
||||
Config: cfg,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Connect database manually for tests
|
||||
dbPath := filepath.Join(tmpDir, "upaas.db")
|
||||
cfg.DataDir = tmpDir
|
||||
_ = dbPath // database will create this
|
||||
|
||||
// Create service
|
||||
svc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
|
||||
Logger: loggerInst,
|
||||
Config: cfg,
|
||||
Database: dbInst,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// t.TempDir() automatically cleans up after test
|
||||
cleanup := func() {}
|
||||
|
||||
return svc, cleanup
|
||||
}
|
||||
|
||||
func TestHashPassword(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("hashes password successfully", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("testpassword")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hash)
|
||||
assert.NotEqual(t, "testpassword", hash)
|
||||
assert.Contains(t, hash, "$") // salt$hash format
|
||||
})
|
||||
|
||||
testingT.Run("produces different hashes for same password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash1, err := svc.HashPassword("testpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
hash2, err := svc.HashPassword("testpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, hash1, hash2) // Different salts
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyPassword(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("verifies correct password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
valid := svc.VerifyPassword(hash, "correctpassword")
|
||||
assert.True(t, valid)
|
||||
})
|
||||
|
||||
testingT.Run("rejects incorrect password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
valid := svc.VerifyPassword(hash, "wrongpassword")
|
||||
assert.False(t, valid)
|
||||
})
|
||||
|
||||
testingT.Run("rejects empty password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
hash, err := svc.HashPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
valid := svc.VerifyPassword(hash, "")
|
||||
assert.False(t, valid)
|
||||
})
|
||||
|
||||
testingT.Run("rejects invalid hash format", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
valid := svc.VerifyPassword("invalid-hash", "password")
|
||||
assert.False(t, valid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsSetupRequired(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("returns true when no users exist", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
required, err := svc.IsSetupRequired(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, required)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateUser(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("creates user successfully", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
user, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
assert.Equal(t, "admin", user.Username)
|
||||
assert.NotEmpty(t, user.PasswordHash)
|
||||
assert.NotZero(t, user.ID)
|
||||
})
|
||||
|
||||
testingT.Run("rejects duplicate user", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateUser(context.Background(), "admin2", "password456")
|
||||
assert.ErrorIs(t, err, auth.ErrUserExists)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthenticate(testingT *testing.T) {
|
||||
testingT.Parallel()
|
||||
|
||||
testingT.Run("authenticates valid credentials", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := svc.Authenticate(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
assert.Equal(t, "admin", user.Username)
|
||||
})
|
||||
|
||||
testingT.Run("rejects invalid password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.CreateUser(context.Background(), "admin", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.Authenticate(context.Background(), "admin", "wrongpassword")
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
|
||||
})
|
||||
|
||||
testingT.Run("rejects unknown user", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := svc.Authenticate(context.Background(), "nonexistent", "password")
|
||||
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
|
||||
})
|
||||
}
|
||||
451
internal/service/deploy/deploy.go
Normal file
451
internal/service/deploy/deploy.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// Package deploy provides deployment services.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||
)
|
||||
|
||||
// Time constants.
|
||||
const (
|
||||
healthCheckDelaySeconds = 60
|
||||
// upaasLabelCount is the number of upaas-specific labels added to containers.
|
||||
upaasLabelCount = 2
|
||||
)
|
||||
|
||||
// Sentinel errors for deployment failures.
|
||||
var (
|
||||
// ErrContainerUnhealthy indicates the container failed health check.
|
||||
ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds")
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
type ServiceParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
Docker *docker.Client
|
||||
Notify *notify.Service
|
||||
}
|
||||
|
||||
// Service provides deployment functionality.
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
db *database.Database
|
||||
docker *docker.Client
|
||||
notify *notify.Service
|
||||
config *config.Config
|
||||
params *ServiceParams
|
||||
}
|
||||
|
||||
// New creates a new deploy Service.
|
||||
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
return &Service{
|
||||
log: params.Logger.Get(),
|
||||
db: params.Database,
|
||||
docker: params.Docker,
|
||||
notify: params.Notify,
|
||||
config: params.Config,
|
||||
params: ¶ms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBuildDir returns the build directory path for an app.
|
||||
func (svc *Service) GetBuildDir(appID string) string {
|
||||
return filepath.Join(svc.config.DataDir, "builds", appID)
|
||||
}
|
||||
|
||||
// Deploy deploys an app.
|
||||
func (svc *Service) Deploy(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
webhookEventID *int64,
|
||||
) error {
|
||||
deployment, err := svc.createDeploymentRecord(ctx, app, webhookEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = svc.updateAppStatusBuilding(ctx, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.notify.NotifyBuildStart(ctx, app, deployment)
|
||||
|
||||
imageID, err := svc.buildImage(ctx, app, deployment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.notify.NotifyBuildSuccess(ctx, app, deployment)
|
||||
|
||||
err = svc.updateDeploymentDeploying(ctx, deployment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.removeOldContainer(ctx, app, deployment)
|
||||
|
||||
containerID, err := svc.createAndStartContainer(ctx, app, deployment, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = svc.updateAppRunning(ctx, app, containerID, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use context.WithoutCancel to ensure health check completes even if
|
||||
// the parent context is cancelled (e.g., HTTP request ends).
|
||||
go svc.checkHealthAfterDelay(context.WithoutCancel(ctx), app, deployment)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) createDeploymentRecord(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
webhookEventID *int64,
|
||||
) (*models.Deployment, error) {
|
||||
deployment := models.NewDeployment(svc.db)
|
||||
deployment.AppID = app.ID
|
||||
|
||||
if webhookEventID != nil {
|
||||
deployment.WebhookEventID = sql.NullInt64{
|
||||
Int64: *webhookEventID,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
deployment.Status = models.DeploymentStatusBuilding
|
||||
|
||||
saveErr := deployment.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return nil, fmt.Errorf("failed to create deployment: %w", saveErr)
|
||||
}
|
||||
|
||||
return deployment, nil
|
||||
}
|
||||
|
||||
func (svc *Service) updateAppStatusBuilding(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
) error {
|
||||
app.Status = models.AppStatusBuilding
|
||||
|
||||
saveErr := app.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to update app status: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) buildImage(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) (string, error) {
|
||||
tempDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer cleanup()
|
||||
|
||||
imageTag := "upaas/" + app.Name + ":latest"
|
||||
|
||||
imageID, err := svc.docker.BuildImage(ctx, docker.BuildImageOptions{
|
||||
ContextDir: tempDir,
|
||||
DockerfilePath: app.DockerfilePath,
|
||||
Tags: []string{imageTag},
|
||||
})
|
||||
if err != nil {
|
||||
svc.notify.NotifyBuildFailed(ctx, app, deployment, err)
|
||||
svc.failDeployment(
|
||||
ctx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to build image: %w", err),
|
||||
)
|
||||
|
||||
return "", fmt.Errorf("failed to build image: %w", err)
|
||||
}
|
||||
|
||||
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
|
||||
_ = deployment.AppendLog(ctx, "Image built: "+imageID)
|
||||
|
||||
return imageID, nil
|
||||
}
|
||||
|
||||
func (svc *Service) cloneRepository(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) (string, func(), error) {
|
||||
tempDir, err := os.MkdirTemp("", "upaas-"+app.ID+"-*")
|
||||
if err != nil {
|
||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
|
||||
|
||||
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() { _ = os.RemoveAll(tempDir) }
|
||||
|
||||
cloneErr := svc.docker.CloneRepo(ctx, app.RepoURL, app.Branch, app.SSHPrivateKey, tempDir)
|
||||
if cloneErr != nil {
|
||||
cleanup()
|
||||
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
|
||||
|
||||
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Repository cloned successfully")
|
||||
|
||||
return tempDir, cleanup, nil
|
||||
}
|
||||
|
||||
func (svc *Service) updateDeploymentDeploying(
|
||||
ctx context.Context,
|
||||
deployment *models.Deployment,
|
||||
) error {
|
||||
deployment.Status = models.DeploymentStatusDeploying
|
||||
|
||||
saveErr := deployment.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to update deployment status: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) removeOldContainer(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) {
|
||||
if !app.ContainerID.Valid || app.ContainerID.String == "" {
|
||||
return
|
||||
}
|
||||
|
||||
svc.log.Info("removing old container", "id", app.ContainerID.String)
|
||||
|
||||
removeErr := svc.docker.RemoveContainer(ctx, app.ContainerID.String, true)
|
||||
if removeErr != nil {
|
||||
svc.log.Warn("failed to remove old container", "error", removeErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Old container removed")
|
||||
}
|
||||
|
||||
func (svc *Service) createAndStartContainer(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
imageID string,
|
||||
) (string, error) {
|
||||
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID)
|
||||
if err != nil {
|
||||
svc.failDeployment(ctx, app, deployment, err)
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
containerID, err := svc.docker.CreateContainer(ctx, containerOpts)
|
||||
if err != nil {
|
||||
svc.notify.NotifyDeployFailed(ctx, app, deployment, err)
|
||||
svc.failDeployment(
|
||||
ctx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to create container: %w", err),
|
||||
)
|
||||
|
||||
return "", fmt.Errorf("failed to create container: %w", err)
|
||||
}
|
||||
|
||||
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
|
||||
_ = deployment.AppendLog(ctx, "Container created: "+containerID)
|
||||
|
||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||
if startErr != nil {
|
||||
svc.notify.NotifyDeployFailed(ctx, app, deployment, startErr)
|
||||
svc.failDeployment(
|
||||
ctx,
|
||||
app,
|
||||
deployment,
|
||||
fmt.Errorf("failed to start container: %w", startErr),
|
||||
)
|
||||
|
||||
return "", fmt.Errorf("failed to start container: %w", startErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Container started")
|
||||
|
||||
return containerID, nil
|
||||
}
|
||||
|
||||
func (svc *Service) buildContainerOptions(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
_ string,
|
||||
) (docker.CreateContainerOptions, error) {
|
||||
envVars, err := app.GetEnvVars(ctx)
|
||||
if err != nil {
|
||||
return docker.CreateContainerOptions{}, fmt.Errorf("failed to get env vars: %w", err)
|
||||
}
|
||||
|
||||
labels, err := app.GetLabels(ctx)
|
||||
if err != nil {
|
||||
return docker.CreateContainerOptions{}, fmt.Errorf("failed to get labels: %w", err)
|
||||
}
|
||||
|
||||
volumes, err := app.GetVolumes(ctx)
|
||||
if err != nil {
|
||||
return docker.CreateContainerOptions{}, fmt.Errorf("failed to get volumes: %w", err)
|
||||
}
|
||||
|
||||
envMap := make(map[string]string, len(envVars))
|
||||
for _, envVar := range envVars {
|
||||
envMap[envVar.Key] = envVar.Value
|
||||
}
|
||||
|
||||
network := ""
|
||||
if app.DockerNetwork.Valid {
|
||||
network = app.DockerNetwork.String
|
||||
}
|
||||
|
||||
return docker.CreateContainerOptions{
|
||||
Name: "upaas-" + app.Name,
|
||||
Image: "upaas/" + app.Name + ":latest",
|
||||
Env: envMap,
|
||||
Labels: buildLabelMap(app, labels),
|
||||
Volumes: buildVolumeMounts(volumes),
|
||||
Network: network,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildLabelMap(app *models.App, labels []*models.Label) map[string]string {
|
||||
labelMap := make(map[string]string, len(labels)+upaasLabelCount)
|
||||
for _, label := range labels {
|
||||
labelMap[label.Key] = label.Value
|
||||
}
|
||||
|
||||
labelMap["upaas.app.id"] = app.ID
|
||||
labelMap["upaas.app.name"] = app.Name
|
||||
|
||||
return labelMap
|
||||
}
|
||||
|
||||
func buildVolumeMounts(volumes []*models.Volume) []docker.VolumeMount {
|
||||
mounts := make([]docker.VolumeMount, 0, len(volumes))
|
||||
for _, vol := range volumes {
|
||||
mounts = append(mounts, docker.VolumeMount{
|
||||
HostPath: vol.HostPath,
|
||||
ContainerPath: vol.ContainerPath,
|
||||
ReadOnly: vol.ReadOnly,
|
||||
})
|
||||
}
|
||||
|
||||
return mounts
|
||||
}
|
||||
|
||||
func (svc *Service) updateAppRunning(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
containerID, imageID string,
|
||||
) error {
|
||||
app.ContainerID = sql.NullString{String: containerID, Valid: true}
|
||||
app.ImageID = sql.NullString{String: imageID, Valid: true}
|
||||
app.Status = models.AppStatusRunning
|
||||
|
||||
saveErr := app.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to update app: %w", saveErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) checkHealthAfterDelay(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) {
|
||||
svc.log.Info(
|
||||
"waiting 60 seconds to check container health",
|
||||
"app", app.Name,
|
||||
)
|
||||
time.Sleep(healthCheckDelaySeconds * time.Second)
|
||||
|
||||
// Reload app to get current state
|
||||
reloadedApp, err := models.FindApp(ctx, svc.db, app.ID)
|
||||
if err != nil || reloadedApp == nil {
|
||||
svc.log.Error("failed to reload app for health check", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !reloadedApp.ContainerID.Valid {
|
||||
return
|
||||
}
|
||||
|
||||
healthy, err := svc.docker.IsContainerHealthy(
|
||||
ctx,
|
||||
reloadedApp.ContainerID.String,
|
||||
)
|
||||
if err != nil {
|
||||
svc.log.Error("failed to check container health", "error", err)
|
||||
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err)
|
||||
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if healthy {
|
||||
svc.log.Info("container healthy after 60 seconds", "app", reloadedApp.Name)
|
||||
svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment)
|
||||
_ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess)
|
||||
} else {
|
||||
svc.log.Warn(
|
||||
"container unhealthy after 60 seconds",
|
||||
"app", reloadedApp.Name,
|
||||
)
|
||||
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy)
|
||||
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
|
||||
reloadedApp.Status = models.AppStatusError
|
||||
_ = reloadedApp.Save(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *Service) failDeployment(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
deployErr error,
|
||||
) {
|
||||
svc.log.Error("deployment failed", "app", app.Name, "error", deployErr)
|
||||
_ = deployment.AppendLog(ctx, "ERROR: "+deployErr.Error())
|
||||
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
|
||||
app.Status = models.AppStatusError
|
||||
_ = app.Save(ctx)
|
||||
}
|
||||
280
internal/service/notify/notify.go
Normal file
280
internal/service/notify/notify.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Package notify provides notification services.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
// HTTP client timeout.
|
||||
const (
|
||||
httpClientTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// HTTP status code thresholds.
|
||||
const (
|
||||
httpStatusClientError = 400
|
||||
)
|
||||
|
||||
// Sentinel errors for notification failures.
|
||||
var (
|
||||
// ErrNtfyFailed indicates the ntfy notification request failed.
|
||||
ErrNtfyFailed = errors.New("ntfy notification failed")
|
||||
// ErrSlackFailed indicates the Slack notification request failed.
|
||||
ErrSlackFailed = errors.New("slack notification failed")
|
||||
)
|
||||
|
||||
// ServiceParams contains dependencies for Service.
|
||||
type ServiceParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Service provides notification functionality.
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
client *http.Client
|
||||
params *ServiceParams
|
||||
}
|
||||
|
||||
// New creates a new notify Service.
|
||||
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
return &Service{
|
||||
log: params.Logger.Get(),
|
||||
client: &http.Client{
|
||||
Timeout: httpClientTimeout,
|
||||
},
|
||||
params: ¶ms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NotifyBuildStart sends a build started notification.
|
||||
func (svc *Service) NotifyBuildStart(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
_ *models.Deployment,
|
||||
) {
|
||||
title := "Build started: " + app.Name
|
||||
message := "Building from branch " + app.Branch
|
||||
svc.sendNotifications(ctx, app, title, message, "info")
|
||||
}
|
||||
|
||||
// NotifyBuildSuccess sends a build success notification.
|
||||
func (svc *Service) NotifyBuildSuccess(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
_ *models.Deployment,
|
||||
) {
|
||||
title := "Build success: " + app.Name
|
||||
message := "Image built successfully from branch " + app.Branch
|
||||
svc.sendNotifications(ctx, app, title, message, "success")
|
||||
}
|
||||
|
||||
// NotifyBuildFailed sends a build failed notification.
|
||||
func (svc *Service) NotifyBuildFailed(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
_ *models.Deployment,
|
||||
buildErr error,
|
||||
) {
|
||||
title := "Build failed: " + app.Name
|
||||
message := "Build failed: " + buildErr.Error()
|
||||
svc.sendNotifications(ctx, app, title, message, "error")
|
||||
}
|
||||
|
||||
// NotifyDeploySuccess sends a deploy success notification.
|
||||
func (svc *Service) NotifyDeploySuccess(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
_ *models.Deployment,
|
||||
) {
|
||||
title := "Deploy success: " + app.Name
|
||||
message := "Successfully deployed from branch " + app.Branch
|
||||
svc.sendNotifications(ctx, app, title, message, "success")
|
||||
}
|
||||
|
||||
// NotifyDeployFailed sends a deploy failed notification.
|
||||
func (svc *Service) NotifyDeployFailed(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
_ *models.Deployment,
|
||||
deployErr error,
|
||||
) {
|
||||
title := "Deploy failed: " + app.Name
|
||||
message := "Deployment failed: " + deployErr.Error()
|
||||
svc.sendNotifications(ctx, app, title, message, "error")
|
||||
}
|
||||
|
||||
func (svc *Service) sendNotifications(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
title, message, priority string,
|
||||
) {
|
||||
// Send to ntfy if configured
|
||||
if app.NtfyTopic.Valid && app.NtfyTopic.String != "" {
|
||||
ntfyTopic := app.NtfyTopic.String
|
||||
appName := app.Name
|
||||
|
||||
go func() {
|
||||
// Use context.WithoutCancel to ensure notification completes
|
||||
// even if the parent context is cancelled.
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
ntfyErr := svc.sendNtfy(notifyCtx, ntfyTopic, title, message, priority)
|
||||
if ntfyErr != nil {
|
||||
svc.log.Error(
|
||||
"failed to send ntfy notification",
|
||||
"error", ntfyErr,
|
||||
"app", appName,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Send to Slack if configured
|
||||
if app.SlackWebhook.Valid && app.SlackWebhook.String != "" {
|
||||
slackWebhook := app.SlackWebhook.String
|
||||
appName := app.Name
|
||||
|
||||
go func() {
|
||||
// Use context.WithoutCancel to ensure notification completes
|
||||
// even if the parent context is cancelled.
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message)
|
||||
if slackErr != nil {
|
||||
svc.log.Error(
|
||||
"failed to send slack notification",
|
||||
"error", slackErr,
|
||||
"app", appName,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *Service) sendNtfy(
|
||||
ctx context.Context,
|
||||
topic, title, message, priority string,
|
||||
) error {
|
||||
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
topic,
|
||||
bytes.NewBufferString(message),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create ntfy request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Title", title)
|
||||
request.Header.Set("Priority", svc.ntfyPriority(priority))
|
||||
|
||||
resp, err := svc.client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send ntfy request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= httpStatusClientError {
|
||||
return fmt.Errorf("%w: status %d", ErrNtfyFailed, resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) ntfyPriority(priority string) string {
|
||||
switch priority {
|
||||
case "error":
|
||||
return "urgent"
|
||||
case "success":
|
||||
return "default"
|
||||
case "info":
|
||||
return "low"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
// SlackPayload represents a Slack webhook payload.
|
||||
type SlackPayload struct {
|
||||
Text string `json:"text"`
|
||||
Attachments []SlackAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// SlackAttachment represents a Slack attachment.
|
||||
type SlackAttachment struct {
|
||||
Color string `json:"color"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (svc *Service) sendSlack(
|
||||
ctx context.Context,
|
||||
webhookURL, title, message string,
|
||||
) error {
|
||||
svc.log.Debug(
|
||||
"sending slack notification",
|
||||
"url", webhookURL,
|
||||
"title", title,
|
||||
)
|
||||
|
||||
payload := SlackPayload{
|
||||
Attachments: []SlackAttachment{
|
||||
{
|
||||
Color: "#36a64f",
|
||||
Title: title,
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create slack request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := svc.client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send slack request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= httpStatusClientError {
|
||||
return fmt.Errorf("%w: status %d", ErrSlackFailed, resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
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