diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e9147d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.gradle +.idea +.opencode +.swarm +build +*.md +docker-compose*.yml +.env* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..68769fe --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Database +DATABASE_JDBC_URL=jdbc:postgresql://localhost:5432/barotrauma-admin +DATABASE_USER=postgres +DATABASE_PASSWORD=postgres +DATABASE_POOL_SIZE=10 + +# Container Registry +REGISTRY_URL=your-registry.example.com +REGISTRY_USER=robot-user +REGISTRY_PASSWORD=your-token + +# Deployment +DEPLOY_HOST=your-server.example.com +DEPLOY_PORT=22 +DEPLOY_USER=deploy +DEPLOY_SSH_KEY=path/to/ssh-private-key +DEPLOY_PATH=/opt/barotrauma-admin + +# Service +HOST_PORT=8080 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..3ae1324 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,74 @@ +name: Build & Deploy +run-name: ${{ gitea.event_name }} by ${{ gitea.actor }} + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master] + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Grant Gradle executable + run: chmod +x gradlew + + - name: Build + run: ./gradlew --no-daemon build + + - name: Test + run: ./gradlew --no-daemon test + + - name: Build distribution + run: ./gradlew --no-daemon installDist + + - name: Login to Container Registry + run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_URL }} -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build Docker image + run: | + docker build -t ${{ secrets.REGISTRY_URL }}/${{ gitea.repository }}:${{ gitea.sha }} -t ${{ secrets.REGISTRY_URL }}/${{ gitea.repository }}:latest . + + - name: Push Docker image + run: | + docker push ${{ secrets.REGISTRY_URL }}/${{ gitea.repository }}:${{ gitea.sha }} + docker push ${{ secrets.REGISTRY_URL }}/${{ gitea.repository }}:latest + + deploy: + runs-on: ubuntu-24.04 + needs: build + if: gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master' + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + port: ${{ secrets.DEPLOY_PORT || '22' }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -e + cd ${{ secrets.DEPLOY_PATH }} + cat > .env << EOF + REGISTRY_URL=${{ secrets.REGISTRY_URL }} + IMAGE_NAME=${{ gitea.repository }} + IMAGE_TAG=${{ gitea.sha }} + DATABASE_JDBC_URL=${{ secrets.DATABASE_JDBC_URL }} + DATABASE_USER=${{ secrets.DATABASE_USER }} + DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} + DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE:-10} + HOST_PORT=${HOST_PORT:-8080} + EOF + docker compose -f docker-compose.remote.yml pull + docker compose -f docker-compose.remote.yml up -d --remove-orphans + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24b3579 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea/ +/.opencode/ +/.swarm/ +/.kotlin/ diff --git a/.opencode/skills/codebase-review-swarm/README.md b/.opencode/skills/codebase-review-swarm/README.md new file mode 100644 index 0000000..31c314f --- /dev/null +++ b/.opencode/skills/codebase-review-swarm/README.md @@ -0,0 +1,44 @@ +# Codebase Review Swarm Skill v8.2 + +Portable Agent Skill for OpenCode, Codex, and Claude Code. It converts the v7 codebase-review swarm prompt into a progressive-disclosure skill package with a short routing-focused `SKILL.md`, detailed protocol references, parseable schemas, report template, optional Codex metadata, and deterministic helper scripts. + +## Contents + +```text +codebase-review-swarm/ + SKILL.md + INSTALL.md + README.md + agents/ + openai.yaml + assets/ + jsonl-schemas.md + review-report-template.md + references/ + compatibility-and-research-notes.md + full-v7-source-prompt.md + review-protocol-v8.2.md + scripts/ + init-review-run.py + validate-skill-package.py +``` + +## Design summary + +- Canonical opencode-swarm repo path: `.opencode/skills/codebase-review-swarm/`. +- Claude path: `.claude/skills/codebase-review-swarm/` as a thin adapter to the canonical OpenCode skill. +- Codex path: `.agents/skills/codebase-review-swarm/` as a thin adapter with `agents/openai.yaml`. +- Portable user install paths may still use `.agents/skills/`, `.opencode/skills/`, or `.claude/skills/` depending on host. +- Frontmatter is intentionally portable: required `name` and `description`, plus harmless metadata. +- Long instructions are split into references/assets to preserve routing quality and context budget. +- Focused track selections expand depth inside the selected domain; multi-track/all-track selections add waves rather than sacrificing per-track quality. +- The full v7 prompt is preserved verbatim for detailed track checklists. +- Standards are current as of 2026-06-08: ASVS 5.0.0, OWASP LLM Top 10 2025, SLSA v1.2, WCAG 2.2 AA, OpenTelemetry. + +## Primary command + +```text +$codebase-review-swarm +``` + +Begin at repository root. The skill runs Phase 0 inventory, stops for review mode selection unless preselected, then performs selected exhaustive tracks with coverage closure, review-depth planning, non-diluting multi-track execution, and critic validation. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..978c5e3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /app + +COPY gradlew settings.gradle.kts build.gradle.kts ./ +COPY gradle gradle/ +COPY gradle.properties ./ +RUN ./gradlew --no-daemon dependencies 2>/dev/null || true + +COPY src src/ +RUN ./gradlew --no-daemon installDist + +FROM eclipse-temurin:21-jre +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/build/install/admin /app + +EXPOSE 8080 + +ENTRYPOINT ["/app/bin/admin"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..de9c847 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Barotrauma Admin + +Ktor + Exposed + PostgreSQL backend for managing game-server tasks and executing remote commands. + +--- + +## Project Structure + +``` +src/main/kotlin/com/nano/ +├── main.kt # Application entry point + DI wiring +├── Routing.kt # HTTP routes (tasks, exec) +├── model/ +│ ├── Task.kt # Task domain model + TaskStatus enum +│ └── ExecutorResult.kt +├── db/ +│ ├── DatabaseConfig.kt # HikariCP + Exposed Database setup +│ └── Tables.kt # Exposed Table definitions (TaskTable) +├── repository/ +│ └── TaskRepository.kt # CRUD operations on TaskTable, returns model objects +├── service/ +│ └── TaskService.kt # Business logic: create, list, cancel tasks +└── executor/ + └── Executor.kt # Bash command runner with log streaming +``` + +### Layer flow +``` +Model → DB (connection + table) → Repository → Service → Route +``` + +--- + +## Local Development + +```bash +# Start PostgreSQL + app +docker compose up --build + +# Or run app without Docker (needs local Postgres) +./gradlew run +``` + +App is served at `http://localhost:8080`. + +### Environment variables + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_JDBC_URL` | `jdbc:postgresql://localhost:5432/barotrauma-admin` | PostgreSQL JDBC URL | +| `DATABASE_USER` | `postgres` | DB user | +| `DATABASE_PASSWORD` | `postgres` | DB password | +| `DATABASE_POOL_SIZE` | `10` | HikariCP pool size | + +--- + +## API Endpoints + +| Method | Path | Description | +|---|---|---| +| GET | `/` | Health check | +| GET | `/tasks` | List all tasks (`?status=RUNNING` to filter) | +| GET | `/tasks/{id}` | Get task by UUID | +| POST | `/tasks` | Create task `{"name":"...", "payload":"..."}` | +| POST | `/tasks/{id}/cancel` | Cancel task | +| DELETE | `/tasks/{id}` | Delete task | +| POST | `/exec` | Run bash command `{"command":"ls -la"}` | + +--- + +## CI/CD (Gitea Actions) + +The pipeline in `.gitea/workflows/ci.yaml` does: + +1. **Build** — compiles the project, runs tests +2. **Docker** — builds image, pushes to registry +3. **Deploy** — SSHes to target host, pulls image, restarts via `docker-compose.remote.yml` + +### Required secrets in Gitea + +| Secret | Description | +|---|---| +| `REGISTRY_URL` | Container registry host (e.g. `gitea.example.com`) | +| `REGISTRY_USER` | Registry username | +| `REGISTRY_PASSWORD` | Registry token / password | +| `DEPLOY_HOST` | Remote server hostname | +| `DEPLOY_PORT` | SSH port (default 22) | +| `DEPLOY_USER` | Remote SSH user | +| `DEPLOY_SSH_KEY` | Private SSH key for deployment | +| `DEPLOY_PATH` | Target directory on remote host | +| `DATABASE_JDBC_URL` | Production JDBC URL | +| `DATABASE_USER` | Production DB user | +| `DATABASE_PASSWORD` | Production DB password | + +### Required variables in Gitea + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_POOL_SIZE` | `10` | Connection pool size | +| `HOST_PORT` | `8080` | Host port mapping | + +### Runner tag + +Pipeline uses `runs-on: ubuntu-24.04`. Register your Gitea runner with: + +```bash +gitea actions runner register --labels "ubuntu-24.04:docker://node:20-bookworm" +``` + +--- + +## Remote deployment + +On the target host, place `docker-compose.remote.yml` and a `.env` file with the variables listed above. + +```bash +docker compose -f docker-compose.remote.yml pull +docker compose -f docker-compose.remote.yml up -d +``` + +--- + +## Building locally + +```bash +./gradlew build +./gradlew test +./gradlew installDist +``` diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3800cf7 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(ktorLibs.plugins.ktor) +} + +group = "com.nano" +version = "1.0.0-SNAPSHOT" + +application { + mainClass = "io.ktor.server.netty.EngineMain" +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(ktorLibs.server.config.yaml) + implementation(ktorLibs.server.core) + implementation(ktorLibs.server.netty) + + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.server.call.logging) + implementation(libs.ktor.server.call.id) + + implementation(libs.logback.classic) + + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.jdbc) + implementation(libs.hikari) + implementation(libs.postgresql) + + testImplementation(kotlin("test")) + testImplementation(ktorLibs.server.testHost) + testImplementation("io.ktor:ktor-server-content-negotiation:3.5.0") + testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.5.0") + testImplementation("com.h2database:h2:2.3.232") + testImplementation("org.jetbrains.exposed:exposed-core:0.57.0") + testImplementation("org.jetbrains.exposed:exposed-jdbc:0.57.0") +} diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml new file mode 100644 index 0000000..39acfa6 --- /dev/null +++ b/docker-compose.remote.yml @@ -0,0 +1,16 @@ +services: + admin: + image: ${REGISTRY_URL}/${IMAGE_NAME}:${IMAGE_TAG:-latest} + restart: unless-stopped + ports: + - "${HOST_PORT:-8080}:8080" + environment: + DATABASE_JDBC_URL: ${DATABASE_JDBC_URL} + DATABASE_USER: ${DATABASE_USER} + DATABASE_PASSWORD: ${DATABASE_PASSWORD} + DATABASE_POOL_SIZE: "${DATABASE_POOL_SIZE:-10}" + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f5a8726 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: barotrauma-admin + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d barotrauma-admin"] + interval: 5s + timeout: 5s + retries: 5 + + admin: + build: . + restart: unless-stopped + ports: + - "8080:8080" + environment: + DATABASE_JDBC_URL: jdbc:postgresql://db:5432/barotrauma-admin + DATABASE_USER: postgres + DATABASE_PASSWORD: postgres + DATABASE_POOL_SIZE: "10" + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..a3293d9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df6a6ad --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7d4d2f1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } + versionCatalogs { + create("ktorLibs").from("io.ktor:ktor-version-catalog:3.5.0") + } +} + +rootProject.name = "admin" + diff --git a/src/main/kotlin/com/nano/Routing.kt b/src/main/kotlin/com/nano/Routing.kt new file mode 100644 index 0000000..39cb38b --- /dev/null +++ b/src/main/kotlin/com/nano/Routing.kt @@ -0,0 +1,98 @@ +package com.nano + +import com.nano.executor.Executor +import com.nano.model.TaskStatus +import com.nano.service.TaskService +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import java.util.UUID + +private object UUIDSerializer : kotlinx.serialization.KSerializer { + override val descriptor = kotlinx.serialization.descriptors.PrimitiveSerialDescriptor("UUID", kotlinx.serialization.descriptors.PrimitiveKind.STRING) + override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: UUID) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): UUID = UUID.fromString(decoder.decodeString()) +} + +@Serializable +data class CreateTaskRequest(val name: String, val payload: String = "") + +@Serializable +data class ExecCommandRequest(val command: String) + +fun Application.configureRouting(taskService: TaskService, executor: Executor) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + serializersModule = SerializersModule { + contextual(UUIDSerializer) + } + }) + } + routing { + get("/") { + call.respondText("Barotrauma Admin API") + } + + route("/tasks") { + get { + val statusParam = call.request.queryParameters["status"] + val status = statusParam?.let { TaskStatus.valueOf(it.uppercase()) } + call.respond(taskService.listTasks(status)) + } + + get("/{id}") { + val id = UUID.fromString(call.parameters["id"]) + val task = taskService.getTask(id) + if (task != null) call.respond(task) + else call.respondText("Not Found", status = HttpStatusCode.NotFound) + } + + post { + val body = call.receive() + val task = taskService.createTask(body.name, body.payload) + call.respond(HttpStatusCode.Created, task) + } + + post("/{id}/cancel") { + val id = UUID.fromString(call.parameters["id"]) + val task = taskService.updateStatus(id, TaskStatus.CANCELLED) + if (task != null) call.respond(task) + else call.respondText("Not Found", status = HttpStatusCode.NotFound) + } + + delete("/{id}") { + val id = UUID.fromString(call.parameters["id"]) + val deleted = taskService.deleteTask(id) + if (deleted) call.respondText("Deleted") + else call.respondText("Not Found", status = HttpStatusCode.NotFound) + } + } + + route("/exec") { + post { + val body = call.receive() + val logs = mutableListOf() + val result = executor.execute(body.command, onLog = { logs.add(it) }) + call.respond( + mapOf( + "exitCode" to result.exitCode, + "success" to result.isSuccess, + "stdout" to result.stdout, + "stderr" to result.stderr, + "logs" to logs + ) + ) + } + } + } +} diff --git a/src/main/kotlin/com/nano/db/DatabaseConfig.kt b/src/main/kotlin/com/nano/db/DatabaseConfig.kt new file mode 100644 index 0000000..d306252 --- /dev/null +++ b/src/main/kotlin/com/nano/db/DatabaseConfig.kt @@ -0,0 +1,38 @@ +package com.nano.db + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import org.jetbrains.exposed.sql.Database +import javax.sql.DataSource + +object DatabaseConfig { + + private var dataSource: DataSource? = null + + fun connect( + jdbcUrl: String = System.getenv("DATABASE_JDBC_URL") + ?: "jdbc:postgresql://localhost:5432/barotrauma-admin", + driver: String = "org.postgresql.Driver", + user: String = System.getenv("DATABASE_USER") ?: "postgres", + password: String = System.getenv("DATABASE_PASSWORD") ?: "postgres", + poolSize: Int = (System.getenv("DATABASE_POOL_SIZE") ?: "10").toInt() + ): Database { + val config = HikariConfig().apply { + this.jdbcUrl = jdbcUrl + this.driverClassName = driver + this.username = user + this.password = password + maximumPoolSize = poolSize + isAutoCommit = false + transactionIsolation = "TRANSACTION_REPEATABLE_READ" + validate() + } + val ds = HikariDataSource(config) + dataSource = ds + return Database.connect(ds) + } + + fun shutdown() { + (dataSource as? HikariDataSource)?.close() + } +} diff --git a/src/main/kotlin/com/nano/db/Tables.kt b/src/main/kotlin/com/nano/db/Tables.kt new file mode 100644 index 0000000..29b525f --- /dev/null +++ b/src/main/kotlin/com/nano/db/Tables.kt @@ -0,0 +1,16 @@ +package com.nano.db + +import org.jetbrains.exposed.sql.Table +import java.util.UUID + +object TaskTable : Table("tasks") { + val id = uuid("id").autoGenerate() + val name = varchar("name", 255) + val payload = text("payload").default("") + val status = enumerationByName("status", 32, com.nano.model.TaskStatus::class) + .default(com.nano.model.TaskStatus.PENDING) + val createdAt = long("created_at") + val updatedAt = long("updated_at") + + override val primaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/com/nano/executor/Executor.kt b/src/main/kotlin/com/nano/executor/Executor.kt new file mode 100644 index 0000000..f5b4e7d --- /dev/null +++ b/src/main/kotlin/com/nano/executor/Executor.kt @@ -0,0 +1,61 @@ +package com.nano.executor + +import com.nano.model.ExecutorResult +import java.io.BufferedReader +import java.io.InputStreamReader + +class Executor { + fun execute( + command: String, + onLog: ((String) -> Unit)? = null, + workDir: String? = null, + env: Map = emptyMap(), + ): ExecutorResult { + val builder = + ProcessBuilder() + .command("bash", "-c", command) + + if (workDir != null) { + builder.directory(java.io.File(workDir)) + } + builder.environment().putAll(env) + builder.redirectErrorStream(false) + + val process = builder.start() + val stdoutBuilder = StringBuilder() + val stderrBuilder = StringBuilder() + + val stdoutThread = + Thread { + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + reader.lines().forEach { line -> + stdoutBuilder.appendLine(line) + onLog?.invoke(line) + } + } + } + + val stderrThread = + Thread { + BufferedReader(InputStreamReader(process.errorStream)).use { reader -> + reader.lines().forEach { line -> + stderrBuilder.appendLine(line) + onLog?.invoke("[ERR] $line") + } + } + } + + stdoutThread.start() + stderrThread.start() + + val exitCode = process.waitFor() + stdoutThread.join() + stderrThread.join() + + return ExecutorResult( + exitCode = exitCode, + stdout = stdoutBuilder.toString().trimEnd(), + stderr = stderrBuilder.toString().trimEnd(), + ) + } +} diff --git a/src/main/kotlin/com/nano/main.kt b/src/main/kotlin/com/nano/main.kt new file mode 100644 index 0000000..32c3525 --- /dev/null +++ b/src/main/kotlin/com/nano/main.kt @@ -0,0 +1,29 @@ +package com.nano + +import com.nano.db.DatabaseConfig +import com.nano.db.TaskTable +import com.nano.executor.Executor +import com.nano.repository.TaskRepository +import com.nano.service.TaskService +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.transaction + +fun main(args: Array) { + EngineMain.main(args) +} + +fun Application.module() { + val db = DatabaseConfig.connect() + transaction(db) { + SchemaUtils.createMissingTablesAndColumns(TaskTable) + } + + val taskRepository = TaskRepository(db) + val taskService = TaskService(taskRepository) + val executor = Executor() + + configureRouting(taskService, executor) +} diff --git a/src/main/kotlin/com/nano/model/ExecutorResult.kt b/src/main/kotlin/com/nano/model/ExecutorResult.kt new file mode 100644 index 0000000..39a8e41 --- /dev/null +++ b/src/main/kotlin/com/nano/model/ExecutorResult.kt @@ -0,0 +1,11 @@ +package com.nano.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ExecutorResult( + val exitCode: Int, + val stdout: String, + val stderr: String, + val isSuccess: Boolean = exitCode == 0, +) diff --git a/src/main/kotlin/com/nano/model/Task.kt b/src/main/kotlin/com/nano/model/Task.kt new file mode 100644 index 0000000..1d6ed8d --- /dev/null +++ b/src/main/kotlin/com/nano/model/Task.kt @@ -0,0 +1,28 @@ +package com.nano.model + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +@Serializable +data class Task( + @Contextual val id: UUID = UUID.randomUUID(), + val name: String, + val payload: String = "", + val status: TaskStatus = TaskStatus.PENDING, + val createdAt: Long = Instant.now().toEpochMilli(), + val updatedAt: Long = Instant.now().toEpochMilli() +) { + val createdAtInstant: Instant get() = Instant.ofEpochMilli(createdAt) + val updatedAtInstant: Instant get() = Instant.ofEpochMilli(updatedAt) +} + +@Serializable +enum class TaskStatus { + PENDING, + RUNNING, + COMPLETED, + FAILED, + CANCELLED +} diff --git a/src/main/kotlin/com/nano/repository/TaskRepository.kt b/src/main/kotlin/com/nano/repository/TaskRepository.kt new file mode 100644 index 0000000..c6c5b3b --- /dev/null +++ b/src/main/kotlin/com/nano/repository/TaskRepository.kt @@ -0,0 +1,87 @@ +package com.nano.repository + +import com.nano.db.TaskTable +import com.nano.model.Task +import com.nano.model.TaskStatus +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.transactions.transaction +import java.time.Instant +import java.util.UUID + +class TaskRepository( + private val db: Database, +) { + fun create(task: Task): Task = + transaction(db) { + val now = Instant.now().toEpochMilli() + TaskTable.insert { + it[id] = task.id + it[name] = task.name + it[payload] = task.payload + it[status] = task.status + it[createdAt] = now + it[updatedAt] = now + } + task.copy(createdAt = now, updatedAt = now) + } + + fun findById(id: UUID): Task? = + transaction(db) { + TaskTable + .selectAll() + .where(TaskTable.id eq id) + .singleOrNull() + ?.toTask() + } + + fun findAll(): List = + transaction(db) { + TaskTable + .selectAll() + .orderBy(TaskTable.createdAt, SortOrder.DESC) + .map { it.toTask() } + } + + fun findByStatus(status: TaskStatus): List = + transaction(db) { + TaskTable + .selectAll() + .where(TaskTable.status eq status) + .orderBy(TaskTable.createdAt, SortOrder.DESC) + .map { it.toTask() } + } + + fun updateStatus( + id: UUID, + newStatus: TaskStatus, + ): Task? = + transaction(db) { + val now = Instant.now().toEpochMilli() + val updated = + TaskTable.update({ TaskTable.id eq id }) { + it[status] = newStatus + it[updatedAt] = now + } + if (updated == 0) { + null + } else { + findById(id) + } + } + + fun delete(id: UUID): Boolean = + transaction(db) { + TaskTable.deleteWhere { TaskTable.id eq id } > 0 + } + + private fun ResultRow.toTask() = + Task( + id = this[TaskTable.id], + name = this[TaskTable.name], + payload = this[TaskTable.payload], + status = this[TaskTable.status], + createdAt = this[TaskTable.createdAt], + updatedAt = this[TaskTable.updatedAt], + ) +} diff --git a/src/main/kotlin/com/nano/service/TaskService.kt b/src/main/kotlin/com/nano/service/TaskService.kt new file mode 100644 index 0000000..3539d2c --- /dev/null +++ b/src/main/kotlin/com/nano/service/TaskService.kt @@ -0,0 +1,25 @@ +package com.nano.service + +import com.nano.model.Task +import com.nano.model.TaskStatus +import com.nano.repository.TaskRepository +import java.util.UUID + +class TaskService(private val repository: TaskRepository) { + + fun createTask(name: String, payload: String = ""): Task { + val task = Task(name = name, payload = payload) + return repository.create(task) + } + + fun getTask(id: UUID): Task? = repository.findById(id) + + fun listTasks(status: TaskStatus? = null): List = + if (status != null) repository.findByStatus(status) + else repository.findAll() + + fun updateStatus(id: UUID, status: TaskStatus): Task? = + repository.updateStatus(id, status) + + fun deleteTask(id: UUID): Boolean = repository.delete(id) +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..5884545 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +ktor: + deployment: + port: 8080 + application: + modules: + - com.nano.MainKt.module diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..aadef5d --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/src/test/kotlin/ServerTest.kt b/src/test/kotlin/ServerTest.kt new file mode 100644 index 0000000..c7e13dc --- /dev/null +++ b/src/test/kotlin/ServerTest.kt @@ -0,0 +1,50 @@ +package com.nano + +import com.nano.db.TaskTable +import com.nano.executor.Executor +import com.nano.repository.TaskRepository +import com.nano.service.TaskService +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.transaction +import kotlin.test.* + +class ServerTest { + + @Test + fun `root endpoint returns OK`() = testApplication { + val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "org.h2.Driver") + transaction(db) { SchemaUtils.createMissingTablesAndColumns(TaskTable) } + val taskService = TaskService(TaskRepository(db)) + + application { + configureRouting(taskService, Executor()) + } + + assertEquals(HttpStatusCode.OK, client.get("/").status) + } + + @Test + fun `create task returns Created`() = testApplication { + val db = Database.connect("jdbc:h2:mem:test2;DB_CLOSE_DELAY=-1", "org.h2.Driver") + transaction(db) { SchemaUtils.createMissingTablesAndColumns(TaskTable) } + val taskService = TaskService(TaskRepository(db)) + + application { + configureRouting(taskService, Executor()) + } + + val response = client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"name":"test-task","payload":"hello"}""") + } + assertEquals(HttpStatusCode.Created, response.status) + } +}