Skip to content

Commit 1a051bd

Browse files
committed
feat: add Apprise notification integration for deployment status updates
1 parent 32900e3 commit 1a051bd

File tree

6 files changed

+185
-23
lines changed

6 files changed

+185
-23
lines changed

cmd/doco-cd/http_handler.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"strings"
1212
"time"
1313

14+
"github.com/kimdre/doco-cd/internal/notification"
15+
1416
"github.com/docker/cli/cli/command"
1517
"github.com/docker/compose/v2/pkg/api"
1618
"github.com/docker/docker/api/types/container"
@@ -44,6 +46,8 @@ func onError(repoName string, w http.ResponseWriter, log *slog.Logger, errMsg st
4446
details,
4547
jobID,
4648
statusCode)
49+
50+
_ = notification.Send(notification.Failure, "Deployment ßFailed", errMsg)
4751
}
4852

4953
// getRepoName extracts the repository name from the clone URL.

cmd/doco-cd/poll_handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"sync"
1111
"time"
1212

13+
"github.com/kimdre/doco-cd/internal/notification"
14+
1315
"github.com/docker/cli/cli/command"
1416
"github.com/docker/compose/v2/pkg/api"
1517
"github.com/docker/docker/api/types/container"
@@ -87,6 +89,11 @@ func (h *handlerData) PollHandler(pollJob *config.PollJob) {
8789
err := RunPoll(context.Background(), pollJob.Config, h.appConfig, h.dataMountPoint, h.dockerCli, h.dockerClient, logger)
8890
if err != nil {
8991
prometheus.PollErrors.WithLabelValues(repoName).Inc()
92+
93+
err = notification.Send(notification.Failure, "Deployment Failed", err.Error())
94+
if err != nil {
95+
logger.Error("failed to send notification", log.ErrAttr(err))
96+
}
9097
}
9198

9299
lock.Unlock()

dev.compose.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ services:
1717
restart: unless-stopped
1818
depends_on:
1919
- proxy
20+
- apprise
2021
ports:
2122
- "80:80"
2223
- "9120:9120"
@@ -30,6 +31,9 @@ services:
3031
<<: *poll-config
3132
#POLL_CONFIG_FILE: /poll.yml
3233
HTTP_PROXY: http://username:password@proxy:8888
34+
APPRISE_API_URL: http://apprise:8000/notify
35+
APPRISE_NOTIFY_LEVEL: info
36+
# APPRISE_NOTIFY_URLS: # .env
3337
volumes:
3438
- /var/run/docker.sock:/var/run/docker.sock
3539
- data:/data
@@ -51,6 +55,15 @@ services:
5155
- source: tinyproxy.conf
5256
target: /etc/tinyproxy/tinyproxy.conf
5357

58+
apprise:
59+
image: caronc/apprise:latest
60+
restart: unless-stopped
61+
ports:
62+
- "8000:8000"
63+
environment:
64+
TZ: Europe/Berlin
65+
APPRISE_WORKER_COUNT: 1
66+
5467
volumes:
5568
data:
5669

internal/config/app_config.go

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"fmt"
55
"strings"
66

7+
"github.com/kimdre/doco-cd/internal/notification"
8+
79
"github.com/caarlos0/env/v11"
810
"github.com/go-git/go-git/v5/plumbing/transport"
911
"gopkg.in/validator.v2"
@@ -15,23 +17,27 @@ const AppName = "doco-cd" // Name of the application
1517
// AppConfig is used to configure this application
1618
// https://github.com/caarlos0/env?tab=readme-ov-file#env-tag-options
1719
type AppConfig struct {
18-
LogLevel string `env:"LOG_LEVEL,notEmpty" envDefault:"info"` // LogLevel is the log level for the application
19-
HttpPort uint16 `env:"HTTP_PORT,notEmpty" envDefault:"80" validate:"min=1,max=65535"` // HttpPort is the port the HTTP server will listen on
20-
HttpProxyString string `env:"HTTP_PROXY"` // HttpProxyString is the HTTP proxy URL as a string
21-
HttpProxy transport.ProxyOptions // HttpProxy is the HTTP proxy configuration parsed from the HttpProxyString
22-
WebhookSecret string `env:"WEBHOOK_SECRET"` // WebhookSecret is the secret used to authenticate the webhook
23-
WebhookSecretFile string `env:"WEBHOOK_SECRET_FILE,file"` // WebhookSecretFile is the file containing the WebhookSecret
24-
GitAccessToken string `env:"GIT_ACCESS_TOKEN"` // GitAccessToken is the access token used to authenticate with the Git server (e.g. GitHub) for private repositories
25-
GitAccessTokenFile string `env:"GIT_ACCESS_TOKEN_FILE,file"` // GitAccessTokenFile is the file containing the GitAccessToken
26-
AuthType string `env:"AUTH_TYPE,notEmpty" envDefault:"oauth2"` // AuthType is the type of authentication to use when cloning repositories
27-
SkipTLSVerification bool `env:"SKIP_TLS_VERIFICATION,notEmpty" envDefault:"false"` // SkipTLSVerification skips the TLS verification when cloning repositories.
28-
DockerQuietDeploy bool `env:"DOCKER_QUIET_DEPLOY,notEmpty" envDefault:"true"` // DockerQuietDeploy suppresses the status output of dockerCli in deployments (e.g. pull, create, start)
29-
DockerSwarmFeatures bool `env:"DOCKER_SWARM_FEATURES,notEmpty" envDefault:"true"` // DockerSwarmFeatures enables the usage Docker Swarm features in the application if it has detected that it is running in a Docker Swarm environment
30-
PollConfigYAML string `env:"POLL_CONFIG"` // PollConfigYAML is the unparsed string containing the PollConfig in YAML format
31-
PollConfigFile string `env:"POLL_CONFIG_FILE,file"` // PollConfigFile is the file containing the PollConfig in YAML format
32-
PollConfig []PollConfig `yaml:"-"` // PollConfig is the YAML configuration for polling Git repositories for changes
33-
MaxPayloadSize int64 `env:"MAX_PAYLOAD_SIZE,notEmpty" envDefault:"1048576"` // MaxPayloadSize is the maximum size of the payload in bytes that the HTTP server will accept (default 1MB = 1048576 bytes)
34-
MetricsPort uint16 `env:"METRICS_PORT,notEmpty" envDefault:"9120" validate:"min=1,max=65535"` // MetricsPort is the port the prometheus metrics server will listen on
20+
LogLevel string `env:"LOG_LEVEL,notEmpty" envDefault:"info"` // LogLevel is the log level for the application
21+
HttpPort uint16 `env:"HTTP_PORT,notEmpty" envDefault:"80" validate:"min=1,max=65535"` // HttpPort is the port the HTTP server will listen on
22+
HttpProxyString string `env:"HTTP_PROXY"` // HttpProxyString is the HTTP proxy URL as a string
23+
HttpProxy transport.ProxyOptions // HttpProxy is the HTTP proxy configuration parsed from the HttpProxyString
24+
WebhookSecret string `env:"WEBHOOK_SECRET"` // WebhookSecret is the secret used to authenticate the webhook
25+
WebhookSecretFile string `env:"WEBHOOK_SECRET_FILE,file"` // WebhookSecretFile is the file containing the WebhookSecret
26+
GitAccessToken string `env:"GIT_ACCESS_TOKEN"` // GitAccessToken is the access token used to authenticate with the Git server (e.g. GitHub) for private repositories
27+
GitAccessTokenFile string `env:"GIT_ACCESS_TOKEN_FILE,file"` // GitAccessTokenFile is the file containing the GitAccessToken
28+
AuthType string `env:"AUTH_TYPE,notEmpty" envDefault:"oauth2"` // AuthType is the type of authentication to use when cloning repositories
29+
SkipTLSVerification bool `env:"SKIP_TLS_VERIFICATION,notEmpty" envDefault:"false"` // SkipTLSVerification skips the TLS verification when cloning repositories.
30+
DockerQuietDeploy bool `env:"DOCKER_QUIET_DEPLOY,notEmpty" envDefault:"true"` // DockerQuietDeploy suppresses the status output of dockerCli in deployments (e.g. pull, create, start)
31+
DockerSwarmFeatures bool `env:"DOCKER_SWARM_FEATURES,notEmpty" envDefault:"true"` // DockerSwarmFeatures enables the usage Docker Swarm features in the application if it has detected that it is running in a Docker Swarm environment
32+
PollConfigYAML string `env:"POLL_CONFIG"` // PollConfigYAML is the unparsed string containing the PollConfig in YAML format
33+
PollConfigFile string `env:"POLL_CONFIG_FILE,file"` // PollConfigFile is the file containing the PollConfig in YAML format
34+
PollConfig []PollConfig `yaml:"-"` // PollConfig is the YAML configuration for polling Git repositories for changes
35+
MaxPayloadSize int64 `env:"MAX_PAYLOAD_SIZE,notEmpty" envDefault:"1048576"` // MaxPayloadSize is the maximum size of the payload in bytes that the HTTP server will accept (default 1MB = 1048576 bytes)
36+
MetricsPort uint16 `env:"METRICS_PORT,notEmpty" envDefault:"9120" validate:"min=1,max=65535"` // MetricsPort is the port the prometheus metrics server will listen on
37+
AppriseApiURL HttpUrl `env:"APPRISE_API_URL" validate:"httpUrl"` // AppriseApiURL is the URL of the Apprise notification service
38+
AppriseNotifyUrls string `env:"APPRISE_NOTIFY_URLS"` // AppriseNotifyUrls is a comma-separated list of URLs to notify via the Apprise notification service
39+
AppriseNotifyUrlsFile string `env:"APPRISE_NOTIFY_URLS_FILE,file"` // AppriseNotifyUrlsFile is the file containing the AppriseNotifyUrls
40+
AppriseNotifyLevel string `env:"APPRISE_NOTIFY_LEVEL,notEmpty" envDefault:"info"` // AppriseNotifyLevel is the level of notifications to send via the Apprise notification service
3541
}
3642

3743
// GetAppConfig returns the configuration.
@@ -83,18 +89,26 @@ func GetAppConfig() (*AppConfig, error) {
8389
}
8490
}
8591

