commit a85a49dfd51357a4483778b6f3d14dcace027490 Author: Ein Anderssono Date: Tue Oct 20 11:24:52 2026 +0200 Add isolated Luau rule engine service diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml new file mode 100644 index 0000000..84566a7 --- /dev/null +++ b/.gitea/workflows/publish.yaml @@ -0,0 +1,134 @@ +name: Build and publish rule engine + +on: + push: + branches: [main] + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: large + if: ${{ !startsWith(github.ref, 'refs/heads/') || !contains(github.event.head_commit.message, '[auto-version-bump]') }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITEA_TOKEN }} + persist-credentials: true + + - name: Test and lint in the shared builder image + shell: bash + run: | + set -euo pipefail + docker run --rm \ + --volume "$PWD:/src" \ + --volume ai-spel-rule-engine-cargo:/src/target \ + --workdir /src \ + harbor.k3s.k0.nu/ai-spel/game-builder:rust1.89-v2 \ + sh -c 'cargo generate-lockfile && cargo fmt --all && cargo fmt --all -- --check && cargo test --workspace && cargo clippy --locked --workspace --all-targets -- -D warnings' + + - name: Bump version and create Git tag + id: bump + if: startsWith(github.ref, 'refs/heads/') + shell: bash + run: | + set -euo pipefail + git fetch origin main --quiet + test "$(git rev-parse origin/main)" = "$GITHUB_SHA" + version="$(python3 scripts/bump-release-version.py)" + git config user.name "Gitea Actions" + git config user.email "actions@users.noreply.gitea" + git add -u + git add Cargo.toml Cargo.lock deploy/helm/ai-spel-rule-engine/Chart.yaml deploy/argocd/application.yaml + git commit -m "Release $version [auto-version-bump]" + git tag -a "v$version" -m "v$version" + git push origin HEAD:main "refs/tags/v$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Build and publish image and Helm chart + id: publish + if: ${{ startsWith(github.ref, 'refs/tags/') || steps.bump.outputs.version != '' }} + shell: bash + env: + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + AUTO_RELEASE_VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + version="${AUTO_RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" + printf '%s' "$HARBOR_PASSWORD" | docker login harbor.k3s.k0.nu --username "$HARBOR_USERNAME" --password-stdin + image="harbor.k3s.k0.nu/ai-spel/rule-engine:$version" + docker build --platform linux/amd64 --pull --tag "$image" . + docker image push "$image" + helm_version=v3.17.3 + if ! command -v helm >/dev/null 2>&1; then + mkdir -p "$RUNNER_TEMP/helm" "$HOME/.local/bin" + archive="helm-${helm_version}-linux-amd64.tar.gz" + curl -fsSLo "$RUNNER_TEMP/helm/$archive" "https://get.helm.sh/$archive" + curl -fsSLo "$RUNNER_TEMP/helm/$archive.sha256sum" "https://get.helm.sh/$archive.sha256sum" + (cd "$RUNNER_TEMP/helm" && sha256sum -c "$archive.sha256sum") + tar -xzf "$RUNNER_TEMP/helm/$archive" -C "$RUNNER_TEMP/helm" + install "$RUNNER_TEMP/helm/linux-amd64/helm" "$HOME/.local/bin/helm" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + fi + helm lint deploy/helm/ai-spel-rule-engine + package_dir="$(mktemp -d)" + trap 'rm -rf "$package_dir"' EXIT + helm package deploy/helm/ai-spel-rule-engine --destination "$package_dir" --version "$version" --app-version "$version" + printf '%s' "$HARBOR_PASSWORD" | helm registry login harbor.k3s.k0.nu --username "$HARBOR_USERNAME" --password-stdin + helm push "$package_dir/ai-spel-rule-engine-$version.tgz" oci://harbor.k3s.k0.nu/ai-spel + helm template ai-spel-rule-engine "$package_dir/ai-spel-rule-engine-$version.tgz" --namespace ai-spel > "$package_dir/manifest.yaml" + oras_version=1.2.3 + mkdir -p "$RUNNER_TEMP/oras" + curl -fsSLo "$RUNNER_TEMP/oras/oras.tgz" "https://github.com/oras-project/oras/releases/download/v${oras_version}/oras_${oras_version}_linux_amd64.tar.gz" + echo 'b4efc97a91f471f323f193ea4b4d63d8ff443ca3aab514151a30751330852827 '"$RUNNER_TEMP/oras/oras.tgz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/oras/oras.tgz" -C "$RUNNER_TEMP/oras" oras + install "$RUNNER_TEMP/oras/oras" "$RUNNER_TEMP/oras-bin" + printf '%s' "$HARBOR_PASSWORD" | "$RUNNER_TEMP/oras-bin" login harbor.k3s.k0.nu --username "$HARBOR_USERNAME" --password-stdin + unset HARBOR_PASSWORD + "$RUNNER_TEMP/oras-bin" push --artifact-type application/vnd.ai-spel.kubernetes-manifest.v1 \ + "harbor.k3s.k0.nu/ai-spel/rule-engine-manifest:$version" \ + "$package_dir/manifest.yaml:application/yaml" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Deploy rule engine through Argo CD + if: ${{ startsWith(github.ref, 'refs/tags/') || steps.bump.outputs.version != '' }} + shell: bash + env: + KUBECONFIG_B64: ${{ secrets.AI_SPEL_TEST_KUBECONFIG }} + AUTO_RELEASE_VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + test -n "$KUBECONFIG_B64" + mkdir -p "$RUNNER_TEMP/kube" + printf '%s' "$KUBECONFIG_B64" | base64 -d > "$RUNNER_TEMP/kube/config" + chmod 600 "$RUNNER_TEMP/kube/config" + unset KUBECONFIG_B64 + export KUBECONFIG="$RUNNER_TEMP/kube/config" + version="${AUTO_RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" + if ! kubectl -n ai-spel get statefulset ai-spel-nats >/dev/null 2>&1 \ + && ! kubectl -n argocd get application ai-spel-rule-engine >/dev/null 2>&1; then + echo 'NATS is not installed yet. The backend release pipeline bootstraps NATS and this Argo Application.' + exit 0 + fi + kubectl -n ai-spel rollout status statefulset/ai-spel-nats --timeout=10m + kubectl -n argocd apply -f deploy/argocd/application.yaml + kubectl -n argocd patch application ai-spel-rule-engine --type merge -p "{\"spec\":{\"source\":{\"targetRevision\":\"$version\"}}}" + deadline=$((SECONDS + 600)) + while (( SECONDS < deadline )); do + sync="$(kubectl -n argocd get application ai-spel-rule-engine -o jsonpath='{.status.sync.status}')" + health="$(kubectl -n argocd get application ai-spel-rule-engine -o jsonpath='{.status.health.status}')" + if [[ "$sync" == Synced && "$health" == Healthy ]]; then break; fi + sleep 5 + done + [[ "$sync" == Synced && "$health" == Healthy ]] || { + echo "Argo CD did not reach Synced/Healthy for rule engine $version" + kubectl -n argocd get application ai-spel-rule-engine -o jsonpath='{.status.conditions[*].message}' + exit 1 + } + kubectl -n ai-spel rollout status deployment/ai-spel-rule-engine --timeout=10m + kubectl -n ai-spel get pods -l app.kubernetes.io/instance=ai-spel-rule-engine -o wide diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..04074f5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +members = ["crates/rule-engine"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" + +[workspace.dependencies] +async-nats = "0.50" +axum = { version = "0.8", features = ["macros"] } +futures-util = "0.3" +mlua = { version = "0.11.6", features = ["luau", "send", "serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "sync"] } +uuid = { version = "1", features = ["serde", "v4"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..820d01f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM harbor.k3s.k0.nu/ai-spel/game-builder:rust1.89-v2 AS builder +WORKDIR /src +COPY Cargo.toml Cargo.lock* ./ +COPY crates ./crates +RUN cargo build --locked --release -p ai-spel-rule-engine + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /src/target/release/ai-spel-rule-engine /usr/local/bin/ai-spel-rule-engine +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/ai-spel-rule-engine"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..218ba06 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# ai-spel-rule-engine + +The isolated Luau evaluation service for the AI-driven Swedish life simulator. +It receives bounded, versioned rule invocations over NATS and returns typed +effect proposals. It has no database or AI-provider integration; the game API +remains responsible for validating and committing effects. + +## Contract + +Requests use NATS subject `ai-spel.rules.evaluate.v1` and contract version 1. +The canonical JSON Schema is `contracts/rule-evaluation-v1.schema.json`. +Responses echo the correlation ID and have status `evaluated` or `rejected`. + +The NATS user may subscribe only to the evaluation subject and publish only to +reply inboxes. The service receives no credentials other than its own NATS +identity and has no configured database or AI-provider secrets. + +## Local commands + +For source development, the Rust commands are `cargo fmt --all -- --check`, +`cargo test --workspace`, and `cargo clippy --workspace --all-targets -- -D warnings`. +Production builds, tests, image builds, and executable verification for this +project are run by Gitea Actions on the existing `large` runner and Kubernetes. diff --git a/contracts/rule-evaluation-v1.schema.json b/contracts/rule-evaluation-v1.schema.json new file mode 100644 index 0000000..1f947a2 --- /dev/null +++ b/contracts/rule-evaluation-v1.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gitea.k3s.k0.nu/ai-spel/ai-spel-rule-engine/raw/tag/v0.1.0/contracts/rule-evaluation-v1.schema.json", + "title": "AI Spel Luau rule evaluation contract v1", + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "correlation_id", "invocation"], + "properties": { + "contract_version": { "const": 1 }, + "correlation_id": { "type": "string", "format": "uuid" }, + "invocation": { + "type": "object", + "additionalProperties": false, + "required": ["rule_id", "rule_version", "source", "snapshot"], + "properties": { + "rule_id": { "type": "string", "format": "uuid" }, + "rule_version": { "type": "integer", "minimum": 1 }, + "source": { "type": "string", "maxLength": 16384 }, + "snapshot": { + "type": "object", + "additionalProperties": false, + "required": ["world_id", "version", "world_time", "facts"], + "properties": { + "world_id": { "type": "string", "format": "uuid" }, + "version": { "type": "integer" }, + "world_time": { "type": "integer" }, + "facts": { "type": "object" } + } + } + } + } + }, + "$defs": { + "effect": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "payload"], + "properties": { + "kind": { "type": "string", "minLength": 1, "maxLength": 64 }, + "payload": { "type": "object" } + } + }, + "response": { + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "correlation_id", "outcome"], + "properties": { + "contract_version": { "const": 1 }, + "correlation_id": { "type": "string", "format": "uuid" }, + "outcome": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "effects"], + "properties": { + "status": { "const": "evaluated" }, + "effects": { "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/effect" } } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "code", "detail"], + "properties": { + "status": { "const": "rejected" }, + "code": { "type": "string" }, + "detail": { "type": "string" } + } + } + ] + } + } + } + } +} diff --git a/crates/rule-engine/Cargo.toml b/crates/rule-engine/Cargo.toml new file mode 100644 index 0000000..9510aa3 --- /dev/null +++ b/crates/rule-engine/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ai-spel-rule-engine" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +async-nats.workspace = true +axum.workspace = true +futures-util.workspace = true +mlua.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +uuid.workspace = true diff --git a/crates/rule-engine/src/lib.rs b/crates/rule-engine/src/lib.rs new file mode 100644 index 0000000..5e2eb0a --- /dev/null +++ b/crates/rule-engine/src/lib.rs @@ -0,0 +1,250 @@ +use mlua::{Function, Lua, LuaSerdeExt, Value as LuaValue}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +pub const CONTRACT_VERSION: u16 = 1; +pub const EVALUATION_SUBJECT: &str = "ai-spel.rules.evaluate.v1"; +pub const MAX_RULE_BYTES: usize = 16 * 1024; +pub const MAX_SNAPSHOT_BYTES: usize = 32 * 1024; +pub const MAX_RESULT_BYTES: usize = 16 * 1024; +pub const MAX_EFFECTS: usize = 16; +pub const MAX_MEMORY_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_INTERRUPTS: usize = 10_000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorldSnapshot { + pub world_id: Uuid, + pub version: i64, + pub world_time: i64, + pub facts: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ProposedEffect { + pub kind: String, + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuleInvocation { + pub rule_id: Uuid, + pub rule_version: i32, + pub source: String, + pub snapshot: WorldSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvaluationRequest { + pub contract_version: u16, + pub correlation_id: Uuid, + pub invocation: RuleInvocation, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum EvaluationOutcome { + Evaluated { effects: Vec }, + Rejected { code: String, detail: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvaluationResponse { + pub contract_version: u16, + pub correlation_id: Uuid, + pub outcome: EvaluationOutcome, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleError { + RuleTooLarge, + SnapshotTooLarge, + ResultTooLarge, + TooManyEffects, + InvalidEffect, + Sandbox(String), +} + +impl RuleError { + pub fn code(&self) -> &'static str { + match self { + Self::RuleTooLarge => "rule_too_large", + Self::SnapshotTooLarge => "snapshot_too_large", + Self::ResultTooLarge => "result_too_large", + Self::TooManyEffects => "too_many_effects", + Self::InvalidEffect => "invalid_effect", + Self::Sandbox(_) => "sandbox_rejected", + } + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct LuauRuntime; + +impl LuauRuntime { + pub fn evaluate(&self, invocation: &RuleInvocation) -> Result, RuleError> { + if invocation.source.len() > MAX_RULE_BYTES { + return Err(RuleError::RuleTooLarge); + } + let snapshot_json = serde_json::to_vec(&invocation.snapshot) + .map_err(|error| RuleError::Sandbox(error.to_string()))?; + if snapshot_json.len() > MAX_SNAPSHOT_BYTES { + return Err(RuleError::SnapshotTooLarge); + } + + let lua = Lua::new(); + lua.set_memory_limit(MAX_MEMORY_BYTES) + .map_err(|error| RuleError::Sandbox(error.to_string()))?; + lua.sandbox(true) + .map_err(|error| RuleError::Sandbox(error.to_string()))?; + let interrupt_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + lua.set_interrupt({ + let interrupt_count = interrupt_count.clone(); + move |_| { + let count = interrupt_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if count >= MAX_INTERRUPTS { + return Err(mlua::Error::RuntimeError( + "rule execution budget exceeded".into(), + )); + } + Ok(mlua::VmState::Continue) + } + }); + + let result = (|| -> mlua::Result { + let rule: Function = lua.load(&invocation.source).set_name("game-rule").eval()?; + let snapshot = lua.to_value(&invocation.snapshot)?; + rule.call(snapshot) + })() + .map_err(|error| RuleError::Sandbox(error.to_string()))?; + + let effects: Vec = lua + .from_value(result) + .map_err(|error| RuleError::Sandbox(error.to_string()))?; + if effects.len() > MAX_EFFECTS { + return Err(RuleError::TooManyEffects); + } + let encoded = serde_json::to_vec(&effects) + .map_err(|error| RuleError::Sandbox(error.to_string()))?; + if encoded.len() > MAX_RESULT_BYTES { + return Err(RuleError::ResultTooLarge); + } + if effects.iter().any(|effect| { + effect.kind.trim().is_empty() + || effect.kind.len() > 64 + || !effect.payload.is_object() + || serde_json::to_vec(&effect.payload) + .map(|payload| payload.len() > MAX_RESULT_BYTES) + .unwrap_or(true) + }) { + return Err(RuleError::InvalidEffect); + } + Ok(effects) + } +} + +pub fn evaluate_request(request: EvaluationRequest) -> EvaluationResponse { + let correlation_id = request.correlation_id; + let outcome = if request.contract_version != CONTRACT_VERSION { + EvaluationOutcome::Rejected { + code: "unsupported_contract_version".into(), + detail: "The requested rule evaluation contract version is not supported.".into(), + } + } else { + match LuauRuntime.evaluate(&request.invocation) { + Ok(effects) => EvaluationOutcome::Evaluated { effects }, + Err(error) => EvaluationOutcome::Rejected { + code: error.code().into(), + detail: match error { + RuleError::Sandbox(detail) => detail, + _ => format!("Rule evaluation rejected: {}", error.code()), + }, + }, + } + }; + EvaluationResponse { + contract_version: CONTRACT_VERSION, + correlation_id, + outcome, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(source: &str) -> EvaluationRequest { + EvaluationRequest { + contract_version: CONTRACT_VERSION, + correlation_id: Uuid::nil(), + invocation: RuleInvocation { + rule_id: Uuid::nil(), + rule_version: 1, + source: source.into(), + snapshot: WorldSnapshot { + world_id: Uuid::nil(), + version: 7, + world_time: 12, + facts: serde_json::json!({"minutes":5}), + }, + }, + } + } + + #[test] + fn evaluates_luau_to_typed_proposals() { + let result = evaluate_request(request( + r#"return function(snapshot) + return {{ kind = "advance_time", payload = { minutes = snapshot.facts.minutes } }} + end"#, + )); + let EvaluationOutcome::Evaluated { effects } = result.outcome else { + panic!("expected a typed effect proposal"); + }; + assert_eq!(effects[0].kind, "advance_time"); + assert_eq!(effects[0].payload["minutes"], 5); + } + + #[test] + fn rejects_ambient_io_and_non_effect_results() { + let result = evaluate_request(request( + "return function() return io.open('/etc/passwd') end", + )); + assert!(matches!(result.outcome, EvaluationOutcome::Rejected { .. })); + let result = evaluate_request(request( + "return function() return {{ kind = 'advance_time', payload = 'not an object' }} end", + )); + assert!(matches!(result.outcome, EvaluationOutcome::Rejected { .. })); + let result = evaluate_request(request( + "return function() return 'not an effect list' end", + )); + assert!(matches!(result.outcome, EvaluationOutcome::Rejected { .. })); + } + + #[test] + fn rejects_execution_budget_exhaustion() { + let result = evaluate_request(request("return function() while true do end end")); + assert!(matches!(result.outcome, EvaluationOutcome::Rejected { .. })); + } + + #[test] + fn rejects_wrong_contract_and_oversized_source() { + let mut wrong_version = request("return function() return {} end"); + wrong_version.contract_version = CONTRACT_VERSION + 1; + let result = evaluate_request(wrong_version); + assert!(matches!( + result.outcome, + EvaluationOutcome::Rejected { ref code, .. } if code == "unsupported_contract_version" + )); + let result = evaluate_request(request(&" ".repeat(MAX_RULE_BYTES + 1))); + assert!(matches!( + result.outcome, + EvaluationOutcome::Rejected { ref code, .. } if code == "rule_too_large" + )); + } +} diff --git a/crates/rule-engine/src/main.rs b/crates/rule-engine/src/main.rs new file mode 100644 index 0000000..a009085 --- /dev/null +++ b/crates/rule-engine/src/main.rs @@ -0,0 +1,171 @@ +use ai_spel_rule_engine::{ + CONTRACT_VERSION, EVALUATION_SUBJECT, EvaluationRequest, EvaluationResponse, evaluate_request, +}; +use async_nats::{Client, ConnectOptions, Subscriber, Subject}; +use axum::{Router, extract::State, http::StatusCode, routing::get}; +use futures_util::StreamExt; +use std::{env, sync::Arc, time::Duration}; +use tokio::{task::JoinSet, time}; + +#[derive(Clone)] +struct Health { + nats_connected: bool, +} + +async fn live() -> StatusCode { + StatusCode::NO_CONTENT +} + +async fn ready(State(health): State>>) -> StatusCode { + if health.read().await.nats_connected { + StatusCode::NO_CONTENT + } else { + StatusCode::SERVICE_UNAVAILABLE + } +} + +async fn connect_nats() -> Result> { + let url = env::var("NATS_URL").unwrap_or_else(|_| "nats://ai-spel-nats:4222".into()); + let username = env::var("NATS_USERNAME")?; + let password = env::var("NATS_PASSWORD")?; + Ok(ConnectOptions::new() + .user_and_password(username, password) + .connect(url) + .await?) +} + +async fn smoke_nats() -> Result<(), Box> { + let client = connect_nats().await?; + let correlation_id = uuid::Uuid::new_v4(); + let request = EvaluationRequest { + contract_version: CONTRACT_VERSION, + correlation_id, + invocation: ai_spel_rule_engine::RuleInvocation { + rule_id: uuid::Uuid::nil(), + rule_version: 1, + source: "return function() return {} end".into(), + snapshot: ai_spel_rule_engine::WorldSnapshot { + world_id: uuid::Uuid::nil(), + version: 0, + world_time: 0, + facts: serde_json::json!({}), + }, + }, + }; + let bytes = serde_json::to_vec(&request)?; + let response = time::timeout( + Duration::from_secs(5), + client.request(EVALUATION_SUBJECT, bytes.into()), + ) + .await??; + let response: EvaluationResponse = serde_json::from_slice(&response.payload)?; + if response.contract_version != CONTRACT_VERSION || response.correlation_id != correlation_id { + return Err("rule service smoke response did not match the request contract".into()); + } + match response.outcome { + ai_spel_rule_engine::EvaluationOutcome::Evaluated { effects } if effects.is_empty() => { + println!("Rule engine NATS request/reply smoke passed") + } + _ => return Err("rule service smoke evaluation was rejected or returned effects".into()), + } + Ok(()) +} + +fn response_for_payload(payload: &[u8]) -> Option> { + let request: EvaluationRequest = match serde_json::from_slice(payload) { + Ok(request) => request, + Err(_) => return None, + }; + serde_json::to_vec(&evaluate_request(request)).ok() +} + +async fn respond(client: Client, reply: Subject, payload: Vec) { + if let Err(error) = client.publish(reply, payload.into()).await { + eprintln!( + "{{\"level\":\"error\",\"event\":\"rule.reply_publish_failed\",\"error_category\":\"nats_publish\",\"detail\":{:?}}}", + error.to_string() + ); + } +} + +async fn serve_rule_requests( + client: Client, + mut subscriber: Subscriber, + max_concurrency: usize, +) -> Result<(), Box> { + let mut tasks = JoinSet::new(); + loop { + tokio::select! { + message = subscriber.next() => { + let Some(message) = message else { break; }; + let Some(reply) = message.reply else { continue; }; + while tasks.len() >= max_concurrency { + let _ = tasks.join_next().await; + } + let client = client.clone(); + tasks.spawn(async move { + let payload = tokio::task::spawn_blocking(move || response_for_payload(&message.payload)).await; + if let Ok(Some(payload)) = payload { + respond(client, reply, payload).await; + } + }); + } + Some(_) = tasks.join_next(), if !tasks.is_empty() => {} + } + } + while tasks.join_next().await.is_some() {} + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + if env::args().any(|argument| argument == "--smoke") { + return smoke_nats().await; + } + let health = Arc::new(tokio::sync::RwLock::new(Health { + nats_connected: false, + })); + let health_router = Router::new() + .route("/health/live", get(live)) + .route("/health/ready", get(ready)) + .with_state(health.clone()); + let health_address = env::var("HEALTH_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let health_listener = tokio::net::TcpListener::bind(health_address).await?; + tokio::spawn(async move { let _ = axum::serve(health_listener, health_router).await; }); + + let client = loop { + match connect_nats().await { + Ok(client) => break client, + Err(error) => { + eprintln!("{{\"level\":\"warn\",\"event\":\"nats.connect_retry\",\"error_category\":\"nats_connect\",\"detail\":{:?}}}", error.to_string()); + time::sleep(Duration::from_secs(2)).await; + } + } + }; + let queue_group = env::var("NATS_QUEUE_GROUP").unwrap_or_else(|_| "ai-spel-rule-engine".into()); + let subscriber = client + .queue_subscribe(EVALUATION_SUBJECT, queue_group) + .await?; + health.write().await.nats_connected = true; + { + let health = health.clone(); + let health_client = client.clone(); + tokio::spawn(async move { + loop { + let connected = matches!( + time::timeout(Duration::from_secs(2), health_client.flush()).await, + Ok(Ok(())) + ); + health.write().await.nats_connected = connected; + time::sleep(Duration::from_secs(5)).await; + } + }); + } + let concurrency = env::var("RULE_MAX_CONCURRENCY") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| (1..=32).contains(value)) + .unwrap_or(8); + eprintln!("{{\"level\":\"info\",\"event\":\"rule_engine.ready\",\"contract_version\":{},\"max_concurrency\":{}}}", CONTRACT_VERSION, concurrency); + serve_rule_requests(client, subscriber, concurrency).await +} diff --git a/deploy/argocd/application.yaml b/deploy/argocd/application.yaml new file mode 100644 index 0000000..f126d3a --- /dev/null +++ b/deploy/argocd/application.yaml @@ -0,0 +1,22 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ai-spel-rule-engine + namespace: argocd +spec: + project: ai-spel + source: + repoURL: harbor.k3s.k0.nu/ai-spel + chart: ai-spel-rule-engine + targetRevision: "0.1.0" + helm: + releaseName: ai-spel-rule-engine + destination: + server: https://kubernetes.default.svc + namespace: ai-spel + syncPolicy: + automated: + prune: false + selfHeal: true + syncOptions: + - CreateNamespace=false diff --git a/deploy/helm/ai-spel-rule-engine/Chart.yaml b/deploy/helm/ai-spel-rule-engine/Chart.yaml new file mode 100644 index 0000000..a821047 --- /dev/null +++ b/deploy/helm/ai-spel-rule-engine/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: ai-spel-rule-engine +description: Isolated, sandboxed Luau proposal evaluator +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/deploy/helm/ai-spel-rule-engine/templates/deployment.yaml b/deploy/helm/ai-spel-rule-engine/templates/deployment.yaml new file mode 100644 index 0000000..c99eb7d --- /dev/null +++ b/deploy/helm/ai-spel-rule-engine/templates/deployment.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount }} + minReadySeconds: {{ .Values.deployment.minReadySeconds }} + progressDeadlineSeconds: {{ .Values.deployment.progressDeadlineSeconds }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: {{ .Values.deployment.maxUnavailable }} + maxSurge: {{ .Values.deployment.maxSurge }} + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + automountServiceAccountToken: false + {{- with .Values.image.pullSecretName }} + imagePullSecrets: + - name: {{ . | quote }} + {{- end }} + containers: + - name: rule-engine + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: health + containerPort: 8080 + env: + - name: HEALTH_ADDR + value: "0.0.0.0:8080" + - name: NATS_URL + value: {{ .Values.nats.url | quote }} + - name: NATS_USERNAME + valueFrom: + secretKeyRef: + name: {{ .Values.nats.secretName }} + key: {{ .Values.nats.usernameKey }} + - name: NATS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.nats.secretName }} + key: {{ .Values.nats.passwordKey }} + - name: NATS_QUEUE_GROUP + value: {{ .Values.nats.queueGroup | quote }} + - name: RULE_MAX_CONCURRENCY + value: {{ .Values.runtime.maxConcurrency | quote }} + readinessProbe: + httpGet: + path: /health/ready + port: health + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health/live + port: health + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + resources: {{- toYaml .Values.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: health + port: {{ .Values.service.port }} + targetPort: health +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ .Release.Name }} +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/deploy/helm/ai-spel-rule-engine/templates/networkpolicy.yaml b/deploy/helm/ai-spel-rule-engine/templates/networkpolicy.yaml new file mode 100644 index 0000000..b0ba2bb --- /dev/null +++ b/deploy/helm/ai-spel-rule-engine/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + policyTypes: [Egress] + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/instance: ai-spel-nats + ports: + - protocol: TCP + port: 4222 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/deploy/helm/ai-spel-rule-engine/templates/pdb.yaml b/deploy/helm/ai-spel-rule-engine/templates/pdb.yaml new file mode 100644 index 0000000..50554b1 --- /dev/null +++ b/deploy/helm/ai-spel-rule-engine/templates/pdb.yaml @@ -0,0 +1,10 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ .Release.Name }} +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/deploy/helm/ai-spel-rule-engine/values.yaml b/deploy/helm/ai-spel-rule-engine/values.yaml new file mode 100644 index 0000000..9459154 --- /dev/null +++ b/deploy/helm/ai-spel-rule-engine/values.yaml @@ -0,0 +1,27 @@ +image: + repository: harbor.k3s.k0.nu/ai-spel/rule-engine + pullPolicy: Always + pullSecretName: harbor-pull +replicaCount: 2 +deployment: + maxUnavailable: 0 + maxSurge: 1 + minReadySeconds: 5 + progressDeadlineSeconds: 600 +service: + port: 8080 +nats: + url: nats://ai-spel-nats:4222 + secretName: ai-spel-rule-engine-nats + usernameKey: username + passwordKey: password + queueGroup: ai-spel-rule-engine +runtime: + maxConcurrency: 8 +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 1 + memory: 384Mi diff --git a/docs/adr/0001-isolated-luau-runtime.md b/docs/adr/0001-isolated-luau-runtime.md new file mode 100644 index 0000000..f011549 --- /dev/null +++ b/docs/adr/0001-isolated-luau-runtime.md @@ -0,0 +1,15 @@ +# ADR 0001: Isolated Luau runtime + +## Status + +Accepted; deployment verification is pending the first `large` pipeline release. + +## Decision + +This repository owns the Luau sandbox and the v1 request/reply schema. It accepts only a versioned rule source and a bounded, read-only snapshot, then returns typed effect proposals. It has no database or AI provider dependency. The service connects to the private `ai-spel.rules.evaluate.v1` NATS subject in a queue group so multiple replicas can share direct-scene evaluations. + +The service cannot make a world change. The API validates every returned effect and is the only writer of canonical state. Synchronous evaluations use Core NATS; durable asynchronous work is out of scope until an outbox/inbox and idempotent JetStream consumer exist. + +## Verification required + +The release workflow runs Rust checks and Helm checks on the existing `large` runner, publishes version-matched image/chart/manifest artifacts, and verifies the Argo rollout in Kubernetes. Live worker-loss and request/reply checks remain required before calling this service production-verified. diff --git a/scripts/bump-release-version.py b/scripts/bump-release-version.py new file mode 100644 index 0000000..032f914 --- /dev/null +++ b/scripts/bump-release-version.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Bump all rule-engine release metadata from the workspace version.""" +from pathlib import Path +import re + +cargo_path = Path("Cargo.toml") +cargo = cargo_path.read_text() +match = re.search(r'(?ms)(^\[workspace\.package\]\s+.*?^version\s*=\s*")(\d+)\.(\d+)\.(\d+)(")', cargo) +if not match: + raise SystemExit("Missing [workspace.package].version") +major, minor, patch = map(int, match.group(2, 3, 4)) +version = f"{major}.{minor}.{patch + 1}" +cargo_path.write_text(cargo[:match.start(2)] + version + cargo[match.end(4):]) + +for path, pattern in ( + (Path("deploy/helm/ai-spel-rule-engine/Chart.yaml"), r'(?m)^version:\s*\d+\.\d+\.\d+\s*$'), + (Path("deploy/helm/ai-spel-rule-engine/Chart.yaml"), r'(?m)^appVersion:\s*"\d+\.\d+\.\d+"\s*$'), + (Path("deploy/argocd/application.yaml"), r'(?m)^\s+targetRevision:\s*"\d+\.\d+\.\d+"\s*$'), +): + contents = path.read_text() + if not re.search(pattern, contents): + raise SystemExit(f"Missing version field in {path}") + replacement = f'version: {version}' if pattern.startswith(r'(?m)^version') else ( + f'appVersion: "{version}"' if pattern.startswith(r'(?m)^appVersion') else f' targetRevision: "{version}"' + ) + path.write_text(re.sub(pattern, replacement, contents, count=1)) +print(version)