Skip to content

Commit 22941fb

Browse files
committed
Support stepwise apply and retry for configs
Introduce a stepwise apply pipeline for captcha and Discord notification configuration with optional ?step=N re-run support. Backend: define named pipeline steps, validate step query param, run individual steps and return per-step results; use RestartContainerWithTimeout for container restarts and propagate errors; add errString helper to common package. Frontend: allow apply API to accept an optional step parameter, add retry UI and logic (StepProgress shows Retry button and loader), and implement single-step retry handling in Captcha and Notifications pages; update notification status UI to reflect individual step outcomes.
1 parent 4af9caa commit 22941fb

9 files changed

Lines changed: 381 additions & 170 deletions

File tree

internal/api/handlers/captcha.go

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9-
"time"
109

1110
"crowdsec-manager/internal/config"
1211
"crowdsec-manager/internal/database"
@@ -43,26 +42,20 @@ func createCaptchaHTML(cfg *config.Config, provider, siteKey string) error {
4342
return nil
4443
}
4544

46-
// restartTraefikContainer performs a clean stop+start of the Traefik container.
45+
// restartTraefikContainer restarts the Traefik container with a short timeout
46+
// to avoid exceeding the HTTP write deadline.
4747
func restartTraefikContainer(dockerClient *docker.Client, cfg *config.Config) error {
48-
if err := dockerClient.StopContainer(cfg.TraefikContainerName); err != nil {
49-
logger.Warn("Failed to stop Traefik (continuing)", "error", err)
50-
} else {
51-
time.Sleep(1 * time.Second)
48+
if err := dockerClient.RestartContainerWithTimeout(cfg.TraefikContainerName, 10); err != nil {
49+
return fmt.Errorf("failed to restart Traefik: %w", err)
5250
}
53-
if err := dockerClient.StartContainer(cfg.TraefikContainerName); err != nil {
54-
return fmt.Errorf("failed to start Traefik: %w", err)
55-
}
56-
time.Sleep(3 * time.Second)
5751
return nil
5852
}
5953

60-
// restartCrowdSecContainer restarts the CrowdSec container.
54+
// restartCrowdSecContainer restarts the CrowdSec container with a short timeout.
6155
func restartCrowdSecContainer(dockerClient *docker.Client, cfg *config.Config) error {
62-
if err := dockerClient.RestartContainer(cfg.CrowdsecContainerName); err != nil {
56+
if err := dockerClient.RestartContainerWithTimeout(cfg.CrowdsecContainerName, 10); err != nil {
6357
return fmt.Errorf("failed to restart CrowdSec: %w", err)
6458
}
65-
time.Sleep(3 * time.Second)
6659
return nil
6760
}
6861

internal/api/handlers/captcha_config.go

Lines changed: 102 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"net/http"
66
"path/filepath"
7+
"strconv"
78

89
"crowdsec-manager/internal/config"
910
"crowdsec-manager/internal/database"
@@ -69,8 +70,18 @@ func SaveCaptchaConfig(db *database.Database) gin.HandlerFunc {
6970
}
7071
}
7172

73+
// captchaApplyStep is a named step in the captcha apply pipeline.
74+
type captchaApplyStep struct {
75+
Num int
76+
Name string
77+
Run func(req models.CaptchaSetupRequest) error
78+
}
79+
7280
// ApplyCaptchaConfig reads the captcha configuration saved by SaveCaptchaConfig and applies it
7381
// to all systems: captcha HTML, Traefik dynamic config, CrowdSec profiles, and container restarts.
82+
//
83+
// Supports an optional "step" query parameter to re-run a single step (e.g. ?step=4).
84+
// When step is omitted, all steps run sequentially.
7485
func ApplyCaptchaConfig(dockerClient *docker.Client, db *database.Database, cfg *config.Config) gin.HandlerFunc {
7586
return func(c *gin.Context) {
7687
dockerClient = resolveDockerClient(c, dockerClient)
@@ -94,91 +105,119 @@ func ApplyCaptchaConfig(dockerClient *docker.Client, db *database.Database, cfg
94105
return
95106
}
96107

97-
steps := []gin.H{}
98-
99-
// Step 1: Write captcha.html to host filesystem.
100-
htmlErr := createCaptchaHTML(cfg, req.Provider, req.SiteKey)
101-
steps = append(steps, gin.H{
102-
"step": 1,
103-
"name": "Create captcha HTML page",
104-
"success": htmlErr == nil,
105-
"error": errString(htmlErr),
106-
})
108+
// Parse optional step filter.
109+
var onlyStep int
110+
if stepStr := c.Query("step"); stepStr != "" {
111+
parsed, parseErr := strconv.Atoi(stepStr)
112+
if parseErr != nil || parsed < 1 || parsed > 6 {
113+
c.JSON(http.StatusBadRequest, models.Response{
114+
Success: false,
115+
Error: "Invalid step parameter. Must be 1-6.",
116+
})
117+
return
118+
}
119+
onlyStep = parsed
120+
}
107121

108-
// Step 2: Update Traefik dynamic_config.yml.
122+
// Define the pipeline of steps.
109123
traefikConfigDir := filepath.Join(cfg.ConfigDir, "traefik")
110-
traefikErr := updateTraefikCaptchaConfig(dockerClient, cfg, req, traefikConfigDir)
111-
steps = append(steps, gin.H{
112-
"step": 2,
113-
"name": "Update Traefik dynamic config",
114-
"success": traefikErr == nil,
115-
"error": errString(traefikErr),
116-
})
117124

118-
// Step 3: Update CrowdSec profiles.yaml.
119-
profilesErr := updateCrowdSecProfiles(dockerClient, cfg)
120-
steps = append(steps, gin.H{
121-
"step": 3,
122-
"name": "Update CrowdSec profiles",
123-
"success": profilesErr == nil,
124-
"error": errString(profilesErr),
125-
})
125+
pipeline := []captchaApplyStep{
126+
{
127+
Num: 1,
128+
Name: "Create captcha HTML page",
129+
Run: func(r models.CaptchaSetupRequest) error {
130+
return createCaptchaHTML(cfg, r.Provider, r.SiteKey)
131+
},
132+
},
133+
{
134+
Num: 2,
135+
Name: "Update Traefik dynamic config",
136+
Run: func(r models.CaptchaSetupRequest) error {
137+
return updateTraefikCaptchaConfig(dockerClient, cfg, r, traefikConfigDir)
138+
},
139+
},
140+
{
141+
Num: 3,
142+
Name: "Update CrowdSec profiles",
143+
Run: func(_ models.CaptchaSetupRequest) error {
144+
return updateCrowdSecProfiles(dockerClient, cfg)
145+
},
146+
},
147+
{
148+
Num: 4,
149+
Name: "Restart Traefik",
150+
Run: func(_ models.CaptchaSetupRequest) error {
151+
return restartTraefikContainer(dockerClient, cfg)
152+
},
153+
},
154+
{
155+
Num: 5,
156+
Name: "Restart CrowdSec",
157+
Run: func(_ models.CaptchaSetupRequest) error {
158+
return restartCrowdSecContainer(dockerClient, cfg)
159+
},
160+
},
161+
{
162+
Num: 6,
163+
Name: "Verify setup",
164+
Run: func(_ models.CaptchaSetupRequest) error {
165+
// Verification is implicit — if we got here, previous steps ran.
166+
return nil
167+
},
168+
},
169+
}
126170

127-
// Step 4: Restart Traefik (stop + start for clean config reload).
128-
traefikRestartErr := restartTraefikContainer(dockerClient, cfg)
129-
steps = append(steps, gin.H{
130-
"step": 4,
131-
"name": "Restart Traefik",
132-
"success": traefikRestartErr == nil,
133-
"error": errString(traefikRestartErr),
134-
})
171+
// Execute steps.
172+
steps := []gin.H{}
173+
allOK := true
135174

136-
// Step 5: Restart CrowdSec.
137-
csRestartErr := restartCrowdSecContainer(dockerClient, cfg)
138-
steps = append(steps, gin.H{
139-
"step": 5,
140-
"name": "Restart CrowdSec",
141-
"success": csRestartErr == nil,
142-
"error": errString(csRestartErr),
143-
})
175+
for _, s := range pipeline {
176+
if onlyStep > 0 && s.Num != onlyStep {
177+
continue
178+
}
144179

145-
// Step 6: Verify the full setup.
146-
verified := htmlErr == nil && traefikErr == nil && profilesErr == nil
147-
steps = append(steps, gin.H{
148-
"step": 6,
149-
"name": "Verify setup",
150-
"success": verified,
151-
})
180+
stepErr := s.Run(req)
181+
success := stepErr == nil
182+
if !success {
183+
allOK = false
184+
}
152185

153-
// Mark as applied in DB when all critical steps (HTML + Traefik + profiles) passed.
154-
allCriticalOK := htmlErr == nil && traefikErr == nil && profilesErr == nil
155-
if allCriticalOK {
186+
steps = append(steps, gin.H{
187+
"step": s.Num,
188+
"name": s.Name,
189+
"success": success,
190+
"error": errString(stepErr),
191+
})
192+
}
193+
194+
// Mark as applied in DB when all steps pass in a full run.
195+
if onlyStep == 0 && allOK {
156196
if markErr := db.MarkFeatureApplied("captcha"); markErr != nil {
157197
logger.Warn("Failed to mark captcha as applied in DB", "error", markErr)
158198
}
159199
}
160200

161201
message := "Captcha applied successfully"
162-
if !allCriticalOK {
202+
if !allOK {
163203
message = "Captcha applied with some errors — check step details"
164204
}
205+
if onlyStep > 0 {
206+
if allOK {
207+
message = "Step re-run succeeded"
208+
} else {
209+
message = "Step re-run failed — check details"
210+
}
211+
}
165212

166213
c.JSON(http.StatusOK, models.Response{
167-
Success: allCriticalOK,
214+
Success: allOK,
168215
Message: message,
169216
Data: gin.H{
170217
"steps": steps,
171-
"applied": allCriticalOK,
218+
"applied": allOK,
172219
"provider": req.Provider,
173220
},
174221
})
175222
}
176223
}
177-
178-
// errString safely converts an error to a string, returning "" for nil.
179-
func errString(err error) string {
180-
if err == nil {
181-
return ""
182-
}
183-
return err.Error()
184-
}

internal/api/handlers/common.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,11 @@ func GetConsoleStatusHelper(dockerClient interface {
187187

188188
return status, nil
189189
}
190+
191+
// errString safely converts an error to a string, returning "" for nil.
192+
func errString(err error) string {
193+
if err == nil {
194+
return ""
195+
}
196+
return err.Error()
197+
}

0 commit comments

Comments
 (0)