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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user