Skip to content
Merged
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
41 changes: 41 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -86,30 +87,70 @@ 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
}
}

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 {
Expand Down
97 changes: 97 additions & 0 deletions cmd/build_plugin_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 34 additions & 1 deletion docs/spec/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions examples/sample-event/plugins/logger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
;;
Expand Down
47 changes: 45 additions & 2 deletions internal/plugin/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 25 additions & 3 deletions internal/plugin/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"`
}
Loading