Skip to content

Commit ee62d07

Browse files
authored
feat: evidence notification digest (#311)
* feat: digest (wip) Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: add description to cron-6 behavior fix: evidences should always expire fix: gather only the latest evidence chore: helper functions and commands Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: evidence expires, email links Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: config should use viper fix: messages should be debug Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: checkdiff Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: integration Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: copilot issues Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: copilot issues #2 Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: copilot issues continuation Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: swag Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: lint Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> --------- Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com>
1 parent ea2fc55 commit ee62d07

32 files changed

+2634
-67
lines changed

cmd/digest.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
8+
"github.com/compliance-framework/api/internal/config"
9+
"github.com/compliance-framework/api/internal/service"
10+
"github.com/compliance-framework/api/internal/service/digest"
11+
"github.com/compliance-framework/api/internal/service/email"
12+
"github.com/spf13/cobra"
13+
"github.com/spf13/viper"
14+
"go.uber.org/zap"
15+
)
16+
17+
var (
18+
dryRun bool
19+
20+
DigestCmd = &cobra.Command{
21+
Use: "digest",
22+
Short: "Digest management commands",
23+
}
24+
25+
digestTestCmd = &cobra.Command{
26+
Use: "test",
27+
Short: "Test the digest by sending it immediately to all subscribed users",
28+
Run: runDigestTest,
29+
}
30+
31+
digestPreviewCmd = &cobra.Command{
32+
Use: "preview",
33+
Short: "Preview the digest summary without sending emails",
34+
Run: runDigestPreview,
35+
}
36+
)
37+
38+
func init() {
39+
digestTestCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be sent without sending emails")
40+
DigestCmd.AddCommand(digestTestCmd)
41+
DigestCmd.AddCommand(digestPreviewCmd)
42+
}
43+
44+
func runDigestTest(cmd *cobra.Command, args []string) {
45+
ctx := context.Background()
46+
47+
var sugar *zap.SugaredLogger
48+
if viper.GetBool("use_dev_logger") {
49+
sugar = zap.Must(zap.NewDevelopment()).Sugar()
50+
} else {
51+
sugar = zap.Must(zap.NewProduction()).Sugar()
52+
}
53+
54+
defer func() {
55+
if err := sugar.Sync(); err != nil {
56+
log.Printf("failed to sync zap logger: %v", err)
57+
}
58+
}()
59+
60+
cfg := config.NewConfig(sugar)
61+
62+
// Check if this is production and add confirmation if not dry-run
63+
if cfg.Environment == "production" && !dryRun {
64+
fmt.Print("⚠️ WARNING: You are about to send digest emails to all subscribed users in PRODUCTION!\n")
65+
fmt.Print("This will send emails to real users. Are you sure you want to continue? (type 'yes' to confirm): ")
66+
67+
var response string
68+
_, err := fmt.Scanln(&response)
69+
if err != nil {
70+
sugar.Fatalw("Failed to read user input", "error", err)
71+
}
72+
if response != "yes" {
73+
fmt.Println("Operation cancelled.")
74+
return
75+
}
76+
}
77+
78+
db, err := service.ConnectSQLDb(ctx, cfg, sugar)
79+
if err != nil {
80+
sugar.Fatalw("Failed to connect to SQL database", "error", err)
81+
}
82+
83+
emailService, err := email.NewService(cfg.Email, sugar)
84+
if err != nil {
85+
sugar.Fatalw("Failed to initialize email service", "error", err)
86+
}
87+
88+
digestService := digest.NewService(db, emailService, cfg, sugar)
89+
90+
if dryRun {
91+
sugar.Info("Running digest test in DRY-RUN mode (no emails will be sent)...")
92+
93+
// Get the digest summary without sending
94+
summary, err := digestService.GetGlobalEvidenceSummary(ctx)
95+
if err != nil {
96+
sugar.Fatalw("Failed to get digest summary", "error", err)
97+
}
98+
99+
sugar.Infow("Digest summary (dry-run)",
100+
"total_evidence", summary.TotalCount,
101+
"satisfied", summary.SatisfiedCount,
102+
"not_satisfied", summary.NotSatisfiedCount,
103+
"expired", summary.ExpiredCount,
104+
"top_not_satisfied_count", len(summary.TopNotSatisfied),
105+
"top_expired_count", len(summary.TopExpired),
106+
)
107+
108+
sugar.Info("Dry-run completed successfully - no emails were sent")
109+
return
110+
}
111+
112+
sugar.Info("Running digest test...")
113+
if err := digestService.SendGlobalDigest(ctx); err != nil {
114+
sugar.Fatalw("Failed to send digest", "error", err)
115+
}
116+
117+
sugar.Info("Digest test completed successfully")
118+
}
119+
120+
func runDigestPreview(cmd *cobra.Command, args []string) {
121+
ctx := context.Background()
122+
123+
var sugar *zap.SugaredLogger
124+
if viper.GetBool("use_dev_logger") {
125+
sugar = zap.Must(zap.NewDevelopment()).Sugar()
126+
} else {
127+
sugar = zap.Must(zap.NewProduction()).Sugar()
128+
}
129+
130+
defer func() {
131+
if err := sugar.Sync(); err != nil {
132+
log.Printf("failed to sync zap logger: %v", err)
133+
}
134+
}()
135+
136+
cfg := config.NewConfig(sugar)
137+
138+
db, err := service.ConnectSQLDb(ctx, cfg, sugar)
139+
if err != nil {
140+
sugar.Fatalw("Failed to connect to SQL database", "error", err)
141+
}
142+
143+
emailService, err := email.NewService(cfg.Email, sugar)
144+
if err != nil {
145+
sugar.Warnw("Failed to initialize email service", "error", err)
146+
}
147+
148+
digestService := digest.NewService(db, emailService, cfg, sugar)
149+
150+
summary, err := digestService.GetGlobalEvidenceSummary(ctx)
151+
if err != nil {
152+
sugar.Fatalw("Failed to get evidence summary", "error", err)
153+
}
154+
155+
users, err := digestService.GetSubscribedUsers(ctx)
156+
if err != nil {
157+
sugar.Fatalw("Failed to get subscribed users", "error", err)
158+
}
159+
160+
fmt.Println("\n=== Evidence Digest Preview ===")
161+
fmt.Printf("Total Evidence: %d\n", summary.TotalCount)
162+
fmt.Printf("Satisfied: %d\n", summary.SatisfiedCount)
163+
fmt.Printf("Not Satisfied: %d\n", summary.NotSatisfiedCount)
164+
fmt.Printf("Expired: %d\n", summary.ExpiredCount)
165+
fmt.Printf("Other: %d\n", summary.OtherCount)
166+
fmt.Printf("\nSubscribed Users: %d\n", len(users))
167+
168+
if len(summary.TopNotSatisfied) > 0 {
169+
fmt.Println("\nTop Not Satisfied Evidence:")
170+
for i, item := range summary.TopNotSatisfied {
171+
fmt.Printf(" %d. %s (UUID: %s)\n", i+1, item.Title, item.UUID)
172+
}
173+
}
174+
175+
if len(summary.TopExpired) > 0 {
176+
fmt.Println("\nTop Expired Evidence:")
177+
for i, item := range summary.TopExpired {
178+
fmt.Printf(" %d. %s (UUID: %s, Expired: %v)\n", i+1, item.Title, item.UUID, item.ExpiresAt)
179+
}
180+
}
181+
182+
if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 {
183+
fmt.Println("\n✓ No issues found - digest would be skipped")
184+
} else {
185+
fmt.Println("\n✓ Digest would be sent to subscribed users")
186+
}
187+
}

