From 574dcca0a0b9a6da13147fbb70311d9c98ddc409 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 13 Oct 2026 13:34:30 +0100 Subject: [PATCH 1/3] schema(config): add event description, start_at, and end_at to schema and config model Extend event configuration in schema/mctf-config.schema.json with optional description, start_at (RFC3339), and end_at (RFC3339) properties. Synchronize embedded schemas via make schemas, add corresponding fields to config.Event, and add unit tests validating loading and schema rejection of invalid date-time strings. --- internal/config/config.go | 3 ++ internal/config/config_test.go | 50 ++++++++++++++++++++++++ internal/schemas/mctf-config.schema.json | 16 ++++++++ schema/mctf-config.schema.json | 16 ++++++++ 4 files changed, 85 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 9a797ca..c994ad4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,6 +53,9 @@ type Config struct { type Event struct { Name string `json:"name"` Slug string `json:"slug"` + Description string `json:"description,omitempty"` + StartAt string `json:"start_at,omitempty"` + EndAt string `json:"end_at,omitempty"` Domain string `json:"domain,omitempty"` FlagPrefix string `json:"flag_prefix,omitempty"` WavesNumber int `json:"waves_number,omitempty"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8f931d1..724914e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -56,6 +56,56 @@ profile: chaos } } +func TestLoadEventMetadata(t *testing.T) { + dir := t.TempDir() + cfg := []byte(` +version: 1 +event: + name: "My CTF" + slug: "my-ctf" + description: "Annual security competition" + start_at: "2026-10-15T10:00:00Z" + end_at: "2026-10-17T18:00:00Z" +`) + if err := writeFile(filepath.Join(dir, FileName), cfg); err != nil { + t.Fatal(err) + } + c, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.Event.Description != "Annual security competition" { + t.Errorf("description = %q", c.Event.Description) + } + if c.Event.StartAt != "2026-10-15T10:00:00Z" { + t.Errorf("start_at = %q", c.Event.StartAt) + } + if c.Event.EndAt != "2026-10-17T18:00:00Z" { + t.Errorf("end_at = %q", c.Event.EndAt) + } +} + +func TestLoadInvalidEventTimestamps(t *testing.T) { + dir := t.TempDir() + bad := []byte(` +version: 1 +event: + name: "Bad" + slug: "bad" + start_at: "not-a-rfc3339-timestamp" +`) + if err := writeFile(filepath.Join(dir, FileName), bad); err != nil { + t.Fatal(err) + } + _, err := Load(dir) + if err == nil { + t.Fatal("expected schema error for invalid date-time format") + } + if !contains(err.Error(), "/event/start_at") { + t.Errorf("error missing /event/start_at path: %v", err) + } +} + // trivial helpers (kept here to avoid importing extra packages just for tests) func writeFile(path string, data []byte) error { return _writeFile(path, data) diff --git a/internal/schemas/mctf-config.schema.json b/internal/schemas/mctf-config.schema.json index ef1c8d5..bf22126 100644 --- a/internal/schemas/mctf-config.schema.json +++ b/internal/schemas/mctf-config.schema.json @@ -23,6 +23,22 @@ "pattern": "^[a-z][a-z0-9-]{0,40}$", "description": "Used in image tags, network names, container labels." }, + "description": { + "type": "string", + "description": "Event description displayed on the CTF platform." + }, + "start_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "description": "Competition start timestamp (RFC3339/ISO 8601), e.g. 2026-10-15T10:00:00Z." + }, + "end_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "description": "Competition end timestamp (RFC3339/ISO 8601), e.g. 2026-10-17T18:00:00Z." + }, "domain": { "type": "string", "description": "Public DNS used for ingress: .. Required if any deployable challenge exists." diff --git a/schema/mctf-config.schema.json b/schema/mctf-config.schema.json index ef1c8d5..bf22126 100644 --- a/schema/mctf-config.schema.json +++ b/schema/mctf-config.schema.json @@ -23,6 +23,22 @@ "pattern": "^[a-z][a-z0-9-]{0,40}$", "description": "Used in image tags, network names, container labels." }, + "description": { + "type": "string", + "description": "Event description displayed on the CTF platform." + }, + "start_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "description": "Competition start timestamp (RFC3339/ISO 8601), e.g. 2026-10-15T10:00:00Z." + }, + "end_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$", + "description": "Competition end timestamp (RFC3339/ISO 8601), e.g. 2026-10-17T18:00:00Z." + }, "domain": { "type": "string", "description": "Public DNS used for ingress: .. Required if any deployable challenge exists." From 729d89291a1f7735f21e2bfa53e02a5cb9c14704 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 13 Oct 2026 13:37:03 +0100 Subject: [PATCH 2/3] feat(platform): wire event metadata sync in ctfd client and platform configure command Add EventConfig DTO and Configure method to the CTFd client to patch ctf_name, ctf_description, start, and end configs sequentially. Update mctf platform configure to display all planned fields in dry-run, validate that start_at is strictly before end_at when both are provided, and dispatch Configure to sync the platform state. --- cmd/platform.go | 43 ++++++++++--- cmd/platform_configure_test.go | 74 ++++++++++++++++++++++ internal/platform/ctfd/client.go | 34 +++++++++++ internal/platform/ctfd/client_test.go | 88 +++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 cmd/platform_configure_test.go diff --git a/cmd/platform.go b/cmd/platform.go index 0fcdde9..74a963b 100644 --- a/cmd/platform.go +++ b/cmd/platform.go @@ -8,6 +8,7 @@ import ( "io" "os" "strings" + "time" "github.com/spf13/cobra" @@ -141,11 +142,11 @@ var ( var platformConfigureCmd = &cobra.Command{ Use: "configure", - Short: "Sync event-level CTFd config (ctf_name) from mctf.config.yml", - Long: "Pushes ctf_name to CTFd's /api/v1/configs from the loaded\n" + - "event config. Theme settings (theme_name, theme_color, custom\n" + - "HTML) are intentionally not synced — those are typically a one-\n" + - "time UI setup, not per-event automation.\n\n" + + Short: "Sync event-level CTFd config (ctf_name, ctf_description, start, end) from mctf.config.yml", + Long: "Pushes ctf_name, ctf_description, start, and end timestamps to CTFd's\n" + + "/api/v1/configs from the loaded event config. Theme settings\n" + + "(theme_name, theme_color, custom HTML) are intentionally not synced —\n" + + "those are typically a one-time UI setup, not per-event automation.\n\n" + "Token: export MCTF_CTFD_TOKEN= before running.\n\n" + "Examples:\n" + " mctf platform configure --dry-run\n" + @@ -169,16 +170,44 @@ var platformConfigureCmd = &cobra.Command{ return fmt.Errorf("event.name is empty in mctf.config.yml — nothing to push") } + if cfg.Event.StartAt != "" && cfg.Event.EndAt != "" { + start, err := time.Parse(time.RFC3339, cfg.Event.StartAt) + if err != nil { + return fmt.Errorf("invalid event.start_at: %w", err) + } + end, err := time.Parse(time.RFC3339, cfg.Event.EndAt) + if err != nil { + return fmt.Errorf("invalid event.end_at: %w", err) + } + if !start.Before(end) { + return fmt.Errorf("event.start_at (%s) must be before event.end_at (%s)", cfg.Event.StartAt, cfg.Event.EndAt) + } + } + fmt.Printf("planned config:\n") fmt.Printf(" ctf_name = %q\n", cfg.Event.Name) + if cfg.Event.Description != "" { + fmt.Printf(" ctf_description = %q\n", cfg.Event.Description) + } + if cfg.Event.StartAt != "" { + fmt.Printf(" start = %q\n", cfg.Event.StartAt) + } + if cfg.Event.EndAt != "" { + fmt.Printf(" end = %q\n", cfg.Event.EndAt) + } if flagConfigureDryRun { return nil } client := ctfd.New(cfg.Platform.URL, token) ctx := context.Background() - if err := client.SetConfig(ctx, "ctf_name", cfg.Event.Name); err != nil { - return fmt.Errorf("set ctf_name: %w", err) + if err := client.Configure(ctx, ctfd.EventConfig{ + Name: cfg.Event.Name, + Description: cfg.Event.Description, + StartAt: cfg.Event.StartAt, + EndAt: cfg.Event.EndAt, + }); err != nil { + return err } fmt.Println("\nconfig pushed") return nil diff --git a/cmd/platform_configure_test.go b/cmd/platform_configure_test.go new file mode 100644 index 0000000..0a0a52a --- /dev/null +++ b/cmd/platform_configure_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPlatformConfigure_StartAfterEndValidation(t *testing.T) { + dir := t.TempDir() + cfg := []byte(` +version: 1 +event: + name: "Bad Dates" + slug: "bad-dates" + start_at: "2026-10-18T10:00:00Z" + end_at: "2026-10-15T10:00:00Z" +platform: + type: ctfd + url: "https://ctfd.example.com" +`) + if err := os.WriteFile(filepath.Join(dir, "mctf.config.yml"), cfg, 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("MCTF_CTFD_TOKEN", "mock-token") + flagDir = dir + flagConfigureDryRun = true + defer func() { + flagDir = "" + flagConfigureDryRun = false + }() + + err := platformConfigureCmd.RunE(platformConfigureCmd, nil) + if err == nil { + t.Fatal("expected error when start_at is after end_at") + } + if !strings.Contains(err.Error(), "must be before") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPlatformConfigure_DryRunOutput(t *testing.T) { + dir := t.TempDir() + cfg := []byte(` +version: 1 +event: + name: "Valid CTF" + slug: "valid-ctf" + description: "Test description" + start_at: "2026-10-15T10:00:00Z" + end_at: "2026-10-17T18:00:00Z" +platform: + type: ctfd + url: "https://ctfd.example.com" +`) + if err := os.WriteFile(filepath.Join(dir, "mctf.config.yml"), cfg, 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("MCTF_CTFD_TOKEN", "mock-token") + flagDir = dir + flagConfigureDryRun = true + defer func() { + flagDir = "" + flagConfigureDryRun = false + }() + + err := platformConfigureCmd.RunE(platformConfigureCmd, nil) + if err != nil { + t.Fatalf("unexpected error in dry-run: %v", err) + } +} diff --git a/internal/platform/ctfd/client.go b/internal/platform/ctfd/client.go index f74cad8..1688ef1 100644 --- a/internal/platform/ctfd/client.go +++ b/internal/platform/ctfd/client.go @@ -325,6 +325,40 @@ func (c *Client) SetConfig(ctx context.Context, key, value string) error { return c.do(ctx, "PATCH", "/api/v1/configs/"+key, body, nil) } +// EventConfig defines event-level metadata pushed to CTFd configs. +type EventConfig struct { + Name string + Description string + StartAt string + EndAt string +} + +// Configure pushes event-level settings (ctf_name, ctf_description, start, end) +// to CTFd's /api/v1/configs. Non-empty fields are updated sequentially. +func (c *Client) Configure(ctx context.Context, cfg EventConfig) error { + if cfg.Name != "" { + if err := c.SetConfig(ctx, "ctf_name", cfg.Name); err != nil { + return fmt.Errorf("set ctf_name: %w", err) + } + } + if cfg.Description != "" { + if err := c.SetConfig(ctx, "ctf_description", cfg.Description); err != nil { + return fmt.Errorf("set ctf_description: %w", err) + } + } + if cfg.StartAt != "" { + if err := c.SetConfig(ctx, "start", cfg.StartAt); err != nil { + return fmt.Errorf("set start: %w", err) + } + } + if cfg.EndAt != "" { + if err := c.SetConfig(ctx, "end", cfg.EndAt); err != nil { + return fmt.Errorf("set end: %w", err) + } + } + return nil +} + // GetConfig fetches a single config key. func (c *Client) GetConfig(ctx context.Context, key string) (string, error) { var env envelope[ConfigEntry] diff --git a/internal/platform/ctfd/client_test.go b/internal/platform/ctfd/client_test.go index 1ed0e08..504c138 100644 --- a/internal/platform/ctfd/client_test.go +++ b/internal/platform/ctfd/client_test.go @@ -320,3 +320,91 @@ func TestUpdateChallenge_PATCH(t *testing.T) { t.Errorf("name = %q", out.Name) } } + +func TestConfigure_AllFields(t *testing.T) { + f, c, _ := newFake(t) + calls := make(map[string]string) + f.handler = func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("method = %s", r.Method) + } + var b map[string]string + _ = json.Unmarshal(f.bodies[len(f.bodies)-1], &b) + calls[r.URL.Path] = b["value"] + _, _ = w.Write([]byte(`{"success": true, "data": {}}`)) + } + + cfg := EventConfig{ + Name: "Test CTF", + Description: "A great CTF", + StartAt: "2026-10-15T10:00:00Z", + EndAt: "2026-10-17T18:00:00Z", + } + if err := c.Configure(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + want := map[string]string{ + "/api/v1/configs/ctf_name": "Test CTF", + "/api/v1/configs/ctf_description": "A great CTF", + "/api/v1/configs/start": "2026-10-15T10:00:00Z", + "/api/v1/configs/end": "2026-10-17T18:00:00Z", + } + if len(calls) != len(want) { + t.Fatalf("got %d calls, want %d", len(calls), len(want)) + } + for k, v := range want { + if calls[k] != v { + t.Errorf("calls[%q] = %q, want %q", k, calls[k], v) + } + } +} + +func TestConfigure_PartialFields(t *testing.T) { + f, c, _ := newFake(t) + var paths []string + f.handler = func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + _, _ = w.Write([]byte(`{"success": true, "data": {}}`)) + } + + // Only name and description, no timestamps + cfg := EventConfig{ + Name: "Only Name", + Description: "Only Desc", + } + if err := c.Configure(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + if len(paths) != 2 { + t.Fatalf("got %d calls, want 2: %v", len(paths), paths) + } + if paths[0] != "/api/v1/configs/ctf_name" || paths[1] != "/api/v1/configs/ctf_description" { + t.Errorf("unexpected paths: %v", paths) + } +} + +func TestConfigure_ErrorPropagation(t *testing.T) { + f, c, _ := newFake(t) + f.handler = func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/configs/start" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"success": false, "message": "database error"}`)) + return + } + _, _ = w.Write([]byte(`{"success": true, "data": {}}`)) + } + + cfg := EventConfig{ + Name: "Test CTF", + StartAt: "2026-10-15T10:00:00Z", + } + err := c.Configure(context.Background(), cfg) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "set start:") { + t.Errorf("unexpected error message: %v", err) + } +} From e432c70a1a1c4b0bad446433010919bf437133cf Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 13 Oct 2026 13:38:48 +0100 Subject: [PATCH 3/3] docs: update documentation, sample event config, and changelog for platform configure Document ctf_description, start, and end synchronisation in docs/commands.md. Showcase description, start_at, and end_at in examples/sample-event/mctf.config.yml. Mark platform configure extensions as shipped in docs/BACKLOG.md and document the feature in docs/spec/CHANGELOG.md. --- docs/BACKLOG.md | 2 +- docs/commands.md | 2 +- docs/spec/CHANGELOG.md | 2 ++ examples/sample-event/mctf.config.yml | 3 +++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b47effb..89de011 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -14,7 +14,7 @@ Open work organized by size. Each line is intentionally GitHub-issue-shaped: tit - **rCTF + generic-webhook platforms** — second + third platform targets behind the existing platform interface, ideally via plugin once the system lands. - **Per-team auth on the instancer** — currently bearer tokens have full power; add team-scoped tokens (`POST /spawn` only allows the request when the token's team_id matches the body). - **Native instancer: docker-network isolation** — currently spawns share the event network; isolate each per-team instance to its own bridge. -- **`mctf platform configure` extensions** — sync `start` / `end` timestamps (needs schema addition `event.start_at` / `event.end_at`), `ctf_description`. +- ~~**`mctf platform configure` extensions** — sync `start` / `end` timestamps (needs schema addition `event.start_at` / `event.end_at`), `ctf_description`.~~ **Shipped** (schema v1 event metadata + CTFd sync; see `docs/spec/CHANGELOG.md`). - **`mctf release`** — tag git, build via goreleaser, draft a release notes file from conventional-commits since last tag. ## Small wins (~half-day each) diff --git a/docs/commands.md b/docs/commands.md index 2567fc1..9e9e7b4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -51,7 +51,7 @@ deterministic deploys and `deploy --rollback`. | --- | --- | | `mctf platform sync` | Sync challenges to the configured platform (re-uploads assets on content change) | | `mctf platform unsync` | Remove a challenge from the platform | -| `mctf platform configure` | Sync event-level config (e.g. `ctf_name`) | +| `mctf platform configure` | Sync event-level config (`ctf_name`, `ctf_description`, `start`, `end`) | ## Inspect diff --git a/docs/spec/CHANGELOG.md b/docs/spec/CHANGELOG.md index 28e88b5..70cf217 100644 --- a/docs/spec/CHANGELOG.md +++ b/docs/spec/CHANGELOG.md @@ -26,6 +26,8 @@ All features below are merged into `develop` and pass CI. Not yet released to `m - **`feat(platform)`: CTFd asset content-hash re-upload** — `mctf platform sync` now re-uploads CTFd assets when their content hash changes, not just when the filename changes. Prevents stale handout files on CTFd. +- **`feat(platform)`: `mctf platform configure` event metadata** — `mctf.config.yml` gained `event.description`, `event.start_at`, and `event.end_at` (RFC3339). `mctf platform configure` pushes them to CTFd as `ctf_description`, `start`, and `end` configurations alongside `ctf_name`, with date-time validation and dry-run preview. + - **`feat(deploy)`: immutable image-tag pinning** — `mctf deploy` now writes the resolved image tag **literally** into `docker-compose.mctf.yml` (e.g. `image: registry/evt-web-chall:a1b2c3d`) instead of a `${IMAGE_TAG}` variable. The generated compose is a self-contained, reproducible record of exactly what is deployed — no ambient env var required — which is what makes deterministic deploys and `--rollback` work. The tag is no longer threaded through the environment (`envForUnit` dropped `IMAGE_TAG`). Empty tag falls back to `:latest`. Closes #5. - **`feat(deploy)`: GitOps state sync + rollback** — `mctf deploy` gained a rollback system. On failure, the previous compose state is restored from git history. Registry push is now gated on `registry.url` presence to avoid crashes in registry-less setups. diff --git a/examples/sample-event/mctf.config.yml b/examples/sample-event/mctf.config.yml index e3dfcf9..5b8328f 100644 --- a/examples/sample-event/mctf.config.yml +++ b/examples/sample-event/mctf.config.yml @@ -5,6 +5,9 @@ version: 1 event: name: "Sample MCTF Event" slug: "sample-event" + description: "A sample capture-the-flag event illustrating schema v1." + start_at: "2026-10-15T10:00:00Z" + end_at: "2026-10-17T18:00:00Z" domain: "ctf.example.com" flag_prefix: "mctf" waves_number: 3