diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9da8e..38c16ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## vX.X.X (YYYY-MM-DD) +### Added +- Auto-detect a standalone Docker Compose file (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`) next to `challenge.yml` for multi-service challenges, so authors no longer have to embed the whole document as an inline `compose_definition` string +- Optional `deploy_parameters.compose_file` to point at a differently named compose file (relative to the challenge directory) +- Default `playbook_name` to `custom_compose` when a compose file is detected and no playbook is set explicitly +- `deploy_parameters.expose` block for compose challenges: automatically wire Traefik (http: external network + router/service labels + domain) and published ports (tcp: reuses per-team host-port randomization/persistence), removing the need to hand-write Traefik labels, attach networks, or pick host ports. Multiple HTTP exposures get per-service subdomains, and connection info is derived automatically +- Validate `expose` entries at challenge index time (structure + that referenced services exist in the compose definition) +- Document `traefik_network` in `config.example.yaml` (required for http challenges and http exposures) + +### Changed +- `example/custom_compose` now demonstrates the standalone `docker-compose.yml` workflow with an `expose` block; inline `compose_definition` still works and takes precedence over a sibling file + ## v0.7.0 (2026-03-18) ### Added diff --git a/README.md b/README.md index 5f32c8d..3fb76bd 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,11 @@ All endpoints except `/health` require a JWT bearer token. Tokens are generated ## Challenge Definitions -Example challenge file: +Example challenge files (one per playbook type): -- `data/challenges/example/challenge.yml` +- `data/challenges/example/http/challenge.yml` +- `data/challenges/example/tcp/challenge.yml` +- `data/challenges/example/custom_compose/challenge.yml` (with a standalone `docker-compose.yml`) Key fields: @@ -101,6 +103,83 @@ Key fields: - `deploy_parameters`: image, ports, env, or Compose definition - `flags`, `value`, `description`, `tags` +### Multi-service / Docker Compose challenges + +For challenges that need more than one container (databases, sidecars, custom +networks/volumes), drop a standard Docker Compose file next to your +`challenge.yml`: + +``` +data/challenges/web/my-chall/ +├── challenge.yml +└── docker-compose.yml +``` + +Galvanize auto-detects the first of `compose.yaml`, `compose.yml`, +`docker-compose.yaml`, or `docker-compose.yml` in the challenge directory, +validates it, and deploys it. When a compose file is present, `playbook_name` +defaults to `custom_compose`, so a minimal `challenge.yml` is enough: + +```yaml +name: my-chall +author: YourName +category: web +type: zync + +deploy_parameters: + unique: false +``` + +This lets you develop and test the stack locally with a plain +`docker compose up`, then ship the file unchanged. + +#### Exposing services (`expose`) + +Compose challenges no longer need hand-written Traefik labels, manual network +wiring, or hand-picked host ports. Declare an `expose` block and Galvanize wires +the networking for you — the same automation the `http`/`tcp` playbooks provide +for single containers: + +```yaml +deploy_parameters: + unique: false + expose: + - service: web # HTTP via Traefik → auto domain + SSL + port: 80 + type: http + - service: ssh # raw TCP → published host port + port: 22 + type: tcp + scheme: ssh # optional, only affects the rendered connection URL +``` + +For each entry Galvanize will, on deploy: + +- `type: http` — attach the service to the external Traefik network, add the + `traefik.enable` + router/service labels, and route + `https://./` to it (when more than one HTTP service + is exposed, each gets a `-.` subdomain). + Requires `traefik_network` in `instancer.extra_deployment_parameters`. +- `type: tcp` — publish the container port. With + `instancer.randomize_published_ports` enabled, the host port is randomized per + team and persisted, so instances don't collide. + +Connection info is returned automatically from the resulting Traefik route or +published port. + +Notes: + +- Use `deploy_parameters.compose_file: ` to point at a differently named + file (relative to the challenge directory). +- An inline `deploy_parameters.compose_definition` string still works and takes + precedence over any compose file, so existing challenges are unaffected. +- `expose` is optional: if you prefer, you can still hand-write Traefik labels + and `ports:` in the compose file and omit `expose` entirely. +- The project name is generated automatically per team; do not set + `container_name` in services or instances will collide. +- Image `build:` contexts and host bind mounts reference paths on the **target + deploy host**, not the instancer — prefer pre-built images. + ## Troubleshooting - If deployments fail, verify SSH access to the target host and that Docker is installed. diff --git a/config.example.yaml b/config.example.yaml index 30dc010..325d57e 100755 --- a/config.example.yaml +++ b/config.example.yaml @@ -31,4 +31,7 @@ instancer: memory: "512M" # Memory limit per container (e.g. "256M", "512M", "1G") pids_limit: 256 # Max number of PIDs per container (fork bomb protection) extra_deployment_parameters: # Additional parameters for deployment (added to vars in Ansible) + # Name of the external Docker network Traefik watches on the target host(s). + # Required for http challenges and for http exposures in compose challenges. + traefik_network: "traefik" example_param: "example_value" \ No newline at end of file diff --git a/data/challenges/example/custom_compose/challenge.yml b/data/challenges/example/custom_compose/challenge.yml index 614dd20..a60a311 100644 --- a/data/challenges/example/custom_compose/challenge.yml +++ b/data/challenges/example/custom_compose/challenge.yml @@ -4,30 +4,50 @@ category: web type: zync -# The 'custom_compose' playbook deploys an arbitrary Docker Compose definition. -# Use this when a challenge needs multiple services, custom networks, or volumes. -# Resource limits are applied to every service in the definition. -playbook_name: custom_compose +# Multi-service challenges just need a standard Docker Compose file next to +# this challenge.yml. Galvanize auto-detects compose.yaml, compose.yml, +# docker-compose.yaml, or docker-compose.yml and deploys it for you. +# +# Because a compose file is present, playbook_name defaults to +# "custom_compose" and can be omitted. You can still set it explicitly: +# playbook_name: custom_compose +# +# This means you can develop and test the stack locally with a plain +# `docker compose up`, then drop the file in here unchanged. deploy_parameters: unique: false - # Inline Docker Compose definition. - # The project name is generated automatically. - compose_definition: |- - services: - web: - image: nginx:alpine - ports: - - "80:80" - environment: - FLAG: "flag{example_custom}" - depends_on: - - db - db: - image: postgres:16-alpine - environment: - POSTGRES_PASSWORD: secret + # 'expose' lets Galvanize do the networking wiring for you, just like the + # http/tcp playbooks do for single-container challenges. Each entry names a + # service from the compose file, its container port, and how to reach it: + # + # type: http -> routed through Traefik (auto domain + SSL). Requires + # 'traefik_network' in extra_deployment_parameters. + # type: tcp -> published as a host port. With + # instancer.randomize_published_ports enabled, the host port + # is randomized per team to avoid collisions. + # + # You no longer need to hand-write Traefik labels, attach the external + # network, or pick host ports yourself. + expose: + - service: web + port: 80 + type: http + # - service: ssh + # port: 22 + # type: tcp + # scheme: ssh # optional, used only to render nicer connection URLs + + # By default the sibling compose file is used automatically. To point at a + # differently named file (relative to this directory), set: + # compose_file: stack.yaml + # + # Alternatively, you can still inline the whole definition as a string: + # compose_definition: |- + # services: + # web: + # image: nginx:alpine # Override global default_resource_limits for this challenge. # Applied to every service in the compose definition. @@ -39,8 +59,9 @@ deploy_parameters: value: 7 description: |- - Example custom compose challenge. Multiple services are deployed using - an inline Docker Compose definition. + Example custom compose challenge. Multiple services are deployed from a + standalone docker-compose.yml file, and the web service is exposed over HTTP + via Traefik using the 'expose' block. flags: - flag{example_custom} diff --git a/data/challenges/example/custom_compose/docker-compose.yml b/data/challenges/example/custom_compose/docker-compose.yml new file mode 100644 index 0000000..b9ab12f --- /dev/null +++ b/data/challenges/example/custom_compose/docker-compose.yml @@ -0,0 +1,11 @@ +services: + web: + image: nginx:alpine + environment: + FLAG: "flag{example_custom}" + depends_on: + - db + db: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: secret diff --git a/galvanize-instancer/internal/ansible/compose_expose.go b/galvanize-instancer/internal/ansible/compose_expose.go new file mode 100644 index 0000000..527c904 --- /dev/null +++ b/galvanize-instancer/internal/ansible/compose_expose.go @@ -0,0 +1,218 @@ +package ansible + +import ( + "fmt" + "strconv" + + "github.com/28Pollux28/galvanize/internal/challenge" + "github.com/28Pollux28/galvanize/internal/docker" + "github.com/28Pollux28/galvanize/pkg/config" + yaml "github.com/oasdiff/yaml3" +) + +// traefikNetworkParam is the extra_deployment_parameters key that names the +// external Docker network Traefik watches. It is the same value the http/tcp +// playbooks consume. +const traefikNetworkParam = "traefik_network" + +// exposeTCPContainerPorts returns the container ports (as strings) of every tcp +// exposure, so the deployer can reserve persistent random host ports for them +// using the same machinery as the tcp playbook's published_ports. +func exposeTCPContainerPorts(params map[string]interface{}) []string { + exposures, err := challenge.ParseExposures(params) + if err != nil || len(exposures) == 0 { + return nil + } + var ports []string + for _, exp := range exposures { + if exp.Type == challenge.ExposeTCP { + ports = append(ports, strconv.Itoa(exp.Port)) + } + } + return ports +} + +// applyComposeExposures wires up networking for compose challenges that declare +// a deploy_parameters.expose block, so they get the same automatic Traefik +// (http) and published-port (tcp) handling the single-container playbooks +// provide. +// +// It mutates params["compose_definition"] in place and returns protocol hints +// (keyed by container/target port) used when rendering connection info. +// +// portBindings maps a container port (as a string, e.g. "22") to a persisted +// random host port; it is empty when port randomization is disabled, in which +// case tcp exposures publish the container port directly. +func applyComposeExposures(conf *config.Config, chall *challenge.Challenge, teamID string, params map[string]interface{}, portBindings map[string]int) (map[int]string, error) { + exposures, err := challenge.ParseExposures(chall.DeployParameters) + if err != nil { + return nil, err + } + if len(exposures) == 0 { + return nil, nil + } + + defStr, ok := params["compose_definition"].(string) + if !ok || defStr == "" { + return nil, fmt.Errorf("expose requires a compose_definition") + } + + var compose map[string]interface{} + if err := yaml.Unmarshal([]byte(defStr), &compose); err != nil { + return nil, fmt.Errorf("failed to parse compose definition for exposure wiring: %w", err) + } + services, ok := compose["services"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("compose definition has no services map") + } + + project := docker.BuildComposeProject(chall.Unique, chall.Name, teamID) + domainRoot := conf.Instancer.InstancerHost + traefikNetwork, _ := conf.Instancer.ExtraDeploymentParameters[traefikNetworkParam].(string) + + httpCount := 0 + for _, exp := range exposures { + if exp.Type == challenge.ExposeHTTP { + httpCount++ + } + } + + hints := map[int]string{} + needTraefikNetwork := false + + for _, exp := range exposures { + svc, ok := services[exp.Service].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expose references unknown or invalid service %q", exp.Service) + } + + switch exp.Type { + case challenge.ExposeHTTP: + if traefikNetwork == "" { + return nil, fmt.Errorf("http exposure for service %q requires %q in extra_deployment_parameters", exp.Service, traefikNetworkParam) + } + routerName := project + domain := project + "." + domainRoot + if httpCount > 1 { + routerName = docker.SanitizeProjectName(exp.Service+"-"+project) + domain = docker.SanitizeProjectName(exp.Service+"-"+project) + "." + domainRoot + } + applyTraefikLabels(svc, routerName, domain, exp.Port) + attachNetwork(svc, traefikNetwork) + needTraefikNetwork = true + case challenge.ExposeTCP: + portSpec := strconv.Itoa(exp.Port) + if hostPort, ok := portBindings[strconv.Itoa(exp.Port)]; ok && hostPort > 0 { + portSpec = strconv.Itoa(hostPort) + ":" + strconv.Itoa(exp.Port) + } + addPort(svc, portSpec) + scheme := exp.Scheme + if scheme == "" { + scheme = "tcp" + } + hints[exp.Port] = scheme + } + + services[exp.Service] = svc + } + + if needTraefikNetwork { + ensureExternalNetwork(compose, traefikNetwork) + } + + out, err := yaml.Marshal(compose) + if err != nil { + return nil, fmt.Errorf("failed to re-serialize compose definition: %w", err) + } + params["compose_definition"] = string(out) + delete(params, "expose") + + return hints, nil +} + +// applyTraefikLabels adds the Traefik router/service labels to a compose +// service, mirroring the http playbook. Existing labels are preserved. +func applyTraefikLabels(svc map[string]interface{}, routerName, domain string, port int) { + labels := normalizeLabels(svc["labels"]) + labels["traefik.enable"] = "true" + labels["traefik.http.routers."+routerName+".rule"] = fmt.Sprintf("Host(`%s`)", domain) + labels["traefik.http.services."+routerName+".loadbalancer.server.port"] = strconv.Itoa(port) + svc["labels"] = labels +} + +// normalizeLabels converts compose labels (list of "k=v" strings or a map) into +// a string-keyed map so new labels can be merged in. +func normalizeLabels(existing interface{}) map[string]interface{} { + labels := map[string]interface{}{} + switch v := existing.(type) { + case map[string]interface{}: + for k, val := range v { + labels[k] = val + } + case []interface{}: + for _, item := range v { + s, ok := item.(string) + if !ok { + continue + } + for i := 0; i < len(s); i++ { + if s[i] == '=' { + labels[s[:i]] = s[i+1:] + break + } + } + } + } + return labels +} + +// attachNetwork ensures a service is connected to the named network, preserving +// any existing network configuration. +func attachNetwork(svc map[string]interface{}, network string) { + switch v := svc["networks"].(type) { + case nil: + svc["networks"] = []interface{}{network} + case []interface{}: + for _, n := range v { + if s, ok := n.(string); ok && s == network { + return + } + } + svc["networks"] = append(v, network) + case map[string]interface{}: + if _, ok := v[network]; !ok { + v[network] = map[string]interface{}{} + } + svc["networks"] = v + } +} + +// addPort appends a port specification to a service's ports list. +func addPort(svc map[string]interface{}, portSpec string) { + switch v := svc["ports"].(type) { + case nil: + svc["ports"] = []interface{}{portSpec} + case []interface{}: + for _, p := range v { + if s, ok := p.(string); ok && s == portSpec { + return + } + } + svc["ports"] = append(v, portSpec) + default: + svc["ports"] = []interface{}{portSpec} + } +} + +// ensureExternalNetwork declares the given network as external at the top level +// of the compose definition so Traefik can reach the service. +func ensureExternalNetwork(compose map[string]interface{}, network string) { + networks, ok := compose["networks"].(map[string]interface{}) + if !ok { + networks = map[string]interface{}{} + } + if _, exists := networks[network]; !exists { + networks[network] = map[string]interface{}{"external": true} + } + compose["networks"] = networks +} diff --git a/galvanize-instancer/internal/ansible/compose_expose_test.go b/galvanize-instancer/internal/ansible/compose_expose_test.go new file mode 100644 index 0000000..80a336b --- /dev/null +++ b/galvanize-instancer/internal/ansible/compose_expose_test.go @@ -0,0 +1,234 @@ +package ansible + +import ( + "strings" + "testing" + + "github.com/28Pollux28/galvanize/internal/challenge" + "github.com/28Pollux28/galvanize/pkg/config" + yaml "github.com/oasdiff/yaml3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newComposeChallenge(t *testing.T, composeDef string, expose []interface{}) *challenge.Challenge { + t.Helper() + params := map[string]interface{}{ + "compose_definition": composeDef, + } + if expose != nil { + params["expose"] = expose + } + return &challenge.Challenge{ + Name: "multi", + Category: "web", + PlaybookName: "custom_compose", + Type: "zync", + DeployParameters: params, + } +} + +// parseResult re-parses the mutated compose_definition for assertions. +func parseResult(t *testing.T, params map[string]interface{}) map[string]interface{} { + t.Helper() + def, ok := params["compose_definition"].(string) + require.True(t, ok, "compose_definition should be a string") + var compose map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(def), &compose)) + return compose +} + +func service(t *testing.T, compose map[string]interface{}, name string) map[string]interface{} { + t.Helper() + services, ok := compose["services"].(map[string]interface{}) + require.True(t, ok) + svc, ok := services[name].(map[string]interface{}) + require.True(t, ok, "service %s should exist", name) + return svc +} + +func TestApplyComposeExposures_NoExposeIsNoOp(t *testing.T) { + def := "services:\n web:\n image: nginx\n" + chall := newComposeChallenge(t, def, nil) + params := map[string]interface{}{"compose_definition": def} + + hints, err := applyComposeExposures(&config.Config{}, chall, "team1", params, nil) + require.NoError(t, err) + assert.Nil(t, hints) + assert.Equal(t, def, params["compose_definition"], "definition should be untouched when no expose block") +} + +func TestApplyComposeExposures_HTTPWiresTraefik(t *testing.T) { + def := "services:\n web:\n image: nginx\n db:\n image: postgres\n" + expose := []interface{}{ + map[string]interface{}{"service": "web", "port": 80, "type": "http"}, + } + chall := newComposeChallenge(t, def, expose) + params := map[string]interface{}{"compose_definition": def, "expose": expose} + + conf := &config.Config{} + conf.Instancer.InstancerHost = "challs.example.com" + conf.Instancer.ExtraDeploymentParameters = map[string]interface{}{"traefik_network": "traefik"} + + hints, err := applyComposeExposures(conf, chall, "team1", params, nil) + require.NoError(t, err) + assert.Empty(t, hints, "http exposures produce no protocol hints") + + compose := parseResult(t, params) + web := service(t, compose, "web") + + labels, ok := web["labels"].(map[string]interface{}) + require.True(t, ok, "labels should be a map") + assert.Equal(t, "true", labels["traefik.enable"]) + + // Single http exposure → domain is .. + var ruleFound, portFound bool + for k, v := range labels { + if strings.HasPrefix(k, "traefik.http.routers.") && strings.HasSuffix(k, ".rule") { + ruleFound = true + assert.Contains(t, v, "challs.example.com") + } + if strings.HasPrefix(k, "traefik.http.services.") && strings.HasSuffix(k, ".loadbalancer.server.port") { + portFound = true + assert.Equal(t, "80", v) + } + } + assert.True(t, ruleFound, "router rule label should be set") + assert.True(t, portFound, "loadbalancer port label should be set") + + // Service joined the traefik network. + nets, ok := web["networks"].([]interface{}) + require.True(t, ok) + assert.Contains(t, nets, "traefik") + + // Top-level external network declared. + topNets, ok := compose["networks"].(map[string]interface{}) + require.True(t, ok) + traefik, ok := topNets["traefik"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, true, traefik["external"]) + + // expose helper key must not leak into Ansible vars. + _, leaked := params["expose"] + assert.False(t, leaked) +} + +func TestApplyComposeExposures_HTTPRequiresTraefikNetwork(t *testing.T) { + def := "services:\n web:\n image: nginx\n" + expose := []interface{}{ + map[string]interface{}{"service": "web", "port": 80, "type": "http"}, + } + chall := newComposeChallenge(t, def, expose) + params := map[string]interface{}{"compose_definition": def, "expose": expose} + + conf := &config.Config{} // no traefik_network configured + conf.Instancer.InstancerHost = "challs.example.com" + + _, err := applyComposeExposures(conf, chall, "team1", params, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "traefik_network") +} + +func TestApplyComposeExposures_TCPPublishesFixedPort(t *testing.T) { + def := "services:\n ssh:\n image: openssh\n" + expose := []interface{}{ + map[string]interface{}{"service": "ssh", "port": 22, "type": "tcp", "scheme": "ssh"}, + } + chall := newComposeChallenge(t, def, expose) + params := map[string]interface{}{"compose_definition": def, "expose": expose} + + hints, err := applyComposeExposures(&config.Config{}, chall, "team1", params, nil) + require.NoError(t, err) + assert.Equal(t, "ssh", hints[22], "scheme hint should be propagated") + + compose := parseResult(t, params) + ssh := service(t, compose, "ssh") + ports, ok := ssh["ports"].([]interface{}) + require.True(t, ok) + assert.Contains(t, ports, "22") +} + +func TestApplyComposeExposures_TCPUsesRandomizedHostPort(t *testing.T) { + def := "services:\n ssh:\n image: openssh\n" + expose := []interface{}{ + map[string]interface{}{"service": "ssh", "port": 22, "type": "tcp"}, + } + chall := newComposeChallenge(t, def, expose) + params := map[string]interface{}{"compose_definition": def, "expose": expose} + + bindings := map[string]int{"22": 34567} + hints, err := applyComposeExposures(&config.Config{}, chall, "team1", params, bindings) + require.NoError(t, err) + assert.Equal(t, "tcp", hints[22], "default scheme is tcp") + + compose := parseResult(t, params) + ssh := service(t, compose, "ssh") + ports, ok := ssh["ports"].([]interface{}) + require.True(t, ok) + assert.Contains(t, ports, "34567:22") +} + +func TestApplyComposeExposures_MultipleHTTPUsesSubdomains(t *testing.T) { + def := "services:\n web:\n image: nginx\n admin:\n image: nginx\n" + expose := []interface{}{ + map[string]interface{}{"service": "web", "port": 80, "type": "http"}, + map[string]interface{}{"service": "admin", "port": 8080, "type": "http"}, + } + chall := newComposeChallenge(t, def, expose) + params := map[string]interface{}{"compose_definition": def, "expose": expose} + + conf := &config.Config{} + conf.Instancer.InstancerHost = "challs.example.com" + conf.Instancer.ExtraDeploymentParameters = map[string]interface{}{"traefik_network": "traefik"} + + _, err := applyComposeExposures(conf, chall, "team1", params, nil) + require.NoError(t, err) + + compose := parseResult(t, params) + web := service(t, compose, "web") + admin := service(t, compose, "admin") + + assert.True(t, hasRuleContaining(web, "web-"), "web service should get a web- subdomain rule") + assert.True(t, hasRuleContaining(admin, "admin-"), "admin service should get an admin- subdomain rule") +} + +func TestApplyComposeExposures_UnknownServiceErrors(t *testing.T) { + def := "services:\n web:\n image: nginx\n" + expose := []interface{}{ + map[string]interface{}{"service": "nope", "port": 80, "type": "http"}, + } + chall := newComposeChallenge(t, def, expose) + params := map[string]interface{}{"compose_definition": def, "expose": expose} + + conf := &config.Config{} + conf.Instancer.ExtraDeploymentParameters = map[string]interface{}{"traefik_network": "traefik"} + + _, err := applyComposeExposures(conf, chall, "team1", params, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nope") +} + +func TestExposeTCPContainerPorts(t *testing.T) { + params := map[string]interface{}{ + "expose": []interface{}{ + map[string]interface{}{"service": "ssh", "port": 22, "type": "tcp"}, + map[string]interface{}{"service": "web", "port": 80, "type": "http"}, + map[string]interface{}{"service": "dns", "port": 53, "type": "tcp"}, + }, + } + ports := exposeTCPContainerPorts(params) + assert.ElementsMatch(t, []string{"22", "53"}, ports) +} + +func hasRuleContaining(svc map[string]interface{}, substr string) bool { + labels, ok := svc["labels"].(map[string]interface{}) + if !ok { + return false + } + for k := range labels { + if strings.HasPrefix(k, "traefik.http.routers.") && strings.Contains(k, substr) { + return true + } + } + return false +} diff --git a/galvanize-instancer/internal/ansible/deployer.go b/galvanize-instancer/internal/ansible/deployer.go index 7067477..f6f5305 100644 --- a/galvanize-instancer/internal/ansible/deployer.go +++ b/galvanize-instancer/internal/ansible/deployer.go @@ -46,12 +46,20 @@ func (a *AnsibleDeployer) Deploy(ctx context.Context, conf *config.Config, chall existingBindings := loadPortBindingsFromDB(conf.Instancer.DBPath, key) if randomizePorts { randomizable := randomizableContainerPorts(chall.DeployParameters) + randomizable = append(randomizable, exposeTCPContainerPorts(chall.DeployParameters)...) existingBindings = ensureRandomPortBindingsInDB(conf.Instancer.DBPath, key, randomizable) } normalizedParams, protocolHints, _ := normalizePublishedPortsWithState(chall.DeployParameters, randomizePorts, existingBindings, false) if randomizePorts { savePortBindingsToDB(conf.Instancer.DBPath, key, existingBindings) } + exposeHints, err := applyComposeExposures(conf, chall, teamID, normalizedParams, existingBindings) + if err != nil { + return "", fmt.Errorf("failed to apply compose exposures: %w", err) + } + for port, scheme := range exposeHints { + protocolHints[port] = scheme + } executor, resultsBuff := PreparePlaybook(conf, "create", chall, teamID, normalizedParams) if err := executor.Execute(ctx); err != nil { @@ -98,6 +106,9 @@ func (a *AnsibleDeployer) Terminate(ctx context.Context, conf *config.Config, ch for attempt := 1; attempt <= maxRetries; attempt++ { existingBindings := loadPortBindingsFromDB(conf.Instancer.DBPath, key) normalizedParams, _, _ := normalizePublishedPortsWithState(chall.DeployParameters, randomizePorts, existingBindings, false) + if _, err := applyComposeExposures(conf, chall, teamID, normalizedParams, existingBindings); err != nil { + return fmt.Errorf("failed to apply compose exposures: %w", err) + } executor, resultsBuff := PreparePlaybook(conf, "delete", chall, teamID, normalizedParams) if err := executor.Execute(ctx); err != nil { diff --git a/galvanize-instancer/internal/challenge/challenge.go b/galvanize-instancer/internal/challenge/challenge.go index 5dafe67..2ed8c74 100644 --- a/galvanize-instancer/internal/challenge/challenge.go +++ b/galvanize-instancer/internal/challenge/challenge.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "sync" "github.com/28Pollux28/galvanize/pkg/config" @@ -12,6 +13,75 @@ import ( "go.uber.org/zap" ) +// composeFileNames lists the standard Docker Compose file names that are +// auto-detected next to a challenge.yml, in the order Docker Compose itself +// resolves them. +var composeFileNames = []string{ + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +} + +// Exposure types supported by the expose block. +const ( + ExposeHTTP = "http" + ExposeTCP = "tcp" +) + +// Exposure declares how a single Docker Compose service should be reached by +// players. It lets compose challenges get the same automatic networking wiring +// (Traefik for http, published host ports for tcp) that the http/tcp playbooks +// provide for single-container challenges. +type Exposure struct { + // Service is the compose service name to expose. + Service string + // Port is the container port the service listens on. + Port int + // Type is either "http" (routed through Traefik) or "tcp" (published port). + Type string + // Scheme overrides the URL scheme used in connection info (e.g. "ssh"). + Scheme string +} + +// ParseExposures reads the optional deploy_parameters.expose list. It returns +// nil when no expose block is present. Each entry is validated for structure. +func ParseExposures(params map[string]interface{}) ([]Exposure, error) { + raw, ok := params["expose"] + if !ok { + return nil, nil + } + list, ok := raw.([]interface{}) + if !ok { + return nil, fmt.Errorf("expose must be a list of exposure entries") + } + + exposures := make([]Exposure, 0, len(list)) + for i, item := range list { + m, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expose[%d] must be a mapping", i) + } + exp := Exposure{ + Service: toStringField(m, "service"), + Port: toIntField(m, "port"), + Type: strings.ToLower(toStringField(m, "type")), + Scheme: toStringField(m, "scheme"), + } + if exp.Service == "" { + return nil, fmt.Errorf("expose[%d]: missing service", i) + } + if exp.Port <= 0 { + return nil, fmt.Errorf("expose[%d] (%s): port must be a positive integer", i, exp.Service) + } + if exp.Type != ExposeHTTP && exp.Type != ExposeTCP { + return nil, fmt.Errorf("expose[%d] (%s): type must be %q or %q", i, exp.Service, ExposeHTTP, ExposeTCP) + } + exposures = append(exposures, exp) + } + return exposures, nil +} + func toStringField(m map[string]interface{}, key string) string { if v, ok := m[key]; ok { if s, ok := v.(string); ok { @@ -59,6 +129,7 @@ type Challenge struct { Type string `yaml:"type"` Unique bool `yaml:"-"` ResourceLimits config.ResourceLimits `yaml:"-"` + Dir string `yaml:"-"` DeployParameters map[string]interface{} `yaml:"deploy_parameters"` } @@ -153,6 +224,12 @@ func parseChallenge(challengeFilePath string) (*Challenge, error) { if challenge.Type == "" { return nil, fmt.Errorf("missing type in challenge file") } + challenge.Dir = filepath.Dir(challengeFilePath) + + if challenge.DeployParameters == nil { + challenge.DeployParameters = map[string]interface{}{} + } + if unique, ok := challenge.DeployParameters["unique"]; ok { if b, ok := unique.(bool); ok && b { challenge.Unique = true @@ -167,5 +244,107 @@ func parseChallenge(challengeFilePath string) (*Challenge, error) { } } + if err := loadComposeDefinition(&challenge); err != nil { + return nil, err + } + + if err := validateExposures(&challenge); err != nil { + return nil, err + } + return &challenge, nil } + +// loadComposeDefinition lets authors keep a standalone Docker Compose file +// (e.g. compose.yaml or docker-compose.yml) next to challenge.yml instead of +// embedding the whole document as a multiline string under +// deploy_parameters.compose_definition. +// +// Resolution order: +// 1. An inline deploy_parameters.compose_definition always wins. +// 2. An explicit deploy_parameters.compose_file (path relative to the +// challenge directory). +// 3. Auto-detected standard Compose file names in the challenge directory. +// +// When a file is loaded, its contents are placed in compose_definition so the +// existing custom_compose playbook consumes it unchanged, and playbook_name +// defaults to "custom_compose" if the author did not set one. +func loadComposeDefinition(c *Challenge) error { + if def, ok := c.DeployParameters["compose_definition"].(string); ok && strings.TrimSpace(def) != "" { + return nil + } + + var composePath string + if explicit := toStringField(c.DeployParameters, "compose_file"); explicit != "" { + composePath = filepath.Join(c.Dir, explicit) + if _, err := os.Stat(composePath); err != nil { + return fmt.Errorf("compose_file %q for challenge %q not found: %w", explicit, c.Name, err) + } + delete(c.DeployParameters, "compose_file") + } else { + for _, name := range composeFileNames { + candidate := filepath.Join(c.Dir, name) + if _, err := os.Stat(candidate); err == nil { + composePath = candidate + break + } + } + } + + if composePath == "" { + return nil + } + + data, err := os.ReadFile(composePath) + if err != nil { + return fmt.Errorf("failed to read compose file %s: %w", composePath, err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal(data, &parsed); err != nil { + return fmt.Errorf("invalid compose file %s: %w", composePath, err) + } + if _, ok := parsed["services"]; !ok { + return fmt.Errorf("compose file %s has no 'services' section", composePath) + } + + c.DeployParameters["compose_definition"] = string(data) + if c.PlaybookName == "" { + c.PlaybookName = "custom_compose" + } + zap.S().Infof("Loaded compose definition for challenge %q from %s", c.Name, filepath.Base(composePath)) + + return nil +} + +// validateExposures checks the optional expose block: entries must be +// well-formed and reference services that exist in the compose definition. +// It runs after the compose definition has been resolved so authors get clear +// errors at index time rather than at deploy time. +func validateExposures(c *Challenge) error { + exposures, err := ParseExposures(c.DeployParameters) + if err != nil { + return fmt.Errorf("challenge %q: %w", c.Name, err) + } + if len(exposures) == 0 { + return nil + } + + def, ok := c.DeployParameters["compose_definition"].(string) + if !ok || strings.TrimSpace(def) == "" { + return fmt.Errorf("challenge %q: expose requires a compose definition (compose file or compose_definition)", c.Name) + } + + var parsed struct { + Services map[string]interface{} `yaml:"services"` + } + if err := yaml.Unmarshal([]byte(def), &parsed); err != nil { + return fmt.Errorf("challenge %q: invalid compose definition: %w", c.Name, err) + } + for _, exp := range exposures { + if _, ok := parsed.Services[exp.Service]; !ok { + return fmt.Errorf("challenge %q: expose references unknown service %q", c.Name, exp.Service) + } + } + return nil +} diff --git a/galvanize-instancer/internal/challenge/challenge_test.go b/galvanize-instancer/internal/challenge/challenge_test.go index e973314..bba072d 100644 --- a/galvanize-instancer/internal/challenge/challenge_test.go +++ b/galvanize-instancer/internal/challenge/challenge_test.go @@ -22,6 +22,14 @@ func writeChallenge(t *testing.T, baseDir, subdir, content string) { require.NoError(t, os.WriteFile(filepath.Join(dir, "challenge.yml"), []byte(content), 0o644)) } +// writeFile is a helper that creates an arbitrary file inside baseDir/subdir/. +func writeFile(t *testing.T, baseDir, subdir, name, content string) { + t.Helper() + dir := filepath.Join(baseDir, subdir) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) +} + func TestNewChallengeIndex(t *testing.T) { dir := t.TempDir() @@ -246,6 +254,170 @@ deploy_parameters: assert.False(t, names["bof"]) } +func TestLoadComposeDefinition_AutoDetectsSiblingFile(t *testing.T) { + dir := t.TempDir() + + // No playbook_name and no inline compose_definition: a standalone + // compose file next to challenge.yml should be picked up automatically. + writeChallenge(t, dir, "web", ` +name: multi +category: web +type: zync +deploy_parameters: + unique: false +`) + compose := `services: + web: + image: nginx:alpine + ports: + - "80:80" + db: + image: postgres:16-alpine +` + writeFile(t, dir, "web", "docker-compose.yml", compose) + + idx, err := NewChallengeIndex(dir) + require.NoError(t, err) + + chall, err := idx.Get("web", "multi") + require.NoError(t, err) + assert.Equal(t, "custom_compose", chall.PlaybookName, "playbook_name should default to custom_compose") + assert.Equal(t, compose, chall.DeployParameters["compose_definition"]) +} + +func TestLoadComposeDefinition_PrefersCanonicalFileName(t *testing.T) { + dir := t.TempDir() + + writeChallenge(t, dir, "web", ` +name: multi +category: web +type: zync +deploy_parameters: + unique: false +`) + // compose.yaml has higher precedence than docker-compose.yml. + writeFile(t, dir, "web", "compose.yaml", "services:\n a:\n image: a\n") + writeFile(t, dir, "web", "docker-compose.yml", "services:\n b:\n image: b\n") + + idx, err := NewChallengeIndex(dir) + require.NoError(t, err) + + chall, err := idx.Get("web", "multi") + require.NoError(t, err) + assert.Contains(t, chall.DeployParameters["compose_definition"], "image: a") + assert.NotContains(t, chall.DeployParameters["compose_definition"], "image: b") +} + +func TestLoadComposeDefinition_ExplicitComposeFile(t *testing.T) { + dir := t.TempDir() + + writeChallenge(t, dir, "web", ` +name: multi +category: web +playbook_name: custom_compose +type: zync +deploy_parameters: + unique: false + compose_file: stack.yaml +`) + writeFile(t, dir, "web", "stack.yaml", "services:\n web:\n image: nginx\n") + + idx, err := NewChallengeIndex(dir) + require.NoError(t, err) + + chall, err := idx.Get("web", "multi") + require.NoError(t, err) + assert.Contains(t, chall.DeployParameters["compose_definition"], "image: nginx") + // The helper key should be stripped so it does not leak into Ansible vars. + _, ok := chall.DeployParameters["compose_file"] + assert.False(t, ok, "compose_file should be removed after loading") +} + +func TestLoadComposeDefinition_InlineTakesPrecedence(t *testing.T) { + dir := t.TempDir() + + writeChallenge(t, dir, "web", ` +name: multi +category: web +playbook_name: custom_compose +type: zync +deploy_parameters: + unique: false + compose_definition: |- + services: + inline: + image: inline-image +`) + // A sibling file exists but must be ignored in favour of the inline value. + writeFile(t, dir, "web", "docker-compose.yml", "services:\n file:\n image: file-image\n") + + idx, err := NewChallengeIndex(dir) + require.NoError(t, err) + + chall, err := idx.Get("web", "multi") + require.NoError(t, err) + assert.Contains(t, chall.DeployParameters["compose_definition"], "inline-image") + assert.NotContains(t, chall.DeployParameters["compose_definition"], "file-image") +} + +func TestLoadComposeDefinition_MissingExplicitFileErrors(t *testing.T) { + dir := t.TempDir() + + writeChallenge(t, dir, "web", ` +name: multi +category: web +playbook_name: custom_compose +type: zync +deploy_parameters: + unique: false + compose_file: nope.yaml +`) + + _, err := NewChallengeIndex(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "compose_file") +} + +func TestLoadComposeDefinition_NoServicesSectionErrors(t *testing.T) { + dir := t.TempDir() + + writeChallenge(t, dir, "web", ` +name: multi +category: web +type: zync +deploy_parameters: + unique: false +`) + writeFile(t, dir, "web", "docker-compose.yml", "version: \"3\"\nnetworks:\n default: {}\n") + + _, err := NewChallengeIndex(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "services") +} + +func TestLoadComposeDefinition_NoComposeFileLeavesHTTPUntouched(t *testing.T) { + dir := t.TempDir() + + writeChallenge(t, dir, "web", ` +name: http +category: web +playbook_name: http +type: zync +deploy_parameters: + unique: false + image: nginx:latest +`) + + idx, err := NewChallengeIndex(dir) + require.NoError(t, err) + + chall, err := idx.Get("web", "http") + require.NoError(t, err) + assert.Equal(t, "http", chall.PlaybookName) + _, ok := chall.DeployParameters["compose_definition"] + assert.False(t, ok, "non-compose challenges should not gain a compose_definition") +} + func TestBuildIndex_UniqueChallenge(t *testing.T) { dir := t.TempDir()