cmd/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ func setDefaultEnvironmentVariables() {
2626
viper.SetDefault("db_debug", "false")
2727
viper.SetDefault("metrics_enabled", "true")
2828
viper.SetDefault("metrics_port", ":9090")
29+
viper.SetDefault("evidence_default_expiry_months", "1")
30+
viper.SetDefault("digest_enabled", "true")
31+
viper.SetDefault("digest_schedule", "@weekly")
2932
}
3033

3134
func bindEnvironmentVariables() {
@@ -45,6 +48,9 @@ func bindEnvironmentVariables() {
4548
viper.MustBindEnv("metrics_enabled")
4649
viper.MustBindEnv("metrics_port")
4750
viper.MustBindEnv("use_dev_logger")
51+
viper.MustBindEnv("evidence_default_expiry_months")
52+
viper.MustBindEnv("digest_enabled")
53+
viper.MustBindEnv("digest_schedule")
4854
}
4955

5056
func init() {
@@ -71,6 +77,7 @@ func init() {
7177
rootCmd.AddCommand(seed.RootCmd)
7278
rootCmd.AddCommand(newMigrateCMD())
7379
rootCmd.AddCommand(dashboards.RootCmd)
80+
rootCmd.AddCommand(DigestCmd)
7481
}
7582

7683
func Execute() error {

cmd/run.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ package cmd
33
import (
44
"context"
55
"log"
6+
"time"
67

78
"github.com/compliance-framework/api/internal/api"
89
"github.com/compliance-framework/api/internal/api/handler"
910
"github.com/compliance-framework/api/internal/api/handler/auth"
1011
"github.com/compliance-framework/api/internal/api/handler/oscal"
1112
"github.com/compliance-framework/api/internal/config"
1213
"github.com/compliance-framework/api/internal/service"
14+
"github.com/compliance-framework/api/internal/service/digest"
15+
"github.com/compliance-framework/api/internal/service/email"
16+
"github.com/compliance-framework/api/internal/service/scheduler"
1317
"github.com/spf13/cobra"
1418
"github.com/spf13/viper"
1519
"go.uber.org/zap"
@@ -51,9 +55,46 @@ func RunServer(cmd *cobra.Command, args []string) {
5155
sugar.Fatalw("Failed to migrate database", "error", err)
5256
}
5357

58+
// Initialize email service
59+
emailService, err := email.NewService(cfg.Email, sugar)
60+
if err != nil {
61+
sugar.Warnw("Failed to initialize email service, digests will be disabled", "error", err)
62+
}
63+
64+
// Initialize digest service
65+
digestService := digest.NewService(db, emailService, cfg, sugar)
66+
67+
// Initialize scheduler
68+
sched := scheduler.NewCronScheduler(sugar)
69+
70+
// Register digest job using config
71+
if cfg.DigestEnabled {
72+
digestJob := digest.NewGlobalDigestJob(digestService, sugar)
73+
if err := sched.ScheduleCron(cfg.DigestSchedule, digestJob); err != nil {
74+
sugar.Warnw("Failed to schedule digest job", "schedule", cfg.DigestSchedule, "error", err)
75+
} else {
76+
sugar.Debugw("Digest job scheduled", "schedule", cfg.DigestSchedule)
77+
}
78+
} else {
79+
sugar.Debugw("Digest scheduler disabled")
80+
}
81+
82+
// Start the scheduler
83+
sched.Start()
84+
defer func() {
85+
stopCtx := sched.Stop()
86+
// Wait for jobs to finish gracefully with a 10-second timeout
87+
select {
88+
case <-stopCtx.Done():
89+
sugar.Debug("All scheduled jobs completed gracefully")
90+
case <-time.After(10 * time.Second):
91+
sugar.Warn("Scheduler shutdown timeout, some jobs may not have completed")
92+
}
93+
}()
94+
5495
metrics := api.NewMetricsHandler(ctx, sugar)
5596
server := api.NewServer(ctx, sugar, cfg, metrics)
56-
handler.RegisterHandlers(server, sugar, db, cfg)
97+
handler.RegisterHandlers(server, sugar, db, cfg, digestService, sched)
5798
oscal.RegisterHandlers(server, sugar, db, cfg)
5899
auth.RegisterHandlers(server, sugar, db, cfg, metrics)
59100

0 commit comments

Comments
 (0)