Skip to content
Open
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 {
return fmt.Errorf("build completed, but plugin hook failed: %w", err)
}
fmt.Println("\nbuild complete")
return nil
},
Expand Down
116 changes: 100 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,7 @@ var deployCmd = &cobra.Command{

fmt.Println("\ndeploy complete")
if !flagDeployDryRun {
pm.Dispatch(ctx, plugin.HookAfterDeploy, deployHookPayload(cfg, mode, "after", false, plan, "success", nil))
_ = pm.Dispatch(ctx, plugin.HookAfterDeploy, deployHookPayload(cfg, mode, "after", false, plan, "success", nil))
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 +264,32 @@ 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{
Challenge: u.Challenge,
})
}
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 +397,29 @@ 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 {
_ = 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))
return err
}

if !flagDownDryRun {
_ = pm.Dispatch(ctx, plugin.HookAfterDown,
deployHookPayload(cfg, mode, "after", false, plan, "success", nil))
}
fmt.Println("\ndown complete")
if !flagDownDryRun {
notifyDiscord(ctx, "teardown complete", "info",
Expand Down Expand Up @@ -470,6 +517,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 +549,44 @@ 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 {
_ = 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 {
return applyErr
}

if !dry {
_ = pm.Dispatch(ctx, afterHook,
kubeDeployHookPayload(cfg, "after", false, plan, "success", nil))

if verb == "up" {
fmt.Println("\ndeploy complete")
notifyDiscord(ctx, "deploy succeeded", "success",
fmt.Sprintf("%d challenge(s) up [kubernetes]", len(plan)))
}
} else {
if err := kubernetes.ApplyDown(ctx, runner, plan); err != nil {
notifyDiscord(ctx, "teardown failed", "error", err.Error())
return err
}
fmt.Println("\ndown complete")
if !dry {
} 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
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.
- 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
2 changes: 1 addition & 1 deletion internal/instancer/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func (s *Server) spawn(w http.ResponseWriter, r *http.Request) {
if s.cfg != nil {
eventSlug = s.cfg.Event.Slug
}
s.pm.Dispatch(r.Context(), plugin.HookOnInstanceSpawn, plugin.InstanceSpawnPayload{
_ = s.pm.Dispatch(r.Context(), plugin.HookOnInstanceSpawn, plugin.InstanceSpawnPayload{
Event: eventSlug,
InstanceID: inst.ID,
ChallengeSlug: inst.ChallengeSlug,
Expand Down
11 changes: 6 additions & 5 deletions internal/plugin/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (

// Config is one plugin entry from mctf.config.yml `plugins:`.
type Config struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil => enabled
Config map[string]any `json:"config,omitempty"` // opaque, passed to the plugin
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil => enabled
Required bool `json:"required,omitempty"`
Config map[string]any `json:"config,omitempty"` // opaque, passed to the plugin
}

func (c Config) isEnabled() bool { return c.Enabled == nil || *c.Enabled }
Expand Down
Loading
Loading