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")