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
14 changes: 9 additions & 5 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,31 +91,35 @@ var buildCmd = &cobra.Command{
// 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))
if err := pm.Dispatch(ctx, plugin.HookBeforeBuild, buildHookPayload(cfg, opts, "before", flagBuildDryRun, plan, "", nil)); err != nil {
return fmt.Errorf("build blocked by plugin: %w", err)
}

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))
_ = 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))
_ = 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))
_ = 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))
if err := pm.Dispatch(ctx, plugin.HookAfterBuild, buildHookPayload(cfg, opts, "after", flagBuildDryRun, plan, "success", nil)); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an inconsistency in how after_* hook failures are handled across commands:

  • In cmd/build.go, an error returned by a required plugin during after_build causes the command to exit with an error.
  • In cmd/deploy.go (lines 233, 423, 572), errors from after_deploy and after_down are discarded with _ =.

We should align the behavior across all commands:

  • If after_* hooks are non-blocking as stated in docs/spec/plugins.md ("Failures in after_* hooks do not undo an operation that has already completed"), change this to _ = pm.Dispatch(...).
  • If failing after_* hooks should report an error to CI, check and return the error in cmd/deploy.go as well.

return fmt.Errorf("build completed, but plugin hook failed: %w", err)
}
fmt.Println("\nbuild complete")
return nil
},
Expand Down
132 changes: 116 additions & 16 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,13 @@ var deployCmd = &cobra.Command{
// 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.HookBeforeDeploy, deployHookPayload(cfg, mode, "before", flagDeployDryRun, plan, "", nil))
if err := pm.Dispatch(ctx, plugin.HookBeforeDeploy,
deployHookPayload(cfg, mode, "before", flagDeployDryRun, plan, "", nil)); err != nil {
return fmt.Errorf("deployment blocked by plugin: %w", err)
}

if err := deploy.ApplyUp(ctx, runner, plan); err != nil {
pm.Dispatch(ctx, plugin.HookAfterDeploy, deployHookPayload(cfg, mode, "after", flagDeployDryRun, plan, "failed", err))
_ = pm.Dispatch(ctx, plugin.HookAfterDeploy, deployHookPayload(cfg, mode, "after", flagDeployDryRun, plan, "failed", err))
notifyDiscord(ctx, "deploy failed", "error", fmt.Sprintf("%s: %v", planSlugs(plan), err))
return err
}
Expand Down Expand Up @@ -230,7 +233,9 @@ var deployCmd = &cobra.Command{

fmt.Println("\ndeploy complete")
if !flagDeployDryRun {
pm.Dispatch(ctx, plugin.HookAfterDeploy, deployHookPayload(cfg, mode, "after", false, plan, "success", nil))
if err := pm.Dispatch(ctx, plugin.HookAfterDeploy, deployHookPayload(cfg, mode, "after", false, plan, "success", nil)); err != nil {
return fmt.Errorf("after_deploy hook failed: %w", err)
}
notifyDiscord(ctx, "deploy succeeded", "success", fmt.Sprintf("%d challenge(s) up:\n%s", len(plan), planSlugs(plan)))
}
return nil
Expand Down Expand Up @@ -261,6 +266,36 @@ func deployHookPayload(cfg *config.Config, mode, phase string, dryRun bool, plan
return p
}

func kubeDeployHookPayload(
cfg *config.Config,
phase string,
dryRun bool,
plan []kubernetes.KubeUnit,
outcome string,
err error,
) plugin.DeployPayload {
p := plugin.DeployPayload{
Event: cfg.Event.Slug,
Phase: phase,
Mode: "kubernetes",
DryRun: dryRun,
Outcome: outcome,
}
if err != nil {
p.Error = err.Error()
}
for _, u := range plan {
p.Units = append(p.Units, plugin.DeployUnit{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In kubeDeployHookPayload, only Challenge is set. The fields Category, Slug, ProjectName, and ImageTag are left empty, unlike in deployHookPayload.

Please map the remaining fields from u so Kubernetes hook payloads remain consistent with Docker and SSH modes:

p.Units = append(p.Units, plugin.DeployUnit{
	Challenge:   u.Challenge,
	Category:    u.Category,
	Slug:        u.Slug,
	ProjectName: u.Name,
	ImageTag:    u.Image,
})

Challenge: u.Challenge,
Category: u.Category,
Slug: u.Slug,
ProjectName: u.Name,
ImageTag: u.Image,
})
}
return p
}

func loadPlanAndRunner(dir, sha string, dryRun bool) ([]deploy.Unit, deploy.Runner, *config.Config, error) {
cfg, err := config.Load(dir)
if err != nil {
Expand Down Expand Up @@ -368,11 +403,34 @@ var downCmd = &cobra.Command{
}
}
}
pm := plugin.Load(ctx, cfg.Plugins, plugin.Options{
HostVersion: version,
Dir: flagDir,
})
defer pm.Close()

if err := pm.Dispatch(ctx, plugin.HookBeforeDown,
deployHookPayload(cfg, mode, "before", flagDownDryRun, plan, "", nil)); err != nil {
return fmt.Errorf("teardown blocked by plugin: %w", err)
}

if err := deploy.ApplyDown(ctx, runner, plan, flagDownVolumes); err != nil {
hookErr := pm.Dispatch(ctx, plugin.HookAfterDown,
deployHookPayload(cfg, mode, "after", flagDownDryRun, plan, "failed", err))
notifyDiscord(ctx, "teardown failed", "error",
fmt.Sprintf("%s: %v", planSlugs(plan), err))
if hookErr != nil {
return fmt.Errorf("after_down hook failed: %w (teardown also failed: %v)", hookErr, err)
}
return err
}

if !flagDownDryRun {
if err := pm.Dispatch(ctx, plugin.HookAfterDown,
deployHookPayload(cfg, mode, "after", false, plan, "success", nil)); err != nil {
return fmt.Errorf("after_down hook failed: %w", err)
}
}
fmt.Println("\ndown complete")
if !flagDownDryRun {
notifyDiscord(ctx, "teardown complete", "info",
Expand Down Expand Up @@ -470,6 +528,26 @@ func applyKube(ctx context.Context, cfg *config.Config, challs []*challenge.Chal
}

printKubePlan(verb, plan)
pm := plugin.Load(ctx, cfg.Plugins, plugin.Options{
HostVersion: version,
Dir: flagDir,
})
defer pm.Close()

beforeHook := plugin.HookBeforeDeploy
afterHook := plugin.HookAfterDeploy
operation := "deployment"

if verb == "down" {
beforeHook = plugin.HookBeforeDown
afterHook = plugin.HookAfterDown
operation = "teardown"
}

if err := pm.Dispatch(ctx, beforeHook,
kubeDeployHookPayload(cfg, "before", dry, plan, "", nil)); err != nil {
return fmt.Errorf("%s blocked by plugin: %w", operation, err)
}

kubeconfig := ""
if cfg.Deploy.Kubernetes != nil {
Expand All @@ -482,27 +560,49 @@ func applyKube(ctx context.Context, cfg *config.Config, challs []*challenge.Chal
runner = kubernetes.NewKubeRunner(kubeconfig)
}

var applyErr error
if verb == "up" {
if err := kubernetes.ApplyUp(ctx, runner, plan); err != nil {
notifyDiscord(ctx, "deploy failed", "error", err.Error())
return err
applyErr = kubernetes.ApplyUp(ctx, runner, plan)
} else {
applyErr = kubernetes.ApplyDown(ctx, runner, plan)
}

if applyErr != nil {
hookErr := pm.Dispatch(ctx, afterHook,
kubeDeployHookPayload(cfg, "after", dry, plan, "failed", applyErr))

if verb == "up" {
notifyDiscord(ctx, "deploy failed", "error", applyErr.Error())
} else {
notifyDiscord(ctx, "teardown failed", "error", applyErr.Error())
}
fmt.Println("\ndeploy complete")
if !dry {
notifyDiscord(ctx, "deploy succeeded", "success",
fmt.Sprintf("%d challenge(s) up [kubernetes]", len(plan)))
if hookErr != nil {
return fmt.Errorf("after_%s hook failed: %w (operation also failed: %v)", verb, hookErr, applyErr)
}
} else {
if err := kubernetes.ApplyDown(ctx, runner, plan); err != nil {
notifyDiscord(ctx, "teardown failed", "error", err.Error())
return err
return applyErr
}

if !dry {
if err := pm.Dispatch(ctx, afterHook,
kubeDeployHookPayload(cfg, "after", false, plan, "success", nil)); err != nil {
return fmt.Errorf("after_%s hook failed: %w", verb, err)
}
fmt.Println("\ndown complete")
if !dry {

if verb == "up" {
fmt.Println("\ndeploy complete")
notifyDiscord(ctx, "deploy succeeded", "success",
fmt.Sprintf("%d challenge(s) up [kubernetes]", len(plan)))
} else {
fmt.Println("\ndown complete")
notifyDiscord(ctx, "teardown complete", "info",
fmt.Sprintf("%d challenge(s) down [kubernetes]", len(plan)))
}
} else if verb == "up" {
fmt.Println("\ndeploy complete")
} else {
fmt.Println("\ndown complete")
}

return nil
}

Expand Down
57 changes: 57 additions & 0 deletions cmd/deploy_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"

"github.com/MicroClub-USTHB/mctf-cli/internal/config"
"github.com/MicroClub-USTHB/mctf-cli/internal/kubernetes"
)

func cfgWithMode(mode string) *config.Config {
Expand Down Expand Up @@ -100,3 +101,59 @@ func TestApplyKube_DoesNotMutateConfig(t *testing.T) {
t.Errorf("config mutated: Deploy = %+v, want nil", nilDeploy.Deploy)
}
}

func TestKubeDeployHookPayload_MapsAllUnitFields(t *testing.T) {
cfg := &config.Config{
Event: config.Event{Slug: "test-event"},
}

plan := []kubernetes.KubeUnit{
{
Challenge: "web/my-challenge",
Category: "web",
Slug: "my-challenge",
Name: "mctf-test-web-my-challenge",
Image: "registry.example.com/my-challenge:v1",
},
}

payload := kubeDeployHookPayload(
cfg,
"after",
false,
plan,
"success",
nil,
)

if payload.Event != "test-event" {
t.Errorf("Event = %q, want %q", payload.Event, "test-event")
}
if payload.Phase != "after" {
t.Errorf("Phase = %q, want %q", payload.Phase, "after")
}
if payload.Mode != "kubernetes" {
t.Errorf("Mode = %q, want %q", payload.Mode, "kubernetes")
}
if len(payload.Units) != 1 {
t.Fatalf("got %d units, want 1", len(payload.Units))
}

unit := payload.Units[0]

if unit.Challenge != "web/my-challenge" {
t.Errorf("Challenge = %q, want %q", unit.Challenge, "web/my-challenge")
}
if unit.Category != "web" {
t.Errorf("Category = %q, want %q", unit.Category, "web")
}
if unit.Slug != "my-challenge" {
t.Errorf("Slug = %q, want %q", unit.Slug, "my-challenge")
}
if unit.ProjectName != "mctf-test-web-my-challenge" {
t.Errorf("ProjectName = %q, want %q", unit.ProjectName, "mctf-test-web-my-challenge")
}
if unit.ImageTag != "registry.example.com/my-challenge:v1" {
t.Errorf("ImageTag = %q, want %q", unit.ImageTag, "registry.example.com/my-challenge:v1")
}
}
2 changes: 1 addition & 1 deletion cmd/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ var instanceSpawnCmd = &cobra.Command{
// 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.HookOnInstanceSpawn, instanceSpawnPayload(cfg, inst))
_ = pm.Dispatch(ctx, plugin.HookOnInstanceSpawn, instanceSpawnPayload(cfg, inst))

fmt.Printf("\nspawn complete (expires %s)\n", inst.ExpiresAt.Format(time.RFC3339))
return nil
Expand Down
47 changes: 31 additions & 16 deletions docs/spec/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ can run arbitrary logic (notifications, policy checks, custom provisioning, …)
without being compiled into the CLI. They are language-agnostic: any executable
that speaks the protocol below works.

> **Status:** foundation + the `before_deploy` / `after_deploy` hooks ship now.
> The remaining hooks are planned — see [Hook roster](#hook-roster).
> **Status:** The plugin foundation, deploy hooks (before_deploy / after_deploy), teardown hooks (before_down / after_down), build hooks, and instance-spawn hook are implemented. Required plugins can block commands when a required plugin fails during startup or a blocking before_* hook.

## Trust model

Expand All @@ -24,6 +23,7 @@ plugins:
command: ./plugins/logger.sh # executable; relative paths resolve to the event root
args: ["--verbose"] # optional
enabled: true # optional (default true)
required: false # optional; defaults to false
config: # optional, opaque — passed to the plugin at initialize
file: ./audit.log
```
Expand Down Expand Up @@ -64,31 +64,47 @@ name and whose `params` is the hook payload; the plugin replies with any result

The host sends `{"method":"shutdown"}` and closes stdin; the plugin exits.

## Semantics: best-effort & crash-isolated
## Semantics: best-effort and required plugins

Plugins are best-effort by default. A plugin with `required: false` (the default)
that fails to start, fails its handshake, returns an error, crashes, or times out
is logged and dropped; the command continues.

Setting `required: true` opts a plugin into blocking behavior:

- If a required plugin fails to start or complete its initialization handshake,
the command is blocked when the plugin manager reports that failure.
- If a required plugin returns an error or fails during a blocking `before_*`
hook, the command is aborted before the corresponding operation runs.
- Failures in after_* hooks do not undo an operation that has already completed. However, failures from required plugins are returned to the CLI, allowing CI pipelines to detect them.
- Non-required plugins remain best-effort, and their failures do not block the
command.

Each hook call is bounded by a timeout. A hung plugin is terminated. Only
opt-in required-plugin failures can block commands; ordinary plugin failures
remain isolated.

Plugins never break an MCTF command. A plugin that fails to start, botches the
handshake, errors, crashes, or hangs is **logged to stderr and dropped** — the
command continues. Each hook call is bounded by a timeout; a hung plugin is
killed. (This mirrors the `notify` package.)

## Hook roster

| Hook | Payload | Status |
| --- | --- | --- |
| `before_deploy` | deploy payload (event, mode, units, dry_run) | **shipped** |
| `after_deploy` | deploy payload + `outcome` (`success`/`failed`) + `error` | **shipped** |
| `before_build` | build payload (event, sha, dry_run, images) | **shipped** |
| `after_build` | build payload + `outcome` (`success`/`failed`) + `error` | **shipped** |
| `on_instance_spawn` | instance spawn payload (event, instance_id, challenge_slug, team_id, ports, flag, containers, expires_at) | **shipped** |
| `on_health_failure` | — | planned (needs a health-monitoring loop) |
| `before_deploy` | Deploy payload (event, mode, units, dry_run) | **Shipped** |
| `after_deploy` | Deploy payload plus `outcome` (`success` / `failed`) and `error` | **Shipped** |
| `before_down` | Teardown payload (event, mode, units, dry_run) | **Shipped** |
| `after_down` | Teardown payload plus `outcome` (`success` / `failed`) and `error` | **Shipped** |
| `before_build` | Build payload (event, sha, dry_run, images) | **Shipped** |
| `after_build` | Build payload plus `outcome` (`success` / `failed`) and `error` | **Shipped** |
| `on_instance_spawn` | Instance spawn payload (event, instance_id, challenge_slug, team_id, ports, flag, containers, expires_at) | **Shipped** |
| `on_health_failure` | — | Planned (needs a health-monitoring loop) |

### Deploy payload

```json
{
"event": "myctf",
"phase": "before", // "before" | "after"
"mode": "docker", // "docker" | "ssh"
"mode": "docker", // "docker" | "ssh" | "kubernetes"
"dry_run": false,
"units": [
{"challenge":"web/echo","category":"web","slug":"echo",
Expand All @@ -99,8 +115,7 @@ 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.
`after_deploy(success)` fires only after a real, non-dry-run deploy. `after_deploy(failed)` fires when the deploy operation fails. Teardown uses the same payload structure with `before_down` and `after_down`. The `mode` field identifies the execution mode: `docker`, `ssh`, or `kubernetes`. Successful `after_*` hooks fire only after a real, non-dry-run operation; failure hooks fire when the operation fails.

### Build payload

Expand Down
Loading
Loading