Add isolated Luau rule engine service
Build and publish rule engine / release (push) Failing after 1m34s

This commit is contained in:
Ein Anderssono
2026-10-20 11:24:52 +02:00
commit a85a49dfd5
16 changed files with 936 additions and 0 deletions
+134
View File
@@ -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
+18
View File
@@ -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"] }
+12
View File
@@ -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"]
+23
View File
@@ -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.
+76
View File
@@ -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" }
}
}
]
}
}
}
}
}
+15
View File
@@ -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
+250
View File
@@ -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<ProposedEffect> },
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<Vec<ProposedEffect>, 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<LuaValue> {
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<ProposedEffect> = 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"
));
}
}
+171
View File
@@ -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<Arc<tokio::sync::RwLock<Health>>>) -> StatusCode {
if health.read().await.nats_connected {
StatusCode::NO_CONTENT
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
async fn connect_nats() -> Result<Client, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<u8>> {
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<u8>) {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<usize>().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
}
+22
View File
@@ -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
@@ -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"
@@ -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 }}
@@ -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
@@ -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 }}
@@ -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
+15
View File
@@ -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.
+27
View File
@@ -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)