feat: add integration tests for CrowdSec preset pull and apply
- Introduced `crowdsec_integration_test.go` to validate the integration of the CrowdSec preset pull and apply functionality. - Updated `RealCommandExecutor` to return combined output for command execution. - Enhanced `CrowdsecHandler` to map errors to appropriate HTTP status codes, including handling timeouts. - Added tests for timeout scenarios in `crowdsec_presets_handler_test.go`. - Improved `HubService` to support configurable pull and apply timeouts via environment variables. - Implemented fallback logic for fetching hub index from a default URL if the primary fails. - Updated documentation to reflect changes in preset handling and cscli availability. - Refactored frontend tests to utilize a new test query client for better state management. - Added a new integration script `crowdsec_integration.sh` for automated testing of the CrowdSec integration.
This commit is contained in:
34
backend/integration/crowdsec_integration_test.go
Normal file
34
backend/integration/crowdsec_integration_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCrowdsecIntegration runs scripts/crowdsec_integration.sh and ensures it completes successfully.
|
||||
func TestCrowdsecIntegration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := exec.CommandContext(context.Background(), "bash", "./scripts/crowdsec_integration.sh")
|
||||
// Ensure script runs from repo root so relative paths in scripts work reliably
|
||||
cmd.Dir = "../../"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
cmd = exec.CommandContext(ctx, "bash", "./scripts/crowdsec_integration.sh")
|
||||
cmd.Dir = "../../"
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("crowdsec_integration script output:\n%s", string(out))
|
||||
if err != nil {
|
||||
t.Fatalf("crowdsec integration failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "Apply response: ") {
|
||||
t.Fatalf("unexpected script output, expected Apply response in output")
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,10 @@ type CommandExecutor interface {
|
||||
// RealCommandExecutor executes commands using os/exec.
|
||||
type RealCommandExecutor struct{}
|
||||
|
||||
// Execute runs a command and returns its output
|
||||
// Execute runs a command and returns its combined output (stdout/stderr)
|
||||
func (r *RealCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
return cmd.Output()
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// CrowdsecHandler manages CrowdSec process and config imports.
|
||||
@@ -55,6 +55,13 @@ type CrowdsecHandler struct {
|
||||
Hub *crowdsec.HubService
|
||||
}
|
||||
|
||||
func mapCrowdsecStatus(err error, defaultCode int) int {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return http.StatusGatewayTimeout
|
||||
}
|
||||
return defaultCode
|
||||
}
|
||||
|
||||
func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
|
||||
cacheDir := filepath.Join(dataDir, "hub_cache")
|
||||
cache, err := crowdsec.NewHubCache(cacheDir, 24*time.Hour)
|
||||
@@ -470,8 +477,9 @@ func (h *CrowdsecHandler) PullPreset(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
res, err := h.Hub.Pull(ctx, slug)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("slug", slug).Warn("crowdsec preset pull failed")
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
status := mapCrowdsecStatus(err, http.StatusBadGateway)
|
||||
logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).Warn("crowdsec preset pull failed")
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -514,11 +522,12 @@ func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
res, err := h.Hub.Apply(ctx, slug)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("slug", slug).Warn("crowdsec preset apply failed")
|
||||
status := mapCrowdsecStatus(err, http.StatusInternalServerError)
|
||||
logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).Warn("crowdsec preset apply failed")
|
||||
if h.DB != nil {
|
||||
_ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slug, Action: "apply", Status: "failed", CacheKey: res.CacheKey, BackupPath: res.BackupPath, Error: err.Error()}).Error
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "backup": res.BackupPath})
|
||||
c.JSON(status, gin.H{"error": err.Error(), "backup": res.BackupPath})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,34 @@ func TestPullPresetHandlerHubError(t *testing.T) {
|
||||
require.Equal(t, http.StatusBadGateway, w.Code)
|
||||
}
|
||||
|
||||
func TestPullPresetHandlerTimeout(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
hub := crowdsec.NewHubService(nil, cache, t.TempDir())
|
||||
hub.HubBaseURL = "http://example.com"
|
||||
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
})}
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = hub
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusGatewayTimeout, w.Code)
|
||||
require.Contains(t, w.Body.String(), "deadline")
|
||||
}
|
||||
|
||||
func TestGetCachedPresetNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +31,8 @@ const (
|
||||
defaultHubArchivePath = "/%s.tgz"
|
||||
defaultHubPreviewPath = "/%s.yaml"
|
||||
maxArchiveSize = int64(25 * 1024 * 1024) // 25MiB safety cap
|
||||
defaultPullTimeout = 25 * time.Second
|
||||
defaultApplyTimeout = 45 * time.Second
|
||||
)
|
||||
|
||||
// HubIndexEntry represents a single hub catalog entry.
|
||||
@@ -78,21 +82,46 @@ type HubService struct {
|
||||
|
||||
// NewHubService constructs a HubService with sane defaults.
|
||||
func NewHubService(exec CommandExecutor, cache *HubCache, dataDir string) *HubService {
|
||||
clientTimeout := 10 * time.Second
|
||||
pullTimeout := defaultPullTimeout
|
||||
if raw := strings.TrimSpace(os.Getenv("HUB_PULL_TIMEOUT_SECONDS")); raw != "" {
|
||||
if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
|
||||
pullTimeout = time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
applyTimeout := defaultApplyTimeout
|
||||
if raw := strings.TrimSpace(os.Getenv("HUB_APPLY_TIMEOUT_SECONDS")); raw != "" {
|
||||
if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
|
||||
applyTimeout = time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return &HubService{
|
||||
Exec: exec,
|
||||
Cache: cache,
|
||||
DataDir: dataDir,
|
||||
HTTPClient: newHubHTTPClient(clientTimeout),
|
||||
HTTPClient: newHubHTTPClient(pullTimeout),
|
||||
HubBaseURL: normalizeHubBaseURL(os.Getenv("HUB_BASE_URL")),
|
||||
PullTimeout: clientTimeout,
|
||||
ApplyTimeout: 15 * time.Second,
|
||||
PullTimeout: pullTimeout,
|
||||
ApplyTimeout: applyTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func newHubHTTPClient(timeout time.Duration) *http.Client {
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{ // keep dials bounded to avoid hanging sockets
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
ExpectContinueTimeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
@@ -107,6 +136,27 @@ func normalizeHubBaseURL(raw string) string {
|
||||
return strings.TrimRight(trimmed, "/")
|
||||
}
|
||||
|
||||
func buildIndexURL(base string) string {
|
||||
normalized := normalizeHubBaseURL(base)
|
||||
if strings.HasSuffix(strings.ToLower(normalized), ".json") {
|
||||
return normalized
|
||||
}
|
||||
return strings.TrimRight(normalized, "/") + defaultHubIndexPath
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchIndex downloads the hub index. If the hub is unreachable, returns ErrCacheMiss.
|
||||
func (s *HubService) FetchIndex(ctx context.Context) (HubIndex, error) {
|
||||
if s.Exec != nil {
|
||||
@@ -197,11 +247,32 @@ func (s *HubService) fetchIndexHTTP(ctx context.Context) (HubIndex, error) {
|
||||
if s.HTTPClient == nil {
|
||||
return HubIndex{}, fmt.Errorf("http client missing")
|
||||
}
|
||||
target := strings.TrimRight(s.HubBaseURL, "/") + defaultHubIndexPath
|
||||
|
||||
targets := uniqueStrings([]string{buildIndexURL(s.HubBaseURL), buildIndexURL(defaultHubBaseURL)})
|
||||
var errs []error
|
||||
|
||||
for _, target := range targets {
|
||||
idx, err := s.fetchIndexHTTPFromURL(ctx, target)
|
||||
if err == nil {
|
||||
return idx, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", target, err))
|
||||
}
|
||||
|
||||
if len(errs) == 1 {
|
||||
return HubIndex{}, fmt.Errorf("fetch hub index: %w", errs[0])
|
||||
}
|
||||
|
||||
return HubIndex{}, fmt.Errorf("fetch hub index: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func (s *HubService) fetchIndexHTTPFromURL(ctx context.Context, target string) (HubIndex, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return HubIndex{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return HubIndex{}, fmt.Errorf("fetch hub index: %w", err)
|
||||
@@ -408,19 +479,19 @@ func (s *HubService) fetchWithLimit(ctx context.Context, url string) ([]byte, er
|
||||
}
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("request %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("http %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxArchiveSize+1024)
|
||||
data, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
if int64(len(data)) > maxArchiveSize {
|
||||
return nil, fmt.Errorf("payload too large")
|
||||
return nil, fmt.Errorf("payload too large from %s", url)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -129,6 +129,35 @@ func TestFetchIndexHTTPRejectsHTML(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "HTML")
|
||||
}
|
||||
|
||||
func TestFetchIndexHTTPFallsBackToDefaultHub(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
svc.HubBaseURL = "https://hub.crowdsec.net"
|
||||
calls := make([]string, 0)
|
||||
|
||||
indexBody := `{"items":[{"name":"crowdsecurity/demo","title":"Demo","type":"collection"}]}`
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls = append(calls, req.URL.String())
|
||||
switch req.URL.String() {
|
||||
case "https://hub.crowdsec.net/api/index.json":
|
||||
resp := newResponse(http.StatusMovedPermanently, "")
|
||||
resp.Header.Set("Location", "https://hub-data.crowdsec.net/api/index.json")
|
||||
return resp, nil
|
||||
case "https://hub-data.crowdsec.net/api/index.json":
|
||||
resp := newResponse(http.StatusOK, indexBody)
|
||||
resp.Header.Set("Content-Type", "application/json")
|
||||
return resp, nil
|
||||
default:
|
||||
return newResponse(http.StatusNotFound, ""), nil
|
||||
}
|
||||
})}
|
||||
|
||||
idx, err := svc.fetchIndexHTTP(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, idx.Items, 1)
|
||||
require.Equal(t, "crowdsecurity/demo", idx.Items[0].Name)
|
||||
require.Equal(t, []string{"https://hub.crowdsec.net/api/index.json", "https://hub-data.crowdsec.net/api/index.json"}, calls)
|
||||
}
|
||||
|
||||
func TestPullCachesPreview(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
dataDir := filepath.Join(t.TempDir(), "crowdsec")
|
||||
|
||||
@@ -161,6 +161,7 @@ Charon includes **Cerberus**, a security system that blocks bad guys. It's off b
|
||||
### Configuration Packages
|
||||
|
||||
- **Hub presets:** Pull presets from the CrowdSec Hub over HTTPS, use cache keys/ETags for faster repeat pulls, preview changes, then apply with an automatic backup and reload flag. Requires Cerberus to be enabled with admin scope; `cscli` is preferred for execution.
|
||||
- **cscli availability:** Docker images (v1.7.4+) ship with cscli pre-installed. Bare-metal deployments can install cscli for Hub preset sync or use HTTP fallback with HUB_BASE_URL. Preset pull/apply requires either cscli or cached presets.
|
||||
- **Offline/curated:** If the Hub is unreachable or apply is not supported, curated/offline presets remain available.
|
||||
- **Validation:** Slugs are validated before apply. Hub errors surface cleanly (503 uses retry or cached data; 400 for bad slugs; apply failures prompt you to restore from the backup).
|
||||
---
|
||||
|
||||
@@ -14,6 +14,21 @@
|
||||
- Likely 500 on apply: backup rename fails, `cscli` install fails with no cache fallback (if pull never succeeded or cache expired/missing), cache read errors (`metadata.json`/`bundle.tgz` unreadable), tar extraction rejects symlinks/unsafe paths, or rollback after extract failure. Handler writes `CrowdsecPresetEvent` (if DB reachable) with backup path and returns 500 with `backup` hint.
|
||||
- Validation steps during triage: verify cache entry freshness (TTL 24h) via `metadata.json` timestamps; confirm `cscli hub install <slug>` succeeds manually; if cscli missing, ensure prior pull populated cache; test hub egress with curl to hub index and archive URLs; check file ownership/permissions on `data/crowdsec` and `data/crowdsec/hub_cache`; confirm log lines around warnings for exact error message; inspect backup directory to restore if partial apply.
|
||||
|
||||
### Current incident: preset apply returning "Network Error" (feature/beta-release)
|
||||
- What we see: frontend reports axios "Network Error" while applying a preset. Backend logs do not yet show the apply warning, suggesting the client drops before an HTTP response arrives. Apply path runs `HubService.Apply` in [backend/internal/crowdsec/hub_sync.go](backend/internal/crowdsec/hub_sync.go) with a 15s context; pull uses a 10s HTTP client timeout and does not follow redirects. Axios flags a network error when the TCP connection is reset/timeout rather than when a 4xx/5xx is returned.
|
||||
- Probable roots to verify quickly:
|
||||
- Hub index/preview/archives now redirect to another host; our HTTP client forbids redirects, so FetchIndex/Pull return an error and the handler responds 502 only after the hub timeout. Long hub connect attempts can hit the 10s client timeout, causing the upstream (Caddy) or browser to drop the socket and surface a network error.
|
||||
- Runtime image may be missing `cscli` if the release archive layout changed; Dockerfile only moves the binaries when expected paths exist. Without cscli, Apply falls back to cache, but if Pull already failed, Apply exits with an error and no response body. Validate `cscli version` inside the running container built from feature/beta-release.
|
||||
- Outbound egress/proxy: container must reach https://hub-data.crowdsec.net (default) from within the Docker network. Missing `HTTP(S)_PROXY`/`NO_PROXY` or a transparent MITM can cause TLS handshake or connection timeouts that the client reports as network errors.
|
||||
- TLS/HTML responses: hub returning HTML (maintenance/Cloudflare) or a 3xx/302 to http is treated as an error (`hub index responded with HTML`), which becomes 502. If the redirect/HTML arrives after ~10s the browser may already have given up.
|
||||
- Timeout budget: 10s pull / 15s apply may be too tight for hub downloads + cscli install. When the context cancels mid-stream, gin closes the connection and axios logs network error instead of an HTTP code.
|
||||
- Remediation plan (no code yet):
|
||||
- Confirm cscli exists in the runtime image from [Dockerfile](Dockerfile) by running `cscli version` inside the failing container; if missing, adjust build or add a startup preflight that logs absence and forces HTTP hub path.
|
||||
- Override HUB_BASE_URL to a known JSON endpoint (e.g., `https://hub-data.crowdsec.net/api/index.json`) when redirects occur, or point to an internal mirror reachable from the Docker network; document this in env examples.
|
||||
- Ensure outbound 443 to hub-data is allowed or set `HTTP(S)_PROXY`/`NO_PROXY` on the container; retry pull/apply after validating `curl -v https://hub-data.crowdsec.net/api/index.json` inside the runtime.
|
||||
- Consider raising pull/apply timeouts (and matching frontend request timeout) and log when contexts cancel so we return a 504/timeout JSON instead of a dropped socket.
|
||||
- Capture docker logs for `charon-debug` during repro; look for `crowdsec preset pull/apply failed` warnings and any TLS/redirect messages from [backend/internal/crowdsec/hub_sync.go](backend/internal/crowdsec/hub_sync.go).
|
||||
|
||||
## Goal
|
||||
Implement real CrowdSec Hub preset sync + apply on backend (using cscli or direct hub index) with caching, validation, backups, rollback, and wire the UI to new endpoints so operators can preview/apply hub items with clear status/errors.
|
||||
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
# QA Report: CrowdSec Hub Preset (feature/beta-release)
|
||||
# QA Report: CrowdSec Hub/Preset Network-Error Fix (feature/beta-release)
|
||||
|
||||
**Date:** December 8, 2025
|
||||
**QA Agent:** QA_Security
|
||||
**Scope:** Post-merge QA after CrowdSec hub preset backend/frontend changes on `feature/beta-release`.
|
||||
**Requested Steps:** `pre-commit run --all-files`, `backend: go test ./...`, `frontend: npm run test:ci`.
|
||||
**Date:** December 8, 2025 - 21:26 UTC
|
||||
**QA Agent:** QA_Automation
|
||||
**Scope:** Regression after CrowdSec hub/preset network-error fix on `feature/beta-release`.
|
||||
**Requested Steps:** `pre-commit run --all-files`, `cd backend && go test ./...`, `cd frontend && npm run test:ci`.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Final Verdict:** ✅ PASS (coverage gate met)
|
||||
**Final Verdict:** ✅ PASS (all commands green; coverage gate met)
|
||||
|
||||
- `pre-commit run --all-files` passes; coverage hook reports 85.0% vs required 85% (gate met) after adding middleware sanitize tests. Hooks include Go vet, version check, frontend type-check, and lint fix.
|
||||
- `go test ./...` (backend) passes via task `Go: Test Backend`.
|
||||
- `npm run test:ci` passes (Vitest, 70 files / 598 tests). React Query undefined-data warnings and jsdom navigation warnings appear but suites stay green.
|
||||
- `pre-commit run --all-files` **PASSED** — Hooks completed; coverage gate at **85.1%** (≥ 85%).
|
||||
- `cd backend && go test ./...` **PASSED** — All packages succeeded.
|
||||
- `cd frontend && npm run test:ci` **PASSED** — 70 files / 598 tests passed; one non-blocking warning about undefined query data in Layout feature-flags test.
|
||||
|
||||
## Test Results
|
||||
|
||||
| Area | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Pre-commit | ✅ PASS | Coverage gate satisfied at 85.0% (minimum 85%) after middleware sanitize tests; all hooks succeeded. |
|
||||
| Backend Unit Tests | ✅ PASS | `cd backend && go test ./...` (task: Go: Test Backend). |
|
||||
| Frontend Unit Tests | ✅ PASS* | `npm run test:ci` (Vitest, 70 files / 598 tests). Warnings: React Query "query data cannot be undefined" for `securityConfig`/`securityRulesets`/`feature-flags`; jsdom "navigation to another Document". |
|
||||
| Area | Command | Status | Details |
|
||||
| --- | --- | --- | --- |
|
||||
| Pre-commit Hooks | `pre-commit run --all-files` | ✅ PASS | Coverage 85.1% (min 85%), Go Vet, .version check, TS check, frontend lint all passed |
|
||||
| Backend Tests | `cd backend && go test ./...` | ✅ PASS | All packages passed (services, util, version, handlers, middleware) |
|
||||
| Frontend Tests | `cd frontend && npm run test:ci` | ✅ PASS | 70 files / 598 tests passed; duration ~46s; warning: React Query "query data cannot be undefined" for `feature-flags` in Layout.test |
|
||||
|
||||
## Evidence / Logs
|
||||
## Detailed Results
|
||||
|
||||
- Coverage hook output: `Computed coverage: 85.0% (minimum required 85%)` followed by “Coverage requirement met.”
|
||||
- Backend tests: task output shows `ok github.com/Wikid82/charon/backend/internal/...` with no failures.
|
||||
- Frontend Vitest: full log at [test-results/frontend-test.log](test-results/frontend-test.log) (70 files, 598 tests, warnings noted above).
|
||||
### Pre-commit (All Files)
|
||||
- **Status:** ✅ Passed
|
||||
- **Coverage Gate:** 85.1% (requirement 85%)
|
||||
- **Hooks:** Go Vet, version tag check, Frontend TypeScript check, Frontend Lint (Fix)
|
||||
|
||||
### Backend Tests
|
||||
- **Status:** ✅ Passed
|
||||
- **Notes:** `go test ./...` completed without failures; packages include services, util, version, handlers, middleware.
|
||||
|
||||
### Frontend Tests
|
||||
- **Status:** ✅ Passed
|
||||
- **Totals:** 70 files; 598 tests; duration ~46s.
|
||||
- **Warnings (non-blocking):** React Query "query data cannot be undefined" for `feature-flags` in `Layout.test.tsx`; jsdom "navigation to another Document" informational notices.
|
||||
|
||||
## Evidence
|
||||
|
||||
### Pre-commit Output (excerpt)
|
||||
```
|
||||
Computed coverage: 85.1% (minimum required 85%)
|
||||
Coverage requirement met
|
||||
Go Vet...................................................................Passed
|
||||
Check .version matches latest Git tag....................................Passed
|
||||
Frontend TypeScript Check................................................Passed
|
||||
Frontend Lint (Fix)......................................................Passed
|
||||
```
|
||||
|
||||
### Frontend Tests (vitest)
|
||||
```
|
||||
Test Files 70 passed (70)
|
||||
Tests 598 passed (598)
|
||||
Duration 46.45s
|
||||
Warning Query data cannot be undefined. Affected query key: ["feature-flags"]
|
||||
```
|
||||
|
||||
## Follow-ups / Recommendations
|
||||
|
||||
1. Optionally tighten React Query mocks in Security and Layout suites to eliminate "query data cannot be undefined" warnings; consider default fixtures for `securityConfig`, `securityRulesets`, and `feature-flags`.
|
||||
2. Silence jsdom "navigation to another Document" warnings if noise persists (e.g., stub navigation or avoid window.location changes in tests).
|
||||
1. **Silence React Query warning:** Provide default fixtures/mocks for `feature-flags` query in `Layout.test.tsx` to avoid undefined data warning.
|
||||
2. **Keep coverage gate ≥ 85%:** Current computed coverage 85.1%.
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ QA Passed (coverage gate satisfied).
|
||||
**Status:** ✅ QA PASS — All requested commands succeeded; coverage gate met at 85.1%
|
||||
|
||||
@@ -137,6 +137,7 @@ Now only devices on `192.168.x.x` or `10.x.x.x` can access it. The public intern
|
||||
|
||||
- **Import/Export:** You can import or export Cerberus configuration packages; exports prompt you to confirm the filename before saving.
|
||||
- **Presets (CrowdSec Hub):** Pull presets from the CrowdSec Hub over HTTPS using cache keys/ETags, prefer `cscli` execution, and require Cerberus to be enabled with an admin-scoped session. Workflow: pull → preview → apply with an automatic backup and reload flag.
|
||||
- **cscli availability:** Docker images (v1.7.4+) ship with cscli pre-installed. Bare-metal deployments can install cscli for Hub preset sync or use HTTP fallback with HUB_BASE_URL. Preset pull/apply requires either cscli or cached presets.
|
||||
- **Fallbacks:** If the Hub is unreachable (503 uses retry or cached data), curated/offline presets stay available; invalid slugs return a 400 with validation detail; apply failures remind you to restore from the backup; if apply is not supported (501), stay on curated/offline presets.
|
||||
|
||||
---
|
||||
|
||||
@@ -5,11 +5,18 @@ Keep Cerberus terminology and the Configuration Packages flow in mind while debu
|
||||
## Quick checks
|
||||
- Cerberus is enabled and you are signed in with admin scope.
|
||||
- `cscli` is available (preferred path); HTTPS CrowdSec Hub endpoints only.
|
||||
- Docker images (v1.7.4+): cscli is pre-installed.
|
||||
- Bare-metal deployments: install cscli for Hub preset sync or use HTTP fallback with HUB_BASE_URL.
|
||||
- HUB_BASE_URL points to a JSON hub endpoint (default: https://hub-data.crowdsec.net/api/index.json). Redirects to HTML will be rejected.
|
||||
- Proxy env is set when required: HTTP(S)_PROXY and NO_PROXY are respected by the hub client.
|
||||
- For slow or proxied networks, increase HUB_PULL_TIMEOUT_SECONDS (default 25) and HUB_APPLY_TIMEOUT_SECONDS (default 45) to avoid premature timeouts.
|
||||
- Preset workflow: pull from Hub using cache keys/ETags → preview changes → apply with automatic backup and reload flag.
|
||||
- Preset pull/apply requires either cscli or cached presets.
|
||||
- Offline/curated presets remain available at all times.
|
||||
|
||||
## Common issues
|
||||
- Hub unreachable (503): retry once, then Charon falls back to cached Hub data if available; otherwise stay on curated/offline presets until connectivity returns.
|
||||
- Hub returns HTML/redirect: set HUB_BASE_URL to the JSON endpoint above or install cscli so the index is fetched locally.
|
||||
- Bad preset slug (400): the slug must match Hub naming; correct the slug before retrying.
|
||||
- Apply failed: review the apply response and restore from the backup that was taken automatically, then retry after fixing the underlying issue.
|
||||
- Apply not supported (501): use curated/offline presets; Hub apply will be re-enabled when supported in your environment.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import CertificateList from '../CertificateList'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
@@ -44,7 +46,7 @@ vi.mock('../../utils/toast', () => ({
|
||||
}))
|
||||
|
||||
function renderWithClient(ui: React.ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
const qc = createTestQueryClient()
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
@@ -54,6 +56,7 @@ describe('CertificateList', () => {
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { createBackup } = await import('../../api/backups')
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
@@ -61,7 +64,7 @@ describe('CertificateList', () => {
|
||||
expect(customRow).toBeTruthy()
|
||||
const customBtn = customRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
|
||||
expect(customBtn).toBeTruthy()
|
||||
await customBtn.click()
|
||||
await user.click(customBtn)
|
||||
|
||||
await waitFor(() => expect(createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
|
||||
@@ -72,11 +75,12 @@ describe('CertificateList', () => {
|
||||
it('deletes staging certificate when confirmed', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const stagingButtons = await screen.findAllByTitle('Delete Staging Certificate')
|
||||
expect(stagingButtons.length).toBeGreaterThan(0)
|
||||
await stagingButtons[0].click()
|
||||
await user.click(stagingButtons[0])
|
||||
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(2))
|
||||
confirmSpy.mockRestore()
|
||||
@@ -84,24 +88,26 @@ describe('CertificateList', () => {
|
||||
|
||||
it('blocks deletion when certificate is in use by a proxy host', async () => {
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
|
||||
// Find button corresponding to ActiveCert (id 3)
|
||||
const activeButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
|
||||
expect(activeButton).toBeTruthy()
|
||||
if (activeButton) await activeButton.click()
|
||||
if (activeButton) await user.click(activeButton)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('in use')))
|
||||
})
|
||||
|
||||
it('blocks deletion when certificate status is active (valid/expiring)', async () => {
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
|
||||
// ActiveCert (valid) should block even if not linked – ensure hosts mock links it so previous test covers linkage.
|
||||
// Here, simulate clicking a valid cert button if present
|
||||
const validButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
|
||||
expect(validButton).toBeTruthy()
|
||||
if (validButton) await validButton.click()
|
||||
if (validButton) await user.click(validButton)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as api from '../../api/crowdsec'
|
||||
import * as backups from '../../api/backups'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
const qc = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
@@ -38,7 +37,8 @@ describe('ImportCrowdSec page', () => {
|
||||
expect(fileInput).toBeTruthy()
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
const importBtn = screen.getByText('Import')
|
||||
await userEvent.click(importBtn)
|
||||
const user = userEvent.setup()
|
||||
await user.click(importBtn)
|
||||
|
||||
await waitFor(() => expect(backups.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(api.importCrowdsecConfig).toHaveBeenCalledWith(file))
|
||||
|
||||
@@ -2,11 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
@@ -27,7 +28,7 @@ describe('ImportCrowdSec', () => {
|
||||
})
|
||||
|
||||
const renderPage = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const qc = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
@@ -49,11 +50,12 @@ describe('ImportCrowdSec', () => {
|
||||
|
||||
const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement
|
||||
const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' })
|
||||
const user = userEvent.setup()
|
||||
|
||||
await userEvent.upload(fileInput, file)
|
||||
await user.upload(fileInput, file)
|
||||
|
||||
const importButton = screen.getByRole('button', { name: /Import/i })
|
||||
await userEvent.click(importButton)
|
||||
await user.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled()
|
||||
|
||||
@@ -2,22 +2,48 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as api from '../../api/security'
|
||||
import type { SecurityStatus, RuleSetsResponse } from '../../api/security'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return { ...actual, useNavigate: () => mockNavigate }
|
||||
})
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/crowdsec')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const defaultFeatureFlags = {
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
}
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
const baseStatus: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
|
||||
const createQueryClient = (initialData = []) => createTestQueryClient([
|
||||
{ key: ['securityConfig'], data: mockSecurityConfig },
|
||||
{ key: ['securityRulesets'], data: mockRuleSets },
|
||||
{ key: ['feature-flags'], data: defaultFeatureFlags },
|
||||
...initialData,
|
||||
])
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode, initialData = []) => {
|
||||
const qc = createQueryClient(initialData)
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
@@ -46,6 +72,10 @@ const mockRuleSets: RuleSetsResponse = {
|
||||
describe('Security page', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
|
||||
vi.mocked(api.getSecurityConfig).mockResolvedValue(mockSecurityConfig)
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
vi.mocked(api.updateSecurityConfig).mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('shows banner when all services are disabled and links to docs', async () => {
|
||||
|
||||
18
frontend/src/test/createTestQueryClient.ts
Normal file
18
frontend/src/test/createTestQueryClient.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { QueryClient, QueryKey } from '@tanstack/react-query'
|
||||
|
||||
interface InitialDataEntry {
|
||||
key: QueryKey
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export function createTestQueryClient(initialData: InitialDataEntry[] = []) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
initialData.forEach(({ key, data }) => client.setQueryData(key, data))
|
||||
return client
|
||||
}
|
||||
75
scripts/crowdsec_integration.sh
Normal file
75
scripts/crowdsec_integration.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
trap 'echo "Error occurred, dumping debug info..."; docker logs charon-debug 2>&1 | tail -200 || true' ERR
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker is not available; aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building charon:local image..."
|
||||
docker build -t charon:local .
|
||||
|
||||
docker rm -f charon-debug >/dev/null 2>&1 || true
|
||||
if ! docker network inspect containers_default >/dev/null 2>&1; then
|
||||
docker network create containers_default
|
||||
fi
|
||||
|
||||
docker run -d --name charon-debug --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --network containers_default -p 80:80 -p 443:443 -p 8080:8080 -p 2019:2019 -p 2345:2345 \
|
||||
-e CHARON_ENV=development -e CHARON_DEBUG=1 -e CHARON_HTTP_PORT=8080 -e CHARON_DB_PATH=/app/data/charon.db -e CHARON_FRONTEND_DIR=/app/frontend/dist \
|
||||
-e CHARON_CADDY_ADMIN_API=http://localhost:2019 -e CHARON_CADDY_CONFIG_DIR=/app/data/caddy -e CHARON_CADDY_BINARY=caddy -e CHARON_IMPORT_CADDYFILE=/import/Caddyfile \
|
||||
-e CHARON_IMPORT_DIR=/app/data/imports -e CHARON_ACME_STAGING=false -e FEATURE_CERBERUS_ENABLED=true \
|
||||
-v charon_data:/app/data -v caddy_data:/data -v caddy_config:/config -v /var/run/docker.sock:/var/run/docker.sock:ro charon:local
|
||||
|
||||
echo "Waiting for Charon API to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s -f http://localhost:8080/api/v1/ >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
echo -n '.'
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Registering admin user and logging in..."
|
||||
TMP_COOKIE=$(mktemp)
|
||||
curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123","name":"Integration Tester"}' http://localhost:8080/api/v1/auth/register >/dev/null || true
|
||||
curl -s -X POST -H "Content-Type: application/json" -d '{"email":"integration@example.local","password":"password123"}' -c ${TMP_COOKIE} http://localhost:8080/api/v1/auth/login >/dev/null
|
||||
|
||||
echo "Pulled presets list..."
|
||||
LIST=$(curl -s -H "Content-Type: application/json" -b ${TMP_COOKIE} http://localhost:8080/api/v1/admin/crowdsec/presets)
|
||||
echo "$LIST" | jq -r .presets | head -20
|
||||
|
||||
SLUG="bot-mitigation-essentials"
|
||||
echo "Pulling preset $SLUG"
|
||||
PULL_RESP=$(curl -s -X POST -H "Content-Type: application/json" -d '{"slug":"'${SLUG}'"}' -b ${TMP_COOKIE} http://localhost:8080/api/v1/admin/crowdsec/presets/pull)
|
||||
echo "Pull response: $PULL_RESP"
|
||||
if ! echo "$PULL_RESP" | jq -e .status >/dev/null 2>&1; then
|
||||
echo "Pull failed: $PULL_RESP"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$PULL_RESP" | jq -r .status)" != "pulled" ]; then
|
||||
echo "Unexpected pull status: $(echo $PULL_RESP | jq -r .status)"
|
||||
exit 1
|
||||
fi
|
||||
CACHE_KEY=$(echo "$PULL_RESP" | jq -r .cache_key)
|
||||
|
||||
echo "Applying preset $SLUG"
|
||||
APPLY_RESP=$(curl -s -X POST -H "Content-Type: application/json" -d '{"slug":"'${SLUG}'"}' -b ${TMP_COOKIE} http://localhost:8080/api/v1/admin/crowdsec/presets/apply)
|
||||
echo "Apply response: $APPLY_RESP"
|
||||
if ! echo "$APPLY_RESP" | jq -e .status >/dev/null 2>&1; then
|
||||
echo "Apply failed: $APPLY_RESP"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$APPLY_RESP" | jq -r .status)" != "applied" ]; then
|
||||
echo "Unexpected apply status: $(echo $APPLY_RESP | jq -r .status)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleanup and exit"
|
||||
docker rm -f charon-debug >/dev/null 2>&1 || true
|
||||
rm -f ${TMP_COOKIE}
|
||||
echo "Done"
|
||||
Reference in New Issue
Block a user