From b15a4fbc6355c3a11a2f2e76fb1ae6430790ca13 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Mon, 12 Oct 2026 13:38:33 +0100 Subject: [PATCH 1/3] feat(plugin): define before_build and after_build hooks and payloads - Add HookBeforeBuild and HookAfterBuild constants in protocol.go. - Define BuildPayload and BuildImage structs matching build.ImageSpec data. - Update TestHelperProcess and add TestManager_BuildHooksDelivered test. --- internal/plugin/plugin_test.go | 47 ++++++++++++++++++++++++++++++++-- internal/plugin/protocol.go | 28 +++++++++++++++++--- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 46619e5..4ae48db 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -50,14 +50,14 @@ func TestHelperProcess(t *testing.T) { fmt.Fprintln(os.Stdout, "this is not json") // desync the stream continue } - hooks := []string{"before_deploy", "after_deploy"} + hooks := []string{"before_deploy", "after_deploy", "before_build", "after_build"} if mode == "nohooks" { hooks = nil } reply(req.ID, map[string]any{"name": "helper-" + mode, "hooks": hooks}) case "shutdown": os.Exit(0) - case "before_deploy", "after_deploy": + case "before_deploy", "after_deploy", "before_build", "after_build": switch mode { case "crash": os.Exit(1) @@ -129,6 +129,49 @@ func TestManager_HandshakeAndDispatch(t *testing.T) { } } +func TestManager_BuildHooksDelivered(t *testing.T) { + out := filepath.Join(t.TempDir(), "build_hooks.log") + m := helperManager(t, "echo", out) + if m == nil { + t.Fatal("manager should have one live plugin") + } + defer m.Close() + + ctx := context.Background() + m.Dispatch(ctx, HookBeforeBuild, BuildPayload{ + Event: "test-event", + Phase: "before", + SHA: "abc1234", + Images: []BuildImage{ + { + Challenge: "web/cub3-clone", + Category: "web", + Slug: "cub3-clone", + Tags: []string{"registry.test/web:abc1234", "registry.test/web:latest"}, + Labels: map[string]string{"mctf.challenge": "web/cub3-clone"}, + }, + }, + }) + m.Dispatch(ctx, HookAfterBuild, BuildPayload{ + Event: "test-event", + Phase: "after", + SHA: "abc1234", + Outcome: "success", + }) + + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read hooks log: %v", err) + } + s := string(data) + if !strings.Contains(s, "before_build") || !strings.Contains(s, `"web/cub3-clone"`) { + t.Errorf("before_build payload not delivered:\n%s", s) + } + if !strings.Contains(s, "after_build") || !strings.Contains(s, `"outcome":"success"`) { + t.Errorf("after_build payload not delivered:\n%s", s) + } +} + func TestManager_UnsubscribedHookNotDelivered(t *testing.T) { out := filepath.Join(t.TempDir(), "hooks.log") m := helperManager(t, "nohooks", out) diff --git a/internal/plugin/protocol.go b/internal/plugin/protocol.go index 7665f8d..12825c0 100644 --- a/internal/plugin/protocol.go +++ b/internal/plugin/protocol.go @@ -59,12 +59,12 @@ type initResult struct { Hooks []string `json:"hooks"` } -// Hook names. Only the deploy hooks are wired in this slice; the rest of the -// backlog roster (before_build, after_build, on_instance_spawn, -// on_health_failure) is added alongside its wiring in follow-ups. +// Hook names. const ( HookBeforeDeploy = "before_deploy" HookAfterDeploy = "after_deploy" + HookBeforeBuild = "before_build" + HookAfterBuild = "after_build" ) // DeployPayload is the JSON body for the before_/after_deploy hooks. @@ -86,3 +86,25 @@ type DeployUnit struct { ProjectName string `json:"project_name"` ImageTag string `json:"image_tag,omitempty"` } + +// BuildPayload is the JSON body for the before_/after_build hooks. +type BuildPayload struct { + Event string `json:"event"` + Phase string `json:"phase"` // "before" | "after" + SHA string `json:"sha,omitempty"` + NoCache bool `json:"no_cache"` + Push bool `json:"push"` + DryRun bool `json:"dry_run"` + Images []BuildImage `json:"images"` + Outcome string `json:"outcome,omitempty"` // after: "success" | "failed" + Error string `json:"error,omitempty"` +} + +// BuildImage is one image in a build payload. +type BuildImage struct { + Challenge string `json:"challenge"` + Category string `json:"category"` + Slug string `json:"slug"` + Tags []string `json:"tags"` + Labels map[string]string `json:"labels,omitempty"` +} From 9e98ec1f3a6fd98860823295e0efff6a605907c9 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Mon, 12 Oct 2026 13:41:03 +0100 Subject: [PATCH 2/3] feat(cmd): wire before_build and after_build hooks in mctf build - Load lifecycle plugins via plugin.Load using cfg.Plugins. - Dispatch before_build prior to build.Apply with ImageSpec details. - Dispatch after_build with outcome failed on build, scan, or push failure. - Dispatch after_build with outcome success on completion. - Add buildHookPayload helper and unit tests in cmd/build_plugin_test.go. --- cmd/build.go | 41 +++++++++++++++++ cmd/build_plugin_test.go | 97 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 cmd/build_plugin_test.go diff --git a/cmd/build.go b/cmd/build.go index 315c802..c4bbe2c 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -11,6 +11,7 @@ import ( "github.com/MicroClub-USTHB/mctf-cli/internal/build" "github.com/MicroClub-USTHB/mctf-cli/internal/challenge" "github.com/MicroClub-USTHB/mctf-cli/internal/config" + "github.com/MicroClub-USTHB/mctf-cli/internal/plugin" ) var ( @@ -86,14 +87,22 @@ var buildCmd = &cobra.Command{ } ctx := context.Background() + + // Lifecycle plugins (best-effort; a no-op without a `plugins:` block). + pm := plugin.Load(ctx, cfg.Plugins, plugin.Options{HostVersion: version, Dir: flagDir}) + defer pm.Close() + pm.Dispatch(ctx, plugin.HookBeforeBuild, buildHookPayload(cfg, opts, "before", flagBuildDryRun, plan, "", nil)) + buildOpts := opts buildOpts.Push = false if err := build.Apply(ctx, runner, plan, buildOpts); err != nil { + pm.Dispatch(ctx, plugin.HookAfterBuild, buildHookPayload(cfg, opts, "after", flagBuildDryRun, plan, "failed", err)) return err } if !flagBuildDryRun && flagBuildScan { if err := scanBuiltImages(ctx, plan); err != nil { + pm.Dispatch(ctx, plugin.HookAfterBuild, buildHookPayload(cfg, opts, "after", flagBuildDryRun, plan, "failed", err)) return err } } @@ -101,15 +110,47 @@ var buildCmd = &cobra.Command{ if flagBuildPush { fmt.Printf("\npushing %d image(s) to registry...\n", len(plan)) if err := build.ApplyPush(ctx, runner, plan, opts); err != nil { + pm.Dispatch(ctx, plugin.HookAfterBuild, buildHookPayload(cfg, opts, "after", flagBuildDryRun, plan, "failed", err)) return err } } + pm.Dispatch(ctx, plugin.HookAfterBuild, buildHookPayload(cfg, opts, "after", flagBuildDryRun, plan, "success", nil)) fmt.Println("\nbuild complete") return nil }, } +// buildHookPayload builds the plugin hook body for a build run. +func buildHookPayload(cfg *config.Config, opts build.Options, phase string, dryRun bool, plan []build.ImageSpec, outcome string, err error) plugin.BuildPayload { + eventSlug := "" + if cfg != nil { + eventSlug = cfg.Event.Slug + } + p := plugin.BuildPayload{ + Event: eventSlug, + Phase: phase, + SHA: opts.SHA, + NoCache: opts.NoCache, + Push: opts.Push, + DryRun: dryRun, + Outcome: outcome, + } + if err != nil { + p.Error = err.Error() + } + for _, spec := range plan { + p.Images = append(p.Images, plugin.BuildImage{ + Challenge: spec.Challenge, + Category: spec.Category, + Slug: spec.Slug, + Tags: spec.Tags, + Labels: spec.Labels, + }) + } + return p +} + // scanBuiltImages runs the post-build leak scan for every spec in // plan and aborts on the first hit. func scanBuiltImages(ctx context.Context, plan []build.ImageSpec) error { diff --git a/cmd/build_plugin_test.go b/cmd/build_plugin_test.go new file mode 100644 index 0000000..4f80d48 --- /dev/null +++ b/cmd/build_plugin_test.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/MicroClub-USTHB/mctf-cli/internal/build" + "github.com/MicroClub-USTHB/mctf-cli/internal/config" +) + +func TestBuildHookPayload(t *testing.T) { + cfg := &config.Config{ + Event: config.Event{Slug: "test-event"}, + } + opts := build.Options{ + SHA: "1a2b3c4", + NoCache: true, + Push: false, + } + plan := []build.ImageSpec{ + { + Challenge: "web/cub3-clone", + Category: "web", + Slug: "cub3-clone", + Tags: []string{"ghcr.io/test/web-cub3:1a2b3c4", "ghcr.io/test/web-cub3:latest"}, + Labels: map[string]string{"mctf.challenge": "web/cub3-clone"}, + }, + { + Challenge: "pwn/baby-overflow", + Category: "pwn", + Slug: "baby-overflow", + Tags: []string{"ghcr.io/test/pwn-baby:1a2b3c4"}, + }, + } + + // 1. "before" phase payload + pBefore := buildHookPayload(cfg, opts, "before", true, plan, "", nil) + if pBefore.Event != "test-event" { + t.Errorf("Event = %q, want test-event", pBefore.Event) + } + if pBefore.Phase != "before" { + t.Errorf("Phase = %q, want before", pBefore.Phase) + } + if !pBefore.DryRun { + t.Errorf("DryRun = false, want true") + } + if !pBefore.NoCache { + t.Errorf("NoCache = false, want true") + } + if pBefore.SHA != "1a2b3c4" { + t.Errorf("SHA = %q, want 1a2b3c4", pBefore.SHA) + } + if len(pBefore.Images) != 2 { + t.Fatalf("Images count = %d, want 2", len(pBefore.Images)) + } + if pBefore.Images[0].Challenge != "web/cub3-clone" || pBefore.Images[0].Slug != "cub3-clone" { + t.Errorf("Images[0] = %+v", pBefore.Images[0]) + } + if pBefore.Images[0].Labels["mctf.challenge"] != "web/cub3-clone" { + t.Errorf("Images[0].Labels = %+v", pBefore.Images[0].Labels) + } + if pBefore.Outcome != "" || pBefore.Error != "" { + t.Errorf("unexpected outcome/error on before hook: %q / %q", pBefore.Outcome, pBefore.Error) + } + + // 2. "after" success phase payload + pAfterSuccess := buildHookPayload(cfg, opts, "after", false, plan, "success", nil) + if pAfterSuccess.Phase != "after" { + t.Errorf("Phase = %q, want after", pAfterSuccess.Phase) + } + if pAfterSuccess.Outcome != "success" { + t.Errorf("Outcome = %q, want success", pAfterSuccess.Outcome) + } + if pAfterSuccess.Error != "" { + t.Errorf("Error = %q, want empty", pAfterSuccess.Error) + } + + // 3. "after" failure phase payload + errFailed := errors.New("docker daemon unresponsive") + pAfterFail := buildHookPayload(cfg, opts, "after", false, plan, "failed", errFailed) + if pAfterFail.Outcome != "failed" { + t.Errorf("Outcome = %q, want failed", pAfterFail.Outcome) + } + if pAfterFail.Error != "docker daemon unresponsive" { + t.Errorf("Error = %q, want docker daemon unresponsive", pAfterFail.Error) + } +} + +func TestBuildHookPayload_NilConfigSafe(t *testing.T) { + p := buildHookPayload(nil, build.Options{}, "before", false, nil, "", nil) + if p.Event != "" { + t.Errorf("Event = %q, want empty", p.Event) + } + if len(p.Images) != 0 { + t.Errorf("Images = %+v, want empty", p.Images) + } +} From c4b0d29d1a1284a1b738172d69ea2115c7eb64ed Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Mon, 12 Oct 2026 13:43:49 +0100 Subject: [PATCH 3/3] docs(plugin): document build hooks and update sample logger plugin - Mark before_build and after_build hooks as shipped in docs/spec/plugins.md. - Document Build payload schema with JSON example. - Update examples/sample-event/plugins/logger.sh to subscribe and log build hooks. - Update docs/BACKLOG.md tracking. --- docs/BACKLOG.md | 2 +- docs/spec/plugins.md | 35 ++++++++++++++++++++++++- examples/sample-event/plugins/logger.sh | 4 +-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b47effb..897f5fe 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -5,7 +5,7 @@ Open work organized by size. Each line is intentionally GitHub-issue-shaped: tit ## Big rocks (multi-day each) - **K8s deploy mode** — `mctf deploy --mode kubernetes`. Generate K8s Deployment / Service / Ingress manifests from per-challenge compose, `kubectl apply -f`. Honor `cfg.Deploy.Kubernetes.{Kubeconfig, Namespace, IngressClass}`. -- **Plugin system** — subprocess + JSON-RPC over stdio (Terraform-style), language-agnostic, crash-isolated. **Foundation + `before_deploy`/`after_deploy` shipped** (`internal/plugin`, `docs/spec/plugins.md`). *Remaining:* `before_build`/`after_build`, `on_instance_spawn` (two sites — `cmd/instance.go` + `internal/instancer/server.go`), `on_health_failure` (needs a health-monitoring loop — largest), dispatch on `down`/kubernetes paths, and a blocking/`required` mode (a plugin that can veto). +- **Plugin system** — subprocess + JSON-RPC over stdio (Terraform-style), language-agnostic, crash-isolated. **Foundation + `before_deploy`/`after_deploy` + `before_build`/`after_build` shipped** (`internal/plugin`, `docs/spec/plugins.md`). *Remaining:* `on_instance_spawn` (two sites — `cmd/instance.go` + `internal/instancer/server.go`), `on_health_failure` (needs a health-monitoring loop — largest), dispatch on `down`/kubernetes paths, and a blocking/`required` mode (a plugin that can veto). - ~~**Prometheus / Grafana wiring** — `mctf monitoring up` brings up prom + grafana + cAdvisor with prebuilt dashboards. Generate scrape config from challenge labels.~~ **Shipped:** `mctf monitoring up` / `down` render and run the stack under `monitoring/stack/` (see `docs/spec/monitoring.md`). - **Terraform wrapper** — `mctf infra plan|apply|destroy` for VPS, clusters, networking, DNS. Templates for DigitalOcean, Hetzner, DOKS, EKS, GKE. diff --git a/docs/spec/plugins.md b/docs/spec/plugins.md index 75d316a..a507e06 100644 --- a/docs/spec/plugins.md +++ b/docs/spec/plugins.md @@ -77,7 +77,8 @@ killed. (This mirrors the `notify` package.) | --- | --- | --- | | `before_deploy` | deploy payload (event, mode, units, dry_run) | **shipped** | | `after_deploy` | deploy payload + `outcome` (`success`/`failed`) + `error` | **shipped** | -| `before_build` / `after_build` | build payload | planned | +| `before_build` | build payload (event, sha, dry_run, images) | **shipped** | +| `after_build` | build payload + `outcome` (`success`/`failed`) + `error` | **shipped** | | `on_instance_spawn` | instance payload | planned | | `on_health_failure` | — | planned (needs a health-monitoring loop) | @@ -101,6 +102,38 @@ killed. (This mirrors the `notify` package.) `after_deploy(success)` fires only on a real (non-dry-run) deploy; `after_deploy(failed)` fires whenever the deploy errors. +### Build payload + +```json +{ + "event": "myctf", + "phase": "before", // "before" | "after" + "sha": "a1b2c3d", + "no_cache": false, + "push": false, + "dry_run": false, + "images": [ + { + "challenge": "web/echo", + "category": "web", + "slug": "echo", + "tags": [ + "ghcr.io/example/myctf-web-echo:a1b2c3d", + "ghcr.io/example/myctf-web-echo:latest" + ], + "labels": { + "mctf.challenge": "web/echo" + } + } + ], + "outcome": "success", // after only + "error": "" // after + failed only +} +``` + +`before_build` fires before `build.Apply` executes image builds. +`after_build` fires after building, leak scanning, and optional registry pushing complete (with `outcome: "success"` or `outcome: "failed"` and `error`). + ## Example A minimal POSIX-`sh` plugin lives at diff --git a/examples/sample-event/plugins/logger.sh b/examples/sample-event/plugins/logger.sh index 82584fa..44cb075 100755 --- a/examples/sample-event/plugins/logger.sh +++ b/examples/sample-event/plugins/logger.sh @@ -29,12 +29,12 @@ while IFS= read -r line; do cfgfile=$(field file "$line") [ -n "$cfgfile" ] && logfile="$cfgfile" echo "[logger] initialized, logging to $logfile" >&2 - printf '{"jsonrpc":"2.0","id":%s,"result":{"name":"logger","hooks":["before_deploy","after_deploy"]}}\n' "$id" + printf '{"jsonrpc":"2.0","id":%s,"result":{"name":"logger","hooks":["before_deploy","after_deploy","before_build","after_build"]}}\n' "$id" ;; shutdown) exit 0 ;; - before_deploy|after_deploy) + before_deploy|after_deploy|before_build|after_build) echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $method $line" >> "$logfile" printf '{"jsonrpc":"2.0","id":%s,"result":{"ok":true}}\n' "$id" ;;