diff --git a/.env.example b/.env.example index 91317cd..4237475 100644 --- a/.env.example +++ b/.env.example @@ -41,17 +41,14 @@ APP_CONFIG_CACHE_TTL=2m # CORS CORS_ALLOWED_ORIGINS=http://localhost:3000,https://smctf.example.com -# Stack (Container Provisioner) -STACKS_ENABLED=true -STACKS_MAX_SCOPE=team -STACKS_MAX_PER=3 -STACKS_PROVISIONER_BASE_URL=http://localhost:8081 -STACKS_PROVISIONER_USE_GRPC=false -STACKS_PROVISIONER_GRPC_ADDR=localhost:9090 -STACKS_PROVISIONER_API_KEY=change-me -STACKS_PROVISIONER_TIMEOUT=5s -STACKS_CREATE_WINDOW=1m -STACKS_CREATE_MAX=1 +# VM (Container Orchestrator) +VMS_ENABLED=true +VMS_MAX_SCOPE=team +VMS_MAX_PER=3 +VMS_ORCHESTRATOR_BASE_URL=http://localhost:8081 +VMS_ORCHESTRATOR_TIMEOUT=5s +VMS_CREATE_WINDOW=1m +VMS_CREATE_MAX=1 # Logging LOG_DIR=logs diff --git a/Makefile b/Makefile index eea0ec7..bf7e9ce 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,10 @@ SHELL := /bin/bash GO ?= go -BUF ?= buf -BUF_VERSION ?= v1.66.1 -BUF_MODULE ?= buf.build/smctf/container-provisioner -.PHONY: all fmt vet lint buf-install buf-lint buf-generate test build +.PHONY: all fmt vet lint test build -all: buf-lint buf-generate test build +all: lint test build fmt: $(GO) fmt ./... @@ -15,19 +12,13 @@ fmt: vet: $(GO) vet ./... -lint: buf-lint vet - -buf-install: - $(GO) install github.com/bufbuild/buf/cmd/buf@$(BUF_VERSION) - -buf-lint: - $(BUF) lint $(BUF_MODULE) - -buf-generate: - $(BUF) generate $(BUF_MODULE) --template buf.gen.yaml +lint: vet test: $(GO) test ./... build: $(GO) build ./cmd/server + +run: + $(GO) run ./cmd/server \ No newline at end of file diff --git a/README.md b/README.md index a6355ac..4cd490e 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ Docs | Backend | - - Container Provisioner + + Container Orchestrator | @@ -71,8 +71,8 @@ See [SMCTF Docs](https://ctf.null4u.cloud/smctf/) for more details. This README - Frontend has been moved to a separate repository ([nullforu/smctfe](https://github.com/nullforu/smctfe)) - Challenge file upload/download support via AWS S3 Presigned URL - Ref Issue: [#20](https://github.com/nullforu/smctf/issues/20), PR: [#21](https://github.com/nullforu/smctf/pull/21) -- Per challenge individual Stack(instance/VM) provisioning support via Kubernetes - - Ref PR: [#25](https://github.com/nullforu/smctf/pull/25), See [container-provisioner-k8s](https://github.com/nullforu/container-provisioner-k8s) and [docs](https://ctf.null4u.cloud/container-provisioner/) for more details. +- Per challenge individual VM(instance/VM) provisioning support via Kubernetes + - Ref PR: [#25](https://github.com/nullforu/smctf/pull/25), See [container-orchestrator-k8s](https://github.com/nullforu/container-orchestrator-k8s) and [docs](https://ctf.null4u.cloud/container-orchestrator/) for more details. - ... and more! (See [docs](https://github.com/nullforu/smctf-docs) for more details) --> ### Planned/Upcoming features: @@ -82,10 +82,10 @@ Also, the following features are planned to be implemented. see [issues](https:/ - (WIP) Systematized admin dashboard and behavior log/monitoring system integration - ... and more features to be added. -## Tech Stacks +## Tech VMs - Backend: [Go](https://go.dev/), [Gin](https://github.com/gin-gonic/gin), [Bun ORM](https://bun.uptrace.dev/) -- Container Provisioner: [Go (nullforu/container-provisioner-k8s)](https://github.com/nullforu/container-provisioner-k8s) +- Container Orchestrator: [Go (nullforu/container-orchestrator-k8s)](https://github.com/nullforu/container-orchestrator-k8s) - Frontend: React [(nullforu/smctfe)](https://github.com/nullforu/smctfe) - Database, Cache: [PostgreSQL](https://www.postgresql.org/)(instead of MySQL/MariaDB), [Redis](https://redis.io/) - Testing: [Testcontainers for Go](https://github.com/testcontainers/testcontainers-go) @@ -168,17 +168,14 @@ SUBMIT_MAX=10 TIMELINE_CACHE_TTL=60s LEADERBOARD_CACHE_TTL=60s -# Stack (Container Provisioner) -STACKS_ENABLED=true -STACKS_MAX_SCOPE=team -STACKS_MAX_PER=3 -STACKS_PROVISIONER_BASE_URL=http://localhost:8081 -STACKS_PROVISIONER_USE_GRPC=false -STACKS_PROVISIONER_GRPC_ADDR=localhost:9090 -STACKS_PROVISIONER_API_KEY=change-me -STACKS_PROVISIONER_TIMEOUT=5s -STACKS_CREATE_WINDOW=1m -STACKS_CREATE_MAX=1 +# VM (Container Orchestrator) +VMS_ENABLED=true +VMS_MAX_SCOPE=team +VMS_MAX_PER=3 +VMS_ORCHESTRATOR_BASE_URL=http://localhost:8081 +VMS_ORCHESTRATOR_TIMEOUT=5s +VMS_CREATE_WINDOW=1m +VMS_CREATE_MAX=1 # Logging LOG_DIR=logs @@ -205,29 +202,6 @@ S3_PRESIGN_TTL=15m -## Buf / BSR (container-provisioner proto) - -This repo consumes the container-provisioner proto via Buf Schema Registry (BSR). - -Setup: - -```bash -make buf-install -buf registry login -``` - -Generate code: - -```bash -make buf-generate -``` - -Module reference is set via `BUF_MODULE` (Makefile). You can override via: - -```bash -make buf-generate BUF_MODULE=buf.build//container-provisioner -``` - > [!IMPORTANT] > > Make sure to change `JWT_SECRET` to a secure random string in production! @@ -271,7 +245,7 @@ go build -o smctf ./cmd/server "app": { "type": "string" }, "legacy": { "type": "boolean" }, "error": {}, - "stack": { "type": "string" }, + "stack_trace": { "type": "string" }, "http": { "type": "object", "additionalProperties": true, diff --git a/buf.gen.yaml b/buf.gen.yaml deleted file mode 100644 index 3ae24ae..0000000 --- a/buf.gen.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: v1 -plugins: - - plugin: buf.build/protocolbuffers/go - out: internal/gen - opt: paths=source_relative - - plugin: buf.build/grpc/go - out: internal/gen - opt: paths=source_relative diff --git a/buf.lock b/buf.lock deleted file mode 100644 index 4f98143..0000000 --- a/buf.lock +++ /dev/null @@ -1,2 +0,0 @@ -# Generated by buf. DO NOT EDIT. -version: v2 diff --git a/cmd/server/main.go b/cmd/server/main.go index 7ab8b28..c8cf745 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,8 +18,8 @@ import ( "smctf/internal/realtime" "smctf/internal/repo" "smctf/internal/service" - "smctf/internal/stack" "smctf/internal/storage" + "smctf/internal/vm" ) func main() { @@ -84,7 +84,7 @@ func main() { submissionRepo := repo.NewSubmissionRepo(database) scoreRepo := repo.NewScoreboardRepo(database) appConfigRepo := repo.NewAppConfigRepo(database) - stackRepo := repo.NewStackRepo(database) + vmRepo := repo.NewVMRepo(database) var fileStore storage.ChallengeFileStore if cfg.S3.Enabled { @@ -104,29 +104,8 @@ func main() { ctfSvc := service.NewCTFService(cfg, challengeRepo, submissionRepo, redisClient, fileStore) appConfigSvc := service.NewAppConfigService(appConfigRepo, redisClient, cfg.Cache.AppConfigTTL) - var stackClient stack.API - var stackClientCloser func() error - if cfg.Stack.ProvisionerUseGRPC { - client, err := stack.NewGRPCClient(cfg.Stack.ProvisionerGRPCAddr, cfg.Stack.ProvisionerAPIKey, cfg.Stack.ProvisionerTimeout) - if err != nil { - logger.Error("grpc stack client init error", slog.Any("error", err)) - os.Exit(1) - } - - stackClient = client - stackClientCloser = client.Close - } else { - stackClient = stack.NewClient(cfg.Stack.ProvisionerBaseURL, cfg.Stack.ProvisionerAPIKey, cfg.Stack.ProvisionerTimeout) - } - if stackClientCloser != nil { - defer func() { - if err := stackClientCloser(); err != nil { - logger.Warn("stack client close error", slog.Any("error", err)) - } - }() - } - - stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, stackClient, redisClient) + vmClient := vm.NewClient(cfg.VM.OrchestratorBaseURL, cfg.VM.OrchestratorTimeout) + vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, vmClient, redisClient) bootstrap.BootstrapAdmin(ctx, cfg, database, userRepo, teamRepo, divisionRepo, logger) @@ -143,7 +122,7 @@ func main() { leaderboardBus := realtime.NewScoreboardBus(redisClient, cfg, scoreSvc, divisionSvc, logger, sseHub) leaderboardBus.Start(ctx) - router := httpserver.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, stackSvc, redisClient, logger, sseHub) + router := httpserver.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, redisClient, logger, sseHub) srv := &nethttp.Server{ Addr: cfg.HTTPAddr, Handler: router, diff --git a/codecov.yaml b/codecov.yaml index 3baafd8..479bf06 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -23,6 +23,5 @@ ignore: - "migrations/**" - "**/*.sql" - "internal/storage/s3.go" # S3 storage is production only code. instead, we test mock storage. - - "internal/stack/client.go" # Container Provisioner HTTP client is production only code. instead, we test mock client. - - "internal/stack/grpc_client.go" # Container Provisioner gRPC client is production only code. instead, we test mock client. + - "internal/vm/client.go" # Container Orchestrator HTTP client is production only code. instead, we test mock client. - "internal/http/handlers/types.go" # only type definitions and constructors. diff --git a/docs/docs/admin.md b/docs/docs/admin.md index 02e8ead..f49ee92 100644 --- a/docs/docs/admin.md +++ b/docs/docs/admin.md @@ -94,9 +94,8 @@ Response 200 "file_key": null, "file_name": null, "file_uploaded_at": null, - "stack_enabled": false, - "stack_target_ports": [], - "stack_pod_spec": null, + "vm_enabled": false, + "vm_spec": null, "created_at": "2026-02-17T12:00:00Z" } ], @@ -134,7 +133,7 @@ Response 200 "updated_at": "2026-02-17T10:00:00Z" } ], - "stacks": [], + "vms": [], "registration_keys": [], "submissions": [], "app_config": [], @@ -476,19 +475,13 @@ Request "flag": "flag{...}", "previous_challenge_id": 1, "is_active": true, - "stack_enabled": false, - "stack_target_ports": [ - { - "container_port": 80, - "protocol": "TCP" - } - ], - "stack_pod_spec": "apiVersion: v1\nkind: Pod\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx:stable\n ports:\n - containerPort: 80" + "vm_enabled": false, + "vm_spec": "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx:stable\n ports:\n - containerPort: 80" } ``` If `minimum_points` is omitted, it defaults to the same value as `points`. -If `stack_enabled` is true, both `stack_target_ports` and `stack_pod_spec` are required. +If `vm_enabled` is true, `vm_spec` is required. Categories @@ -511,7 +504,8 @@ Response 201 "solve_count": 0, "previous_challenge_id": 1, "is_active": true, - "has_file": false + "has_file": false, + "vm_enabled": false } ``` @@ -544,22 +538,21 @@ To keep existing values, omit the field entirely. Field behavior: -| Field | Type | Omit | null | Empty/Whitespace String | Other | -| ----------------------- | ------ | ------------- | ------------ | ----------------------- | -------------------------------------------------------------------------- | -| `title` | string | Keep existing | Error | Allowed | Allowed | -| `description` | string | Keep existing | Error | Allowed | Allowed | -| `category` | string | Keep existing | Error | Error | Must be a valid category | -| `points` | int | Keep existing | Error | Error | Must be >= 0 | -| `minimum_points` | int | Keep existing | Error | Error | Must be >= 0 and <= `points` | -| `flag` | string | Keep existing | Error | Error | Updates flag | -| `previous_challenge_id` | int | Keep existing | Clears value | Error | Must be a valid challenge id (not self) | -| `is_active` | bool | Keep existing | Error | Error | Sets value | -| `stack_enabled` | bool | Keep existing | Error | Error | If `false`, clears `stack_target_ports` + `stack_pod_spec` | -| `stack_target_ports` | array | Keep existing | Error | Error | Requires `stack_enabled` true; container port 1-65535 and protocol TCP/UDP | -| `stack_pod_spec` | string | Keep existing | Error | Error | Requires `stack_enabled` true and non-empty value | - -If `stack_enabled` is true after updates, `stack_target_ports` and `stack_pod_spec` are required (non-empty). -To clear stack fields, set `stack_enabled` to `false` (and omit `stack_target_ports` / `stack_pod_spec`). +| Field | Type | Omit | null | Empty/Whitespace String | Other | +| ----------------------- | ------ | ------------- | ------------ | ----------------------- | ---------------------------------------------- | +| `title` | string | Keep existing | Error | Allowed | Allowed | +| `description` | string | Keep existing | Error | Allowed | Allowed | +| `category` | string | Keep existing | Error | Error | Must be a valid category | +| `points` | int | Keep existing | Error | Error | Must be >= 0 | +| `minimum_points` | int | Keep existing | Error | Error | Must be >= 0 and <= `points` | +| `flag` | string | Keep existing | Error | Error | Updates flag | +| `previous_challenge_id` | int | Keep existing | Clears value | Error | Must be a valid challenge id (not self) | +| `is_active` | bool | Keep existing | Error | Error | Sets value | +| `vm_enabled` | bool | Keep existing | Error | Error | If `false`, clears `vm_spec` | +| `vm_spec` | string | Keep existing | Error | Error | Requires `vm_enabled` true and non-empty value | + +If `vm_enabled` is true after updates, `vm_spec` is required (non-empty). +To clear vm fields, set `vm_enabled` to `false` (and omit `vm_spec`). ```json { @@ -569,14 +562,8 @@ To clear stack fields, set `stack_enabled` to `false` (and omit `stack_target_po "flag": "flag{rotated}", "previous_challenge_id": 1, "is_active": false, - "stack_enabled": true, - "stack_target_ports": [ - { - "container_port": 80, - "protocol": "TCP" - } - ], - "stack_pod_spec": "apiVersion: v1\nkind: Pod\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx:stable\n ports:\n - containerPort: 80" + "vm_enabled": true, + "vm_spec": "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx:stable\n ports:\n - containerPort: 80" } ``` @@ -596,13 +583,7 @@ Response 200 "is_active": false, "has_file": true, "file_name": "challenge.zip", - "stack_enabled": true, - "stack_target_ports": [ - { - "container_port": 80, - "protocol": "TCP" - } - ] + "vm_enabled": true } ``` @@ -649,20 +630,14 @@ Response 200 "is_active": false, "has_file": true, "file_name": "challenge.zip", - "stack_enabled": true, - "stack_target_ports": [ - { - "container_port": 80, - "protocol": "TCP" - } - ], - "stack_pod_spec": "apiVersion: v1\nkind: Pod\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx:stable\n ports:\n - containerPort: 80" + "vm_enabled": true, + "vm_spec": "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx:stable\n ports:\n - containerPort: 80" } ``` Notes: -- `stack_pod_spec` is only returned via this admin-only endpoint. +- `vm_spec` is only returned via this admin-only endpoint. Errors: @@ -698,7 +673,7 @@ Errors: --- -## Stack Management (Admin) +## VM Management (Admin) Headers @@ -706,17 +681,17 @@ Headers Cookie: access_token= ``` -### List All Stacks +### List All VMs -`GET /api/admin/stacks` +`GET /api/admin/vms` Response 200 ```json { - "stacks": [ + "vms": [ { - "stack_id": "stack-716b6384dd477b0b", + "vm_id": "vm-716b6384dd477b0b", "ttl_expires_at": "2026-02-10T04:02:26.535664Z", "created_at": "2026-02-10T02:02:26.535664Z", "updated_at": "2026-02-10T02:06:33.16031Z", @@ -737,30 +712,35 @@ Errors: - 401 `invalid token` or `missing access_token cookie` - 403 `forbidden` -- 503 `stack feature disabled` or `stack provisioner unavailable` +- 503 `vm feature disabled` or `vm orchestrator unavailable` -### Get Stack Detail +### Get VM Detail -`GET /api/admin/stacks/{stack_id}` +`GET /api/admin/vms/{vm_id}` Response 200 ```json { - "stack_id": "stack-716b6384dd477b0b", + "vm_id": "vm-716b6384dd477b0b", "challenge_id": 5, - "status": "running", - "node_public_ip": "12.34.56.78", + "status": "Running", + "node_name": "sandboxd-node-1", + "external_ip": "12.34.56.78", "ports": [ { + "host_port": 31538, "container_port": 80, - "protocol": "TCP", - "node_port": 31538 + "protocol": "tcp" } ], "ttl_expires_at": "2026-02-10T04:02:26.535664Z", + "last_error": null, "created_at": "2026-02-10T02:02:26.535664Z", - "updated_at": "2026-02-10T02:06:33.16031Z" + "updated_at": "2026-02-10T02:06:33.16031Z", + "created_by_user_id": 12, + "created_by_username": "alice", + "challenge_title": "Web 1" } ``` @@ -768,19 +748,19 @@ Errors: - 401 `invalid token` or `missing access_token cookie` - 403 `forbidden` -- 404 `stack not found` -- 503 `stack feature disabled` or `stack provisioner unavailable` +- 404 `vm not found` +- 503 `vm feature disabled` or `vm orchestrator unavailable` -### Delete Stack +### Delete VM -`DELETE /api/admin/stacks/{stack_id}` +`DELETE /api/admin/vms/{vm_id}` Response 200 ```json { "deleted": true, - "stack_id": "stack-716b6384dd477b0b" + "vm_id": "vm-716b6384dd477b0b" } ``` @@ -788,8 +768,8 @@ Errors: - 401 `invalid token` or `missing access_token cookie` - 403 `forbidden` -- 404 `stack not found` -- 503 `stack feature disabled` or `stack provisioner unavailable` +- 404 `vm not found` +- 503 `vm feature disabled` or `vm orchestrator unavailable` --- diff --git a/docs/docs/auth.md b/docs/docs/auth.md index 436b449..329f6ca 100644 --- a/docs/docs/auth.md +++ b/docs/docs/auth.md @@ -68,8 +68,8 @@ Response 200 "team_name": "team-alpha", "division_id": 2, "division_name": "고등부", - "stack_count": 0, - "stack_limit": 3, + "vm_count": 0, + "vm_limit": 3, "blocked_reason": null, "blocked_at": null } @@ -83,7 +83,7 @@ Errors: Notes: -- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide. +- `vm_count` and `vm_limit` reflect the configured scope. If `VMS_MAX_SCOPE=team`, these values are team-wide. - `access_token` and `refresh_token` are issued as `HttpOnly` cookies. - `csrf_token` is issued as a readable cookie for double-submit CSRF protection. diff --git a/docs/docs/challenges.md b/docs/docs/challenges.md index bf11771..5fdc0a3 100644 --- a/docs/docs/challenges.md +++ b/docs/docs/challenges.md @@ -28,8 +28,7 @@ Response 200 "is_locked": false, "has_file": true, "file_name": "challenge.zip", - "stack_enabled": false, - "stack_target_ports": [] + "vm_enabled": false } ] } @@ -39,7 +38,7 @@ Notes: - `points` is dynamically calculated based on solves. - `has_file` indicates whether a challenge file is available. -- `stack_enabled` indicates if a stack instance is supported for this challenge. Scope is controlled by `STACKS_MAX_SCOPE` (user or team). +- `vm_enabled` indicates if a vm instance is supported for this challenge. Scope is controlled by `VMS_MAX_SCOPE` (user or team). - If a challenge is locked, the response includes only `id`, `title`, `category`, `points`, `initial_points`, `minimum_points`, `solve_count`, `previous_challenge_id`, `previous_challenge_title`, `previous_challenge_category`, `is_active`, and `is_locked`. - If `ctf_state` is `not_started`, the response only includes `ctf_state`. diff --git a/docs/docs/errors.md b/docs/docs/errors.md index 69bde69..5977bfe 100644 --- a/docs/docs/errors.md +++ b/docs/docs/errors.md @@ -91,4 +91,4 @@ For blocked users: Notes: -- Returned only on endpoints that require an active (non-blocked) user. Some read-only endpoints are still available to blocked users (e.g. stack listing and stack detail). +- Returned only on endpoints that require an active (non-blocked) user. Some read-only endpoints are still available to blocked users (e.g. vm listing and vm detail). diff --git a/docs/docs/report.schema.json b/docs/docs/report.schema.json index 22931b1..836e6ff 100644 --- a/docs/docs/report.schema.json +++ b/docs/docs/report.schema.json @@ -38,24 +38,9 @@ "is_active": { "type": "boolean" }, - "stack_enabled": { + "vm_enabled": { "type": "boolean" }, - "stack_target_ports": { - "type": "array", - "items": { - "type": "object", - "properties": { - "container_port": { - "type": "number" - }, - "protocol": { - "type": "string" - } - }, - "required": ["container_port", "protocol"] - } - }, "created_at": { "type": "string" }, @@ -68,8 +53,8 @@ "file_uploaded_at": { "type": "string" }, - "stack_pod_spec": { - "type": "string" + "vm_spec": { + "type": ["string", "null"] } }, "required": [ @@ -82,8 +67,8 @@ "minimum_points", "solve_count", "is_active", - "stack_enabled", - "stack_target_ports", + "vm_enabled", + "vm_spec", "created_at" ] } @@ -133,15 +118,7 @@ "type": "number" } }, - "required": [ - "id", - "name", - "division_id", - "division_name", - "created_at", - "member_count", - "total_score" - ] + "required": ["id", "name", "division_id", "division_name", "created_at", "member_count", "total_score"] } }, "users": { @@ -194,7 +171,7 @@ ] } }, - "stacks": { + "vms": { "type": "array", "items": {} }, @@ -505,7 +482,7 @@ "divisions", "teams", "users", - "stacks", + "vms", "registration_keys", "submissions", "app_config", diff --git a/docs/docs/stacks.md b/docs/docs/stacks.md deleted file mode 100644 index e772e41..0000000 --- a/docs/docs/stacks.md +++ /dev/null @@ -1,183 +0,0 @@ ---- -title: Stacks -nav_order: 8 ---- - -## List My Stacks - -`GET /api/stacks` - -Headers - -``` -Cookie: access_token= -``` - -Response 200 - -```json -{ - "ctf_state": "active", - "stacks": [ - { - "stack_id": "stack-716b6384dd477b0b", - "challenge_id": 12, - "challenge_title": "SQLi 101", - "status": "running", - "node_public_ip": "12.34.56.78", - "ports": [ - { - "container_port": 80, - "protocol": "TCP", - "node_port": 31538 - } - ], - "ttl_expires_at": "2026-02-10T04:02:26Z", - "created_at": "2026-02-10T02:02:26Z", - "updated_at": "2026-02-10T02:07:29Z", - "created_by_user_id": 17, - "created_by_username": "alice", - "ctf_state": "active" - } - ] -} -``` - -Errors: - -- 401 `invalid token` or `missing access_token cookie` -- 503 `stack feature disabled` -- If `ctf_state` is `not_started`, the response only includes `ctf_state`. - Notes: - -- Blocked users can access this endpoint (read-only). -- If `STACKS_MAX_SCOPE=team`, this list includes stacks created by any member of the same team. - ---- - -## Create Stack For Challenge - -`POST /api/challenges/{id}/stack` - -Headers - -``` -Cookie: access_token= -``` - -Response 201 - -```json -{ - "stack_id": "stack-716b6384dd477b0b", - "challenge_id": 12, - "challenge_title": "SQLi 101", - "status": "creating", - "node_public_ip": "12.34.56.78", - "ports": [ - { - "container_port": 80, - "protocol": "TCP", - "node_port": 31538 - } - ], - "ttl_expires_at": "2026-02-10T04:02:26Z", - "created_at": "2026-02-10T02:02:26Z", - "updated_at": "2026-02-10T02:02:26Z", - "created_by_user_id": 17, - "created_by_username": "alice", - "ctf_state": "active" -} -``` - -Errors: - -- 400 `invalid input` or `stack not enabled for challenge` -- 401 `invalid token` or `missing access_token cookie` -- 403 `challenge locked` -- 404 `challenge not found` -- 409 `stack limit reached` or `challenge already solved` -- 429 `too many submissions` (rate limited) -- 503 `stack feature disabled` or `stack provisioner unavailable` -- If `ctf_state` is `not_started` or `ended`, the response only includes `ctf_state`. - -Notes: - -- Stack creation is rate-limited by scope. Configure via `STACKS_CREATE_WINDOW` and `STACKS_CREATE_MAX`. -- If `STACKS_MAX_SCOPE=team`, creating a stack counts against the team limit and team rate limit. - ---- - -## Get Stack For Challenge - -`GET /api/challenges/{id}/stack` - -Headers - -``` -Cookie: access_token= -``` - -Response 200 - -```json -{ - "stack_id": "stack-716b6384dd477b0b", - "challenge_id": 12, - "challenge_title": "SQLi 101", - "status": "running", - "node_public_ip": "12.34.56.78", - "ports": [ - { - "container_port": 80, - "protocol": "TCP", - "node_port": 31538 - } - ], - "ttl_expires_at": "2026-02-10T04:02:26Z", - "created_at": "2026-02-10T02:02:26Z", - "updated_at": "2026-02-10T02:07:29Z", - "created_by_user_id": 17, - "created_by_username": "alice", - "ctf_state": "active" -} -``` - -Errors: - -- 401 `invalid token` or `missing access_token cookie` -- 404 `stack not found` -- 503 `stack feature disabled` or `stack provisioner unavailable` -- If `ctf_state` is `not_started`, the response only includes `ctf_state`. - Notes: - -- Blocked users can access this endpoint (read-only). -- If `STACKS_MAX_SCOPE=team`, this returns the team stack for the challenge (if any). - ---- - -## Delete Stack For Challenge - -`DELETE /api/challenges/{id}/stack` - -Headers - -``` -Cookie: access_token= -``` - -Response 200 - -```json -{ - "status": "ok", - "ctf_state": "active" -} -``` - -Errors: - -- 401 `invalid token` or `missing access_token cookie` -- 404 `stack not found` -- 503 `stack feature disabled` or `stack provisioner unavailable` -- If `ctf_state` is `not_started`, the response only includes `ctf_state`. diff --git a/docs/docs/users.md b/docs/docs/users.md index 46e7bf3..fbe71ae 100644 --- a/docs/docs/users.md +++ b/docs/docs/users.md @@ -29,8 +29,8 @@ Response 200 "team_name": "서울고등학교", "division_id": 2, "division_name": "고등부", - "stack_count": 0, - "stack_limit": 3, + "vm_count": 0, + "vm_limit": 3, "blocked_reason": null, "blocked_at": null } @@ -42,7 +42,7 @@ Errors: Notes: -- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide. +- `vm_count` and `vm_limit` reflect the configured scope. If `VMS_MAX_SCOPE=team`, these values are team-wide. --- @@ -76,8 +76,8 @@ Response 200 "team_name": "서울고등학교", "division_id": 2, "division_name": "고등부", - "stack_count": 0, - "stack_limit": 3, + "vm_count": 0, + "vm_limit": 3, "blocked_reason": null, "blocked_at": null } @@ -92,7 +92,7 @@ Errors: Notes: -- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide. +- `vm_count` and `vm_limit` reflect the configured scope. If `VMS_MAX_SCOPE=team`, these values are team-wide. --- diff --git a/docs/docs/vms.md b/docs/docs/vms.md new file mode 100644 index 0000000..ce74618 --- /dev/null +++ b/docs/docs/vms.md @@ -0,0 +1,344 @@ +--- +title: VMs +nav_order: 11 +--- + +Notes: + +- VM APIs are backed by `sandboxd-orch` (HTTP API). +- For authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests, send both `csrf_token` cookie and matching `X-CSRF-Token` header. +- VM row deletion from SMCTF DB is explicit through user/admin delete endpoints. +- VM row deletion is also attempted automatically right after a correct flag submission for that challenge. +- VM rows are not deleted automatically just because orchestrator status changed to error or missing. + +## VM Response Schema + +`vmResponse` fields: + +- `vm_id`: generated by service (`vm-{user_id}-{challenge_id}-{suffix}`). +- `challenge_id`: challenge identifier. +- `challenge_title`: challenge title snapshot for UI. +- `status`: latest sandbox phase from orchestrator (`Running`, `Pending`, `Failed`, etc.). +- `node_name`: optional node name from orchestrator status. +- `external_ip`: optional public/external IP from orchestrator status. +- `ports`: list of assigned ports (`host_port`, `container_port`, `protocol`). +- `ttl_expires_at`: TTL expiration time from orchestrator. +- `last_error`: latest VM-visible error text (nullable). +- `created_at`, `updated_at`: DB timestamps (UTC). +- `created_by_user_id`, `created_by_username`: owner metadata. + +## List My VMs + +`GET /api/vms` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 + +```json +{ + "vms": [ + { + "vm_id": "vm-1-10-abc123def456", + "challenge_id": 10, + "challenge_title": "Pwn Warmup", + "status": "Running", + "node_name": "node-a", + "external_ip": "203.0.113.10", + "ports": [ + { + "host_port": 31000, + "container_port": 31337, + "protocol": "tcp" + } + ], + "ttl_expires_at": "2026-05-17T12:00:00Z", + "last_error": null, + "created_at": "2026-05-17T11:00:00Z", + "updated_at": "2026-05-17T11:00:05Z", + "created_by_user_id": 1, + "created_by_username": "player" + } + ] +} +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 503 `vm feature disabled` or `vm orchestrator unavailable` +- 500 internal error + +Notes: + +- This endpoint refreshes each VM from orchestrator before returning. +- Blocked users can access this endpoint (read-only). + +--- + +## Create VM For Challenge + +`POST /api/challenges/{id}/vm` + +Headers + +``` +Cookie: access_token= +``` + +Response 201 + +```json +{ + "vm_id": "vm-17-12-6dcb8db3b4d1", + "challenge_id": 12, + "challenge_title": "SQLi 101", + "status": "Pending", + "node_name": null, + "external_ip": null, + "ports": [], + "ttl_expires_at": "2026-05-18T04:02:26Z", + "last_error": null, + "created_at": "2026-05-18T02:02:26Z", + "updated_at": "2026-05-18T02:02:26Z", + "created_by_user_id": 17, + "created_by_username": "alice" +} +``` + +Errors: + +- 400 `invalid input`, `vm not enabled for challenge`, `vm spec invalid` +- 401 `invalid token` or `missing access_token cookie` +- 403 `user blocked` or `challenge locked` +- 404 `challenge not found` +- 409 `vm limit reached` or `challenge already solved` +- 429 `too many submissions` +- 503 `vm feature disabled` or `vm orchestrator unavailable` +- 500 internal error + +Create behavior: + +- Challenge must have `vm_enabled=true` and non-empty `vm_spec` (YAML). +- Service parses the YAML, rewrites `id` to generated `vm_id`, and sends the rewritten spec to orchestrator. +- If an existing VM row already exists for `(user_id, challenge_id)`, create returns the existing VM instead of creating a second one. +- User VM cap and VM creation rate limit are enforced server-side. + +--- + +## Get VM For Challenge + +`GET /api/challenges/{id}/vm` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 + +```json +{ + "vm_id": "vm-17-12-6dcb8db3b4d1", + "challenge_id": 12, + "challenge_title": "SQLi 101", + "status": "Running", + "node_name": "node-a", + "external_ip": "203.0.113.10", + "ports": [ + { + "host_port": 10000, + "container_port": 31337, + "protocol": "tcp" + } + ], + "ttl_expires_at": "2026-05-18T04:02:26Z", + "last_error": null, + "created_at": "2026-05-18T02:02:26Z", + "updated_at": "2026-05-18T02:04:01Z", + "created_by_user_id": 17, + "created_by_username": "alice" +} +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 404 `vm not found` +- 503 `vm feature disabled` or `vm orchestrator unavailable` +- 500 internal error + +Refresh behavior: + +- On successful orchestrator read, VM status and network fields are updated in DB. +- If orchestrator returns a non-availability HTTP error (for example 404, 409), VM row stays, and the message is saved into `last_error`. +- If orchestrator is unavailable (network error, timeout, 502/503/504), request returns error (`vm orchestrator unavailable`) and does not overwrite `last_error`. +- Blocked users can access this endpoint (read-only). + +--- + +## Delete VM For Challenge + +`DELETE /api/challenges/{id}/vm` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 + +```json +{ + "status": "ok", + "ctf_state": "active" +} +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 404 `vm not found` +- 503 `vm feature disabled` or `vm orchestrator unavailable` +- 500 internal error + +Delete behavior: + +- Service requests sandbox deletion in orchestrator first. +- If orchestrator returns `404`, DB row deletion still continues. +- DB row is removed only by explicit delete (or solve-triggered cleanup). + +--- + +## Admin VM List + +`GET /api/admin/vms` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 + +```json +{ + "vms": [ + { + "vm_id": "vm-17-12-6dcb8db3b4d1", + "ttl_expires_at": "2026-05-18T04:02:26Z", + "created_at": "2026-05-18T02:02:26Z", + "updated_at": "2026-05-18T02:04:01Z", + "user_id": 17, + "username": "alice", + "email": "alice@example.com", + "challenge_id": 12, + "challenge_title": "SQLi 101", + "challenge_category": "Web" + } + ] +} +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 403 `insufficient permissions` +- 503 `vm feature disabled` or `vm orchestrator unavailable` +- 500 internal error + +--- + +## Admin VM Detail + +`GET /api/admin/vms/{vm_id}` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 + +- Same JSON schema as `GET /api/challenges/{id}/vm` (`vmResponse`). + +Errors: + +- 400 `invalid input` (`vm_id` missing) +- 401 `invalid token` or `missing access_token cookie` +- 403 `insufficient permissions` +- 404 `vm not found` +- 503 `vm feature disabled` or `vm orchestrator unavailable` +- 500 internal error + +--- + +## Admin Delete VM + +`DELETE /api/admin/vms/{vm_id}` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 + +```json +{ + "deleted": true, + "vm_id": "vm-17-12-6dcb8db3b4d1" +} +``` + +Errors: + +- 400 `invalid input` (`vm_id` missing) +- 401 `invalid token` or `missing access_token cookie` +- 403 `insufficient permissions` +- 404 `vm not found` +- 503 `vm feature disabled` or `vm orchestrator unavailable` +- 500 internal error + +--- + +## Solve-Triggered Cleanup + +When a user submits a correct flag: + +- scoreboard cache/notify flow runs as before. +- vm cleanup (legacy) is attempted. +- VM cleanup is attempted (`DELETE` path in service). +- VM cleanup failure does not revert accepted solve result; it is logged on server side. + +--- + +## Configuration (ENV) + +VM service options: + +- `VMS_ENABLED` (default: `true`) +- `VMS_MAX_SCOPE` (default: `team`, allowed: `team` or `user`) +- `VMS_MAX_PER` (default: `3`) +- `VMS_ORCHESTRATOR_BASE_URL` (default: `http://localhost:8081`) +- `VMS_ORCHESTRATOR_TIMEOUT` (default: `5s`) +- `VMS_CREATE_WINDOW` (default: `1m`) +- `VMS_CREATE_MAX` (default: `1`) + +Validation rules when `VMS_ENABLED=true`: + +- `VMS_MAX_PER > 0` +- `VMS_MAX_SCOPE` must be `team` or `user` +- `VMS_ORCHESTRATOR_BASE_URL` must not be empty +- `VMS_ORCHESTRATOR_TIMEOUT > 0` +- `VM_CREATE_WINDOW > 0` +- `VM_CREATE_MAX > 0` diff --git a/go.mod b/go.mod index b21b290..40e701f 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,7 @@ require ( github.com/uptrace/bun/driver/pgdriver v1.2.16 github.com/uptrace/bun/extra/bundebug v1.2.16 golang.org/x/crypto v0.47.0 - google.golang.org/grpc v1.79.3 - google.golang.org/protobuf v1.36.10 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -121,7 +120,7 @@ require ( go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect @@ -129,7 +128,7 @@ require ( golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect mellium.im/sasl v0.3.2 // indirect ) diff --git a/go.sum b/go.sum index 66a8a79..12bd9b3 100644 --- a/go.sum +++ b/go.sum @@ -118,8 +118,6 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -267,8 +265,6 @@ go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWv go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= @@ -301,8 +297,6 @@ golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= diff --git a/internal/config/config.go b/internal/config/config.go index b509dcd..cce2a31 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,7 +28,7 @@ type Config struct { CORS CORSConfig Logging LoggingConfig S3 S3Config - Stack StackConfig + VM VMConfig Bootstrap BootstrapConfig } @@ -90,15 +90,12 @@ type S3Config struct { PresignTTL time.Duration } -type StackConfig struct { +type VMConfig struct { Enabled bool MaxScope string MaxPer int - ProvisionerBaseURL string - ProvisionerGRPCAddr string - ProvisionerUseGRPC bool - ProvisionerAPIKey string - ProvisionerTimeout time.Duration + OrchestratorBaseURL string + OrchestratorTimeout time.Duration CreateWindow time.Duration CreateMax int } @@ -226,36 +223,29 @@ func Load() (Config, error) { errs = append(errs, err) } - stackEnabled, err := getEnvBool("STACKS_ENABLED", true) + vmEnabled, err := getEnvBool("VMS_ENABLED", true) if err != nil { errs = append(errs, err) } - stackMaxScope := strings.ToLower(strings.TrimSpace(getEnv("STACKS_MAX_SCOPE", "team"))) + vmMaxScope := strings.ToLower(strings.TrimSpace(getEnv("VMS_MAX_SCOPE", "team"))) - stackMaxPer, err := getEnvInt("STACKS_MAX_PER", 3) + vmMaxPer, err := getEnvInt("VMS_MAX_PER", 3) if err != nil { errs = append(errs, err) } - stackTimeout, err := getDuration("STACKS_PROVISIONER_TIMEOUT", 5*time.Second) + vmTimeout, err := getDuration("VMS_ORCHESTRATOR_TIMEOUT", 5*time.Second) if err != nil { errs = append(errs, err) } - stackUseGRPC, err := getEnvBool("STACKS_PROVISIONER_USE_GRPC", false) + vmCreateWindow, err := getDuration("VMS_CREATE_WINDOW", time.Minute) if err != nil { errs = append(errs, err) } - stackGRPCAddr := getEnv("STACKS_PROVISIONER_GRPC_ADDR", "localhost:9090") - - stackCreateWindow, err := getDuration("STACKS_CREATE_WINDOW", time.Minute) - if err != nil { - errs = append(errs, err) - } - - stackCreateMax, err := getEnvInt("STACKS_CREATE_MAX", 1) + vmCreateMax, err := getEnvInt("VMS_CREATE_MAX", 1) if err != nil { errs = append(errs, err) } @@ -327,17 +317,14 @@ func Load() (Config, error) { ForcePathStyle: s3ForcePathStyle, PresignTTL: s3PresignTTL, }, - Stack: StackConfig{ - Enabled: stackEnabled, - MaxScope: stackMaxScope, - MaxPer: stackMaxPer, - ProvisionerBaseURL: getEnv("STACKS_PROVISIONER_BASE_URL", "http://localhost:8081"), - ProvisionerGRPCAddr: stackGRPCAddr, - ProvisionerUseGRPC: stackUseGRPC, - ProvisionerAPIKey: getEnv("STACKS_PROVISIONER_API_KEY", ""), - ProvisionerTimeout: stackTimeout, - CreateWindow: stackCreateWindow, - CreateMax: stackCreateMax, + VM: VMConfig{ + Enabled: vmEnabled, + MaxScope: vmMaxScope, + MaxPer: vmMaxPer, + OrchestratorBaseURL: getEnv("VMS_ORCHESTRATOR_BASE_URL", "http://localhost:8081"), + OrchestratorTimeout: vmTimeout, + CreateWindow: vmCreateWindow, + CreateMax: vmCreateMax, }, Bootstrap: BootstrapConfig{ AdminTeamEnabled: bootstrapAdminTeamEnabled, @@ -492,31 +479,24 @@ func validateConfig(cfg Config) error { } } - if cfg.Stack.Enabled { - if cfg.Stack.MaxPer <= 0 { - errs = append(errs, errors.New("STACKS_MAX_PER must be positive")) - } - if cfg.Stack.MaxScope != "user" && cfg.Stack.MaxScope != "team" { - errs = append(errs, errors.New("STACKS_MAX_SCOPE must be user or team")) + if cfg.VM.Enabled { + if cfg.VM.MaxPer <= 0 { + errs = append(errs, errors.New("VMS_MAX_PER must be positive")) } - if cfg.Stack.ProvisionerUseGRPC { - if cfg.Stack.ProvisionerGRPCAddr == "" { - errs = append(errs, errors.New("STACKS_PROVISIONER_GRPC_ADDR must not be empty when STACKS_PROVISIONER_USE_GRPC=true")) - } - } else if cfg.Stack.ProvisionerBaseURL == "" { - errs = append(errs, errors.New("STACKS_PROVISIONER_BASE_URL must not be empty when STACKS_PROVISIONER_USE_GRPC=false")) + if cfg.VM.MaxScope != "user" && cfg.VM.MaxScope != "team" { + errs = append(errs, errors.New("VMS_MAX_SCOPE must be user or team")) } - if cfg.Stack.ProvisionerTimeout <= 0 { - errs = append(errs, errors.New("STACKS_PROVISIONER_TIMEOUT must be positive")) + if cfg.VM.OrchestratorBaseURL == "" { + errs = append(errs, errors.New("VMS_ORCHESTRATOR_BASE_URL must not be empty")) } - if cfg.Stack.ProvisionerAPIKey == "" { - errs = append(errs, errors.New("STACKS_PROVISIONER_API_KEY must not be empty")) + if cfg.VM.OrchestratorTimeout <= 0 { + errs = append(errs, errors.New("VMS_ORCHESTRATOR_TIMEOUT must be positive")) } - if cfg.Stack.CreateWindow <= 0 { - errs = append(errs, errors.New("STACKS_CREATE_WINDOW must be positive")) + if cfg.VM.CreateWindow <= 0 { + errs = append(errs, errors.New("VMS_CREATE_WINDOW must be positive")) } - if cfg.Stack.CreateMax <= 0 { - errs = append(errs, errors.New("STACKS_CREATE_MAX must be positive")) + if cfg.VM.CreateMax <= 0 { + errs = append(errs, errors.New("VMS_CREATE_MAX must be positive")) } } @@ -533,7 +513,6 @@ func Redact(cfg Config) Config { cfg.JWT.Secret = redact(cfg.JWT.Secret) cfg.S3.AccessKeyID = redact(cfg.S3.AccessKeyID) cfg.S3.SecretAccessKey = redact(cfg.S3.SecretAccessKey) - cfg.Stack.ProvisionerAPIKey = redact(cfg.Stack.ProvisionerAPIKey) cfg.Bootstrap.AdminEmail = redact(cfg.Bootstrap.AdminEmail) cfg.Bootstrap.AdminPassword = redact(cfg.Bootstrap.AdminPassword) @@ -630,17 +609,14 @@ func FormatForLog(cfg Config) map[string]any { "force_path_style": cfg.S3.ForcePathStyle, "presign_ttl": seconds(cfg.S3.PresignTTL), }, - "stack": map[string]any{ - "enabled": cfg.Stack.Enabled, - "max_scope": cfg.Stack.MaxScope, - "max_per": cfg.Stack.MaxPer, - "provisioner_base_url": cfg.Stack.ProvisionerBaseURL, - "provisioner_grpc_addr": cfg.Stack.ProvisionerGRPCAddr, - "provisioner_use_grpc": cfg.Stack.ProvisionerUseGRPC, - "provisioner_api_key": cfg.Stack.ProvisionerAPIKey, - "provisioner_timeout": seconds(cfg.Stack.ProvisionerTimeout), - "create_window": seconds(cfg.Stack.CreateWindow), - "create_max": cfg.Stack.CreateMax, + "vm": map[string]any{ + "enabled": cfg.VM.Enabled, + "max_scope": cfg.VM.MaxScope, + "max_per": cfg.VM.MaxPer, + "orchestrator_base_url": cfg.VM.OrchestratorBaseURL, + "orchestrator_timeout": seconds(cfg.VM.OrchestratorTimeout), + "create_window": seconds(cfg.VM.CreateWindow), + "create_max": cfg.VM.CreateMax, }, "bootstrap": map[string]any{ "admin_team_enabled": cfg.Bootstrap.AdminTeamEnabled, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c5674db..c5587ae 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,7 +11,6 @@ import ( func TestLoadConfigDefaults(t *testing.T) { os.Clearenv() - os.Setenv("STACKS_PROVISIONER_API_KEY", "test-key") defer os.Clearenv() cfg, err := Load() @@ -59,12 +58,12 @@ func TestLoadConfigDefaults(t *testing.T) { t.Errorf("expected S3.Region us-east-1, got %s", cfg.S3.Region) } - if cfg.Stack.CreateWindow != time.Minute { - t.Errorf("expected Stack.CreateWindow 1m, got %v", cfg.Stack.CreateWindow) + if cfg.VM.CreateWindow != time.Minute { + t.Errorf("expected VM.CreateWindow 1m, got %v", cfg.VM.CreateWindow) } - if cfg.Stack.CreateMax != 1 { - t.Errorf("expected Stack.CreateMax 1, got %d", cfg.Stack.CreateMax) + if cfg.VM.CreateMax != 1 { + t.Errorf("expected VM.CreateMax 1, got %d", cfg.VM.CreateMax) } } @@ -97,14 +96,13 @@ func TestLoadConfigCustomValues(t *testing.T) { os.Setenv("S3_ENDPOINT", "https://s3.example.com") os.Setenv("S3_FORCE_PATH_STYLE", "true") os.Setenv("S3_PRESIGN_TTL", "20m") - os.Setenv("STACKS_ENABLED", "true") - os.Setenv("STACKS_MAX_SCOPE", "team") - os.Setenv("STACKS_MAX_PER", "5") - os.Setenv("STACKS_PROVISIONER_BASE_URL", "http://localhost:18081") - os.Setenv("STACKS_PROVISIONER_API_KEY", "custom-key") - os.Setenv("STACKS_PROVISIONER_TIMEOUT", "9s") - os.Setenv("STACKS_CREATE_WINDOW", "2m") - os.Setenv("STACKS_CREATE_MAX", "2") + os.Setenv("VMS_ENABLED", "true") + os.Setenv("VMS_MAX_SCOPE", "team") + os.Setenv("VMS_MAX_PER", "5") + os.Setenv("VMS_ORCHESTRATOR_BASE_URL", "http://localhost:18081") + os.Setenv("VMS_ORCHESTRATOR_TIMEOUT", "9s") + os.Setenv("VMS_CREATE_WINDOW", "2m") + os.Setenv("VMS_CREATE_MAX", "2") defer os.Clearenv() @@ -186,17 +184,17 @@ func TestLoadConfigCustomValues(t *testing.T) { if cfg.Logging.MaxBodyBytes != 2048 { t.Errorf("expected Logging.MaxBodyBytes 2048, got %d", cfg.Logging.MaxBodyBytes) } - if cfg.Stack.CreateWindow != 2*time.Minute { - t.Errorf("expected Stack.CreateWindow 2m, got %v", cfg.Stack.CreateWindow) + if cfg.VM.CreateWindow != 2*time.Minute { + t.Errorf("expected VM.CreateWindow 2m, got %v", cfg.VM.CreateWindow) } - if cfg.Stack.CreateMax != 2 { - t.Errorf("expected Stack.CreateMax 2, got %d", cfg.Stack.CreateMax) + if cfg.VM.CreateMax != 2 { + t.Errorf("expected VM.CreateMax 2, got %d", cfg.VM.CreateMax) } - if cfg.Stack.MaxScope != "team" { - t.Errorf("expected Stack.MaxScope team, got %s", cfg.Stack.MaxScope) + if cfg.VM.MaxScope != "team" { + t.Errorf("expected VM.MaxScope team, got %s", cfg.VM.MaxScope) } - if cfg.Stack.MaxPer != 5 { - t.Errorf("expected Stack.MaxPer 5, got %d", cfg.Stack.MaxPer) + if cfg.VM.MaxPer != 5 { + t.Errorf("expected VM.MaxPer 5, got %d", cfg.VM.MaxPer) } } @@ -217,7 +215,7 @@ func TestLoadConfigInvalidValues(t *testing.T) { {"invalid s3 enabled", "S3_ENABLED", "not-a-bool"}, {"invalid s3 presign ttl", "S3_PRESIGN_TTL", "bad-duration"}, {"invalid s3 force path", "S3_FORCE_PATH_STYLE", "bad-bool"}, - {"invalid stack max scope", "STACKS_MAX_SCOPE", "org"}, + {"invalid vm max scope", "VMS_MAX_SCOPE", "org"}, {"invalid leaderboard cache ttl", "LEADERBOARD_CACHE_TTL", "bad-duration"}, {"invalid app config cache ttl", "APP_CONFIG_CACHE_TTL", "bad-duration"}, } @@ -334,7 +332,6 @@ func TestValidateConfigInvalidS3(t *testing.T) { func TestLoadConfigProductionValidation(t *testing.T) { os.Clearenv() os.Setenv("APP_ENV", "production") - os.Setenv("STACKS_PROVISIONER_API_KEY", "test-key") defer os.Clearenv() _, err := Load() @@ -343,7 +340,6 @@ func TestLoadConfigProductionValidation(t *testing.T) { } os.Setenv("JWT_SECRET", "production-secret-123") - os.Setenv("STACKS_PROVISIONER_API_KEY", "test-key") cfg, err := Load() if err != nil { @@ -588,7 +584,7 @@ func TestValidateConfigInvalidDBConfig(t *testing.T) { } } -func TestValidateConfigInvalidStackConfig(t *testing.T) { +func TestValidateConfigInvalidVMConfig(t *testing.T) { cfg := Config{ AppEnv: "local", HTTPAddr: ":8080", @@ -629,25 +625,24 @@ func TestValidateConfigInvalidStackConfig(t *testing.T) { FilePrefix: "app", MaxBodyBytes: 1024, }, - Stack: StackConfig{ - Enabled: true, - MaxScope: "user", - MaxPer: 0, - ProvisionerBaseURL: "", - ProvisionerAPIKey: "", - ProvisionerTimeout: 0, - CreateWindow: 0, - CreateMax: 0, + VM: VMConfig{ + Enabled: true, + MaxScope: "user", + MaxPer: 0, + OrchestratorBaseURL: "", + OrchestratorTimeout: 0, + CreateWindow: 0, + CreateMax: 0, }, } err := validateConfig(cfg) if err == nil { - t.Fatal("expected stack validation error") + t.Fatal("expected vm validation error") } - if !strings.Contains(err.Error(), "STACKS_MAX_PER") { - t.Fatalf("expected stack error, got %v", err) + if !strings.Contains(err.Error(), "VMS_MAX_PER") { + t.Fatalf("expected vm error, got %v", err) } } @@ -714,9 +709,7 @@ func TestRedact(t *testing.T) { AccessKeyID: "access-key", SecretAccessKey: "secret-key", }, - Stack: StackConfig{ - ProvisionerAPIKey: "stack-key", - }, + VM: VMConfig{}, Bootstrap: BootstrapConfig{ AdminEmail: "admin@example.com", AdminPassword: "adminpass", @@ -745,10 +738,6 @@ func TestRedact(t *testing.T) { t.Fatalf("expected s3 secret key redacted") } - if redacted.Stack.ProvisionerAPIKey == cfg.Stack.ProvisionerAPIKey { - t.Fatalf("expected stack api key redacted") - } - if redacted.Bootstrap.AdminEmail == cfg.Bootstrap.AdminEmail { t.Fatalf("expected bootstrap admin email redacted") } @@ -830,13 +819,12 @@ func TestFormatForLog(t *testing.T) { ForcePathStyle: true, PresignTTL: 10 * time.Minute, }, - Stack: StackConfig{ - Enabled: true, - MaxScope: "user", - MaxPer: 3, - ProvisionerBaseURL: "http://localhost:8081", - ProvisionerAPIKey: "stack-key", - ProvisionerTimeout: 5 * time.Second, + VM: VMConfig{ + Enabled: true, + MaxScope: "user", + MaxPer: 3, + OrchestratorBaseURL: "http://localhost:8081", + OrchestratorTimeout: 5 * time.Second, }, Bootstrap: BootstrapConfig{ AdminTeamEnabled: true, @@ -854,9 +842,7 @@ func TestFormatForLog(t *testing.T) { db := out["db"].(map[string]any) redis := out["redis"].(map[string]any) jwt := out["jwt"].(map[string]any) - stack := out["stack"].(map[string]any) - - if db["password"].(string) == "dbpass" || redis["password"].(string) == "redispass" || jwt["secret"].(string) == "jwtsecret" || stack["provisioner_api_key"].(string) == "stack-key" { + if db["password"].(string) == "dbpass" || redis["password"].(string) == "redispass" || jwt["secret"].(string) == "jwtsecret" { t.Fatalf("expected secrets redacted") } diff --git a/internal/db/db.go b/internal/db/db.go index d7ec492..c170ea4 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -42,7 +42,7 @@ func AutoMigrate(ctx context.Context, db *bun.DB) error { (*models.Team)(nil), (*models.User)(nil), (*models.Challenge)(nil), - (*models.Stack)(nil), + (*models.VM)(nil), (*models.Submission)(nil), (*models.RegistrationKey)(nil), (*models.RegistrationKeyUse)(nil), @@ -103,16 +103,16 @@ func createIndexes(ctx context.Context, db *bun.DB) error { query: "CREATE INDEX IF NOT EXISTS idx_registration_key_uses_key_id ON registration_key_uses (registration_key_id)", }, { - name: "idx_stacks_user_id", - query: "CREATE INDEX IF NOT EXISTS idx_stacks_user_id ON stacks (user_id)", + name: "idx_vms_user_id", + query: "CREATE INDEX IF NOT EXISTS idx_vms_user_id ON vms (user_id)", }, { - name: "idx_stacks_user_challenge", - query: "CREATE UNIQUE INDEX IF NOT EXISTS idx_stacks_user_challenge ON stacks (user_id, challenge_id)", + name: "idx_vms_user_challenge", + query: "CREATE UNIQUE INDEX IF NOT EXISTS idx_vms_user_challenge ON vms (user_id, challenge_id)", }, { - name: "idx_stacks_stack_id", - query: "CREATE UNIQUE INDEX IF NOT EXISTS idx_stacks_stack_id ON stacks (stack_id)", + name: "idx_vms_vm_id", + query: "CREATE UNIQUE INDEX IF NOT EXISTS idx_vms_vm_id ON vms (vm_id)", }, } diff --git a/internal/gen/stack/v1/stack.pb.go b/internal/gen/stack/v1/stack.pb.go deleted file mode 100644 index 63f8f3b..0000000 --- a/internal/gen/stack/v1/stack.pb.go +++ /dev/null @@ -1,1761 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: stack/v1/stack.proto - -package stackv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type Status int32 - -const ( - Status_STATUS_UNSPECIFIED Status = 0 - Status_STATUS_CREATING Status = 1 - Status_STATUS_RUNNING Status = 2 - Status_STATUS_STOPPED Status = 3 - Status_STATUS_FAILED Status = 4 - Status_STATUS_NODE_DELETED Status = 5 -) - -// Enum value maps for Status. -var ( - Status_name = map[int32]string{ - 0: "STATUS_UNSPECIFIED", - 1: "STATUS_CREATING", - 2: "STATUS_RUNNING", - 3: "STATUS_STOPPED", - 4: "STATUS_FAILED", - 5: "STATUS_NODE_DELETED", - } - Status_value = map[string]int32{ - "STATUS_UNSPECIFIED": 0, - "STATUS_CREATING": 1, - "STATUS_RUNNING": 2, - "STATUS_STOPPED": 3, - "STATUS_FAILED": 4, - "STATUS_NODE_DELETED": 5, - } -) - -func (x Status) Enum() *Status { - p := new(Status) - *p = x - return p -} - -func (x Status) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (Status) Descriptor() protoreflect.EnumDescriptor { - return file_stack_v1_stack_proto_enumTypes[0].Descriptor() -} - -func (Status) Type() protoreflect.EnumType { - return &file_stack_v1_stack_proto_enumTypes[0] -} - -func (x Status) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use Status.Descriptor instead. -func (Status) EnumDescriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{0} -} - -type JobStatus int32 - -const ( - JobStatus_JOB_STATUS_UNSPECIFIED JobStatus = 0 - JobStatus_JOB_STATUS_QUEUED JobStatus = 1 - JobStatus_JOB_STATUS_RUNNING JobStatus = 2 - JobStatus_JOB_STATUS_COMPLETED JobStatus = 3 - JobStatus_JOB_STATUS_FAILED JobStatus = 4 -) - -// Enum value maps for JobStatus. -var ( - JobStatus_name = map[int32]string{ - 0: "JOB_STATUS_UNSPECIFIED", - 1: "JOB_STATUS_QUEUED", - 2: "JOB_STATUS_RUNNING", - 3: "JOB_STATUS_COMPLETED", - 4: "JOB_STATUS_FAILED", - } - JobStatus_value = map[string]int32{ - "JOB_STATUS_UNSPECIFIED": 0, - "JOB_STATUS_QUEUED": 1, - "JOB_STATUS_RUNNING": 2, - "JOB_STATUS_COMPLETED": 3, - "JOB_STATUS_FAILED": 4, - } -) - -func (x JobStatus) Enum() *JobStatus { - p := new(JobStatus) - *p = x - return p -} - -func (x JobStatus) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (JobStatus) Descriptor() protoreflect.EnumDescriptor { - return file_stack_v1_stack_proto_enumTypes[1].Descriptor() -} - -func (JobStatus) Type() protoreflect.EnumType { - return &file_stack_v1_stack_proto_enumTypes[1] -} - -func (x JobStatus) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use JobStatus.Descriptor instead. -func (JobStatus) EnumDescriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{1} -} - -type HealthzRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthzRequest) Reset() { - *x = HealthzRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthzRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthzRequest) ProtoMessage() {} - -func (x *HealthzRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthzRequest.ProtoReflect.Descriptor instead. -func (*HealthzRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{0} -} - -type HealthzResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthzResponse) Reset() { - *x = HealthzResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthzResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthzResponse) ProtoMessage() {} - -func (x *HealthzResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthzResponse.ProtoReflect.Descriptor instead. -func (*HealthzResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{1} -} - -func (x *HealthzResponse) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -type CreateStackRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - PodSpec string `protobuf:"bytes,1,opt,name=pod_spec,json=podSpec,proto3" json:"pod_spec,omitempty"` - TargetPorts []*PortSpec `protobuf:"bytes,2,rep,name=target_ports,json=targetPorts,proto3" json:"target_ports,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateStackRequest) Reset() { - *x = CreateStackRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateStackRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateStackRequest) ProtoMessage() {} - -func (x *CreateStackRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateStackRequest.ProtoReflect.Descriptor instead. -func (*CreateStackRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{2} -} - -func (x *CreateStackRequest) GetPodSpec() string { - if x != nil { - return x.PodSpec - } - return "" -} - -func (x *CreateStackRequest) GetTargetPorts() []*PortSpec { - if x != nil { - return x.TargetPorts - } - return nil -} - -type CreateStackResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Stack *Stack `protobuf:"bytes,1,opt,name=stack,proto3" json:"stack,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateStackResponse) Reset() { - *x = CreateStackResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateStackResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateStackResponse) ProtoMessage() {} - -func (x *CreateStackResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateStackResponse.ProtoReflect.Descriptor instead. -func (*CreateStackResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{3} -} - -func (x *CreateStackResponse) GetStack() *Stack { - if x != nil { - return x.Stack - } - return nil -} - -type GetStackRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - StackId string `protobuf:"bytes,1,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetStackRequest) Reset() { - *x = GetStackRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetStackRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetStackRequest) ProtoMessage() {} - -func (x *GetStackRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetStackRequest.ProtoReflect.Descriptor instead. -func (*GetStackRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{4} -} - -func (x *GetStackRequest) GetStackId() string { - if x != nil { - return x.StackId - } - return "" -} - -type GetStackResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Stack *Stack `protobuf:"bytes,1,opt,name=stack,proto3" json:"stack,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetStackResponse) Reset() { - *x = GetStackResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetStackResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetStackResponse) ProtoMessage() {} - -func (x *GetStackResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetStackResponse.ProtoReflect.Descriptor instead. -func (*GetStackResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{5} -} - -func (x *GetStackResponse) GetStack() *Stack { - if x != nil { - return x.Stack - } - return nil -} - -type GetStackStatusSummaryRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - StackId string `protobuf:"bytes,1,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetStackStatusSummaryRequest) Reset() { - *x = GetStackStatusSummaryRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetStackStatusSummaryRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetStackStatusSummaryRequest) ProtoMessage() {} - -func (x *GetStackStatusSummaryRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetStackStatusSummaryRequest.ProtoReflect.Descriptor instead. -func (*GetStackStatusSummaryRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{6} -} - -func (x *GetStackStatusSummaryRequest) GetStackId() string { - if x != nil { - return x.StackId - } - return "" -} - -type GetStackStatusSummaryResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Summary *StackStatusSummary `protobuf:"bytes,1,opt,name=summary,proto3" json:"summary,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetStackStatusSummaryResponse) Reset() { - *x = GetStackStatusSummaryResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetStackStatusSummaryResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetStackStatusSummaryResponse) ProtoMessage() {} - -func (x *GetStackStatusSummaryResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetStackStatusSummaryResponse.ProtoReflect.Descriptor instead. -func (*GetStackStatusSummaryResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{7} -} - -func (x *GetStackStatusSummaryResponse) GetSummary() *StackStatusSummary { - if x != nil { - return x.Summary - } - return nil -} - -type DeleteStackRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - StackId string `protobuf:"bytes,1,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteStackRequest) Reset() { - *x = DeleteStackRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteStackRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteStackRequest) ProtoMessage() {} - -func (x *DeleteStackRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteStackRequest.ProtoReflect.Descriptor instead. -func (*DeleteStackRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{8} -} - -func (x *DeleteStackRequest) GetStackId() string { - if x != nil { - return x.StackId - } - return "" -} - -type DeleteStackResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` - StackId string `protobuf:"bytes,2,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteStackResponse) Reset() { - *x = DeleteStackResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteStackResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteStackResponse) ProtoMessage() {} - -func (x *DeleteStackResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteStackResponse.ProtoReflect.Descriptor instead. -func (*DeleteStackResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{9} -} - -func (x *DeleteStackResponse) GetDeleted() bool { - if x != nil { - return x.Deleted - } - return false -} - -func (x *DeleteStackResponse) GetStackId() string { - if x != nil { - return x.StackId - } - return "" -} - -type ListStacksRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListStacksRequest) Reset() { - *x = ListStacksRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListStacksRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListStacksRequest) ProtoMessage() {} - -func (x *ListStacksRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListStacksRequest.ProtoReflect.Descriptor instead. -func (*ListStacksRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{10} -} - -type ListStacksResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Stacks []*Stack `protobuf:"bytes,1,rep,name=stacks,proto3" json:"stacks,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListStacksResponse) Reset() { - *x = ListStacksResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListStacksResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListStacksResponse) ProtoMessage() {} - -func (x *ListStacksResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListStacksResponse.ProtoReflect.Descriptor instead. -func (*ListStacksResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{11} -} - -func (x *ListStacksResponse) GetStacks() []*Stack { - if x != nil { - return x.Stacks - } - return nil -} - -type CreateBatchDeleteJobRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - StackIds []string `protobuf:"bytes,1,rep,name=stack_ids,json=stackIds,proto3" json:"stack_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateBatchDeleteJobRequest) Reset() { - *x = CreateBatchDeleteJobRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateBatchDeleteJobRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateBatchDeleteJobRequest) ProtoMessage() {} - -func (x *CreateBatchDeleteJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateBatchDeleteJobRequest.ProtoReflect.Descriptor instead. -func (*CreateBatchDeleteJobRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{12} -} - -func (x *CreateBatchDeleteJobRequest) GetStackIds() []string { - if x != nil { - return x.StackIds - } - return nil -} - -type CreateBatchDeleteJobResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateBatchDeleteJobResponse) Reset() { - *x = CreateBatchDeleteJobResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateBatchDeleteJobResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateBatchDeleteJobResponse) ProtoMessage() {} - -func (x *CreateBatchDeleteJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateBatchDeleteJobResponse.ProtoReflect.Descriptor instead. -func (*CreateBatchDeleteJobResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{13} -} - -func (x *CreateBatchDeleteJobResponse) GetJobId() string { - if x != nil { - return x.JobId - } - return "" -} - -type GetBatchDeleteJobRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetBatchDeleteJobRequest) Reset() { - *x = GetBatchDeleteJobRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetBatchDeleteJobRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetBatchDeleteJobRequest) ProtoMessage() {} - -func (x *GetBatchDeleteJobRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetBatchDeleteJobRequest.ProtoReflect.Descriptor instead. -func (*GetBatchDeleteJobRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{14} -} - -func (x *GetBatchDeleteJobRequest) GetJobId() string { - if x != nil { - return x.JobId - } - return "" -} - -type GetBatchDeleteJobResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Job *BatchDeleteJob `protobuf:"bytes,1,opt,name=job,proto3" json:"job,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetBatchDeleteJobResponse) Reset() { - *x = GetBatchDeleteJobResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetBatchDeleteJobResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetBatchDeleteJobResponse) ProtoMessage() {} - -func (x *GetBatchDeleteJobResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetBatchDeleteJobResponse.ProtoReflect.Descriptor instead. -func (*GetBatchDeleteJobResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{15} -} - -func (x *GetBatchDeleteJobResponse) GetJob() *BatchDeleteJob { - if x != nil { - return x.Job - } - return nil -} - -type GetStatsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetStatsRequest) Reset() { - *x = GetStatsRequest{} - mi := &file_stack_v1_stack_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetStatsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetStatsRequest) ProtoMessage() {} - -func (x *GetStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetStatsRequest.ProtoReflect.Descriptor instead. -func (*GetStatsRequest) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{16} -} - -type GetStatsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Stats *Stats `protobuf:"bytes,1,opt,name=stats,proto3" json:"stats,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetStatsResponse) Reset() { - *x = GetStatsResponse{} - mi := &file_stack_v1_stack_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetStatsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetStatsResponse) ProtoMessage() {} - -func (x *GetStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[17] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetStatsResponse.ProtoReflect.Descriptor instead. -func (*GetStatsResponse) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{17} -} - -func (x *GetStatsResponse) GetStats() *Stats { - if x != nil { - return x.Stats - } - return nil -} - -type Stats struct { - state protoimpl.MessageState `protogen:"open.v1"` - TotalStacks int32 `protobuf:"varint,1,opt,name=total_stacks,json=totalStacks,proto3" json:"total_stacks,omitempty"` - ActiveStacks int32 `protobuf:"varint,2,opt,name=active_stacks,json=activeStacks,proto3" json:"active_stacks,omitempty"` - NodeDistribution map[string]int32 `protobuf:"bytes,3,rep,name=node_distribution,json=nodeDistribution,proto3" json:"node_distribution,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` - UsedNodePorts int32 `protobuf:"varint,4,opt,name=used_node_ports,json=usedNodePorts,proto3" json:"used_node_ports,omitempty"` - ReservedCpuMilli int64 `protobuf:"varint,5,opt,name=reserved_cpu_milli,json=reservedCpuMilli,proto3" json:"reserved_cpu_milli,omitempty"` - ReservedMemoryBytes int64 `protobuf:"varint,6,opt,name=reserved_memory_bytes,json=reservedMemoryBytes,proto3" json:"reserved_memory_bytes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Stats) Reset() { - *x = Stats{} - mi := &file_stack_v1_stack_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Stats) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Stats) ProtoMessage() {} - -func (x *Stats) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Stats.ProtoReflect.Descriptor instead. -func (*Stats) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{18} -} - -func (x *Stats) GetTotalStacks() int32 { - if x != nil { - return x.TotalStacks - } - return 0 -} - -func (x *Stats) GetActiveStacks() int32 { - if x != nil { - return x.ActiveStacks - } - return 0 -} - -func (x *Stats) GetNodeDistribution() map[string]int32 { - if x != nil { - return x.NodeDistribution - } - return nil -} - -func (x *Stats) GetUsedNodePorts() int32 { - if x != nil { - return x.UsedNodePorts - } - return 0 -} - -func (x *Stats) GetReservedCpuMilli() int64 { - if x != nil { - return x.ReservedCpuMilli - } - return 0 -} - -func (x *Stats) GetReservedMemoryBytes() int64 { - if x != nil { - return x.ReservedMemoryBytes - } - return 0 -} - -type Stack struct { - state protoimpl.MessageState `protogen:"open.v1"` - StackId string `protobuf:"bytes,1,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` - PodId string `protobuf:"bytes,2,opt,name=pod_id,json=podId,proto3" json:"pod_id,omitempty"` - Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"` - NodeId string `protobuf:"bytes,4,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - NodePublicIp *string `protobuf:"bytes,5,opt,name=node_public_ip,json=nodePublicIp,proto3,oneof" json:"node_public_ip,omitempty"` - PodSpec string `protobuf:"bytes,6,opt,name=pod_spec,json=podSpec,proto3" json:"pod_spec,omitempty"` - Ports []*PortMapping `protobuf:"bytes,7,rep,name=ports,proto3" json:"ports,omitempty"` - ServiceName string `protobuf:"bytes,8,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - Status Status `protobuf:"varint,9,opt,name=status,proto3,enum=stack.v1.Status" json:"status,omitempty"` - TtlExpiresAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=ttl_expires_at,json=ttlExpiresAt,proto3" json:"ttl_expires_at,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - RequestedCpuMilli int64 `protobuf:"varint,13,opt,name=requested_cpu_milli,json=requestedCpuMilli,proto3" json:"requested_cpu_milli,omitempty"` - RequestedMemoryBytes int64 `protobuf:"varint,14,opt,name=requested_memory_bytes,json=requestedMemoryBytes,proto3" json:"requested_memory_bytes,omitempty"` - TargetPorts []*PortSpec `protobuf:"bytes,15,rep,name=target_ports,json=targetPorts,proto3" json:"target_ports,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Stack) Reset() { - *x = Stack{} - mi := &file_stack_v1_stack_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Stack) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Stack) ProtoMessage() {} - -func (x *Stack) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Stack.ProtoReflect.Descriptor instead. -func (*Stack) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{19} -} - -func (x *Stack) GetStackId() string { - if x != nil { - return x.StackId - } - return "" -} - -func (x *Stack) GetPodId() string { - if x != nil { - return x.PodId - } - return "" -} - -func (x *Stack) GetNamespace() string { - if x != nil { - return x.Namespace - } - return "" -} - -func (x *Stack) GetNodeId() string { - if x != nil { - return x.NodeId - } - return "" -} - -func (x *Stack) GetNodePublicIp() string { - if x != nil && x.NodePublicIp != nil { - return *x.NodePublicIp - } - return "" -} - -func (x *Stack) GetPodSpec() string { - if x != nil { - return x.PodSpec - } - return "" -} - -func (x *Stack) GetPorts() []*PortMapping { - if x != nil { - return x.Ports - } - return nil -} - -func (x *Stack) GetServiceName() string { - if x != nil { - return x.ServiceName - } - return "" -} - -func (x *Stack) GetStatus() Status { - if x != nil { - return x.Status - } - return Status_STATUS_UNSPECIFIED -} - -func (x *Stack) GetTtlExpiresAt() *timestamppb.Timestamp { - if x != nil { - return x.TtlExpiresAt - } - return nil -} - -func (x *Stack) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *Stack) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -func (x *Stack) GetRequestedCpuMilli() int64 { - if x != nil { - return x.RequestedCpuMilli - } - return 0 -} - -func (x *Stack) GetRequestedMemoryBytes() int64 { - if x != nil { - return x.RequestedMemoryBytes - } - return 0 -} - -func (x *Stack) GetTargetPorts() []*PortSpec { - if x != nil { - return x.TargetPorts - } - return nil -} - -type StackStatusSummary struct { - state protoimpl.MessageState `protogen:"open.v1"` - StackId string `protobuf:"bytes,1,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` - Status Status `protobuf:"varint,2,opt,name=status,proto3,enum=stack.v1.Status" json:"status,omitempty"` - Ttl *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=ttl,proto3" json:"ttl,omitempty"` - Ports []*PortMapping `protobuf:"bytes,4,rep,name=ports,proto3" json:"ports,omitempty"` - NodePublicIp *string `protobuf:"bytes,5,opt,name=node_public_ip,json=nodePublicIp,proto3,oneof" json:"node_public_ip,omitempty"` - TargetPorts []*PortSpec `protobuf:"bytes,6,rep,name=target_ports,json=targetPorts,proto3" json:"target_ports,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StackStatusSummary) Reset() { - *x = StackStatusSummary{} - mi := &file_stack_v1_stack_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StackStatusSummary) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StackStatusSummary) ProtoMessage() {} - -func (x *StackStatusSummary) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[20] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StackStatusSummary.ProtoReflect.Descriptor instead. -func (*StackStatusSummary) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{20} -} - -func (x *StackStatusSummary) GetStackId() string { - if x != nil { - return x.StackId - } - return "" -} - -func (x *StackStatusSummary) GetStatus() Status { - if x != nil { - return x.Status - } - return Status_STATUS_UNSPECIFIED -} - -func (x *StackStatusSummary) GetTtl() *timestamppb.Timestamp { - if x != nil { - return x.Ttl - } - return nil -} - -func (x *StackStatusSummary) GetPorts() []*PortMapping { - if x != nil { - return x.Ports - } - return nil -} - -func (x *StackStatusSummary) GetNodePublicIp() string { - if x != nil && x.NodePublicIp != nil { - return *x.NodePublicIp - } - return "" -} - -func (x *StackStatusSummary) GetTargetPorts() []*PortSpec { - if x != nil { - return x.TargetPorts - } - return nil -} - -type PortSpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - ContainerPort int32 `protobuf:"varint,1,opt,name=container_port,json=containerPort,proto3" json:"container_port,omitempty"` - Protocol string `protobuf:"bytes,2,opt,name=protocol,proto3" json:"protocol,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PortSpec) Reset() { - *x = PortSpec{} - mi := &file_stack_v1_stack_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PortSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PortSpec) ProtoMessage() {} - -func (x *PortSpec) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[21] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PortSpec.ProtoReflect.Descriptor instead. -func (*PortSpec) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{21} -} - -func (x *PortSpec) GetContainerPort() int32 { - if x != nil { - return x.ContainerPort - } - return 0 -} - -func (x *PortSpec) GetProtocol() string { - if x != nil { - return x.Protocol - } - return "" -} - -type PortMapping struct { - state protoimpl.MessageState `protogen:"open.v1"` - ContainerPort int32 `protobuf:"varint,1,opt,name=container_port,json=containerPort,proto3" json:"container_port,omitempty"` - Protocol string `protobuf:"bytes,2,opt,name=protocol,proto3" json:"protocol,omitempty"` - NodePort int32 `protobuf:"varint,3,opt,name=node_port,json=nodePort,proto3" json:"node_port,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PortMapping) Reset() { - *x = PortMapping{} - mi := &file_stack_v1_stack_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PortMapping) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PortMapping) ProtoMessage() {} - -func (x *PortMapping) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[22] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PortMapping.ProtoReflect.Descriptor instead. -func (*PortMapping) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{22} -} - -func (x *PortMapping) GetContainerPort() int32 { - if x != nil { - return x.ContainerPort - } - return 0 -} - -func (x *PortMapping) GetProtocol() string { - if x != nil { - return x.Protocol - } - return "" -} - -func (x *PortMapping) GetNodePort() int32 { - if x != nil { - return x.NodePort - } - return 0 -} - -type BatchDeleteJob struct { - state protoimpl.MessageState `protogen:"open.v1"` - JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` - Status JobStatus `protobuf:"varint,2,opt,name=status,proto3,enum=stack.v1.JobStatus" json:"status,omitempty"` - Total int32 `protobuf:"varint,3,opt,name=total,proto3" json:"total,omitempty"` - Deleted int32 `protobuf:"varint,4,opt,name=deleted,proto3" json:"deleted,omitempty"` - NotFound int32 `protobuf:"varint,5,opt,name=not_found,json=notFound,proto3" json:"not_found,omitempty"` - Failed int32 `protobuf:"varint,6,opt,name=failed,proto3" json:"failed,omitempty"` - Errors []*JobError `protobuf:"bytes,7,rep,name=errors,proto3" json:"errors,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BatchDeleteJob) Reset() { - *x = BatchDeleteJob{} - mi := &file_stack_v1_stack_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BatchDeleteJob) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BatchDeleteJob) ProtoMessage() {} - -func (x *BatchDeleteJob) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[23] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BatchDeleteJob.ProtoReflect.Descriptor instead. -func (*BatchDeleteJob) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{23} -} - -func (x *BatchDeleteJob) GetJobId() string { - if x != nil { - return x.JobId - } - return "" -} - -func (x *BatchDeleteJob) GetStatus() JobStatus { - if x != nil { - return x.Status - } - return JobStatus_JOB_STATUS_UNSPECIFIED -} - -func (x *BatchDeleteJob) GetTotal() int32 { - if x != nil { - return x.Total - } - return 0 -} - -func (x *BatchDeleteJob) GetDeleted() int32 { - if x != nil { - return x.Deleted - } - return 0 -} - -func (x *BatchDeleteJob) GetNotFound() int32 { - if x != nil { - return x.NotFound - } - return 0 -} - -func (x *BatchDeleteJob) GetFailed() int32 { - if x != nil { - return x.Failed - } - return 0 -} - -func (x *BatchDeleteJob) GetErrors() []*JobError { - if x != nil { - return x.Errors - } - return nil -} - -func (x *BatchDeleteJob) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *BatchDeleteJob) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -type JobError struct { - state protoimpl.MessageState `protogen:"open.v1"` - StackId string `protobuf:"bytes,1,opt,name=stack_id,json=stackId,proto3" json:"stack_id,omitempty"` - Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *JobError) Reset() { - *x = JobError{} - mi := &file_stack_v1_stack_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *JobError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*JobError) ProtoMessage() {} - -func (x *JobError) ProtoReflect() protoreflect.Message { - mi := &file_stack_v1_stack_proto_msgTypes[24] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use JobError.ProtoReflect.Descriptor instead. -func (*JobError) Descriptor() ([]byte, []int) { - return file_stack_v1_stack_proto_rawDescGZIP(), []int{24} -} - -func (x *JobError) GetStackId() string { - if x != nil { - return x.StackId - } - return "" -} - -func (x *JobError) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -var File_stack_v1_stack_proto protoreflect.FileDescriptor - -const file_stack_v1_stack_proto_rawDesc = "" + - "\n" + - "\x14stack/v1/stack.proto\x12\bstack.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x10\n" + - "\x0eHealthzRequest\")\n" + - "\x0fHealthzResponse\x12\x16\n" + - "\x06status\x18\x01 \x01(\tR\x06status\"f\n" + - "\x12CreateStackRequest\x12\x19\n" + - "\bpod_spec\x18\x01 \x01(\tR\apodSpec\x125\n" + - "\ftarget_ports\x18\x02 \x03(\v2\x12.stack.v1.PortSpecR\vtargetPorts\"<\n" + - "\x13CreateStackResponse\x12%\n" + - "\x05stack\x18\x01 \x01(\v2\x0f.stack.v1.StackR\x05stack\",\n" + - "\x0fGetStackRequest\x12\x19\n" + - "\bstack_id\x18\x01 \x01(\tR\astackId\"9\n" + - "\x10GetStackResponse\x12%\n" + - "\x05stack\x18\x01 \x01(\v2\x0f.stack.v1.StackR\x05stack\"9\n" + - "\x1cGetStackStatusSummaryRequest\x12\x19\n" + - "\bstack_id\x18\x01 \x01(\tR\astackId\"W\n" + - "\x1dGetStackStatusSummaryResponse\x126\n" + - "\asummary\x18\x01 \x01(\v2\x1c.stack.v1.StackStatusSummaryR\asummary\"/\n" + - "\x12DeleteStackRequest\x12\x19\n" + - "\bstack_id\x18\x01 \x01(\tR\astackId\"J\n" + - "\x13DeleteStackResponse\x12\x18\n" + - "\adeleted\x18\x01 \x01(\bR\adeleted\x12\x19\n" + - "\bstack_id\x18\x02 \x01(\tR\astackId\"\x13\n" + - "\x11ListStacksRequest\"=\n" + - "\x12ListStacksResponse\x12'\n" + - "\x06stacks\x18\x01 \x03(\v2\x0f.stack.v1.StackR\x06stacks\":\n" + - "\x1bCreateBatchDeleteJobRequest\x12\x1b\n" + - "\tstack_ids\x18\x01 \x03(\tR\bstackIds\"5\n" + - "\x1cCreateBatchDeleteJobResponse\x12\x15\n" + - "\x06job_id\x18\x01 \x01(\tR\x05jobId\"1\n" + - "\x18GetBatchDeleteJobRequest\x12\x15\n" + - "\x06job_id\x18\x01 \x01(\tR\x05jobId\"G\n" + - "\x19GetBatchDeleteJobResponse\x12*\n" + - "\x03job\x18\x01 \x01(\v2\x18.stack.v1.BatchDeleteJobR\x03job\"\x11\n" + - "\x0fGetStatsRequest\"9\n" + - "\x10GetStatsResponse\x12%\n" + - "\x05stats\x18\x01 \x01(\v2\x0f.stack.v1.StatsR\x05stats\"\xf2\x02\n" + - "\x05Stats\x12!\n" + - "\ftotal_stacks\x18\x01 \x01(\x05R\vtotalStacks\x12#\n" + - "\ractive_stacks\x18\x02 \x01(\x05R\factiveStacks\x12R\n" + - "\x11node_distribution\x18\x03 \x03(\v2%.stack.v1.Stats.NodeDistributionEntryR\x10nodeDistribution\x12&\n" + - "\x0fused_node_ports\x18\x04 \x01(\x05R\rusedNodePorts\x12,\n" + - "\x12reserved_cpu_milli\x18\x05 \x01(\x03R\x10reservedCpuMilli\x122\n" + - "\x15reserved_memory_bytes\x18\x06 \x01(\x03R\x13reservedMemoryBytes\x1aC\n" + - "\x15NodeDistributionEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\"\x98\x05\n" + - "\x05Stack\x12\x19\n" + - "\bstack_id\x18\x01 \x01(\tR\astackId\x12\x15\n" + - "\x06pod_id\x18\x02 \x01(\tR\x05podId\x12\x1c\n" + - "\tnamespace\x18\x03 \x01(\tR\tnamespace\x12\x17\n" + - "\anode_id\x18\x04 \x01(\tR\x06nodeId\x12)\n" + - "\x0enode_public_ip\x18\x05 \x01(\tH\x00R\fnodePublicIp\x88\x01\x01\x12\x19\n" + - "\bpod_spec\x18\x06 \x01(\tR\apodSpec\x12+\n" + - "\x05ports\x18\a \x03(\v2\x15.stack.v1.PortMappingR\x05ports\x12!\n" + - "\fservice_name\x18\b \x01(\tR\vserviceName\x12(\n" + - "\x06status\x18\t \x01(\x0e2\x10.stack.v1.StatusR\x06status\x12@\n" + - "\x0ettl_expires_at\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\fttlExpiresAt\x129\n" + - "\n" + - "created_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + - "\n" + - "updated_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12.\n" + - "\x13requested_cpu_milli\x18\r \x01(\x03R\x11requestedCpuMilli\x124\n" + - "\x16requested_memory_bytes\x18\x0e \x01(\x03R\x14requestedMemoryBytes\x125\n" + - "\ftarget_ports\x18\x0f \x03(\v2\x12.stack.v1.PortSpecR\vtargetPortsB\x11\n" + - "\x0f_node_public_ip\"\xa9\x02\n" + - "\x12StackStatusSummary\x12\x19\n" + - "\bstack_id\x18\x01 \x01(\tR\astackId\x12(\n" + - "\x06status\x18\x02 \x01(\x0e2\x10.stack.v1.StatusR\x06status\x12,\n" + - "\x03ttl\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x03ttl\x12+\n" + - "\x05ports\x18\x04 \x03(\v2\x15.stack.v1.PortMappingR\x05ports\x12)\n" + - "\x0enode_public_ip\x18\x05 \x01(\tH\x00R\fnodePublicIp\x88\x01\x01\x125\n" + - "\ftarget_ports\x18\x06 \x03(\v2\x12.stack.v1.PortSpecR\vtargetPortsB\x11\n" + - "\x0f_node_public_ip\"M\n" + - "\bPortSpec\x12%\n" + - "\x0econtainer_port\x18\x01 \x01(\x05R\rcontainerPort\x12\x1a\n" + - "\bprotocol\x18\x02 \x01(\tR\bprotocol\"m\n" + - "\vPortMapping\x12%\n" + - "\x0econtainer_port\x18\x01 \x01(\x05R\rcontainerPort\x12\x1a\n" + - "\bprotocol\x18\x02 \x01(\tR\bprotocol\x12\x1b\n" + - "\tnode_port\x18\x03 \x01(\x05R\bnodePort\"\xdb\x02\n" + - "\x0eBatchDeleteJob\x12\x15\n" + - "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12+\n" + - "\x06status\x18\x02 \x01(\x0e2\x13.stack.v1.JobStatusR\x06status\x12\x14\n" + - "\x05total\x18\x03 \x01(\x05R\x05total\x12\x18\n" + - "\adeleted\x18\x04 \x01(\x05R\adeleted\x12\x1b\n" + - "\tnot_found\x18\x05 \x01(\x05R\bnotFound\x12\x16\n" + - "\x06failed\x18\x06 \x01(\x05R\x06failed\x12*\n" + - "\x06errors\x18\a \x03(\v2\x12.stack.v1.JobErrorR\x06errors\x129\n" + - "\n" + - "created_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + - "\n" + - "updated_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\";\n" + - "\bJobError\x12\x19\n" + - "\bstack_id\x18\x01 \x01(\tR\astackId\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error*\x89\x01\n" + - "\x06Status\x12\x16\n" + - "\x12STATUS_UNSPECIFIED\x10\x00\x12\x13\n" + - "\x0fSTATUS_CREATING\x10\x01\x12\x12\n" + - "\x0eSTATUS_RUNNING\x10\x02\x12\x12\n" + - "\x0eSTATUS_STOPPED\x10\x03\x12\x11\n" + - "\rSTATUS_FAILED\x10\x04\x12\x17\n" + - "\x13STATUS_NODE_DELETED\x10\x05*\x87\x01\n" + - "\tJobStatus\x12\x1a\n" + - "\x16JOB_STATUS_UNSPECIFIED\x10\x00\x12\x15\n" + - "\x11JOB_STATUS_QUEUED\x10\x01\x12\x16\n" + - "\x12JOB_STATUS_RUNNING\x10\x02\x12\x18\n" + - "\x14JOB_STATUS_COMPLETED\x10\x03\x12\x15\n" + - "\x11JOB_STATUS_FAILED\x10\x042\xe4\x05\n" + - "\fStackService\x12>\n" + - "\aHealthz\x12\x18.stack.v1.HealthzRequest\x1a\x19.stack.v1.HealthzResponse\x12J\n" + - "\vCreateStack\x12\x1c.stack.v1.CreateStackRequest\x1a\x1d.stack.v1.CreateStackResponse\x12A\n" + - "\bGetStack\x12\x19.stack.v1.GetStackRequest\x1a\x1a.stack.v1.GetStackResponse\x12h\n" + - "\x15GetStackStatusSummary\x12&.stack.v1.GetStackStatusSummaryRequest\x1a'.stack.v1.GetStackStatusSummaryResponse\x12J\n" + - "\vDeleteStack\x12\x1c.stack.v1.DeleteStackRequest\x1a\x1d.stack.v1.DeleteStackResponse\x12G\n" + - "\n" + - "ListStacks\x12\x1b.stack.v1.ListStacksRequest\x1a\x1c.stack.v1.ListStacksResponse\x12e\n" + - "\x14CreateBatchDeleteJob\x12%.stack.v1.CreateBatchDeleteJobRequest\x1a&.stack.v1.CreateBatchDeleteJobResponse\x12\\\n" + - "\x11GetBatchDeleteJob\x12\".stack.v1.GetBatchDeleteJobRequest\x1a#.stack.v1.GetBatchDeleteJobResponse\x12A\n" + - "\bGetStats\x12\x19.stack.v1.GetStatsRequest\x1a\x1a.stack.v1.GetStatsResponseB%Z#smctf/internal/gen/stack/v1;stackv1b\x06proto3" - -var ( - file_stack_v1_stack_proto_rawDescOnce sync.Once - file_stack_v1_stack_proto_rawDescData []byte -) - -func file_stack_v1_stack_proto_rawDescGZIP() []byte { - file_stack_v1_stack_proto_rawDescOnce.Do(func() { - file_stack_v1_stack_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stack_v1_stack_proto_rawDesc), len(file_stack_v1_stack_proto_rawDesc))) - }) - return file_stack_v1_stack_proto_rawDescData -} - -var file_stack_v1_stack_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_stack_v1_stack_proto_msgTypes = make([]protoimpl.MessageInfo, 26) -var file_stack_v1_stack_proto_goTypes = []any{ - (Status)(0), // 0: stack.v1.Status - (JobStatus)(0), // 1: stack.v1.JobStatus - (*HealthzRequest)(nil), // 2: stack.v1.HealthzRequest - (*HealthzResponse)(nil), // 3: stack.v1.HealthzResponse - (*CreateStackRequest)(nil), // 4: stack.v1.CreateStackRequest - (*CreateStackResponse)(nil), // 5: stack.v1.CreateStackResponse - (*GetStackRequest)(nil), // 6: stack.v1.GetStackRequest - (*GetStackResponse)(nil), // 7: stack.v1.GetStackResponse - (*GetStackStatusSummaryRequest)(nil), // 8: stack.v1.GetStackStatusSummaryRequest - (*GetStackStatusSummaryResponse)(nil), // 9: stack.v1.GetStackStatusSummaryResponse - (*DeleteStackRequest)(nil), // 10: stack.v1.DeleteStackRequest - (*DeleteStackResponse)(nil), // 11: stack.v1.DeleteStackResponse - (*ListStacksRequest)(nil), // 12: stack.v1.ListStacksRequest - (*ListStacksResponse)(nil), // 13: stack.v1.ListStacksResponse - (*CreateBatchDeleteJobRequest)(nil), // 14: stack.v1.CreateBatchDeleteJobRequest - (*CreateBatchDeleteJobResponse)(nil), // 15: stack.v1.CreateBatchDeleteJobResponse - (*GetBatchDeleteJobRequest)(nil), // 16: stack.v1.GetBatchDeleteJobRequest - (*GetBatchDeleteJobResponse)(nil), // 17: stack.v1.GetBatchDeleteJobResponse - (*GetStatsRequest)(nil), // 18: stack.v1.GetStatsRequest - (*GetStatsResponse)(nil), // 19: stack.v1.GetStatsResponse - (*Stats)(nil), // 20: stack.v1.Stats - (*Stack)(nil), // 21: stack.v1.Stack - (*StackStatusSummary)(nil), // 22: stack.v1.StackStatusSummary - (*PortSpec)(nil), // 23: stack.v1.PortSpec - (*PortMapping)(nil), // 24: stack.v1.PortMapping - (*BatchDeleteJob)(nil), // 25: stack.v1.BatchDeleteJob - (*JobError)(nil), // 26: stack.v1.JobError - nil, // 27: stack.v1.Stats.NodeDistributionEntry - (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp -} -var file_stack_v1_stack_proto_depIdxs = []int32{ - 23, // 0: stack.v1.CreateStackRequest.target_ports:type_name -> stack.v1.PortSpec - 21, // 1: stack.v1.CreateStackResponse.stack:type_name -> stack.v1.Stack - 21, // 2: stack.v1.GetStackResponse.stack:type_name -> stack.v1.Stack - 22, // 3: stack.v1.GetStackStatusSummaryResponse.summary:type_name -> stack.v1.StackStatusSummary - 21, // 4: stack.v1.ListStacksResponse.stacks:type_name -> stack.v1.Stack - 25, // 5: stack.v1.GetBatchDeleteJobResponse.job:type_name -> stack.v1.BatchDeleteJob - 20, // 6: stack.v1.GetStatsResponse.stats:type_name -> stack.v1.Stats - 27, // 7: stack.v1.Stats.node_distribution:type_name -> stack.v1.Stats.NodeDistributionEntry - 24, // 8: stack.v1.Stack.ports:type_name -> stack.v1.PortMapping - 0, // 9: stack.v1.Stack.status:type_name -> stack.v1.Status - 28, // 10: stack.v1.Stack.ttl_expires_at:type_name -> google.protobuf.Timestamp - 28, // 11: stack.v1.Stack.created_at:type_name -> google.protobuf.Timestamp - 28, // 12: stack.v1.Stack.updated_at:type_name -> google.protobuf.Timestamp - 23, // 13: stack.v1.Stack.target_ports:type_name -> stack.v1.PortSpec - 0, // 14: stack.v1.StackStatusSummary.status:type_name -> stack.v1.Status - 28, // 15: stack.v1.StackStatusSummary.ttl:type_name -> google.protobuf.Timestamp - 24, // 16: stack.v1.StackStatusSummary.ports:type_name -> stack.v1.PortMapping - 23, // 17: stack.v1.StackStatusSummary.target_ports:type_name -> stack.v1.PortSpec - 1, // 18: stack.v1.BatchDeleteJob.status:type_name -> stack.v1.JobStatus - 26, // 19: stack.v1.BatchDeleteJob.errors:type_name -> stack.v1.JobError - 28, // 20: stack.v1.BatchDeleteJob.created_at:type_name -> google.protobuf.Timestamp - 28, // 21: stack.v1.BatchDeleteJob.updated_at:type_name -> google.protobuf.Timestamp - 2, // 22: stack.v1.StackService.Healthz:input_type -> stack.v1.HealthzRequest - 4, // 23: stack.v1.StackService.CreateStack:input_type -> stack.v1.CreateStackRequest - 6, // 24: stack.v1.StackService.GetStack:input_type -> stack.v1.GetStackRequest - 8, // 25: stack.v1.StackService.GetStackStatusSummary:input_type -> stack.v1.GetStackStatusSummaryRequest - 10, // 26: stack.v1.StackService.DeleteStack:input_type -> stack.v1.DeleteStackRequest - 12, // 27: stack.v1.StackService.ListStacks:input_type -> stack.v1.ListStacksRequest - 14, // 28: stack.v1.StackService.CreateBatchDeleteJob:input_type -> stack.v1.CreateBatchDeleteJobRequest - 16, // 29: stack.v1.StackService.GetBatchDeleteJob:input_type -> stack.v1.GetBatchDeleteJobRequest - 18, // 30: stack.v1.StackService.GetStats:input_type -> stack.v1.GetStatsRequest - 3, // 31: stack.v1.StackService.Healthz:output_type -> stack.v1.HealthzResponse - 5, // 32: stack.v1.StackService.CreateStack:output_type -> stack.v1.CreateStackResponse - 7, // 33: stack.v1.StackService.GetStack:output_type -> stack.v1.GetStackResponse - 9, // 34: stack.v1.StackService.GetStackStatusSummary:output_type -> stack.v1.GetStackStatusSummaryResponse - 11, // 35: stack.v1.StackService.DeleteStack:output_type -> stack.v1.DeleteStackResponse - 13, // 36: stack.v1.StackService.ListStacks:output_type -> stack.v1.ListStacksResponse - 15, // 37: stack.v1.StackService.CreateBatchDeleteJob:output_type -> stack.v1.CreateBatchDeleteJobResponse - 17, // 38: stack.v1.StackService.GetBatchDeleteJob:output_type -> stack.v1.GetBatchDeleteJobResponse - 19, // 39: stack.v1.StackService.GetStats:output_type -> stack.v1.GetStatsResponse - 31, // [31:40] is the sub-list for method output_type - 22, // [22:31] is the sub-list for method input_type - 22, // [22:22] is the sub-list for extension type_name - 22, // [22:22] is the sub-list for extension extendee - 0, // [0:22] is the sub-list for field type_name -} - -func init() { file_stack_v1_stack_proto_init() } -func file_stack_v1_stack_proto_init() { - if File_stack_v1_stack_proto != nil { - return - } - file_stack_v1_stack_proto_msgTypes[19].OneofWrappers = []any{} - file_stack_v1_stack_proto_msgTypes[20].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_stack_v1_stack_proto_rawDesc), len(file_stack_v1_stack_proto_rawDesc)), - NumEnums: 2, - NumMessages: 26, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_stack_v1_stack_proto_goTypes, - DependencyIndexes: file_stack_v1_stack_proto_depIdxs, - EnumInfos: file_stack_v1_stack_proto_enumTypes, - MessageInfos: file_stack_v1_stack_proto_msgTypes, - }.Build() - File_stack_v1_stack_proto = out.File - file_stack_v1_stack_proto_goTypes = nil - file_stack_v1_stack_proto_depIdxs = nil -} diff --git a/internal/gen/stack/v1/stack_grpc.pb.go b/internal/gen/stack/v1/stack_grpc.pb.go deleted file mode 100644 index e42c578..0000000 --- a/internal/gen/stack/v1/stack_grpc.pb.go +++ /dev/null @@ -1,425 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.1 -// - protoc (unknown) -// source: stack/v1/stack.proto - -package stackv1 - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - StackService_Healthz_FullMethodName = "/stack.v1.StackService/Healthz" - StackService_CreateStack_FullMethodName = "/stack.v1.StackService/CreateStack" - StackService_GetStack_FullMethodName = "/stack.v1.StackService/GetStack" - StackService_GetStackStatusSummary_FullMethodName = "/stack.v1.StackService/GetStackStatusSummary" - StackService_DeleteStack_FullMethodName = "/stack.v1.StackService/DeleteStack" - StackService_ListStacks_FullMethodName = "/stack.v1.StackService/ListStacks" - StackService_CreateBatchDeleteJob_FullMethodName = "/stack.v1.StackService/CreateBatchDeleteJob" - StackService_GetBatchDeleteJob_FullMethodName = "/stack.v1.StackService/GetBatchDeleteJob" - StackService_GetStats_FullMethodName = "/stack.v1.StackService/GetStats" -) - -// StackServiceClient is the client API for StackService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type StackServiceClient interface { - Healthz(ctx context.Context, in *HealthzRequest, opts ...grpc.CallOption) (*HealthzResponse, error) - CreateStack(ctx context.Context, in *CreateStackRequest, opts ...grpc.CallOption) (*CreateStackResponse, error) - GetStack(ctx context.Context, in *GetStackRequest, opts ...grpc.CallOption) (*GetStackResponse, error) - GetStackStatusSummary(ctx context.Context, in *GetStackStatusSummaryRequest, opts ...grpc.CallOption) (*GetStackStatusSummaryResponse, error) - DeleteStack(ctx context.Context, in *DeleteStackRequest, opts ...grpc.CallOption) (*DeleteStackResponse, error) - ListStacks(ctx context.Context, in *ListStacksRequest, opts ...grpc.CallOption) (*ListStacksResponse, error) - CreateBatchDeleteJob(ctx context.Context, in *CreateBatchDeleteJobRequest, opts ...grpc.CallOption) (*CreateBatchDeleteJobResponse, error) - GetBatchDeleteJob(ctx context.Context, in *GetBatchDeleteJobRequest, opts ...grpc.CallOption) (*GetBatchDeleteJobResponse, error) - GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) -} - -type stackServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewStackServiceClient(cc grpc.ClientConnInterface) StackServiceClient { - return &stackServiceClient{cc} -} - -func (c *stackServiceClient) Healthz(ctx context.Context, in *HealthzRequest, opts ...grpc.CallOption) (*HealthzResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(HealthzResponse) - err := c.cc.Invoke(ctx, StackService_Healthz_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) CreateStack(ctx context.Context, in *CreateStackRequest, opts ...grpc.CallOption) (*CreateStackResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateStackResponse) - err := c.cc.Invoke(ctx, StackService_CreateStack_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) GetStack(ctx context.Context, in *GetStackRequest, opts ...grpc.CallOption) (*GetStackResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetStackResponse) - err := c.cc.Invoke(ctx, StackService_GetStack_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) GetStackStatusSummary(ctx context.Context, in *GetStackStatusSummaryRequest, opts ...grpc.CallOption) (*GetStackStatusSummaryResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetStackStatusSummaryResponse) - err := c.cc.Invoke(ctx, StackService_GetStackStatusSummary_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) DeleteStack(ctx context.Context, in *DeleteStackRequest, opts ...grpc.CallOption) (*DeleteStackResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeleteStackResponse) - err := c.cc.Invoke(ctx, StackService_DeleteStack_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) ListStacks(ctx context.Context, in *ListStacksRequest, opts ...grpc.CallOption) (*ListStacksResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListStacksResponse) - err := c.cc.Invoke(ctx, StackService_ListStacks_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) CreateBatchDeleteJob(ctx context.Context, in *CreateBatchDeleteJobRequest, opts ...grpc.CallOption) (*CreateBatchDeleteJobResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateBatchDeleteJobResponse) - err := c.cc.Invoke(ctx, StackService_CreateBatchDeleteJob_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) GetBatchDeleteJob(ctx context.Context, in *GetBatchDeleteJobRequest, opts ...grpc.CallOption) (*GetBatchDeleteJobResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetBatchDeleteJobResponse) - err := c.cc.Invoke(ctx, StackService_GetBatchDeleteJob_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *stackServiceClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetStatsResponse) - err := c.cc.Invoke(ctx, StackService_GetStats_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// StackServiceServer is the server API for StackService service. -// All implementations must embed UnimplementedStackServiceServer -// for forward compatibility. -type StackServiceServer interface { - Healthz(context.Context, *HealthzRequest) (*HealthzResponse, error) - CreateStack(context.Context, *CreateStackRequest) (*CreateStackResponse, error) - GetStack(context.Context, *GetStackRequest) (*GetStackResponse, error) - GetStackStatusSummary(context.Context, *GetStackStatusSummaryRequest) (*GetStackStatusSummaryResponse, error) - DeleteStack(context.Context, *DeleteStackRequest) (*DeleteStackResponse, error) - ListStacks(context.Context, *ListStacksRequest) (*ListStacksResponse, error) - CreateBatchDeleteJob(context.Context, *CreateBatchDeleteJobRequest) (*CreateBatchDeleteJobResponse, error) - GetBatchDeleteJob(context.Context, *GetBatchDeleteJobRequest) (*GetBatchDeleteJobResponse, error) - GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) - mustEmbedUnimplementedStackServiceServer() -} - -// UnimplementedStackServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedStackServiceServer struct{} - -func (UnimplementedStackServiceServer) Healthz(context.Context, *HealthzRequest) (*HealthzResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Healthz not implemented") -} -func (UnimplementedStackServiceServer) CreateStack(context.Context, *CreateStackRequest) (*CreateStackResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreateStack not implemented") -} -func (UnimplementedStackServiceServer) GetStack(context.Context, *GetStackRequest) (*GetStackResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetStack not implemented") -} -func (UnimplementedStackServiceServer) GetStackStatusSummary(context.Context, *GetStackStatusSummaryRequest) (*GetStackStatusSummaryResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetStackStatusSummary not implemented") -} -func (UnimplementedStackServiceServer) DeleteStack(context.Context, *DeleteStackRequest) (*DeleteStackResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteStack not implemented") -} -func (UnimplementedStackServiceServer) ListStacks(context.Context, *ListStacksRequest) (*ListStacksResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListStacks not implemented") -} -func (UnimplementedStackServiceServer) CreateBatchDeleteJob(context.Context, *CreateBatchDeleteJobRequest) (*CreateBatchDeleteJobResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreateBatchDeleteJob not implemented") -} -func (UnimplementedStackServiceServer) GetBatchDeleteJob(context.Context, *GetBatchDeleteJobRequest) (*GetBatchDeleteJobResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetBatchDeleteJob not implemented") -} -func (UnimplementedStackServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetStats not implemented") -} -func (UnimplementedStackServiceServer) mustEmbedUnimplementedStackServiceServer() {} -func (UnimplementedStackServiceServer) testEmbeddedByValue() {} - -// UnsafeStackServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to StackServiceServer will -// result in compilation errors. -type UnsafeStackServiceServer interface { - mustEmbedUnimplementedStackServiceServer() -} - -func RegisterStackServiceServer(s grpc.ServiceRegistrar, srv StackServiceServer) { - // If the following call panics, it indicates UnimplementedStackServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&StackService_ServiceDesc, srv) -} - -func _StackService_Healthz_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(HealthzRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).Healthz(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_Healthz_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).Healthz(ctx, req.(*HealthzRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_CreateStack_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreateStackRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).CreateStack(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_CreateStack_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).CreateStack(ctx, req.(*CreateStackRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_GetStack_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetStackRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).GetStack(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_GetStack_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).GetStack(ctx, req.(*GetStackRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_GetStackStatusSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetStackStatusSummaryRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).GetStackStatusSummary(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_GetStackStatusSummary_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).GetStackStatusSummary(ctx, req.(*GetStackStatusSummaryRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_DeleteStack_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteStackRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).DeleteStack(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_DeleteStack_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).DeleteStack(ctx, req.(*DeleteStackRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_ListStacks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListStacksRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).ListStacks(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_ListStacks_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).ListStacks(ctx, req.(*ListStacksRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_CreateBatchDeleteJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreateBatchDeleteJobRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).CreateBatchDeleteJob(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_CreateBatchDeleteJob_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).CreateBatchDeleteJob(ctx, req.(*CreateBatchDeleteJobRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_GetBatchDeleteJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetBatchDeleteJobRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).GetBatchDeleteJob(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_GetBatchDeleteJob_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).GetBatchDeleteJob(ctx, req.(*GetBatchDeleteJobRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _StackService_GetStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetStatsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(StackServiceServer).GetStats(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StackService_GetStats_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StackServiceServer).GetStats(ctx, req.(*GetStatsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// StackService_ServiceDesc is the grpc.ServiceDesc for StackService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var StackService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "stack.v1.StackService", - HandlerType: (*StackServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Healthz", - Handler: _StackService_Healthz_Handler, - }, - { - MethodName: "CreateStack", - Handler: _StackService_CreateStack_Handler, - }, - { - MethodName: "GetStack", - Handler: _StackService_GetStack_Handler, - }, - { - MethodName: "GetStackStatusSummary", - Handler: _StackService_GetStackStatusSummary_Handler, - }, - { - MethodName: "DeleteStack", - Handler: _StackService_DeleteStack_Handler, - }, - { - MethodName: "ListStacks", - Handler: _StackService_ListStacks_Handler, - }, - { - MethodName: "CreateBatchDeleteJob", - Handler: _StackService_CreateBatchDeleteJob_Handler, - }, - { - MethodName: "GetBatchDeleteJob", - Handler: _StackService_GetBatchDeleteJob_Handler, - }, - { - MethodName: "GetStats", - Handler: _StackService_GetStats_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "stack/v1/stack.proto", -} diff --git a/internal/http/handlers/errors.go b/internal/http/handlers/errors.go index 429b256..fd3877d 100644 --- a/internal/http/handlers/errors.go +++ b/internal/http/handlers/errors.go @@ -116,24 +116,24 @@ func mapError(err error) (int, errorResponse, map[string]string) { case errors.Is(err, service.ErrRateLimited): status = http.StatusTooManyRequests resp.Error = service.ErrRateLimited.Error() - case errors.Is(err, service.ErrStackDisabled): + case errors.Is(err, service.ErrVMDisabled): status = http.StatusServiceUnavailable - resp.Error = service.ErrStackDisabled.Error() - case errors.Is(err, service.ErrStackNotEnabled): + resp.Error = service.ErrVMDisabled.Error() + case errors.Is(err, service.ErrVMNotEnabled): status = http.StatusBadRequest - resp.Error = service.ErrStackNotEnabled.Error() - case errors.Is(err, service.ErrStackLimitReached): + resp.Error = service.ErrVMNotEnabled.Error() + case errors.Is(err, service.ErrVMLimitReached): status = http.StatusConflict - resp.Error = service.ErrStackLimitReached.Error() - case errors.Is(err, service.ErrStackNotFound): + resp.Error = service.ErrVMLimitReached.Error() + case errors.Is(err, service.ErrVMNotFound): status = http.StatusNotFound - resp.Error = service.ErrStackNotFound.Error() - case errors.Is(err, service.ErrStackProvisionerDown): + resp.Error = service.ErrVMNotFound.Error() + case errors.Is(err, service.ErrVMOrchestratorDown): status = http.StatusServiceUnavailable - resp.Error = service.ErrStackProvisionerDown.Error() - case errors.Is(err, service.ErrStackInvalidSpec): + resp.Error = service.ErrVMOrchestratorDown.Error() + case errors.Is(err, service.ErrVMInvalidSpec): status = http.StatusBadRequest - resp.Error = service.ErrStackInvalidSpec.Error() + resp.Error = service.ErrVMInvalidSpec.Error() case errors.Is(err, service.ErrNotFound): status = http.StatusNotFound resp.Error = "not found" diff --git a/internal/http/handlers/errors_test.go b/internal/http/handlers/errors_test.go index 8ee2d21..f96606f 100644 --- a/internal/http/handlers/errors_test.go +++ b/internal/http/handlers/errors_test.go @@ -71,12 +71,12 @@ func TestMapErrorSentinels(t *testing.T) { {service.ErrStorageUnavailable, http.StatusServiceUnavailable, service.ErrStorageUnavailable.Error(), 0}, {service.ErrAlreadySolved, http.StatusConflict, service.ErrAlreadySolved.Error(), 0}, {service.ErrRateLimited, http.StatusTooManyRequests, service.ErrRateLimited.Error(), 0}, - {service.ErrStackDisabled, http.StatusServiceUnavailable, service.ErrStackDisabled.Error(), 0}, - {service.ErrStackNotEnabled, http.StatusBadRequest, service.ErrStackNotEnabled.Error(), 0}, - {service.ErrStackLimitReached, http.StatusConflict, service.ErrStackLimitReached.Error(), 0}, - {service.ErrStackNotFound, http.StatusNotFound, service.ErrStackNotFound.Error(), 0}, - {service.ErrStackProvisionerDown, http.StatusServiceUnavailable, service.ErrStackProvisionerDown.Error(), 0}, - {service.ErrStackInvalidSpec, http.StatusBadRequest, service.ErrStackInvalidSpec.Error(), 0}, + {service.ErrVMDisabled, http.StatusServiceUnavailable, service.ErrVMDisabled.Error(), 0}, + {service.ErrVMNotEnabled, http.StatusBadRequest, service.ErrVMNotEnabled.Error(), 0}, + {service.ErrVMLimitReached, http.StatusConflict, service.ErrVMLimitReached.Error(), 0}, + {service.ErrVMNotFound, http.StatusNotFound, service.ErrVMNotFound.Error(), 0}, + {service.ErrVMOrchestratorDown, http.StatusServiceUnavailable, service.ErrVMOrchestratorDown.Error(), 0}, + {service.ErrVMInvalidSpec, http.StatusBadRequest, service.ErrVMInvalidSpec.Error(), 0}, {service.ErrNotFound, http.StatusNotFound, "not found", 0}, } diff --git a/internal/http/handlers/handler.go b/internal/http/handlers/handler.go index effca81..2182b78 100644 --- a/internal/http/handlers/handler.go +++ b/internal/http/handlers/handler.go @@ -16,27 +16,26 @@ import ( "smctf/internal/models" "smctf/internal/realtime" "smctf/internal/service" - "smctf/internal/stack" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" ) type Handler struct { - cfg config.Config - auth *service.AuthService - ctf *service.CTFService - app *service.AppConfigService - users *service.UserService - score *service.ScoreboardService - divs *service.DivisionService - teams *service.TeamService - stacks *service.StackService - redis *redis.Client + cfg config.Config + auth *service.AuthService + ctf *service.CTFService + app *service.AppConfigService + users *service.UserService + score *service.ScoreboardService + divs *service.DivisionService + teams *service.TeamService + vms *service.VMService + redis *redis.Client } -func New(cfg config.Config, auth *service.AuthService, ctf *service.CTFService, app *service.AppConfigService, users *service.UserService, score *service.ScoreboardService, divisions *service.DivisionService, teams *service.TeamService, stacks *service.StackService, redis *redis.Client) *Handler { - return &Handler{cfg: cfg, auth: auth, ctf: ctf, app: app, users: users, score: score, divs: divisions, teams: teams, stacks: stacks, redis: redis} +func New(cfg config.Config, auth *service.AuthService, ctf *service.CTFService, app *service.AppConfigService, users *service.UserService, score *service.ScoreboardService, divisions *service.DivisionService, teams *service.TeamService, vms *service.VMService, redis *redis.Client) *Handler { + return &Handler{cfg: cfg, auth: auth, ctf: ctf, app: app, users: users, score: score, divs: divisions, teams: teams, vms: vms, redis: redis} } func (h *Handler) respondFromCache(ctx *gin.Context, cacheKey string) bool { @@ -387,14 +386,14 @@ func (h *Handler) Register(ctx *gin.Context) { }) } -func (h *Handler) userStackSummary(ctx context.Context, userID int64) (int, int) { - if h.stacks == nil { +func (h *Handler) userVMSummary(ctx context.Context, userID int64) (int, int) { + if h.vms == nil { return 0, 0 } - count, limit, err := h.stacks.UserStackSummary(ctx, userID) + count, limit, err := h.vms.UserVMSummary(ctx, userID) if err != nil { - slog.Warn("stack summary lookup failed", slog.Int64("user_id", userID), slog.Any("error", err)) + slog.Warn("vm summary lookup failed", slog.Int64("user_id", userID), slog.Any("error", err)) return 0, limit } @@ -418,10 +417,10 @@ func (h *Handler) Login(ctx *gin.Context) { return } - stackCount, stackLimit := h.userStackSummary(ctx.Request.Context(), user.ID) + vmCount, vmLimit := h.userVMSummary(ctx.Request.Context(), user.ID) ctx.JSON(http.StatusOK, loginResponse{ - User: newUserMeResponse(user, stackCount, stackLimit), + User: newUserMeResponse(user, vmCount, vmLimit), }) } @@ -466,9 +465,9 @@ func (h *Handler) Me(ctx *gin.Context) { return } - stackCount, stackLimit := h.userStackSummary(ctx.Request.Context(), userID) + vmCount, vmLimit := h.userVMSummary(ctx.Request.Context(), userID) - ctx.JSON(http.StatusOK, newUserMeResponse(user, stackCount, stackLimit)) + ctx.JSON(http.StatusOK, newUserMeResponse(user, vmCount, vmLimit)) } func (h *Handler) UpdateMe(ctx *gin.Context) { @@ -485,11 +484,11 @@ func (h *Handler) UpdateMe(ctx *gin.Context) { return } - stackCount, stackLimit := h.userStackSummary(ctx.Request.Context(), userID) + vmCount, vmLimit := h.userVMSummary(ctx.Request.Context(), userID) h.notifyScoreboardChanged(ctx.Request.Context(), "user_profile_update", user.DivisionID) - ctx.JSON(http.StatusOK, newUserMeResponse(user, stackCount, stackLimit)) + ctx.JSON(http.StatusOK, newUserMeResponse(user, vmCount, vmLimit)) } // Challenge Handlers @@ -595,8 +594,8 @@ func (h *Handler) SubmitFlag(ctx *gin.Context) { h.notifyScoreboardChanged(ctx.Request.Context(), "submission_correct", divisionID) } - if h.stacks != nil { - _ = h.stacks.DeleteStackByUserAndChallenge(ctx.Request.Context(), middleware.UserID(ctx), challengeID) + if h.vms != nil { + _ = h.vms.DeleteVMByUserAndChallenge(ctx.Request.Context(), middleware.UserID(ctx), challengeID) } } @@ -606,9 +605,9 @@ func (h *Handler) SubmitFlag(ctx *gin.Context) { }) } -func (h *Handler) CreateStack(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) +func (h *Handler) CreateVM(ctx *gin.Context) { + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } @@ -627,18 +626,18 @@ func (h *Handler) CreateStack(ctx *gin.Context) { return } - stackModel, err := h.stacks.GetOrCreateStack(ctx.Request.Context(), middleware.UserID(ctx), challengeID) + vmModel, err := h.vms.GetOrCreateVM(ctx.Request.Context(), middleware.UserID(ctx), challengeID) if err != nil { writeError(ctx, err) return } - ctx.JSON(http.StatusCreated, newStackResponse(stackModel, string(state))) + ctx.JSON(http.StatusCreated, newVMResponse(vmModel, string(state))) } -func (h *Handler) GetStack(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) +func (h *Handler) GetVM(ctx *gin.Context) { + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } @@ -657,18 +656,18 @@ func (h *Handler) GetStack(ctx *gin.Context) { return } - stackModel, err := h.stacks.GetStack(ctx.Request.Context(), middleware.UserID(ctx), challengeID) + vmModel, err := h.vms.GetVM(ctx.Request.Context(), middleware.UserID(ctx), challengeID) if err != nil { writeError(ctx, err) return } - ctx.JSON(http.StatusOK, newStackResponse(stackModel, string(state))) + ctx.JSON(http.StatusOK, newVMResponse(vmModel, string(state))) } -func (h *Handler) DeleteStack(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) +func (h *Handler) DeleteVM(ctx *gin.Context) { + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } @@ -687,7 +686,7 @@ func (h *Handler) DeleteStack(ctx *gin.Context) { return } - if err := h.stacks.DeleteStack(ctx.Request.Context(), middleware.UserID(ctx), challengeID); err != nil { + if err := h.vms.DeleteVM(ctx.Request.Context(), middleware.UserID(ctx), challengeID); err != nil { writeError(ctx, err) return } @@ -695,9 +694,9 @@ func (h *Handler) DeleteStack(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"status": "ok", "ctf_state": string(state)}) } -func (h *Handler) ListStacks(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) +func (h *Handler) ListVMs(ctx *gin.Context) { + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } @@ -711,85 +710,85 @@ func (h *Handler) ListStacks(ctx *gin.Context) { return } - stacks, err := h.stacks.ListUserStacks(ctx.Request.Context(), middleware.UserID(ctx)) + vms, err := h.vms.ListUserVMs(ctx.Request.Context(), middleware.UserID(ctx)) if err != nil { writeError(ctx, err) return } - resp := make([]stackResponse, 0, len(stacks)) - for i := range stacks { - stackModel := stacks[i] - resp = append(resp, newStackResponse(&stackModel, string(state))) + resp := make([]vmResponse, 0, len(vms)) + for i := range vms { + vmModel := vms[i] + resp = append(resp, newVMResponse(&vmModel, string(state))) } - ctx.JSON(http.StatusOK, stacksListResponse{CTFState: string(state), Stacks: resp}) + ctx.JSON(http.StatusOK, vmsListResponse{CTFState: string(state), VMs: resp}) } -func (h *Handler) AdminListStacks(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) +func (h *Handler) AdminListVMs(ctx *gin.Context) { + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } - stacks, err := h.stacks.ListAdminStacks(ctx.Request.Context()) + vms, err := h.vms.ListAdminVMs(ctx.Request.Context()) if err != nil { writeError(ctx, err) return } - resp := make([]adminStackResponse, 0, len(stacks)) - for i := range stacks { - resp = append(resp, newAdminStackResponse(stacks[i])) + resp := make([]adminVMResponse, 0, len(vms)) + for i := range vms { + resp = append(resp, newAdminVMResponse(vms[i])) } - ctx.JSON(http.StatusOK, adminStacksListResponse{Stacks: resp}) + ctx.JSON(http.StatusOK, adminVMsListResponse{VMs: resp}) } -func (h *Handler) AdminDeleteStack(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) +func (h *Handler) AdminDeleteVM(ctx *gin.Context) { + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } - stackID := strings.TrimSpace(ctx.Param("stack_id")) - if stackID == "" { - writeError(ctx, service.NewValidationError(service.FieldError{Field: "stack_id", Reason: "required"})) + vmID := strings.TrimSpace(ctx.Param("vm_id")) + if vmID == "" { + writeError(ctx, service.NewValidationError(service.FieldError{Field: "vm_id", Reason: "required"})) return } - if err := h.stacks.DeleteStackByStackID(ctx.Request.Context(), stackID); err != nil { + if err := h.vms.DeleteVMByVMID(ctx.Request.Context(), vmID); err != nil { writeError(ctx, err) return } - ctx.JSON(http.StatusOK, gin.H{"deleted": true, "stack_id": stackID}) + ctx.JSON(http.StatusOK, gin.H{"deleted": true, "vm_id": vmID}) } -func (h *Handler) AdminGetStack(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) +func (h *Handler) AdminGetVM(ctx *gin.Context) { + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } - stackID := strings.TrimSpace(ctx.Param("stack_id")) - if stackID == "" { - writeError(ctx, service.NewValidationError(service.FieldError{Field: "stack_id", Reason: "required"})) + vmID := strings.TrimSpace(ctx.Param("vm_id")) + if vmID == "" { + writeError(ctx, service.NewValidationError(service.FieldError{Field: "vm_id", Reason: "required"})) return } - stackModel, err := h.stacks.GetStackByStackID(ctx.Request.Context(), stackID) + vmModel, err := h.vms.GetVMByVMID(ctx.Request.Context(), vmID) if err != nil { writeError(ctx, err) return } - ctx.JSON(http.StatusOK, newStackResponse(stackModel, "")) + ctx.JSON(http.StatusOK, newVMResponse(vmModel, "")) } func (h *Handler) AdminReport(ctx *gin.Context) { - if h.stacks == nil { - writeError(ctx, service.ErrStackDisabled) + if h.vms == nil { + writeError(ctx, service.ErrVMDisabled) return } @@ -817,7 +816,7 @@ func (h *Handler) AdminReport(ctx *gin.Context) { return } - stacks, err := h.stacks.ListAllStacks(ctx.Request.Context()) + vms, err := h.vms.ListAllVMs(ctx.Request.Context()) if err != nil { writeError(ctx, err) return @@ -888,7 +887,7 @@ func (h *Handler) AdminReport(ctx *gin.Context) { Divisions: divisions, Teams: teams, Users: reportUsers, - Stacks: stacks, + VMs: vms, RegistrationKeys: keys, Submissions: reportSubmissions, AppConfig: appConfigs, @@ -916,12 +915,12 @@ func (h *Handler) CreateChallenge(ctx *gin.Context) { minimumPoints = *req.MinimumPoints } - stackEnabled := false - if req.StackEnabled != nil { - stackEnabled = *req.StackEnabled + vmEnabled := false + if req.VMEnabled != nil { + vmEnabled = *req.VMEnabled } - challenge, err := h.ctf.CreateChallenge(ctx.Request.Context(), req.Title, req.Description, req.Category, req.Points, minimumPoints, req.Flag, active, stackEnabled, stack.TargetPortSpecs(req.StackTargetPorts), req.StackPodSpec, req.PreviousChallengeID) + challenge, err := h.ctf.CreateChallenge(ctx.Request.Context(), req.Title, req.Description, req.Category, req.Points, minimumPoints, req.Flag, active, vmEnabled, req.VMSpec, req.PreviousChallengeID) if err != nil { writeError(ctx, err) return @@ -967,7 +966,7 @@ func (h *Handler) UpdateChallenge(ctx *gin.Context) { return } - stackPodSpec := optionalStringToPointer(req.StackPodSpec) + vmSpec := optionalStringToPointer(req.VMSpec) previousChallengeID := (*int64)(nil) previousChallengeSet := req.PreviousChallengeID.Set @@ -975,7 +974,7 @@ func (h *Handler) UpdateChallenge(ctx *gin.Context) { previousChallengeID = req.PreviousChallengeID.Value } - challenge, err := h.ctf.UpdateChallenge(ctx.Request.Context(), challengeID, title, description, category, req.Points, req.MinimumPoints, flag, req.IsActive, req.StackEnabled, req.StackTargetPorts, stackPodSpec, previousChallengeID, previousChallengeSet) + challenge, err := h.ctf.UpdateChallenge(ctx.Request.Context(), challengeID, title, description, category, req.Points, req.MinimumPoints, flag, req.IsActive, req.VMEnabled, vmSpec, previousChallengeID, previousChallengeSet) if err != nil { writeError(ctx, err) return @@ -1024,7 +1023,7 @@ func (h *Handler) AdminGetChallenge(ctx *gin.Context) { resp := adminChallengeResponse{ challengeResponse: newChallengeResponse(challenge), - StackPodSpec: challenge.StackPodSpec, + VMSpec: challenge.VMSpec, } ctx.JSON(http.StatusOK, resp) diff --git a/internal/http/handlers/handler_test.go b/internal/http/handlers/handler_test.go index f90c51d..42204b4 100644 --- a/internal/http/handlers/handler_test.go +++ b/internal/http/handlers/handler_test.go @@ -21,9 +21,9 @@ import ( "smctf/internal/realtime" "smctf/internal/repo" "smctf/internal/service" - "smctf/internal/stack" "smctf/internal/storage" "smctf/internal/utils" + "smctf/internal/vm" "github.com/gin-gonic/gin" "github.com/uptrace/bun" @@ -383,8 +383,8 @@ func TestHandlerRegisterLoginRefreshLogout(t *testing.T) { TeamName string `json:"team_name"` DivisionID int64 `json:"division_id"` DivisionName string `json:"division_name"` - StackCount int `json:"stack_count"` - StackLimit int `json:"stack_limit"` + VMCount int `json:"vm_count"` + VMLimit int `json:"vm_limit"` BlockedReason *string `json:"blocked_reason"` BlockedAt *time.Time `json:"blocked_at"` } `json:"user"` @@ -400,11 +400,11 @@ func TestHandlerRegisterLoginRefreshLogout(t *testing.T) { if loginResp.User.DivisionID == 0 || loginResp.User.DivisionName == "" { t.Fatalf("missing division fields in login response") } - if loginResp.User.StackCount != 0 { - t.Fatalf("expected stack_count 0, got %d", loginResp.User.StackCount) + if loginResp.User.VMCount != 0 { + t.Fatalf("expected vm_count 0, got %d", loginResp.User.VMCount) } - if loginResp.User.StackLimit != env.cfg.Stack.MaxPer { - t.Fatalf("expected stack_limit %d, got %d", env.cfg.Stack.MaxPer, loginResp.User.StackLimit) + if loginResp.User.VMLimit != env.cfg.VM.MaxPer { + t.Fatalf("expected vm_limit %d, got %d", env.cfg.VM.MaxPer, loginResp.User.VMLimit) } if loginResp.User.BlockedReason != nil || loginResp.User.BlockedAt != nil { t.Fatalf("expected blocked fields to be null") @@ -461,10 +461,10 @@ func TestHandlerRegisterLoginRefreshLogout(t *testing.T) { } } -func TestHandlerUserStackSummaryWithoutStackService(t *testing.T) { +func TestHandlerUserVMSummaryWithoutVMService(t *testing.T) { handler := New(handlerCfg, nil, nil, nil, nil, nil, nil, nil, nil, handlerRedis) - count, limit := handler.userStackSummary(context.Background(), 123) + count, limit := handler.userVMSummary(context.Background(), 123) if count != 0 || limit != 0 { t.Fatalf("expected zero summary, got %d/%d", count, limit) } @@ -755,11 +755,11 @@ func TestHandlerChallengesAndSubmit(t *testing.T) { t.Fatalf("expected whitespace title to be allowed, got %d", rec.Code) } - ctx, rec = newJSONContext(t, http.MethodPut, "/api/admin/challenges/1", map[string]any{"stack_enabled": true, "stack_pod_spec": nil, "stack_target_ports": []map[string]any{{"container_port": 80, "protocol": "TCP"}}}) + ctx, rec = newJSONContext(t, http.MethodPut, "/api/admin/challenges/1", map[string]any{"vm_enabled": true, "vm_spec": nil}) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", challenge.ID)}} env.handler.UpdateChallenge(ctx) if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for null stack_pod_spec with stack_enabled, got %d", rec.Code) + t.Fatalf("expected 400 for null vm_spec with vm_enabled, got %d", rec.Code) } ctx, rec = newJSONContext(t, http.MethodPut, "/api/admin/challenges/1", "{") @@ -769,22 +769,21 @@ func TestHandlerChallengesAndSubmit(t *testing.T) { t.Fatalf("expected 400 for invalid JSON, got %d", rec.Code) } - ctx, rec = newJSONContext(t, http.MethodPut, "/api/admin/challenges/1", map[string]any{"stack_enabled": false, "stack_pod_spec": " "}) + ctx, rec = newJSONContext(t, http.MethodPut, "/api/admin/challenges/1", map[string]any{"vm_enabled": false, "vm_spec": " "}) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", challenge.ID)}} env.handler.UpdateChallenge(ctx) if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for stack_pod_spec when stack disabled, got %d", rec.Code) + t.Fatalf("expected 400 for vm_spec when vm disabled, got %d", rec.Code) } ctx, rec = newJSONContext(t, http.MethodPut, "/api/admin/challenges/1", map[string]any{ - "stack_enabled": true, - "stack_target_ports": []map[string]any{{"container_port": 70000, "protocol": "TCP"}}, - "stack_pod_spec": "apiVersion: v1\nkind: Pod\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n", + "vm_enabled": true, + "vm_spec": "", }) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", challenge.ID)}} env.handler.UpdateChallenge(ctx) if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for out-of-range stack_target_ports, got %d", rec.Code) + t.Fatalf("expected 400 for out-of-range vm_spec, got %d", rec.Code) } ctx, rec = newJSONContext(t, http.MethodPut, "/api/admin/challenges/1", map[string]any{ @@ -1199,22 +1198,19 @@ func TestHandlerCreateChallengeAndBindErrors(t *testing.T) { ) } -func createHandlerStackChallenge(t *testing.T, env handlerEnv, title string) *models.Challenge { +func createHandlerVMChallenge(t *testing.T, env handlerEnv, title string) *models.Challenge { t.Helper() - podSpec := "apiVersion: v1\nkind: Pod\nmetadata:\n name: handler\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" + sandboxSpec := "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: handler\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" challenge := &models.Challenge{ Title: title, Description: "desc", Category: "Web", Points: 100, MinimumPoints: 100, - StackEnabled: true, - StackTargetPorts: stack.TargetPortSpecs{ - {ContainerPort: 80, Protocol: "TCP"}, - }, - StackPodSpec: &podSpec, - IsActive: true, - CreatedAt: time.Now().UTC(), + VMEnabled: true, + VMSpec: &sandboxSpec, + IsActive: true, + CreatedAt: time.Now().UTC(), } hash, err := utils.HashFlag("flag", bcrypt.MinCost) if err != nil { @@ -1229,24 +1225,24 @@ func createHandlerStackChallenge(t *testing.T, env handlerEnv, title string) *mo return challenge } -func setupHandlerStackService(t *testing.T, env handlerEnv, client stack.API) (*service.StackService, *repo.StackRepo) { +func setupHandlerVMService(t *testing.T, env handlerEnv, client vm.API) (*service.VMService, *repo.VMRepo) { t.Helper() - stackRepo := repo.NewStackRepo(env.db) - stackCfg := config.StackConfig{ + vmRepo := repo.NewVMRepo(env.db) + vmCfg := config.VMConfig{ Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5, } - stackSvc := service.NewStackService(stackCfg, stackRepo, env.challengeRepo, env.submissionRepo, client, env.redis) - return stackSvc, stackRepo + vmSvc := service.NewVMService(vmCfg, vmRepo, env.challengeRepo, env.submissionRepo, client, env.redis) + return vmSvc, vmRepo } -func setupHandlerStackServiceWithScope(t *testing.T, env handlerEnv, client stack.API, scope string) (*service.StackService, *repo.StackRepo) { +func setupHandlerVMServiceWithScope(t *testing.T, env handlerEnv, client vm.API, scope string) (*service.VMService, *repo.VMRepo) { t.Helper() - stackRepo := repo.NewStackRepo(env.db) - stackCfg := config.StackConfig{ + vmRepo := repo.NewVMRepo(env.db) + vmCfg := config.VMConfig{ Enabled: true, MaxScope: scope, MaxPer: 3, @@ -1254,48 +1250,58 @@ func setupHandlerStackServiceWithScope(t *testing.T, env handlerEnv, client stac CreateMax: 5, } - stackSvc := service.NewStackService(stackCfg, stackRepo, env.challengeRepo, env.submissionRepo, client, env.redis) - return stackSvc, stackRepo + vmSvc := service.NewVMService(vmCfg, vmRepo, env.challengeRepo, env.submissionRepo, client, env.redis) + return vmSvc, vmRepo } -func TestStackHandlersCRUD(t *testing.T) { +func TestVMHandlersCRUD(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "u1@example.com", "u1", "pass", models.UserRole) - challenge := createHandlerStackChallenge(t, env, "stack") + challenge := createHandlerVMChallenge(t, env, "vm") var deleteCalls atomic.Int32 - mock := &stack.MockClient{ - CreateStackFn: func(ctx context.Context, targetPorts []stack.TargetPortSpec, podSpec string) (*stack.StackInfo, error) { - return &stack.StackInfo{ - StackID: "stack-1", - Status: "running", - Ports: []stack.PortMapping{{ContainerPort: targetPorts[0].ContainerPort, Protocol: targetPorts[0].Protocol, NodePort: 31001}}, + mock := &vm.MockClient{ + CreateSandboxFn: func(ctx context.Context, id string, specYAML string) (*vm.Sandbox, error) { + return &vm.Sandbox{ + ID: id, + Status: vm.SandboxStatus{ + Phase: "Running", + ExternalIP: "127.0.0.1", + AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", HostPort: 31001}}, + }, }, nil }, - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + GetSandboxFn: func(ctx context.Context, vmID string) (*vm.Sandbox, error) { + return &vm.Sandbox{ + ID: vmID, + Status: vm.SandboxStatus{ + Phase: "Running", + ExternalIP: "127.0.0.1", + AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", HostPort: 31001}}, + }, + }, nil }, - DeleteStackFn: func(ctx context.Context, stackID string) error { + DeleteSandboxFn: func(ctx context.Context, vmID string) error { deleteCalls.Add(1) return nil }, } - stackSvc, _ := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + vmSvc, _ := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - ctx, rec := newJSONContext(t, http.MethodPost, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx, rec := newJSONContext(t, http.MethodPost, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/vm", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) - env.handler.CreateStack(ctx) + env.handler.CreateVM(ctx) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d", rec.Code) } - var created stackResponse + var created vmResponse decodeJSON(t, rec, &created) - if created.StackID == "" || len(created.Ports) != 1 || created.Ports[0].ContainerPort != 80 { + if created.VMID == "" || len(created.Ports) != 1 || created.Ports[0].ContainerPort != 80 { t.Fatalf("unexpected response: %+v", created) } @@ -1303,20 +1309,20 @@ func TestStackHandlersCRUD(t *testing.T) { t.Fatalf("expected created_by and challenge_title, got %+v", created) } - ctx, rec = newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx, rec = newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/vm", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) - env.handler.GetStack(ctx) + env.handler.GetVM(ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - ctx, rec = newJSONContext(t, http.MethodDelete, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx, rec = newJSONContext(t, http.MethodDelete, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/vm", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) - env.handler.DeleteStack(ctx) + env.handler.DeleteVM(ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } @@ -1326,39 +1332,39 @@ func TestStackHandlersCRUD(t *testing.T) { } } -func TestStackHandlersList(t *testing.T) { +func TestVMHandlersList(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "u2@example.com", "u2", "pass", models.UserRole) - challenge1 := createHandlerStackChallenge(t, env, "stack-1") - challenge2 := createHandlerStackChallenge(t, env, "stack-2") + challenge1 := createHandlerVMChallenge(t, env, "vm-1") + challenge2 := createHandlerVMChallenge(t, env, "vm-2") - mock := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + mock := &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, vmID string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: vmID, Status: vm.SandboxStatus{Phase: "running", AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}}, nil }, } - stackSvc, stackRepo := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + vmSvc, vmRepo := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - stack1 := &models.Stack{UserID: user.ID, ChallengeID: challenge1.ID, StackID: "stack-1", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} - stack2 := &models.Stack{UserID: user.ID, ChallengeID: challenge2.ID, StackID: "stack-2", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31002}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} - if err := stackRepo.Create(context.Background(), stack1); err != nil { - t.Fatalf("create stack1: %v", err) + vm1 := &models.VM{UserID: user.ID, ChallengeID: challenge1.ID, VMID: "vm-1", Status: "running", Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + vm2 := &models.VM{UserID: user.ID, ChallengeID: challenge2.ID, VMID: "vm-2", Status: "running", Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31002}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + if err := vmRepo.Create(context.Background(), vm1); err != nil { + t.Fatalf("create vm1: %v", err) } - if err := stackRepo.Create(context.Background(), stack2); err != nil { - t.Fatalf("create stack2: %v", err) + if err := vmRepo.Create(context.Background(), vm2); err != nil { + t.Fatalf("create vm2: %v", err) } - ctx, rec := newJSONContext(t, http.MethodGet, "/api/stacks", nil) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/vms", nil) ctx.Set("userID", user.ID) - env.handler.ListStacks(ctx) + env.handler.ListVMs(ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - var resp stacksListResponse + var resp vmsListResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } @@ -1367,101 +1373,101 @@ func TestStackHandlersList(t *testing.T) { t.Fatalf("expected ctf_state active, got %s", resp.CTFState) } - if len(resp.Stacks) != 2 { - t.Fatalf("expected 2 stacks, got %d", len(resp.Stacks)) + if len(resp.VMs) != 2 { + t.Fatalf("expected 2 vms, got %d", len(resp.VMs)) } } -func TestStackHandlersListTeamScope(t *testing.T) { +func TestVMHandlersListTeamScope(t *testing.T) { env := setupHandlerTest(t) team := createHandlerTeam(t, env, "TeamList") user := createHandlerUserWithTeam(t, env, "t1@example.com", "t1", "pass", models.UserRole, team.ID) user2 := createHandlerUserWithTeam(t, env, "t2@example.com", "t2", "pass", models.UserRole, team.ID) - challenge1 := createHandlerStackChallenge(t, env, "team-stack-1") - challenge2 := createHandlerStackChallenge(t, env, "team-stack-2") + challenge1 := createHandlerVMChallenge(t, env, "team-vm-1") + challenge2 := createHandlerVMChallenge(t, env, "team-vm-2") - mock := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + mock := &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, vmID string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: vmID, Status: vm.SandboxStatus{Phase: "running", AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}}, nil }, } - stackSvc, stackRepo := setupHandlerStackServiceWithScope(t, env, mock, "team") - env.handler.stacks = stackSvc + vmSvc, vmRepo := setupHandlerVMServiceWithScope(t, env, mock, "team") + env.handler.vms = vmSvc now := time.Now().UTC() - stack1 := &models.Stack{UserID: user.ID, ChallengeID: challenge1.ID, StackID: "team-stack-1", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: now, UpdatedAt: now} - stack2 := &models.Stack{UserID: user2.ID, ChallengeID: challenge2.ID, StackID: "team-stack-2", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31002}}, CreatedAt: now, UpdatedAt: now} - if err := stackRepo.Create(context.Background(), stack1); err != nil { - t.Fatalf("create stack1: %v", err) + vm1 := &models.VM{UserID: user.ID, ChallengeID: challenge1.ID, VMID: "team-vm-1", Status: "running", Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: now, UpdatedAt: now} + vm2 := &models.VM{UserID: user2.ID, ChallengeID: challenge2.ID, VMID: "team-vm-2", Status: "running", Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31002}}, CreatedAt: now, UpdatedAt: now} + if err := vmRepo.Create(context.Background(), vm1); err != nil { + t.Fatalf("create vm1: %v", err) } - if err := stackRepo.Create(context.Background(), stack2); err != nil { - t.Fatalf("create stack2: %v", err) + if err := vmRepo.Create(context.Background(), vm2); err != nil { + t.Fatalf("create vm2: %v", err) } - ctx, rec := newJSONContext(t, http.MethodGet, "/api/stacks", nil) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/vms", nil) ctx.Set("userID", user.ID) - env.handler.ListStacks(ctx) + env.handler.ListVMs(ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - var resp stacksListResponse + var resp vmsListResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } - if len(resp.Stacks) != 2 { - t.Fatalf("expected 2 team stacks, got %d", len(resp.Stacks)) + if len(resp.VMs) != 2 { + t.Fatalf("expected 2 team vms, got %d", len(resp.VMs)) } - if resp.Stacks[0].CreatedByUserID == 0 || resp.Stacks[0].CreatedByUsername == "" { - t.Fatalf("expected created_by fields set, got %+v", resp.Stacks[0]) + if resp.VMs[0].CreatedByUserID == 0 || resp.VMs[0].CreatedByUsername == "" { + t.Fatalf("expected created_by fields set, got %+v", resp.VMs[0]) } - if resp.Stacks[0].ChallengeTitle == "" { - t.Fatalf("expected challenge_title set, got %+v", resp.Stacks[0]) + if resp.VMs[0].ChallengeTitle == "" { + t.Fatalf("expected challenge_title set, got %+v", resp.VMs[0]) } } -func TestStackHandlersGetTeamScope(t *testing.T) { +func TestVMHandlersGetTeamScope(t *testing.T) { env := setupHandlerTest(t) team := createHandlerTeam(t, env, "TeamGet") user := createHandlerUserWithTeam(t, env, "g1@example.com", "g1", "pass", models.UserRole, team.ID) user2 := createHandlerUserWithTeam(t, env, "g2@example.com", "g2", "pass", models.UserRole, team.ID) - challenge := createHandlerStackChallenge(t, env, "team-get") + challenge := createHandlerVMChallenge(t, env, "team-get") - mock := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + mock := &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, vmID string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: vmID, Status: vm.SandboxStatus{Phase: "running", AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}}, nil }, } - stackSvc, stackRepo := setupHandlerStackServiceWithScope(t, env, mock, "team") - env.handler.stacks = stackSvc + vmSvc, vmRepo := setupHandlerVMServiceWithScope(t, env, mock, "team") + env.handler.vms = vmSvc now := time.Now().UTC() - stackModel := &models.Stack{UserID: user2.ID, ChallengeID: challenge.ID, StackID: "team-get", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: now, UpdatedAt: now} - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) + vmModel := &models.VM{UserID: user2.ID, ChallengeID: challenge.ID, VMID: "team-get", Status: "running", Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: now, UpdatedAt: now} + if err := vmRepo.Create(context.Background(), vmModel); err != nil { + t.Fatalf("create vm: %v", err) } - ctx, rec := newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/vm", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) - env.handler.GetStack(ctx) + env.handler.GetVM(ctx) if rec.Code != http.StatusOK { - t.Fatalf("get stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("get vm status %d: %s", rec.Code, rec.Body.String()) } - var resp stackResponse + var resp vmResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } - if resp.StackID != "team-get" { - t.Fatalf("expected team stack, got %+v", resp) + if resp.VMID != "team-get" { + t.Fatalf("expected team vm, got %+v", resp) } if resp.CreatedByUserID != user2.ID || resp.CreatedByUsername == "" { @@ -1473,98 +1479,98 @@ func TestStackHandlersGetTeamScope(t *testing.T) { } } -func TestAdminStackHandlersList(t *testing.T) { +func TestAdminVMHandlersList(t *testing.T) { env := setupHandlerTest(t) team := createHandlerTeam(t, env, "Alpha") user := createHandlerUserWithTeam(t, env, "admin@example.com", "uadmin", "pass", models.UserRole, team.ID) - challenge := createHandlerStackChallenge(t, env, "admin-stack") + challenge := createHandlerVMChallenge(t, env, "admin-vm") - mock := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + mock := &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, vmID string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: vmID, Status: vm.SandboxStatus{Phase: "running", AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}}, nil }, } - stackSvc, stackRepo := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + vmSvc, vmRepo := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - stackModel := &models.Stack{ + vmModel := &models.VM{ UserID: user.ID, ChallengeID: challenge.ID, - StackID: "stack-admin-1", + VMID: "vm-admin-1", Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, + Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) + if err := vmRepo.Create(context.Background(), vmModel); err != nil { + t.Fatalf("create vm: %v", err) } - ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/stacks", nil) - env.handler.AdminListStacks(ctx) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/vms", nil) + env.handler.AdminListVMs(ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - var resp adminStacksListResponse + var resp adminVMsListResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } - if len(resp.Stacks) != 1 { - t.Fatalf("expected 1 stack, got %d", len(resp.Stacks)) + if len(resp.VMs) != 1 { + t.Fatalf("expected 1 vm, got %d", len(resp.VMs)) } - item := resp.Stacks[0] - if item.StackID != "stack-admin-1" || item.Username != user.Username || item.TeamName != team.Name || item.ChallengeTitle != challenge.Title { - t.Fatalf("unexpected admin stack response: %+v", item) + item := resp.VMs[0] + if item.VMID != "vm-admin-1" || item.Username != user.Username || item.TeamName != team.Name || item.ChallengeTitle != challenge.Title { + t.Fatalf("unexpected admin vm response: %+v", item) } } -func TestAdminStackHandlersListDisabled(t *testing.T) { +func TestAdminVMHandlersListDisabled(t *testing.T) { env := setupHandlerTest(t) - env.handler.stacks = nil + env.handler.vms = nil - ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/stacks", nil) - env.handler.AdminListStacks(ctx) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/vms", nil) + env.handler.AdminListVMs(ctx) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) } } -func TestAdminStackHandlersDelete(t *testing.T) { +func TestAdminVMHandlersDelete(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "admin@example.com", "uadmin-del", "pass", models.UserRole) - challenge := createHandlerStackChallenge(t, env, "admin-del") + challenge := createHandlerVMChallenge(t, env, "admin-del") var deleteCalls atomic.Int32 - mock := &stack.MockClient{ - DeleteStackFn: func(ctx context.Context, stackID string) error { + mock := &vm.MockClient{ + DeleteSandboxFn: func(ctx context.Context, vmID string) error { deleteCalls.Add(1) return nil }, } - stackSvc, stackRepo := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + vmSvc, vmRepo := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - stackModel := &models.Stack{ + vmModel := &models.VM{ UserID: user.ID, ChallengeID: challenge.ID, - StackID: "stack-admin-del", + VMID: "vm-admin-del", Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, + Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) + if err := vmRepo.Create(context.Background(), vmModel); err != nil { + t.Fatalf("create vm: %v", err) } - ctx, rec := newJSONContext(t, http.MethodDelete, "/api/admin/stacks/stack-admin-del", nil) - ctx.Params = gin.Params{{Key: "stack_id", Value: "stack-admin-del"}} - env.handler.AdminDeleteStack(ctx) + ctx, rec := newJSONContext(t, http.MethodDelete, "/api/admin/vms/vm-admin-del", nil) + ctx.Params = gin.Params{{Key: "vm_id", Value: "vm-admin-del"}} + env.handler.AdminDeleteVM(ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } @@ -1573,19 +1579,19 @@ func TestAdminStackHandlersDelete(t *testing.T) { t.Fatalf("expected delete call, got %d", deleteCalls.Load()) } - if _, err := stackRepo.GetByStackID(context.Background(), "stack-admin-del"); !errors.Is(err, repo.ErrNotFound) { - t.Fatalf("expected stack deleted, got %v", err) + if _, err := vmRepo.GetByVMID(context.Background(), "vm-admin-del"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected vm deleted, got %v", err) } } -func TestAdminStackHandlersDeleteMissingStackID(t *testing.T) { +func TestAdminVMHandlersDeleteMissingVMID(t *testing.T) { env := setupHandlerTest(t) - mock := &stack.MockClient{} - stackSvc, _ := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + mock := &vm.MockClient{} + vmSvc, _ := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - ctx, rec := newJSONContext(t, http.MethodDelete, "/api/admin/stacks/", nil) - env.handler.AdminDeleteStack(ctx) + ctx, rec := newJSONContext(t, http.MethodDelete, "/api/admin/vms/", nil) + env.handler.AdminDeleteVM(ctx) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } @@ -1597,55 +1603,55 @@ func TestAdminStackHandlersDeleteMissingStackID(t *testing.T) { } } -func TestAdminStackHandlersGet(t *testing.T) { +func TestAdminVMHandlersGet(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "u-admin-get@example.com", "uadmin-get", "pass", models.UserRole) - challenge := createHandlerStackChallenge(t, env, "admin-get") + challenge := createHandlerVMChallenge(t, env, "admin-get") - mock := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + mock := &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, vmID string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: vmID, Status: vm.SandboxStatus{Phase: "running", AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}}, nil }, } - stackSvc, stackRepo := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + vmSvc, vmRepo := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - stackModel := &models.Stack{ + vmModel := &models.VM{ UserID: user.ID, ChallengeID: challenge.ID, - StackID: "stack-admin-get", + VMID: "vm-admin-get", Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, + Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) + if err := vmRepo.Create(context.Background(), vmModel); err != nil { + t.Fatalf("create vm: %v", err) } - ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/stacks/stack-admin-get", nil) - ctx.Params = gin.Params{{Key: "stack_id", Value: "stack-admin-get"}} - env.handler.AdminGetStack(ctx) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/vms/vm-admin-get", nil) + ctx.Params = gin.Params{{Key: "vm_id", Value: "vm-admin-get"}} + env.handler.AdminGetVM(ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } - var resp stackResponse + var resp vmResponse decodeJSON(t, rec, &resp) - if resp.StackID != "stack-admin-get" || resp.ChallengeID != challenge.ID { + if resp.VMID != "vm-admin-get" || resp.ChallengeID != challenge.ID { t.Fatalf("unexpected response: %+v", resp) } } -func TestAdminStackHandlersGetMissingStackID(t *testing.T) { +func TestAdminVMHandlersGetMissingVMID(t *testing.T) { env := setupHandlerTest(t) - mock := &stack.MockClient{} - stackSvc, _ := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + mock := &vm.MockClient{} + vmSvc, _ := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/stacks/", nil) - env.handler.AdminGetStack(ctx) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/vms/", nil) + env.handler.AdminGetVM(ctx) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } @@ -1657,15 +1663,15 @@ func TestAdminStackHandlersGetMissingStackID(t *testing.T) { } } -func TestAdminStackHandlersGetNotFound(t *testing.T) { +func TestAdminVMHandlersGetNotFound(t *testing.T) { env := setupHandlerTest(t) - mock := &stack.MockClient{} - stackSvc, _ := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + mock := &vm.MockClient{} + vmSvc, _ := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/stacks/missing", nil) - ctx.Params = gin.Params{{Key: "stack_id", Value: "missing"}} - env.handler.AdminGetStack(ctx) + ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/vms/missing", nil) + ctx.Params = gin.Params{{Key: "vm_id", Value: "missing"}} + env.handler.AdminGetVM(ctx) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rec.Code) } @@ -1675,25 +1681,25 @@ func TestAdminReport(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "report@example.com", "reporter", "pass", models.UserRole) - challenge := createHandlerStackChallenge(t, env, "report-challenge") + challenge := createHandlerVMChallenge(t, env, "report-challenge") - stackRepo := repo.NewStackRepo(env.db) - stackModel := &models.Stack{ + vmRepo := repo.NewVMRepo(env.db) + vmModel := &models.VM{ UserID: user.ID, ChallengeID: challenge.ID, - StackID: "stack-report", + VMID: "vm-report", Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, + Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) + if err := vmRepo.Create(context.Background(), vmModel); err != nil { + t.Fatalf("create vm: %v", err) } - mock := &stack.MockClient{} - stackSvc := service.NewStackService(config.StackConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}, stackRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) - env.handler.stacks = stackSvc + mock := &vm.MockClient{} + vmSvc := service.NewVMService(config.VMConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}, vmRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) + env.handler.vms = vmSvc createHandlerSubmission(t, env, user.ID, challenge.ID, true, time.Now().UTC()) @@ -1764,29 +1770,29 @@ func TestAdminReport(t *testing.T) { } } -func TestStackHandlersNotStarted(t *testing.T) { +func TestVMHandlersNotStarted(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "u3@example.com", "u3", "pass", models.UserRole) - challenge := createHandlerStackChallenge(t, env, "stack") + challenge := createHandlerVMChallenge(t, env, "vm") start := time.Now().Add(2 * time.Hour) setHandlerCTFWindow(t, env, &start, nil) - mock := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + mock := &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, vmID string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: vmID, Status: vm.SandboxStatus{Phase: "running", AssignedPorts: []vm.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}}, nil }, } - stackSvc, _ := setupHandlerStackService(t, env, mock) - env.handler.stacks = stackSvc + vmSvc, _ := setupHandlerVMService(t, env, mock) + env.handler.vms = vmSvc - ctx, rec := newJSONContext(t, http.MethodPost, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx, rec := newJSONContext(t, http.MethodPost, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/vm", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) - env.handler.CreateStack(ctx) + env.handler.CreateVM(ctx) if rec.Code != http.StatusOK { - t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("create vm status %d: %s", rec.Code, rec.Body.String()) } var resp map[string]any @@ -1795,11 +1801,11 @@ func TestStackHandlersNotStarted(t *testing.T) { t.Fatalf("expected ctf_state not_started, got %v", resp["ctf_state"]) } - ctx, rec = newJSONContext(t, http.MethodGet, "/api/stacks", nil) + ctx, rec = newJSONContext(t, http.MethodGet, "/api/vms", nil) ctx.Set("userID", user.ID) - env.handler.ListStacks(ctx) + env.handler.ListVMs(ctx) if rec.Code != http.StatusOK { - t.Fatalf("list stacks status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("list vms status %d: %s", rec.Code, rec.Body.String()) } resp = map[string]any{} @@ -1808,12 +1814,12 @@ func TestStackHandlersNotStarted(t *testing.T) { t.Fatalf("expected ctf_state not_started, got %v", resp["ctf_state"]) } - ctx, rec = newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx, rec = newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/vm", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) - env.handler.GetStack(ctx) + env.handler.GetVM(ctx) if rec.Code != http.StatusOK { - t.Fatalf("get stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("get vm status %d: %s", rec.Code, rec.Body.String()) } resp = map[string]any{} @@ -1822,12 +1828,12 @@ func TestStackHandlersNotStarted(t *testing.T) { t.Fatalf("expected ctf_state not_started, got %v", resp["ctf_state"]) } - ctx, rec = newJSONContext(t, http.MethodDelete, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx, rec = newJSONContext(t, http.MethodDelete, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/vm", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) - env.handler.DeleteStack(ctx) + env.handler.DeleteVM(ctx) if rec.Code != http.StatusOK { - t.Fatalf("delete stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("delete vm status %d: %s", rec.Code, rec.Body.String()) } resp = map[string]any{} @@ -1837,9 +1843,9 @@ func TestStackHandlersNotStarted(t *testing.T) { } } -func TestAdminGetChallengeIncludesStackSpec(t *testing.T) { +func TestAdminGetChallengeIncludesVMSpec(t *testing.T) { env := setupHandlerTest(t) - challenge := createHandlerStackChallenge(t, env, "stack") + challenge := createHandlerVMChallenge(t, env, "vm") ctx, rec := newJSONContext(t, http.MethodGet, "/api/admin/challenges/"+fmt.Sprint(challenge.ID), nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} @@ -1853,8 +1859,8 @@ func TestAdminGetChallengeIncludesStackSpec(t *testing.T) { t.Fatalf("decode response: %v", err) } - if resp["stack_pod_spec"] == nil { - t.Fatalf("expected stack_pod_spec in response") + if resp["vm_spec"] == nil { + t.Fatalf("expected vm_spec in response") } } @@ -1869,28 +1875,28 @@ func TestAdminGetChallengeInvalidID(t *testing.T) { } } -func TestSubmitFlagDeletesStack(t *testing.T) { +func TestSubmitFlagDeletesVM(t *testing.T) { env := setupHandlerTest(t) user := createHandlerUser(t, env, "u3@example.com", "u3", "pass", models.UserRole) - challenge := createHandlerStackChallenge(t, env, "stack") + challenge := createHandlerVMChallenge(t, env, "vm") - stackRepo := repo.NewStackRepo(env.db) - stackModel := &models.Stack{UserID: user.ID, ChallengeID: challenge.ID, StackID: "stack-sub", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) + vmRepo := repo.NewVMRepo(env.db) + vmModel := &models.VM{UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-sub", Status: "running", Ports: vm.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + if err := vmRepo.Create(context.Background(), vmModel); err != nil { + t.Fatalf("create vm: %v", err) } deleted := false - mock := &stack.MockClient{ - DeleteStackFn: func(ctx context.Context, stackID string) error { - if stackID == "stack-sub" { + mock := &vm.MockClient{ + DeleteSandboxFn: func(ctx context.Context, vmID string) error { + if vmID == "vm-sub" { deleted = true } return nil }, } - stackSvc := service.NewStackService(config.StackConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}, stackRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) - env.handler.stacks = stackSvc + vmSvc := service.NewVMService(config.VMConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}, vmRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) + env.handler.vms = vmSvc ctx, rec := newJSONContext(t, http.MethodPost, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/submit", submitRequest{Flag: "flag"}) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} @@ -1902,7 +1908,7 @@ func TestSubmitFlagDeletesStack(t *testing.T) { } if !deleted { - t.Fatalf("expected stack delete call") + t.Fatalf("expected vm delete call") } } @@ -2375,21 +2381,21 @@ func TestHandlerMeUpdateUsers(t *testing.T) { } var meResp struct { - ID int64 `json:"id"` - StackCount int `json:"stack_count"` - StackLimit int `json:"stack_limit"` + ID int64 `json:"id"` + VMCount int `json:"vm_count"` + VMLimit int `json:"vm_limit"` } decodeJSON(t, rec, &meResp) if meResp.ID != user.ID { t.Fatalf("unexpected me response id: %d", meResp.ID) } - if meResp.StackCount != 0 { - t.Fatalf("expected me stack_count 0, got %d", meResp.StackCount) + if meResp.VMCount != 0 { + t.Fatalf("expected me vm_count 0, got %d", meResp.VMCount) } - if meResp.StackLimit != env.cfg.Stack.MaxPer { - t.Fatalf("expected me stack_limit %d, got %d", env.cfg.Stack.MaxPer, meResp.StackLimit) + if meResp.VMLimit != env.cfg.VM.MaxPer { + t.Fatalf("expected me vm_limit %d, got %d", env.cfg.VM.MaxPer, meResp.VMLimit) } ctx, rec = newJSONContext(t, http.MethodPut, "/api/me", map[string]string{"username": "user2"}) @@ -2406,21 +2412,21 @@ func TestHandlerMeUpdateUsers(t *testing.T) { } var updateResp struct { - ID int64 `json:"id"` - StackCount int `json:"stack_count"` - StackLimit int `json:"stack_limit"` + ID int64 `json:"id"` + VMCount int `json:"vm_count"` + VMLimit int `json:"vm_limit"` } decodeJSON(t, rec, &updateResp) if updateResp.ID != user.ID { t.Fatalf("unexpected update response id: %d", updateResp.ID) } - if updateResp.StackCount != 0 { - t.Fatalf("expected update stack_count 0, got %d", updateResp.StackCount) + if updateResp.VMCount != 0 { + t.Fatalf("expected update vm_count 0, got %d", updateResp.VMCount) } - if updateResp.StackLimit != env.cfg.Stack.MaxPer { - t.Fatalf("expected update stack_limit %d, got %d", env.cfg.Stack.MaxPer, updateResp.StackLimit) + if updateResp.VMLimit != env.cfg.VM.MaxPer { + t.Fatalf("expected update vm_limit %d, got %d", env.cfg.VM.MaxPer, updateResp.VMLimit) } waitForCacheClear(t, env, diff --git a/internal/http/handlers/testenv_test.go b/internal/http/handlers/testenv_test.go index 3de96c6..6d61efe 100644 --- a/internal/http/handlers/testenv_test.go +++ b/internal/http/handlers/testenv_test.go @@ -12,9 +12,9 @@ import ( "smctf/internal/models" "smctf/internal/repo" "smctf/internal/service" - "smctf/internal/stack" "smctf/internal/storage" "smctf/internal/utils" + "smctf/internal/vm" "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" @@ -36,7 +36,7 @@ type handlerEnv struct { challengeRepo *repo.ChallengeRepo submissionRepo *repo.SubmissionRepo appConfigRepo *repo.AppConfigRepo - stackRepo *repo.StackRepo + vmRepo *repo.VMRepo authSvc *service.AuthService userSvc *service.UserService scoreSvc *service.ScoreboardService @@ -44,7 +44,7 @@ type handlerEnv struct { divisionSvc *service.DivisionService teamSvc *service.TeamService appConfigSvc *service.AppConfigService - stackSvc *service.StackService + vmSvc *service.VMService handler *Handler defaultDivisionID int64 } @@ -117,7 +117,7 @@ func TestMain(m *testing.M) { LeaderboardTTL: 2 * time.Minute, AppConfigTTL: 2 * time.Minute, }, - Stack: config.StackConfig{ + VM: config.VMConfig{ Enabled: true, MaxPer: 3, CreateWindow: time.Minute, @@ -236,7 +236,7 @@ func setupHandlerTest(t *testing.T) handlerEnv { submissionRepo := repo.NewSubmissionRepo(handlerDB) scoreRepo := repo.NewScoreboardRepo(handlerDB) appConfigRepo := repo.NewAppConfigRepo(handlerDB) - stackRepo := repo.NewStackRepo(handlerDB) + vmRepo := repo.NewVMRepo(handlerDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) @@ -247,9 +247,9 @@ func setupHandlerTest(t *testing.T) handlerEnv { divisionSvc := service.NewDivisionService(divisionRepo) teamSvc := service.NewTeamService(teamRepo, divisionRepo) ctfSvc := service.NewCTFService(handlerCfg, challengeRepo, submissionRepo, handlerRedis, fileStore) - stackSvc := service.NewStackService(handlerCfg.Stack, stackRepo, challengeRepo, submissionRepo, &stack.MockClient{}, handlerRedis) + vmSvc := service.NewVMService(handlerCfg.VM, vmRepo, challengeRepo, submissionRepo, &vm.MockClient{}, handlerRedis) - handler := New(handlerCfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, stackSvc, handlerRedis) + handler := New(handlerCfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, handlerRedis) env := handlerEnv{ cfg: handlerCfg, @@ -262,7 +262,7 @@ func setupHandlerTest(t *testing.T) handlerEnv { challengeRepo: challengeRepo, submissionRepo: submissionRepo, appConfigRepo: appConfigRepo, - stackRepo: stackRepo, + vmRepo: vmRepo, authSvc: authSvc, userSvc: userSvc, scoreSvc: scoreSvc, @@ -270,7 +270,7 @@ func setupHandlerTest(t *testing.T) handlerEnv { divisionSvc: divisionSvc, teamSvc: teamSvc, appConfigSvc: appConfigSvc, - stackSvc: stackSvc, + vmSvc: vmSvc, handler: handler, } @@ -290,7 +290,7 @@ func setupHandlerTest(t *testing.T) handlerEnv { func resetHandlerState(t *testing.T) { t.Helper() - if _, err := handlerDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, stacks, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { + if _, err := handlerDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, vms, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } diff --git a/internal/http/handlers/types.go b/internal/http/handlers/types.go index 50a0702..a1c5a03 100644 --- a/internal/http/handlers/types.go +++ b/internal/http/handlers/types.go @@ -5,7 +5,7 @@ import ( "time" "smctf/internal/models" - stackpkg "smctf/internal/stack" + vmpkg "smctf/internal/vm" ) type appConfigResponse struct { @@ -85,31 +85,29 @@ type loginRequest struct { } type createChallengeRequest struct { - Title string `json:"title" binding:"required"` - Description string `json:"description" binding:"required"` - Category string `json:"category" binding:"required"` - Points int `json:"points" binding:"required"` - MinimumPoints *int `json:"minimum_points"` - Flag string `json:"flag" binding:"required"` - PreviousChallengeID *int64 `json:"previous_challenge_id"` - IsActive *bool `json:"is_active"` - StackEnabled *bool `json:"stack_enabled"` - StackTargetPorts []stackpkg.TargetPortSpec `json:"stack_target_ports"` - StackPodSpec *string `json:"stack_pod_spec"` + Title string `json:"title" binding:"required"` + Description string `json:"description" binding:"required"` + Category string `json:"category" binding:"required"` + Points int `json:"points" binding:"required"` + MinimumPoints *int `json:"minimum_points"` + Flag string `json:"flag" binding:"required"` + PreviousChallengeID *int64 `json:"previous_challenge_id"` + IsActive *bool `json:"is_active"` + VMEnabled *bool `json:"vm_enabled"` + VMSpec *string `json:"vm_spec"` } type updateChallengeRequest struct { - Title optionalString `json:"title"` - Description optionalString `json:"description"` - Category optionalString `json:"category"` - Points *int `json:"points"` - MinimumPoints *int `json:"minimum_points"` - Flag optionalString `json:"flag"` - PreviousChallengeID optionalInt64 `json:"previous_challenge_id"` - IsActive *bool `json:"is_active"` - StackEnabled *bool `json:"stack_enabled"` - StackTargetPorts *[]stackpkg.TargetPortSpec `json:"stack_target_ports"` - StackPodSpec optionalString `json:"stack_pod_spec"` + Title optionalString `json:"title"` + Description optionalString `json:"description"` + Category optionalString `json:"category"` + Points *int `json:"points"` + MinimumPoints *int `json:"minimum_points"` + Flag optionalString `json:"flag"` + PreviousChallengeID optionalInt64 `json:"previous_challenge_id"` + IsActive *bool `json:"is_active"` + VMEnabled *bool `json:"vm_enabled"` + VMSpec optionalString `json:"vm_spec"` } type challengeFileUploadRequest struct { @@ -139,8 +137,7 @@ type adminBlockUserRequest struct { Reason string `json:"reason" binding:"required"` } -type adminUnblockUserRequest struct { -} +type adminUnblockUserRequest struct{} type registerResponse struct { ID int64 `json:"id"` @@ -167,8 +164,8 @@ type userMeResponse struct { TeamName string `json:"team_name"` DivisionID int64 `json:"division_id"` DivisionName string `json:"division_name"` - StackCount int `json:"stack_count"` - StackLimit int `json:"stack_limit"` + VMCount int `json:"vm_count"` + VMLimit int `json:"vm_limit"` BlockedReason *string `json:"blocked_reason"` BlockedAt *time.Time `json:"blocked_at"` } @@ -199,21 +196,20 @@ type adminUserResponse struct { } type challengeResponse struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Category string `json:"category"` - Points int `json:"points"` - InitialPoints int `json:"initial_points"` - MinimumPoints int `json:"minimum_points"` - SolveCount int `json:"solve_count"` - PreviousChallengeID *int64 `json:"previous_challenge_id,omitempty"` - IsActive bool `json:"is_active"` - IsLocked bool `json:"is_locked"` - HasFile bool `json:"has_file"` - FileName *string `json:"file_name,omitempty"` - StackEnabled bool `json:"stack_enabled"` - StackTargetPorts []stackpkg.TargetPortSpec `json:"stack_target_ports"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + Points int `json:"points"` + InitialPoints int `json:"initial_points"` + MinimumPoints int `json:"minimum_points"` + SolveCount int `json:"solve_count"` + PreviousChallengeID *int64 `json:"previous_challenge_id,omitempty"` + IsActive bool `json:"is_active"` + IsLocked bool `json:"is_locked"` + HasFile bool `json:"has_file"` + FileName *string `json:"file_name,omitempty"` + VMEnabled bool `json:"vm_enabled"` } type lockedChallengeResponse struct { @@ -242,7 +238,7 @@ type challengesListResponse struct { type adminChallengeResponse struct { challengeResponse - StackPodSpec *string `json:"stack_pod_spec,omitempty"` + VMSpec *string `json:"vm_spec,omitempty"` } type presignedPostResponse struct { @@ -262,6 +258,10 @@ type challengeFileUploadResponse struct { Upload presignedPostResponse `json:"upload"` } +type challengeFileDownloadResponse struct { + Download presignedURLResponse `json:"download"` +} + type teamResponse struct { ID int64 `json:"id"` Name string `json:"name"` @@ -282,23 +282,22 @@ type teamTimelineResponse struct { } type adminReportChallenge struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Category string `json:"category"` - Points int `json:"points"` - InitialPoints int `json:"initial_points"` - MinimumPoints int `json:"minimum_points"` - SolveCount int `json:"solve_count"` - PreviousChallengeID *int64 `json:"previous_challenge_id,omitempty"` - IsActive bool `json:"is_active"` - FileKey *string `json:"file_key,omitempty"` - FileName *string `json:"file_name,omitempty"` - FileUploadedAt *time.Time `json:"file_uploaded_at,omitempty"` - StackEnabled bool `json:"stack_enabled"` - StackTargetPorts []stackpkg.TargetPortSpec `json:"stack_target_ports"` - StackPodSpec *string `json:"stack_pod_spec,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + Points int `json:"points"` + InitialPoints int `json:"initial_points"` + MinimumPoints int `json:"minimum_points"` + SolveCount int `json:"solve_count"` + PreviousChallengeID *int64 `json:"previous_challenge_id,omitempty"` + IsActive bool `json:"is_active"` + FileKey *string `json:"file_key,omitempty"` + FileName *string `json:"file_name,omitempty"` + FileUploadedAt *time.Time `json:"file_uploaded_at,omitempty"` + VMEnabled bool `json:"vm_enabled"` + VMSpec *string `json:"vm_spec,omitempty"` + CreatedAt time.Time `json:"created_at"` } type adminReportUser struct { @@ -330,7 +329,7 @@ type adminReportResponse struct { Divisions []models.Division `json:"divisions"` Teams []models.TeamSummary `json:"teams"` Users []adminReportUser `json:"users"` - Stacks []models.Stack `json:"stacks"` + VMs []models.VM `json:"vms"` RegistrationKeys []models.RegistrationKeySummary `json:"registration_keys"` Submissions []adminReportSubmission `json:"submissions"` AppConfig []models.AppConfig `json:"app_config"` @@ -340,28 +339,30 @@ type adminReportResponse struct { TeamLeaderboard models.TeamLeaderboardResponse `json:"team_leaderboard"` } -type stackResponse struct { - StackID string `json:"stack_id"` - ChallengeID int64 `json:"challenge_id"` - Status string `json:"status"` - NodePublicIP *string `json:"node_public_ip,omitempty"` - Ports []stackpkg.PortMapping `json:"ports,omitempty"` - TTLExpiresAt *time.Time `json:"ttl_expires_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CreatedByUserID int64 `json:"created_by_user_id"` - CreatedByUsername string `json:"created_by_username"` - ChallengeTitle string `json:"challenge_title"` - CTFState string `json:"-"` +type vmResponse struct { + VMID string `json:"vm_id"` + ChallengeID int64 `json:"challenge_id"` + Status string `json:"status"` + NodeName *string `json:"node_name,omitempty"` + ExternalIP *string `json:"external_ip,omitempty"` + Ports []vmpkg.PortMapping `json:"ports,omitempty"` + TTLExpiresAt *time.Time `json:"ttl_expires_at,omitempty"` + LastError *string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedByUserID int64 `json:"created_by_user_id"` + CreatedByUsername string `json:"created_by_username"` + ChallengeTitle string `json:"challenge_title"` + CTFState string `json:"-"` } -type stacksListResponse struct { - CTFState string `json:"ctf_state"` - Stacks []stackResponse `json:"stacks,omitempty"` +type vmsListResponse struct { + CTFState string `json:"ctf_state"` + VMs []vmResponse `json:"vms,omitempty"` } -type adminStackResponse struct { - StackID string `json:"stack_id"` +type adminVMResponse struct { + VMID string `json:"vm_id"` TTLExpiresAt *time.Time `json:"ttl_expires_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -375,41 +376,43 @@ type adminStackResponse struct { ChallengeCategory string `json:"challenge_category"` } -type adminStacksListResponse struct { - Stacks []adminStackResponse `json:"stacks,omitempty"` -} - -func newStackResponse(stack *models.Stack, ctfState string) stackResponse { - return stackResponse{ - StackID: stack.StackID, - ChallengeID: stack.ChallengeID, - Status: stack.Status, - NodePublicIP: stack.NodePublicIP, - Ports: []stackpkg.PortMapping(stack.Ports), - TTLExpiresAt: stack.TTLExpiresAt, - CreatedAt: stack.CreatedAt.UTC(), - UpdatedAt: stack.UpdatedAt.UTC(), - CreatedByUserID: stack.UserID, - CreatedByUsername: stack.Username, - ChallengeTitle: stack.ChallengeTitle, +type adminVMsListResponse struct { + VMs []adminVMResponse `json:"vms,omitempty"` +} + +func newVMResponse(vm *models.VM, ctfState string) vmResponse { + return vmResponse{ + VMID: vm.VMID, + ChallengeID: vm.ChallengeID, + Status: vm.Status, + NodeName: vm.NodeName, + ExternalIP: vm.ExternalIP, + Ports: []vmpkg.PortMapping(vm.Ports), + TTLExpiresAt: vm.TTLExpiresAt, + LastError: vm.LastError, + CreatedAt: vm.CreatedAt.UTC(), + UpdatedAt: vm.UpdatedAt.UTC(), + CreatedByUserID: vm.UserID, + CreatedByUsername: vm.Username, + ChallengeTitle: vm.ChallengeTitle, CTFState: ctfState, } } -func newAdminStackResponse(stack models.AdminStackSummary) adminStackResponse { - return adminStackResponse{ - StackID: stack.StackID, - TTLExpiresAt: timePtrUTC(stack.TTLExpiresAt), - CreatedAt: stack.CreatedAt.UTC(), - UpdatedAt: stack.UpdatedAt.UTC(), - UserID: stack.UserID, - Username: stack.Username, - Email: stack.Email, - TeamID: stack.TeamID, - TeamName: stack.TeamName, - ChallengeID: stack.ChallengeID, - ChallengeTitle: stack.ChallengeTitle, - ChallengeCategory: stack.ChallengeCategory, +func newAdminVMResponse(vm models.AdminVMSummary) adminVMResponse { + return adminVMResponse{ + VMID: vm.VMID, + TTLExpiresAt: timePtrUTC(vm.TTLExpiresAt), + CreatedAt: vm.CreatedAt.UTC(), + UpdatedAt: vm.UpdatedAt.UTC(), + UserID: vm.UserID, + Username: vm.Username, + Email: vm.Email, + TeamID: vm.TeamID, + TeamName: vm.TeamName, + ChallengeID: vm.ChallengeID, + ChallengeTitle: vm.ChallengeTitle, + ChallengeCategory: vm.ChallengeCategory, } } @@ -428,9 +431,8 @@ func newAdminReportChallenge(challenge models.Challenge) adminReportChallenge { FileKey: challenge.FileKey, FileName: challenge.FileName, FileUploadedAt: challenge.FileUploadedAt, - StackEnabled: challenge.StackEnabled, - StackTargetPorts: []stackpkg.TargetPortSpec(challenge.StackTargetPorts), - StackPodSpec: challenge.StackPodSpec, + VMEnabled: challenge.VMEnabled, + VMSpec: challenge.VMSpec, CreatedAt: challenge.CreatedAt.UTC(), } } @@ -471,7 +473,7 @@ func timePtrUTC(value *time.Time) *time.Time { return &utc } -func newUserMeResponse(user *models.User, stackCount, stackLimit int) userMeResponse { +func newUserMeResponse(user *models.User, vmCount, vmLimit int) userMeResponse { return userMeResponse{ ID: user.ID, Email: user.Email, @@ -481,8 +483,8 @@ func newUserMeResponse(user *models.User, stackCount, stackLimit int) userMeResp TeamName: user.TeamName, DivisionID: user.DivisionID, DivisionName: user.DivisionName, - StackCount: stackCount, - StackLimit: stackLimit, + VMCount: vmCount, + VMLimit: vmLimit, BlockedReason: user.BlockedReason, BlockedAt: user.BlockedAt, } @@ -533,8 +535,7 @@ func newChallengeResponse(challenge *models.Challenge) challengeResponse { IsLocked: false, HasFile: hasFile, FileName: challenge.FileName, - StackEnabled: challenge.StackEnabled, - StackTargetPorts: []stackpkg.TargetPortSpec(challenge.StackTargetPorts), + VMEnabled: challenge.VMEnabled, } } diff --git a/internal/http/integration/admin_test.go b/internal/http/integration/admin_test.go index e240ac9..84cc970 100644 --- a/internal/http/integration/admin_test.go +++ b/internal/http/integration/admin_test.go @@ -10,8 +10,8 @@ import ( "smctf/internal/config" "smctf/internal/models" - "smctf/internal/stack" "smctf/internal/utils" + "smctf/internal/vm" ) func TestAdminCreateChallenge(t *testing.T) { @@ -211,26 +211,24 @@ func TestAdminUpdateChallenge(t *testing.T) { } rec = doRequest(t, env.router, http.MethodPut, "/api/admin/challenges/"+itoa(created.ID), map[string]any{ - "stack_enabled": true, - "stack_target_ports": []map[string]any{{"container_port": 80, "protocol": "TCP"}}, - "stack_pod_spec": nil, + "vm_enabled": true, + "vm_spec": nil, }, authHeader(adminAccess)) if rec.Code != http.StatusBadRequest { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } rec = doRequest(t, env.router, http.MethodPut, "/api/admin/challenges/"+itoa(created.ID), map[string]any{ - "stack_enabled": false, - "stack_pod_spec": " ", + "vm_enabled": false, + "vm_spec": " ", }, authHeader(adminAccess)) if rec.Code != http.StatusBadRequest { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } rec = doRequest(t, env.router, http.MethodPut, "/api/admin/challenges/"+itoa(created.ID), map[string]any{ - "stack_enabled": true, - "stack_target_ports": []map[string]any{{"container_port": 70000, "protocol": "TCP"}}, - "stack_pod_spec": "apiVersion: v1\nkind: Pod\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n", + "vm_enabled": true, + "vm_spec": "", }, authHeader(adminAccess)) if rec.Code != http.StatusBadRequest { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) @@ -243,6 +241,48 @@ func TestAdminUpdateChallenge(t *testing.T) { if rec.Code != http.StatusBadRequest { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } + + sandboxSpec := "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" + rec = doRequest(t, env.router, http.MethodPut, "/api/admin/challenges/"+itoa(created.ID), map[string]any{ + "vm_enabled": true, + "vm_spec": sandboxSpec, + }, authHeader(adminAccess)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + + model, err := env.challengeRepo.GetByID(context.Background(), created.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + + if !model.VMEnabled { + t.Fatalf("expected vm_enabled to be true") + } + + if model.VMSpec == nil || strings.TrimSpace(*model.VMSpec) != strings.TrimSpace(sandboxSpec) { + t.Fatalf("expected vm_spec to be persisted") + } + + rec = doRequest(t, env.router, http.MethodPut, "/api/admin/challenges/"+itoa(created.ID), map[string]any{ + "vm_enabled": false, + }, authHeader(adminAccess)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + + model, err = env.challengeRepo.GetByID(context.Background(), created.ID) + if err != nil { + t.Fatalf("GetByID after disable: %v", err) + } + + if model.VMEnabled { + t.Fatalf("expected vm_enabled to be false") + } + + if model.VMSpec != nil { + t.Fatalf("expected vm_spec to be cleared when vm is disabled") + } } func TestAdminGetChallengeDetail(t *testing.T) { @@ -250,11 +290,10 @@ func TestAdminGetChallengeDetail(t *testing.T) { _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) adminAccess, _, _ := loginUser(t, env.router, "admin@example.com", "adminpass") - podSpec := "apiVersion: v1\nkind: Pod\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" - challenge := createChallenge(t, env, "Stacked", 100, "flag{stack}", true) - challenge.StackEnabled = true - challenge.StackTargetPorts = stack.TargetPortSpecs{{ContainerPort: 80, Protocol: "TCP"}} - challenge.StackPodSpec = &podSpec + sandboxSpec := "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" + challenge := createChallenge(t, env, "VMed", 100, "flag{vm}", true) + challenge.VMEnabled = true + challenge.VMSpec = &sandboxSpec if err := env.challengeRepo.Update(context.Background(), challenge); err != nil { t.Fatalf("update challenge: %v", err) } @@ -266,8 +305,8 @@ func TestAdminGetChallengeDetail(t *testing.T) { var resp map[string]any decodeJSON(t, rec, &resp) - if resp["stack_pod_spec"] == nil { - t.Fatalf("expected stack_pod_spec") + if resp["vm_spec"] == nil { + t.Fatalf("expected vm_spec") } } @@ -572,100 +611,100 @@ func TestAdminUnblockUser(t *testing.T) { } } -func TestAdminStackManagement(t *testing.T) { +func TestAdminVMManagement(t *testing.T) { cfg := testCfg - cfg.Stack = config.StackConfig{ + cfg.VM = config.VMConfig{ Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) + mock := vm.NewOrchestratorMock() + env := setupVMTest(t, cfg, mock.Client()) _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) adminAccess, _, _ := loginUser(t, env.router, "admin@example.com", "adminpass") userAccess, _, _ := registerAndLogin(t, env, "user@example.com", models.UserRole, "strong-pass") - challenge := createStackChallenge(t, env, "StackChal") + challenge := createVMChallenge(t, env, "VMChal") - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(userAccess)) + rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/vm", nil, authHeader(userAccess)) if rec.Code != http.StatusCreated { - t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("create vm status %d: %s", rec.Code, rec.Body.String()) } var created struct { - StackID string `json:"stack_id"` + VMID string `json:"vm_id"` } decodeJSON(t, rec, &created) - rec = doRequest(t, env.router, http.MethodGet, "/api/admin/stacks", nil, authHeader(adminAccess)) + rec = doRequest(t, env.router, http.MethodGet, "/api/admin/vms", nil, authHeader(adminAccess)) if rec.Code != http.StatusOK { - t.Fatalf("admin list stacks status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin list vms status %d: %s", rec.Code, rec.Body.String()) } var listResp struct { - Stacks []struct { - StackID string `json:"stack_id"` - } `json:"stacks"` + VMs []struct { + VMID string `json:"vm_id"` + } `json:"vms"` } decodeJSON(t, rec, &listResp) - if len(listResp.Stacks) != 1 || listResp.Stacks[0].StackID != created.StackID { - t.Fatalf("unexpected admin stacks response: %+v", listResp.Stacks) + if len(listResp.VMs) != 1 || listResp.VMs[0].VMID != created.VMID { + t.Fatalf("unexpected admin vms response: %+v", listResp.VMs) } - rec = doRequest(t, env.router, http.MethodGet, "/api/admin/stacks/"+created.StackID, nil, authHeader(adminAccess)) + rec = doRequest(t, env.router, http.MethodGet, "/api/admin/vms/"+created.VMID, nil, authHeader(adminAccess)) if rec.Code != http.StatusOK { - t.Fatalf("admin get stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin get vm status %d: %s", rec.Code, rec.Body.String()) } var detailResp struct { - StackID string `json:"stack_id"` + VMID string `json:"vm_id"` } decodeJSON(t, rec, &detailResp) - if detailResp.StackID != created.StackID { - t.Fatalf("unexpected admin stack detail: %+v", detailResp) + if detailResp.VMID != created.VMID { + t.Fatalf("unexpected admin vm detail: %+v", detailResp) } - rec = doRequest(t, env.router, http.MethodDelete, "/api/admin/stacks/"+created.StackID, nil, authHeader(adminAccess)) + rec = doRequest(t, env.router, http.MethodDelete, "/api/admin/vms/"+created.VMID, nil, authHeader(adminAccess)) if rec.Code != http.StatusOK { - t.Fatalf("admin delete stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin delete vm status %d: %s", rec.Code, rec.Body.String()) } } -func TestAdminStackEndpointsAuth(t *testing.T) { +func TestAdminVMEndpointsAuth(t *testing.T) { env := setupTest(t, testCfg) - rec := doRequest(t, env.router, http.MethodGet, "/api/admin/stacks", nil, nil) + rec := doRequest(t, env.router, http.MethodGet, "/api/admin/vms", nil, nil) if rec.Code != http.StatusUnauthorized { - t.Fatalf("admin stacks unauth status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin vms unauth status %d: %s", rec.Code, rec.Body.String()) } - rec = doRequest(t, env.router, http.MethodGet, "/api/admin/stacks/stack-missing", nil, nil) + rec = doRequest(t, env.router, http.MethodGet, "/api/admin/vms/vm-missing", nil, nil) if rec.Code != http.StatusUnauthorized { - t.Fatalf("admin stack detail unauth status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin vm detail unauth status %d: %s", rec.Code, rec.Body.String()) } - rec = doRequest(t, env.router, http.MethodDelete, "/api/admin/stacks/stack-missing", nil, nil) + rec = doRequest(t, env.router, http.MethodDelete, "/api/admin/vms/vm-missing", nil, nil) if rec.Code != http.StatusUnauthorized { - t.Fatalf("admin stack delete unauth status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin vm delete unauth status %d: %s", rec.Code, rec.Body.String()) } accessUser, _, _ := registerAndLogin(t, env, "user@example.com", models.UserRole, "strong-pass") - rec = doRequest(t, env.router, http.MethodGet, "/api/admin/stacks", nil, authHeader(accessUser)) + rec = doRequest(t, env.router, http.MethodGet, "/api/admin/vms", nil, authHeader(accessUser)) if rec.Code != http.StatusForbidden { - t.Fatalf("admin stacks forbidden status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin vms forbidden status %d: %s", rec.Code, rec.Body.String()) } - rec = doRequest(t, env.router, http.MethodGet, "/api/admin/stacks/stack-missing", nil, authHeader(accessUser)) + rec = doRequest(t, env.router, http.MethodGet, "/api/admin/vms/vm-missing", nil, authHeader(accessUser)) if rec.Code != http.StatusForbidden { - t.Fatalf("admin stack detail forbidden status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin vm detail forbidden status %d: %s", rec.Code, rec.Body.String()) } - rec = doRequest(t, env.router, http.MethodDelete, "/api/admin/stacks/stack-missing", nil, authHeader(accessUser)) + rec = doRequest(t, env.router, http.MethodDelete, "/api/admin/vms/vm-missing", nil, authHeader(accessUser)) if rec.Code != http.StatusForbidden { - t.Fatalf("admin stack delete forbidden status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("admin vm delete forbidden status %d: %s", rec.Code, rec.Body.String()) } } @@ -686,27 +725,27 @@ func TestAdminReportAuth(t *testing.T) { func TestAdminReportSuccess(t *testing.T) { cfg := testCfg - cfg.Stack = config.StackConfig{ + cfg.VM = config.VMConfig{ Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) + mock := vm.NewOrchestratorMock() + env := setupVMTest(t, cfg, mock.Client()) _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) adminAccess, _, _ := loginUser(t, env.router, "admin@example.com", "adminpass") userAccess, _, _ := registerAndLogin(t, env, "user@example.com", models.UserRole, "strong-pass") - challenge := createStackChallenge(t, env, "StackChal") + challenge := createVMChallenge(t, env, "VMChal") - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(userAccess)) + rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/vm", nil, authHeader(userAccess)) if rec.Code != http.StatusCreated { - t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("create vm status %d: %s", rec.Code, rec.Body.String()) } - rec = doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/submit", map[string]string{"flag": "flag{stack}"}, authHeader(userAccess)) + rec = doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/submit", map[string]string{"flag": "flag{vm}"}, authHeader(userAccess)) if rec.Code != http.StatusOK { t.Fatalf("submit flag status %d: %s", rec.Code, rec.Body.String()) } @@ -726,8 +765,8 @@ func TestAdminReportSuccess(t *testing.T) { t.Fatalf("expected users in report") } - if _, ok := resp["stacks"]; !ok { - t.Fatalf("expected stacks in report") + if _, ok := resp["vms"]; !ok { + t.Fatalf("expected vms in report") } if _, ok := resp["leaderboard"]; !ok { diff --git a/internal/http/integration/stacks_test.go b/internal/http/integration/stacks_test.go deleted file mode 100644 index 4b0cc44..0000000 --- a/internal/http/integration/stacks_test.go +++ /dev/null @@ -1,422 +0,0 @@ -package http_test - -import ( - "context" - "net/http" - "testing" - "time" - - "smctf/internal/config" - apphttp "smctf/internal/http" - "smctf/internal/models" - "smctf/internal/repo" - "smctf/internal/service" - "smctf/internal/stack" - "smctf/internal/storage" - "smctf/internal/utils" - - "golang.org/x/crypto/bcrypt" -) - -func setupStackTest(t *testing.T, cfg config.Config, client stack.API) testEnv { - t.Helper() - skipIfIntegrationDisabled(t) - resetState(t) - - userRepo := repo.NewUserRepo(testDB) - registrationKeyRepo := repo.NewRegistrationKeyRepo(testDB) - divisionRepo := repo.NewDivisionRepo(testDB) - teamRepo := repo.NewTeamRepo(testDB) - challengeRepo := repo.NewChallengeRepo(testDB) - submissionRepo := repo.NewSubmissionRepo(testDB) - scoreRepo := repo.NewScoreboardRepo(testDB) - appConfigRepo := repo.NewAppConfigRepo(testDB) - stackRepo := repo.NewStackRepo(testDB) - - fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) - - authSvc := service.NewAuthService(cfg, testDB, userRepo, registrationKeyRepo, teamRepo, testRedis) - userSvc := service.NewUserService(userRepo, teamRepo) - scoreSvc := service.NewScoreboardService(scoreRepo) - divisionSvc := service.NewDivisionService(divisionRepo) - teamSvc := service.NewTeamService(teamRepo, divisionRepo) - ctfSvc := service.NewCTFService(cfg, challengeRepo, submissionRepo, testRedis, fileStore) - appConfigSvc := service.NewAppConfigService(appConfigRepo, testRedis, cfg.Cache.AppConfigTTL) - stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, client, testRedis) - - router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, stackSvc, testRedis, testLogger, nil) - - env := testEnv{ - cfg: cfg, - router: router, - userRepo: userRepo, - regKeyRepo: registrationKeyRepo, - divisionRepo: divisionRepo, - teamRepo: teamRepo, - challengeRepo: challengeRepo, - submissionRepo: submissionRepo, - appConfigRepo: appConfigRepo, - authSvc: authSvc, - ctfSvc: ctfSvc, - divisionSvc: divisionSvc, - teamSvc: teamSvc, - appConfigSvc: appConfigSvc, - } - - division := &models.Division{ - Name: "Default", - CreatedAt: time.Now().UTC(), - } - if err := divisionRepo.Create(context.Background(), division); err != nil { - t.Fatalf("create division: %v", err) - } - - env.defaultDivisionID = division.ID - - return env -} - -func createStackChallenge(t *testing.T, env testEnv, title string) *models.Challenge { - t.Helper() - podSpec := "apiVersion: v1\nkind: Pod\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx:stable\n ports:\n - containerPort: 80\n protocol: TCP\n" - - challenge := &models.Challenge{ - Title: title, - Description: "stack desc", - Category: "Web", - Points: 100, - MinimumPoints: 100, - StackEnabled: true, - StackTargetPorts: stack.TargetPortSpecs{ - {ContainerPort: 80, Protocol: "TCP"}, - }, - StackPodSpec: &podSpec, - IsActive: true, - CreatedAt: time.Now().UTC(), - } - - hash, err := utils.HashFlag("flag{stack}", bcrypt.MinCost) - if err != nil { - t.Fatalf("hash flag: %v", err) - } - - challenge.FlagHash = hash - - if err := env.challengeRepo.Create(context.Background(), challenge); err != nil { - t.Fatalf("create challenge: %v", err) - } - - return challenge -} - -func TestStackLifecycle(t *testing.T) { - cfg := testCfg - cfg.Stack = config.StackConfig{ - Enabled: true, - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 1, - } - - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) - - _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) - user, _, _ := registerAndLogin(t, env, "user@example.com", models.UserRole, "strong-pass") - challenge := createStackChallenge(t, env, "StackChal") - - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(user)) - if rec.Code != http.StatusCreated { - t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) - } - - rec = doRequest(t, env.router, http.MethodGet, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(user)) - if rec.Code != http.StatusOK { - t.Fatalf("get stack status %d: %s", rec.Code, rec.Body.String()) - } - - rec = doRequest(t, env.router, http.MethodDelete, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(user)) - if rec.Code != http.StatusOK { - t.Fatalf("delete stack status %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestStackCreateBlockedAfterSolve(t *testing.T) { - cfg := testCfg - cfg.Stack = config.StackConfig{ - Enabled: true, - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 1, - } - - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) - - _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) - access, _, _ := registerAndLogin(t, env, "user2@example.com", "user2", "strong-pass") - challenge := createStackChallenge(t, env, "SolvedStack") - - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/submit", map[string]string{"flag": "flag{stack}"}, authHeader(access)) - if rec.Code != http.StatusOK { - t.Fatalf("submit status %d: %s", rec.Code, rec.Body.String()) - } - - rec = doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusConflict { - t.Fatalf("create stack after solve status %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestStackCreateRateLimit(t *testing.T) { - cfg := testCfg - cfg.Stack = config.StackConfig{ - Enabled: true, - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 1, - } - - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) - - _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) - access, _, _ := registerAndLogin(t, env, "user3@example.com", "user3", "strong-pass") - challenge1 := createStackChallenge(t, env, "RateLimit1") - challenge2 := createStackChallenge(t, env, "RateLimit2") - - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge1.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusCreated { - t.Fatalf("first stack status %d: %s", rec.Code, rec.Body.String()) - } - - rec = doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge2.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusTooManyRequests { - t.Fatalf("rate limit status %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestStackCreateLocked(t *testing.T) { - cfg := testCfg - cfg.Stack = config.StackConfig{ - Enabled: true, - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 1, - } - - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) - - _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) - access, _, userID := registerAndLogin(t, env, "userlocked@example.com", "userlocked", "strong-pass") - prev := createChallenge(t, env, "Prev", 50, "flag{prev}", true) - challenge := createStackChallenge(t, env, "LockedStack") - challenge.PreviousChallengeID = &prev.ID - if err := env.challengeRepo.Update(context.Background(), challenge); err != nil { - t.Fatalf("update challenge: %v", err) - } - - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusForbidden { - t.Fatalf("create locked stack status %d: %s", rec.Code, rec.Body.String()) - } - - createSubmission(t, env, userID, prev.ID, true, time.Now().UTC()) - rec = doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusCreated { - t.Fatalf("create unlocked stack status %d: %s", rec.Code, rec.Body.String()) - } -} - -func TestStackListTeamScope(t *testing.T) { - cfg := testCfg - cfg.Stack = config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 1, - } - - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) - - admin := ensureAdminUser(t, env) - team := createTeam(t, env, "team-"+nextRegistrationCode()) - keyA := createRegistrationKeyWithTeam(t, env, admin.ID, team.ID) - keyB := createRegistrationKeyWithTeam(t, env, admin.ID, team.ID) - - userA := func() string { - regBody := map[string]string{ - "email": "team-a@example.com", - "username": "team-a", - "password": "strong-pass", - "registration_key": keyA.Code, - } - rec := doRequest(t, env.router, http.MethodPost, "/api/auth/register", regBody, nil) - if rec.Code != http.StatusCreated { - t.Fatalf("register team-a status %d: %s", rec.Code, rec.Body.String()) - } - - loginBody := map[string]string{"email": "team-a@example.com", "password": "strong-pass"} - rec = doRequest(t, env.router, http.MethodPost, "/api/auth/login", loginBody, nil) - if rec.Code != http.StatusOK { - t.Fatalf("login team-a status %d: %s", rec.Code, rec.Body.String()) - } - - accessToken := cookieValueFromSetCookie(rec, "access_token") - if accessToken == "" { - t.Fatalf("missing access_token cookie") - } - - return accessToken - }() - - userB := func() string { - regBody := map[string]string{ - "email": "team-b@example.com", - "username": "team-b", - "password": "strong-pass", - "registration_key": keyB.Code, - } - rec := doRequest(t, env.router, http.MethodPost, "/api/auth/register", regBody, nil) - if rec.Code != http.StatusCreated { - t.Fatalf("register team-b status %d: %s", rec.Code, rec.Body.String()) - } - - loginBody := map[string]string{"email": "team-b@example.com", "password": "strong-pass"} - rec = doRequest(t, env.router, http.MethodPost, "/api/auth/login", loginBody, nil) - if rec.Code != http.StatusOK { - t.Fatalf("login team-b status %d: %s", rec.Code, rec.Body.String()) - } - - accessToken := cookieValueFromSetCookie(rec, "access_token") - if accessToken == "" { - t.Fatalf("missing access_token cookie") - } - - return accessToken - }() - - challenge := createStackChallenge(t, env, "TeamScopeStack") - - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(userA)) - if rec.Code != http.StatusCreated { - t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) - } - - rec = doRequest(t, env.router, http.MethodGet, "/api/stacks", nil, authHeader(userB)) - if rec.Code != http.StatusOK { - t.Fatalf("list stacks status %d: %s", rec.Code, rec.Body.String()) - } - - var resp struct { - Stacks []struct { - CreatedByUsername string `json:"created_by_username"` - ChallengeTitle string `json:"challenge_title"` - } `json:"stacks"` - } - decodeJSON(t, rec, &resp) - if len(resp.Stacks) != 1 { - t.Fatalf("expected 1 team stack, got %d", len(resp.Stacks)) - } - - if resp.Stacks[0].CreatedByUsername == "" || resp.Stacks[0].ChallengeTitle == "" { - t.Fatalf("expected created_by and challenge_title, got %+v", resp.Stacks[0]) - } -} - -func TestStacksBlockedBeforeStart(t *testing.T) { - cfg := testCfg - cfg.Stack = config.StackConfig{ - Enabled: true, - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 1, - } - - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) - start := time.Now().Add(2 * time.Hour) - end := time.Now().Add(4 * time.Hour) - setCTFWindow(t, env, &start, &end) - - _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) - access, _, _ := registerAndLogin(t, env, "user@example.com", models.UserRole, "strong-pass") - challenge := createStackChallenge(t, env, "StackChal") - - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusOK { - t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) - } - - var resp map[string]any - decodeJSON(t, rec, &resp) - if resp["ctf_state"] != string(service.CTFStateNotStarted) { - t.Fatalf("expected ctf_state not_started, got %v", resp["ctf_state"]) - } - - rec = doRequest(t, env.router, http.MethodGet, "/api/stacks", nil, authHeader(access)) - if rec.Code != http.StatusOK { - t.Fatalf("list stack status %d: %s", rec.Code, rec.Body.String()) - } - - resp = map[string]any{} - decodeJSON(t, rec, &resp) - if resp["ctf_state"] != string(service.CTFStateNotStarted) { - t.Fatalf("expected ctf_state not_started, got %v", resp["ctf_state"]) - } - - rec = doRequest(t, env.router, http.MethodGet, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusOK { - t.Fatalf("get stack status %d: %s", rec.Code, rec.Body.String()) - } - - resp = map[string]any{} - decodeJSON(t, rec, &resp) - if resp["ctf_state"] != string(service.CTFStateNotStarted) { - t.Fatalf("expected ctf_state not_started, got %v", resp["ctf_state"]) - } - - rec = doRequest(t, env.router, http.MethodDelete, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusOK { - t.Fatalf("delete stack status %d: %s", rec.Code, rec.Body.String()) - } - - resp = map[string]any{} - decodeJSON(t, rec, &resp) - if resp["ctf_state"] != string(service.CTFStateNotStarted) { - t.Fatalf("expected ctf_state not_started, got %v", resp["ctf_state"]) - } -} - -func TestStacksCreateBlockedAfterEnd(t *testing.T) { - cfg := testCfg - cfg.Stack = config.StackConfig{ - Enabled: true, - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 1, - } - - mock := stack.NewProvisionerMock() - env := setupStackTest(t, cfg, mock.Client()) - end := time.Now().Add(-2 * time.Hour) - setCTFWindow(t, env, nil, &end) - - _ = createUser(t, env, "admin@example.com", models.AdminRole, "adminpass", models.AdminRole) - access, _, _ := registerAndLogin(t, env, "user2@example.com", "user2", "strong-pass") - challenge := createStackChallenge(t, env, "EndedStack") - - rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(access)) - if rec.Code != http.StatusOK { - t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) - } - - var resp map[string]any - decodeJSON(t, rec, &resp) - if resp["ctf_state"] != string(service.CTFStateEnded) { - t.Fatalf("expected ctf_state ended, got %v", resp["ctf_state"]) - } -} diff --git a/internal/http/integration/testenv_test.go b/internal/http/integration/testenv_test.go index abd49cf..76b5111 100644 --- a/internal/http/integration/testenv_test.go +++ b/internal/http/integration/testenv_test.go @@ -23,9 +23,9 @@ import ( "smctf/internal/models" "smctf/internal/repo" "smctf/internal/service" - "smctf/internal/stack" "smctf/internal/storage" "smctf/internal/utils" + "smctf/internal/vm" "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" @@ -46,13 +46,13 @@ type testEnv struct { challengeRepo *repo.ChallengeRepo submissionRepo *repo.SubmissionRepo appConfigRepo *repo.AppConfigRepo - stackRepo *repo.StackRepo + vmRepo *repo.VMRepo authSvc *service.AuthService ctfSvc *service.CTFService divisionSvc *service.DivisionService teamSvc *service.TeamService appConfigSvc *service.AppConfigService - stackSvc *service.StackService + vmSvc *service.VMService defaultDivisionID int64 } @@ -165,7 +165,7 @@ func TestMain(m *testing.M) { FilePrefix: "test", MaxBodyBytes: 1024 * 1024, }, - Stack: config.StackConfig{ + VM: config.VMConfig{ Enabled: true, MaxPer: 3, CreateWindow: time.Minute, @@ -277,7 +277,7 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { submissionRepo := repo.NewSubmissionRepo(testDB) scoreRepo := repo.NewScoreboardRepo(testDB) appConfigRepo := repo.NewAppConfigRepo(testDB) - stackRepo := repo.NewStackRepo(testDB) + vmRepo := repo.NewVMRepo(testDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) @@ -288,9 +288,9 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { teamSvc := service.NewTeamService(teamRepo, divisionRepo) ctfSvc := service.NewCTFService(cfg, challengeRepo, submissionRepo, testRedis, fileStore) appConfigSvc := service.NewAppConfigService(appConfigRepo, testRedis, cfg.Cache.AppConfigTTL) - stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, &stack.MockClient{}, testRedis) + vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, &vm.MockClient{}, testRedis) - router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, stackSvc, testRedis, testLogger, nil) + router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, testRedis, testLogger, nil) env := testEnv{ cfg: cfg, @@ -302,13 +302,13 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { challengeRepo: challengeRepo, submissionRepo: submissionRepo, appConfigRepo: appConfigRepo, - stackRepo: stackRepo, + vmRepo: vmRepo, authSvc: authSvc, ctfSvc: ctfSvc, divisionSvc: divisionSvc, teamSvc: teamSvc, appConfigSvc: appConfigSvc, - stackSvc: stackSvc, + vmSvc: vmSvc, } division := &models.Division{ @@ -324,6 +324,62 @@ func setupTest(t *testing.T, cfg config.Config) testEnv { return env } +func setupVMTest(t *testing.T, cfg config.Config, client vm.API) testEnv { + t.Helper() + skipIfIntegrationDisabled(t) + resetState(t) + + userRepo := repo.NewUserRepo(testDB) + registrationKeyRepo := repo.NewRegistrationKeyRepo(testDB) + divisionRepo := repo.NewDivisionRepo(testDB) + teamRepo := repo.NewTeamRepo(testDB) + challengeRepo := repo.NewChallengeRepo(testDB) + submissionRepo := repo.NewSubmissionRepo(testDB) + scoreRepo := repo.NewScoreboardRepo(testDB) + appConfigRepo := repo.NewAppConfigRepo(testDB) + vmRepo := repo.NewVMRepo(testDB) + + fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) + + authSvc := service.NewAuthService(cfg, testDB, userRepo, registrationKeyRepo, teamRepo, testRedis) + userSvc := service.NewUserService(userRepo, teamRepo) + scoreSvc := service.NewScoreboardService(scoreRepo) + divisionSvc := service.NewDivisionService(divisionRepo) + teamSvc := service.NewTeamService(teamRepo, divisionRepo) + ctfSvc := service.NewCTFService(cfg, challengeRepo, submissionRepo, testRedis, fileStore) + appConfigSvc := service.NewAppConfigService(appConfigRepo, testRedis, cfg.Cache.AppConfigTTL) + vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, client, testRedis) + + router := apphttp.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, testRedis, testLogger, nil) + + env := testEnv{ + cfg: cfg, + router: router, + userRepo: userRepo, + regKeyRepo: registrationKeyRepo, + divisionRepo: divisionRepo, + teamRepo: teamRepo, + challengeRepo: challengeRepo, + submissionRepo: submissionRepo, + appConfigRepo: appConfigRepo, + vmRepo: vmRepo, + authSvc: authSvc, + ctfSvc: ctfSvc, + divisionSvc: divisionSvc, + teamSvc: teamSvc, + appConfigSvc: appConfigSvc, + vmSvc: vmSvc, + } + + division := &models.Division{Name: "Default", CreatedAt: time.Now().UTC()} + if err := divisionRepo.Create(context.Background(), division); err != nil { + t.Fatalf("create division: %v", err) + } + env.defaultDivisionID = division.ID + + return env +} + func setCTFWindow(t *testing.T, env testEnv, startAt, endAt *time.Time) { t.Helper() @@ -354,7 +410,7 @@ func setCTFWindow(t *testing.T, env testEnv, startAt, endAt *time.Time) { func resetState(t *testing.T) { t.Helper() - if _, err := testDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, stacks, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { + if _, err := testDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, vms, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } @@ -480,11 +536,11 @@ func registerAndLogin(t *testing.T, env testEnv, email, username, password strin var loginResp struct { User struct { - ID int64 `json:"id"` - TeamID int64 `json:"team_id"` - TeamName string `json:"team_name"` - StackCount int `json:"stack_count"` - StackLimit int `json:"stack_limit"` + ID int64 `json:"id"` + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name"` + VMCount int `json:"vm_count"` + VMLimit int `json:"vm_limit"` } `json:"user"` } @@ -493,12 +549,12 @@ func registerAndLogin(t *testing.T, env testEnv, email, username, password strin t.Fatalf("missing team fields in login response") } - if loginResp.User.StackCount != 0 { - t.Fatalf("expected stack_count 0, got %d", loginResp.User.StackCount) + if loginResp.User.VMCount != 0 { + t.Fatalf("expected vm_count 0, got %d", loginResp.User.VMCount) } - if loginResp.User.StackLimit != env.cfg.Stack.MaxPer { - t.Fatalf("expected stack_limit %d, got %d", env.cfg.Stack.MaxPer, loginResp.User.StackLimit) + if loginResp.User.VMLimit != env.cfg.VM.MaxPer { + t.Fatalf("expected vm_limit %d, got %d", env.cfg.VM.MaxPer, loginResp.User.VMLimit) } accessToken := cookieValueFromSetCookie(rec, "access_token") @@ -568,11 +624,11 @@ func loginUser(t *testing.T, router *gin.Engine, email, password string) (string var resp struct { User struct { - ID int64 `json:"id"` - TeamID int64 `json:"team_id"` - TeamName string `json:"team_name"` - StackCount int `json:"stack_count"` - StackLimit int `json:"stack_limit"` + ID int64 `json:"id"` + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name"` + VMCount int `json:"vm_count"` + VMLimit int `json:"vm_limit"` } `json:"user"` } @@ -688,6 +744,18 @@ func createChallenge(t *testing.T, env testEnv, title string, points int, flag s return challenge } +func createVMChallenge(t *testing.T, env testEnv, title string) *models.Challenge { + t.Helper() + challenge := createChallenge(t, env, title, 100, "flag{vm}", true) + spec := "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: challenge\nspec:\n containers:\n - name: app\n image: nginx\n" + challenge.VMEnabled = true + challenge.VMSpec = &spec + if err := env.challengeRepo.Update(context.Background(), challenge); err != nil { + t.Fatalf("update vm challenge: %v", err) + } + return challenge +} + func createSubmission(t *testing.T, env testEnv, userID, challengeID int64, correct bool, submittedAt time.Time) { t.Helper() diff --git a/internal/http/middleware/recovery_logger.go b/internal/http/middleware/recovery_logger.go index 8e953f2..0a10da2 100644 --- a/internal/http/middleware/recovery_logger.go +++ b/internal/http/middleware/recovery_logger.go @@ -3,7 +3,6 @@ package middleware import ( "log/slog" "net/http" - "runtime/debug" "smctf/internal/logging" @@ -24,7 +23,6 @@ func RecoveryLogger(logger *logging.Logger) gin.HandlerFunc { slog.Any("error", recovered), slog.String("path", ctx.Request.URL.Path), slog.String("method", ctx.Request.Method), - slog.String("stack", string(debug.Stack())), ) } diff --git a/internal/http/router.go b/internal/http/router.go index 4308c61..d901a58 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -16,7 +16,7 @@ import ( "github.com/redis/go-redis/v9" ) -func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service.CTFService, appConfigSvc *service.AppConfigService, userSvc *service.UserService, scoreSvc *service.ScoreboardService, divisionSvc *service.DivisionService, teamSvc *service.TeamService, stackSvc *service.StackService, redis *redis.Client, logger *logging.Logger, sse *realtime.SSEHub) *gin.Engine { +func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service.CTFService, appConfigSvc *service.AppConfigService, userSvc *service.UserService, scoreSvc *service.ScoreboardService, divisionSvc *service.DivisionService, teamSvc *service.TeamService, vmSvc *service.VMService, redis *redis.Client, logger *logging.Logger, sse *realtime.SSEHub) *gin.Engine { if cfg.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) } @@ -27,7 +27,7 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. r.Use(middleware.CORS(cfg.AppEnv == "local", cfg.CORS.AllowedOrigins)) r.Use(middleware.CSRF()) - h := handlers.New(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, stackSvc, redis) + h := handlers.New(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, redis) sseHandler := handlers.NewSSEHandler(sse) r.GET("/healthz", func(ctx *gin.Context) { @@ -62,16 +62,16 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. auth := api.Group("") auth.Use(middleware.Auth(cfg.JWT)) auth.GET("/me", h.Me) - auth.GET("/stacks", h.ListStacks) - auth.GET("/challenges/:id/stack", h.GetStack) + auth.GET("/vms", h.ListVMs) + auth.GET("/challenges/:id/vm", h.GetVM) unblocked := auth.Group("") unblocked.Use(middleware.RequireActiveUser(userSvc)) unblocked.PUT("/me", h.UpdateMe) unblocked.POST("/challenges/:id/submit", h.SubmitFlag) unblocked.POST("/challenges/:id/file/download", h.RequestChallengeFileDownload) - unblocked.POST("/challenges/:id/stack", h.CreateStack) - unblocked.DELETE("/challenges/:id/stack", h.DeleteStack) + unblocked.POST("/challenges/:id/vm", h.CreateVM) + unblocked.DELETE("/challenges/:id/vm", h.DeleteVM) admin := api.Group("/admin") admin.Use(middleware.Auth(cfg.JWT), middleware.RequireActiveUser(userSvc), middleware.RequireRole(models.AdminRole)) @@ -86,9 +86,9 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. admin.GET("/registration-keys", h.ListRegistrationKeys) admin.POST("/divisions", h.CreateDivision) admin.POST("/teams", h.CreateTeam) - admin.GET("/stacks", h.AdminListStacks) - admin.GET("/stacks/:stack_id", h.AdminGetStack) - admin.DELETE("/stacks/:stack_id", h.AdminDeleteStack) + admin.GET("/vms", h.AdminListVMs) + admin.GET("/vms/:vm_id", h.AdminGetVM) + admin.DELETE("/vms/:vm_id", h.AdminDeleteVM) admin.GET("/report", h.AdminReport) admin.POST("/users/:id/team", h.AdminMoveUserTeam) admin.POST("/users/:id/block", h.AdminBlockUser) diff --git a/internal/logging/logging.go b/internal/logging/logging.go index efdafb0..22a992c 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -7,7 +7,6 @@ import ( "log/slog" "os" "path/filepath" - "runtime/debug" "strings" "sync" "time" @@ -135,7 +134,6 @@ func PanicLogger(logger *Logger, ctx context.Context, recovered any) { err := fmt.Errorf("panic: %v", recovered) log.Error("panic recovered", slog.Any("error", err), - slog.String("stack", string(debug.Stack())), ) } diff --git a/internal/models/challenge.go b/internal/models/challenge.go index ad58570..91f4f72 100644 --- a/internal/models/challenge.go +++ b/internal/models/challenge.go @@ -3,30 +3,27 @@ package models import ( "time" - "smctf/internal/stack" - "github.com/uptrace/bun" ) // Database model for challenges type Challenge struct { bun.BaseModel `bun:"table:challenges"` - ID int64 `bun:"id,pk,autoincrement"` - Title string `bun:"title,notnull"` - Description string `bun:"description,notnull"` - Points int `bun:"points,notnull,default:0"` - MinimumPoints int `bun:"minimum_points,notnull,default:0"` - Category string `bun:"category,notnull"` - FlagHash string `bun:"flag_hash,notnull"` - PreviousChallengeID *int64 `bun:"previous_challenge_id,nullzero"` - FileKey *string `bun:"file_key,nullzero"` - FileName *string `bun:"file_name,nullzero"` - FileUploadedAt *time.Time `bun:"file_uploaded_at,nullzero"` - StackEnabled bool `bun:"stack_enabled,notnull,default:false"` - StackTargetPorts stack.TargetPortSpecs `bun:"stack_target_ports,type:jsonb,nullzero"` - StackPodSpec *string `bun:"stack_pod_spec,nullzero"` - IsActive bool `bun:"is_active,notnull"` - CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` - InitialPoints int `bun:"-"` - SolveCount int `bun:"-"` + ID int64 `bun:"id,pk,autoincrement"` + Title string `bun:"title,notnull"` + Description string `bun:"description,notnull"` + Points int `bun:"points,notnull,default:0"` + MinimumPoints int `bun:"minimum_points,notnull,default:0"` + Category string `bun:"category,notnull"` + FlagHash string `bun:"flag_hash,notnull"` + PreviousChallengeID *int64 `bun:"previous_challenge_id,nullzero"` + FileKey *string `bun:"file_key,nullzero"` + FileName *string `bun:"file_name,nullzero"` + FileUploadedAt *time.Time `bun:"file_uploaded_at,nullzero"` + VMEnabled bool `bun:"vm_enabled,notnull,default:false"` + VMSpec *string `bun:"vm_spec,nullzero"` + IsActive bool `bun:"is_active,notnull"` + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` + InitialPoints int `bun:"-"` + SolveCount int `bun:"-"` } diff --git a/internal/models/stack.go b/internal/models/stack.go deleted file mode 100644 index bfc86f2..0000000 --- a/internal/models/stack.go +++ /dev/null @@ -1,40 +0,0 @@ -package models - -import ( - "time" - - "smctf/internal/stack" - - "github.com/uptrace/bun" -) - -type Stack struct { - bun.BaseModel `bun:"table:stacks"` - ID int64 `bun:"id,pk,autoincrement"` - UserID int64 `bun:"user_id,notnull"` - Username string `bun:"username,scanonly"` - ChallengeID int64 `bun:"challenge_id,notnull"` - ChallengeTitle string `bun:"challenge_title,scanonly"` - StackID string `bun:"stack_id,notnull"` - Status string `bun:"status,notnull"` - NodePublicIP *string `bun:"node_public_ip,nullzero"` - Ports stack.PortMappings `bun:"ports,type:jsonb,nullzero"` - TTLExpiresAt *time.Time `bun:"ttl_expires_at,nullzero"` - CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` - UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` -} - -type AdminStackSummary struct { - StackID string `bun:"stack_id" json:"stack_id"` - TTLExpiresAt *time.Time `bun:"ttl_expires_at" json:"ttl_expires_at,omitempty"` - CreatedAt time.Time `bun:"created_at" json:"created_at"` - UpdatedAt time.Time `bun:"updated_at" json:"updated_at"` - UserID int64 `bun:"user_id" json:"user_id"` - Username string `bun:"username" json:"username"` - Email string `bun:"email" json:"email"` - TeamID int64 `bun:"team_id" json:"team_id"` - TeamName string `bun:"team_name" json:"team_name"` - ChallengeID int64 `bun:"challenge_id" json:"challenge_id"` - ChallengeTitle string `bun:"challenge_title" json:"challenge_title"` - ChallengeCategory string `bun:"challenge_category" json:"challenge_category"` -} diff --git a/internal/models/vm.go b/internal/models/vm.go new file mode 100644 index 0000000..b1ce952 --- /dev/null +++ b/internal/models/vm.go @@ -0,0 +1,42 @@ +package models + +import ( + "time" + + "smctf/internal/vm" + + "github.com/uptrace/bun" +) + +type VM struct { + bun.BaseModel `bun:"table:vms"` + ID int64 `bun:"id,pk,autoincrement"` + UserID int64 `bun:"user_id,notnull"` + Username string `bun:"username,scanonly"` + ChallengeID int64 `bun:"challenge_id,notnull"` + ChallengeTitle string `bun:"challenge_title,scanonly"` + VMID string `bun:"vm_id,notnull"` + Status string `bun:"status,notnull"` + NodeName *string `bun:"node_name,nullzero"` + ExternalIP *string `bun:"external_ip,nullzero"` + Ports vm.PortMappings `bun:"ports,type:jsonb,nullzero"` + TTLExpiresAt *time.Time `bun:"ttl_expires_at,nullzero"` + LastError *string `bun:"last_error,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` +} + +type AdminVMSummary struct { + VMID string `bun:"vm_id" json:"vm_id"` + TTLExpiresAt *time.Time `bun:"ttl_expires_at" json:"ttl_expires_at,omitempty"` + CreatedAt time.Time `bun:"created_at" json:"created_at"` + UpdatedAt time.Time `bun:"updated_at" json:"updated_at"` + UserID int64 `bun:"user_id" json:"user_id"` + Username string `bun:"username" json:"username"` + Email string `bun:"email" json:"email"` + TeamID int64 `bun:"team_id" json:"team_id"` + TeamName string `bun:"team_name" json:"team_name"` + ChallengeID int64 `bun:"challenge_id" json:"challenge_id"` + ChallengeTitle string `bun:"challenge_title" json:"challenge_title"` + ChallengeCategory string `bun:"challenge_category" json:"challenge_category"` +} diff --git a/internal/repo/stack_repo.go b/internal/repo/stack_repo.go deleted file mode 100644 index 9876a78..0000000 --- a/internal/repo/stack_repo.go +++ /dev/null @@ -1,239 +0,0 @@ -package repo - -import ( - "context" - - "smctf/internal/models" - - "github.com/uptrace/bun" -) - -type StackRepo struct { - db *bun.DB -} - -func NewStackRepo(db *bun.DB) *StackRepo { - return &StackRepo{db: db} -} - -func (r *StackRepo) ListByUser(ctx context.Context, userID int64) ([]models.Stack, error) { - stacks := make([]models.Stack, 0) - if err := r.db.NewSelect(). - Model(&stacks). - ColumnExpr("stack.*"). - ColumnExpr("u.username AS username"). - ColumnExpr("c.title AS challenge_title"). - Join("LEFT JOIN users AS u ON u.id = stack.user_id"). - Join("LEFT JOIN challenges AS c ON c.id = stack.challenge_id"). - Where("stack.user_id = ?", userID). - Order("stack.created_at DESC"). - Scan(ctx); err != nil { - return nil, wrapError("stackRepo.ListByUser", err) - } - - return stacks, nil -} - -func (r *StackRepo) ListByTeam(ctx context.Context, teamID int64) ([]models.Stack, error) { - stacks := make([]models.Stack, 0) - if err := r.db.NewSelect(). - Model(&stacks). - ColumnExpr("stack.*"). - ColumnExpr("u.username AS username"). - ColumnExpr("c.title AS challenge_title"). - Join("JOIN users AS u ON u.id = stack.user_id"). - Join("JOIN challenges AS c ON c.id = stack.challenge_id"). - Where("u.team_id = ?", teamID). - Order("stack.created_at DESC"). - Scan(ctx); err != nil { - return nil, wrapError("stackRepo.ListByTeam", err) - } - - return stacks, nil -} - -func (r *StackRepo) ListAll(ctx context.Context) ([]models.Stack, error) { - stacks := make([]models.Stack, 0) - if err := r.db.NewSelect(). - Model(&stacks). - Order("created_at DESC"). - Scan(ctx); err != nil { - return nil, wrapError("stackRepo.ListAll", err) - } - - return stacks, nil -} - -func (r *StackRepo) ListAdmin(ctx context.Context) ([]models.AdminStackSummary, error) { - stacks := make([]models.AdminStackSummary, 0) - if err := r.db.NewSelect(). - TableExpr("stacks AS s"). - ColumnExpr("s.stack_id AS stack_id"). - ColumnExpr("s.ttl_expires_at AS ttl_expires_at"). - ColumnExpr("s.created_at AS created_at"). - ColumnExpr("s.updated_at AS updated_at"). - ColumnExpr("s.user_id AS user_id"). - ColumnExpr("u.username AS username"). - ColumnExpr("u.email AS email"). - ColumnExpr("u.team_id AS team_id"). - ColumnExpr("g.name AS team_name"). - ColumnExpr("s.challenge_id AS challenge_id"). - ColumnExpr("c.title AS challenge_title"). - ColumnExpr("c.category AS challenge_category"). - Join("JOIN users AS u ON u.id = s.user_id"). - Join("JOIN teams AS g ON g.id = u.team_id"). - Join("JOIN challenges AS c ON c.id = s.challenge_id"). - OrderExpr("s.created_at DESC"). - Scan(ctx, &stacks); err != nil { - return nil, wrapError("stackRepo.ListAdmin", err) - } - - return stacks, nil -} - -func (r *StackRepo) CountByUser(ctx context.Context, userID int64) (int, error) { - count, err := r.db.NewSelect(). - Model((*models.Stack)(nil)). - Where("user_id = ?", userID). - Count(ctx) - if err != nil { - return 0, wrapError("stackRepo.CountByUser", err) - } - - return count, nil -} - -func (r *StackRepo) CountByUserExcludingStatuses(ctx context.Context, userID int64, statuses []string) (int, error) { - query := r.db.NewSelect(). - Model((*models.Stack)(nil)). - Where("user_id = ?", userID) - if len(statuses) > 0 { - query = query.Where("status NOT IN (?)", bun.In(statuses)) - } - - count, err := query.Count(ctx) - if err != nil { - return 0, wrapError("stackRepo.CountByUserExcludingStatuses", err) - } - - return count, nil -} - -func (r *StackRepo) CountByTeamExcludingStatuses(ctx context.Context, teamID int64, statuses []string) (int, error) { - query := r.db.NewSelect(). - Model((*models.Stack)(nil)). - Join("JOIN users AS u ON u.id = stack.user_id"). - Where("u.team_id = ?", teamID) - if len(statuses) > 0 { - query = query.Where("stack.status NOT IN (?)", bun.In(statuses)) - } - - count, err := query.Count(ctx) - if err != nil { - return 0, wrapError("stackRepo.CountByTeamExcludingStatuses", err) - } - - return count, nil -} - -func (r *StackRepo) TeamIDForUser(ctx context.Context, userID int64) (int64, error) { - var teamID int64 - if err := r.db.NewSelect(). - TableExpr("users"). - Column("team_id"). - Where("id = ?", userID). - Scan(ctx, &teamID); err != nil { - return 0, wrapNotFound("stackRepo.TeamIDForUser", err) - } - - return teamID, nil -} - -func (r *StackRepo) GetByUserAndChallenge(ctx context.Context, userID, challengeID int64) (*models.Stack, error) { - stack := new(models.Stack) - if err := r.db.NewSelect(). - Model(stack). - ColumnExpr("stack.*"). - ColumnExpr("u.username AS username"). - ColumnExpr("c.title AS challenge_title"). - Join("LEFT JOIN users AS u ON u.id = stack.user_id"). - Join("LEFT JOIN challenges AS c ON c.id = stack.challenge_id"). - Where("stack.user_id = ?", userID). - Where("stack.challenge_id = ?", challengeID). - Scan(ctx); err != nil { - return nil, wrapNotFound("stackRepo.GetByUserAndChallenge", err) - } - - return stack, nil -} - -func (r *StackRepo) GetByTeamAndChallenge(ctx context.Context, teamID, challengeID int64) (*models.Stack, error) { - stack := new(models.Stack) - if err := r.db.NewSelect(). - Model(stack). - ColumnExpr("stack.*"). - ColumnExpr("u.username AS username"). - ColumnExpr("c.title AS challenge_title"). - Join("JOIN users AS u ON u.id = stack.user_id"). - Join("JOIN challenges AS c ON c.id = stack.challenge_id"). - Where("u.team_id = ?", teamID). - Where("stack.challenge_id = ?", challengeID). - Scan(ctx); err != nil { - return nil, wrapNotFound("stackRepo.GetByTeamAndChallenge", err) - } - - return stack, nil -} - -func (r *StackRepo) GetByStackID(ctx context.Context, stackID string) (*models.Stack, error) { - stack := new(models.Stack) - if err := r.db.NewSelect(). - Model(stack). - ColumnExpr("stack.*"). - ColumnExpr("u.username AS username"). - ColumnExpr("c.title AS challenge_title"). - Join("LEFT JOIN users AS u ON u.id = stack.user_id"). - Join("LEFT JOIN challenges AS c ON c.id = stack.challenge_id"). - Where("stack.stack_id = ?", stackID). - Scan(ctx); err != nil { - return nil, wrapNotFound("stackRepo.GetByStackID", err) - } - - return stack, nil -} - -func (r *StackRepo) Create(ctx context.Context, stack *models.Stack) error { - if _, err := r.db.NewInsert().Model(stack).Exec(ctx); err != nil { - return wrapError("stackRepo.Create", err) - } - - return nil -} - -func (r *StackRepo) Update(ctx context.Context, stack *models.Stack) error { - if _, err := r.db.NewUpdate().Model(stack).WherePK().Exec(ctx); err != nil { - return wrapError("stackRepo.Update", err) - } - - return nil -} - -func (r *StackRepo) Delete(ctx context.Context, stack *models.Stack) error { - if _, err := r.db.NewDelete().Model(stack).WherePK().Exec(ctx); err != nil { - return wrapError("stackRepo.Delete", err) - } - - return nil -} - -func (r *StackRepo) DeleteByUserAndChallenge(ctx context.Context, userID, challengeID int64) error { - if _, err := r.db.NewDelete(). - Model((*models.Stack)(nil)). - Where("user_id = ?", userID). - Where("challenge_id = ?", challengeID). - Exec(ctx); err != nil { - return wrapError("stackRepo.DeleteByUserAndChallenge", err) - } - - return nil -} diff --git a/internal/repo/stack_repo_test.go b/internal/repo/stack_repo_test.go deleted file mode 100644 index 56f9cf6..0000000 --- a/internal/repo/stack_repo_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package repo - -import ( - "context" - "errors" - "testing" - "time" - - "smctf/internal/models" - "smctf/internal/stack" -) - -func createStack(t *testing.T, env repoEnv, userID, challengeID int64, stackID string, createdAt time.Time) *models.Stack { - t.Helper() - stack := &models.Stack{ - UserID: userID, - ChallengeID: challengeID, - StackID: stackID, - Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - CreatedAt: createdAt, - UpdatedAt: createdAt, - } - if err := env.stackRepo.Create(context.Background(), stack); err != nil { - t.Fatalf("create stack: %v", err) - } - - return stack -} - -func TestStackRepoCRUD(t *testing.T) { - env := setupRepoTest(t) - - user := createUserWithNewTeam(t, env, "stacker@example.com", "stacker", "pass", models.UserRole) - challenge := createChallenge(t, env, "Stacked", 100, "flag{1}", true) - - now := time.Now().UTC() - stack := createStack(t, env, user.ID, challenge.ID, "stack-1", now) - - got, err := env.stackRepo.GetByUserAndChallenge(context.Background(), user.ID, challenge.ID) - if err != nil { - t.Fatalf("GetByUserAndChallenge: %v", err) - } - - if got.StackID != stack.StackID { - t.Fatalf("expected stack %s, got %s", stack.StackID, got.StackID) - } - - if got.Username != user.Username { - t.Fatalf("expected username %s, got %s", user.Username, got.Username) - } - - if got.ChallengeTitle != challenge.Title { - t.Fatalf("expected challenge title %s, got %s", challenge.Title, got.ChallengeTitle) - } - - got, err = env.stackRepo.GetByStackID(context.Background(), stack.StackID) - if err != nil { - t.Fatalf("GetByStackID: %v", err) - } - - if got.ID != stack.ID { - t.Fatalf("expected stack id %d, got %d", stack.ID, got.ID) - } - - if got.Username != user.Username { - t.Fatalf("expected username %s, got %s", user.Username, got.Username) - } - - if got.ChallengeTitle != challenge.Title { - t.Fatalf("expected challenge title %s, got %s", challenge.Title, got.ChallengeTitle) - } - - count, err := env.stackRepo.CountByUser(context.Background(), user.ID) - if err != nil { - t.Fatalf("CountByUser: %v", err) - } - if count != 1 { - t.Fatalf("expected count 1, got %d", count) - } - - stacks, err := env.stackRepo.ListByUser(context.Background(), user.ID) - if err != nil { - t.Fatalf("ListByUser: %v", err) - } - - if len(stacks) != 1 { - t.Fatalf("expected 1 stack, got %d", len(stacks)) - } - - if stacks[0].StackID != stack.StackID { - t.Fatalf("expected stack %s, got %s", stack.StackID, stacks[0].StackID) - } - - if stacks[0].Username != user.Username { - t.Fatalf("expected username %s, got %s", user.Username, stacks[0].Username) - } - - if stacks[0].ChallengeTitle != challenge.Title { - t.Fatalf("expected challenge title %s, got %s", challenge.Title, stacks[0].ChallengeTitle) - } - - if err := env.stackRepo.DeleteByUserAndChallenge(context.Background(), user.ID, challenge.ID); err != nil { - t.Fatalf("DeleteByUserAndChallenge: %v", err) - } - - if _, err := env.stackRepo.GetByStackID(context.Background(), stack.StackID); !errors.Is(err, ErrNotFound) { - t.Fatalf("expected ErrNotFound, got %v", err) - } -} - -func TestStackRepoListByUserOrdering(t *testing.T) { - env := setupRepoTest(t) - - user := createUserWithNewTeam(t, env, "order@example.com", "order", "pass", models.UserRole) - challenge1 := createChallenge(t, env, "Ch1", 100, "flag{1}", true) - challenge2 := createChallenge(t, env, "Ch2", 100, "flag{2}", true) - - createStack(t, env, user.ID, challenge1.ID, "stack-old", time.Now().UTC().Add(-time.Hour)) - createStack(t, env, user.ID, challenge2.ID, "stack-new", time.Now().UTC()) - - stacks, err := env.stackRepo.ListByUser(context.Background(), user.ID) - if err != nil { - t.Fatalf("ListByUser: %v", err) - } - - if len(stacks) != 2 { - t.Fatalf("expected 2 stacks, got %d", len(stacks)) - } - - if stacks[0].StackID != "stack-new" { - t.Fatalf("expected newest stack first, got %s", stacks[0].StackID) - } -} - -func TestStackRepoListAll(t *testing.T) { - env := setupRepoTest(t) - - user := createUserWithNewTeam(t, env, "all@example.com", "all", "pass", models.UserRole) - challenge1 := createChallenge(t, env, "ChAll1", 100, "flag{all1}", true) - challenge2 := createChallenge(t, env, "ChAll2", 100, "flag{all2}", true) - - createStack(t, env, user.ID, challenge1.ID, "stack-all-1", time.Now().UTC().Add(-time.Minute)) - createStack(t, env, user.ID, challenge2.ID, "stack-all-2", time.Now().UTC()) - - stacks, err := env.stackRepo.ListAll(context.Background()) - if err != nil { - t.Fatalf("ListAll: %v", err) - } - - if len(stacks) != 2 { - t.Fatalf("expected 2 stacks, got %d", len(stacks)) - } - - if stacks[0].StackID != "stack-all-2" { - t.Fatalf("expected newest stack first, got %s", stacks[0].StackID) - } -} - -func TestStackRepoCountByUserExcludingStatuses(t *testing.T) { - env := setupRepoTest(t) - - user := createUserWithNewTeam(t, env, "count@example.com", "count", "pass", models.UserRole) - challenge := createChallenge(t, env, "CountCh", 100, "flag{count}", true) - terminalChallenge := createChallenge(t, env, "CountChTerm", 100, "flag{count2}", true) - if terminalChallenge.ID == challenge.ID { - terminalChallenge = createChallenge(t, env, "CountChTerm2", 100, "flag{count3}", true) - } - - now := time.Now().UTC() - createStack(t, env, user.ID, challenge.ID, "stack-running", now) - - terminal := &models.Stack{ - UserID: user.ID, - ChallengeID: terminalChallenge.ID, - StackID: "stack-stopped", - Status: "stopped", - CreatedAt: now.Add(-time.Minute), - UpdatedAt: now.Add(-time.Minute), - } - if err := env.stackRepo.Create(context.Background(), terminal); err != nil { - t.Fatalf("create terminal stack: %v", err) - } - - count, err := env.stackRepo.CountByUserExcludingStatuses(context.Background(), user.ID, []string{"stopped"}) - if err != nil { - t.Fatalf("CountByUserExcludingStatuses: %v", err) - } - - if count != 1 { - t.Fatalf("expected count 1, got %d", count) - } - - count, err = env.stackRepo.CountByUserExcludingStatuses(context.Background(), user.ID, nil) - if err != nil { - t.Fatalf("CountByUserExcludingStatuses nil: %v", err) - } - - if count != 2 { - t.Fatalf("expected count 2, got %d", count) - } -} - -func TestStackRepoListByTeam(t *testing.T) { - env := setupRepoTest(t) - - team := createTeam(t, env, "ListTeam") - userA := createUserWithTeam(t, env, "teamA@example.com", "teamA", "pass", models.UserRole, team.ID) - userB := createUserWithTeam(t, env, "teamB@example.com", "teamB", "pass", models.UserRole, team.ID) - otherTeam := createTeam(t, env, "OtherTeam") - otherUser := createUserWithTeam(t, env, "other@example.com", "other", "pass", models.UserRole, otherTeam.ID) - - challengeA := createChallenge(t, env, "TeamCh1", 100, "flag{ta}", true) - challengeB := createChallenge(t, env, "TeamCh2", 100, "flag{tb}", true) - otherChallenge := createChallenge(t, env, "OtherCh", 100, "flag{tc}", true) - - createStack(t, env, userA.ID, challengeA.ID, "stack-team-1", time.Now().UTC().Add(-time.Minute)) - createStack(t, env, userB.ID, challengeB.ID, "stack-team-2", time.Now().UTC()) - createStack(t, env, otherUser.ID, otherChallenge.ID, "stack-other", time.Now().UTC()) - - stacks, err := env.stackRepo.ListByTeam(context.Background(), team.ID) - if err != nil { - t.Fatalf("ListByTeam: %v", err) - } - - if len(stacks) != 2 { - t.Fatalf("expected 2 stacks, got %d", len(stacks)) - } - - if stacks[0].StackID != "stack-team-2" { - t.Fatalf("expected newest team stack first, got %s", stacks[0].StackID) - } - - if stacks[0].Username == "" || stacks[0].ChallengeTitle == "" { - t.Fatalf("expected username and challenge title set, got %+v", stacks[0]) - } -} - -func TestStackRepoGetByTeamAndChallenge(t *testing.T) { - env := setupRepoTest(t) - - team := createTeam(t, env, "TeamGet") - user := createUserWithTeam(t, env, "teamget@example.com", "teamget", "pass", models.UserRole, team.ID) - challenge := createChallenge(t, env, "TeamGetCh", 100, "flag{tg}", true) - - createStack(t, env, user.ID, challenge.ID, "stack-team-get", time.Now().UTC()) - - got, err := env.stackRepo.GetByTeamAndChallenge(context.Background(), team.ID, challenge.ID) - if err != nil { - t.Fatalf("GetByTeamAndChallenge: %v", err) - } - - if got.StackID != "stack-team-get" { - t.Fatalf("expected stack-team-get, got %s", got.StackID) - } - - if got.Username != user.Username || got.ChallengeTitle != challenge.Title { - t.Fatalf("expected username %s and title %s, got %+v", user.Username, challenge.Title, got) - } -} - -func TestStackRepoCountByTeamExcludingStatuses(t *testing.T) { - env := setupRepoTest(t) - - team := createTeam(t, env, "CountTeam") - user := createUserWithTeam(t, env, "countteam@example.com", "countteam", "pass", models.UserRole, team.ID) - challenge := createChallenge(t, env, "CountTeamCh", 100, "flag{ct}", true) - terminalChallenge := createChallenge(t, env, "CountTeamChTerm", 100, "flag{ct2}", true) - - now := time.Now().UTC() - createStack(t, env, user.ID, challenge.ID, "stack-team-running", now) - - terminal := &models.Stack{ - UserID: user.ID, - ChallengeID: terminalChallenge.ID, - StackID: "stack-team-stopped", - Status: "stopped", - CreatedAt: now.Add(-time.Minute), - UpdatedAt: now.Add(-time.Minute), - } - if err := env.stackRepo.Create(context.Background(), terminal); err != nil { - t.Fatalf("create terminal stack: %v", err) - } - - count, err := env.stackRepo.CountByTeamExcludingStatuses(context.Background(), team.ID, []string{"stopped"}) - if err != nil { - t.Fatalf("CountByTeamExcludingStatuses: %v", err) - } - - if count != 1 { - t.Fatalf("expected count 1, got %d", count) - } -} - -func TestStackRepoTeamIDForUser(t *testing.T) { - env := setupRepoTest(t) - - team := createTeam(t, env, "TeamLookup") - user := createUserWithTeam(t, env, "lookup@example.com", "lookup", "pass", models.UserRole, team.ID) - - teamID, err := env.stackRepo.TeamIDForUser(context.Background(), user.ID) - if err != nil { - t.Fatalf("TeamIDForUser: %v", err) - } - - if teamID != team.ID { - t.Fatalf("expected team id %d, got %d", team.ID, teamID) - } -} - -func TestStackRepoListAdmin(t *testing.T) { - env := setupRepoTest(t) - - team := createTeam(t, env, "Alpha") - user := createUserWithTeam(t, env, "admin-stack@example.com", "adminstack", "pass", models.UserRole, team.ID) - challenge := createChallenge(t, env, "AdminStack", 200, "flag{admin}", true) - - createStack(t, env, user.ID, challenge.ID, "stack-admin", time.Now().UTC()) - - stacks, err := env.stackRepo.ListAdmin(context.Background()) - if err != nil { - t.Fatalf("ListAdmin: %v", err) - } - - if len(stacks) != 1 { - t.Fatalf("expected 1 stack, got %d", len(stacks)) - } - - item := stacks[0] - if item.StackID != "stack-admin" { - t.Fatalf("expected stack-admin, got %s", item.StackID) - } - - if item.Username != user.Username || item.Email != user.Email { - t.Fatalf("expected user info, got %+v", item) - } - - if item.TeamName != team.Name { - t.Fatalf("expected team name %s, got %s", team.Name, item.TeamName) - } - - if item.ChallengeTitle != challenge.Title || item.ChallengeCategory != challenge.Category { - t.Fatalf("expected challenge info, got %+v", item) - } -} - -func TestStackRepoNotFound(t *testing.T) { - env := setupRepoTest(t) - _, err := env.stackRepo.GetByStackID(context.Background(), "missing") - if !errors.Is(err, ErrNotFound) { - t.Fatalf("expected ErrNotFound, got %v", err) - } -} diff --git a/internal/repo/testenv_test.go b/internal/repo/testenv_test.go index 90a6b99..3a900af 100644 --- a/internal/repo/testenv_test.go +++ b/internal/repo/testenv_test.go @@ -27,7 +27,7 @@ type repoEnv struct { teamRepo *TeamRepo challengeRepo *ChallengeRepo submissionRepo *SubmissionRepo - stackRepo *StackRepo + vmRepo *VMRepo defaultDivisionID int64 } @@ -154,7 +154,7 @@ func setupRepoTest(t *testing.T) repoEnv { teamRepo: NewTeamRepo(repoDB), challengeRepo: NewChallengeRepo(repoDB), submissionRepo: NewSubmissionRepo(repoDB), - stackRepo: NewStackRepo(repoDB), + vmRepo: NewVMRepo(repoDB), } division := &models.Division{ @@ -172,7 +172,7 @@ func setupRepoTest(t *testing.T) repoEnv { func resetRepoState(t *testing.T) { t.Helper() - if _, err := repoDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, stacks, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { + if _, err := repoDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, vms, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } } diff --git a/internal/repo/vm_repo.go b/internal/repo/vm_repo.go new file mode 100644 index 0000000..f4df557 --- /dev/null +++ b/internal/repo/vm_repo.go @@ -0,0 +1,197 @@ +package repo + +import ( + "context" + + "smctf/internal/models" + + "github.com/uptrace/bun" +) + +type VMRepo struct { + db *bun.DB +} + +func NewVMRepo(db *bun.DB) *VMRepo { + return &VMRepo{db: db} +} + +func (r *VMRepo) ListByUser(ctx context.Context, userID int64) ([]models.VM, error) { + vms := make([]models.VM, 0) + if err := r.db.NewSelect(). + Model(&vms). + ColumnExpr("vm.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = vm.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = vm.challenge_id"). + Where("vm.user_id = ?", userID). + Order("vm.created_at DESC"). + Scan(ctx); err != nil { + return nil, wrapError("vmRepo.ListByUser", err) + } + + return vms, nil +} + +func (r *VMRepo) ListByTeamUser(ctx context.Context, userID int64) ([]models.VM, error) { + vms := make([]models.VM, 0) + if err := r.db.NewSelect(). + Model(&vms). + ColumnExpr("vm.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = vm.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = vm.challenge_id"). + Where("vm.user_id IN (SELECT u2.id FROM users AS u2 JOIN users AS me ON me.id = ? WHERE u2.team_id = me.team_id)", userID). + Order("vm.created_at DESC"). + Scan(ctx); err != nil { + return nil, wrapError("vmRepo.ListByTeamUser", err) + } + + return vms, nil +} + +func (r *VMRepo) ListAdmin(ctx context.Context) ([]models.AdminVMSummary, error) { + vms := make([]models.AdminVMSummary, 0) + if err := r.db.NewSelect(). + TableExpr("vms AS v"). + ColumnExpr("v.vm_id AS vm_id"). + ColumnExpr("v.ttl_expires_at AS ttl_expires_at"). + ColumnExpr("v.created_at AS created_at"). + ColumnExpr("v.updated_at AS updated_at"). + ColumnExpr("v.user_id AS user_id"). + ColumnExpr("u.username AS username"). + ColumnExpr("u.email AS email"). + ColumnExpr("u.team_id AS team_id"). + ColumnExpr("t.name AS team_name"). + ColumnExpr("v.challenge_id AS challenge_id"). + ColumnExpr("c.title AS challenge_title"). + ColumnExpr("c.category AS challenge_category"). + Join("JOIN users AS u ON u.id = v.user_id"). + Join("JOIN teams AS t ON t.id = u.team_id"). + Join("JOIN challenges AS c ON c.id = v.challenge_id"). + OrderExpr("v.created_at DESC"). + Scan(ctx, &vms); err != nil { + return nil, wrapError("vmRepo.ListAdmin", err) + } + + return vms, nil +} + +func (r *VMRepo) ListAll(ctx context.Context) ([]models.VM, error) { + vms := make([]models.VM, 0) + if err := r.db.NewSelect(). + Model(&vms). + ColumnExpr("vm.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = vm.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = vm.challenge_id"). + Order("vm.created_at DESC"). + Scan(ctx); err != nil { + return nil, wrapError("vmRepo.ListAll", err) + } + + return vms, nil +} + +func (r *VMRepo) CountByUser(ctx context.Context, userID int64) (int, error) { + count, err := r.db.NewSelect().Model((*models.VM)(nil)).Where("user_id = ?", userID).Count(ctx) + if err != nil { + return 0, wrapError("vmRepo.CountByUser", err) + } + + return count, nil +} + +func (r *VMRepo) CountByTeamUser(ctx context.Context, userID int64) (int, error) { + count, err := r.db.NewSelect(). + Model((*models.VM)(nil)). + Where("user_id IN (SELECT u2.id FROM users AS u2 JOIN users AS me ON me.id = ? WHERE u2.team_id = me.team_id)", userID). + Count(ctx) + if err != nil { + return 0, wrapError("vmRepo.CountByTeamUser", err) + } + + return count, nil +} + +func (r *VMRepo) GetByUserAndChallenge(ctx context.Context, userID, challengeID int64) (*models.VM, error) { + vm := new(models.VM) + if err := r.db.NewSelect(). + Model(vm). + ColumnExpr("vm.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = vm.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = vm.challenge_id"). + Where("vm.user_id = ?", userID). + Where("vm.challenge_id = ?", challengeID). + Scan(ctx); err != nil { + return nil, wrapNotFound("vmRepo.GetByUserAndChallenge", err) + } + + return vm, nil +} + +func (r *VMRepo) GetByTeamUserAndChallenge(ctx context.Context, userID, challengeID int64) (*models.VM, error) { + vm := new(models.VM) + if err := r.db.NewSelect(). + Model(vm). + ColumnExpr("vm.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = vm.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = vm.challenge_id"). + Where("vm.challenge_id = ?", challengeID). + Where("vm.user_id IN (SELECT u2.id FROM users AS u2 JOIN users AS me ON me.id = ? WHERE u2.team_id = me.team_id)", userID). + OrderExpr("vm.created_at DESC"). + Limit(1). + Scan(ctx); err != nil { + return nil, wrapNotFound("vmRepo.GetByTeamUserAndChallenge", err) + } + + return vm, nil +} + +func (r *VMRepo) GetByVMID(ctx context.Context, vmID string) (*models.VM, error) { + vm := new(models.VM) + if err := r.db.NewSelect(). + Model(vm). + ColumnExpr("vm.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = vm.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = vm.challenge_id"). + Where("vm.vm_id = ?", vmID). + Scan(ctx); err != nil { + return nil, wrapNotFound("vmRepo.GetByVMID", err) + } + + return vm, nil +} + +func (r *VMRepo) Create(ctx context.Context, vm *models.VM) error { + if _, err := r.db.NewInsert().Model(vm).Exec(ctx); err != nil { + return wrapError("vmRepo.Create", err) + } + + return nil +} + +func (r *VMRepo) Update(ctx context.Context, vm *models.VM) error { + if _, err := r.db.NewUpdate().Model(vm).WherePK().Exec(ctx); err != nil { + return wrapError("vmRepo.Update", err) + } + + return nil +} + +func (r *VMRepo) Delete(ctx context.Context, vm *models.VM) error { + if _, err := r.db.NewDelete().Model(vm).WherePK().Exec(ctx); err != nil { + return wrapError("vmRepo.Delete", err) + } + + return nil +} diff --git a/internal/repo/vm_repo_test.go b/internal/repo/vm_repo_test.go new file mode 100644 index 0000000..304b62e --- /dev/null +++ b/internal/repo/vm_repo_test.go @@ -0,0 +1,225 @@ +package repo + +import ( + "context" + "errors" + "testing" + "time" + + "smctf/internal/models" + vmpkg "smctf/internal/vm" +) + +func createVMRow(t *testing.T, vmRepo *VMRepo, userID, challengeID int64, vmID, status string, createdAt time.Time) *models.VM { + t.Helper() + row := &models.VM{ + UserID: userID, + ChallengeID: challengeID, + VMID: vmID, + Status: status, + Ports: vmpkg.PortMappings{{HostPort: 10000, ContainerPort: 31337, Protocol: "tcp"}}, + TTLExpiresAt: ptrTime(createdAt.Add(time.Hour)), + CreatedAt: createdAt, + UpdatedAt: createdAt, + } + if err := vmRepo.Create(context.Background(), row); err != nil { + t.Fatalf("create vm: %v", err) + } + + return row +} + +func ptrTime(t time.Time) *time.Time { return &t } + +func TestVMRepoCRUD(t *testing.T) { + env := setupRepoTest(t) + vmRepo := NewVMRepo(env.db) + user := createUserWithNewTeam(t, env, "vmcrud@example.com", "vmcrud", "pass", models.UserRole) + challenge := createChallenge(t, env, "VM CRUD", 100, "flag{vmcrud}", true) + + now := time.Now().UTC() + created := createVMRow(t, vmRepo, user.ID, challenge.ID, "vm-crud-1", "Pending", now) + + got, err := vmRepo.GetByUserAndChallenge(context.Background(), user.ID, challenge.ID) + if err != nil { + t.Fatalf("GetByUserAndChallenge: %v", err) + } + + if got.VMID != created.VMID || got.Username != user.Username || got.ChallengeTitle != challenge.Title { + t.Fatalf("unexpected vm row: %+v", got) + } + + got.Status = "Running" + lastErr := "none" + got.LastError = &lastErr + if err := vmRepo.Update(context.Background(), got); err != nil { + t.Fatalf("Update: %v", err) + } + + updated, err := vmRepo.GetByVMID(context.Background(), created.VMID) + if err != nil { + t.Fatalf("GetByVMID: %v", err) + } + + if updated.Status != "Running" { + t.Fatalf("expected updated status Running, got %s", updated.Status) + } + + if err := vmRepo.Delete(context.Background(), updated); err != nil { + t.Fatalf("Delete: %v", err) + } + + if _, err := vmRepo.GetByVMID(context.Background(), created.VMID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestVMRepoListAndCountByUser(t *testing.T) { + env := setupRepoTest(t) + vmRepo := NewVMRepo(env.db) + user := createUserWithNewTeam(t, env, "vmlist@example.com", "vmlist", "pass", models.UserRole) + challenge1 := createChallenge(t, env, "VM List 1", 100, "flag{vmlist1}", true) + challenge2 := createChallenge(t, env, "VM List 2", 100, "flag{vmlist2}", true) + + now := time.Now().UTC() + createVMRow(t, vmRepo, user.ID, challenge1.ID, "vm-old", "Running", now.Add(-time.Minute)) + createVMRow(t, vmRepo, user.ID, challenge2.ID, "vm-new", "Pending", now) + + list, err := vmRepo.ListByUser(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + + if len(list) != 2 { + t.Fatalf("expected 2 vm rows, got %d", len(list)) + } + + if list[0].VMID != "vm-new" { + t.Fatalf("expected newest row first, got %+v", list) + } + + count, err := vmRepo.CountByUser(context.Background(), user.ID) + if err != nil { + t.Fatalf("CountByUser: %v", err) + } + + if count != 2 { + t.Fatalf("expected count 2, got %d", count) + } +} + +func TestVMRepoListAdmin(t *testing.T) { + env := setupRepoTest(t) + vmRepo := NewVMRepo(env.db) + user := createUserWithNewTeam(t, env, "vmadmin@example.com", "vmadmin", "pass", models.UserRole) + challenge := createChallenge(t, env, "VM Admin", 300, "flag{vmadmin}", true) + + createVMRow(t, vmRepo, user.ID, challenge.ID, "vm-admin-1", "Running", time.Now().UTC()) + + rows, err := vmRepo.ListAdmin(context.Background()) + if err != nil { + t.Fatalf("ListAdmin: %v", err) + } + + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + if rows[0].VMID != "vm-admin-1" || rows[0].Username != user.Username || rows[0].ChallengeTitle != challenge.Title { + t.Fatalf("unexpected admin row: %+v", rows[0]) + } +} + +func TestVMRepoNotFound(t *testing.T) { + env := setupRepoTest(t) + vmRepo := NewVMRepo(env.db) + + if _, err := vmRepo.GetByVMID(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + + if _, err := vmRepo.GetByUserAndChallenge(context.Background(), 999, 999); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestVMRepoTeamScopeQueries(t *testing.T) { + env := setupRepoTest(t) + vmRepo := NewVMRepo(env.db) + + team := createTeam(t, env, "TeamScope") + user1 := createUserWithTeam(t, env, "team1@example.com", "team1", "pass", models.UserRole, team.ID) + user2 := createUserWithTeam(t, env, "team2@example.com", "team2", "pass", models.UserRole, team.ID) + other := createUserWithNewTeam(t, env, "other@example.com", "other", "pass", models.UserRole) + + ch1 := createChallenge(t, env, "Team Challenge 1", 100, "flag{t1}", true) + ch2 := createChallenge(t, env, "Team Challenge 2", 100, "flag{t2}", true) + chOther := createChallenge(t, env, "Other Challenge", 100, "flag{o}", true) + + now := time.Now().UTC() + createVMRow(t, vmRepo, user1.ID, ch1.ID, "vm-team-1", "Running", now.Add(-time.Minute)) + createVMRow(t, vmRepo, user2.ID, ch2.ID, "vm-team-2", "Pending", now) + createVMRow(t, vmRepo, other.ID, chOther.ID, "vm-other-1", "Running", now.Add(-2*time.Minute)) + + list, err := vmRepo.ListByTeamUser(context.Background(), user1.ID) + if err != nil { + t.Fatalf("ListByTeamUser: %v", err) + } + + if len(list) != 2 { + t.Fatalf("expected 2 team VMs, got %d", len(list)) + } + + if list[0].VMID != "vm-team-2" { + t.Fatalf("expected newest team VM first, got %+v", list) + } + + count, err := vmRepo.CountByTeamUser(context.Background(), user1.ID) + if err != nil { + t.Fatalf("CountByTeamUser: %v", err) + } + + if count != 2 { + t.Fatalf("expected count 2, got %d", count) + } + + got, err := vmRepo.GetByTeamUserAndChallenge(context.Background(), user1.ID, ch2.ID) + if err != nil { + t.Fatalf("GetByTeamUserAndChallenge: %v", err) + } + + if got.VMID != "vm-team-2" { + t.Fatalf("expected vm-team-2, got %+v", got) + } + + if _, err := vmRepo.GetByTeamUserAndChallenge(context.Background(), user1.ID, chOther.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for other team VM, got %v", err) + } +} + +func TestVMRepoListAll(t *testing.T) { + env := setupRepoTest(t) + vmRepo := NewVMRepo(env.db) + + user := createUserWithNewTeam(t, env, "all1@example.com", "all1", "pass", models.UserRole) + user2 := createUserWithNewTeam(t, env, "all2@example.com", "all2", "pass", models.UserRole) + ch1 := createChallenge(t, env, "All 1", 100, "flag{a1}", true) + ch2 := createChallenge(t, env, "All 2", 100, "flag{a2}", true) + + now := time.Now().UTC() + createVMRow(t, vmRepo, user.ID, ch1.ID, "vm-all-1", "Running", now.Add(-time.Minute)) + createVMRow(t, vmRepo, user2.ID, ch2.ID, "vm-all-2", "Pending", now) + + rows, err := vmRepo.ListAll(context.Background()) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + + if rows[0].VMID != "vm-all-2" { + t.Fatalf("expected newest row first, got %+v", rows) + } +} diff --git a/internal/service/ctf_service.go b/internal/service/ctf_service.go index 76ae09d..ce3df5c 100644 --- a/internal/service/ctf_service.go +++ b/internal/service/ctf_service.go @@ -10,7 +10,6 @@ import ( "smctf/internal/config" "smctf/internal/models" "smctf/internal/repo" - "smctf/internal/stack" "smctf/internal/storage" "smctf/internal/utils" @@ -39,42 +38,6 @@ var challengeCategories = map[string]struct{}{ "Blockchain": {}, } -func normalizeStackTargetPorts(ports stack.TargetPortSpecs, validator *fieldValidator) stack.TargetPortSpecs { - if len(ports) == 0 { - validator.fields = append(validator.fields, FieldError{Field: "stack_target_ports", Reason: "required"}) - return nil - } - - normalized := make(stack.TargetPortSpecs, 0, len(ports)) - seen := make(map[string]struct{}) - for _, port := range ports { - if port.ContainerPort <= 0 || port.ContainerPort > 65535 { - validator.fields = append(validator.fields, FieldError{Field: "stack_target_ports", Reason: "invalid"}) - continue - } - - protocol := strings.ToUpper(strings.TrimSpace(port.Protocol)) - if protocol != "TCP" && protocol != "UDP" { - validator.fields = append(validator.fields, FieldError{Field: "stack_target_ports", Reason: "invalid"}) - continue - } - - key := fmt.Sprintf("%d/%s", port.ContainerPort, protocol) - if _, exists := seen[key]; exists { - validator.fields = append(validator.fields, FieldError{Field: "stack_target_ports", Reason: "invalid"}) - continue - } - seen[key] = struct{}{} - - normalized = append(normalized, stack.TargetPortSpec{ - ContainerPort: port.ContainerPort, - Protocol: protocol, - }) - } - - return normalized -} - type CTFService struct { cfg config.Config challengeRepo *repo.ChallengeRepo @@ -123,7 +86,7 @@ func (s *CTFService) GetChallengeByID(ctx context.Context, id int64) (*models.Ch return challenge, nil } -func (s *CTFService) CreateChallenge(ctx context.Context, title, description, category string, points int, minimumPoints int, flag string, active bool, stackEnabled bool, stackTargetPorts stack.TargetPortSpecs, stackPodSpec *string, previousChallengeID *int64) (*models.Challenge, error) { +func (s *CTFService) CreateChallenge(ctx context.Context, title, description, category string, points int, minimumPoints int, flag string, active bool, vmEnabled bool, vmSpec *string, previousChallengeID *int64) (*models.Challenge, error) { title = normalizeTrim(title) description = normalizeTrim(description) category = normalizeTrim(category) @@ -148,10 +111,9 @@ func (s *CTFService) CreateChallenge(ctx context.Context, title, description, ca validator.fields = append(validator.fields, FieldError{Field: "category", Reason: "invalid"}) } - if stackEnabled { - stackTargetPorts = normalizeStackTargetPorts(stackTargetPorts, validator) - if stackPodSpec == nil || normalizeTrim(*stackPodSpec) == "" { - validator.fields = append(validator.fields, FieldError{Field: "stack_pod_spec", Reason: "required"}) + if vmEnabled { + if vmSpec == nil || normalizeTrim(*vmSpec) == "" { + validator.fields = append(validator.fields, FieldError{Field: "vm_spec", Reason: "required"}) } } @@ -169,12 +131,10 @@ func (s *CTFService) CreateChallenge(ctx context.Context, title, description, ca } } - podSpec := (*string)(nil) - if stackEnabled && stackPodSpec != nil { - trimmed := normalizeTrim(*stackPodSpec) - podSpec = &trimmed - } else if !stackEnabled { - stackTargetPorts = nil + normalizedSpec := (*string)(nil) + if vmEnabled && vmSpec != nil { + trimmed := normalizeTrim(*vmSpec) + normalizedSpec = &trimmed } challenge := &models.Challenge{ @@ -184,9 +144,8 @@ func (s *CTFService) CreateChallenge(ctx context.Context, title, description, ca Points: points, MinimumPoints: minimumPoints, PreviousChallengeID: previousChallengeID, - StackEnabled: stackEnabled, - StackTargetPorts: stackTargetPorts, - StackPodSpec: podSpec, + VMEnabled: vmEnabled, + VMSpec: normalizedSpec, IsActive: active, CreatedAt: time.Now().UTC(), } @@ -209,7 +168,7 @@ func (s *CTFService) CreateChallenge(ctx context.Context, title, description, ca return challenge, nil } -func (s *CTFService) UpdateChallenge(ctx context.Context, id int64, title, description, category *string, points *int, minimumPoints *int, flag *string, active *bool, stackEnabled *bool, stackTargetPorts *[]stack.TargetPortSpec, stackPodSpec *string, previousChallengeID *int64, previousChallengeSet bool) (*models.Challenge, error) { +func (s *CTFService) UpdateChallenge(ctx context.Context, id int64, title, description, category *string, points *int, minimumPoints *int, flag *string, active *bool, vmEnabled *bool, vmSpec *string, previousChallengeID *int64, previousChallengeSet bool) (*models.Challenge, error) { validator := newFieldValidator() validator.PositiveID("id", id) @@ -247,10 +206,10 @@ func (s *CTFService) UpdateChallenge(ctx context.Context, id int64, title, descr } } - var normalizedPodSpec *string - if stackPodSpec != nil { - value := strings.TrimSpace(*stackPodSpec) - normalizedPodSpec = &value + var normalizedVMSpec *string + if vmSpec != nil { + value := strings.TrimSpace(*vmSpec) + normalizedVMSpec = &value } if points != nil { @@ -317,37 +276,22 @@ func (s *CTFService) UpdateChallenge(ctx context.Context, id int64, title, descr challenge.IsActive = *active } - if stackEnabled != nil { - challenge.StackEnabled = *stackEnabled - if !*stackEnabled { - challenge.StackTargetPorts = nil - challenge.StackPodSpec = nil - } - } - - if stackTargetPorts != nil { - if !challenge.StackEnabled { - return nil, NewValidationError(FieldError{Field: "stack_target_ports", Reason: "stack disabled"}) + if vmEnabled != nil { + challenge.VMEnabled = *vmEnabled + if !*vmEnabled { + challenge.VMSpec = nil } - - validator := newFieldValidator() - normalized := normalizeStackTargetPorts(stack.TargetPortSpecs(*stackTargetPorts), validator) - if err := validator.Error(); err != nil { - return nil, err - } - - challenge.StackTargetPorts = normalized } - if normalizedPodSpec != nil { - if !challenge.StackEnabled { - return nil, NewValidationError(FieldError{Field: "stack_pod_spec", Reason: "stack disabled"}) + if normalizedVMSpec != nil { + if !challenge.VMEnabled { + return nil, NewValidationError(FieldError{Field: "vm_spec", Reason: "vm disabled"}) } - if *normalizedPodSpec == "" { - challenge.StackPodSpec = nil + if *normalizedVMSpec == "" { + challenge.VMSpec = nil } else { - challenge.StackPodSpec = normalizedPodSpec + challenge.VMSpec = normalizedVMSpec } } @@ -360,13 +304,9 @@ func (s *CTFService) UpdateChallenge(ctx context.Context, id int64, title, descr challenge.FlagHash = flagHash } - if challenge.StackEnabled { - if len(challenge.StackTargetPorts) == 0 { - return nil, NewValidationError(FieldError{Field: "stack_target_ports", Reason: "required"}) - } - - if challenge.StackPodSpec == nil || normalizeTrim(*challenge.StackPodSpec) == "" { - return nil, NewValidationError(FieldError{Field: "stack_pod_spec", Reason: "required"}) + if challenge.VMEnabled { + if challenge.VMSpec == nil || normalizeTrim(*challenge.VMSpec) == "" { + return nil, NewValidationError(FieldError{Field: "vm_spec", Reason: "required"}) } } diff --git a/internal/service/ctf_service_test.go b/internal/service/ctf_service_test.go index 2517fdb..075ff67 100644 --- a/internal/service/ctf_service_test.go +++ b/internal/service/ctf_service_test.go @@ -10,7 +10,6 @@ import ( "smctf/internal/db" "smctf/internal/models" "smctf/internal/repo" - "smctf/internal/stack" "smctf/internal/storage" "smctf/internal/utils" @@ -58,7 +57,7 @@ func newClosedServiceDB(t *testing.T) *bun.DB { func TestCTFServiceCreateAndListChallenges(t *testing.T) { env := setupServiceTest(t) - challenge, err := env.ctfSvc.CreateChallenge(context.Background(), "Title", "Desc", "Misc", 100, 80, "FLAG{1}", true, false, nil, nil, nil) + challenge, err := env.ctfSvc.CreateChallenge(context.Background(), "Title", "Desc", "Misc", 100, 80, "FLAG{1}", true, false, nil, nil) if err != nil { t.Fatalf("create challenge: %v", err) } @@ -88,62 +87,47 @@ func TestCTFServiceCreateAndListChallenges(t *testing.T) { func TestCTFServiceCreateChallengeValidation(t *testing.T) { env := setupServiceTest(t) - _, err := env.ctfSvc.CreateChallenge(context.Background(), "", "", "Nope", -1, 0, "", true, false, nil, nil, nil) + _, err := env.ctfSvc.CreateChallenge(context.Background(), "", "", "Nope", -1, 0, "", true, false, nil, nil) var ve *ValidationError if !errors.As(err, &ve) { t.Fatalf("expected validation error, got %v", err) } - _, err = env.ctfSvc.CreateChallenge(context.Background(), "Title", "Desc", "Misc", 100, 200, "FLAG{X}", true, false, nil, nil, nil) + _, err = env.ctfSvc.CreateChallenge(context.Background(), "Title", "Desc", "Misc", 100, 200, "FLAG{X}", true, false, nil, nil) if !errors.As(err, &ve) { t.Fatalf("expected validation error for minimum_points, got %v", err) } - podSpec := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" - _, err = env.ctfSvc.CreateChallenge(context.Background(), "Stack", "Desc", "Web", 100, 80, "FLAG{S}", true, true, nil, &podSpec, nil) + _, err = env.ctfSvc.CreateChallenge(context.Background(), "VM", "Desc", "Web", 100, 80, "FLAG{S}", true, true, nil, nil) if !errors.As(err, &ve) { - t.Fatalf("expected validation error for stack_target_ports, got %v", err) + t.Fatalf("expected validation error for vm_spec, got %v", err) } missingPrev := int64(9999) - _, err = env.ctfSvc.CreateChallenge(context.Background(), "Locked", "Desc", "Misc", 100, 80, "FLAG{P}", true, false, nil, nil, &missingPrev) + _, err = env.ctfSvc.CreateChallenge(context.Background(), "Locked", "Desc", "Misc", 100, 80, "FLAG{P}", true, false, nil, &missingPrev) if !errors.As(err, &ve) { t.Fatalf("expected validation error for previous_challenge_id, got %v", err) } } -func TestCTFServiceStackTargetPortsValidation(t *testing.T) { +func TestCTFServiceVMSpecValidation(t *testing.T) { env := setupServiceTest(t) - podSpec := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" + sandboxSpec := "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: test\nspec:\n containers:\n - name: app\n image: nginx\n" + empty := " " - invalidProtocol := stack.TargetPortSpecs{{ContainerPort: 80, Protocol: "ICMP"}} - _, err := env.ctfSvc.CreateChallenge(context.Background(), "StackBadProto", "Desc", "Web", 100, 80, "FLAG{P1}", true, true, invalidProtocol, &podSpec, nil) + _, err := env.ctfSvc.CreateChallenge(context.Background(), "VMBadSpec", "Desc", "Web", 100, 80, "FLAG{P1}", true, true, &empty, nil) var ve *ValidationError if !errors.As(err, &ve) { - t.Fatalf("expected validation error for stack_target_ports protocol, got %v", err) + t.Fatalf("expected validation error for empty vm_spec, got %v", err) } - duplicatePorts := stack.TargetPortSpecs{ - {ContainerPort: 80, Protocol: "tcp"}, - {ContainerPort: 80, Protocol: "TCP"}, - } - _, err = env.ctfSvc.CreateChallenge(context.Background(), "StackDup", "Desc", "Web", 100, 80, "FLAG{P2}", true, true, duplicatePorts, &podSpec, nil) - if !errors.As(err, &ve) { - t.Fatalf("expected validation error for duplicate stack_target_ports, got %v", err) - } - - mixedProtocols := stack.TargetPortSpecs{ - {ContainerPort: 80, Protocol: "tcp"}, - {ContainerPort: 80, Protocol: "udp"}, - } - created, err := env.ctfSvc.CreateChallenge(context.Background(), "StackOK", "Desc", "Web", 100, 80, "FLAG{P3}", true, true, mixedProtocols, &podSpec, nil) + created, err := env.ctfSvc.CreateChallenge(context.Background(), "VMOK", "Desc", "Web", 100, 80, "FLAG{P3}", true, true, &sandboxSpec, nil) if err != nil { - t.Fatalf("expected mixed protocols to be allowed, got %v", err) + t.Fatalf("expected vm challenge create, got %v", err) } - - if len(created.StackTargetPorts) != 2 { - t.Fatalf("expected 2 stack_target_ports, got %d", len(created.StackTargetPorts)) + if created.VMSpec == nil || strings.TrimSpace(*created.VMSpec) == "" { + t.Fatalf("expected non-empty vm_spec") } } @@ -153,7 +137,7 @@ func TestCTFServiceListChallengesDynamicPoints(t *testing.T) { teamUser := createUserWithTeam(t, env, "t1@example.com", "t1", "pass", models.UserRole, team.ID) soloUser := createUserWithNewTeam(t, env, "s1@example.com", "s1", "pass", models.UserRole) - challenge, err := env.ctfSvc.CreateChallenge(context.Background(), "Dynamic", "Desc", "Misc", 500, 100, "FLAG{DYN}", true, false, nil, nil, nil) + challenge, err := env.ctfSvc.CreateChallenge(context.Background(), "Dynamic", "Desc", "Misc", 500, 100, "FLAG{DYN}", true, false, nil, nil) if err != nil { t.Fatalf("create challenge: %v", err) } @@ -217,7 +201,7 @@ func TestCTFServiceUpdateChallenge(t *testing.T) { newActive := false newMin := 40 - updated, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, &newTitle, &newDesc, &newCat, &newPoints, &newMin, nil, &newActive, nil, nil, nil, nil, false) + updated, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, &newTitle, &newDesc, &newCat, &newPoints, &newMin, nil, &newActive, nil, nil, nil, false) if err != nil { t.Fatalf("update challenge: %v", err) } @@ -227,12 +211,12 @@ func TestCTFServiceUpdateChallenge(t *testing.T) { } emptyFlag := " " - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, &emptyFlag, nil, nil, nil, nil, nil, false); err == nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, &emptyFlag, nil, nil, nil, nil, false); err == nil { t.Fatalf("expected empty flag to be rejected") } newFlag := "FLAG{UPDATED}" - updatedFlag, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, &newFlag, nil, nil, nil, nil, nil, false) + updatedFlag, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, &newFlag, nil, nil, nil, nil, false) if err != nil { t.Fatalf("expected flag update, got %v", err) } @@ -242,55 +226,55 @@ func TestCTFServiceUpdateChallenge(t *testing.T) { } badCat := "Bad" - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, &badCat, nil, nil, nil, nil, nil, nil, nil, nil, false); err == nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, &badCat, nil, nil, nil, nil, nil, nil, nil, false); err == nil { t.Fatalf("expected validation error") } whitespaceTitle := " " - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, &whitespaceTitle, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, false); err != nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, &whitespaceTitle, nil, nil, nil, nil, nil, nil, nil, nil, nil, false); err != nil { t.Fatalf("expected whitespace title to be allowed, got %v", err) } whitespaceDesc := " " - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, &whitespaceDesc, nil, nil, nil, nil, nil, nil, nil, nil, nil, false); err != nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, &whitespaceDesc, nil, nil, nil, nil, nil, nil, nil, nil, false); err != nil { t.Fatalf("expected whitespace description to be allowed, got %v", err) } whitespaceCat := " " - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, &whitespaceCat, nil, nil, nil, nil, nil, nil, nil, nil, false); err == nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, &whitespaceCat, nil, nil, nil, nil, nil, nil, nil, false); err == nil { t.Fatalf("expected whitespace category to be rejected") } negPoints := -1 - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, &negPoints, nil, nil, nil, nil, nil, nil, nil, false); err == nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, &negPoints, nil, nil, nil, nil, nil, nil, false); err == nil { t.Fatalf("expected negative points to be rejected") } negMin := -1 - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, &negMin, nil, nil, nil, nil, nil, nil, false); err == nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, &negMin, nil, nil, nil, nil, nil, false); err == nil { t.Fatalf("expected negative minimum_points to be rejected") } badMin := 200 - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, &newPoints, &badMin, nil, nil, nil, nil, nil, nil, false); err == nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, &newPoints, &badMin, nil, nil, nil, nil, nil, false); err == nil { t.Fatalf("expected minimum_points > points to be rejected") } prev := createChallenge(t, env, "Prev", 40, "FLAG{PREV}", true) prevID := prev.ID - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, &prevID, true); err != nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, nil, &prevID, true); err != nil { t.Fatalf("expected previous_challenge_id update, got %v", err) } - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, &challenge.ID, true); err == nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, nil, &challenge.ID, true); err == nil { t.Fatalf("expected self previous_challenge_id to be rejected") } - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, true); err != nil { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, true); err != nil { t.Fatalf("expected previous_challenge_id clear, got %v", err) } - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), 9999, &newTitle, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, false); !errors.Is(err, ErrChallengeNotFound) { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), 9999, &newTitle, nil, nil, nil, nil, nil, nil, nil, nil, nil, false); !errors.Is(err, ErrChallengeNotFound) { t.Fatalf("expected ErrChallengeNotFound, got %v", err) } } @@ -299,12 +283,12 @@ func TestCTFServiceChallengeFlagTooLong(t *testing.T) { env := setupServiceTest(t) longFlag := strings.Repeat("a", 73) - if _, err := env.ctfSvc.CreateChallenge(context.Background(), "Title", "Desc", "Misc", 100, 50, longFlag, true, false, nil, nil, nil); !errors.Is(err, ErrInvalidInput) { + if _, err := env.ctfSvc.CreateChallenge(context.Background(), "Title", "Desc", "Misc", 100, 50, longFlag, true, false, nil, nil); !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected invalid input for create long flag, got %v", err) } challenge := createChallenge(t, env, "Old", 50, "FLAG{2}", true) - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, &longFlag, nil, nil, nil, nil, nil, false); !errors.Is(err, ErrInvalidInput) { + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, &longFlag, nil, nil, nil, nil, false); !errors.Is(err, ErrInvalidInput) { t.Fatalf("expected invalid input for update long flag, got %v", err) } } @@ -769,38 +753,38 @@ func TestChallengeFileDeleteMissing(t *testing.T) { } } -func TestCTFServiceStackFields(t *testing.T) { +func TestCTFServiceVMFields(t *testing.T) { env := setupServiceTest(t) - podSpec := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" + sandboxSpec := "apiVersion: v1\nkind: Sandbox\nmetadata:\n name: test\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" - challenge, err := env.ctfSvc.CreateChallenge(context.Background(), "Stack", "Desc", "Web", 100, 80, "FLAG{STACK}", true, true, stack.TargetPortSpecs{{ContainerPort: 80, Protocol: "TCP"}}, &podSpec, nil) + challenge, err := env.ctfSvc.CreateChallenge(context.Background(), "VM", "Desc", "Web", 100, 80, "FLAG{VM}", true, true, &sandboxSpec, nil) if err != nil { t.Fatalf("create challenge: %v", err) } - if !challenge.StackEnabled || len(challenge.StackTargetPorts) != 1 || challenge.StackTargetPorts[0].ContainerPort != 80 || challenge.StackPodSpec == nil { - t.Fatalf("unexpected stack fields: %+v", challenge) + if !challenge.VMEnabled || challenge.VMSpec == nil { + t.Fatalf("unexpected vm fields: %+v", challenge) } disable := false - updated, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &disable, nil, nil, nil, false) + updated, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &disable, nil, nil, false) if err != nil { - t.Fatalf("disable stack: %v", err) + t.Fatalf("disable vm: %v", err) } - if updated.StackEnabled || len(updated.StackTargetPorts) != 0 || updated.StackPodSpec != nil { - t.Fatalf("expected stack cleared, got %+v", updated) + if updated.VMEnabled || updated.VMSpec != nil { + t.Fatalf("expected vm cleared, got %+v", updated) } - newPorts := []stack.TargetPortSpec{{ContainerPort: 80, Protocol: "TCP"}} - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, &newPorts, nil, nil, false); err == nil { - t.Fatalf("expected validation error when stack disabled") + newSpec := "apiVersion: v1" + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, nil, &newSpec, nil, false); err == nil { + t.Fatalf("expected validation error when vm disabled") } enable := true empty := "" - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &enable, &newPorts, &empty, nil, false); err == nil { - t.Fatalf("expected validation error for empty pod spec") + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &enable, &empty, nil, false); err == nil { + t.Fatalf("expected validation error for empty sandbox spec") } else { var ve *ValidationError if !errors.As(err, &ve) { @@ -808,18 +792,8 @@ func TestCTFServiceStackFields(t *testing.T) { } } - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &enable, nil, &podSpec, nil, false); err == nil { - t.Fatalf("expected validation error for missing stack_target_ports when stack enabled") - } - - outOfRangePorts := []stack.TargetPortSpec{{ContainerPort: 70000, Protocol: "TCP"}} - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &enable, &outOfRangePorts, &podSpec, nil, false); err == nil { - t.Fatalf("expected validation error for out-of-range port") - } - - zeroPorts := []stack.TargetPortSpec{{ContainerPort: 0, Protocol: "TCP"}} - if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &enable, &zeroPorts, &podSpec, nil, false); err == nil { - t.Fatalf("expected validation error for zero port") + if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, nil, nil, &enable, &sandboxSpec, nil, false); err != nil { + t.Fatalf("expected vm enable update success, got %v", err) } } diff --git a/internal/service/errors.go b/internal/service/errors.go index d485dfe..3190f48 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -13,12 +13,12 @@ var ( ErrStorageUnavailable = errors.New("storage unavailable") ErrAlreadySolved = errors.New("challenge already solved") ErrRateLimited = errors.New("too many submissions") - ErrStackDisabled = errors.New("stack feature disabled") - ErrStackNotEnabled = errors.New("stack not enabled for challenge") - ErrStackLimitReached = errors.New("stack limit reached") - ErrStackNotFound = errors.New("stack not found") - ErrStackProvisionerDown = errors.New("stack provisioner unavailable") - ErrStackInvalidSpec = errors.New("stack spec invalid") + ErrVMDisabled = errors.New("vm feature disabled") + ErrVMNotEnabled = errors.New("vm not enabled for challenge") + ErrVMLimitReached = errors.New("vm limit reached") + ErrVMNotFound = errors.New("vm not found") + ErrVMOrchestratorDown = errors.New("vm orchestrator unavailable") + ErrVMInvalidSpec = errors.New("vm spec invalid") ErrNotFound = errors.New("not found") ) diff --git a/internal/service/stack_service.go b/internal/service/stack_service.go deleted file mode 100644 index b94951f..0000000 --- a/internal/service/stack_service.go +++ /dev/null @@ -1,627 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "smctf/internal/config" - "smctf/internal/models" - "smctf/internal/repo" - "smctf/internal/stack" - - "github.com/redis/go-redis/v9" -) - -type StackService struct { - cfg config.StackConfig - stackRepo *repo.StackRepo - challengeRepo *repo.ChallengeRepo - submissionRepo *repo.SubmissionRepo - client stack.API - redis *redis.Client -} - -var terminalStackStatusList = []string{"stopped", "failed", "node_deleted"} - -var terminalStackStatusSet = func() map[string]struct{} { - m := make(map[string]struct{}, len(terminalStackStatusList)) - for _, status := range terminalStackStatusList { - m[status] = struct{}{} - } - return m -}() - -func NewStackService(cfg config.StackConfig, stackRepo *repo.StackRepo, challengeRepo *repo.ChallengeRepo, submissionRepo *repo.SubmissionRepo, client stack.API, redisClient *redis.Client) *StackService { - return &StackService{ - cfg: cfg, - stackRepo: stackRepo, - challengeRepo: challengeRepo, - submissionRepo: submissionRepo, - client: client, - redis: redisClient, - } -} - -func (s *StackService) UserStackSummary(ctx context.Context, userID int64) (int, int, error) { - if !s.cfg.Enabled { - return 0, 0, nil - } - - limit := s.cfg.MaxPer - if userID <= 0 { - return 0, limit, nil - } - - var count int - var err error - if s.maxScopeIsTeam() { - teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) - if lookupErr != nil { - return 0, limit, lookupErr - } - - count, err = s.stackRepo.CountByTeamExcludingStatuses(ctx, teamID, terminalStackStatusList) - } else { - count, err = s.stackRepo.CountByUserExcludingStatuses(ctx, userID, terminalStackStatusList) - } - - if err != nil { - return 0, limit, err - } - - return count, limit, nil -} - -func (s *StackService) ListUserStacks(ctx context.Context, userID int64) ([]models.Stack, error) { - if err := s.ensureEnabled(); err != nil { - return nil, err - } - - var stacks []models.Stack - var err error - if s.maxScopeIsTeam() { - teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) - if lookupErr != nil { - return nil, lookupErr - } - stacks, err = s.stackRepo.ListByTeam(ctx, teamID) - } else { - stacks, err = s.stackRepo.ListByUser(ctx, userID) - } - - if err != nil { - return nil, err - } - - updated := make([]models.Stack, 0, len(stacks)) - for i := range stacks { - stackModel := stacks[i] - refreshed, err := s.refreshStack(ctx, &stackModel) - if err != nil { - if errors.Is(err, ErrStackNotFound) { - continue - } - - return nil, err - } - - updated = append(updated, *refreshed) - } - - return updated, nil -} - -func (s *StackService) ListAdminStacks(ctx context.Context) ([]models.AdminStackSummary, error) { - if err := s.ensureEnabled(); err != nil { - return nil, err - } - - return s.stackRepo.ListAdmin(ctx) -} - -func (s *StackService) ListAllStacks(ctx context.Context) ([]models.Stack, error) { - return s.stackRepo.ListAll(ctx) -} - -func (s *StackService) DeleteStackByStackID(ctx context.Context, stackID string) error { - if err := s.ensureEnabled(); err != nil { - return err - } - - existing, err := s.stackRepo.GetByStackID(ctx, stackID) - if err != nil { - if errors.Is(err, repo.ErrNotFound) { - return ErrStackNotFound - } - - return fmt.Errorf("stack.DeleteStackByStackID lookup: %w", err) - } - - if err := s.client.DeleteStack(ctx, existing.StackID); err != nil && !errors.Is(err, stack.ErrNotFound) { - return mapProvisionerError(err) - } - - if err := s.stackRepo.Delete(ctx, existing); err != nil { - return fmt.Errorf("stack.DeleteStackByStackID delete: %w", err) - } - - return nil -} - -func (s *StackService) GetStackByStackID(ctx context.Context, stackID string) (*models.Stack, error) { - if err := s.ensureEnabled(); err != nil { - return nil, err - } - - existing, err := s.stackRepo.GetByStackID(ctx, stackID) - if err != nil { - if errors.Is(err, repo.ErrNotFound) { - return nil, ErrStackNotFound - } - - return nil, fmt.Errorf("stack.GetStackByStackID lookup: %w", err) - } - - return s.refreshStack(ctx, existing) -} - -func (s *StackService) GetOrCreateStack(ctx context.Context, userID, challengeID int64) (*models.Stack, error) { - if err := s.ensureEnabled(); err != nil { - return nil, err - } - - challenge, podSpec, err := s.loadChallengeSpec(ctx, challengeID) - if err != nil { - return nil, err - } - - if err := s.ensureUnlocked(ctx, userID, challenge); err != nil { - return nil, err - } - - if err := s.ensureNotSolved(ctx, userID, challengeID); err != nil { - return nil, err - } - - existing, err := s.findExistingStack(ctx, userID, challengeID) - if err != nil { - return nil, err - } - if existing != nil { - return existing, nil - } - - if err := s.applyRateLimit(ctx, userID); err != nil { - return nil, err - } - - if err := s.ensureUserLimit(ctx, userID); err != nil { - return nil, err - } - - stackModel, err := s.createStack(ctx, userID, challengeID, challenge.StackTargetPorts, podSpec) - if err != nil { - return nil, err - } - - if s.maxScopeIsTeam() { - teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) - if lookupErr == nil { - if reloaded, reloadErr := s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID); reloadErr == nil { - return reloaded, nil - } - } - } else { - if reloaded, reloadErr := s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID); reloadErr == nil { - return reloaded, nil - } - } - - return stackModel, nil -} - -func (s *StackService) GetStack(ctx context.Context, userID, challengeID int64) (*models.Stack, error) { - if err := s.ensureEnabled(); err != nil { - return nil, err - } - - var existing *models.Stack - var err error - if s.maxScopeIsTeam() { - teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) - if lookupErr != nil { - return nil, lookupErr - } - existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) - } else { - existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) - } - - if err != nil { - if errors.Is(err, repo.ErrNotFound) { - return nil, ErrStackNotFound - } - - return nil, fmt.Errorf("stack.GetStack lookup: %w", err) - } - - return s.refreshStack(ctx, existing) -} - -func (s *StackService) DeleteStack(ctx context.Context, userID, challengeID int64) error { - if err := s.ensureEnabled(); err != nil { - return err - } - - var existing *models.Stack - var err error - if s.maxScopeIsTeam() { - teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) - if lookupErr != nil { - return lookupErr - } - existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) - } else { - existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) - } - if err != nil { - if errors.Is(err, repo.ErrNotFound) { - return ErrStackNotFound - } - - return fmt.Errorf("stack.DeleteStack lookup: %w", err) - } - - if err := s.client.DeleteStack(ctx, existing.StackID); err != nil && !errors.Is(err, stack.ErrNotFound) { - return mapProvisionerError(err) - } - - if err := s.stackRepo.Delete(ctx, existing); err != nil { - return fmt.Errorf("stack.DeleteStack delete: %w", err) - } - - return nil -} - -func (s *StackService) DeleteStackByUserAndChallenge(ctx context.Context, userID, challengeID int64) error { - if err := s.ensureEnabled(); err != nil { - return err - } - - var existing *models.Stack - var err error - if s.maxScopeIsTeam() { - teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) - if lookupErr != nil { - return lookupErr - } - existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) - } else { - existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) - } - - if err != nil { - if errors.Is(err, repo.ErrNotFound) { - return ErrStackNotFound - } - - return fmt.Errorf("stack.DeleteStackByUserAndChallenge lookup: %w", err) - } - - if err := s.client.DeleteStack(ctx, existing.StackID); err != nil && !errors.Is(err, stack.ErrNotFound) { - return mapProvisionerError(err) - } - - if err := s.stackRepo.Delete(ctx, existing); err != nil { - return fmt.Errorf("stack.DeleteStackByUserAndChallenge delete: %w", err) - } - - return nil -} - -func (s *StackService) ensureEnabled() error { - if !s.cfg.Enabled { - return ErrStackDisabled - } - - return nil -} - -func (s *StackService) loadChallengeSpec(ctx context.Context, challengeID int64) (*models.Challenge, string, error) { - challenge, err := s.challengeRepo.GetByID(ctx, challengeID) - if err != nil { - if errors.Is(err, repo.ErrNotFound) { - return nil, "", ErrChallengeNotFound - } - - return nil, "", fmt.Errorf("stack.GetOrCreateStack challenge: %w", err) - } - - if !challenge.StackEnabled { - return nil, "", ErrStackNotEnabled - } - - podSpec := "" - if challenge.StackPodSpec != nil { - podSpec = *challenge.StackPodSpec - } - - if strings.TrimSpace(podSpec) == "" || len(challenge.StackTargetPorts) == 0 { - return nil, "", ErrStackInvalidSpec - } - - return challenge, podSpec, nil -} - -func (s *StackService) ensureNotSolved(ctx context.Context, userID, challengeID int64) error { - if s.submissionRepo == nil { - return nil - } - - solved, err := s.submissionRepo.HasCorrect(ctx, userID, challengeID) - if err != nil { - return fmt.Errorf("stack.GetOrCreateStack solved: %w", err) - } - - if !solved { - return nil - } - - existing, err := s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) - if err == nil { - _ = s.client.DeleteStack(ctx, existing.StackID) - _ = s.stackRepo.Delete(ctx, existing) - } - - return ErrAlreadySolved -} - -func (s *StackService) ensureUnlocked(ctx context.Context, userID int64, challenge *models.Challenge) error { - if challenge.PreviousChallengeID == nil || *challenge.PreviousChallengeID <= 0 { - return nil - } - - if userID <= 0 || s.submissionRepo == nil { - return ErrChallengeLocked - } - - solved, err := s.submissionRepo.HasCorrect(ctx, userID, *challenge.PreviousChallengeID) - if err != nil { - return fmt.Errorf("stack.ensureUnlocked: %w", err) - } - - if !solved { - return ErrChallengeLocked - } - - return nil -} - -func (s *StackService) findExistingStack(ctx context.Context, userID, challengeID int64) (*models.Stack, error) { - var existing *models.Stack - var err error - if s.maxScopeIsTeam() { - teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) - if lookupErr != nil { - return nil, lookupErr - } - existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) - } else { - existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) - } - - if err == nil { - refreshed, refreshErr := s.refreshStack(ctx, existing) - if refreshErr == nil { - return refreshed, nil - } - - if errors.Is(refreshErr, ErrStackNotFound) { - return nil, nil - } - - return nil, refreshErr - } - - if !errors.Is(err, repo.ErrNotFound) { - return nil, fmt.Errorf("stack.GetOrCreateStack lookup: %w", err) - } - - return nil, nil -} - -func (s *StackService) applyRateLimit(ctx context.Context, userID int64) error { - if s.redis == nil { - return nil - } - - key := stackRateLimitKey(userID) - if s.maxScopeIsTeam() { - teamID, err := s.stackRepo.TeamIDForUser(ctx, userID) - if err != nil { - return fmt.Errorf("stack.GetOrCreateStack team: %w", err) - } - key = stackTeamRateLimitKey(teamID) - } - - return rateLimit(ctx, s.redis, key, s.cfg.CreateWindow, s.cfg.CreateMax) -} - -func (s *StackService) ensureUserLimit(ctx context.Context, userID int64) error { - if s.maxScopeIsTeam() { - activeStacks, err := s.ListUserStacks(ctx, userID) - if err != nil { - return fmt.Errorf("stack.GetOrCreateStack list: %w", err) - } - - if len(activeStacks) >= s.cfg.MaxPer { - return ErrStackLimitReached - } - - return nil - } - - activeStacks, err := s.ListUserStacks(ctx, userID) - if err != nil { - return fmt.Errorf("stack.GetOrCreateStack list: %w", err) - } - - if len(activeStacks) >= s.cfg.MaxPer { - return ErrStackLimitReached - } - - return nil -} - -func (s *StackService) createStack(ctx context.Context, userID, challengeID int64, targetPorts stack.TargetPortSpecs, podSpec string) (*models.Stack, error) { - ports, err := toTargetPortSpecs(targetPorts) - if err != nil { - return nil, ErrStackInvalidSpec - } - - info, err := s.client.CreateStack(ctx, ports, podSpec) - if err != nil { - return nil, mapProvisionerError(err) - } - - now := time.Now().UTC() - stackModel := &models.Stack{ - UserID: userID, - ChallengeID: challengeID, - StackID: info.StackID, - Status: info.Status, - NodePublicIP: nullIfEmpty(info.NodePublicIP), - Ports: toModelPortMappings(info.Ports), - TTLExpiresAt: timePtr(info.TTLExpiresAt), - CreatedAt: now, - UpdatedAt: now, - } - - if err := s.stackRepo.Create(ctx, stackModel); err != nil { - return nil, fmt.Errorf("stack.GetOrCreateStack create: %w", err) - } - - return stackModel, nil -} - -func (s *StackService) refreshStack(ctx context.Context, existing *models.Stack) (*models.Stack, error) { - status, err := s.client.GetStackStatus(ctx, existing.StackID) - if err != nil { - if errors.Is(err, stack.ErrNotFound) { - _ = s.stackRepo.Delete(ctx, existing) - - return nil, ErrStackNotFound - } - - return nil, mapProvisionerError(err) - } - - if isTerminalStackStatus(status.Status) { - _ = s.stackRepo.Delete(ctx, existing) - return nil, ErrStackNotFound - } - - existing.Status = status.Status - existing.NodePublicIP = nullIfEmpty(status.NodePublicIP) - existing.Ports = toModelPortMappings(status.Ports) - existing.TTLExpiresAt = timePtr(status.TTL) - existing.UpdatedAt = time.Now().UTC() - - if err := s.stackRepo.Update(ctx, existing); err != nil { - return nil, fmt.Errorf("stack.refreshStack update: %w", err) - } - - return existing, nil -} - -func isTerminalStackStatus(status string) bool { - _, ok := terminalStackStatusSet[status] - return ok -} - -func mapProvisionerError(err error) error { - switch { - case errors.Is(err, stack.ErrNotFound): - return ErrStackNotFound - case errors.Is(err, stack.ErrInvalid): - return ErrStackInvalidSpec - case errors.Is(err, stack.ErrUnavailable): - return ErrStackProvisionerDown - default: - return fmt.Errorf("stack provisioner: %w", err) - } -} - -func nullIfEmpty(value string) *string { - if strings.TrimSpace(value) == "" { - return nil - } - - return &value -} - -func toTargetPortSpecs(ports stack.TargetPortSpecs) ([]stack.TargetPortSpec, error) { - if len(ports) == 0 { - return nil, ErrStackInvalidSpec - } - - normalized := make([]stack.TargetPortSpec, 0, len(ports)) - for _, port := range ports { - protocol := strings.ToUpper(strings.TrimSpace(port.Protocol)) - if port.ContainerPort <= 0 || port.ContainerPort > 65535 { - return nil, ErrStackInvalidSpec - } - - if protocol != "TCP" && protocol != "UDP" { - return nil, ErrStackInvalidSpec - } - - normalized = append(normalized, stack.TargetPortSpec{ - ContainerPort: port.ContainerPort, - Protocol: protocol, - }) - } - - return normalized, nil -} - -func toModelPortMappings(ports []stack.PortMapping) stack.PortMappings { - if len(ports) == 0 { - return nil - } - - out := make(stack.PortMappings, 0, len(ports)) - for _, port := range ports { - out = append(out, stack.PortMapping{ - ContainerPort: port.ContainerPort, - Protocol: port.Protocol, - NodePort: port.NodePort, - }) - } - - return out -} - -func timePtr(value time.Time) *time.Time { - if value.IsZero() { - return nil - } - - return &value -} - -func stackRateLimitKey(userID int64) string { - return "stack:create:" + strconv.FormatInt(userID, 10) -} - -func stackTeamRateLimitKey(teamID int64) string { - return "stack:create:team:" + strconv.FormatInt(teamID, 10) -} - -func (s *StackService) maxScopeIsTeam() bool { - return strings.EqualFold(s.cfg.MaxScope, "team") -} diff --git a/internal/service/stack_service_test.go b/internal/service/stack_service_test.go deleted file mode 100644 index e69ed15..0000000 --- a/internal/service/stack_service_test.go +++ /dev/null @@ -1,936 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - "time" - - "smctf/internal/config" - "smctf/internal/models" - "smctf/internal/repo" - "smctf/internal/stack" - "smctf/internal/utils" - - "golang.org/x/crypto/bcrypt" -) - -func createStackChallenge(t *testing.T, env serviceEnv, title string) *models.Challenge { - t.Helper() - podSpec := "apiVersion: v1\nkind: Pod\nmetadata:\n name: test\nspec:\n containers:\n - name: app\n image: nginx\n ports:\n - containerPort: 80\n" - challenge := &models.Challenge{ - Title: title, - Description: "desc", - Category: "Web", - Points: 100, - MinimumPoints: 100, - StackEnabled: true, - StackTargetPorts: stack.TargetPortSpecs{ - {ContainerPort: 80, Protocol: "TCP"}, - }, - StackPodSpec: &podSpec, - IsActive: true, - CreatedAt: time.Now().UTC(), - } - hash, err := utils.HashFlag("flag", bcrypt.MinCost) - if err != nil { - t.Fatalf("hash flag: %v", err) - } - challenge.FlagHash = hash - - if err := env.challengeRepo.Create(context.Background(), challenge); err != nil { - t.Fatalf("create challenge: %v", err) - } - - return challenge -} - -func newStackService(env serviceEnv, client stack.API, cfg config.StackConfig) (*StackService, *repo.StackRepo) { - stackRepo := repo.NewStackRepo(env.db) - return NewStackService(cfg, stackRepo, env.challengeRepo, env.submissionRepo, client, env.redis), stackRepo -} - -func TestStackServiceGetOrCreateStack(t *testing.T) { - env := setupServiceTest(t) - challenge := createStackChallenge(t, env, "stack") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{ - Enabled: true, - MaxPer: 2, - CreateWindow: time.Minute, - CreateMax: 5, - } - stackSvc, _ := newStackService(env, mock.Client(), cfg) - - stackModel, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge.ID) - if err != nil { - t.Fatalf("GetOrCreateStack: %v", err) - } - - if stackModel.StackID == "" || len(stackModel.Ports) != 1 || stackModel.Ports[0].ContainerPort != 80 { - t.Fatalf("unexpected stack model: %+v", stackModel) - } - - again, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge.ID) - if err != nil { - t.Fatalf("GetOrCreateStack again: %v", err) - } - - if again.StackID != stackModel.StackID || mock.CreateCount() != 1 { - t.Fatalf("expected cached stack, calls=%d", mock.CreateCount()) - } -} - -func TestStackServiceUserStackSummary(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "stack-summary@example.com", "stack-summary", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "stack-summary") - terminalChallenge := createStackChallenge(t, env, "stack-summary-term") - - cfg := config.StackConfig{ - Enabled: true, - MaxPer: 3, - CreateWindow: time.Minute, - CreateMax: 5, - } - stackSvc, stackRepo := newStackService(env, stack.NewProvisionerMock().Client(), cfg) - - disabledSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{Enabled: false}) - count, limit, err := disabledSvc.UserStackSummary(context.Background(), user.ID) - if err != nil { - t.Fatalf("UserStackSummary disabled: %v", err) - } - - if count != 0 || limit != 0 { - t.Fatalf("expected disabled summary 0/0, got %d/%d", count, limit) - } - - count, limit, err = stackSvc.UserStackSummary(context.Background(), 0) - if err != nil { - t.Fatalf("UserStackSummary empty: %v", err) - } - - if count != 0 || limit != cfg.MaxPer { - t.Fatalf("expected empty summary 0/%d, got %d/%d", cfg.MaxPer, count, limit) - } - - now := time.Now().UTC() - stackModel := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge.ID, - StackID: "stack-summary-1", - Status: "running", - CreatedAt: now, - UpdatedAt: now, - } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - terminal := &models.Stack{ - UserID: user.ID, - ChallengeID: terminalChallenge.ID, - StackID: "stack-summary-stopped", - Status: "stopped", - CreatedAt: now.Add(-time.Minute), - UpdatedAt: now.Add(-time.Minute), - } - if err := stackRepo.Create(context.Background(), terminal); err != nil { - t.Fatalf("create terminal stack: %v", err) - } - - count, limit, err = stackSvc.UserStackSummary(context.Background(), user.ID) - if err != nil { - t.Fatalf("UserStackSummary: %v", err) - } - if count != 1 || limit != cfg.MaxPer { - t.Fatalf("expected summary 1/%d, got %d/%d", cfg.MaxPer, count, limit) - } -} - -func TestStackServiceUserStackSummaryTeamScope(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "team-summary-1@example.com", "team-summary-1", "pass", models.UserRole) - user2 := createUserWithTeam(t, env, "team-summary-2@example.com", "team-summary-2", "pass", models.UserRole, user.TeamID) - challenge1 := createStackChallenge(t, env, "team-summary-1") - challenge2 := createStackChallenge(t, env, "team-summary-2") - - cfg := config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 5, - } - stackSvc, stackRepo := newStackService(env, stack.NewProvisionerMock().Client(), cfg) - - now := time.Now().UTC() - stackOne := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge1.ID, - StackID: "team-summary-1", - Status: "running", - CreatedAt: now, - UpdatedAt: now, - } - if err := stackRepo.Create(context.Background(), stackOne); err != nil { - t.Fatalf("create stack one: %v", err) - } - - stack2 := &models.Stack{ - UserID: user2.ID, - ChallengeID: challenge2.ID, - StackID: "team-summary-2", - Status: "running", - CreatedAt: now, - UpdatedAt: now, - } - if err := stackRepo.Create(context.Background(), stack2); err != nil { - t.Fatalf("create stack 2: %v", err) - } - - count, limit, err := stackSvc.UserStackSummary(context.Background(), user.ID) - if err != nil { - t.Fatalf("UserStackSummary team: %v", err) - } - - if count != 2 || limit != cfg.MaxPer { - t.Fatalf("expected team summary 2/%d, got %d/%d", cfg.MaxPer, count, limit) - } -} - -func TestStackServiceListUserStacksTeamScope(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "team-list-1@example.com", "team-list-1", "pass", models.UserRole) - user2 := createUserWithTeam(t, env, "team-list-2@example.com", "team-list-2", "pass", models.UserRole, user.TeamID) - challenge1 := createStackChallenge(t, env, "team-list-1") - challenge2 := createStackChallenge(t, env, "team-list-2") - - client := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running"}, nil - }, - } - - cfg := config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 5, - } - stackSvc, stackRepo := newStackService(env, client, cfg) - - now := time.Now().UTC() - stackOne := &models.Stack{UserID: user.ID, ChallengeID: challenge1.ID, StackID: "team-list-1", Status: "running", CreatedAt: now, UpdatedAt: now} - stack2 := &models.Stack{UserID: user2.ID, ChallengeID: challenge2.ID, StackID: "team-list-2", Status: "running", CreatedAt: now, UpdatedAt: now} - if err := stackRepo.Create(context.Background(), stackOne); err != nil { - t.Fatalf("create stack one: %v", err) - } - - if err := stackRepo.Create(context.Background(), stack2); err != nil { - t.Fatalf("create stack 2: %v", err) - } - - stacks, err := stackSvc.ListUserStacks(context.Background(), user.ID) - if err != nil { - t.Fatalf("ListUserStacks team: %v", err) - } - - if len(stacks) != 2 { - t.Fatalf("expected 2 stacks, got %d", len(stacks)) - } -} - -func TestStackServiceGetStackTeamScope(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "team-get-1@example.com", "team-get-1", "pass", models.UserRole) - user2 := createUserWithTeam(t, env, "team-get-2@example.com", "team-get-2", "pass", models.UserRole, user.TeamID) - challenge := createStackChallenge(t, env, "team-get") - - client := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running"}, nil - }, - } - - cfg := config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 5, - } - stackSvc, stackRepo := newStackService(env, client, cfg) - - now := time.Now().UTC() - stackModel := &models.Stack{UserID: user2.ID, ChallengeID: challenge.ID, StackID: "team-get", Status: "running", CreatedAt: now, UpdatedAt: now} - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - got, err := stackSvc.GetStack(context.Background(), user.ID, challenge.ID) - if err != nil { - t.Fatalf("GetStack team: %v", err) - } - - if got.StackID != "team-get" { - t.Fatalf("expected team stack, got %+v", got) - } -} - -func TestStackServiceCreateStackInvalidPorts(t *testing.T) { - env := setupServiceTest(t) - challenge := createStackChallenge(t, env, "stack-invalid") - challenge.StackTargetPorts = stack.TargetPortSpecs{{ContainerPort: 0, Protocol: "TCP"}} - if err := env.challengeRepo.Update(context.Background(), challenge); err != nil { - t.Fatalf("update challenge: %v", err) - } - - mock := stack.NewProvisionerMock() - cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} - stackSvc, _ := newStackService(env, mock.Client(), cfg) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge.ID); !errors.Is(err, ErrStackInvalidSpec) { - t.Fatalf("expected ErrStackInvalidSpec, got %v", err) - } -} - -func TestStackServiceCreateStackProvisionerUnavailable(t *testing.T) { - env := setupServiceTest(t) - challenge := createStackChallenge(t, env, "stack-provisioner-down") - - client := &stack.MockClient{ - CreateStackFn: func(ctx context.Context, targetPorts []stack.TargetPortSpec, podSpec string) (*stack.StackInfo, error) { - return nil, stack.ErrUnavailable - }, - } - cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} - stackSvc, _ := newStackService(env, client, cfg) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge.ID); !errors.Is(err, ErrStackProvisionerDown) { - t.Fatalf("expected ErrStackProvisionerDown, got %v", err) - } -} - -func TestStackServiceGetStackNotFound(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "get-missing@example.com", "get-missing", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "get-missing") - - stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) - - if _, err := stackSvc.GetStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { - t.Fatalf("expected ErrStackNotFound, got %v", err) - } -} - -func TestStackServiceLockedChallenge(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "locked-stack@example.com", "locked-stack", "pass", models.UserRole) - prev := createChallenge(t, env, "Prev", 50, "flag{prev}", true) - challenge := createStackChallenge(t, env, "stack-locked") - challenge.PreviousChallengeID = &prev.ID - if err := env.challengeRepo.Update(context.Background(), challenge); err != nil { - t.Fatalf("update challenge: %v", err) - } - - mock := stack.NewProvisionerMock() - cfg := config.StackConfig{ - Enabled: true, - MaxPer: 2, - CreateWindow: time.Minute, - CreateMax: 5, - } - stackSvc, _ := newStackService(env, mock.Client(), cfg) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrChallengeLocked) { - t.Fatalf("expected locked error, got %v", err) - } - - createSubmission(t, env, user.ID, prev.ID, true, time.Now().UTC()) - if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge.ID); err != nil { - t.Fatalf("expected stack after unlock, got %v", err) - } -} - -func TestStackServiceRateLimit(t *testing.T) { - env := setupServiceTest(t) - challenge1 := createStackChallenge(t, env, "stack-1") - challenge2 := createStackChallenge(t, env, "stack-2") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{ - Enabled: true, - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 1, - } - stackSvc, _ := newStackService(env, mock.Client(), cfg) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge1.ID); err != nil { - t.Fatalf("first create: %v", err) - } - - if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge2.ID); !errors.Is(err, ErrRateLimited) { - t.Fatalf("expected rate limit error, got %v", err) - } -} - -func TestStackServiceGetStackTeamScopeNotFound(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "team-get-missing@example.com", "team-get-missing", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "team-get-missing") - - stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 5, - }) - - if _, err := stackSvc.GetStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { - t.Fatalf("expected ErrStackNotFound, got %v", err) - } -} - -func TestStackServiceListUserStacksTeamScopeIgnoresOtherTeam(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "team-list-a@example.com", "team-list-a", "pass", models.UserRole) - otherUser := createUserWithNewTeam(t, env, "team-list-b@example.com", "team-list-b", "pass", models.UserRole) - challenge1 := createStackChallenge(t, env, "team-list-a") - challenge2 := createStackChallenge(t, env, "team-list-b") - - client := &stack.MockClient{ - GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { - return &stack.StackStatus{StackID: stackID, Status: "running"}, nil - }, - } - - stackSvc, stackRepo := newStackService(env, client, config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 5, - }) - - now := time.Now().UTC() - stackOne := &models.Stack{UserID: user.ID, ChallengeID: challenge1.ID, StackID: "team-list-a-1", Status: "running", CreatedAt: now, UpdatedAt: now} - stackTwo := &models.Stack{UserID: otherUser.ID, ChallengeID: challenge2.ID, StackID: "team-list-b-1", Status: "running", CreatedAt: now, UpdatedAt: now} - if err := stackRepo.Create(context.Background(), stackOne); err != nil { - t.Fatalf("create stack one: %v", err) - } - - if err := stackRepo.Create(context.Background(), stackTwo); err != nil { - t.Fatalf("create stack two: %v", err) - } - - stacks, err := stackSvc.ListUserStacks(context.Background(), user.ID) - if err != nil { - t.Fatalf("ListUserStacks team: %v", err) - } - - if len(stacks) != 1 || stacks[0].StackID != "team-list-a-1" { - t.Fatalf("expected only team stack, got %+v", stacks) - } -} - -func TestStackServiceRateLimitTeamScope(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "rate-team-1@example.com", "rate-team-1", "pass", models.UserRole) - user2 := createUserWithTeam(t, env, "rate-team-2@example.com", "rate-team-2", "pass", models.UserRole, user.TeamID) - challenge1 := createStackChallenge(t, env, "rate-team-1") - challenge2 := createStackChallenge(t, env, "rate-team-2") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 1, - } - stackSvc, _ := newStackService(env, mock.Client(), cfg) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge1.ID); err != nil { - t.Fatalf("first create: %v", err) - } - - if _, err := stackSvc.GetOrCreateStack(context.Background(), user2.ID, challenge2.ID); !errors.Is(err, ErrRateLimited) { - t.Fatalf("expected team rate limit error, got %v", err) - } -} - -func TestStackServiceDeleteStackNotFound(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "delete-missing@example.com", "delete-missing", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "delete-missing") - - stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) - - if err := stackSvc.DeleteStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { - t.Fatalf("expected ErrStackNotFound, got %v", err) - } -} - -func TestStackServiceDeleteStackByUserAndChallengeNotFound(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "delete-user-missing@example.com", "delete-user-missing", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "delete-user-missing") - - stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) - - if err := stackSvc.DeleteStackByUserAndChallenge(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { - t.Fatalf("expected ErrStackNotFound, got %v", err) - } -} - -func TestStackServiceUserLimit(t *testing.T) { - env := setupServiceTest(t) - challenge1 := createStackChallenge(t, env, "stack-1") - challenge2 := createStackChallenge(t, env, "stack-2") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{ - Enabled: true, - MaxPer: 1, - CreateWindow: time.Minute, - CreateMax: 10, - } - stackSvc, _ := newStackService(env, mock.Client(), cfg) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge1.ID); err != nil { - t.Fatalf("first create: %v", err) - } - - if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge2.ID); !errors.Is(err, ErrStackLimitReached) { - t.Fatalf("expected stack limit error, got %v", err) - } -} - -func TestStackServiceUserLimitTeamScope(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "limit-team-1@example.com", "limit-team-1", "pass", models.UserRole) - user2 := createUserWithTeam(t, env, "limit-team-2@example.com", "limit-team-2", "pass", models.UserRole, user.TeamID) - challenge1 := createStackChallenge(t, env, "limit-team-1") - challenge2 := createStackChallenge(t, env, "limit-team-2") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 1, - CreateWindow: time.Minute, - CreateMax: 10, - } - stackSvc, _ := newStackService(env, mock.Client(), cfg) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge1.ID); err != nil { - t.Fatalf("first create: %v", err) - } - - if _, err := stackSvc.GetOrCreateStack(context.Background(), user2.ID, challenge2.ID); !errors.Is(err, ErrStackLimitReached) { - t.Fatalf("expected team stack limit error, got %v", err) - } -} - -func TestStackServiceDeleteStackTeamScope(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "team-del-1@example.com", "team-del-1", "pass", models.UserRole) - userTwo := createUserWithTeam(t, env, "team-del-2@example.com", "team-del-2", "pass", models.UserRole, user.TeamID) - challenge := createStackChallenge(t, env, "team-del") - - deleted := false - client := &stack.MockClient{ - DeleteStackFn: func(ctx context.Context, stackID string) error { - if stackID == "team-del" { - deleted = true - } - return nil - }, - } - - stackSvc, stackRepo := newStackService(env, client, config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 5, - }) - - now := time.Now().UTC() - stackModel := &models.Stack{UserID: userTwo.ID, ChallengeID: challenge.ID, StackID: "team-del", Status: "running", CreatedAt: now, UpdatedAt: now} - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - if err := stackSvc.DeleteStack(context.Background(), user.ID, challenge.ID); err != nil { - t.Fatalf("DeleteStack: %v", err) - } - - if !deleted { - t.Fatalf("expected provisioner delete") - } - - if _, err := stackRepo.GetByStackID(context.Background(), "team-del"); !errors.Is(err, repo.ErrNotFound) { - t.Fatalf("expected stack deleted, got %v", err) - } -} - -func TestStackServiceDeleteStackByUserAndChallengeTeamScope(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "team-del-u-1@example.com", "team-del-u-1", "pass", models.UserRole) - userTwo := createUserWithTeam(t, env, "team-del-u-2@example.com", "team-del-u-2", "pass", models.UserRole, user.TeamID) - challenge := createStackChallenge(t, env, "team-del-u") - - deleted := false - client := &stack.MockClient{ - DeleteStackFn: func(ctx context.Context, stackID string) error { - if stackID == "team-del-u" { - deleted = true - } - return nil - }, - } - - stackSvc, stackRepo := newStackService(env, client, config.StackConfig{ - Enabled: true, - MaxScope: "team", - MaxPer: 5, - CreateWindow: time.Minute, - CreateMax: 5, - }) - - now := time.Now().UTC() - stackModel := &models.Stack{UserID: userTwo.ID, ChallengeID: challenge.ID, StackID: "team-del-u", Status: "running", CreatedAt: now, UpdatedAt: now} - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - if err := stackSvc.DeleteStackByUserAndChallenge(context.Background(), user.ID, challenge.ID); err != nil { - t.Fatalf("DeleteStackByUserAndChallenge: %v", err) - } - - if !deleted { - t.Fatalf("expected provisioner delete") - } - - if _, err := stackRepo.GetByStackID(context.Background(), "team-del-u"); !errors.Is(err, repo.ErrNotFound) { - t.Fatalf("expected stack deleted, got %v", err) - } -} - -func TestStackServiceTerminalStatusDeletes(t *testing.T) { - env := setupServiceTest(t) - challenge := createStackChallenge(t, env, "stack") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{ - Enabled: true, - MaxPer: 2, - CreateWindow: time.Minute, - CreateMax: 5, - } - stackSvc, stackRepo := newStackService(env, mock.Client(), cfg) - - stackModel, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge.ID) - if err != nil { - t.Fatalf("create: %v", err) - } - - mock.SetStatus(stackModel.StackID, "stopped") - - if _, err := stackSvc.GetStack(context.Background(), 1, challenge.ID); !errors.Is(err, ErrStackNotFound) { - t.Fatalf("expected not found, got %v", err) - } - - if _, err := stackRepo.GetByStackID(context.Background(), stackModel.StackID); !errors.Is(err, repo.ErrNotFound) { - t.Fatalf("expected repo delete, got %v", err) - } -} - -func TestStackServiceAlreadySolvedDeletesExisting(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "u1@example.com", "u1", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "stack") - - stackRepo := repo.NewStackRepo(env.db) - stackModel := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge.ID, - StackID: "stack-solved", - Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - createSubmission(t, env, user.ID, challenge.ID, true, time.Now().UTC()) - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} - stackSvc := NewStackService(cfg, stackRepo, env.challengeRepo, env.submissionRepo, mock.Client(), env.redis) - - if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrAlreadySolved) { - t.Fatalf("expected already solved, got %v", err) - } - - if mock.DeleteCount("stack-solved") == 0 { - t.Fatalf("expected provisioner delete call") - } - - if _, err := stackRepo.GetByStackID(context.Background(), "stack-solved"); !errors.Is(err, repo.ErrNotFound) { - t.Fatalf("expected stack deleted, got %v", err) - } -} - -func TestStackServiceDeleteStackByStackID(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "admin-del@example.com", "admin-del", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "admin-del-stack") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} - stackSvc, stackRepo := newStackService(env, mock.Client(), cfg) - - stackModel := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge.ID, - StackID: "stack-del", - Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - mock.AddStack(stack.StackInfo{ - StackID: "stack-del", - Status: "running", - Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - }) - - if err := stackSvc.DeleteStackByStackID(context.Background(), "stack-del"); err != nil { - t.Fatalf("DeleteStackByStackID: %v", err) - } - - if mock.DeleteCount("stack-del") == 0 { - t.Fatalf("expected provisioner delete call") - } - - if _, err := stackRepo.GetByStackID(context.Background(), "stack-del"); !errors.Is(err, repo.ErrNotFound) { - t.Fatalf("expected stack deleted, got %v", err) - } -} - -func TestStackServiceGetStackByStackID(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "admin-get@example.com", "admin-get", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "admin-get-stack") - - mock := stack.NewProvisionerMock() - - cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} - stackSvc, stackRepo := newStackService(env, mock.Client(), cfg) - - stackModel := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge.ID, - StackID: "stack-get", - Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - mock.AddStack(stack.StackInfo{ - StackID: "stack-get", - Status: "running", - Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - }) - - got, err := stackSvc.GetStackByStackID(context.Background(), "stack-get") - if err != nil { - t.Fatalf("GetStackByStackID: %v", err) - } - - if got.StackID != "stack-get" || got.ChallengeID != challenge.ID { - t.Fatalf("unexpected stack: %+v", got) - } -} - -func TestStackServiceListAdminStacks(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "admin-list@example.com", "admin-list", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "admin-stack") - - mock := stack.NewProvisionerMock() - stackSvc, stackRepo := newStackService(env, mock.Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) - - stackModel := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge.ID, - StackID: "stack-admin", - Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - stacks, err := stackSvc.ListAdminStacks(context.Background()) - if err != nil { - t.Fatalf("ListAdminStacks: %v", err) - } - - if len(stacks) != 1 { - t.Fatalf("expected 1 stack, got %d", len(stacks)) - } - - if stacks[0].StackID != "stack-admin" { - t.Fatalf("unexpected stack: %+v", stacks[0]) - } -} - -func TestStackServiceListAdminStacksDisabled(t *testing.T) { - env := setupServiceTest(t) - mock := stack.NewProvisionerMock() - stackSvc, _ := newStackService(env, mock.Client(), config.StackConfig{Enabled: false}) - - if _, err := stackSvc.ListAdminStacks(context.Background()); !errors.Is(err, ErrStackDisabled) { - t.Fatalf("expected ErrStackDisabled, got %v", err) - } -} - -func TestStackServiceDeleteStackByStackIDNotFound(t *testing.T) { - env := setupServiceTest(t) - mock := stack.NewProvisionerMock() - stackSvc, _ := newStackService(env, mock.Client(), config.StackConfig{Enabled: true}) - - if err := stackSvc.DeleteStackByStackID(context.Background(), "missing"); !errors.Is(err, ErrStackNotFound) { - t.Fatalf("expected ErrStackNotFound, got %v", err) - } -} - -func TestStackServiceDeleteStackByStackIDProvisionerDown(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "admin-del-down@example.com", "admin-del-down", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "admin-del-down") - - mock := stack.NewProvisionerMock() - - stackSvc, stackRepo := newStackService(env, mock.Client(), config.StackConfig{Enabled: true}) - - stackModel := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge.ID, - StackID: "stack-down", - Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - mock.SetDeleteError("stack-down", stack.ErrUnavailable) - - if err := stackSvc.DeleteStackByStackID(context.Background(), "stack-down"); !errors.Is(err, ErrStackProvisionerDown) { - t.Fatalf("expected ErrStackProvisionerDown, got %v", err) - } -} - -func TestStackServiceGetStackByStackIDNotFound(t *testing.T) { - env := setupServiceTest(t) - mock := stack.NewProvisionerMock() - stackSvc, _ := newStackService(env, mock.Client(), config.StackConfig{Enabled: true}) - - if _, err := stackSvc.GetStackByStackID(context.Background(), "missing"); !errors.Is(err, ErrStackNotFound) { - t.Fatalf("expected ErrStackNotFound, got %v", err) - } -} - -func TestStackServiceGetStackByStackIDDisabled(t *testing.T) { - env := setupServiceTest(t) - mock := stack.NewProvisionerMock() - stackSvc, _ := newStackService(env, mock.Client(), config.StackConfig{Enabled: false}) - - if _, err := stackSvc.GetStackByStackID(context.Background(), "stack"); !errors.Is(err, ErrStackDisabled) { - t.Fatalf("expected ErrStackDisabled, got %v", err) - } -} - -func TestStackServiceListAllStacks(t *testing.T) { - env := setupServiceTest(t) - user := createUserWithNewTeam(t, env, "stack-all@example.com", "stackall", "pass", models.UserRole) - challenge := createStackChallenge(t, env, "stack-all") - - mock := stack.NewProvisionerMock() - stackSvc, stackRepo := newStackService(env, mock.Client(), config.StackConfig{Enabled: false}) - - stackModel := &models.Stack{ - UserID: user.ID, - ChallengeID: challenge.ID, - StackID: "stack-all", - Status: "running", - Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - if err := stackRepo.Create(context.Background(), stackModel); err != nil { - t.Fatalf("create stack: %v", err) - } - - stacks, err := stackSvc.ListAllStacks(context.Background()) - if err != nil { - t.Fatalf("ListAllStacks: %v", err) - } - if len(stacks) != 1 || stacks[0].StackID != "stack-all" { - t.Fatalf("unexpected stacks: %+v", stacks) - } -} - -func TestToTargetPortSpecsValidation(t *testing.T) { - if _, err := toTargetPortSpecs(nil); !errors.Is(err, ErrStackInvalidSpec) { - t.Fatalf("expected ErrStackInvalidSpec for empty ports, got %v", err) - } - - if _, err := toTargetPortSpecs(stack.TargetPortSpecs{{ContainerPort: 70000, Protocol: "TCP"}}); !errors.Is(err, ErrStackInvalidSpec) { - t.Fatalf("expected ErrStackInvalidSpec for invalid port, got %v", err) - } - - if _, err := toTargetPortSpecs(stack.TargetPortSpecs{{ContainerPort: 80, Protocol: "icmp"}}); !errors.Is(err, ErrStackInvalidSpec) { - t.Fatalf("expected ErrStackInvalidSpec for invalid protocol, got %v", err) - } - - ports, err := toTargetPortSpecs(stack.TargetPortSpecs{{ContainerPort: 80, Protocol: "tcp"}}) - if err != nil { - t.Fatalf("expected normalized ports, got %v", err) - } - - if len(ports) != 1 || ports[0].Protocol != "TCP" { - t.Fatalf("expected TCP normalized, got %+v", ports) - } -} diff --git a/internal/service/testenv_test.go b/internal/service/testenv_test.go index df53987..2d269a5 100644 --- a/internal/service/testenv_test.go +++ b/internal/service/testenv_test.go @@ -11,9 +11,9 @@ import ( "smctf/internal/db" "smctf/internal/models" "smctf/internal/repo" - "smctf/internal/stack" "smctf/internal/storage" "smctf/internal/utils" + "smctf/internal/vm" "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" @@ -35,14 +35,14 @@ type serviceEnv struct { challengeRepo *repo.ChallengeRepo submissionRepo *repo.SubmissionRepo scoreRepo *repo.ScoreboardRepo - stackRepo *repo.StackRepo + vmRepo *repo.VMRepo authSvc *AuthService userSvc *UserService scoreSvc *ScoreboardService ctfSvc *CTFService divisionSvc *DivisionService teamSvc *TeamService - stackSvc *StackService + vmSvc *VMService defaultDivisionID int64 } @@ -199,7 +199,7 @@ func setupServiceTest(t *testing.T) serviceEnv { challengeRepo := repo.NewChallengeRepo(serviceDB) submissionRepo := repo.NewSubmissionRepo(serviceDB) scoreRepo := repo.NewScoreboardRepo(serviceDB) - stackRepo := repo.NewStackRepo(serviceDB) + vmRepo := repo.NewVMRepo(serviceDB) fileStore := storage.NewMemoryChallengeFileStore(10 * time.Minute) @@ -209,7 +209,7 @@ func setupServiceTest(t *testing.T) serviceEnv { divisionSvc := NewDivisionService(divisionRepo) teamSvc := NewTeamService(teamRepo, divisionRepo) ctfSvc := NewCTFService(serviceCfg, challengeRepo, submissionRepo, serviceRedis, fileStore) - stackSvc := NewStackService(serviceCfg.Stack, stackRepo, challengeRepo, submissionRepo, &stack.MockClient{}, serviceRedis) + vmSvc := NewVMService(serviceCfg.VM, vmRepo, challengeRepo, submissionRepo, &vm.MockClient{}, serviceRedis) env := serviceEnv{ cfg: serviceCfg, @@ -222,14 +222,14 @@ func setupServiceTest(t *testing.T) serviceEnv { challengeRepo: challengeRepo, submissionRepo: submissionRepo, scoreRepo: scoreRepo, - stackRepo: stackRepo, + vmRepo: vmRepo, authSvc: authSvc, userSvc: userSvc, scoreSvc: scoreSvc, ctfSvc: ctfSvc, divisionSvc: divisionSvc, teamSvc: teamSvc, - stackSvc: stackSvc, + vmSvc: vmSvc, } division := &models.Division{ @@ -248,7 +248,7 @@ func setupServiceTest(t *testing.T) serviceEnv { func resetServiceState(t *testing.T) { t.Helper() - if _, err := serviceDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, stacks, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { + if _, err := serviceDB.ExecContext(context.Background(), "TRUNCATE TABLE app_configs, submissions, registration_key_uses, registration_keys, vms, challenges, users, teams, divisions RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate tables: %v", err) } diff --git a/internal/service/vm_service.go b/internal/service/vm_service.go new file mode 100644 index 0000000..0141cb8 --- /dev/null +++ b/internal/service/vm_service.go @@ -0,0 +1,475 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "smctf/internal/config" + "smctf/internal/models" + "smctf/internal/repo" + "smctf/internal/vm" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +type VMService struct { + cfg config.VMConfig + vmRepo *repo.VMRepo + challengeRepo *repo.ChallengeRepo + submissionRepo *repo.SubmissionRepo + client vm.API + redis *redis.Client +} + +func NewVMService(cfg config.VMConfig, vmRepo *repo.VMRepo, challengeRepo *repo.ChallengeRepo, submissionRepo *repo.SubmissionRepo, client vm.API, redisClient *redis.Client) *VMService { + return &VMService{ + cfg: cfg, + vmRepo: vmRepo, + challengeRepo: challengeRepo, + submissionRepo: submissionRepo, + client: client, + redis: redisClient, + } +} + +func (s *VMService) UserVMSummary(ctx context.Context, userID int64) (int, int, error) { + if !s.cfg.Enabled { + return 0, 0, nil + } + + limit := s.cfg.MaxPer + if userID <= 0 { + return 0, limit, nil + } + + count, err := s.countByScope(ctx, userID) + if err != nil { + return 0, limit, err + } + + return count, limit, nil +} + +func (s *VMService) ListUserVMs(ctx context.Context, userID int64) ([]models.VM, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + if s.scopeIsTeam() { + return s.vmRepo.ListByTeamUser(ctx, userID) + } + + return s.vmRepo.ListByUser(ctx, userID) +} + +func (s *VMService) ListAdminVMs(ctx context.Context) ([]models.AdminVMSummary, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + return s.vmRepo.ListAdmin(ctx) +} + +func (s *VMService) ListAllVMs(ctx context.Context) ([]models.VM, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + return s.vmRepo.ListAll(ctx) +} + +func (s *VMService) GetVMByVMID(ctx context.Context, vmID string) (*models.VM, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + existing, err := s.vmRepo.GetByVMID(ctx, vmID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return nil, ErrVMNotFound + } + + return nil, fmt.Errorf("vm.GetVMByVMID lookup: %w", err) + } + + return s.refreshVM(ctx, existing) +} + +func (s *VMService) DeleteVMByVMID(ctx context.Context, vmID string) error { + if err := s.ensureEnabled(); err != nil { + return err + } + + existing, err := s.vmRepo.GetByVMID(ctx, vmID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return ErrVMNotFound + } + + return fmt.Errorf("vm.DeleteVMByVMID lookup: %w", err) + } + + if err := s.client.DeleteSandbox(ctx, existing.VMID); err != nil && !errors.Is(err, vm.ErrNotFound) { + return mapVMOrchestratorError(err) + } + + if err := s.vmRepo.Delete(ctx, existing); err != nil { + return fmt.Errorf("vm.DeleteVMByVMID delete: %w", err) + } + + return nil +} + +func (s *VMService) GetOrCreateVM(ctx context.Context, userID, challengeID int64) (*models.VM, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + challenge, spec, err := s.loadChallengeSpec(ctx, challengeID) + if err != nil { + return nil, err + } + + if err := s.ensureUnlocked(ctx, userID, challenge); err != nil { + return nil, err + } + + if err := s.ensureNotSolved(ctx, userID, challengeID); err != nil { + return nil, err + } + + existing, err := s.findExistingVM(ctx, userID, challengeID) + if err != nil { + return nil, err + } + + if existing != nil { + return existing, nil + } + + if err := s.applyRateLimit(ctx, userID); err != nil { + return nil, err + } + + if err := s.ensureUserLimit(ctx, userID); err != nil { + return nil, err + } + + return s.createVM(ctx, userID, challengeID, spec) +} + +func (s *VMService) GetVM(ctx context.Context, userID, challengeID int64) (*models.VM, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + + existing, err := s.getByScope(ctx, userID, challengeID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return nil, ErrVMNotFound + } + + return nil, fmt.Errorf("vm.GetVM lookup: %w", err) + } + + return s.refreshVM(ctx, existing) +} + +func (s *VMService) DeleteVM(ctx context.Context, userID, challengeID int64) error { + if err := s.ensureEnabled(); err != nil { + return err + } + + existing, err := s.getByScope(ctx, userID, challengeID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return ErrVMNotFound + } + + return fmt.Errorf("vm.DeleteVM lookup: %w", err) + } + + if err := s.client.DeleteSandbox(ctx, existing.VMID); err != nil && !errors.Is(err, vm.ErrNotFound) { + return mapVMOrchestratorError(err) + } + + if err := s.vmRepo.Delete(ctx, existing); err != nil { + return fmt.Errorf("vm.DeleteVM delete: %w", err) + } + + return nil +} + +func (s *VMService) DeleteVMByUserAndChallenge(ctx context.Context, userID, challengeID int64) error { + err := s.DeleteVM(ctx, userID, challengeID) + if errors.Is(err, ErrVMNotFound) { + return nil + } + return err +} + +func (s *VMService) ensureEnabled() error { + if !s.cfg.Enabled { + return ErrVMDisabled + } + + return nil +} + +func (s *VMService) loadChallengeSpec(ctx context.Context, challengeID int64) (*models.Challenge, string, error) { + challenge, err := s.challengeRepo.GetByID(ctx, challengeID) + if err != nil { + if errors.Is(err, repo.ErrNotFound) { + return nil, "", ErrChallengeNotFound + } + + return nil, "", fmt.Errorf("vm.GetOrCreateVM challenge: %w", err) + } + + if !challenge.VMEnabled { + return nil, "", ErrVMNotEnabled + } + + if challenge.VMSpec == nil || strings.TrimSpace(*challenge.VMSpec) == "" { + return nil, "", ErrVMInvalidSpec + } + + return challenge, *challenge.VMSpec, nil +} + +func (s *VMService) ensureNotSolved(ctx context.Context, userID, challengeID int64) error { + if s.submissionRepo == nil { + return nil + } + + solved, err := s.submissionRepo.HasCorrect(ctx, userID, challengeID) + if err != nil { + return fmt.Errorf("vm.GetOrCreateVM solved: %w", err) + } + + if solved { + return ErrAlreadySolved + } + + return nil +} + +func (s *VMService) ensureUnlocked(ctx context.Context, userID int64, challenge *models.Challenge) error { + if challenge.PreviousChallengeID == nil || *challenge.PreviousChallengeID <= 0 { + return nil + } + + if userID <= 0 || s.submissionRepo == nil { + return ErrChallengeLocked + } + + solved, err := s.submissionRepo.HasCorrect(ctx, userID, *challenge.PreviousChallengeID) + if err != nil { + return fmt.Errorf("vm.ensureUnlocked: %w", err) + } + + if !solved { + return ErrChallengeLocked + } + + return nil +} + +func (s *VMService) findExistingVM(ctx context.Context, userID, challengeID int64) (*models.VM, error) { + existing, err := s.getByScope(ctx, userID, challengeID) + if err == nil { + return s.refreshVM(ctx, existing) + } + + if !errors.Is(err, repo.ErrNotFound) { + return nil, fmt.Errorf("vm.GetOrCreateVM lookup: %w", err) + } + + return nil, nil +} + +func (s *VMService) applyRateLimit(ctx context.Context, userID int64) error { + if s.redis == nil { + return nil + } + + return rateLimit(ctx, s.redis, vmRateLimitKey(userID), s.cfg.CreateWindow, s.cfg.CreateMax) +} + +func (s *VMService) ensureUserLimit(ctx context.Context, userID int64) error { + count, err := s.countByScope(ctx, userID) + if err != nil { + return fmt.Errorf("vm.GetOrCreateVM count: %w", err) + } + + if count >= s.cfg.MaxPer { + return ErrVMLimitReached + } + + return nil +} + +func (s *VMService) createVM(ctx context.Context, userID, challengeID int64, spec string) (*models.VM, error) { + vmID := newVMID(userID, challengeID) + sandbox, err := s.client.CreateSandbox(ctx, vmID, spec) + if err != nil { + return nil, mapVMOrchestratorError(err) + } + + now := time.Now().UTC() + model := &models.VM{ + UserID: userID, + ChallengeID: challengeID, + VMID: vmID, + Status: sandbox.Status.Phase, + NodeName: nullIfEmpty(sandbox.Status.NodeName), + ExternalIP: nullIfEmpty(sandbox.Status.ExternalIP), + Ports: toVMPortMappings(sandbox.Status.AssignedPorts), + TTLExpiresAt: sandbox.Status.ExpireAt, + LastError: nullIfEmpty(sandbox.Status.LastError), + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.vmRepo.Create(ctx, model); err != nil { + // If concurrent requests race on the unique (user_id, challenge_id) constraint, + // delete the just-created sandbox and return the winner row. + if isUniqueVMConflict(err) { + _ = s.client.DeleteSandbox(ctx, vmID) + if existing, getErr := s.vmRepo.GetByUserAndChallenge(ctx, userID, challengeID); getErr == nil { + return existing, nil + } + } + return nil, fmt.Errorf("vm.GetOrCreateVM create: %w", err) + } + + if reloaded, reloadErr := s.vmRepo.GetByUserAndChallenge(ctx, userID, challengeID); reloadErr == nil { + return reloaded, nil + } + + return model, nil +} + +func (s *VMService) refreshVM(ctx context.Context, existing *models.VM) (*models.VM, error) { + sandbox, err := s.client.GetSandbox(ctx, existing.VMID) + if err != nil { + var statusErr *vm.StatusError + if errors.As(err, &statusErr) && !errors.Is(err, vm.ErrUnavailable) { + if errors.Is(err, vm.ErrNotFound) { + if deleteErr := s.vmRepo.Delete(ctx, existing); deleteErr != nil { + return nil, fmt.Errorf("vm.refreshVM delete missing vm row: %w", deleteErr) + } + return nil, ErrVMNotFound + } + + existing.Status = "Error" + existing.LastError = vmStringPtr(statusErr.Error()) + existing.UpdatedAt = time.Now().UTC() + if err := s.vmRepo.Update(ctx, existing); err != nil { + return nil, fmt.Errorf("vm.refreshVM status error update: %w", err) + } + + return existing, nil + } + + return nil, mapVMOrchestratorError(err) + } + + existing.Status = sandbox.Status.Phase + existing.NodeName = nullIfEmpty(sandbox.Status.NodeName) + existing.ExternalIP = nullIfEmpty(sandbox.Status.ExternalIP) + existing.Ports = toVMPortMappings(sandbox.Status.AssignedPorts) + existing.TTLExpiresAt = sandbox.Status.ExpireAt + existing.LastError = nullIfEmpty(sandbox.Status.LastError) + existing.UpdatedAt = time.Now().UTC() + + if err := s.vmRepo.Update(ctx, existing); err != nil { + return nil, fmt.Errorf("vm.refreshVM update: %w", err) + } + + return existing, nil +} + +func mapVMOrchestratorError(err error) error { + switch { + case errors.Is(err, vm.ErrNotFound): + return ErrVMNotFound + case errors.Is(err, vm.ErrInvalid): + return ErrVMInvalidSpec + case errors.Is(err, vm.ErrUnavailable): + return ErrVMOrchestratorDown + default: + return fmt.Errorf("vm orchestrator: %w", err) + } +} + +func toVMPortMappings(ports []vm.PortMapping) vm.PortMappings { + if len(ports) == 0 { + return nil + } + + out := make(vm.PortMappings, 0, len(ports)) + for _, port := range ports { + out = append(out, vm.PortMapping{ + HostPort: port.HostPort, + ContainerPort: port.ContainerPort, + Protocol: port.Protocol, + }) + } + + return out +} + +func newVMID(userID, challengeID int64) string { + return fmt.Sprintf("vm-%d-%d-%s", userID, challengeID, strings.ReplaceAll(uuid.NewString(), "-", "")[:12]) +} + +func vmRateLimitKey(userID int64) string { + return "vm:create:" + strconv.FormatInt(userID, 10) +} + +func vmStringPtr(value string) *string { + if strings.TrimSpace(value) == "" { + return nil + } + + return &value +} + +func nullIfEmpty(value string) *string { + return vmStringPtr(value) +} + +func isUniqueVMConflict(err error) bool { + if err == nil { + return false + } + + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "duplicate key") || strings.Contains(msg, "unique constraint") +} + +func (s *VMService) scopeIsTeam() bool { + return strings.EqualFold(strings.TrimSpace(s.cfg.MaxScope), "team") +} + +func (s *VMService) countByScope(ctx context.Context, userID int64) (int, error) { + if s.scopeIsTeam() { + return s.vmRepo.CountByTeamUser(ctx, userID) + } + + return s.vmRepo.CountByUser(ctx, userID) +} + +func (s *VMService) getByScope(ctx context.Context, userID, challengeID int64) (*models.VM, error) { + if s.scopeIsTeam() { + return s.vmRepo.GetByTeamUserAndChallenge(ctx, userID, challengeID) + } + + return s.vmRepo.GetByUserAndChallenge(ctx, userID, challengeID) +} diff --git a/internal/service/vm_service_test.go b/internal/service/vm_service_test.go new file mode 100644 index 0000000..0bde368 --- /dev/null +++ b/internal/service/vm_service_test.go @@ -0,0 +1,448 @@ +package service + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "smctf/internal/config" + "smctf/internal/models" + "smctf/internal/repo" + "smctf/internal/utils" + "smctf/internal/vm" + + "golang.org/x/crypto/bcrypt" +) + +const testVMSpec = `apiVersion: sandboxd.o/v1 +kind: Sandbox +id: placeholder +spec: + egress: true + ttl_seconds: 3600 + ports: + - host_port: 0 + container_port: 31337 + protocol: tcp + containers: + - name: app + image: nginx:latest + resource: + cpu: 50m + memory: 64Mi +` + +func createVMChallenge(t *testing.T, env serviceEnv, title string) *models.Challenge { + t.Helper() + spec := testVMSpec + challenge := &models.Challenge{ + Title: title, + Description: "desc", + Category: "Web", + Points: 100, + VMEnabled: true, + VMSpec: &spec, + IsActive: true, + CreatedAt: time.Now().UTC(), + } + + hash, err := utils.HashFlag("flag", bcrypt.MinCost) + if err != nil { + t.Fatalf("hash flag: %v", err) + } + challenge.FlagHash = hash + + if err := env.challengeRepo.Create(context.Background(), challenge); err != nil { + t.Fatalf("create challenge: %v", err) + } + + return challenge +} + +func newVMServiceForTest(env serviceEnv, client vm.API, cfg config.VMConfig) (*VMService, *repo.VMRepo) { + vmRepo := repo.NewVMRepo(env.db) + return NewVMService(cfg, vmRepo, env.challengeRepo, env.submissionRepo, client, env.redis), vmRepo +} + +func TestVMServiceGetOrCreateVMRewritesManifestID(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "vm-user@example.com", "vm-user", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "vm") + var requestedID string + + client := &vm.MockClient{ + CreateSandboxFn: func(ctx context.Context, id string, specYAML string) (*vm.Sandbox, error) { + requestedID = id + _, req, err := vm.RenderManifestWithID(specYAML, id) + if err != nil { + t.Fatalf("render manifest: %v", err) + } + + if req.ID != id { + t.Fatalf("manifest id not rewritten: got %q want %q", req.ID, id) + } + + exp := time.Now().UTC().Add(time.Hour) + return &vm.Sandbox{ID: id, Status: vm.SandboxStatus{Phase: "Pending", ExpireAt: &exp}}, nil + }, + GetSandboxFn: func(ctx context.Context, id string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: id, Status: vm.SandboxStatus{Phase: "Running", ExternalIP: "127.0.0.1", AssignedPorts: []vm.PortMapping{{HostPort: 31000, ContainerPort: 31337, Protocol: "tcp"}}}}, nil + }, + DeleteSandboxFn: func(ctx context.Context, id string) error { return nil }, + } + svc, _ := newVMServiceForTest(env, client, config.VMConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + model, err := svc.GetOrCreateVM(context.Background(), user.ID, challenge.ID) + if err != nil { + t.Fatalf("GetOrCreateVM: %v", err) + } + + if model.VMID == "" || requestedID != model.VMID { + t.Fatalf("unexpected vm id: requested=%q model=%q", requestedID, model.VMID) + } +} + +func TestVMServiceRefreshDoesNotDeleteFailedVM(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "vm-failed@example.com", "vm-failed", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "vm-failed") + svc, vmRepo := newVMServiceForTest(env, &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, id string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: id, Status: vm.SandboxStatus{Phase: "Failed", LastError: "image pull failed"}}, nil + }, + DeleteSandboxFn: func(ctx context.Context, id string) error { return nil }, + }, config.VMConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + now := time.Now().UTC() + if err := vmRepo.Create(context.Background(), &models.VM{UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-failed-1", Status: "Running", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create vm: %v", err) + } + + got, err := svc.GetVM(context.Background(), user.ID, challenge.ID) + if err != nil { + t.Fatalf("GetVM: %v", err) + } + + if got.Status != "Failed" || got.LastError == nil || *got.LastError != "image pull failed" { + t.Fatalf("expected failed vm with error, got %+v", got) + } + + if _, err := vmRepo.GetByVMID(context.Background(), "vm-failed-1"); err != nil { + t.Fatalf("vm should remain in db: %v", err) + } +} + +func TestVMServiceRefreshReturnsUnavailableWithoutLastError(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "vm-unavailable@example.com", "vm-unavailable", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "vm-unavailable") + svc, vmRepo := newVMServiceForTest(env, &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, id string) (*vm.Sandbox, error) { + return nil, vm.ErrUnavailable + }, + }, config.VMConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + now := time.Now().UTC() + if err := vmRepo.Create(context.Background(), &models.VM{UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-unavailable-1", Status: "Pending", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create vm: %v", err) + } + + if _, err := svc.GetVM(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrVMOrchestratorDown) { + t.Fatalf("expected ErrVMOrchestratorDown, got %v", err) + } + + got, err := vmRepo.GetByVMID(context.Background(), "vm-unavailable-1") + if err != nil { + t.Fatalf("vm should remain in db: %v", err) + } + + if got.LastError != nil { + t.Fatalf("network errors should not be stored as last error: %q", *got.LastError) + } +} + +func TestVMServiceRefreshStoresOrchestratorHTTPError(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "vm-http-error@example.com", "vm-http-error", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "vm-http-error") + svc, vmRepo := newVMServiceForTest(env, &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, id string) (*vm.Sandbox, error) { + return nil, &vm.StatusError{StatusCode: 409, Message: "sandbox is terminating"} + }, + }, config.VMConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + now := time.Now().UTC() + if err := vmRepo.Create(context.Background(), &models.VM{UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-http-error-1", Status: "Running", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create vm: %v", err) + } + + got, err := svc.GetVM(context.Background(), user.ID, challenge.ID) + if err != nil { + t.Fatalf("GetVM should keep HTTP response errors in last_error: %v", err) + } + + if got.LastError == nil || *got.LastError != "sandbox is terminating" { + t.Fatalf("expected orchestrator message in last_error, got %+v", got.LastError) + } + + if got.Status != "Error" { + t.Fatalf("expected status Error on orchestrator HTTP error, got %q", got.Status) + } +} + +func TestVMServiceRefreshDeletesMissingVM(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "vm-missing@example.com", "vm-missing", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "vm-missing") + svc, vmRepo := newVMServiceForTest(env, &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, id string) (*vm.Sandbox, error) { + return nil, &vm.StatusError{StatusCode: 404, Message: "not found"} + }, + }, config.VMConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + now := time.Now().UTC() + if err := vmRepo.Create(context.Background(), &models.VM{UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-missing-1", Status: "Running", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create vm: %v", err) + } + + if _, err := svc.GetVM(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrVMNotFound) { + t.Fatalf("expected ErrVMNotFound, got %v", err) + } + + if _, err := vmRepo.GetByVMID(context.Background(), "vm-missing-1"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected vm row to be deleted, got %v", err) + } +} + +func TestVMServiceAdminListDoesNotRefreshRows(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "vm-admin-refresh@example.com", "vm-admin-refresh", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "vm-admin-refresh") + svc, vmRepo := newVMServiceForTest(env, &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, id string) (*vm.Sandbox, error) { + return nil, &vm.StatusError{StatusCode: 404, Message: "not found"} + }, + }, config.VMConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + now := time.Now().UTC() + if err := vmRepo.Create(context.Background(), &models.VM{UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-admin-refresh-1", Status: "Running", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create vm: %v", err) + } + + rows, err := svc.ListAdminVMs(context.Background()) + if err != nil { + t.Fatalf("ListAdminVMs: %v", err) + } + + if len(rows) != 1 || rows[0].VMID != "vm-admin-refresh-1" { + t.Fatalf("expected vm row to remain in list without refresh, got %+v", rows) + } + + if _, err := vmRepo.GetByVMID(context.Background(), "vm-admin-refresh-1"); err != nil { + t.Fatalf("expected vm row to remain in db without refresh, got %v", err) + } +} + +func TestVMServiceUserVMSummaryAndListScopes(t *testing.T) { + env := setupServiceTest(t) + user1 := createUserWithNewTeam(t, env, "sum1@example.com", "sum1", "pass", models.UserRole) + user2 := createUserWithTeam(t, env, "sum2@example.com", "sum2", "pass", models.UserRole, user1.TeamID) + ch1 := createVMChallenge(t, env, "sum-vm-1") + ch2 := createVMChallenge(t, env, "sum-vm-2") + + vmRepo := repo.NewVMRepo(env.db) + now := time.Now().UTC() + if err := vmRepo.Create(context.Background(), &models.VM{UserID: user1.ID, ChallengeID: ch1.ID, VMID: "vm-sum-1", Status: "Running", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create vm1: %v", err) + } + + if err := vmRepo.Create(context.Background(), &models.VM{UserID: user2.ID, ChallengeID: ch2.ID, VMID: "vm-sum-2", Status: "Pending", CreatedAt: now.Add(time.Second), UpdatedAt: now.Add(time.Second)}); err != nil { + t.Fatalf("create vm2: %v", err) + } + + disabledSvc, _ := newVMServiceForTest(env, &vm.MockClient{}, config.VMConfig{Enabled: false, MaxPer: 7}) + count, limit, err := disabledSvc.UserVMSummary(context.Background(), user1.ID) + if err != nil || count != 0 || limit != 0 { + t.Fatalf("disabled summary mismatch: count=%d limit=%d err=%v", count, limit, err) + } + + teamSvc, _ := newVMServiceForTest(env, &vm.MockClient{}, config.VMConfig{Enabled: true, MaxPer: 3, MaxScope: "team"}) + count, limit, err = teamSvc.UserVMSummary(context.Background(), user1.ID) + if err != nil { + t.Fatalf("team summary: %v", err) + } + + if count != 2 || limit != 3 { + t.Fatalf("expected team summary (2,3), got (%d,%d)", count, limit) + } + + list, err := teamSvc.ListUserVMs(context.Background(), user1.ID) + if err != nil { + t.Fatalf("team list: %v", err) + } + + if len(list) != 2 { + t.Fatalf("expected 2 team rows, got %d", len(list)) + } + + userSvc, _ := newVMServiceForTest(env, &vm.MockClient{}, config.VMConfig{Enabled: true, MaxPer: 5, MaxScope: "user"}) + count, limit, err = userSvc.UserVMSummary(context.Background(), user1.ID) + if err != nil { + t.Fatalf("user summary: %v", err) + } + + if count != 1 || limit != 5 { + t.Fatalf("expected user summary (1,5), got (%d,%d)", count, limit) + } + + list, err = userSvc.ListUserVMs(context.Background(), user1.ID) + if err != nil { + t.Fatalf("user list: %v", err) + } + + if len(list) != 1 || list[0].VMID != "vm-sum-1" { + t.Fatalf("unexpected user list: %+v", list) + } +} + +func TestVMServiceListAllAndAdminAndGetDeleteByVMID(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "byid@example.com", "byid", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "byid-vm") + now := time.Now().UTC() + + vmRepo := repo.NewVMRepo(env.db) + if err := vmRepo.Create(context.Background(), &models.VM{ + UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-byid-1", Status: "Pending", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create vm: %v", err) + } + + mock := &vm.MockClient{ + GetSandboxFn: func(ctx context.Context, id string) (*vm.Sandbox, error) { + return &vm.Sandbox{ + ID: id, + Status: vm.SandboxStatus{ + Phase: "Running", + NodeName: "node-a", + ExternalIP: "127.0.0.1", + AssignedPorts: []vm.PortMapping{{HostPort: 31010, ContainerPort: 31337, Protocol: "tcp"}}, + }, + }, nil + }, + DeleteSandboxFn: func(ctx context.Context, id string) error { return nil }, + } + svc, _ := newVMServiceForTest(env, mock, config.VMConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}) + + all, err := svc.ListAllVMs(context.Background()) + if err != nil || len(all) != 1 { + t.Fatalf("ListAllVMs mismatch: len=%d err=%v", len(all), err) + } + + adminRows, err := svc.ListAdminVMs(context.Background()) + if err != nil || len(adminRows) != 1 { + t.Fatalf("ListAdminVMs mismatch: len=%d err=%v", len(adminRows), err) + } + + got, err := svc.GetVMByVMID(context.Background(), "vm-byid-1") + if err != nil { + t.Fatalf("GetVMByVMID: %v", err) + } + + if got.Status != "Running" || got.ExternalIP == nil || *got.ExternalIP != "127.0.0.1" { + t.Fatalf("unexpected refreshed vm: %+v", got) + } + + if err := svc.DeleteVMByVMID(context.Background(), "vm-byid-1"); err != nil { + t.Fatalf("DeleteVMByVMID: %v", err) + } + + if _, err := vmRepo.GetByVMID(context.Background(), "vm-byid-1"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected deleted row, got %v", err) + } + + if err := svc.DeleteVMByVMID(context.Background(), "vm-missing"); !errors.Is(err, ErrVMNotFound) { + t.Fatalf("expected ErrVMNotFound, got %v", err) + } +} + +func TestVMServiceDeleteVMAndDeleteByUserChallenge(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "del@example.com", "del", "pass", models.UserRole) + challenge := createVMChallenge(t, env, "del-vm") + now := time.Now().UTC() + + vmRepo := repo.NewVMRepo(env.db) + if err := vmRepo.Create(context.Background(), &models.VM{ + UserID: user.ID, ChallengeID: challenge.ID, VMID: "vm-del-1", Status: "Running", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create vm: %v", err) + } + + svc, _ := newVMServiceForTest(env, &vm.MockClient{ + DeleteSandboxFn: func(ctx context.Context, id string) error { + if id == "vm-del-1" { + return vm.ErrNotFound + } + return nil + }, + }, config.VMConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}) + + if err := svc.DeleteVM(context.Background(), user.ID, challenge.ID); err != nil { + t.Fatalf("DeleteVM should allow vm.ErrNotFound from orchestrator: %v", err) + } + + if _, err := vmRepo.GetByVMID(context.Background(), "vm-del-1"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected db row deleted, got %v", err) + } + + if err := svc.DeleteVMByUserAndChallenge(context.Background(), user.ID, challenge.ID); err != nil { + t.Fatalf("DeleteVMByUserAndChallenge should ignore not found, got %v", err) + } +} + +func TestVMServiceGetOrCreateVMLockedAndSolvedAndHelpers(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "lock@example.com", "lock", "pass", models.UserRole) + prev := createVMChallenge(t, env, "prev") + challenge := createVMChallenge(t, env, "locked") + challenge.PreviousChallengeID = &prev.ID + if err := env.challengeRepo.Update(context.Background(), challenge); err != nil { + t.Fatalf("update challenge lock: %v", err) + } + + svc, _ := newVMServiceForTest(env, &vm.MockClient{ + CreateSandboxFn: func(ctx context.Context, id string, specYAML string) (*vm.Sandbox, error) { + return &vm.Sandbox{ID: id, Status: vm.SandboxStatus{Phase: "Pending"}}, nil + }, + }, config.VMConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}) + + if _, err := svc.GetOrCreateVM(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrChallengeLocked) { + t.Fatalf("expected ErrChallengeLocked, got %v", err) + } + + createSubmission(t, env, user.ID, prev.ID, true, time.Now().UTC()) + createSubmission(t, env, user.ID, challenge.ID, true, time.Now().UTC()) + if _, err := svc.GetOrCreateVM(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrAlreadySolved) { + t.Fatalf("expected ErrAlreadySolved, got %v", err) + } + + created := toVMPortMappings([]vm.PortMapping{{HostPort: 31000, ContainerPort: 31337, Protocol: "tcp"}}) + if len(created) != 1 || created[0].HostPort != 31000 || created[0].ContainerPort != 31337 { + t.Fatalf("unexpected port mapping conversion: %+v", created) + } + + if toVMPortMappings(nil) != nil { + t.Fatalf("expected nil mapping for empty input") + } + + if !isUniqueVMConflict(fmt.Errorf("duplicate key value violates unique constraint")) { + t.Fatalf("expected duplicate key to be detected") + } + + if isUniqueVMConflict(errors.New("something else")) { + t.Fatalf("did not expect unrelated error to be unique conflict") + } +} diff --git a/internal/stack/client.go b/internal/stack/client.go deleted file mode 100644 index cb9541b..0000000 --- a/internal/stack/client.go +++ /dev/null @@ -1,203 +0,0 @@ -package stack - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -var ( - ErrNotFound = errors.New("stack not found") - ErrInvalid = errors.New("stack request invalid") - ErrUnavailable = errors.New("stack provisioner unavailable") - ErrUnexpected = errors.New("stack provisioner error") -) - -type Client struct { - baseURL string - apiKey string - httpClient *http.Client -} - -type API interface { - CreateStack(ctx context.Context, targetPorts []TargetPortSpec, podSpec string) (*StackInfo, error) - GetStackStatus(ctx context.Context, stackID string) (*StackStatus, error) - DeleteStack(ctx context.Context, stackID string) error -} - -type CreateRequest struct { - TargetPort []TargetPortSpec `json:"target_port"` - PodSpec string `json:"pod_spec"` -} - -type TargetPortSpec struct { - ContainerPort int `json:"container_port"` - Protocol string `json:"protocol"` -} - -type PortMapping struct { - ContainerPort int `json:"container_port"` - Protocol string `json:"protocol"` - NodePort int `json:"node_port"` -} - -type StackInfo struct { - StackID string `json:"stack_id"` - PodID string `json:"pod_id"` - Namespace string `json:"namespace"` - NodeID string `json:"node_id"` - NodePublicIP string `json:"node_public_ip"` - PodSpec string `json:"pod_spec"` - Ports []PortMapping `json:"ports"` - ServiceName string `json:"service_name"` - Status string `json:"status"` - TTLExpiresAt time.Time `json:"ttl_expires_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - RequestedCPUMilli int `json:"requested_cpu_milli"` - RequestedMemoryBytes int `json:"requested_memory_bytes"` -} - -type StackStatus struct { - StackID string `json:"stack_id"` - Status string `json:"status"` - TTL time.Time `json:"ttl"` - Ports []PortMapping `json:"ports"` - NodePublicIP string `json:"node_public_ip"` -} - -func NewClient(baseURL, apiKey string, timeout time.Duration) *Client { - baseURL = strings.TrimRight(baseURL, "/") - - return &Client{ - baseURL: baseURL, - apiKey: apiKey, - httpClient: &http.Client{ - Timeout: timeout, - }, - } -} - -func (c *Client) CreateStack(ctx context.Context, targetPorts []TargetPortSpec, podSpec string) (*StackInfo, error) { - reqBody := CreateRequest{ - TargetPort: targetPorts, - PodSpec: podSpec, - } - var resp StackInfo - if err := c.doJSON(ctx, http.MethodPost, "/stacks", reqBody, &resp); err != nil { - return nil, err - } - - return &resp, nil -} - -func (c *Client) GetStack(ctx context.Context, stackID string) (*StackInfo, error) { - var resp StackInfo - if err := c.doJSON(ctx, http.MethodGet, stackPath(stackID), nil, &resp); err != nil { - return nil, err - } - - return &resp, nil -} - -func (c *Client) GetStackStatus(ctx context.Context, stackID string) (*StackStatus, error) { - var resp StackStatus - if err := c.doJSON(ctx, http.MethodGet, stackStatusPath(stackID), nil, &resp); err != nil { - return nil, err - } - - return &resp, nil -} - -func (c *Client) DeleteStack(ctx context.Context, stackID string) error { - return c.doJSON(ctx, http.MethodDelete, stackPath(stackID), nil, nil) -} - -func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any) error { - reader, err := encodeBody(body) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) - if err != nil { - return fmt.Errorf("stack client request: %w", err) - } - - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - if c.apiKey != "" { - req.Header.Set("X-API-KEY", c.apiKey) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("stack client request: %w", err) - } - defer resp.Body.Close() - - if err := handleStatus(resp.StatusCode); err != nil { - return err - } - - if out == nil { - return nil - } - - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { - return fmt.Errorf("stack client decode: %w", err) - } - - return nil -} - -func encodeBody(body any) (io.Reader, error) { - if body == nil { - return nil, nil - } - - payload, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("stack client marshal: %w", err) - } - - return bytes.NewReader(payload), nil -} - -func handleStatus(status int) error { - if status >= 200 && status < 300 { - return nil - } - - return mapStatus(status) -} - -func mapStatus(status int) error { - switch status { - case http.StatusNotFound: - return ErrNotFound - case http.StatusBadRequest: - return ErrInvalid - case http.StatusServiceUnavailable: - return ErrUnavailable - default: - return ErrUnexpected - } -} - -func stackPath(stackID string) string { - return fmt.Sprintf("/stacks/%s", stackID) -} - -func stackStatusPath(stackID string) string { - return fmt.Sprintf("/stacks/%s/status", stackID) -} diff --git a/internal/stack/grpc_client.go b/internal/stack/grpc_client.go deleted file mode 100644 index 3cf492c..0000000 --- a/internal/stack/grpc_client.go +++ /dev/null @@ -1,240 +0,0 @@ -package stack - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - stackv1 "smctf/internal/gen/stack/v1" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" -) - -type GRPCClient struct { - addr string - apiKey string - timeout time.Duration - conn *grpc.ClientConn - client stackv1.StackServiceClient -} - -func NewGRPCClient(addr, apiKey string, timeout time.Duration) (*GRPCClient, error) { - addr = strings.TrimSpace(addr) - if addr == "" { - return nil, fmt.Errorf("grpc addr is required") - } - - conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - return nil, fmt.Errorf("grpc dial: %w", err) - } - - return &GRPCClient{ - addr: addr, - apiKey: apiKey, - timeout: timeout, - conn: conn, - client: stackv1.NewStackServiceClient(conn), - }, nil -} - -func (c *GRPCClient) Close() error { - if c.conn == nil { - return nil - } - - return c.conn.Close() -} - -func (c *GRPCClient) CreateStack(ctx context.Context, targetPorts []TargetPortSpec, podSpec string) (*StackInfo, error) { - ctx, cancel := c.withTimeout(ctx) - defer cancel() - - ctx = c.withAPIKey(ctx) - resp, err := c.client.CreateStack(ctx, &stackv1.CreateStackRequest{ - PodSpec: podSpec, - TargetPorts: toProtoTargetPorts(targetPorts), - }) - if err != nil { - return nil, mapGRPCError(err) - } - - stack := resp.GetStack() - if stack == nil { - return nil, ErrUnexpected - } - - return toStackInfo(stack), nil -} - -func (c *GRPCClient) GetStackStatus(ctx context.Context, stackID string) (*StackStatus, error) { - ctx, cancel := c.withTimeout(ctx) - defer cancel() - - ctx = c.withAPIKey(ctx) - resp, err := c.client.GetStackStatusSummary(ctx, &stackv1.GetStackStatusSummaryRequest{StackId: stackID}) - if err != nil { - return nil, mapGRPCError(err) - } - - summary := resp.GetSummary() - if summary == nil { - return nil, ErrUnexpected - } - - return toStackStatus(summary), nil -} - -func (c *GRPCClient) DeleteStack(ctx context.Context, stackID string) error { - ctx, cancel := c.withTimeout(ctx) - defer cancel() - - ctx = c.withAPIKey(ctx) - _, err := c.client.DeleteStack(ctx, &stackv1.DeleteStackRequest{StackId: stackID}) - if err != nil { - return mapGRPCError(err) - } - - return nil -} - -func (c *GRPCClient) withAPIKey(ctx context.Context) context.Context { - if strings.TrimSpace(c.apiKey) == "" { - return ctx - } - - return metadata.AppendToOutgoingContext(ctx, "x-api-key", c.apiKey) -} - -func (c *GRPCClient) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { - if c.timeout <= 0 { - return ctx, func() {} - } - - if deadline, ok := ctx.Deadline(); ok { - desired := time.Now().Add(c.timeout) - if deadline.Before(desired) { - return ctx, func() {} - } - return context.WithDeadline(ctx, desired) - } - - return context.WithTimeout(ctx, c.timeout) -} - -func mapGRPCError(err error) error { - if errors.Is(err, context.DeadlineExceeded) { - return ErrUnavailable - } - - if errors.Is(err, context.Canceled) { - return ErrUnexpected - } - - st, ok := status.FromError(err) - if !ok { - return ErrUnexpected - } - - switch st.Code() { - case codes.NotFound: - return ErrNotFound - case codes.InvalidArgument: - return ErrInvalid - case codes.Unavailable, codes.DeadlineExceeded: - return ErrUnavailable - default: - return ErrUnexpected - } -} - -func toProtoTargetPorts(ports []TargetPortSpec) []*stackv1.PortSpec { - out := make([]*stackv1.PortSpec, 0, len(ports)) - for _, port := range ports { - out = append(out, &stackv1.PortSpec{ - ContainerPort: int32(port.ContainerPort), - Protocol: port.Protocol, - }) - } - - return out -} - -func toStackInfo(stack *stackv1.Stack) *StackInfo { - ports := make([]PortMapping, 0, len(stack.Ports)) - for _, port := range stack.Ports { - ports = append(ports, PortMapping{ - ContainerPort: int(port.ContainerPort), - Protocol: port.Protocol, - NodePort: int(port.NodePort), - }) - } - - return &StackInfo{ - StackID: stack.StackId, - PodID: stack.PodId, - Namespace: stack.Namespace, - NodeID: stack.NodeId, - NodePublicIP: stack.GetNodePublicIp(), - PodSpec: stack.PodSpec, - Ports: ports, - ServiceName: stack.ServiceName, - Status: statusToString(stack.Status), - TTLExpiresAt: timeOrZero(stack.TtlExpiresAt), - CreatedAt: timeOrZero(stack.CreatedAt), - UpdatedAt: timeOrZero(stack.UpdatedAt), - RequestedCPUMilli: int(stack.RequestedCpuMilli), - RequestedMemoryBytes: int(stack.RequestedMemoryBytes), - } -} - -func toStackStatus(summary *stackv1.StackStatusSummary) *StackStatus { - ports := make([]PortMapping, 0, len(summary.Ports)) - for _, port := range summary.Ports { - ports = append(ports, PortMapping{ - ContainerPort: int(port.ContainerPort), - Protocol: port.Protocol, - NodePort: int(port.NodePort), - }) - } - - return &StackStatus{ - StackID: summary.StackId, - Status: statusToString(summary.Status), - TTL: timeOrZero(summary.Ttl), - Ports: ports, - NodePublicIP: summary.GetNodePublicIp(), - } -} - -func timeOrZero(ts *timestamppb.Timestamp) time.Time { - if ts == nil { - return time.Time{} - } - - return ts.AsTime() -} - -func statusToString(status stackv1.Status) string { - switch status { - case stackv1.Status_STATUS_CREATING: - return "creating" - case stackv1.Status_STATUS_RUNNING: - return "running" - case stackv1.Status_STATUS_STOPPED: - return "stopped" - case stackv1.Status_STATUS_FAILED: - return "failed" - case stackv1.Status_STATUS_NODE_DELETED: - return "node_deleted" - default: - return "" - } -} diff --git a/internal/stack/mock.go b/internal/stack/mock.go deleted file mode 100644 index f92940e..0000000 --- a/internal/stack/mock.go +++ /dev/null @@ -1,191 +0,0 @@ -package stack - -import ( - "context" - "fmt" - "sync" - "time" -) - -type MockClient struct { - CreateStackFn func(ctx context.Context, targetPorts []TargetPortSpec, podSpec string) (*StackInfo, error) - GetStackStatusFn func(ctx context.Context, stackID string) (*StackStatus, error) - DeleteStackFn func(ctx context.Context, stackID string) error -} - -func (m *MockClient) CreateStack(ctx context.Context, targetPorts []TargetPortSpec, podSpec string) (*StackInfo, error) { - if m.CreateStackFn == nil { - return nil, ErrUnexpected - } - - return m.CreateStackFn(ctx, targetPorts, podSpec) -} - -func (m *MockClient) GetStackStatus(ctx context.Context, stackID string) (*StackStatus, error) { - if m.GetStackStatusFn == nil { - return nil, ErrUnexpected - } - - return m.GetStackStatusFn(ctx, stackID) -} - -func (m *MockClient) DeleteStack(ctx context.Context, stackID string) error { - if m.DeleteStackFn == nil { - return ErrUnexpected - } - - return m.DeleteStackFn(ctx, stackID) -} - -type ProvisionerMock struct { - mu sync.Mutex - nextID int - stacks map[string]StackInfo - - createErr error - statusByID map[string]string - statusErrByID map[string]error - deleteErrByID map[string]error - deleteCalls map[string]int - createCalls int -} - -func NewProvisionerMock() *ProvisionerMock { - return &ProvisionerMock{ - nextID: 1, - stacks: make(map[string]StackInfo), - statusByID: make(map[string]string), - statusErrByID: make(map[string]error), - deleteErrByID: make(map[string]error), - deleteCalls: make(map[string]int), - } -} - -func (p *ProvisionerMock) Client() *MockClient { - return &MockClient{ - CreateStackFn: func(ctx context.Context, targetPorts []TargetPortSpec, podSpec string) (*StackInfo, error) { - p.mu.Lock() - if p.createErr != nil { - err := p.createErr - p.mu.Unlock() - return nil, err - } - id := p.nextID - p.nextID++ - p.createCalls++ - stackID := fmt.Sprintf("stack-test-%d", id) - now := time.Now().UTC() - portMappings := make([]PortMapping, 0, len(targetPorts)) - for idx, port := range targetPorts { - portMappings = append(portMappings, PortMapping{ - ContainerPort: port.ContainerPort, - Protocol: port.Protocol, - NodePort: 31001 + id + idx, - }) - } - info := StackInfo{ - StackID: stackID, - PodID: stackID, - Namespace: "stacks", - NodeID: "dev-worker", - NodePublicIP: "127.0.0.1", - PodSpec: podSpec, - Ports: portMappings, - ServiceName: "svc-" + stackID, - Status: "running", - TTLExpiresAt: now.Add(2 * time.Hour), - CreatedAt: now, - UpdatedAt: now, - } - - p.stacks[stackID] = info - p.mu.Unlock() - - return &info, nil - }, - GetStackStatusFn: func(ctx context.Context, stackID string) (*StackStatus, error) { - p.mu.Lock() - if err, ok := p.statusErrByID[stackID]; ok && err != nil { - p.mu.Unlock() - return nil, err - } - info, ok := p.stacks[stackID] - status := info.Status - if override, ok := p.statusByID[stackID]; ok && override != "" { - status = override - } - p.mu.Unlock() - if !ok { - return nil, ErrNotFound - } - - return &StackStatus{ - StackID: info.StackID, - Status: status, - TTL: info.TTLExpiresAt, - Ports: info.Ports, - NodePublicIP: info.NodePublicIP, - }, nil - }, - DeleteStackFn: func(ctx context.Context, stackID string) error { - p.mu.Lock() - if err, ok := p.deleteErrByID[stackID]; ok && err != nil { - p.deleteCalls[stackID]++ - p.mu.Unlock() - return err - } - _, ok := p.stacks[stackID] - delete(p.stacks, stackID) - p.deleteCalls[stackID]++ - p.mu.Unlock() - - if !ok { - return ErrNotFound - } - - return nil - }, - } -} - -func (p *ProvisionerMock) AddStack(info StackInfo) { - p.mu.Lock() - p.stacks[info.StackID] = info - p.mu.Unlock() -} - -func (p *ProvisionerMock) SetStatus(stackID, status string) { - p.mu.Lock() - p.statusByID[stackID] = status - p.mu.Unlock() -} - -func (p *ProvisionerMock) SetStatusError(stackID string, err error) { - p.mu.Lock() - p.statusErrByID[stackID] = err - p.mu.Unlock() -} - -func (p *ProvisionerMock) SetDeleteError(stackID string, err error) { - p.mu.Lock() - p.deleteErrByID[stackID] = err - p.mu.Unlock() -} - -func (p *ProvisionerMock) SetCreateError(err error) { - p.mu.Lock() - p.createErr = err - p.mu.Unlock() -} - -func (p *ProvisionerMock) DeleteCount(stackID string) int { - p.mu.Lock() - defer p.mu.Unlock() - return p.deleteCalls[stackID] -} - -func (p *ProvisionerMock) CreateCount() int { - p.mu.Lock() - defer p.mu.Unlock() - return p.createCalls -} diff --git a/internal/stack/mock_test.go b/internal/stack/mock_test.go deleted file mode 100644 index 8478e29..0000000 --- a/internal/stack/mock_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package stack - -import ( - "context" - "errors" - "testing" - "time" -) - -func TestMockClientDefaults(t *testing.T) { - m := &MockClient{} - - if _, err := m.CreateStack(context.Background(), []TargetPortSpec{{ContainerPort: 80, Protocol: "TCP"}}, "spec"); !errors.Is(err, ErrUnexpected) { - t.Fatalf("expected ErrUnexpected, got %v", err) - } - - if _, err := m.GetStackStatus(context.Background(), "id"); !errors.Is(err, ErrUnexpected) { - t.Fatalf("expected ErrUnexpected, got %v", err) - } - - if err := m.DeleteStack(context.Background(), "id"); !errors.Is(err, ErrUnexpected) { - t.Fatalf("expected ErrUnexpected, got %v", err) - } -} - -func TestMockClientFunctions(t *testing.T) { - m := &MockClient{} - - m.CreateStackFn = func(ctx context.Context, targetPorts []TargetPortSpec, podSpec string) (*StackInfo, error) { - if len(targetPorts) != 1 || targetPorts[0].ContainerPort != 8080 || targetPorts[0].Protocol != "TCP" || podSpec != "spec" { - t.Fatalf("unexpected args: %+v %s", targetPorts, podSpec) - } - - return &StackInfo{StackID: "stack-1"}, nil - } - - m.GetStackStatusFn = func(ctx context.Context, stackID string) (*StackStatus, error) { - if stackID != "stack-1" { - t.Fatalf("unexpected stackID: %s", stackID) - } - - return &StackStatus{StackID: stackID, Status: "running"}, nil - } - - m.DeleteStackFn = func(ctx context.Context, stackID string) error { - if stackID != "stack-1" { - t.Fatalf("unexpected stackID: %s", stackID) - } - - return nil - } - - info, err := m.CreateStack(context.Background(), []TargetPortSpec{{ContainerPort: 8080, Protocol: "TCP"}}, "spec") - if err != nil { - t.Fatalf("CreateStack: %v", err) - } - - if info.StackID != "stack-1" { - t.Fatalf("unexpected info: %+v", info) - } - - status, err := m.GetStackStatus(context.Background(), "stack-1") - if err != nil { - t.Fatalf("GetStackStatus: %v", err) - } - - if status.Status != "running" { - t.Fatalf("unexpected status: %+v", status) - } - - if err := m.DeleteStack(context.Background(), "stack-1"); err != nil { - t.Fatalf("DeleteStack: %v", err) - } -} - -func TestProvisionerMockLifecycle(t *testing.T) { - p := NewProvisionerMock() - client := p.Client() - - info, err := client.CreateStack(context.Background(), []TargetPortSpec{{ContainerPort: 80, Protocol: "TCP"}}, "spec") - if err != nil { - t.Fatalf("CreateStack: %v", err) - } - - if info.StackID == "" || len(info.Ports) != 1 || info.Ports[0].ContainerPort != 80 { - t.Fatalf("unexpected info: %+v", info) - } - - status, err := client.GetStackStatus(context.Background(), info.StackID) - if err != nil { - t.Fatalf("GetStackStatus: %v", err) - } - - if status.Status != "running" || len(status.Ports) != 1 || status.Ports[0].ContainerPort != 80 { - t.Fatalf("unexpected status: %+v", status) - } - - if err := client.DeleteStack(context.Background(), info.StackID); err != nil { - t.Fatalf("DeleteStack: %v", err) - } - - if err := client.DeleteStack(context.Background(), info.StackID); !errors.Is(err, ErrNotFound) { - t.Fatalf("expected ErrNotFound, got %v", err) - } -} - -func TestProvisionerMockOverrides(t *testing.T) { - p := NewProvisionerMock() - client := p.Client() - - p.SetCreateError(ErrUnavailable) - if _, err := client.CreateStack(context.Background(), []TargetPortSpec{{ContainerPort: 80, Protocol: "TCP"}}, "spec"); !errors.Is(err, ErrUnavailable) { - t.Fatalf("expected create ErrUnavailable, got %v", err) - } - - p.SetCreateError(nil) - info, err := client.CreateStack(context.Background(), []TargetPortSpec{{ContainerPort: 80, Protocol: "TCP"}}, "spec") - if err != nil { - t.Fatalf("CreateStack: %v", err) - } - - p.SetStatus(info.StackID, "stopped") - status, err := client.GetStackStatus(context.Background(), info.StackID) - if err != nil { - t.Fatalf("GetStackStatus: %v", err) - } - - if status.Status != "stopped" { - t.Fatalf("expected status stopped, got %s", status.Status) - } - - p.SetStatusError(info.StackID, ErrUnavailable) - if _, err := client.GetStackStatus(context.Background(), info.StackID); !errors.Is(err, ErrUnavailable) { - t.Fatalf("expected status ErrUnavailable, got %v", err) - } - - p.SetStatusError(info.StackID, nil) - p.SetDeleteError(info.StackID, ErrUnavailable) - if err := client.DeleteStack(context.Background(), info.StackID); !errors.Is(err, ErrUnavailable) { - t.Fatalf("expected delete ErrUnavailable, got %v", err) - } - - p.SetDeleteError(info.StackID, nil) - if err := client.DeleteStack(context.Background(), info.StackID); err != nil { - t.Fatalf("DeleteStack: %v", err) - } - - if p.DeleteCount(info.StackID) == 0 { - t.Fatalf("expected delete count") - } - - if p.CreateCount() == 0 { - t.Fatalf("expected create count") - } - - p.AddStack(StackInfo{ - StackID: "stack-extra", - Status: "running", - Ports: []PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, - TTLExpiresAt: time.Now().UTC().Add(time.Hour), - }) - - if _, err := client.GetStackStatus(context.Background(), "stack-extra"); err != nil { - t.Fatalf("GetStackStatus extra: %v", err) - } -} diff --git a/internal/stack/ports.go b/internal/stack/ports.go deleted file mode 100644 index 60507a3..0000000 --- a/internal/stack/ports.go +++ /dev/null @@ -1,69 +0,0 @@ -package stack - -import ( - "database/sql/driver" - "encoding/json" - "fmt" -) - -type TargetPortSpecs []TargetPortSpec - -func (p TargetPortSpecs) Value() (driver.Value, error) { - if p == nil { - return nil, nil - } - - payload, err := json.Marshal(p) - if err != nil { - return nil, fmt.Errorf("stack target ports marshal: %w", err) - } - - return string(payload), nil -} - -func (p *TargetPortSpecs) Scan(value any) error { - if value == nil { - *p = nil - return nil - } - - switch v := value.(type) { - case []byte: - return json.Unmarshal(v, p) - case string: - return json.Unmarshal([]byte(v), p) - default: - return fmt.Errorf("stack target ports scan: %T", value) - } -} - -type PortMappings []PortMapping - -func (p PortMappings) Value() (driver.Value, error) { - if p == nil { - return nil, nil - } - - payload, err := json.Marshal(p) - if err != nil { - return nil, fmt.Errorf("stack port mappings marshal: %w", err) - } - - return string(payload), nil -} - -func (p *PortMappings) Scan(value any) error { - if value == nil { - *p = nil - return nil - } - - switch v := value.(type) { - case []byte: - return json.Unmarshal(v, p) - case string: - return json.Unmarshal([]byte(v), p) - default: - return fmt.Errorf("stack port mappings scan: %T", value) - } -} diff --git a/internal/stack/ports_test.go b/internal/stack/ports_test.go deleted file mode 100644 index 52f4bfd..0000000 --- a/internal/stack/ports_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package stack - -import ( - "encoding/json" - "testing" -) - -func TestTargetPortSpecsValueAndScan(t *testing.T) { - specs := TargetPortSpecs{{ContainerPort: 80, Protocol: "TCP"}} - value, err := specs.Value() - if err != nil { - t.Fatalf("Value error: %v", err) - } - - raw, ok := value.(string) - if !ok { - t.Fatalf("expected string value, got %T", value) - } - - var decoded []TargetPortSpec - if err := json.Unmarshal([]byte(raw), &decoded); err != nil { - t.Fatalf("json unmarshal: %v", err) - } - - if len(decoded) != 1 || decoded[0].ContainerPort != 80 || decoded[0].Protocol != "TCP" { - t.Fatalf("unexpected decoded value: %+v", decoded) - } - - var scanned TargetPortSpecs - if err := scanned.Scan([]byte(raw)); err != nil { - t.Fatalf("Scan []byte error: %v", err) - } - - if len(scanned) != 1 || scanned[0].Protocol != "TCP" { - t.Fatalf("unexpected scanned value: %+v", scanned) - } - - var scannedString TargetPortSpecs - if err := scannedString.Scan(raw); err != nil { - t.Fatalf("Scan string error: %v", err) - } - - if len(scannedString) != 1 || scannedString[0].ContainerPort != 80 { - t.Fatalf("unexpected scanned string value: %+v", scannedString) - } - - var scannedNil TargetPortSpecs - if err := scannedNil.Scan(nil); err != nil { - t.Fatalf("Scan nil error: %v", err) - } - - if scannedNil != nil { - t.Fatalf("expected nil after scanning nil, got %+v", scannedNil) - } - - if err := scannedNil.Scan(123); err == nil { - t.Fatalf("expected error for unsupported Scan type") - } -} - -func TestPortMappingsValueAndScan(t *testing.T) { - mappings := PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}} - value, err := mappings.Value() - if err != nil { - t.Fatalf("Value error: %v", err) - } - - raw, ok := value.(string) - if !ok { - t.Fatalf("expected string value, got %T", value) - } - - var decoded []PortMapping - if err := json.Unmarshal([]byte(raw), &decoded); err != nil { - t.Fatalf("json unmarshal: %v", err) - } - - if len(decoded) != 1 || decoded[0].NodePort != 31001 { - t.Fatalf("unexpected decoded value: %+v", decoded) - } - - var scanned PortMappings - if err := scanned.Scan([]byte(raw)); err != nil { - t.Fatalf("Scan []byte error: %v", err) - } - - if len(scanned) != 1 || scanned[0].Protocol != "TCP" { - t.Fatalf("unexpected scanned value: %+v", scanned) - } - - var scannedString PortMappings - if err := scannedString.Scan(raw); err != nil { - t.Fatalf("Scan string error: %v", err) - } - - if len(scannedString) != 1 || scannedString[0].ContainerPort != 80 { - t.Fatalf("unexpected scanned string value: %+v", scannedString) - } - - var scannedNil PortMappings - if err := scannedNil.Scan(nil); err != nil { - t.Fatalf("Scan nil error: %v", err) - } - - if scannedNil != nil { - t.Fatalf("expected nil after scanning nil, got %+v", scannedNil) - } - - if err := scannedNil.Scan(123); err == nil { - t.Fatalf("expected error for unsupported Scan type") - } -} diff --git a/internal/vm/client.go b/internal/vm/client.go new file mode 100644 index 0000000..22710f5 --- /dev/null +++ b/internal/vm/client.go @@ -0,0 +1,179 @@ +package vm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +var ( + ErrNotFound = errors.New("vm not found") + ErrInvalid = errors.New("vm request invalid") + ErrUnavailable = errors.New("vm orchestrator unavailable") + ErrUnexpected = errors.New("vm orchestrator error") +) + +type StatusError struct { + StatusCode int + Message string +} + +func (e *StatusError) Error() string { + if strings.TrimSpace(e.Message) != "" { + return e.Message + } + + return fmt.Sprintf("vm orchestrator returned status %d", e.StatusCode) +} + +func (e *StatusError) Unwrap() error { + switch e.StatusCode { + case http.StatusNotFound: + return ErrNotFound + case http.StatusBadRequest: + return ErrInvalid + case http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusGatewayTimeout: + return ErrUnavailable + default: + return ErrUnexpected + } +} + +type API interface { + CreateSandbox(ctx context.Context, id string, specYAML string) (*Sandbox, error) + GetSandbox(ctx context.Context, id string) (*Sandbox, error) + DeleteSandbox(ctx context.Context, id string) error +} + +type Client struct { + baseURL string + httpClient *http.Client +} + +func NewClient(baseURL string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +func (c *Client) CreateSandbox(ctx context.Context, id string, specYAML string) (*Sandbox, error) { + _, req, err := RenderManifestWithID(specYAML, id) + if err != nil { + return nil, err + } + + var resp sandboxObjectResponse + if err := c.doJSON(ctx, http.MethodPost, "/api/v1/sandboxes", req, &resp); err != nil { + return nil, err + } + + if resp.Sandbox == nil { + return nil, ErrUnexpected + } + + return resp.Sandbox, nil +} + +func (c *Client) GetSandbox(ctx context.Context, id string) (*Sandbox, error) { + var resp sandboxObjectResponse + if err := c.doJSON(ctx, http.MethodGet, "/api/v1/sandboxes/"+url.PathEscape(id), nil, &resp); err != nil { + return nil, err + } + + if resp.Sandbox == nil { + return nil, ErrUnexpected + } + + return resp.Sandbox, nil +} + +func (c *Client) DeleteSandbox(ctx context.Context, id string) error { + return c.doJSON(ctx, http.MethodDelete, "/api/v1/sandboxes/"+url.PathEscape(id), nil, nil) +} + +func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any) error { + reader, err := encodeBody(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return fmt.Errorf("vm client request: %w", err) + } + + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + + if err := handleStatus(resp); err != nil { + return err + } + + if out == nil { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("vm client decode: %w", err) + } + + return nil +} + +func encodeBody(body any) (io.Reader, error) { + if body == nil { + return nil, nil + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("vm client marshal: %w", err) + } + + return bytes.NewReader(payload), nil +} + +func handleStatus(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + message := strings.TrimSpace(resp.Status) + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err == nil && len(body) > 0 { + message = parseErrorMessage(body, message) + } + + return &StatusError{StatusCode: resp.StatusCode, Message: message} +} + +func parseErrorMessage(body []byte, fallback string) string { + var resp ErrorResponse + if err := json.Unmarshal(body, &resp); err == nil && strings.TrimSpace(resp.Error) != "" { + return strings.TrimSpace(resp.Error) + } + + if text := strings.TrimSpace(string(body)); text != "" { + return text + } + + return fallback +} diff --git a/internal/vm/manifest.go b/internal/vm/manifest.go new file mode 100644 index 0000000..bd125c0 --- /dev/null +++ b/internal/vm/manifest.go @@ -0,0 +1,47 @@ +package vm + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +type SandboxManifest struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + ID string `yaml:"id"` + Spec SandboxSpec `yaml:"spec"` +} + +func RenderManifestWithID(rawSpec string, id string) ([]byte, CreateSandboxRequest, error) { + var manifest SandboxManifest + if err := yaml.Unmarshal([]byte(rawSpec), &manifest); err != nil { + return nil, CreateSandboxRequest{}, fmt.Errorf("%w: parse yaml", ErrInvalid) + } + + if !strings.EqualFold(strings.TrimSpace(manifest.Kind), "Sandbox") { + return nil, CreateSandboxRequest{}, fmt.Errorf("%w: kind must be Sandbox", ErrInvalid) + } + + if strings.TrimSpace(id) == "" { + return nil, CreateSandboxRequest{}, fmt.Errorf("%w: id is required", ErrInvalid) + } + + if len(manifest.Spec.Containers) == 0 { + return nil, CreateSandboxRequest{}, fmt.Errorf("%w: spec.containers is required", ErrInvalid) + } + + manifest.ID = strings.TrimSpace(id) + rendered, err := yaml.Marshal(&manifest) + if err != nil { + return nil, CreateSandboxRequest{}, fmt.Errorf("%w: render yaml", ErrInvalid) + } + + var req CreateSandboxRequest + if err := yaml.Unmarshal(rendered, &req); err != nil { + return nil, CreateSandboxRequest{}, fmt.Errorf("%w: parse rendered yaml", ErrInvalid) + } + + return rendered, req, nil +} diff --git a/internal/vm/manifest_test.go b/internal/vm/manifest_test.go new file mode 100644 index 0000000..cdf8f6f --- /dev/null +++ b/internal/vm/manifest_test.go @@ -0,0 +1,65 @@ +package vm + +import ( + "errors" + "strings" + "testing" +) + +const validManifest = `apiVersion: sandboxd.o/v1 +kind: Sandbox +id: placeholder +spec: + egress: true + ttl_seconds: 3600 + ports: + - host_port: 0 + container_port: 31337 + protocol: tcp + containers: + - name: app + image: nginx:latest + resource: + cpu: 50m + memory: 64Mi +` + +func TestRenderManifestWithID(t *testing.T) { + rendered, req, err := RenderManifestWithID(validManifest, "vm-1") + if err != nil { + t.Fatalf("RenderManifestWithID: %v", err) + } + + if req.ID != "vm-1" { + t.Fatalf("expected req.ID vm-1, got %q", req.ID) + } + + if len(req.Spec.Containers) != 1 { + t.Fatalf("expected one container, got %d", len(req.Spec.Containers)) + } + + if !strings.Contains(string(rendered), "id: vm-1") { + t.Fatalf("rendered yaml should include rewritten id, got:\n%s", string(rendered)) + } +} + +func TestRenderManifestWithIDErrors(t *testing.T) { + tests := []struct { + name string + spec string + id string + }{ + {name: "invalid yaml", spec: "::: bad :::", id: "vm-1"}, + {name: "invalid kind", spec: strings.Replace(validManifest, "kind: Sandbox", "kind: Invalid", 1), id: "vm-1"}, + {name: "empty id", spec: validManifest, id: " "}, + {name: "missing containers", spec: strings.Replace(validManifest, " containers:\n - name: app\n image: nginx:latest\n resource:\n cpu: 50m\n memory: 64Mi\n", "", 1), id: "vm-1"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, _, err := RenderManifestWithID(tc.spec, tc.id); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected ErrInvalid, got %v", err) + } + }) + } +} diff --git a/internal/vm/mock.go b/internal/vm/mock.go new file mode 100644 index 0000000..3d6e06e --- /dev/null +++ b/internal/vm/mock.go @@ -0,0 +1,85 @@ +package vm + +import ( + "context" + "sync" + "time" +) + +type MockClient struct { + CreateSandboxFn func(ctx context.Context, id string, specYAML string) (*Sandbox, error) + GetSandboxFn func(ctx context.Context, id string) (*Sandbox, error) + DeleteSandboxFn func(ctx context.Context, id string) error +} + +type OrchestratorMock struct { + mu sync.Mutex + sandboxes map[string]*Sandbox +} + +func NewOrchestratorMock() *OrchestratorMock { + return &OrchestratorMock{sandboxes: make(map[string]*Sandbox)} +} + +func (m *OrchestratorMock) Client() API { + return &MockClient{ + CreateSandboxFn: m.createSandbox, + GetSandboxFn: m.getSandbox, + DeleteSandboxFn: m.deleteSandbox, + } +} + +func (m *OrchestratorMock) createSandbox(_ context.Context, id string, _ string) (*Sandbox, error) { + m.mu.Lock() + defer m.mu.Unlock() + exp := time.Now().UTC().Add(time.Hour) + s := &Sandbox{ + ID: id, + Status: SandboxStatus{ + Phase: "Running", + ExternalIP: "127.0.0.1", + AssignedPorts: []PortMapping{{HostPort: 31001, ContainerPort: 80, Protocol: "TCP"}}, + ExpireAt: &exp, + }, + } + m.sandboxes[id] = s + return s, nil +} + +func (m *OrchestratorMock) getSandbox(_ context.Context, id string) (*Sandbox, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sandboxes[id] + if !ok { + return nil, ErrNotFound + } + return s, nil +} + +func (m *OrchestratorMock) deleteSandbox(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sandboxes, id) + return nil +} + +func (m *MockClient) CreateSandbox(ctx context.Context, id string, specYAML string) (*Sandbox, error) { + if m.CreateSandboxFn != nil { + return m.CreateSandboxFn(ctx, id, specYAML) + } + return nil, ErrUnexpected +} + +func (m *MockClient) GetSandbox(ctx context.Context, id string) (*Sandbox, error) { + if m.GetSandboxFn != nil { + return m.GetSandboxFn(ctx, id) + } + return nil, ErrUnexpected +} + +func (m *MockClient) DeleteSandbox(ctx context.Context, id string) error { + if m.DeleteSandboxFn != nil { + return m.DeleteSandboxFn(ctx, id) + } + return ErrUnexpected +} diff --git a/internal/vm/mock_test.go b/internal/vm/mock_test.go new file mode 100644 index 0000000..32374f1 --- /dev/null +++ b/internal/vm/mock_test.go @@ -0,0 +1,130 @@ +package vm + +import ( + "context" + "errors" + "testing" +) + +func TestMockClientCreateSandbox(t *testing.T) { + t.Run("missing fn", func(t *testing.T) { + client := &MockClient{} + if _, err := client.CreateSandbox(context.Background(), "vm-1", "spec"); !errors.Is(err, ErrUnexpected) { + t.Fatalf("expected ErrUnexpected, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + client := &MockClient{ + CreateSandboxFn: func(ctx context.Context, id string, specYAML string) (*Sandbox, error) { + if id != "vm-1" || specYAML != "spec" { + t.Fatalf("unexpected args id=%q spec=%q", id, specYAML) + } + return &Sandbox{ID: id}, nil + }, + } + + sandbox, err := client.CreateSandbox(context.Background(), "vm-1", "spec") + if err != nil { + t.Fatalf("CreateSandbox: %v", err) + } + + if sandbox.ID != "vm-1" { + t.Fatalf("unexpected sandbox: %+v", sandbox) + } + }) +} + +func TestMockClientGetSandbox(t *testing.T) { + t.Run("missing fn", func(t *testing.T) { + client := &MockClient{} + if _, err := client.GetSandbox(context.Background(), "vm-1"); !errors.Is(err, ErrUnexpected) { + t.Fatalf("expected ErrUnexpected, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + client := &MockClient{ + GetSandboxFn: func(ctx context.Context, id string) (*Sandbox, error) { + if id != "vm-1" { + t.Fatalf("unexpected id %q", id) + } + return &Sandbox{ID: id}, nil + }, + } + + sandbox, err := client.GetSandbox(context.Background(), "vm-1") + if err != nil { + t.Fatalf("GetSandbox: %v", err) + } + + if sandbox.ID != "vm-1" { + t.Fatalf("unexpected sandbox: %+v", sandbox) + } + }) +} + +func TestMockClientDeleteSandbox(t *testing.T) { + t.Run("missing fn", func(t *testing.T) { + client := &MockClient{} + if err := client.DeleteSandbox(context.Background(), "vm-1"); !errors.Is(err, ErrUnexpected) { + t.Fatalf("expected ErrUnexpected, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + called := false + client := &MockClient{ + DeleteSandboxFn: func(ctx context.Context, id string) error { + called = true + if id != "vm-1" { + t.Fatalf("unexpected id %q", id) + } + return nil + }, + } + + if err := client.DeleteSandbox(context.Background(), "vm-1"); err != nil { + t.Fatalf("DeleteSandbox: %v", err) + } + + if !called { + t.Fatalf("expected DeleteSandboxFn to be called") + } + }) +} + +func TestNewOrchestratorMockLifecycle(t *testing.T) { + orch := NewOrchestratorMock() + client := orch.Client() + + sandbox, err := client.CreateSandbox(context.Background(), "vm-mock-1", "spec") + if err != nil { + t.Fatalf("CreateSandbox: %v", err) + } + + if sandbox.ID != "vm-mock-1" { + t.Fatalf("unexpected sandbox id: %+v", sandbox) + } + + if sandbox.Status.Phase == "" { + t.Fatalf("expected phase to be set") + } + + got, err := client.GetSandbox(context.Background(), "vm-mock-1") + if err != nil { + t.Fatalf("GetSandbox: %v", err) + } + + if got.ID != "vm-mock-1" { + t.Fatalf("unexpected sandbox: %+v", got) + } + + if err := client.DeleteSandbox(context.Background(), "vm-mock-1"); err != nil { + t.Fatalf("DeleteSandbox: %v", err) + } + + if _, err := client.GetSandbox(context.Background(), "vm-mock-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} diff --git a/internal/vm/types.go b/internal/vm/types.go new file mode 100644 index 0000000..bfb8b5d --- /dev/null +++ b/internal/vm/types.go @@ -0,0 +1,69 @@ +package vm + +import "time" + +type PortSpec struct { + HostPort int `json:"host_port,omitempty" yaml:"host_port,omitempty"` + ContainerPort int `json:"container_port" yaml:"container_port"` + Protocol string `json:"protocol,omitempty" yaml:"protocol,omitempty"` +} + +type PortMapping struct { + HostPort int `json:"host_port"` + NodePort int `json:"node_port,omitempty"` + ContainerPort int `json:"container_port"` + Protocol string `json:"protocol"` +} + +type PortMappings []PortMapping + +type ResourceSpec struct { + CPU string `json:"cpu" yaml:"cpu"` + Memory string `json:"memory" yaml:"memory"` +} + +type ContainerSpec struct { + Name string `json:"name" yaml:"name"` + Image string `json:"image" yaml:"image"` + Args []string `json:"args,omitempty" yaml:"args,omitempty"` + Env []string `json:"env,omitempty" yaml:"env,omitempty"` + WorkDir string `json:"work_dir,omitempty" yaml:"work_dir,omitempty"` + Resource ResourceSpec `json:"resource" yaml:"resource"` +} + +type SandboxSpec struct { + Egress bool `json:"egress" yaml:"egress"` + TTLSeconds int64 `json:"ttl_seconds,omitempty" yaml:"ttl_seconds,omitempty"` + Ports []PortSpec `json:"ports,omitempty" yaml:"ports,omitempty"` + Containers []ContainerSpec `json:"containers" yaml:"containers"` +} + +type CreateSandboxRequest struct { + ID string `json:"id" yaml:"id"` + Spec SandboxSpec `json:"spec" yaml:"spec"` +} + +type SandboxStatus struct { + Phase string `json:"phase"` + NodeName string `json:"node_name,omitempty"` + ExternalIP string `json:"external,omitempty"` + AssignedPorts []PortMapping `json:"assigned_ports,omitempty"` + ExpireAt *time.Time `json:"expire_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +type Sandbox struct { + ID string `json:"id"` + Spec SandboxSpec `json:"spec"` + Status SandboxStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type sandboxObjectResponse struct { + Sandbox *Sandbox `json:"sandbox"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} diff --git a/migrations/2026-05-24/001_add_vms.sql b/migrations/2026-05-24/001_add_vms.sql new file mode 100644 index 0000000..7cc8e07 --- /dev/null +++ b/migrations/2026-05-24/001_add_vms.sql @@ -0,0 +1,26 @@ +BEGIN; + +ALTER TABLE challenges + ADD COLUMN IF NOT EXISTS vm_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS vm_spec TEXT; + +CREATE TABLE IF NOT EXISTS vms ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + challenge_id BIGINT NOT NULL REFERENCES challenges(id) ON DELETE CASCADE, + vm_id TEXT NOT NULL, + status TEXT NOT NULL, + node_name TEXT, + external_ip TEXT, + ports JSONB, + ttl_expires_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_vms_user_id ON vms (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_vms_user_challenge ON vms (user_id, challenge_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_vms_vm_id ON vms (vm_id); + +COMMIT; diff --git a/migrations/2026-05-24/999_rollback.sql b/migrations/2026-05-24/999_rollback.sql new file mode 100644 index 0000000..288c978 --- /dev/null +++ b/migrations/2026-05-24/999_rollback.sql @@ -0,0 +1,12 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_vms_vm_id; +DROP INDEX IF EXISTS idx_vms_user_challenge; +DROP INDEX IF EXISTS idx_vms_user_id; +DROP TABLE IF EXISTS vms; + +ALTER TABLE challenges + DROP COLUMN IF EXISTS vm_spec, + DROP COLUMN IF EXISTS vm_enabled; + +COMMIT; diff --git a/scripts/coverage_check/main.py b/scripts/coverage_check/main.py index 5044bd3..0b61feb 100644 --- a/scripts/coverage_check/main.py +++ b/scripts/coverage_check/main.py @@ -2,7 +2,7 @@ exclude = { 'internal/storage/s3.go', - 'internal/stack/client.go', + 'internal/vm/client.go', 'internal/http/handlers/types.go', } covered = 0 diff --git a/scripts/generate_dummy_sql/defaults/data.yaml b/scripts/generate_dummy_sql/defaults/data.yaml index e2c80da..43e8778 100644 --- a/scripts/generate_dummy_sql/defaults/data.yaml +++ b/scripts/generate_dummy_sql/defaults/data.yaml @@ -85,11 +85,11 @@ challenges: points: 50 flag: "flag{n3xtjs_cve_2O2S_2gg27_m1dd13w4r3}" category: "Web" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -108,11 +108,11 @@ challenges: points: 100 flag: "flag{f8e88a73-000f-41fe-ba8f-9f8636f2d21c}" category: "Web" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" - title: "Strong Bcrypt" description: | 이 웹 서비스를 개발한 개발팀에서 어떠한 방법으로 인증(Authentication) 없이 바이패스되는 취약점이 존재한다는 제보를 받았습니다. @@ -121,11 +121,11 @@ challenges: points: 100 flag: "flag{5c8f6e2d-3b4a-4f7e-9f1d-2e5b6c7d8e9f}" category: "Web" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -138,11 +138,11 @@ challenges: flag: "flag{8b1a9a93-2b11-4a3c-9f8a-6f3f1a1f2b9d}" category: "Web" previous_challenge_id: 4 - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -170,11 +170,11 @@ challenges: points: 150 flag: "SMCH{c5u5t0m_pr0t0c0l_4n4lyz3d}" category: "Network" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -213,11 +213,11 @@ challenges: points: 200 flag: "flag{p4ssw0rd_m4n4g3r_bre4k1n}" category: "Reversing" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -231,11 +231,11 @@ challenges: points: 250 flag: "flag{vm_1ns1de_vm_exploit3d}" category: "Pwnable" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -253,11 +253,11 @@ challenges: points: 300 flag: "flag{s3lf_m0d1fy1ng_n1ghtmar3}" category: "Reversing" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -276,11 +276,11 @@ challenges: points: 200 flag: "ketchup{p0s_syst3m_expl01t3d}" category: "Reversing" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -289,11 +289,11 @@ challenges: points: 50 flag: "flag{ret2win_successful}" category: "Pwnable" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -305,11 +305,11 @@ challenges: points: 200 flag: "flag{h0use_0f_0rang3_expl01t3d}" category: "Pwnable" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -321,11 +321,11 @@ challenges: points: 300 flag: "flag{seccomp_jail_byp4ss3d}" category: "Pwnable" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -337,11 +337,11 @@ challenges: points: 300 flag: "flag{f1nal_r0p_ch4ll3ng3_m4st3r3d}" category: "Pwnable" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 80 protocol: TCP - stack_pod_spec_path: "./stack_pod_spec.yaml" + vm_spec_path: "./vm_spec.yaml" file_name: "challenge.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -427,11 +427,11 @@ challenges: # points: 50 # flag: "flag{w3lc0me_to_smctf_2024}" # category: "Misc" - # stack_enabled: true - # stack_target_ports: + # vm_enabled: true + # vm_spec: # - container_port: 80 # protocol: TCP - # stack_pod_spec_path: "./stack_pod_spec.yaml" + # vm_spec_path: "./vm_spec.yaml" # file_name: "challenge.zip" # file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" # file_uploaded_at: "2026-02-13 12:00:00" @@ -575,11 +575,11 @@ challenges: # points: 500 # flag: "flag{final_boss_defeated_2024}" # category: "Misc" - # stack_enabled: true - # stack_target_ports: + # vm_enabled: true + # vm_spec: # - container_port: 80 # protocol: TCP - # stack_pod_spec_path: "./stack_pod_spec.yaml" + # vm_spec_path: "./vm_spec.yaml" # file_name: "challenge.zip" # file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" # file_uploaded_at: "2026-02-13 12:00:00" diff --git a/scripts/generate_dummy_sql/defaults/stack_pod_spec.yaml b/scripts/generate_dummy_sql/defaults/vm_spec.yaml similarity index 95% rename from scripts/generate_dummy_sql/defaults/stack_pod_spec.yaml rename to scripts/generate_dummy_sql/defaults/vm_spec.yaml index a3b058a..c4d1384 100644 --- a/scripts/generate_dummy_sql/defaults/stack_pod_spec.yaml +++ b/scripts/generate_dummy_sql/defaults/vm_spec.yaml @@ -1,5 +1,5 @@ apiVersion: v1 -kind: Pod +kind: Sandbox metadata: name: challenge spec: diff --git a/scripts/generate_dummy_sql/generator.py b/scripts/generate_dummy_sql/generator.py index 9a65fbc..de1ba1b 100644 --- a/scripts/generate_dummy_sql/generator.py +++ b/scripts/generate_dummy_sql/generator.py @@ -115,8 +115,8 @@ def generate_challenges( timing: Dict[str, Any], constraints: Dict[str, Any], bcrypt_cost: int, - stack_config: Optional[Dict[str, Any]] = None, - stack_pod_spec_content: str = "", + vm_config: Optional[Dict[str, Any]] = None, + vm_spec_content: str = "", file_config: Optional[Dict[str, Any]] = None, ) -> List[ Tuple[ @@ -130,7 +130,6 @@ def generate_challenges( bool, str, bool, - List[Dict[str, Any]], str, Optional[str], Optional[str], @@ -142,21 +141,16 @@ def generate_challenges( step_minutes = timing["challenge_created_minutes_step"] ratio = constraints["min_points_ratio"] floor = constraints["min_points_floor"] - stack_config = stack_config or {} + vm_config = vm_config or {} file_config = file_config or {} - stack_enabled_default = bool(stack_config.get("enabled", False)) - stack_random_count = int(stack_config.get("random_challenge_count", 0)) - stack_target_ports_default = stack_config.get("target_ports") - if not stack_target_ports_default and "target_port" in stack_config: - stack_target_ports_default = [{"container_port": int(stack_config["target_port"]), "protocol": "TCP"}] - if not stack_target_ports_default: - stack_target_ports_default = [{"container_port": 80, "protocol": "TCP"}] + vm_enabled_default = bool(vm_config.get("enabled", False)) + vm_random_count = int(vm_config.get("random_challenge_count", 0)) file_enabled_default = bool(file_config.get("enabled", False)) file_random_count = int(file_config.get("random_challenge_count", 0)) file_default_name = str(file_config.get("file_name", "challenge.zip")) file_uploaded_after_max = int(file_config.get("uploaded_minutes_after_create_max", 120)) - stack_indices = _pick_random_indices( - len(challenges), stack_random_count if stack_enabled_default else 0 + vm_indices = _pick_random_indices( + len(challenges), vm_random_count if vm_enabled_default else 0 ) file_indices = _pick_random_indices( len(challenges), @@ -168,18 +162,15 @@ def generate_challenges( flag_hash = hash_flag(chal["flag"], bcrypt_cost) minimum_points = max(floor, int(chal["points"] * ratio)) created_at = base_time + timedelta(minutes=i * step_minutes) - stack_enabled = bool(chal.get("stack_enabled", False)) - if not stack_enabled and i in stack_indices: - stack_enabled = True - stack_target_ports = list(chal.get("stack_target_ports", [])) if stack_enabled else [] - if stack_enabled and not stack_target_ports: - stack_target_ports = list(stack_target_ports_default) - stack_pod_spec = str(chal.get("stack_pod_spec", "")) if stack_enabled else "" - if stack_enabled and not stack_pod_spec: - stack_pod_spec = stack_pod_spec_content - if stack_enabled and not stack_pod_spec: + vm_enabled = bool(chal.get("vm_enabled", False)) + if not vm_enabled and i in vm_indices: + vm_enabled = True + vm_spec = str(chal.get("vm_spec", "")) if vm_enabled else "" + if vm_enabled and not vm_spec: + vm_spec = vm_spec_content + if vm_enabled and not vm_spec: raise SystemExit( - "Error: stack_enabled challenge requires stack_pod_spec content" + "Error: vm_enabled challenge requires vm_spec content" ) file_key = chal.get("file_key") file_name = chal.get("file_name") @@ -204,9 +195,8 @@ def generate_challenges( chal.get("previous_challenge_id"), True, created_at.strftime("%Y-%m-%d %H:%M:%S"), - stack_enabled, - stack_target_ports, - stack_pod_spec, + vm_enabled, + vm_spec, file_key, file_name, file_uploaded_at, diff --git a/scripts/generate_dummy_sql/main.py b/scripts/generate_dummy_sql/main.py index 3a1829e..6a281d7 100644 --- a/scripts/generate_dummy_sql/main.py +++ b/scripts/generate_dummy_sql/main.py @@ -96,17 +96,17 @@ def load_text_file(path: str) -> str: return f.read().rstrip("\n") -def apply_challenge_pod_spec_paths(challenges: List[dict], base_dir: str) -> None: +def apply_challenge_vm_spec_paths(challenges: List[dict], base_dir: str) -> None: for chal in challenges: - pod_spec_path = chal.get("stack_pod_spec_path") - if not pod_spec_path: + vm_spec_path = chal.get("vm_spec_path") + if not vm_spec_path: continue - resolved = resolve_path(pod_spec_path, base_dir) + resolved = resolve_path(vm_spec_path, base_dir) if not os.path.exists(resolved): raise SystemExit( - f"Error: challenge pod spec file not found: {pod_spec_path}" + f"Error: challenge sandbox spec file not found: {vm_spec_path}" ) - chal["stack_pod_spec"] = load_text_file(resolved) + chal["vm_spec"] = load_text_file(resolved) def normalize_divisions(raw_divisions: object, team_specs: List[dict]) -> List[str]: @@ -182,7 +182,7 @@ def main(argv: List[str]) -> int: settings = load_settings(DEFAULT_SETTINGS_PATH, template_paths, settings_path) data = load_data(data_path) - apply_challenge_pod_spec_paths( + apply_challenge_vm_spec_paths( data.get("challenges", []), os.path.dirname(data_path) ) @@ -199,21 +199,21 @@ def main(argv: List[str]) -> int: security = settings["security"] auth = settings["auth"] admin_team_name = "Admin" - stack_config = settings.get("stack", {}) + vm_config = settings.get("vm", {}) files_config = settings.get("files", {}) - stack_pod_spec = "" - pod_spec_path = stack_config.get("pod_spec_path") - if stack_config.get("enabled", False) and int( - stack_config.get("random_challenge_count", 0) - ) > 0 and not pod_spec_path: + vm_spec = "" + vm_spec_path = vm_config.get("vm_spec_path") + if vm_config.get("enabled", False) and int( + vm_config.get("random_challenge_count", 0) + ) > 0 and not vm_spec_path: raise SystemExit( - "Error: stack.pod_spec_path is required when stack is enabled" + "Error: vm.vm_spec_path is required when vm is enabled" ) - if pod_spec_path: - resolved_pod_spec_path = resolve_path(pod_spec_path, os.getcwd()) - if not os.path.exists(resolved_pod_spec_path): - raise SystemExit(f"Error: pod spec file not found: {pod_spec_path}") - stack_pod_spec = load_text_file(resolved_pod_spec_path) + if vm_spec_path: + resolved_vm_spec_path = resolve_path(vm_spec_path, os.getcwd()) + if not os.path.exists(resolved_vm_spec_path): + raise SystemExit(f"Error: sandbox spec file not found: {vm_spec_path}") + vm_spec = load_text_file(resolved_vm_spec_path) bcrypt_cost = int(os.getenv("BCRYPT_COST", str(security["bcrypt_cost"]))) output_file = os.getenv("OUTPUT_SQL_FILE", settings["output"]["file"]) @@ -273,8 +273,8 @@ def main(argv: List[str]) -> int: settings["timing"], constraints, bcrypt_cost, - stack_config, - stack_pod_spec, + vm_config, + vm_spec, files_config, ) registration_keys, registration_key_uses = generate_registration_keys( diff --git a/scripts/generate_dummy_sql/sql_writer.py b/scripts/generate_dummy_sql/sql_writer.py index 011aac4..4fa93ec 100644 --- a/scripts/generate_dummy_sql/sql_writer.py +++ b/scripts/generate_dummy_sql/sql_writer.py @@ -1,4 +1,3 @@ -import json from datetime import datetime from typing import Any, Dict, List, Optional, Tuple @@ -22,7 +21,6 @@ def write_sql_file( bool, str, bool, - List[Dict[str, Any]], str, Optional[str], Optional[str], @@ -40,9 +38,7 @@ def write_sql_file( f.write(f"-- BCRYPT_COST: {meta['bcrypt_cost']}\n") f.write(f"-- Default password for all users: {meta['default_password']}\n") if meta.get("include_admin", True): - f.write( - f"-- Admin credentials: {meta['admin_email']} / {meta['admin_password']}\n\n" - ) + f.write(f"-- Admin credentials: {meta['admin_email']} / {meta['admin_password']}\n\n") else: f.write("-- Admin credentials: bootstrapped (see server config)\n\n") @@ -52,35 +48,23 @@ def write_sql_file( f.write("-- Clear existing data\n") f.write("TRUNCATE TABLE submissions, registration_key_uses, registration_keys, challenges RESTART IDENTITY CASCADE;\n") if meta.get("bootstrap_mode", False): - f.write( - "-- TRUNCATE TABLE users, teams, divisions RESTART IDENTITY CASCADE;\n\n" - ) + f.write("-- TRUNCATE TABLE users, teams, divisions RESTART IDENTITY CASCADE;\n\n") else: f.write("TRUNCATE TABLE users, teams, divisions RESTART IDENTITY CASCADE;\n\n") f.write("-- Insert divisions\n") for name, created_at in divisions: name_esc = escape_sql_string(name) - prefix = "" - if meta.get("bootstrap_mode", False) and name == "Admin": - prefix = "-- " - f.write(f"{prefix}INSERT INTO divisions (name, created_at) VALUES ") - f.write( - f"('{name_esc}', '{created_at}') ON CONFLICT (name) DO NOTHING;\n" - ) + prefix = "-- " if meta.get("bootstrap_mode", False) and name == "Admin" else "" + f.write(f"{prefix}INSERT INTO divisions (name, created_at) VALUES ('{name_esc}', '{created_at}') ON CONFLICT (name) DO NOTHING;\n") f.write("\n") f.write("-- Insert teams\n") for idx, (name, division, created_at) in enumerate(teams, start=1): name_esc = escape_sql_string(name) division_esc = escape_sql_string(division) - prefix = "" - if meta.get("bootstrap_mode", False) and idx == 1: - prefix = "-- " - f.write(f"{prefix}INSERT INTO teams (name, division_id, created_at) VALUES ") - f.write( - f"('{name_esc}', (SELECT id FROM divisions WHERE name = '{division_esc}'), '{created_at}');\n" - ) + prefix = "-- " if meta.get("bootstrap_mode", False) and idx == 1 else "" + f.write(f"{prefix}INSERT INTO teams (name, division_id, created_at) VALUES ('{name_esc}', (SELECT id FROM divisions WHERE name = '{division_esc}'), '{created_at}');\n") f.write("\n") f.write("-- Insert users\n") @@ -89,49 +73,29 @@ def write_sql_file( username_esc = escape_sql_string(username) password_hash_esc = escape_sql_string(password_hash) role_esc = escape_sql_string(role) - - prefix = "" - if meta.get("bootstrap_mode", False) and idx == 1: - prefix = "-- " + prefix = "-- " if meta.get("bootstrap_mode", False) and idx == 1 else "" f.write( f"{prefix}INSERT INTO users (email, username, password_hash, role, team_id, created_at, updated_at) VALUES " - ) - f.write( f"('{email_esc}', '{username_esc}', '{password_hash_esc}', '{role_esc}', {team_id}, '{created_at}', '{created_at}');\n" ) - f.write("\n") f.write("-- Insert registration keys\n") - for ( - key_id, - code, - created_by, - team_id, - max_uses, - used_count, - created_at, - ) in registration_keys: + for key_id, code, created_by, team_id, max_uses, used_count, created_at in registration_keys: code_esc = escape_sql_string(code) - f.write( "INSERT INTO registration_keys (id, code, created_by, team_id, max_uses, used_count, created_at) VALUES " - ) - f.write( f"({key_id}, '{code_esc}', {created_by}, {team_id}, {max_uses}, {used_count}, '{created_at}');\n" ) - f.write("\n") + f.write("-- Insert registration key uses\n") for key_id, used_by, used_by_ip, used_at in registration_key_uses: used_by_ip_value = f"'{escape_sql_string(used_by_ip)}'" f.write( "INSERT INTO registration_key_uses (registration_key_id, used_by, used_by_ip, used_at) VALUES " - ) - f.write( f"({key_id}, {used_by}, {used_by_ip_value}, '{used_at}');\n" ) - f.write("\n") f.write("-- Insert challenges\n") @@ -145,9 +109,8 @@ def write_sql_file( previous_challenge_id, is_active, created_at, - stack_enabled, - stack_target_ports, - stack_pod_spec, + vm_enabled, + vm_spec, file_key, file_name, file_uploaded_at, @@ -156,42 +119,22 @@ def write_sql_file( description_esc = escape_sql_string(description) category_esc = escape_sql_string(category) flag_hash_esc = escape_sql_string(flag_hash) - stack_pod_spec_esc = escape_sql_string(stack_pod_spec) - stack_pod_spec_value = "NULL" if stack_pod_spec_esc == "" else f"'{stack_pod_spec_esc}'" - file_key_value = "NULL" - file_name_value = "NULL" - file_uploaded_at_value = "NULL" - if file_key: - file_key_value = f"'{escape_sql_string(str(file_key))}'" - if file_name: - file_name_value = f"'{escape_sql_string(str(file_name))}'" - if file_uploaded_at: - file_uploaded_at_value = f"'{escape_sql_string(str(file_uploaded_at))}'" + vm_spec_value = "NULL" if not vm_spec else f"'{escape_sql_string(vm_spec)}'" + previous_value = "NULL" if not previous_challenge_id else str(int(previous_challenge_id)) + file_key_value = "NULL" if not file_key else f"'{escape_sql_string(str(file_key))}'" + file_name_value = "NULL" if not file_name else f"'{escape_sql_string(str(file_name))}'" + file_uploaded_at_value = "NULL" if not file_uploaded_at else f"'{escape_sql_string(str(file_uploaded_at))}'" - previous_value = "NULL" - if previous_challenge_id: - previous_value = str(int(previous_challenge_id)) - - stack_target_ports_value = "NULL" - if stack_target_ports: - ports_json = json.dumps(stack_target_ports, ensure_ascii=False) - stack_target_ports_value = f"'{escape_sql_string(ports_json)}'" - - f.write( - "INSERT INTO challenges (title, description, category, points, minimum_points, flag_hash, previous_challenge_id, is_active, created_at, stack_enabled, stack_target_ports, stack_pod_spec, file_key, file_name, file_uploaded_at) VALUES " - ) f.write( - f"('{title_esc}', '{description_esc}', '{category_esc}', {points}, {minimum_points}, '{flag_hash_esc}', {previous_value}, {is_active}, '{created_at}', {stack_enabled}, {stack_target_ports_value}, {stack_pod_spec_value}, {file_key_value}, {file_name_value}, {file_uploaded_at_value});\n" + "INSERT INTO challenges (title, description, category, points, minimum_points, flag_hash, previous_challenge_id, is_active, created_at, vm_enabled, vm_spec, file_key, file_name, file_uploaded_at) VALUES " + f"('{title_esc}', '{description_esc}', '{category_esc}', {points}, {minimum_points}, '{flag_hash_esc}', {previous_value}, {is_active}, '{created_at}', {vm_enabled}, {vm_spec_value}, {file_key_value}, {file_name_value}, {file_uploaded_at_value});\n" ) - f.write("\n") f.write("-- Insert submissions\n") for user_id, challenge_id, correct, submitted_at, is_first_blood in submissions: f.write( "INSERT INTO submissions (user_id, challenge_id, correct, is_first_blood, submitted_at) VALUES " - ) - f.write( f"({user_id}, {challenge_id}, {correct}, {is_first_blood}, '{submitted_at}');\n" ) @@ -200,15 +143,7 @@ def write_sql_file( f.write("SELECT setval('divisions_id_seq', (SELECT MAX(id) FROM divisions));\n") f.write("SELECT setval('teams_id_seq', (SELECT MAX(id) FROM teams));\n") f.write("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));\n") - f.write( - "SELECT setval('challenges_id_seq', (SELECT MAX(id) FROM challenges));\n" - ) - f.write( - "SELECT setval('registration_keys_id_seq', (SELECT MAX(id) FROM registration_keys));\n" - ) - f.write( - "SELECT setval('registration_key_uses_id_seq', (SELECT MAX(id) FROM registration_key_uses));\n" - ) - f.write( - "SELECT setval('submissions_id_seq', (SELECT MAX(id) FROM submissions));\n" - ) + f.write("SELECT setval('challenges_id_seq', (SELECT MAX(id) FROM challenges));\n") + f.write("SELECT setval('registration_keys_id_seq', (SELECT MAX(id) FROM registration_keys));\n") + f.write("SELECT setval('registration_key_uses_id_seq', (SELECT MAX(id) FROM registration_key_uses));\n") + f.write("SELECT setval('submissions_id_seq', (SELECT MAX(id) FROM submissions));\n") diff --git a/scripts/generate_yaml_sql/defaults/data.yaml b/scripts/generate_yaml_sql/defaults/data.yaml index a81427b..13ac794 100644 --- a/scripts/generate_yaml_sql/defaults/data.yaml +++ b/scripts/generate_yaml_sql/defaults/data.yaml @@ -32,11 +32,11 @@ challenges: points: 100 flag: "DH{Bookwish}" category: "Web" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 8000 protocol: TCP - stack_pod_spec_path: "./web-bookwish.yaml" + vm_spec_path: "./web-bookwish.yaml" file_name: "bookwish-user.zip" file_key: "9f6c1c2a-5b2f-4d0f-9e4f-0f2f7eaa12ab.zip" file_uploaded_at: "2026-02-13 12:00:00" @@ -52,11 +52,11 @@ challenges: points: 100 flag: "DH{Simple_Note_Manager}" category: "Web" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 5000 protocol: TCP - stack_pod_spec_path: "./web-simple-note-manager.yaml" + vm_spec_path: "./web-simple-note-manager.yaml" file_name: "simple-note-manager.zip" file_key: "7e8c9d1b-3a4f-4e5d-9f6a-1b2c3d4e5f6g.zip" file_uploaded_at: "2026-02-14 15:30:00" @@ -70,11 +70,11 @@ challenges: points: 100 flag: "DH{XSS_Filtering_Bypass}" category: "Web" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 8000 protocol: TCP - stack_pod_spec_path: "./web-xss-filtering-bypass.yaml" + vm_spec_path: "./web-xss-filtering-bypass.yaml" file_name: "xss-filtering-bypass.zip" file_key: "0a1b2c3d-4e5f-6g7h-8i9j-0k1l2m3n4o5p.zip" file_uploaded_at: "2026-02-15 09:00:00" @@ -88,11 +88,11 @@ challenges: points: 100 flag: "DH{XSS_Filtering_Bypass_Advanced}" category: "Web" - stack_enabled: true - stack_target_ports: + vm_enabled: true + vm_spec: - container_port: 8000 protocol: TCP - stack_pod_spec_path: "./web-xss-filtering-bypass-advanced.yaml" + vm_spec_path: "./web-xss-filtering-bypass-advanced.yaml" file_name: "xss-filtering-bypass-advanced.zip" file_key: "1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p.zip" file_uploaded_at: "2026-02-15 10:00:00" diff --git a/scripts/generate_yaml_sql/defaults/stack_pod_spec.yaml b/scripts/generate_yaml_sql/defaults/vm_spec.yaml similarity index 80% rename from scripts/generate_yaml_sql/defaults/stack_pod_spec.yaml rename to scripts/generate_yaml_sql/defaults/vm_spec.yaml index 0d2dab0..2ae15c6 100644 --- a/scripts/generate_yaml_sql/defaults/stack_pod_spec.yaml +++ b/scripts/generate_yaml_sql/defaults/vm_spec.yaml @@ -1,7 +1,7 @@ apiVersion: v1 -kind: Pod +kind: Sandbox metadata: - name: challenge-pod + name: challenge-sandbox spec: containers: - name: challenge diff --git a/scripts/generate_yaml_sql/defaults/web-bookwish.yaml b/scripts/generate_yaml_sql/defaults/web-bookwish.yaml index 84b8d31..b570d7c 100644 --- a/scripts/generate_yaml_sql/defaults/web-bookwish.yaml +++ b/scripts/generate_yaml_sql/defaults/web-bookwish.yaml @@ -1,7 +1,7 @@ apiVersion: v1 -kind: Pod +kind: Sandbox metadata: - name: bookwish-pod + name: bookwish-sandbox spec: containers: - name: bookwish-container diff --git a/scripts/generate_yaml_sql/defaults/web-simple-note-manager.yaml b/scripts/generate_yaml_sql/defaults/web-simple-note-manager.yaml index ce33441..2bb3fe5 100644 --- a/scripts/generate_yaml_sql/defaults/web-simple-note-manager.yaml +++ b/scripts/generate_yaml_sql/defaults/web-simple-note-manager.yaml @@ -1,7 +1,7 @@ apiVersion: v1 -kind: Pod +kind: Sandbox metadata: - name: simple-note-manager-pod + name: simple-note-manager-sandbox spec: containers: - name: simple-note-manager-container diff --git a/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass-advanced.yaml b/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass-advanced.yaml index b27373c..0bc1a03 100644 --- a/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass-advanced.yaml +++ b/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass-advanced.yaml @@ -1,7 +1,7 @@ apiVersion: v1 -kind: Pod +kind: Sandbox metadata: - name: xss-filtering-bypass-pod + name: xss-filtering-bypass-sandbox spec: containers: - name: xss-filtering-bypass-container diff --git a/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass.yaml b/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass.yaml index 6217f54..1f5056c 100644 --- a/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass.yaml +++ b/scripts/generate_yaml_sql/defaults/web-xss-filtering-bypass.yaml @@ -1,7 +1,7 @@ apiVersion: v1 -kind: Pod +kind: Sandbox metadata: - name: xss-filtering-bypass-pod + name: xss-filtering-bypass-sandbox spec: containers: - name: xss-filtering-bypass-container diff --git a/scripts/generate_yaml_sql/generator.py b/scripts/generate_yaml_sql/generator.py index d1241b2..3f712e7 100644 --- a/scripts/generate_yaml_sql/generator.py +++ b/scripts/generate_yaml_sql/generator.py @@ -8,7 +8,7 @@ from sql_common.crypto_utils import hash_flag, hash_password UTC = timezone.utc -DEFAULT_STACK_TARGET_PORTS = [{"container_port": 80, "protocol": "TCP"}] +DEFAULT_VM_TARGET_PORTS = [{"container_port": 80, "protocol": "TCP"}] def _render_username(pattern: str, team_name: str, number: int) -> str: @@ -132,17 +132,12 @@ def generate_challenges( minimum_points = max(floor, int(points * ratio)) flag_hash = hash_flag(chal["flag"], bcrypt_cost) - stack_enabled = bool(chal.get("stack_enabled", False)) - stack_target_ports = [] - stack_pod_spec = str(chal.get("stack_pod_spec", "")) - if stack_enabled: - stack_target_ports = list(chal.get("stack_target_ports", [])) - if not stack_target_ports: - stack_target_ports = list(DEFAULT_STACK_TARGET_PORTS) - if not stack_pod_spec: - raise SystemExit( - f"Error: stack_enabled challenge {chal.get('title')} requires stack_pod_spec" - ) + vm_enabled = bool(chal.get("vm_enabled", False)) + vm_spec = str(chal.get("vm_spec", "")) if vm_enabled else "" + if vm_enabled and not vm_spec: + raise SystemExit( + f"Error: vm_enabled challenge {chal.get('title')} requires vm_spec" + ) file_name = chal.get("file_name") file_key = chal.get("file_key") @@ -164,9 +159,8 @@ def generate_challenges( "previous_challenge_id": chal.get("previous_challenge_id"), "is_active": bool(chal.get("is_active", True)), "created_at": created_at_str, - "stack_enabled": stack_enabled, - "stack_target_ports": stack_target_ports, - "stack_pod_spec": stack_pod_spec, + "vm_enabled": vm_enabled, + "vm_spec": vm_spec, "file_key": file_key, "file_name": file_name, "file_uploaded_at": file_uploaded_at, @@ -176,17 +170,17 @@ def generate_challenges( return generated -def apply_challenge_pod_spec_paths(challenges: List[Dict[str, Any]], base_dir: str) -> None: +def apply_challenge_vm_spec_paths(challenges: List[Dict[str, Any]], base_dir: str) -> None: from sql_common.yaml_utils import resolve_path for chal in challenges: - pod_spec_path = chal.get("stack_pod_spec_path") - if not pod_spec_path: + vm_spec_path = chal.get("vm_spec_path") + if not vm_spec_path: continue - resolved = resolve_path(pod_spec_path, base_dir) + resolved = resolve_path(vm_spec_path, base_dir) if not os.path.exists(resolved): raise SystemExit( - f"Error: challenge pod spec file not found: {pod_spec_path}" + f"Error: challenge sandbox spec file not found: {vm_spec_path}" ) with open(resolved, "r", encoding="utf-8") as f: - chal["stack_pod_spec"] = f.read().rstrip("\n") + chal["vm_spec"] = f.read().rstrip("\n") diff --git a/scripts/generate_yaml_sql/main.py b/scripts/generate_yaml_sql/main.py index ba6fbf5..3cc2f82 100644 --- a/scripts/generate_yaml_sql/main.py +++ b/scripts/generate_yaml_sql/main.py @@ -12,7 +12,7 @@ from data_loader import load_data, validate_data from generator import ( - apply_challenge_pod_spec_paths, + apply_challenge_vm_spec_paths, generate_challenges, generate_divisions, generate_teams, @@ -74,7 +74,7 @@ def main(argv: List[str]) -> int: raise SystemExit("Error: divisions must be provided in data YAML") validate_data(data) - apply_challenge_pod_spec_paths( + apply_challenge_vm_spec_paths( data.get("challenges", []), os.path.dirname(data_path) ) diff --git a/scripts/generate_yaml_sql/sql_writer.py b/scripts/generate_yaml_sql/sql_writer.py index 77a4109..4e7bc90 100644 --- a/scripts/generate_yaml_sql/sql_writer.py +++ b/scripts/generate_yaml_sql/sql_writer.py @@ -1,4 +1,3 @@ -import json from datetime import datetime from typing import Any, Dict, List @@ -31,9 +30,7 @@ def write_sql_file( f.write("-- Guard: refuse to run if tables are not empty\n") f.write("DO $$\n") f.write("BEGIN\n") - f.write( - " IF EXISTS (SELECT 1 FROM divisions) OR EXISTS (SELECT 1 FROM teams) OR EXISTS (SELECT 1 FROM challenges) OR EXISTS (SELECT 1 FROM users) THEN\n" - ) + f.write(" IF EXISTS (SELECT 1 FROM divisions) OR EXISTS (SELECT 1 FROM teams) OR EXISTS (SELECT 1 FROM challenges) OR EXISTS (SELECT 1 FROM users) THEN\n") f.write(" RAISE EXCEPTION 'Refusing to run: tables not empty';\n") f.write(" END IF;\n") f.write("END $$;\n\n") @@ -41,28 +38,20 @@ def write_sql_file( f.write("-- Insert divisions\n") for division in divisions: f.write("INSERT INTO divisions (id, name, created_at) VALUES ") - f.write( - f"({_sql_value(division['id'])}, {_sql_value(division['name'])}, {_sql_value(division['created_at'])});\n" - ) + f.write(f"({_sql_value(division['id'])}, {_sql_value(division['name'])}, {_sql_value(division['created_at'])});\n") f.write("\n") f.write("-- Insert teams\n") for team in teams: f.write("INSERT INTO teams (id, name, division_id, created_at) VALUES ") - f.write( - f"({_sql_value(team['id'])}, {_sql_value(team['name'])}, {_sql_value(team['division_id'])}, {_sql_value(team['created_at'])});\n" - ) + f.write(f"({_sql_value(team['id'])}, {_sql_value(team['name'])}, {_sql_value(team['division_id'])}, {_sql_value(team['created_at'])});\n") f.write("\n") if users: f.write("-- Insert users (with plaintext password comments)\n") for user in users: - f.write( - f"-- User: {escape_sql_string(user['username'])} | Email: {escape_sql_string(user['email'])} | Password: {user['plaintext_password']}\n" - ) - f.write( - "INSERT INTO users (id, email, username, password_hash, role, team_id, created_at, updated_at) VALUES " - ) + f.write(f"-- User: {escape_sql_string(user['username'])} | Email: {escape_sql_string(user['email'])} | Password: {user['plaintext_password']}\n") + f.write("INSERT INTO users (id, email, username, password_hash, role, team_id, created_at, updated_at) VALUES ") f.write( "({id}, {email}, {username}, {password_hash}, {role}, {team_id}, {created_at}, {updated_at});\n".format( id=_sql_value(user["id"]), @@ -79,16 +68,12 @@ def write_sql_file( f.write("-- Insert challenges\n") for chal in challenges: - stack_target_ports_value = "NULL" - if chal["stack_target_ports"]: - ports_json = json.dumps(chal["stack_target_ports"], ensure_ascii=False) - stack_target_ports_value = _sql_value(ports_json) - + vm_spec_value = _sql_value(chal.get("vm_spec") or None) f.write( - "INSERT INTO challenges (id, title, description, category, points, minimum_points, flag_hash, previous_challenge_id, is_active, created_at, stack_enabled, stack_target_ports, stack_pod_spec, file_key, file_name, file_uploaded_at) VALUES " + "INSERT INTO challenges (id, title, description, category, points, minimum_points, flag_hash, previous_challenge_id, is_active, created_at, vm_enabled, vm_spec, file_key, file_name, file_uploaded_at) VALUES " ) f.write( - "({id}, {title}, {description}, {category}, {points}, {minimum_points}, {flag_hash}, {previous_challenge_id}, {is_active}, {created_at}, {stack_enabled}, {stack_target_ports}, {stack_pod_spec}, {file_key}, {file_name}, {file_uploaded_at});\n".format( + "({id}, {title}, {description}, {category}, {points}, {minimum_points}, {flag_hash}, {previous_challenge_id}, {is_active}, {created_at}, {vm_enabled}, {vm_spec}, {file_key}, {file_name}, {file_uploaded_at});\n".format( id=_sql_value(chal["id"]), title=_sql_value(chal["title"]), description=_sql_value(chal["description"]), @@ -99,9 +84,8 @@ def write_sql_file( previous_challenge_id=_sql_value(chal["previous_challenge_id"]), is_active=_sql_value(chal["is_active"]), created_at=_sql_value(chal["created_at"]), - stack_enabled=_sql_value(chal["stack_enabled"]), - stack_target_ports=stack_target_ports_value, - stack_pod_spec=_sql_value(chal["stack_pod_spec"] or None), + vm_enabled=_sql_value(chal["vm_enabled"]), + vm_spec=vm_spec_value, file_key=_sql_value(chal["file_key"]), file_name=_sql_value(chal["file_name"]), file_uploaded_at=_sql_value(chal["file_uploaded_at"]), @@ -114,6 +98,4 @@ def write_sql_file( f.write("SELECT setval('teams_id_seq', (SELECT MAX(id) FROM teams));\n") if users: f.write("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));\n") - f.write( - "SELECT setval('challenges_id_seq', (SELECT MAX(id) FROM challenges));\n" - ) + f.write("SELECT setval('challenges_id_seq', (SELECT MAX(id) FROM challenges));\n")