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
281 lines
6.3 KiB
Go
281 lines
6.3 KiB
Go
// 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
|
|
}
|