Add isolated Luau rule engine service
Build and publish rule engine / release (push) Failing after 1m34s
Build and publish rule engine / release (push) Failing after 1m34s
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user