- Clone specific commit SHA from webhook instead of just branch HEAD - Log webhook payload in deployment logs - Add build/deploy timing to ntfy and Slack notifications - Implement container rollback on deploy failure - Remove old container only after successful deployment - Show relative times in deployment history (hover for full date) - Update port mappings UI with labeled text inputs - Add footer with version info, license, and repo link - Format deploy key comment as upaas_DATE_appname
340 lines
7.7 KiB
Go
340 lines
7.7 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
|
|
)
|
|
|
|
// Display constants.
|
|
const (
|
|
shortCommitLength = 12
|
|
secondsPerMinute = 60
|
|
)
|
|
|
|
// 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,
|
|
deployment *models.Deployment,
|
|
) {
|
|
title := "Build started: " + app.Name
|
|
message := "Building from branch " + app.Branch
|
|
|
|
if deployment.CommitSHA.Valid {
|
|
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
|
|
message += " at " + shortSHA
|
|
}
|
|
|
|
svc.sendNotifications(ctx, app, title, message, "info")
|
|
}
|
|
|
|
// NotifyBuildSuccess sends a build success notification.
|
|
func (svc *Service) NotifyBuildSuccess(
|
|
ctx context.Context,
|
|
app *models.App,
|
|
deployment *models.Deployment,
|
|
) {
|
|
duration := time.Since(deployment.StartedAt)
|
|
title := "Build success: " + app.Name
|
|
message := "Image built successfully in " + formatDuration(duration)
|
|
|
|
svc.sendNotifications(ctx, app, title, message, "success")
|
|
}
|
|
|
|
// NotifyBuildFailed sends a build failed notification.
|
|
func (svc *Service) NotifyBuildFailed(
|
|
ctx context.Context,
|
|
app *models.App,
|
|
deployment *models.Deployment,
|
|
buildErr error,
|
|
) {
|
|
duration := time.Since(deployment.StartedAt)
|
|
title := "Build failed: " + app.Name
|
|
message := "Build failed after " + formatDuration(duration) + ": " + 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,
|
|
deployment *models.Deployment,
|
|
) {
|
|
duration := time.Since(deployment.StartedAt)
|
|
title := "Deploy success: " + app.Name
|
|
message := "Successfully deployed in " + formatDuration(duration)
|
|
|
|
if deployment.CommitSHA.Valid {
|
|
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
|
|
message += " (commit " + shortSHA + ")"
|
|
}
|
|
|
|
svc.sendNotifications(ctx, app, title, message, "success")
|
|
}
|
|
|
|
// NotifyDeployFailed sends a deploy failed notification.
|
|
func (svc *Service) NotifyDeployFailed(
|
|
ctx context.Context,
|
|
app *models.App,
|
|
deployment *models.Deployment,
|
|
deployErr error,
|
|
) {
|
|
duration := time.Since(deployment.StartedAt)
|
|
title := "Deploy failed: " + app.Name
|
|
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
|
|
|
|
svc.sendNotifications(ctx, app, title, message, "error")
|
|
}
|
|
|
|
// formatDuration formats a duration for display.
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
}
|
|
|
|
minutes := int(d.Minutes())
|
|
seconds := int(d.Seconds()) % secondsPerMinute
|
|
|
|
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
|
}
|
|
|
|
// minInt returns the smaller of two integers.
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
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, priority)
|
|
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"
|
|
}
|
|
}
|
|
|
|
func (svc *Service) slackColor(priority string) string {
|
|
switch priority {
|
|
case "error":
|
|
return "#dc3545" // red
|
|
case "success":
|
|
return "#28a745" // green
|
|
case "info":
|
|
return "#17a2b8" // blue
|
|
default:
|
|
return "#6c757d" // gray
|
|
}
|
|
}
|
|
|
|
// 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, priority string,
|
|
) error {
|
|
svc.log.Debug(
|
|
"sending slack notification",
|
|
"url", webhookURL,
|
|
"title", title,
|
|
)
|
|
|
|
payload := SlackPayload{
|
|
Attachments: []SlackAttachment{
|
|
{
|
|
Color: svc.slackColor(priority),
|
|
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
|
|
}
|