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