+ gitignore
+ add boilerplate
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
.gradle
|
||||
.idea
|
||||
.opencode
|
||||
.swarm
|
||||
build
|
||||
*.md
|
||||
docker-compose*.yml
|
||||
.env*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/.idea/
|
||||
/.opencode/
|
||||
/.swarm/
|
||||
/.kotlin/
|
||||
@@ -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.
|
||||
+23
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -0,0 +1,2 @@
|
||||
kotlin.code.style=official
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+9
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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<UUID> {
|
||||
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<CreateTaskRequest>()
|
||||
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<ExecCommandRequest>()
|
||||
val logs = mutableListOf<String>()
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<String, String> = 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<String>) {
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Task> =
|
||||
transaction(db) {
|
||||
TaskTable
|
||||
.selectAll()
|
||||
.orderBy(TaskTable.createdAt, SortOrder.DESC)
|
||||
.map { it.toTask() }
|
||||
}
|
||||
|
||||
fun findByStatus(status: TaskStatus): List<Task> =
|
||||
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],
|
||||
)
|
||||
}
|
||||
@@ -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<Task> =
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
ktor:
|
||||
deployment:
|
||||
port: 8080
|
||||
application:
|
||||
modules:
|
||||
- com.nano.MainKt.module
|
||||
@@ -0,0 +1,12 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
<logger name="org.eclipse.jetty" level="INFO"/>
|
||||
<logger name="io.netty" level="INFO"/>
|
||||
</configuration>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user