upaas/internal/service/notify/notify.go
sneak 3f9d83c436 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
2025-12-29 15:46:03 +07:00

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: &params,
}, 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
}