Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.22.5 as builder
FROM golang:1.22.5 AS builder

WORKDIR /app

Expand All @@ -10,11 +10,11 @@ RUN go mod download && go mod verify

RUN CGO_ENABLED=0 go build -o /app/cert-manager-sync cmd/cert-manager-sync/*.go

FROM alpine:3.21 as alpine
FROM alpine:3.21 AS alpine

RUN apk add -U --no-cache ca-certificates

FROM scratch as app
FROM scratch AS app

WORKDIR /app

Expand Down
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,69 @@ cert_manager_sync_status{namespace="cert-manager",secret="example",store="acm",s

Setting `ENABLE_METRICS=false` will disable the metrics server.

## Slack Notifications

cert-manager-sync can send notifications to Slack when a certificate is successfully synced to a store. This is useful for monitoring certificate updates and renewals.

### Enabling Slack Notifications

There are two ways to configure Slack notifications:

#### 1. Using Annotations on the Secret

Add the following annotations to your Kubernetes TLS secret:

```yaml
cert-manager-sync.lestak.sh/slack-notify-enabled: "true"
cert-manager-sync.lestak.sh/slack-webhook-url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
cert-manager-sync.lestak.sh/slack-channel: "#certificate-updates"
cert-manager-sync.lestak.sh/slack-username: "cert-manager-sync"
```

#### 2. Using a Kubernetes Secret for Webhook URL

If you prefer not to expose your Slack webhook URL in annotations, you can store it in a separate Kubernetes secret:

```bash
kubectl -n cert-manager \
create secret generic slack-webhook-secret \
--from-literal webhook_url=https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
```

Then reference this secret in your TLS secret annotations:

```yaml
cert-manager-sync.lestak.sh/slack-notify-enabled: "true"
cert-manager-sync.lestak.sh/slack-secret-name: "slack-webhook-secret"
cert-manager-sync.lestak.sh/slack-channel: "#certificate-updates"
cert-manager-sync.lestak.sh/slack-username: "cert-manager-sync"
```

### Slack Messages

Notifications will be sent to the configured Slack channel in the following cases:

1. **Success Notifications**: When a certificate is successfully synced to a store
- Green colored attachment
- Lock emoji (:lock:)
- Success message with store details

2. **Failure Notifications**: When a certificate sync fails
- Red colored attachment
- Warning emoji (:warning:)
- Error message with failure details

Each notification includes:
- Certificate name
- Namespace
- Store type (ACM, CloudFlare, etc.)
- Timestamp
- Success or error message

### Multiple Notifications

You can configure different notification settings for different certificates by using the appropriate annotations on each TLS secret.

### Error Logging

The following log filter will display just errors syncing certificates:
Expand All @@ -444,4 +507,5 @@ The following fields are included in the sync error log message:

```bash
level=error action=SyncSecretToStore namespace=cert-manager secret=example store=acm error="error message"
```
```

4 changes: 3 additions & 1 deletion internal/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
IncapsulaStoreType StoreType = "incapsula"
ThreatxStoreType StoreType = "threatx"
VaultStoreType StoreType = "vault"
SlackStoreType StoreType = "slack"
)

var EnabledStores = []StoreType{
Expand All @@ -30,6 +31,7 @@ var EnabledStores = []StoreType{
IncapsulaStoreType,
ThreatxStoreType,
VaultStoreType,
SlackStoreType,
}

func IsValidStoreType(storeType string) bool {
Expand All @@ -39,4 +41,4 @@ func IsValidStoreType(storeType string) bool {
}
}
return false
}
}
136 changes: 135 additions & 1 deletion pkg/certmanagersync/certmanagersync.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/robertlestak/cert-manager-sync/stores/gcpcm"
"github.com/robertlestak/cert-manager-sync/stores/heroku"
"github.com/robertlestak/cert-manager-sync/stores/incapsula"
"github.com/robertlestak/cert-manager-sync/stores/slack"
"github.com/robertlestak/cert-manager-sync/stores/threatx"
"github.com/robertlestak/cert-manager-sync/stores/vault"
log "github.com/sirupsen/logrus"
Expand All @@ -31,6 +32,12 @@ type RemoteStore interface {
FromConfig(config tlssecret.GenericSecretSyncConfig) error
}

// SlackNotifier is an interface that can be implemented by stores that support Slack notifications
type SlackNotifier interface {
NotifySuccess(storeType, secretName, namespace, successMsg string) error
NotifyFailure(storeType, secretName, namespace, errorMsg string) error
}

func NewStore(storeType cmtypes.StoreType) (RemoteStore, error) {
l := log.WithFields(log.Fields{
"action": "NewStore",
Expand All @@ -56,6 +63,8 @@ func NewStore(storeType cmtypes.StoreType) (RemoteStore, error) {
store = &threatx.ThreatXStore{}
case cmtypes.VaultStoreType:
store = &vault.VaultStore{}
case cmtypes.SlackStoreType:
store = &slack.SlackStore{}
default:
return nil, cmtypes.ErrInvalidStoreType
}
Expand Down Expand Up @@ -177,6 +186,119 @@ func calculateNextRetryTime(secret *corev1.Secret) time.Time {
return nextRetryTime
}

// getSlackConfig extracts Slack configuration from annotations if present
func getSlackConfig(s *corev1.Secret) (*slack.SlackStore, error) {
if s.Annotations == nil {
return nil, nil
}

// Check if Slack notifications are enabled
slackEnabled := s.Annotations[state.OperatorName+"/slack-notify-enabled"]
if slackEnabled != "true" {
return nil, nil
}

// Get the Slack configuration
slackConfig := &slack.SlackStore{
SecretNamespace: s.Namespace,
}

if webhookURL := s.Annotations[state.OperatorName+"/slack-webhook-url"]; webhookURL != "" {
slackConfig.WebhookURL = webhookURL
}

if secretName := s.Annotations[state.OperatorName+"/slack-secret-name"]; secretName != "" {
slackConfig.SecretName = secretName
}

if channel := s.Annotations[state.OperatorName+"/slack-channel"]; channel != "" {
slackConfig.ChannelName = channel
}

if username := s.Annotations[state.OperatorName+"/slack-username"]; username != "" {
slackConfig.Username = username
} else {
slackConfig.Username = "cert-manager-sync"
}

// If neither webhook URL nor secret name is provided, we can't send notifications
if slackConfig.WebhookURL == "" && slackConfig.SecretName == "" {
return nil, fmt.Errorf("either slack-webhook-url or slack-secret-name annotation is required for Slack notifications")
}

return slackConfig, nil
}

// sendSlackSuccessNotification sends a success notification to Slack if configured
func sendSlackSuccessNotification(s *corev1.Secret, cert *tlssecret.Certificate, storeType, successMsg string) {
l := log.WithFields(log.Fields{
"action": "sendSlackSuccessNotification",
"namespace": s.Namespace,
"name": s.Name,
"storeType": storeType,
})

slackConfig, err := getSlackConfig(s)
if err != nil {
l.WithError(err).Warn("Failed to get Slack configuration, skipping notification")
return
}

if slackConfig == nil {
// Slack notifications not enabled or configured
return
}

notifier := &slack.SlackStore{
WebhookURL: slackConfig.WebhookURL,
ChannelName: slackConfig.ChannelName,
Username: slackConfig.Username,
SecretName: slackConfig.SecretName,
SecretNamespace: slackConfig.SecretNamespace,
}

if err := notifier.NotifySuccess(storeType, cert.SecretName, cert.Namespace, successMsg); err != nil {
l.WithError(err).Warn("Failed to send Slack success notification")
} else {
l.Debug("Slack success notification sent successfully")
}
}

// sendSlackFailureNotification sends a failure notification to Slack if configured
func sendSlackFailureNotification(s *corev1.Secret, cert *tlssecret.Certificate, storeType, errorMsg string) {
l := log.WithFields(log.Fields{
"action": "sendSlackFailureNotification",
"namespace": s.Namespace,
"name": s.Name,
"storeType": storeType,
})

slackConfig, err := getSlackConfig(s)
if err != nil {
l.WithError(err).Warn("Failed to get Slack configuration, skipping notification")
return
}

if slackConfig == nil {
// Slack notifications not enabled or configured
return
}

notifier := &slack.SlackStore{
WebhookURL: slackConfig.WebhookURL,
ChannelName: slackConfig.ChannelName,
Username: slackConfig.Username,
SecretName: slackConfig.SecretName,
SecretNamespace: slackConfig.SecretNamespace,
}

if err := notifier.NotifyFailure(storeType, cert.SecretName, cert.Namespace, errorMsg); err != nil {
l.WithError(err).Warn("Failed to send Slack failure notification")
} else {
l.Debug("Slack failure notification sent successfully")
}
}

func HandleSecret(s *corev1.Secret) error {
l := log.WithFields(log.Fields{
"action": "HandleSecret",
Expand Down Expand Up @@ -225,13 +347,25 @@ func HandleSecret(s *corev1.Secret) error {
l.WithError(err).Errorf("Sync error")
metrics.SetFailure(s.Namespace, s.Name, sync.Store)
state.EventRecorder.Event(s, corev1.EventTypeWarning, "SyncFailed", fmt.Sprintf("Secret sync failed to store %s", sync.Store))

// Send failure notification to Slack if enabled
errorMsg := fmt.Sprintf("Failed to sync certificate to %s: %v", sync.Store, err)
sendSlackFailureNotification(s, cert, sync.Store, errorMsg)

errs = append(errs, err)
continue
}
if len(updates) > 0 {
l.WithField("updates", updates).Debug("synced with updates")
}
sync.Updates = updates

// Send success notification to Slack if enabled
successMsg := fmt.Sprintf("Certificate successfully synced to %s", sync.Store)
sendSlackSuccessNotification(s, cert, sync.Store, successMsg)

// Set success metrics
metrics.SetSuccess(s.Namespace, s.Name, sync.Store)
}
patchAnnotations := make(map[string]string)
if s.Annotations != nil {
Expand Down Expand Up @@ -301,4 +435,4 @@ func HandleSecret(s *corev1.Secret) error {
eventMsg := fmt.Sprintf("Secret synced to %d store%s", len(cert.Syncs), scf)
state.EventRecorder.Event(s, corev1.EventTypeNormal, "Synced", eventMsg)
return nil
}
}
Loading