92+
notification.SetAppriseConfig(
93+
string(cfg.AppriseApiURL),
94+
cfg.AppriseNotifyUrls,
95+
cfg.AppriseNotifyLevel,
96+
)
97+
8698
return &cfg, nil
8799
}
88100

89101
// loadFileBasedEnvVars loads environment variables from files if the corresponding file-based environment variable is set.
90102
func loadFileBasedEnvVars(cfg *AppConfig) error {
91103
fields := []struct {
92-
fileField string
93-
value *string
94-
name string
104+
fileField string
105+
value *string
106+
name string
107+
allowUnset bool
95108
}{
96-
{cfg.WebhookSecretFile, &cfg.WebhookSecret, "WEBHOOK_SECRET"},
97-
{cfg.GitAccessTokenFile, &cfg.GitAccessToken, "GIT_ACCESS_TOKEN"},
109+
{cfg.WebhookSecretFile, &cfg.WebhookSecret, "WEBHOOK_SECRET", false},
110+
{cfg.GitAccessTokenFile, &cfg.GitAccessToken, "GIT_ACCESS_TOKEN", false},
111+
{cfg.AppriseNotifyUrlsFile, &cfg.AppriseNotifyUrls, "APPRISE_NOTIFY_URLS", true},
98112
}
99113

100114
for _, field := range fields {
@@ -104,7 +118,7 @@ func loadFileBasedEnvVars(cfg *AppConfig) error {
104118
}
105119

106120
*field.value = field.fileField
107-
} else if *field.value == "" {
121+
} else if *field.value == "" && !field.allowUnset {
108122
return fmt.Errorf("%w: %s or %s", ErrBothSecretsNotSet, field.name, field.name+"_FILE")
109123
}
110124
}

internal/docker/compose.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
"strings"
2020
"time"
2121

22+
"github.com/kimdre/doco-cd/internal/notification"
23+
2224
"github.com/go-git/go-git/v5/plumbing/format/diff"
2325

2426
"github.com/docker/docker/client"
@@ -568,6 +570,11 @@ func DeployStack(
568570
prometheus.DeploymentsTotal.WithLabelValues(deployConfig.Name).Inc()
569571
prometheus.DeploymentDuration.WithLabelValues(deployConfig.Name).Observe(time.Since(startTime).Seconds())
570572

573+
err = notification.Send(notification.Success, "Deployment Successful", "Successfully deployed stack "+deployConfig.Name)
574+
if err != nil {
575+
return err
576+
}
577+
571578
return nil
572579
}
573580

internal/notification/notification.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package notification
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
)
10+
11+
type level int
12+
13+
const (
14+
Info level = iota // Informational messages
15+
Success // Successful operations
16+
Warning // Warning messages indicating potential issues
17+
Failure // Error messages indicating failure of operations
18+
)
19+
20+
var logLevels = map[level]string{
21+
Info: "info",
22+
Success: "success",
23+
Warning: "warning",
24+
Failure: "failure",
25+
}
26+
27+
var levelEmojis = map[level]string{
28+
Info: "ℹ️",
29+
Success: "✅",
30+
Warning: "⚠️",
31+
Failure: "❌",
32+
}
33+
34+
var (
35+
appriseApiURL = ""
36+
appriseNotifyUrls = ""
37+
appriseNotifyLevel = Info
38+
)
39+
40+
// appriseRequest represents the structure of a request to the Apprise notification service.
41+
type appriseRequest struct {
42+
NotifyUrls string `json:"urls"`
43+
Title string `json:"title"`
44+
Body string `json:"body"`
45+
Type string `json:"type,omitempty"` // Optional field for specifying the type of notification (info, success, error, failure)
46+
}
47+
48+
// parseLevel converts a string representation of a log level to the level type.
49+
func parseLevel(level string) level {
50+
switch level {
51+
case logLevels[Info]:
52+
return Info
53+
case logLevels[Success]:
54+
return Success
55+
case logLevels[Warning]:
56+
return Warning
57+
case logLevels[Failure]:
58+
return Failure
59+
default:
60+
return Info // Default to Info if the level is not recognized
61+
}
62+
}
63+
64+
// send a notification to the Apprise service.
65+
func send(apiUrl, notifyUrls, title, message, level string) error {
66+
jsonData, err := json.Marshal(appriseRequest{
67+
NotifyUrls: notifyUrls,
68+
Title: title,
69+
Body: message,
70+
Type: level,
71+
})
72+
if err != nil {
73+
return fmt.Errorf("failed to marshal appriseRequest: %w", err)
74+
}
75+
76+
resp, err := http.Post(apiUrl, "application/json", bytes.NewBuffer(jsonData)) // #nosec G107
77+
if err != nil {
78+
return fmt.Errorf("failed to send request to Apprise: %w", err)
79+
}
80+
81+
defer resp.Body.Close() // nolint:errcheck
82+
83+
if resp.StatusCode != http.StatusOK {
84+
return fmt.Errorf("apprise request failed with status: %s", resp.Status)
85+
}
86+
87+
return nil
88+
}
89+
90+
// SetAppriseConfig sets the configuration for the Apprise notification service.
91+
func SetAppriseConfig(apiURL, notifyUrls, notifyLevel string) {
92+
appriseApiURL = apiURL
93+
appriseNotifyUrls = notifyUrls
94+
appriseNotifyLevel = parseLevel(notifyLevel)
95+
}
96+
97+
// Send sends a notification using the Apprise service based on the provided configuration and parameters.
98+
func Send(level level, title, message string) error {
99+
if appriseApiURL == "" || appriseNotifyUrls == "" {
100+
return nil
101+
}
102+
103+
if level < appriseNotifyLevel {
104+
return nil // Do not send notification if the level is lower than the configured level
105+
}
106+
107+
title = levelEmojis[level] + " " + title
108+
109+
message = strings.Replace(message, ": ", ":\n", 1)
110+
111+
err := send(appriseApiURL, appriseNotifyUrls, title, message, logLevels[level])
112+
if err != nil {
113+
return fmt.Errorf("failed to send notification: %w", err)
114+
}
115+
116+
return nil
117+
}

0 commit comments

Comments
 (0)