Compare commits
20 Commits
d4c6240485
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a13bf754a | |||
| f4ef823f43 | |||
| ab7d31de2f | |||
| 938c271ff5 | |||
| da8055cb79 | |||
| 03a239d22b | |||
| ba83958337 | |||
| c11bc1a2bf | |||
| eb82755137 | |||
| 058f392616 | |||
| 0264a66b5a | |||
| 542e72a454 | |||
| a118563366 | |||
| a057ad5db5 | |||
| 8e273ec683 | |||
| 16f1c2f079 | |||
| 62307e8c65 | |||
| 2ebb03b868 | |||
| af5175b96a | |||
| 8af8c1af9c |
0
.codex_write_test
Normal file
0
.codex_write_test
Normal file
@@ -19,7 +19,7 @@ env:
|
||||
jobs:
|
||||
rust-fmt:
|
||||
name: Rustfmt
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
rust-clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
rust-test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
|
||||
rust-audit:
|
||||
name: Cargo Audit & Deny
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
|
||||
web-blocking:
|
||||
name: Web Blocking Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
|
||||
security-blocking:
|
||||
name: Security Blocking Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
|
||||
web-advisory:
|
||||
name: Web Advisory Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
@@ -279,7 +279,7 @@ jobs:
|
||||
|
||||
security-advisory:
|
||||
name: Security Advisory Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: build-amd64
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,7 @@ target/
|
||||
# Configuration files (keep *.example.yaml)
|
||||
config.yaml
|
||||
config.*.yaml
|
||||
!docker/distributable/config.docker.yaml
|
||||
!config.example.yaml
|
||||
!config.development.yaml
|
||||
!config.test.yaml
|
||||
@@ -35,6 +36,7 @@ logs/
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
artifacts/
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
@@ -78,4 +80,5 @@ docker-compose.override.yml
|
||||
*.pid
|
||||
|
||||
packs.examples/
|
||||
packs.external/
|
||||
codex/
|
||||
|
||||
@@ -99,7 +99,7 @@ docker compose -f docker-compose.yaml -f docker-compose.agent.yaml up -d # Star
|
||||
**Key environment overrides**: `JWT_SECRET`, `ENCRYPTION_KEY` (required for production)
|
||||
|
||||
### Docker Build Optimization
|
||||
- **Optimized Dockerfiles**: `docker/Dockerfile.optimized`, `docker/Dockerfile.worker.optimized`, `docker/Dockerfile.sensor.optimized`, and `docker/Dockerfile.agent`
|
||||
- **Active Dockerfiles**: `docker/Dockerfile.optimized`, `docker/Dockerfile.agent`, `docker/Dockerfile.web`, and `docker/Dockerfile.pack-binaries`
|
||||
- **Agent Dockerfile** (`docker/Dockerfile.agent`): Builds a statically-linked `attune-agent` binary using musl (`x86_64-unknown-linux-musl`). Three stages: `builder` (cross-compile), `agent-binary` (scratch — just the binary), `agent-init` (busybox — for volume population via `cp`). The binary has zero runtime dependencies (no glibc, no libssl). Build with `make docker-build-agent`.
|
||||
- **Strategy**: Selective crate copying - only copy crates needed for each service (not entire workspace)
|
||||
- **Performance**: 90% faster incremental builds (~30 sec vs ~5 min for code changes)
|
||||
@@ -681,9 +681,8 @@ When reporting, ask: "Should I fix this first or continue with [original task]?"
|
||||
- `Cargo.toml` - Workspace dependencies
|
||||
- `Makefile` - Development commands
|
||||
- `docker/Dockerfile.optimized` - Optimized service builds (api, executor, notifier)
|
||||
- `docker/Dockerfile.worker.optimized` - Optimized worker builds (shell, python, node, full)
|
||||
- `docker/Dockerfile.sensor.optimized` - Optimized sensor builds (base, full)
|
||||
- `docker/Dockerfile.agent` - Statically-linked agent binary (musl, for injection into any container)
|
||||
- `docker/Dockerfile.web` - Web UI build
|
||||
- `docker/Dockerfile.pack-binaries` - Separate pack binary builder
|
||||
- `scripts/build-pack-binaries.sh` - Build pack binaries script
|
||||
|
||||
|
||||
96
Cargo.lock
generated
96
Cargo.lock
generated
@@ -2150,21 +2150,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -3065,15 +3050,17 @@ dependencies = [
|
||||
"futures-util",
|
||||
"lber",
|
||||
"log",
|
||||
"native-tls",
|
||||
"nom 7.1.3",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3314,23 +3301,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -3576,50 +3546,12 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -4642,6 +4574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5698,16 +5631,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5749,9 +5672,11 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
@@ -5938,8 +5863,9 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
|
||||
@@ -101,7 +101,7 @@ tar = "0.4"
|
||||
flate2 = "1.1"
|
||||
|
||||
# WebSocket client
|
||||
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] }
|
||||
|
||||
# URL parsing
|
||||
url = "2.5"
|
||||
|
||||
50
Makefile
50
Makefile
@@ -237,43 +237,25 @@ docker-build-api:
|
||||
docker-build-web:
|
||||
docker compose build web
|
||||
|
||||
# Build worker images
|
||||
docker-build-workers: docker-build-worker-base docker-build-worker-python docker-build-worker-node docker-build-worker-full
|
||||
@echo "✅ All worker images built successfully"
|
||||
|
||||
docker-build-worker-base:
|
||||
@echo "Building base worker (shell only)..."
|
||||
DOCKER_BUILDKIT=1 docker build --target worker-base -t attune-worker:base -f docker/Dockerfile.worker.optimized .
|
||||
@echo "✅ Base worker image built: attune-worker:base"
|
||||
|
||||
docker-build-worker-python:
|
||||
@echo "Building Python worker (shell + python)..."
|
||||
DOCKER_BUILDKIT=1 docker build --target worker-python -t attune-worker:python -f docker/Dockerfile.worker.optimized .
|
||||
@echo "✅ Python worker image built: attune-worker:python"
|
||||
|
||||
docker-build-worker-node:
|
||||
@echo "Building Node.js worker (shell + node)..."
|
||||
DOCKER_BUILDKIT=1 docker build --target worker-node -t attune-worker:node -f docker/Dockerfile.worker.optimized .
|
||||
@echo "✅ Node.js worker image built: attune-worker:node"
|
||||
|
||||
docker-build-worker-full:
|
||||
@echo "Building full worker (all runtimes)..."
|
||||
DOCKER_BUILDKIT=1 docker build --target worker-full -t attune-worker:full -f docker/Dockerfile.worker.optimized .
|
||||
@echo "✅ Full worker image built: attune-worker:full"
|
||||
|
||||
# Agent binary (statically-linked for injection into any container)
|
||||
AGENT_RUST_TARGET ?= x86_64-unknown-linux-musl
|
||||
|
||||
build-agent:
|
||||
@echo "Installing musl target (if not already installed)..."
|
||||
rustup target add x86_64-unknown-linux-musl 2>/dev/null || true
|
||||
@echo "Building statically-linked agent binary..."
|
||||
SQLX_OFFLINE=true cargo build --release --target x86_64-unknown-linux-musl --bin attune-agent
|
||||
strip target/x86_64-unknown-linux-musl/release/attune-agent
|
||||
@echo "✅ Agent binary built: target/x86_64-unknown-linux-musl/release/attune-agent"
|
||||
@ls -lh target/x86_64-unknown-linux-musl/release/attune-agent
|
||||
rustup target add $(AGENT_RUST_TARGET) 2>/dev/null || true
|
||||
@echo "Building statically-linked worker and sensor agent binaries..."
|
||||
SQLX_OFFLINE=true cargo build --release --target $(AGENT_RUST_TARGET) --bin attune-agent --bin attune-sensor-agent
|
||||
strip target/$(AGENT_RUST_TARGET)/release/attune-agent
|
||||
strip target/$(AGENT_RUST_TARGET)/release/attune-sensor-agent
|
||||
@echo "✅ Agent binaries built:"
|
||||
@echo " - target/$(AGENT_RUST_TARGET)/release/attune-agent"
|
||||
@echo " - target/$(AGENT_RUST_TARGET)/release/attune-sensor-agent"
|
||||
@ls -lh target/$(AGENT_RUST_TARGET)/release/attune-agent
|
||||
@ls -lh target/$(AGENT_RUST_TARGET)/release/attune-sensor-agent
|
||||
|
||||
docker-build-agent:
|
||||
@echo "Building agent Docker image (statically-linked binary)..."
|
||||
DOCKER_BUILDKIT=1 docker buildx build --target agent-init -f docker/Dockerfile.agent -t attune-agent:latest .
|
||||
DOCKER_BUILDKIT=1 docker buildx build --build-arg RUST_TARGET=$(AGENT_RUST_TARGET) --target agent-init -f docker/Dockerfile.agent -t attune-agent:latest .
|
||||
@echo "✅ Agent image built: attune-agent:latest"
|
||||
|
||||
run-agent:
|
||||
@@ -282,6 +264,12 @@ run-agent:
|
||||
run-agent-release:
|
||||
cargo run --bin attune-agent --release
|
||||
|
||||
run-sensor-agent:
|
||||
cargo run --bin attune-sensor-agent
|
||||
|
||||
run-sensor-agent-release:
|
||||
cargo run --bin attune-sensor-agent --release
|
||||
|
||||
docker-up:
|
||||
@echo "Starting all services with Docker Compose..."
|
||||
docker compose up -d
|
||||
|
||||
@@ -13,6 +13,9 @@ Each agent worker uses an init container to copy the statically-linked
|
||||
attune-agent binary into the worker pod via an emptyDir volume. The agent
|
||||
auto-detects available runtimes in the container and registers with Attune.
|
||||
|
||||
The default sensor deployment also uses the same injection pattern, copying
|
||||
`attune-sensor-agent` into the pod before starting a stock runtime image.
|
||||
|
||||
To add more agent workers, append entries to `agentWorkers` in your values:
|
||||
|
||||
agentWorkers:
|
||||
|
||||
@@ -304,7 +304,15 @@ spec:
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
terminationGracePeriodSeconds: 45
|
||||
initContainers:
|
||||
- name: sensor-agent-loader
|
||||
image: {{ include "attune.image" (dict "root" . "image" .Values.images.agent) }}
|
||||
imagePullPolicy: {{ .Values.images.agent.pullPolicy }}
|
||||
command: ["cp", "/usr/local/bin/attune-sensor-agent", "/opt/attune/agent/attune-sensor-agent"]
|
||||
volumeMounts:
|
||||
- name: agent-bin
|
||||
mountPath: /opt/attune/agent
|
||||
- name: wait-for-schema
|
||||
image: postgres:16-alpine
|
||||
command: ["/bin/sh", "-ec"]
|
||||
@@ -333,6 +341,7 @@ spec:
|
||||
- name: sensor
|
||||
image: {{ include "attune.image" (dict "root" . "image" .Values.images.sensor) }}
|
||||
imagePullPolicy: {{ .Values.images.sensor.pullPolicy }}
|
||||
command: ["/opt/attune/agent/attune-sensor-agent"]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "attune.secretName" . }}
|
||||
@@ -343,23 +352,33 @@ spec:
|
||||
value: {{ .Values.database.schema | quote }}
|
||||
- name: ATTUNE__WORKER__WORKER_TYPE
|
||||
value: container
|
||||
- name: ATTUNE_SENSOR_RUNTIMES
|
||||
value: {{ .Values.sensor.runtimes | quote }}
|
||||
- name: ATTUNE_API_URL
|
||||
value: http://{{ include "attune.apiServiceName" . }}:{{ .Values.api.service.port }}
|
||||
- name: ATTUNE_MQ_URL
|
||||
value: {{ include "attune.rabbitmqUrl" . | quote }}
|
||||
- name: ATTUNE_PACKS_BASE_DIR
|
||||
value: /opt/attune/packs
|
||||
- name: RUST_LOG
|
||||
value: {{ .Values.sensor.logLevel | quote }}
|
||||
resources:
|
||||
{{- toYaml .Values.sensor.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: agent-bin
|
||||
mountPath: /opt/attune/agent
|
||||
readOnly: true
|
||||
- name: config
|
||||
mountPath: /opt/attune/config.yaml
|
||||
subPath: config.yaml
|
||||
- name: packs
|
||||
mountPath: /opt/attune/packs
|
||||
readOnly: true
|
||||
- name: runtime-envs
|
||||
mountPath: /opt/attune/runtime_envs
|
||||
volumes:
|
||||
- name: agent-bin
|
||||
emptyDir: {}
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ include "attune.fullname" . }}-config
|
||||
|
||||
@@ -108,8 +108,8 @@ images:
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
sensor:
|
||||
repository: attune-sensor
|
||||
tag: ""
|
||||
repository: nikolaik/python-nodejs
|
||||
tag: python3.12-nodejs22-slim
|
||||
pullPolicy: IfNotPresent
|
||||
notifier:
|
||||
repository: attune-notifier
|
||||
@@ -166,6 +166,8 @@ worker:
|
||||
|
||||
sensor:
|
||||
replicaCount: 1
|
||||
runtimes: shell,python,node,native
|
||||
logLevel: debug
|
||||
resources: {}
|
||||
|
||||
notifier:
|
||||
|
||||
@@ -70,7 +70,7 @@ jsonschema = { workspace = true }
|
||||
# HTTP client
|
||||
reqwest = { workspace = true }
|
||||
openidconnect = "4.0"
|
||||
ldap3 = "0.12"
|
||||
ldap3 = { version = "0.12", default-features = false, features = ["sync", "tls-rustls-ring"] }
|
||||
url = { workspace = true }
|
||||
|
||||
# Archive/compression
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
use attune_common::{
|
||||
config::LdapConfig,
|
||||
repositories::{
|
||||
identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput},
|
||||
identity::{
|
||||
CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository,
|
||||
UpdateIdentityInput,
|
||||
},
|
||||
Create, Update,
|
||||
},
|
||||
};
|
||||
@@ -63,6 +66,11 @@ pub async fn authenticate(
|
||||
|
||||
// Upsert identity in DB and issue JWT tokens
|
||||
let identity = upsert_identity(state, &claims).await?;
|
||||
if identity.frozen {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Identity is frozen and cannot authenticate".to_string(),
|
||||
));
|
||||
}
|
||||
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
|
||||
@@ -351,10 +359,13 @@ async fn upsert_identity(
|
||||
display_name,
|
||||
password_hash: None,
|
||||
attributes: Some(attributes),
|
||||
frozen: None,
|
||||
};
|
||||
IdentityRepository::update(&state.db, identity.id, updated)
|
||||
let identity = IdentityRepository::update(&state.db, identity.id, updated)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
.map_err(ApiError::from)?;
|
||||
sync_roles(&state.db, identity.id, "ldap", &claims.groups).await?;
|
||||
Ok(identity)
|
||||
}
|
||||
None => {
|
||||
// Avoid login collisions
|
||||
@@ -363,7 +374,7 @@ async fn upsert_identity(
|
||||
None => desired_login,
|
||||
};
|
||||
|
||||
IdentityRepository::create(
|
||||
let identity = IdentityRepository::create(
|
||||
&state.db,
|
||||
CreateIdentityInput {
|
||||
login,
|
||||
@@ -372,10 +383,23 @@ async fn upsert_identity(
|
||||
attributes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
sync_roles(&state.db, identity.id, "ldap", &claims.groups).await?;
|
||||
Ok(identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_roles(
|
||||
db: &sqlx::PgPool,
|
||||
identity_id: i64,
|
||||
source: &str,
|
||||
roles: &[String],
|
||||
) -> Result<(), ApiError> {
|
||||
IdentityRoleAssignmentRepository::replace_managed_roles(db, identity_id, source, roles)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the login name from LDAP claims.
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
use attune_common::{
|
||||
config::OidcConfig,
|
||||
repositories::{
|
||||
identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput},
|
||||
identity::{
|
||||
CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository,
|
||||
UpdateIdentityInput,
|
||||
},
|
||||
Create, Update,
|
||||
},
|
||||
};
|
||||
@@ -282,6 +285,11 @@ pub async fn handle_callback(
|
||||
}
|
||||
|
||||
let identity = upsert_identity(state, &oidc_claims).await?;
|
||||
if identity.frozen {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Identity is frozen and cannot authenticate".to_string(),
|
||||
));
|
||||
}
|
||||
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
|
||||
@@ -511,10 +519,13 @@ async fn upsert_identity(
|
||||
display_name,
|
||||
password_hash: None,
|
||||
attributes: Some(attributes.clone()),
|
||||
frozen: None,
|
||||
};
|
||||
IdentityRepository::update(&state.db, identity.id, updated)
|
||||
let identity = IdentityRepository::update(&state.db, identity.id, updated)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
.map_err(ApiError::from)?;
|
||||
sync_roles(&state.db, identity.id, "oidc", &oidc_claims.groups).await?;
|
||||
Ok(identity)
|
||||
}
|
||||
None => {
|
||||
let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? {
|
||||
@@ -522,7 +533,7 @@ async fn upsert_identity(
|
||||
None => desired_login,
|
||||
};
|
||||
|
||||
IdentityRepository::create(
|
||||
let identity = IdentityRepository::create(
|
||||
&state.db,
|
||||
CreateIdentityInput {
|
||||
login,
|
||||
@@ -531,10 +542,23 @@ async fn upsert_identity(
|
||||
attributes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
sync_roles(&state.db, identity.id, "oidc", &oidc_claims.groups).await?;
|
||||
Ok(identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_roles(
|
||||
db: &sqlx::PgPool,
|
||||
identity_id: i64,
|
||||
source: &str,
|
||||
roles: &[String],
|
||||
) -> Result<(), ApiError> {
|
||||
IdentityRoleAssignmentRepository::replace_managed_roles(db, identity_id, source, roles)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_login(oidc_claims: &OidcIdentityClaims) -> String {
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{
|
||||
use attune_common::{
|
||||
rbac::{Action, AuthorizationContext, Grant, Resource},
|
||||
repositories::{
|
||||
identity::{IdentityRepository, PermissionSetRepository},
|
||||
identity::{IdentityRepository, IdentityRoleAssignmentRepository, PermissionSetRepository},
|
||||
FindById,
|
||||
},
|
||||
};
|
||||
@@ -95,8 +95,16 @@ impl AuthorizationService {
|
||||
}
|
||||
|
||||
async fn load_effective_grants(&self, identity_id: i64) -> Result<Vec<Grant>, ApiError> {
|
||||
let permission_sets =
|
||||
let mut permission_sets =
|
||||
PermissionSetRepository::find_by_identity(&self.db, identity_id).await?;
|
||||
let roles =
|
||||
IdentityRoleAssignmentRepository::find_role_names_by_identity(&self.db, identity_id)
|
||||
.await?;
|
||||
let role_permission_sets = PermissionSetRepository::find_by_roles(&self.db, &roles).await?;
|
||||
permission_sets.extend(role_permission_sets);
|
||||
|
||||
let mut seen_permission_sets = std::collections::HashSet::new();
|
||||
permission_sets.retain(|permission_set| seen_permission_sets.insert(permission_set.id));
|
||||
|
||||
let mut grants = Vec::new();
|
||||
for permission_set in permission_sets {
|
||||
@@ -126,10 +134,6 @@ fn resource_name(resource: Resource) -> &'static str {
|
||||
Resource::Inquiries => "inquiries",
|
||||
Resource::Keys => "keys",
|
||||
Resource::Artifacts => "artifacts",
|
||||
Resource::Workflows => "workflows",
|
||||
Resource::Webhooks => "webhooks",
|
||||
Resource::Analytics => "analytics",
|
||||
Resource::History => "history",
|
||||
Resource::Identities => "identities",
|
||||
Resource::Permissions => "permissions",
|
||||
}
|
||||
@@ -145,5 +149,6 @@ fn action_name(action: Action) -> &'static str {
|
||||
Action::Cancel => "cancel",
|
||||
Action::Respond => "respond",
|
||||
Action::Manage => "manage",
|
||||
Action::Decrypt => "decrypt",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,8 @@ pub struct CreateActionRequest {
|
||||
pub label: String,
|
||||
|
||||
/// Action description
|
||||
#[validate(length(min = 1))]
|
||||
#[schema(example = "Posts a message to a Slack channel")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Entry point for action execution (e.g., path to script, function name)
|
||||
#[validate(length(min = 1, max = 1024))]
|
||||
@@ -63,7 +62,6 @@ pub struct UpdateActionRequest {
|
||||
pub label: Option<String>,
|
||||
|
||||
/// Action description
|
||||
#[validate(length(min = 1))]
|
||||
#[schema(example = "Posts a message to a Slack channel with enhanced features")]
|
||||
pub description: Option<String>,
|
||||
|
||||
@@ -121,7 +119,7 @@ pub struct ActionResponse {
|
||||
|
||||
/// Action description
|
||||
#[schema(example = "Posts a message to a Slack channel")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Entry point
|
||||
#[schema(example = "/actions/slack/post_message.py")]
|
||||
@@ -183,7 +181,7 @@ pub struct ActionSummary {
|
||||
|
||||
/// Action description
|
||||
#[schema(example = "Posts a message to a Slack channel")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Entry point
|
||||
#[schema(example = "/actions/slack/post_message.py")]
|
||||
@@ -321,7 +319,7 @@ mod tests {
|
||||
r#ref: "".to_string(), // Invalid: empty
|
||||
pack_ref: "test-pack".to_string(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test description".to_string(),
|
||||
description: Some("Test description".to_string()),
|
||||
entrypoint: "/actions/test.py".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
@@ -338,7 +336,7 @@ mod tests {
|
||||
r#ref: "test.action".to_string(),
|
||||
pack_ref: "test-pack".to_string(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test description".to_string(),
|
||||
description: Some("Test description".to_string()),
|
||||
entrypoint: "/actions/test.py".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -51,9 +51,10 @@ pub use inquiry::{
|
||||
pub use key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest};
|
||||
pub use pack::{CreatePackRequest, PackResponse, PackSummary, UpdatePackRequest};
|
||||
pub use permission::{
|
||||
CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse, IdentitySummary,
|
||||
PermissionAssignmentResponse, PermissionSetQueryParams, PermissionSetSummary,
|
||||
UpdateIdentityRequest,
|
||||
CreateIdentityRequest, CreateIdentityRoleAssignmentRequest, CreatePermissionAssignmentRequest,
|
||||
CreatePermissionSetRoleAssignmentRequest, IdentityResponse, IdentityRoleAssignmentResponse,
|
||||
IdentitySummary, PermissionAssignmentResponse, PermissionSetQueryParams,
|
||||
PermissionSetRoleAssignmentResponse, PermissionSetSummary, UpdateIdentityRequest,
|
||||
};
|
||||
pub use rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest};
|
||||
pub use runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest};
|
||||
|
||||
@@ -14,10 +14,32 @@ pub struct IdentitySummary {
|
||||
pub id: i64,
|
||||
pub login: String,
|
||||
pub display_name: Option<String>,
|
||||
pub frozen: bool,
|
||||
pub attributes: JsonValue,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
pub type IdentityResponse = IdentitySummary;
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct IdentityRoleAssignmentResponse {
|
||||
pub id: i64,
|
||||
pub identity_id: i64,
|
||||
pub role: String,
|
||||
pub source: String,
|
||||
pub managed: bool,
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
pub updated: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct IdentityResponse {
|
||||
pub id: i64,
|
||||
pub login: String,
|
||||
pub display_name: Option<String>,
|
||||
pub frozen: bool,
|
||||
pub attributes: JsonValue,
|
||||
pub roles: Vec<IdentityRoleAssignmentResponse>,
|
||||
pub direct_permissions: Vec<PermissionAssignmentResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct PermissionSetSummary {
|
||||
@@ -27,6 +49,7 @@ pub struct PermissionSetSummary {
|
||||
pub label: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub grants: JsonValue,
|
||||
pub roles: Vec<PermissionSetRoleAssignmentResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -38,6 +61,15 @@ pub struct PermissionAssignmentResponse {
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct PermissionSetRoleAssignmentResponse {
|
||||
pub id: i64,
|
||||
pub permission_set_id: i64,
|
||||
pub permission_set_ref: Option<String>,
|
||||
pub role: String,
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct CreatePermissionAssignmentRequest {
|
||||
pub identity_id: Option<i64>,
|
||||
@@ -45,6 +77,18 @@ pub struct CreatePermissionAssignmentRequest {
|
||||
pub permission_set_ref: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateIdentityRoleAssignmentRequest {
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreatePermissionSetRoleAssignmentRequest {
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateIdentityRequest {
|
||||
#[validate(length(min = 3, max = 255))]
|
||||
@@ -62,4 +106,5 @@ pub struct UpdateIdentityRequest {
|
||||
pub display_name: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub attributes: Option<JsonValue>,
|
||||
pub frozen: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -25,9 +25,8 @@ pub struct CreateRuleRequest {
|
||||
pub label: String,
|
||||
|
||||
/// Rule description
|
||||
#[validate(length(min = 1))]
|
||||
#[schema(example = "Send Slack notification when an error occurs")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Action reference to execute when rule matches
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
@@ -69,7 +68,6 @@ pub struct UpdateRuleRequest {
|
||||
pub label: Option<String>,
|
||||
|
||||
/// Rule description
|
||||
#[validate(length(min = 1))]
|
||||
#[schema(example = "Enhanced error notification with filtering")]
|
||||
pub description: Option<String>,
|
||||
|
||||
@@ -115,7 +113,7 @@ pub struct RuleResponse {
|
||||
|
||||
/// Rule description
|
||||
#[schema(example = "Send Slack notification when an error occurs")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Action ID (null if the referenced action has been deleted)
|
||||
#[schema(example = 1)]
|
||||
@@ -183,7 +181,7 @@ pub struct RuleSummary {
|
||||
|
||||
/// Rule description
|
||||
#[schema(example = "Send Slack notification when an error occurs")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Action reference
|
||||
#[schema(example = "slack.post_message")]
|
||||
@@ -297,7 +295,7 @@ mod tests {
|
||||
r#ref: "".to_string(), // Invalid: empty
|
||||
pack_ref: "test-pack".to_string(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test description".to_string(),
|
||||
description: Some("Test description".to_string()),
|
||||
action_ref: "test.action".to_string(),
|
||||
trigger_ref: "test.trigger".to_string(),
|
||||
conditions: default_empty_object(),
|
||||
@@ -315,7 +313,7 @@ mod tests {
|
||||
r#ref: "test.rule".to_string(),
|
||||
pack_ref: "test-pack".to_string(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test description".to_string(),
|
||||
description: Some("Test description".to_string()),
|
||||
action_ref: "test.action".to_string(),
|
||||
trigger_ref: "test.trigger".to_string(),
|
||||
conditions: serde_json::json!({
|
||||
|
||||
@@ -203,9 +203,8 @@ pub struct CreateSensorRequest {
|
||||
pub label: String,
|
||||
|
||||
/// Sensor description
|
||||
#[validate(length(min = 1))]
|
||||
#[schema(example = "Monitors CPU usage and generates events")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Entry point for sensor execution (e.g., path to script, function name)
|
||||
#[validate(length(min = 1, max = 1024))]
|
||||
@@ -247,7 +246,6 @@ pub struct UpdateSensorRequest {
|
||||
pub label: Option<String>,
|
||||
|
||||
/// Sensor description
|
||||
#[validate(length(min = 1))]
|
||||
#[schema(example = "Enhanced CPU monitoring with alerts")]
|
||||
pub description: Option<String>,
|
||||
|
||||
@@ -297,7 +295,7 @@ pub struct SensorResponse {
|
||||
|
||||
/// Sensor description
|
||||
#[schema(example = "Monitors CPU usage and generates events")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Entry point
|
||||
#[schema(example = "/sensors/monitoring/cpu_monitor.py")]
|
||||
@@ -357,7 +355,7 @@ pub struct SensorSummary {
|
||||
|
||||
/// Sensor description
|
||||
#[schema(example = "Monitors CPU usage and generates events")]
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Trigger reference
|
||||
#[schema(example = "monitoring.cpu_threshold")]
|
||||
@@ -499,7 +497,7 @@ mod tests {
|
||||
r#ref: "test.sensor".to_string(),
|
||||
pack_ref: "test-pack".to_string(),
|
||||
label: "Test Sensor".to_string(),
|
||||
description: "Test description".to_string(),
|
||||
description: Some("Test description".to_string()),
|
||||
entrypoint: "/sensors/test.py".to_string(),
|
||||
runtime_ref: "python3".to_string(),
|
||||
trigger_ref: "test.trigger".to_string(),
|
||||
|
||||
@@ -27,8 +27,11 @@ use crate::dto::{
|
||||
UpdatePackRequest, WorkflowSyncResult,
|
||||
},
|
||||
permission::{
|
||||
CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse,
|
||||
IdentitySummary, PermissionAssignmentResponse, PermissionSetSummary, UpdateIdentityRequest,
|
||||
CreateIdentityRequest, CreateIdentityRoleAssignmentRequest,
|
||||
CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest,
|
||||
IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary,
|
||||
PermissionAssignmentResponse, PermissionSetRoleAssignmentResponse, PermissionSetSummary,
|
||||
UpdateIdentityRequest,
|
||||
},
|
||||
rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest},
|
||||
runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest},
|
||||
@@ -185,6 +188,12 @@ use crate::dto::{
|
||||
crate::routes::permissions::list_identity_permissions,
|
||||
crate::routes::permissions::create_permission_assignment,
|
||||
crate::routes::permissions::delete_permission_assignment,
|
||||
crate::routes::permissions::create_identity_role_assignment,
|
||||
crate::routes::permissions::delete_identity_role_assignment,
|
||||
crate::routes::permissions::create_permission_set_role_assignment,
|
||||
crate::routes::permissions::delete_permission_set_role_assignment,
|
||||
crate::routes::permissions::freeze_identity,
|
||||
crate::routes::permissions::unfreeze_identity,
|
||||
|
||||
// Workflows
|
||||
crate::routes::workflows::list_workflows,
|
||||
@@ -277,6 +286,10 @@ use crate::dto::{
|
||||
PermissionSetSummary,
|
||||
PermissionAssignmentResponse,
|
||||
CreatePermissionAssignmentRequest,
|
||||
CreateIdentityRoleAssignmentRequest,
|
||||
IdentityRoleAssignmentResponse,
|
||||
CreatePermissionSetRoleAssignmentRequest,
|
||||
PermissionSetRoleAssignmentResponse,
|
||||
|
||||
// Runtime DTOs
|
||||
CreateRuntimeRequest,
|
||||
|
||||
@@ -277,7 +277,7 @@ pub async fn update_action(
|
||||
// Create update input
|
||||
let update_input = UpdateActionInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
description: request.description.map(Patch::Set),
|
||||
entrypoint: request.entrypoint,
|
||||
runtime: request.runtime,
|
||||
runtime_version_constraint: request.runtime_version_constraint.map(|patch| match patch {
|
||||
|
||||
@@ -40,7 +40,8 @@ use attune_common::repositories::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
auth::{jwt::TokenType, middleware::AuthenticatedUser, middleware::RequireAuth},
|
||||
authz::{AuthorizationCheck, AuthorizationService},
|
||||
dto::{
|
||||
artifact::{
|
||||
AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactExecutionPatch,
|
||||
@@ -55,6 +56,7 @@ use crate::{
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
use attune_common::rbac::{Action, AuthorizationContext, Resource};
|
||||
|
||||
// ============================================================================
|
||||
// Artifact CRUD
|
||||
@@ -72,7 +74,7 @@ use crate::{
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_artifacts(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<ArtifactQueryParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -88,8 +90,16 @@ pub async fn list_artifacts(
|
||||
};
|
||||
|
||||
let result = ArtifactRepository::search(&state.db, &filters).await?;
|
||||
let mut rows = result.rows;
|
||||
|
||||
let items: Vec<ArtifactSummary> = result.rows.into_iter().map(ArtifactSummary::from).collect();
|
||||
if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
|
||||
rows.retain(|artifact| {
|
||||
let ctx = artifact_authorization_context(identity_id, artifact);
|
||||
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
|
||||
});
|
||||
}
|
||||
|
||||
let items: Vec<ArtifactSummary> = rows.into_iter().map(ArtifactSummary::from).collect();
|
||||
|
||||
let pagination = PaginationParams {
|
||||
page: query.page,
|
||||
@@ -113,7 +123,7 @@ pub async fn list_artifacts(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -121,6 +131,10 @@ pub async fn get_artifact(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(ArtifactResponse::from(artifact))),
|
||||
@@ -140,7 +154,7 @@ pub async fn get_artifact(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_artifact_by_ref(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(artifact_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -148,6 +162,10 @@ pub async fn get_artifact_by_ref(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(ArtifactResponse::from(artifact))),
|
||||
@@ -168,7 +186,7 @@ pub async fn get_artifact_by_ref(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateArtifactRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -200,6 +218,16 @@ pub async fn create_artifact(
|
||||
}
|
||||
});
|
||||
|
||||
authorize_artifact_create(
|
||||
&state,
|
||||
&user,
|
||||
&request.r#ref,
|
||||
request.scope,
|
||||
&request.owner,
|
||||
visibility,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let input = CreateArtifactInput {
|
||||
r#ref: request.r#ref,
|
||||
scope: request.scope,
|
||||
@@ -240,16 +268,18 @@ pub async fn create_artifact(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<UpdateArtifactRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify artifact exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
|
||||
|
||||
let input = UpdateArtifactInput {
|
||||
r#ref: None, // Ref is immutable after creation
|
||||
scope: request.scope,
|
||||
@@ -305,7 +335,7 @@ pub async fn update_artifact(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -313,6 +343,8 @@ pub async fn delete_artifact(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Delete, &artifact).await?;
|
||||
|
||||
// Before deleting DB rows, clean up any file-backed versions on disk
|
||||
let file_versions =
|
||||
ArtifactVersionRepository::find_file_versions_by_artifact(&state.db, id).await?;
|
||||
@@ -355,11 +387,17 @@ pub async fn delete_artifact(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_artifacts_by_execution(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(execution_id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?;
|
||||
let mut artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?;
|
||||
if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
|
||||
artifacts.retain(|artifact| {
|
||||
let ctx = artifact_authorization_context(identity_id, artifact);
|
||||
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
|
||||
});
|
||||
}
|
||||
let items: Vec<ArtifactSummary> = artifacts.into_iter().map(ArtifactSummary::from).collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::new(items))))
|
||||
@@ -387,7 +425,7 @@ pub async fn list_artifacts_by_execution(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn append_progress(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<AppendProgressRequest>,
|
||||
@@ -396,6 +434,8 @@ pub async fn append_progress(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
|
||||
|
||||
if artifact.r#type != ArtifactType::Progress {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.",
|
||||
@@ -430,16 +470,18 @@ pub async fn append_progress(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn set_artifact_data(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<SetDataRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
|
||||
|
||||
let updated = ArtifactRepository::set_data(&state.db, id, &request.data).await?;
|
||||
|
||||
Ok((
|
||||
@@ -468,15 +510,19 @@ pub async fn set_artifact_data(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_versions(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify artifact exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let versions = ArtifactVersionRepository::list_by_artifact(&state.db, id).await?;
|
||||
let items: Vec<ArtifactVersionSummary> = versions
|
||||
.into_iter()
|
||||
@@ -502,15 +548,19 @@ pub async fn list_versions(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, version)): Path<(i64, i32)>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify artifact exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
@@ -536,14 +586,18 @@ pub async fn get_version(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_latest_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let ver = ArtifactVersionRepository::find_latest(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("No versions found for artifact {}", id)))?;
|
||||
@@ -568,15 +622,17 @@ pub async fn get_latest_version(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_version_json(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<CreateVersionJsonRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
|
||||
|
||||
let input = CreateArtifactVersionInput {
|
||||
artifact: id,
|
||||
content_type: Some(
|
||||
@@ -624,7 +680,7 @@ pub async fn create_version_json(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_version_file(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<CreateFileVersionRequest>,
|
||||
@@ -633,6 +689,8 @@ pub async fn create_version_file(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
|
||||
|
||||
// Validate this is a file-type artifact
|
||||
if !is_file_backed_type(artifact.r#type) {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
@@ -726,15 +784,17 @@ pub async fn create_version_file(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn upload_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
mut multipart: Multipart,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
|
||||
|
||||
let mut file_data: Option<Vec<u8>> = None;
|
||||
let mut content_type: Option<String> = None;
|
||||
let mut meta: Option<serde_json::Value> = None;
|
||||
@@ -854,7 +914,7 @@ pub async fn upload_version(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn download_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, version)): Path<(i64, i32)>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -862,6 +922,10 @@ pub async fn download_version(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
// First try without content (cheaper query) to check for file_path
|
||||
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
|
||||
.await?
|
||||
@@ -904,7 +968,7 @@ pub async fn download_version(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn download_latest(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -912,6 +976,10 @@ pub async fn download_latest(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
// First try without content (cheaper query) to check for file_path
|
||||
let ver = ArtifactVersionRepository::find_latest(&state.db, id)
|
||||
.await?
|
||||
@@ -955,7 +1023,7 @@ pub async fn download_latest(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, version)): Path<(i64, i32)>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
@@ -964,6 +1032,8 @@ pub async fn delete_version(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Delete, &artifact).await?;
|
||||
|
||||
// Find the version by artifact + version number
|
||||
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
|
||||
.await?
|
||||
@@ -1042,7 +1112,7 @@ pub async fn delete_version(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn upload_version_by_ref(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(artifact_ref): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
@@ -1157,6 +1227,8 @@ pub async fn upload_version_by_ref(
|
||||
// Upsert: find existing artifact or create a new one
|
||||
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
|
||||
Some(existing) => {
|
||||
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
|
||||
|
||||
// Update execution link if a new execution ID was provided
|
||||
if execution_id.is_some() && execution_id != existing.execution {
|
||||
let update_input = UpdateArtifactInput {
|
||||
@@ -1211,6 +1283,16 @@ pub async fn upload_version_by_ref(
|
||||
}
|
||||
};
|
||||
|
||||
authorize_artifact_create(
|
||||
&state,
|
||||
&user,
|
||||
&artifact_ref,
|
||||
a_scope,
|
||||
owner.as_deref().unwrap_or_default(),
|
||||
a_visibility,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Parse retention
|
||||
let a_retention_policy: RetentionPolicyType = match &retention_policy {
|
||||
Some(rp) if !rp.is_empty() => {
|
||||
@@ -1297,7 +1379,7 @@ pub async fn upload_version_by_ref(
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn allocate_file_version_by_ref(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(artifact_ref): Path<String>,
|
||||
Json(request): Json<AllocateFileVersionByRefRequest>,
|
||||
@@ -1305,6 +1387,8 @@ pub async fn allocate_file_version_by_ref(
|
||||
// Upsert: find existing artifact or create a new one
|
||||
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
|
||||
Some(existing) => {
|
||||
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
|
||||
|
||||
// Update execution link if a new execution ID was provided
|
||||
if request.execution.is_some() && request.execution != existing.execution {
|
||||
let update_input = UpdateArtifactInput {
|
||||
@@ -1347,6 +1431,16 @@ pub async fn allocate_file_version_by_ref(
|
||||
.unwrap_or(RetentionPolicyType::Versions);
|
||||
let a_retention_limit = request.retention_limit.unwrap_or(10);
|
||||
|
||||
authorize_artifact_create(
|
||||
&state,
|
||||
&user,
|
||||
&artifact_ref,
|
||||
a_scope,
|
||||
request.owner.as_deref().unwrap_or_default(),
|
||||
a_visibility,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let create_input = CreateArtifactInput {
|
||||
r#ref: artifact_ref.clone(),
|
||||
scope: a_scope,
|
||||
@@ -1437,6 +1531,105 @@ pub async fn allocate_file_version_by_ref(
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
async fn authorize_artifact_action(
|
||||
state: &Arc<AppState>,
|
||||
user: &AuthenticatedUser,
|
||||
action: Action,
|
||||
artifact: &attune_common::models::artifact::Artifact,
|
||||
) -> Result<(), ApiError> {
|
||||
if user.claims.token_type != TokenType::Access {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let identity_id = user
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
let authz = AuthorizationService::new(state.db.clone());
|
||||
authz
|
||||
.authorize(
|
||||
user,
|
||||
AuthorizationCheck {
|
||||
resource: Resource::Artifacts,
|
||||
action,
|
||||
context: artifact_authorization_context(identity_id, artifact),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authorize_artifact_create(
|
||||
state: &Arc<AppState>,
|
||||
user: &AuthenticatedUser,
|
||||
artifact_ref: &str,
|
||||
scope: OwnerType,
|
||||
owner: &str,
|
||||
visibility: ArtifactVisibility,
|
||||
) -> Result<(), ApiError> {
|
||||
if user.claims.token_type != TokenType::Access {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let identity_id = user
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
let authz = AuthorizationService::new(state.db.clone());
|
||||
let mut ctx = AuthorizationContext::new(identity_id);
|
||||
ctx.target_ref = Some(artifact_ref.to_string());
|
||||
ctx.owner_type = Some(scope);
|
||||
ctx.owner_ref = Some(owner.to_string());
|
||||
ctx.visibility = Some(visibility);
|
||||
|
||||
authz
|
||||
.authorize(
|
||||
user,
|
||||
AuthorizationCheck {
|
||||
resource: Resource::Artifacts,
|
||||
action: Action::Create,
|
||||
context: ctx,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_can_read_any_artifact(
|
||||
state: &Arc<AppState>,
|
||||
user: &AuthenticatedUser,
|
||||
) -> Result<Option<(i64, Vec<attune_common::rbac::Grant>)>, ApiError> {
|
||||
if user.claims.token_type != TokenType::Access {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let identity_id = user
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
let authz = AuthorizationService::new(state.db.clone());
|
||||
let grants = authz.effective_grants(user).await?;
|
||||
|
||||
let can_read_any_artifact = grants
|
||||
.iter()
|
||||
.any(|g| g.resource == Resource::Artifacts && g.actions.contains(&Action::Read));
|
||||
if !can_read_any_artifact {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Insufficient permissions: artifacts:read".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some((identity_id, grants)))
|
||||
}
|
||||
|
||||
fn artifact_authorization_context(
|
||||
identity_id: i64,
|
||||
artifact: &attune_common::models::artifact::Artifact,
|
||||
) -> AuthorizationContext {
|
||||
let mut ctx = AuthorizationContext::new(identity_id);
|
||||
ctx.target_id = Some(artifact.id);
|
||||
ctx.target_ref = Some(artifact.r#ref.clone());
|
||||
ctx.owner_type = Some(artifact.scope);
|
||||
ctx.owner_ref = Some(artifact.owner.clone());
|
||||
ctx.visibility = Some(artifact.visibility);
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Returns true for artifact types that should use file-backed storage on disk.
|
||||
fn is_file_backed_type(artifact_type: ArtifactType) -> bool {
|
||||
matches!(
|
||||
@@ -1775,14 +1968,19 @@ pub async fn stream_artifact(
|
||||
let token = params.token.as_ref().ok_or(ApiError::Unauthorized(
|
||||
"Missing authentication token".to_string(),
|
||||
))?;
|
||||
validate_token(token, &state.jwt_config)
|
||||
let claims = validate_token(token, &state.jwt_config)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid authentication token".to_string()))?;
|
||||
let user = AuthenticatedUser { claims };
|
||||
|
||||
// --- resolve artifact + latest version ---------------------------------
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
authorize_artifact_action(&state, &user, Action::Read, &artifact)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
if !is_file_backed_type(artifact.r#type) {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Artifact '{}' is type {:?} which is not file-backed. \
|
||||
|
||||
@@ -169,6 +169,12 @@ pub async fn login(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?;
|
||||
|
||||
if identity.frozen {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Identity is frozen and cannot authenticate".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if identity has a password set
|
||||
let password_hash = identity
|
||||
.password_hash
|
||||
@@ -324,6 +330,12 @@ pub async fn refresh_token(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?;
|
||||
|
||||
if identity.frozen {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Identity is frozen and cannot authenticate".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
@@ -380,6 +392,12 @@ pub async fn get_current_user(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?;
|
||||
|
||||
if identity.frozen {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Identity is frozen and cannot authenticate".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let response = CurrentUserResponse {
|
||||
id: identity.id,
|
||||
login: identity.login,
|
||||
@@ -551,6 +569,7 @@ pub async fn change_password(
|
||||
display_name: None,
|
||||
password_hash: Some(new_password_hash),
|
||||
attributes: None,
|
||||
frozen: None,
|
||||
};
|
||||
|
||||
IdentityRepository::update(&state.db, identity_id, update_input).await?;
|
||||
|
||||
@@ -82,6 +82,17 @@ pub async fn create_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<CreateEventRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Only sensor and execution tokens may create events directly.
|
||||
// User sessions must go through the webhook receiver instead.
|
||||
use crate::auth::jwt::TokenType;
|
||||
if user.0.claims.token_type == TokenType::Access {
|
||||
return Err(ApiError::Forbidden(
|
||||
"Events may only be created by sensor services. To fire an event as a user, \
|
||||
enable webhooks on the trigger and POST to its webhook URL."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate request
|
||||
payload
|
||||
.validate()
|
||||
@@ -128,7 +139,6 @@ pub async fn create_event(
|
||||
};
|
||||
|
||||
// Determine source (sensor) from authenticated user if it's a sensor token
|
||||
use crate::auth::jwt::TokenType;
|
||||
let (source_id, source_ref) = match user.0.claims.token_type {
|
||||
TokenType::Sensor => {
|
||||
// Extract sensor reference from login
|
||||
|
||||
@@ -93,19 +93,6 @@ pub async fn create_execution(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut execution_ctx = AuthorizationContext::new(identity_id);
|
||||
execution_ctx.pack_ref = Some(action.pack_ref.clone());
|
||||
authz
|
||||
.authorize(
|
||||
&user,
|
||||
AuthorizationCheck {
|
||||
resource: Resource::Executions,
|
||||
action: Action::Create,
|
||||
context: execution_ctx,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Create execution input
|
||||
|
||||
@@ -120,12 +120,16 @@ pub async fn get_key(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
|
||||
|
||||
if user.0.claims.token_type == TokenType::Access {
|
||||
// For encrypted keys, track whether this caller is permitted to see the value.
|
||||
// Non-Access tokens (sensor, execution) always get full access.
|
||||
let can_decrypt = if user.0.claims.token_type == TokenType::Access {
|
||||
let identity_id = user
|
||||
.0
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
let authz = AuthorizationService::new(state.db.clone());
|
||||
|
||||
// Basic read check — hide behind 404 to prevent enumeration.
|
||||
authz
|
||||
.authorize(
|
||||
&user.0,
|
||||
@@ -136,19 +140,43 @@ pub async fn get_key(
|
||||
},
|
||||
)
|
||||
.await
|
||||
// Hide unauthorized records behind 404 to reduce enumeration leakage.
|
||||
.map_err(|_| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
|
||||
}
|
||||
|
||||
// Decrypt value if encrypted
|
||||
// For encrypted keys, separately check Keys::Decrypt.
|
||||
// Failing this is not an error — we just return the value as null.
|
||||
if key.encrypted {
|
||||
let encryption_key = state
|
||||
authz
|
||||
.authorize(
|
||||
&user.0,
|
||||
AuthorizationCheck {
|
||||
resource: Resource::Keys,
|
||||
action: Action::Decrypt,
|
||||
context: key_authorization_context(identity_id, &key),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
// Decrypt value if encrypted and caller has permission.
|
||||
// If they lack Keys::Decrypt, return null rather than the ciphertext.
|
||||
if key.encrypted {
|
||||
if can_decrypt {
|
||||
let encryption_key =
|
||||
state
|
||||
.config
|
||||
.security
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
ApiError::InternalServerError("Encryption key not configured on server".to_string())
|
||||
ApiError::InternalServerError(
|
||||
"Encryption key not configured on server".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let decrypted_value = attune_common::crypto::decrypt_json(&key.value, encryption_key)
|
||||
@@ -158,6 +186,9 @@ pub async fn get_key(
|
||||
})?;
|
||||
|
||||
key.value = decrypted_value;
|
||||
} else {
|
||||
key.value = serde_json::Value::Null;
|
||||
}
|
||||
}
|
||||
|
||||
let response = ApiResponse::new(KeyResponse::from(key));
|
||||
@@ -195,6 +226,7 @@ pub async fn create_key(
|
||||
let mut ctx = AuthorizationContext::new(identity_id);
|
||||
ctx.owner_identity_id = request.owner_identity;
|
||||
ctx.owner_type = Some(request.owner_type);
|
||||
ctx.owner_ref = requested_key_owner_ref(&request);
|
||||
ctx.encrypted = Some(request.encrypted);
|
||||
ctx.target_ref = Some(request.r#ref.clone());
|
||||
|
||||
@@ -541,6 +573,38 @@ fn key_authorization_context(identity_id: i64, key: &Key) -> AuthorizationContex
|
||||
ctx.target_ref = Some(key.r#ref.clone());
|
||||
ctx.owner_identity_id = key.owner_identity;
|
||||
ctx.owner_type = Some(key.owner_type);
|
||||
ctx.owner_ref = key_owner_ref(
|
||||
key.owner_type,
|
||||
key.owner.as_deref(),
|
||||
key.owner_pack_ref.as_deref(),
|
||||
key.owner_action_ref.as_deref(),
|
||||
key.owner_sensor_ref.as_deref(),
|
||||
);
|
||||
ctx.encrypted = Some(key.encrypted);
|
||||
ctx
|
||||
}
|
||||
|
||||
fn requested_key_owner_ref(request: &CreateKeyRequest) -> Option<String> {
|
||||
key_owner_ref(
|
||||
request.owner_type,
|
||||
request.owner.as_deref(),
|
||||
request.owner_pack_ref.as_deref(),
|
||||
request.owner_action_ref.as_deref(),
|
||||
request.owner_sensor_ref.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn key_owner_ref(
|
||||
owner_type: OwnerType,
|
||||
owner: Option<&str>,
|
||||
owner_pack_ref: Option<&str>,
|
||||
owner_action_ref: Option<&str>,
|
||||
owner_sensor_ref: Option<&str>,
|
||||
) -> Option<String> {
|
||||
match owner_type {
|
||||
OwnerType::Pack => owner_pack_ref.map(str::to_string),
|
||||
OwnerType::Action => owner_action_ref.map(str::to_string),
|
||||
OwnerType::Sensor => owner_sensor_ref.map(str::to_string),
|
||||
_ => owner.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
use attune_common::{
|
||||
models::identity::{Identity, PermissionSet},
|
||||
models::identity::{Identity, IdentityRoleAssignment},
|
||||
rbac::{Action, AuthorizationContext, Resource},
|
||||
repositories::{
|
||||
identity::{
|
||||
CreateIdentityInput, CreatePermissionAssignmentInput, IdentityRepository,
|
||||
PermissionAssignmentRepository, PermissionSetRepository, UpdateIdentityInput,
|
||||
CreateIdentityInput, CreateIdentityRoleAssignmentInput,
|
||||
CreatePermissionAssignmentInput, CreatePermissionSetRoleAssignmentInput,
|
||||
IdentityRepository, IdentityRoleAssignmentRepository, PermissionAssignmentRepository,
|
||||
PermissionSetRepository, PermissionSetRoleAssignmentRepository, UpdateIdentityInput,
|
||||
},
|
||||
Create, Delete, FindById, FindByRef, List, Update,
|
||||
},
|
||||
@@ -26,9 +28,12 @@ use crate::{
|
||||
authz::{AuthorizationCheck, AuthorizationService},
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
ApiResponse, CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse,
|
||||
IdentitySummary, PermissionAssignmentResponse, PermissionSetQueryParams,
|
||||
PermissionSetSummary, SuccessResponse, UpdateIdentityRequest,
|
||||
ApiResponse, CreateIdentityRequest, CreateIdentityRoleAssignmentRequest,
|
||||
CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest,
|
||||
IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary,
|
||||
PermissionAssignmentResponse, PermissionSetQueryParams,
|
||||
PermissionSetRoleAssignmentResponse, PermissionSetSummary, SuccessResponse,
|
||||
UpdateIdentityRequest,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
@@ -58,16 +63,22 @@ pub async fn list_identities(
|
||||
let page_items = if start >= identities.len() {
|
||||
Vec::new()
|
||||
} else {
|
||||
identities[start..end]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(IdentitySummary::from)
|
||||
.collect()
|
||||
identities[start..end].to_vec()
|
||||
};
|
||||
|
||||
let mut summaries = Vec::with_capacity(page_items.len());
|
||||
for identity in page_items {
|
||||
let role_assignments =
|
||||
IdentityRoleAssignmentRepository::find_by_identity(&state.db, identity.id).await?;
|
||||
let roles = role_assignments.into_iter().map(|ra| ra.role).collect();
|
||||
let mut summary = IdentitySummary::from(identity);
|
||||
summary.roles = roles;
|
||||
summaries.push(summary);
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(PaginatedResponse::new(page_items, &query, total)),
|
||||
Json(PaginatedResponse::new(summaries, &query, total)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -94,10 +105,42 @@ pub async fn get_identity(
|
||||
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
|
||||
let roles = IdentityRoleAssignmentRepository::find_by_identity(&state.db, identity_id).await?;
|
||||
let assignments =
|
||||
PermissionAssignmentRepository::find_by_identity(&state.db, identity_id).await?;
|
||||
let permission_sets = PermissionSetRepository::find_by_identity(&state.db, identity_id).await?;
|
||||
let permission_set_refs = permission_sets
|
||||
.into_iter()
|
||||
.map(|ps| (ps.id, ps.r#ref))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(IdentityResponse::from(identity))),
|
||||
Json(ApiResponse::new(IdentityResponse {
|
||||
id: identity.id,
|
||||
login: identity.login,
|
||||
display_name: identity.display_name,
|
||||
frozen: identity.frozen,
|
||||
attributes: identity.attributes,
|
||||
roles: roles
|
||||
.into_iter()
|
||||
.map(IdentityRoleAssignmentResponse::from)
|
||||
.collect(),
|
||||
direct_permissions: assignments
|
||||
.into_iter()
|
||||
.filter_map(|assignment| {
|
||||
permission_set_refs.get(&assignment.permset).cloned().map(
|
||||
|permission_set_ref| PermissionAssignmentResponse {
|
||||
id: assignment.id,
|
||||
identity_id: assignment.identity,
|
||||
permission_set_id: assignment.permset,
|
||||
permission_set_ref,
|
||||
created: assignment.created,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -180,6 +223,7 @@ pub async fn update_identity(
|
||||
display_name: request.display_name,
|
||||
password_hash,
|
||||
attributes: request.attributes,
|
||||
frozen: request.frozen,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -257,10 +301,33 @@ pub async fn list_permission_sets(
|
||||
permission_sets.retain(|ps| ps.pack_ref.as_deref() == Some(pack_ref.as_str()));
|
||||
}
|
||||
|
||||
let response: Vec<PermissionSetSummary> = permission_sets
|
||||
let mut response = Vec::with_capacity(permission_sets.len());
|
||||
for permission_set in permission_sets {
|
||||
let permission_set_ref = permission_set.r#ref.clone();
|
||||
let roles = PermissionSetRoleAssignmentRepository::find_by_permission_set(
|
||||
&state.db,
|
||||
permission_set.id,
|
||||
)
|
||||
.await?;
|
||||
response.push(PermissionSetSummary {
|
||||
id: permission_set.id,
|
||||
r#ref: permission_set.r#ref,
|
||||
pack_ref: permission_set.pack_ref,
|
||||
label: permission_set.label,
|
||||
description: permission_set.description,
|
||||
grants: permission_set.grants,
|
||||
roles: roles
|
||||
.into_iter()
|
||||
.map(PermissionSetSummary::from)
|
||||
.collect();
|
||||
.map(|assignment| PermissionSetRoleAssignmentResponse {
|
||||
id: assignment.id,
|
||||
permission_set_id: assignment.permset,
|
||||
permission_set_ref: Some(permission_set_ref.clone()),
|
||||
role: assignment.role,
|
||||
created: assignment.created,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
@@ -412,6 +479,229 @@ pub async fn delete_permission_assignment(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/identities/{id}/roles",
|
||||
tag = "permissions",
|
||||
params(
|
||||
("id" = i64, Path, description = "Identity ID")
|
||||
),
|
||||
request_body = CreateIdentityRoleAssignmentRequest,
|
||||
responses(
|
||||
(status = 201, description = "Identity role assignment created", body = inline(ApiResponse<IdentityRoleAssignmentResponse>)),
|
||||
(status = 404, description = "Identity not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_identity_role_assignment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(identity_id): Path<i64>,
|
||||
Json(request): Json<CreateIdentityRoleAssignmentRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
|
||||
request.validate()?;
|
||||
|
||||
IdentityRepository::find_by_id(&state.db, identity_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
|
||||
|
||||
let assignment = IdentityRoleAssignmentRepository::create(
|
||||
&state.db,
|
||||
CreateIdentityRoleAssignmentInput {
|
||||
identity: identity_id,
|
||||
role: request.role,
|
||||
source: "manual".to_string(),
|
||||
managed: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ApiResponse::new(IdentityRoleAssignmentResponse::from(
|
||||
assignment,
|
||||
))),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/identities/roles/{id}",
|
||||
tag = "permissions",
|
||||
params(
|
||||
("id" = i64, Path, description = "Identity role assignment ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Identity role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
|
||||
(status = 404, description = "Identity role assignment not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_identity_role_assignment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(assignment_id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
|
||||
|
||||
let assignment = IdentityRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!(
|
||||
"Identity role assignment '{}' not found",
|
||||
assignment_id
|
||||
))
|
||||
})?;
|
||||
|
||||
if assignment.managed {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Managed role assignments must be updated through the identity provider sync"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
IdentityRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(SuccessResponse::new(
|
||||
"Identity role assignment deleted successfully",
|
||||
))),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/permissions/sets/{id}/roles",
|
||||
tag = "permissions",
|
||||
params(
|
||||
("id" = i64, Path, description = "Permission set ID")
|
||||
),
|
||||
request_body = CreatePermissionSetRoleAssignmentRequest,
|
||||
responses(
|
||||
(status = 201, description = "Permission set role assignment created", body = inline(ApiResponse<PermissionSetRoleAssignmentResponse>)),
|
||||
(status = 404, description = "Permission set not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_permission_set_role_assignment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(permission_set_id): Path<i64>,
|
||||
Json(request): Json<CreatePermissionSetRoleAssignmentRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
|
||||
request.validate()?;
|
||||
|
||||
let permission_set = PermissionSetRepository::find_by_id(&state.db, permission_set_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Permission set '{}' not found", permission_set_id))
|
||||
})?;
|
||||
|
||||
let assignment = PermissionSetRoleAssignmentRepository::create(
|
||||
&state.db,
|
||||
CreatePermissionSetRoleAssignmentInput {
|
||||
permset: permission_set_id,
|
||||
role: request.role,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ApiResponse::new(PermissionSetRoleAssignmentResponse {
|
||||
id: assignment.id,
|
||||
permission_set_id: assignment.permset,
|
||||
permission_set_ref: Some(permission_set.r#ref),
|
||||
role: assignment.role,
|
||||
created: assignment.created,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/permissions/sets/roles/{id}",
|
||||
tag = "permissions",
|
||||
params(
|
||||
("id" = i64, Path, description = "Permission set role assignment ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Permission set role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
|
||||
(status = 404, description = "Permission set role assignment not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_permission_set_role_assignment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(assignment_id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
|
||||
|
||||
PermissionSetRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!(
|
||||
"Permission set role assignment '{}' not found",
|
||||
assignment_id
|
||||
))
|
||||
})?;
|
||||
|
||||
PermissionSetRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(SuccessResponse::new(
|
||||
"Permission set role assignment deleted successfully",
|
||||
))),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/identities/{id}/freeze",
|
||||
tag = "permissions",
|
||||
params(
|
||||
("id" = i64, Path, description = "Identity ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Identity frozen", body = inline(ApiResponse<SuccessResponse>)),
|
||||
(status = 404, description = "Identity not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn freeze_identity(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(identity_id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
set_identity_frozen(&state, &user, identity_id, true).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/identities/{id}/unfreeze",
|
||||
tag = "permissions",
|
||||
params(
|
||||
("id" = i64, Path, description = "Identity ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Identity unfrozen", body = inline(ApiResponse<SuccessResponse>)),
|
||||
(status = 404, description = "Identity not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn unfreeze_identity(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(identity_id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
set_identity_frozen(&state, &user, identity_id, false).await
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/identities", get(list_identities).post(create_identity))
|
||||
@@ -421,11 +711,29 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.put(update_identity)
|
||||
.delete(delete_identity),
|
||||
)
|
||||
.route(
|
||||
"/identities/{id}/roles",
|
||||
post(create_identity_role_assignment),
|
||||
)
|
||||
.route(
|
||||
"/identities/{id}/permissions",
|
||||
get(list_identity_permissions),
|
||||
)
|
||||
.route("/identities/{id}/freeze", post(freeze_identity))
|
||||
.route("/identities/{id}/unfreeze", post(unfreeze_identity))
|
||||
.route(
|
||||
"/identities/roles/{id}",
|
||||
delete(delete_identity_role_assignment),
|
||||
)
|
||||
.route("/permissions/sets", get(list_permission_sets))
|
||||
.route(
|
||||
"/permissions/sets/{id}/roles",
|
||||
post(create_permission_set_role_assignment),
|
||||
)
|
||||
.route(
|
||||
"/permissions/sets/roles/{id}",
|
||||
delete(delete_permission_set_role_assignment),
|
||||
)
|
||||
.route(
|
||||
"/permissions/assignments",
|
||||
post(create_permission_assignment),
|
||||
@@ -488,20 +796,82 @@ impl From<Identity> for IdentitySummary {
|
||||
id: value.id,
|
||||
login: value.login,
|
||||
display_name: value.display_name,
|
||||
frozen: value.frozen,
|
||||
attributes: value.attributes,
|
||||
roles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PermissionSet> for PermissionSetSummary {
|
||||
fn from(value: PermissionSet) -> Self {
|
||||
impl From<IdentityRoleAssignment> for IdentityRoleAssignmentResponse {
|
||||
fn from(value: IdentityRoleAssignment) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
r#ref: value.r#ref,
|
||||
pack_ref: value.pack_ref,
|
||||
label: value.label,
|
||||
description: value.description,
|
||||
grants: value.grants,
|
||||
identity_id: value.identity,
|
||||
role: value.role,
|
||||
source: value.source,
|
||||
managed: value.managed,
|
||||
created: value.created,
|
||||
updated: value.updated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Identity> for IdentityResponse {
|
||||
fn from(value: Identity) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
login: value.login,
|
||||
display_name: value.display_name,
|
||||
frozen: value.frozen,
|
||||
attributes: value.attributes,
|
||||
roles: Vec::new(),
|
||||
direct_permissions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_identity_frozen(
|
||||
state: &Arc<AppState>,
|
||||
user: &crate::auth::middleware::AuthenticatedUser,
|
||||
identity_id: i64,
|
||||
frozen: bool,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
authorize_permissions(state, user, Resource::Identities, Action::Update).await?;
|
||||
|
||||
let caller_identity_id = user
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
if caller_identity_id == identity_id && frozen {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Refusing to freeze the currently authenticated identity".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
IdentityRepository::find_by_id(&state.db, identity_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
|
||||
|
||||
IdentityRepository::update(
|
||||
&state.db,
|
||||
identity_id,
|
||||
UpdateIdentityInput {
|
||||
display_name: None,
|
||||
password_hash: None,
|
||||
attributes: None,
|
||||
frozen: Some(frozen),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message = if frozen {
|
||||
"Identity frozen successfully"
|
||||
} else {
|
||||
"Identity unfrozen successfully"
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(SuccessResponse::new(message))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use attune_common::repositories::{
|
||||
pack::PackRepository,
|
||||
rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput},
|
||||
trigger::TriggerRepository,
|
||||
Create, Delete, FindByRef, Update,
|
||||
Create, Delete, FindByRef, Patch, Update,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -474,7 +474,7 @@ pub async fn update_rule(
|
||||
// Create update input
|
||||
let update_input = UpdateRuleInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
description: request.description.map(Patch::Set),
|
||||
conditions: request.conditions,
|
||||
action_params: request.action_params,
|
||||
trigger_params: request.trigger_params,
|
||||
|
||||
@@ -176,6 +176,7 @@ pub async fn create_runtime(
|
||||
pack_ref,
|
||||
description: request.description,
|
||||
name: request.name,
|
||||
aliases: vec![],
|
||||
distributions: request.distributions,
|
||||
installation: request.installation,
|
||||
execution_config: request.execution_config,
|
||||
|
||||
@@ -724,7 +724,7 @@ pub async fn update_sensor(
|
||||
// Create update input
|
||||
let update_input = UpdateSensorInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
description: request.description.map(Patch::Set),
|
||||
entrypoint: request.entrypoint,
|
||||
runtime: None,
|
||||
runtime_ref: None,
|
||||
|
||||
@@ -20,8 +20,11 @@ use attune_common::{
|
||||
},
|
||||
};
|
||||
|
||||
use attune_common::rbac::{Action, AuthorizationContext, Resource};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
authz::{AuthorizationCheck, AuthorizationService},
|
||||
dto::{
|
||||
trigger::TriggerResponse,
|
||||
webhook::{WebhookReceiverRequest, WebhookReceiverResponse},
|
||||
@@ -170,7 +173,7 @@ fn get_webhook_config_array(
|
||||
)]
|
||||
pub async fn enable_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
@@ -179,6 +182,26 @@ pub async fn enable_webhook(
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
|
||||
let identity_id = user
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
let authz = AuthorizationService::new(state.db.clone());
|
||||
let mut ctx = AuthorizationContext::new(identity_id);
|
||||
ctx.target_ref = Some(trigger.r#ref.clone());
|
||||
ctx.pack_ref = trigger.pack_ref.clone();
|
||||
authz
|
||||
.authorize(
|
||||
&user,
|
||||
AuthorizationCheck {
|
||||
resource: Resource::Triggers,
|
||||
action: Action::Update,
|
||||
context: ctx,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Enable webhooks for this trigger
|
||||
let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id)
|
||||
.await
|
||||
@@ -213,7 +236,7 @@ pub async fn enable_webhook(
|
||||
)]
|
||||
pub async fn disable_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
@@ -222,6 +245,26 @@ pub async fn disable_webhook(
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
|
||||
let identity_id = user
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
let authz = AuthorizationService::new(state.db.clone());
|
||||
let mut ctx = AuthorizationContext::new(identity_id);
|
||||
ctx.target_ref = Some(trigger.r#ref.clone());
|
||||
ctx.pack_ref = trigger.pack_ref.clone();
|
||||
authz
|
||||
.authorize(
|
||||
&user,
|
||||
AuthorizationCheck {
|
||||
resource: Resource::Triggers,
|
||||
action: Action::Update,
|
||||
context: ctx,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Disable webhooks for this trigger
|
||||
TriggerRepository::disable_webhook(&state.db, trigger.id)
|
||||
.await
|
||||
@@ -257,7 +300,7 @@ pub async fn disable_webhook(
|
||||
)]
|
||||
pub async fn regenerate_webhook_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
@@ -266,6 +309,26 @@ pub async fn regenerate_webhook_key(
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
|
||||
let identity_id = user
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
||||
let authz = AuthorizationService::new(state.db.clone());
|
||||
let mut ctx = AuthorizationContext::new(identity_id);
|
||||
ctx.target_ref = Some(trigger.r#ref.clone());
|
||||
ctx.pack_ref = trigger.pack_ref.clone();
|
||||
authz
|
||||
.authorize(
|
||||
&user,
|
||||
AuthorizationCheck {
|
||||
resource: Resource::Triggers,
|
||||
action: Action::Update,
|
||||
context: ctx,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Check if webhooks are enabled
|
||||
if !trigger.webhook_enabled {
|
||||
return Err(ApiError::BadRequest(
|
||||
|
||||
@@ -18,7 +18,7 @@ use attune_common::repositories::{
|
||||
CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository,
|
||||
WorkflowSearchFilters,
|
||||
},
|
||||
Create, Delete, FindByRef, Update,
|
||||
Create, Delete, FindByRef, Patch, Update,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -217,7 +217,7 @@ pub async fn create_workflow(
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&request.label,
|
||||
&request.description.clone().unwrap_or_default(),
|
||||
request.description.as_deref(),
|
||||
"workflow",
|
||||
request.param_schema.as_ref(),
|
||||
request.out_schema.as_ref(),
|
||||
@@ -416,7 +416,7 @@ pub async fn save_workflow_file(
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&request.label,
|
||||
&request.description.clone().unwrap_or_default(),
|
||||
request.description.as_deref(),
|
||||
&entrypoint,
|
||||
request.param_schema.as_ref(),
|
||||
request.out_schema.as_ref(),
|
||||
@@ -499,7 +499,7 @@ pub async fn update_workflow_file(
|
||||
pack.id,
|
||||
&pack.r#ref,
|
||||
&request.label,
|
||||
&request.description.unwrap_or_default(),
|
||||
request.description.as_deref(),
|
||||
&entrypoint,
|
||||
request.param_schema.as_ref(),
|
||||
request.out_schema.as_ref(),
|
||||
@@ -702,7 +702,7 @@ async fn create_companion_action(
|
||||
pack_id: i64,
|
||||
pack_ref: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
description: Option<&str>,
|
||||
entrypoint: &str,
|
||||
param_schema: Option<&serde_json::Value>,
|
||||
out_schema: Option<&serde_json::Value>,
|
||||
@@ -713,7 +713,7 @@ async fn create_companion_action(
|
||||
pack: pack_id,
|
||||
pack_ref: pack_ref.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
description: description.map(|s| s.to_string()),
|
||||
entrypoint: entrypoint.to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
@@ -787,7 +787,7 @@ async fn update_companion_action(
|
||||
if let Some(action) = existing_action {
|
||||
let update_input = UpdateActionInput {
|
||||
label: label.map(|s| s.to_string()),
|
||||
description: description.map(|s| s.to_string()),
|
||||
description: description.map(|s| Patch::Set(s.to_string())),
|
||||
entrypoint: None,
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
@@ -838,7 +838,7 @@ async fn ensure_companion_action(
|
||||
pack_id: i64,
|
||||
pack_ref: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
description: Option<&str>,
|
||||
entrypoint: &str,
|
||||
param_schema: Option<&serde_json::Value>,
|
||||
out_schema: Option<&serde_json::Value>,
|
||||
@@ -853,7 +853,10 @@ async fn ensure_companion_action(
|
||||
// Update existing companion action
|
||||
let update_input = UpdateActionInput {
|
||||
label: Some(label.to_string()),
|
||||
description: Some(description.to_string()),
|
||||
description: Some(match description {
|
||||
Some(description) => Patch::Set(description.to_string()),
|
||||
None => Patch::Clear,
|
||||
}),
|
||||
entrypoint: Some(entrypoint.to_string()),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -362,7 +362,7 @@ mod tests {
|
||||
pack: 1,
|
||||
pack_ref: "test".to_string(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test action".to_string(),
|
||||
description: Some("Test action".to_string()),
|
||||
entrypoint: "test.sh".to_string(),
|
||||
runtime: Some(1),
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -241,6 +241,7 @@ impl TestContext {
|
||||
}
|
||||
|
||||
/// Create and authenticate a test user
|
||||
#[allow(dead_code)]
|
||||
pub async fn with_auth(mut self) -> Result<Self> {
|
||||
// Generate unique username to avoid conflicts in parallel tests
|
||||
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string();
|
||||
@@ -394,6 +395,7 @@ impl TestContext {
|
||||
}
|
||||
|
||||
/// Get authenticated token
|
||||
#[allow(dead_code)]
|
||||
pub fn token(&self) -> Option<&str> {
|
||||
self.token.as_deref()
|
||||
}
|
||||
@@ -495,7 +497,7 @@ pub async fn create_test_action(pool: &PgPool, pack_id: i64, ref_name: &str) ->
|
||||
pack: pack_id,
|
||||
pack_ref: format!("pack_{}", pack_id),
|
||||
label: format!("Test Action {}", ref_name),
|
||||
description: format!("Test action for {}", ref_name),
|
||||
description: Some(format!("Test action for {}", ref_name)),
|
||||
entrypoint: "main.py".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
276
crates/api/tests/rbac_scoped_resources_api_tests.rs
Normal file
276
crates/api/tests/rbac_scoped_resources_api_tests.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
use axum::http::StatusCode;
|
||||
use helpers::*;
|
||||
use serde_json::json;
|
||||
|
||||
use attune_common::{
|
||||
models::enums::{ArtifactType, ArtifactVisibility, OwnerType, RetentionPolicyType},
|
||||
repositories::{
|
||||
artifact::{ArtifactRepository, CreateArtifactInput},
|
||||
identity::{
|
||||
CreatePermissionAssignmentInput, CreatePermissionSetInput, IdentityRepository,
|
||||
PermissionAssignmentRepository, PermissionSetRepository,
|
||||
},
|
||||
key::{CreateKeyInput, KeyRepository},
|
||||
Create,
|
||||
},
|
||||
};
|
||||
|
||||
mod helpers;
|
||||
|
||||
async fn register_scoped_user(
|
||||
ctx: &TestContext,
|
||||
login: &str,
|
||||
grants: serde_json::Value,
|
||||
) -> Result<String> {
|
||||
let response = ctx
|
||||
.post(
|
||||
"/auth/register",
|
||||
json!({
|
||||
"login": login,
|
||||
"password": "TestPassword123!",
|
||||
"display_name": format!("Scoped User {}", login),
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
let token = body["data"]["access_token"]
|
||||
.as_str()
|
||||
.expect("missing access token")
|
||||
.to_string();
|
||||
|
||||
let identity = IdentityRepository::find_by_login(&ctx.pool, login)
|
||||
.await?
|
||||
.expect("registered identity should exist");
|
||||
|
||||
let permset = PermissionSetRepository::create(
|
||||
&ctx.pool,
|
||||
CreatePermissionSetInput {
|
||||
r#ref: format!("test.scoped_{}", uuid::Uuid::new_v4().simple()),
|
||||
pack: None,
|
||||
pack_ref: None,
|
||||
label: Some("Scoped Test Permission Set".to_string()),
|
||||
description: Some("Scoped test grants".to_string()),
|
||||
grants,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
PermissionAssignmentRepository::create(
|
||||
&ctx.pool,
|
||||
CreatePermissionAssignmentInput {
|
||||
identity: identity.id,
|
||||
permset: permset.id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_pack_scoped_key_permissions_enforce_owner_refs() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let token = register_scoped_user(
|
||||
&ctx,
|
||||
&format!("scoped_keys_{}", uuid::Uuid::new_v4().simple()),
|
||||
json!([
|
||||
{
|
||||
"resource": "keys",
|
||||
"actions": ["read"],
|
||||
"constraints": {
|
||||
"owner_types": ["pack"],
|
||||
"owner_refs": ["python_example"]
|
||||
}
|
||||
}
|
||||
]),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to register scoped user");
|
||||
|
||||
KeyRepository::create(
|
||||
&ctx.pool,
|
||||
CreateKeyInput {
|
||||
r#ref: format!("python_example_key_{}", uuid::Uuid::new_v4().simple()),
|
||||
owner_type: OwnerType::Pack,
|
||||
owner: Some("python_example".to_string()),
|
||||
owner_identity: None,
|
||||
owner_pack: None,
|
||||
owner_pack_ref: Some("python_example".to_string()),
|
||||
owner_action: None,
|
||||
owner_action_ref: None,
|
||||
owner_sensor: None,
|
||||
owner_sensor_ref: None,
|
||||
name: "Python Example Key".to_string(),
|
||||
encrypted: false,
|
||||
encryption_key_hash: None,
|
||||
value: json!("allowed"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create scoped key");
|
||||
|
||||
let blocked_key = KeyRepository::create(
|
||||
&ctx.pool,
|
||||
CreateKeyInput {
|
||||
r#ref: format!("other_pack_key_{}", uuid::Uuid::new_v4().simple()),
|
||||
owner_type: OwnerType::Pack,
|
||||
owner: Some("other_pack".to_string()),
|
||||
owner_identity: None,
|
||||
owner_pack: None,
|
||||
owner_pack_ref: Some("other_pack".to_string()),
|
||||
owner_action: None,
|
||||
owner_action_ref: None,
|
||||
owner_sensor: None,
|
||||
owner_sensor_ref: None,
|
||||
name: "Other Pack Key".to_string(),
|
||||
encrypted: false,
|
||||
encryption_key_hash: None,
|
||||
value: json!("blocked"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create blocked key");
|
||||
|
||||
let allowed_list = ctx
|
||||
.get("/api/v1/keys", Some(&token))
|
||||
.await
|
||||
.expect("Failed to list keys");
|
||||
assert_eq!(allowed_list.status(), StatusCode::OK);
|
||||
let allowed_body: serde_json::Value = allowed_list.json().await.expect("Invalid key list");
|
||||
assert_eq!(
|
||||
allowed_body["data"]
|
||||
.as_array()
|
||||
.expect("expected list")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(allowed_body["data"][0]["owner"], "python_example");
|
||||
|
||||
let blocked_get = ctx
|
||||
.get(&format!("/api/v1/keys/{}", blocked_key.r#ref), Some(&token))
|
||||
.await
|
||||
.expect("Failed to fetch blocked key");
|
||||
assert_eq!(blocked_get.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_pack_scoped_artifact_permissions_enforce_owner_refs() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let token = register_scoped_user(
|
||||
&ctx,
|
||||
&format!("scoped_artifacts_{}", uuid::Uuid::new_v4().simple()),
|
||||
json!([
|
||||
{
|
||||
"resource": "artifacts",
|
||||
"actions": ["read", "create"],
|
||||
"constraints": {
|
||||
"owner_types": ["pack"],
|
||||
"owner_refs": ["python_example"]
|
||||
}
|
||||
}
|
||||
]),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to register scoped user");
|
||||
|
||||
let allowed_artifact = ArtifactRepository::create(
|
||||
&ctx.pool,
|
||||
CreateArtifactInput {
|
||||
r#ref: format!("python_example.allowed_{}", uuid::Uuid::new_v4().simple()),
|
||||
scope: OwnerType::Pack,
|
||||
owner: "python_example".to_string(),
|
||||
r#type: ArtifactType::FileText,
|
||||
visibility: ArtifactVisibility::Private,
|
||||
retention_policy: RetentionPolicyType::Versions,
|
||||
retention_limit: 5,
|
||||
name: Some("Allowed Artifact".to_string()),
|
||||
description: None,
|
||||
content_type: Some("text/plain".to_string()),
|
||||
execution: None,
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create allowed artifact");
|
||||
|
||||
let blocked_artifact = ArtifactRepository::create(
|
||||
&ctx.pool,
|
||||
CreateArtifactInput {
|
||||
r#ref: format!("other_pack.blocked_{}", uuid::Uuid::new_v4().simple()),
|
||||
scope: OwnerType::Pack,
|
||||
owner: "other_pack".to_string(),
|
||||
r#type: ArtifactType::FileText,
|
||||
visibility: ArtifactVisibility::Private,
|
||||
retention_policy: RetentionPolicyType::Versions,
|
||||
retention_limit: 5,
|
||||
name: Some("Blocked Artifact".to_string()),
|
||||
description: None,
|
||||
content_type: Some("text/plain".to_string()),
|
||||
execution: None,
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create blocked artifact");
|
||||
|
||||
let allowed_get = ctx
|
||||
.get(
|
||||
&format!("/api/v1/artifacts/{}", allowed_artifact.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to fetch allowed artifact");
|
||||
assert_eq!(allowed_get.status(), StatusCode::OK);
|
||||
|
||||
let blocked_get = ctx
|
||||
.get(
|
||||
&format!("/api/v1/artifacts/{}", blocked_artifact.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to fetch blocked artifact");
|
||||
assert_eq!(blocked_get.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let create_allowed = ctx
|
||||
.post(
|
||||
"/api/v1/artifacts",
|
||||
json!({
|
||||
"ref": format!("python_example.created_{}", uuid::Uuid::new_v4().simple()),
|
||||
"scope": "pack",
|
||||
"owner": "python_example",
|
||||
"type": "file_text",
|
||||
"name": "Created Artifact"
|
||||
}),
|
||||
Some(&token),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create allowed artifact");
|
||||
assert_eq!(create_allowed.status(), StatusCode::CREATED);
|
||||
|
||||
let create_blocked = ctx
|
||||
.post(
|
||||
"/api/v1/artifacts",
|
||||
json!({
|
||||
"ref": format!("other_pack.created_{}", uuid::Uuid::new_v4().simple()),
|
||||
"scope": "pack",
|
||||
"owner": "other_pack",
|
||||
"type": "file_text",
|
||||
"name": "Blocked Artifact"
|
||||
}),
|
||||
Some(&token),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create blocked artifact");
|
||||
assert_eq!(create_blocked.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ async fn setup_test_pack_and_action(pool: &PgPool) -> Result<(Pack, Action)> {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test action for SSE tests".to_string(),
|
||||
description: Some("Test action for SSE tests".to_string()),
|
||||
entrypoint: "test.sh".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -90,7 +90,7 @@ struct Action {
|
||||
action_ref: String,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
description: Option<String>,
|
||||
entrypoint: String,
|
||||
runtime: Option<i64>,
|
||||
created: String,
|
||||
@@ -105,7 +105,7 @@ struct ActionDetail {
|
||||
pack: i64,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
description: Option<String>,
|
||||
entrypoint: String,
|
||||
runtime: Option<i64>,
|
||||
param_schema: Option<serde_json::Value>,
|
||||
@@ -253,7 +253,7 @@ async fn handle_list(
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
output::truncate(&action.description, 40),
|
||||
output::truncate(&action.description.unwrap_or_default(), 40),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -288,7 +288,10 @@ async fn handle_show(
|
||||
("Reference", action.action_ref.clone()),
|
||||
("Pack", action.pack_ref.clone()),
|
||||
("Label", action.label.clone()),
|
||||
("Description", action.description.clone()),
|
||||
(
|
||||
"Description",
|
||||
action.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Entry Point", action.entrypoint.clone()),
|
||||
(
|
||||
"Runtime",
|
||||
@@ -356,7 +359,10 @@ async fn handle_update(
|
||||
("Ref", action.action_ref.clone()),
|
||||
("Pack", action.pack_ref.clone()),
|
||||
("Label", action.label.clone()),
|
||||
("Description", action.description.clone()),
|
||||
(
|
||||
"Description",
|
||||
action.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Entrypoint", action.entrypoint.clone()),
|
||||
(
|
||||
"Runtime",
|
||||
|
||||
@@ -112,7 +112,7 @@ struct Rule {
|
||||
pack: Option<i64>,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
trigger: Option<i64>,
|
||||
trigger_ref: String,
|
||||
@@ -133,7 +133,7 @@ struct RuleDetail {
|
||||
pack: Option<i64>,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
trigger: Option<i64>,
|
||||
trigger_ref: String,
|
||||
@@ -321,7 +321,10 @@ async fn handle_show(
|
||||
("Ref", rule.rule_ref.clone()),
|
||||
("Pack", rule.pack_ref.clone()),
|
||||
("Label", rule.label.clone()),
|
||||
("Description", rule.description.clone()),
|
||||
(
|
||||
"Description",
|
||||
rule.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Trigger", rule.trigger_ref.clone()),
|
||||
("Action", rule.action_ref.clone()),
|
||||
("Enabled", output::format_bool(rule.enabled)),
|
||||
@@ -440,7 +443,10 @@ async fn handle_update(
|
||||
("Ref", rule.rule_ref.clone()),
|
||||
("Pack", rule.pack_ref.clone()),
|
||||
("Label", rule.label.clone()),
|
||||
("Description", rule.description.clone()),
|
||||
(
|
||||
"Description",
|
||||
rule.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Trigger", rule.trigger_ref.clone()),
|
||||
("Action", rule.action_ref.clone()),
|
||||
("Enabled", output::format_bool(rule.enabled)),
|
||||
|
||||
107
crates/common/src/agent_bootstrap.rs
Normal file
107
crates/common/src/agent_bootstrap.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Shared bootstrap helpers for injected agent binaries.
|
||||
|
||||
use crate::agent_runtime_detection::{
|
||||
detect_runtimes, format_as_env_value, print_detection_report_for_env, DetectedRuntime,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeBootstrapResult {
|
||||
pub runtimes_override: Option<String>,
|
||||
pub detected_runtimes: Option<Vec<DetectedRuntime>>,
|
||||
}
|
||||
|
||||
/// Detect runtimes and populate the agent runtime environment variable when needed.
|
||||
///
|
||||
/// This must run before the Tokio runtime starts because it may mutate process
|
||||
/// environment variables.
|
||||
pub fn bootstrap_runtime_env(env_var_name: &str) -> RuntimeBootstrapResult {
|
||||
let runtimes_override = std::env::var(env_var_name).ok();
|
||||
let mut detected_runtimes = None;
|
||||
|
||||
if let Some(ref override_value) = runtimes_override {
|
||||
info!(
|
||||
"{} already set (override): {}",
|
||||
env_var_name, override_value
|
||||
);
|
||||
info!("Running auto-detection for override-specified runtimes...");
|
||||
|
||||
let detected = detect_runtimes();
|
||||
let override_names: Vec<&str> = override_value.split(',').map(|s| s.trim()).collect();
|
||||
|
||||
let filtered: Vec<_> = detected
|
||||
.into_iter()
|
||||
.filter(|rt| {
|
||||
let lower_name = rt.name.to_ascii_lowercase();
|
||||
override_names
|
||||
.iter()
|
||||
.any(|ov| ov.to_ascii_lowercase() == lower_name)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
warn!(
|
||||
"None of the override runtimes ({}) were found on this system",
|
||||
override_value
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Matched {} override runtime(s) to detected interpreters:",
|
||||
filtered.len()
|
||||
);
|
||||
for rt in &filtered {
|
||||
match &rt.version {
|
||||
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
|
||||
None => info!(" ✓ {} — {}", rt.name, rt.path),
|
||||
}
|
||||
}
|
||||
detected_runtimes = Some(filtered);
|
||||
}
|
||||
} else {
|
||||
info!("No {} override — running auto-detection...", env_var_name);
|
||||
|
||||
let detected = detect_runtimes();
|
||||
|
||||
if detected.is_empty() {
|
||||
warn!("No runtimes detected! The agent may not be able to execute any work.");
|
||||
} else {
|
||||
info!("Detected {} runtime(s):", detected.len());
|
||||
for rt in &detected {
|
||||
match &rt.version {
|
||||
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
|
||||
None => info!(" ✓ {} — {}", rt.name, rt.path),
|
||||
}
|
||||
}
|
||||
|
||||
let runtime_csv = format_as_env_value(&detected);
|
||||
info!("Setting {}={}", env_var_name, runtime_csv);
|
||||
std::env::set_var(env_var_name, &runtime_csv);
|
||||
detected_runtimes = Some(detected);
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeBootstrapResult {
|
||||
runtimes_override,
|
||||
detected_runtimes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_detect_only_report(env_var_name: &str, result: &RuntimeBootstrapResult) {
|
||||
if result.runtimes_override.is_some() {
|
||||
info!("--detect-only: re-running detection to show what is available on this system...");
|
||||
println!(
|
||||
"NOTE: {} is set — auto-detection was skipped during normal startup.",
|
||||
env_var_name
|
||||
);
|
||||
println!(" Showing what auto-detection would find on this system:");
|
||||
println!();
|
||||
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report_for_env(env_var_name, &detected);
|
||||
} else if let Some(ref detected) = result.detected_runtimes {
|
||||
print_detection_report_for_env(env_var_name, detected);
|
||||
} else {
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report_for_env(env_var_name, &detected);
|
||||
}
|
||||
}
|
||||
306
crates/common/src/agent_runtime_detection.rs
Normal file
306
crates/common/src/agent_runtime_detection.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
//! Runtime auto-detection for injected Attune agent binaries.
|
||||
//!
|
||||
//! This module probes the local system directly for well-known interpreters,
|
||||
//! without requiring database access.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// A runtime interpreter discovered on the local system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectedRuntime {
|
||||
/// Canonical runtime name (for example, "python" or "node").
|
||||
pub name: String,
|
||||
|
||||
/// Absolute path to the interpreter binary.
|
||||
pub path: String,
|
||||
|
||||
/// Version string if the version command succeeded.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for DetectedRuntime {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.version {
|
||||
Some(v) => write!(f, "{} ({}, v{})", self.name, self.path, v),
|
||||
None => write!(f, "{} ({})", self.name, self.path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeCandidate {
|
||||
name: &'static str,
|
||||
binaries: &'static [&'static str],
|
||||
version_args: &'static [&'static str],
|
||||
version_parser: VersionParser,
|
||||
}
|
||||
|
||||
enum VersionParser {
|
||||
SemverLike,
|
||||
JavaStyle,
|
||||
}
|
||||
|
||||
fn candidates() -> Vec<RuntimeCandidate> {
|
||||
vec![
|
||||
RuntimeCandidate {
|
||||
name: "shell",
|
||||
binaries: &["bash", "sh"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "python",
|
||||
binaries: &["python3", "python"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "node",
|
||||
binaries: &["node", "nodejs"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "ruby",
|
||||
binaries: &["ruby"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "go",
|
||||
binaries: &["go"],
|
||||
version_args: &["version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "java",
|
||||
binaries: &["java"],
|
||||
version_args: &["-version"],
|
||||
version_parser: VersionParser::JavaStyle,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "r",
|
||||
binaries: &["Rscript"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "perl",
|
||||
binaries: &["perl"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Detect available runtimes by probing the local system.
|
||||
pub fn detect_runtimes() -> Vec<DetectedRuntime> {
|
||||
info!("Starting runtime auto-detection...");
|
||||
|
||||
let mut detected = Vec::new();
|
||||
|
||||
for candidate in candidates() {
|
||||
match detect_single_runtime(&candidate) {
|
||||
Some(runtime) => {
|
||||
info!(" ✓ Detected: {}", runtime);
|
||||
detected.push(runtime);
|
||||
}
|
||||
None => {
|
||||
debug!(" ✗ Not found: {}", candidate.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Runtime auto-detection complete: found {} runtime(s): [{}]",
|
||||
detected.len(),
|
||||
detected
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
detected
|
||||
}
|
||||
|
||||
fn detect_single_runtime(candidate: &RuntimeCandidate) -> Option<DetectedRuntime> {
|
||||
for binary in candidate.binaries {
|
||||
if let Some(path) = which_binary(binary) {
|
||||
let version = get_version(&path, candidate.version_args, &candidate.version_parser);
|
||||
|
||||
return Some(DetectedRuntime {
|
||||
name: candidate.name.to_string(),
|
||||
path,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn which_binary(binary: &str) -> Option<String> {
|
||||
if binary == "bash" || binary == "sh" {
|
||||
let absolute_path = format!("/bin/{}", binary);
|
||||
if std::path::Path::new(&absolute_path).exists() {
|
||||
return Some(absolute_path);
|
||||
}
|
||||
}
|
||||
|
||||
match Command::new("which").arg(binary).output() {
|
||||
Ok(output) if output.status.success() => {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
debug!("'which' command failed ({}), trying 'command -v'", e);
|
||||
match Command::new("sh")
|
||||
.args(["-c", &format!("command -v {}", binary)])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_version(binary_path: &str, version_args: &[&str], parser: &VersionParser) -> Option<String> {
|
||||
let output = match Command::new(binary_path).args(version_args).output() {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
debug!("Failed to run version command for {}: {}", binary_path, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
match parser {
|
||||
VersionParser::SemverLike => parse_semver_like(&combined),
|
||||
VersionParser::JavaStyle => parse_java_version(&combined),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_semver_like(output: &str) -> Option<String> {
|
||||
let re = regex::Regex::new(r"(?:v|go)?(\d+\.\d+(?:\.\d+)?)").ok()?;
|
||||
re.captures(output)
|
||||
.and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn parse_java_version(output: &str) -> Option<String> {
|
||||
let quoted_re = regex::Regex::new(r#"version\s+"([^"]+)""#).ok()?;
|
||||
if let Some(captures) = quoted_re.captures(output) {
|
||||
return captures.get(1).map(|m| m.as_str().to_string());
|
||||
}
|
||||
|
||||
parse_semver_like(output)
|
||||
}
|
||||
|
||||
pub fn format_as_env_value(runtimes: &[DetectedRuntime]) -> String {
|
||||
runtimes
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
pub fn print_detection_report_for_env(env_var_name: &str, runtimes: &[DetectedRuntime]) {
|
||||
println!("=== Attune Agent Runtime Detection Report ===");
|
||||
println!();
|
||||
|
||||
if runtimes.is_empty() {
|
||||
println!("No runtimes detected!");
|
||||
println!();
|
||||
println!("The agent could not find any supported interpreter binaries.");
|
||||
println!("Ensure at least one of the following is installed and on PATH:");
|
||||
println!(" - bash / sh (shell scripts)");
|
||||
println!(" - python3 / python (Python scripts)");
|
||||
println!(" - node / nodejs (Node.js scripts)");
|
||||
println!(" - ruby (Ruby scripts)");
|
||||
println!(" - go (Go programs)");
|
||||
println!(" - java (Java programs)");
|
||||
println!(" - Rscript (R scripts)");
|
||||
println!(" - perl (Perl scripts)");
|
||||
} else {
|
||||
println!("Detected {} runtime(s):", runtimes.len());
|
||||
println!();
|
||||
for rt in runtimes {
|
||||
let version_str = rt.version.as_deref().unwrap_or("unknown version");
|
||||
println!(" ✓ {:<10} {} ({})", rt.name, rt.path, version_str);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}={}", env_var_name, format_as_env_value(runtimes));
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_python() {
|
||||
assert_eq!(
|
||||
parse_semver_like("Python 3.12.1"),
|
||||
Some("3.12.1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_node() {
|
||||
assert_eq!(parse_semver_like("v20.11.0"), Some("20.11.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_go() {
|
||||
assert_eq!(
|
||||
parse_semver_like("go version go1.22.0 linux/amd64"),
|
||||
Some("1.22.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_java_version_openjdk() {
|
||||
assert_eq!(
|
||||
parse_java_version(r#"openjdk version "21.0.1" 2023-10-17"#),
|
||||
Some("21.0.1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_as_env_value_multiple() {
|
||||
let runtimes = vec![
|
||||
DetectedRuntime {
|
||||
name: "shell".to_string(),
|
||||
path: "/bin/bash".to_string(),
|
||||
version: Some("5.2.15".to_string()),
|
||||
},
|
||||
DetectedRuntime {
|
||||
name: "python".to_string(),
|
||||
path: "/usr/bin/python3".to_string(),
|
||||
version: Some("3.12.1".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(format_as_env_value(&runtimes), "shell,python");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! - Configuration
|
||||
//! - Utilities
|
||||
|
||||
pub mod agent_bootstrap;
|
||||
pub mod agent_runtime_detection;
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
|
||||
@@ -444,13 +444,55 @@ pub mod runtime {
|
||||
|
||||
/// Optional environment variables to set during action execution.
|
||||
///
|
||||
/// Values support the same template variables as other fields:
|
||||
/// Entries support the same template variables as other fields:
|
||||
/// `{pack_dir}`, `{env_dir}`, `{interpreter}`, `{manifest_path}`.
|
||||
///
|
||||
/// Example: `{"NODE_PATH": "{env_dir}/node_modules"}` ensures Node.js
|
||||
/// can find packages installed in the isolated runtime environment.
|
||||
/// The shorthand string form replaces the variable entirely:
|
||||
/// `{"NODE_PATH": "{env_dir}/node_modules"}`
|
||||
///
|
||||
/// The object form supports declarative merge semantics:
|
||||
/// `{"PYTHONPATH": {"value": "{pack_dir}/lib", "operation": "prepend"}}`
|
||||
#[serde(default)]
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub env_vars: HashMap<String, RuntimeEnvVarConfig>,
|
||||
}
|
||||
|
||||
/// Declarative configuration for a single runtime environment variable.
|
||||
///
|
||||
/// The string form is shorthand for `{ "value": "...", "operation": "set" }`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum RuntimeEnvVarConfig {
|
||||
Value(String),
|
||||
Spec(RuntimeEnvVarSpec),
|
||||
}
|
||||
|
||||
/// Full configuration for a runtime environment variable.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeEnvVarSpec {
|
||||
/// Template value to resolve for this variable.
|
||||
pub value: String,
|
||||
|
||||
/// How the resolved value should be merged with any existing value.
|
||||
#[serde(default)]
|
||||
pub operation: RuntimeEnvVarOperation,
|
||||
|
||||
/// Separator used for prepend/append operations.
|
||||
#[serde(default = "default_env_var_separator")]
|
||||
pub separator: String,
|
||||
}
|
||||
|
||||
/// Merge behavior for runtime-provided environment variables.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeEnvVarOperation {
|
||||
#[default]
|
||||
Set,
|
||||
Prepend,
|
||||
Append,
|
||||
}
|
||||
|
||||
fn default_env_var_separator() -> String {
|
||||
":".to_string()
|
||||
}
|
||||
|
||||
/// Controls how inline code is materialized before execution.
|
||||
@@ -768,6 +810,43 @@ pub mod runtime {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnvVarConfig {
|
||||
/// Resolve this environment variable against the current template
|
||||
/// variables and any existing value already present in the process env.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
vars: &HashMap<&str, String>,
|
||||
existing_value: Option<&str>,
|
||||
) -> String {
|
||||
match self {
|
||||
Self::Value(value) => RuntimeExecutionConfig::resolve_template(value, vars),
|
||||
Self::Spec(spec) => {
|
||||
let resolved = RuntimeExecutionConfig::resolve_template(&spec.value, vars);
|
||||
match spec.operation {
|
||||
RuntimeEnvVarOperation::Set => resolved,
|
||||
RuntimeEnvVarOperation::Prepend => {
|
||||
join_env_var_values(&resolved, existing_value, &spec.separator)
|
||||
}
|
||||
RuntimeEnvVarOperation::Append => join_env_var_values(
|
||||
existing_value.unwrap_or_default(),
|
||||
Some(&resolved),
|
||||
&spec.separator,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn join_env_var_values(left: &str, right: Option<&str>, separator: &str) -> String {
|
||||
match (left.is_empty(), right.unwrap_or_default().is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(false, true) => left.to_string(),
|
||||
(true, false) => right.unwrap_or_default().to_string(),
|
||||
(false, false) => format!("{}{}{}", left, separator, right.unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Runtime {
|
||||
pub id: Id,
|
||||
@@ -776,6 +855,7 @@ pub mod runtime {
|
||||
pub pack_ref: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub name: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub distributions: JsonDict,
|
||||
pub installation: Option<JsonDict>,
|
||||
pub installers: JsonDict,
|
||||
@@ -886,7 +966,7 @@ pub mod trigger {
|
||||
pub pack: Option<Id>,
|
||||
pub pack_ref: Option<String>,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
pub entrypoint: String,
|
||||
pub runtime: Id,
|
||||
pub runtime_ref: String,
|
||||
@@ -914,7 +994,7 @@ pub mod action {
|
||||
pub pack: Id,
|
||||
pub pack_ref: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
pub entrypoint: String,
|
||||
pub runtime: Option<Id>,
|
||||
/// Optional semver version constraint for the runtime
|
||||
@@ -964,7 +1044,7 @@ pub mod rule {
|
||||
pub pack: Id,
|
||||
pub pack_ref: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
pub action: Option<Id>,
|
||||
pub action_ref: String,
|
||||
pub trigger: Option<Id>,
|
||||
@@ -1220,6 +1300,7 @@ pub mod identity {
|
||||
pub display_name: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
pub attributes: JsonDict,
|
||||
pub frozen: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
}
|
||||
@@ -1244,6 +1325,25 @@ pub mod identity {
|
||||
pub permset: Id,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct IdentityRoleAssignment {
|
||||
pub id: Id,
|
||||
pub identity: Id,
|
||||
pub role: String,
|
||||
pub source: String,
|
||||
pub managed: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct PermissionSetRoleAssignment {
|
||||
pub id: Id,
|
||||
pub permset: Id,
|
||||
pub role: String,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
}
|
||||
|
||||
/// Key/Value storage
|
||||
@@ -1619,3 +1719,68 @@ pub mod entity_history {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::runtime::{
|
||||
RuntimeEnvVarConfig, RuntimeEnvVarOperation, RuntimeEnvVarSpec, RuntimeExecutionConfig,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn runtime_execution_config_env_vars_accept_string_and_object_forms() {
|
||||
let config: RuntimeExecutionConfig = serde_json::from_value(json!({
|
||||
"env_vars": {
|
||||
"NODE_PATH": "{env_dir}/node_modules",
|
||||
"PYTHONPATH": {
|
||||
"value": "{pack_dir}/lib",
|
||||
"operation": "prepend",
|
||||
"separator": ":"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("runtime execution config should deserialize");
|
||||
|
||||
assert!(matches!(
|
||||
config.env_vars.get("NODE_PATH"),
|
||||
Some(RuntimeEnvVarConfig::Value(value)) if value == "{env_dir}/node_modules"
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
config.env_vars.get("PYTHONPATH"),
|
||||
Some(RuntimeEnvVarConfig::Spec(RuntimeEnvVarSpec {
|
||||
value,
|
||||
operation: RuntimeEnvVarOperation::Prepend,
|
||||
separator,
|
||||
})) if value == "{pack_dir}/lib" && separator == ":"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_var_config_resolves_prepend_and_append_against_existing_values() {
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("pack_dir", "/packs/example".to_string());
|
||||
vars.insert("env_dir", "/runtime_envs/example/python".to_string());
|
||||
|
||||
let prepend = RuntimeEnvVarConfig::Spec(RuntimeEnvVarSpec {
|
||||
value: "{pack_dir}/lib".to_string(),
|
||||
operation: RuntimeEnvVarOperation::Prepend,
|
||||
separator: ":".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
prepend.resolve(&vars, Some("/already/set")),
|
||||
"/packs/example/lib:/already/set"
|
||||
);
|
||||
|
||||
let append = RuntimeEnvVarConfig::Spec(RuntimeEnvVarSpec {
|
||||
value: "{env_dir}/node_modules".to_string(),
|
||||
operation: RuntimeEnvVarOperation::Append,
|
||||
separator: ":".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
append.resolve(&vars, Some("/base/modules")),
|
||||
"/base/modules:/runtime_envs/example/python/node_modules"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +404,16 @@ impl<'a> PackComponentLoader<'a> {
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let aliases: Vec<String> = data
|
||||
.get("aliases")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_ascii_lowercase()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Check if runtime already exists — update in place if so
|
||||
if let Some(existing) = RuntimeRepository::find_by_ref(self.pool, &runtime_ref).await? {
|
||||
let update_input = UpdateRuntimeInput {
|
||||
@@ -418,6 +428,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
None => Patch::Clear,
|
||||
}),
|
||||
execution_config: Some(execution_config),
|
||||
aliases: Some(aliases),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -449,6 +460,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
distributions,
|
||||
installation,
|
||||
execution_config,
|
||||
aliases,
|
||||
auto_detected: false,
|
||||
detection_config: serde_json::json!({}),
|
||||
};
|
||||
@@ -713,8 +725,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
let description = data
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let enabled = data
|
||||
.get("enabled")
|
||||
@@ -733,7 +744,10 @@ impl<'a> PackComponentLoader<'a> {
|
||||
if let Some(existing) = TriggerRepository::find_by_ref(self.pool, &trigger_ref).await? {
|
||||
let update_input = UpdateTriggerInput {
|
||||
label: Some(label),
|
||||
description: Some(Patch::Set(description)),
|
||||
description: Some(match description {
|
||||
Some(description) => Patch::Set(description),
|
||||
None => Patch::Clear,
|
||||
}),
|
||||
enabled: Some(enabled),
|
||||
param_schema: Some(match param_schema {
|
||||
Some(value) => Patch::Set(value),
|
||||
@@ -766,7 +780,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
pack: Some(self.pack_id),
|
||||
pack_ref: Some(self.pack_ref.clone()),
|
||||
label,
|
||||
description: Some(description),
|
||||
description,
|
||||
enabled,
|
||||
param_schema,
|
||||
out_schema,
|
||||
@@ -846,8 +860,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
let description = data
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// ── Workflow file handling ──────────────────────────────────
|
||||
// If the action declares `workflow_file`, load the referenced
|
||||
@@ -864,7 +877,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
wf_path,
|
||||
&action_ref,
|
||||
&label,
|
||||
&description,
|
||||
description.as_deref().unwrap_or(""),
|
||||
&data,
|
||||
)
|
||||
.await
|
||||
@@ -944,7 +957,10 @@ impl<'a> PackComponentLoader<'a> {
|
||||
if let Some(existing) = ActionRepository::find_by_ref(self.pool, &action_ref).await? {
|
||||
let update_input = UpdateActionInput {
|
||||
label: Some(label),
|
||||
description: Some(description),
|
||||
description: Some(match description {
|
||||
Some(description) => Patch::Set(description),
|
||||
None => Patch::Clear,
|
||||
}),
|
||||
entrypoint: Some(entrypoint),
|
||||
runtime: runtime_id,
|
||||
runtime_version_constraint: Some(match runtime_version_constraint {
|
||||
@@ -1298,8 +1314,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
let description = data
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let enabled = data
|
||||
.get("enabled")
|
||||
@@ -1335,7 +1350,10 @@ impl<'a> PackComponentLoader<'a> {
|
||||
if let Some(existing) = SensorRepository::find_by_ref(self.pool, &sensor_ref).await? {
|
||||
let update_input = UpdateSensorInput {
|
||||
label: Some(label),
|
||||
description: Some(description),
|
||||
description: Some(match description {
|
||||
Some(description) => Patch::Set(description),
|
||||
None => Patch::Clear,
|
||||
}),
|
||||
entrypoint: Some(entrypoint),
|
||||
runtime: Some(sensor_runtime_id),
|
||||
runtime_ref: Some(sensor_runtime_ref.clone()),
|
||||
|
||||
@@ -21,10 +21,6 @@ pub enum Resource {
|
||||
Inquiries,
|
||||
Keys,
|
||||
Artifacts,
|
||||
Workflows,
|
||||
Webhooks,
|
||||
Analytics,
|
||||
History,
|
||||
Identities,
|
||||
Permissions,
|
||||
}
|
||||
@@ -40,6 +36,7 @@ pub enum Action {
|
||||
Cancel,
|
||||
Respond,
|
||||
Manage,
|
||||
Decrypt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -69,6 +66,8 @@ pub struct GrantConstraints {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub owner_types: Option<Vec<OwnerType>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub owner_refs: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility: Option<Vec<ArtifactVisibility>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub execution_scope: Option<ExecutionScopeConstraint>,
|
||||
@@ -99,6 +98,7 @@ pub struct AuthorizationContext {
|
||||
pub pack_ref: Option<String>,
|
||||
pub owner_identity_id: Option<Id>,
|
||||
pub owner_type: Option<OwnerType>,
|
||||
pub owner_ref: Option<String>,
|
||||
pub visibility: Option<ArtifactVisibility>,
|
||||
pub encrypted: Option<bool>,
|
||||
pub execution_owner_identity_id: Option<Id>,
|
||||
@@ -115,6 +115,7 @@ impl AuthorizationContext {
|
||||
pack_ref: None,
|
||||
owner_identity_id: None,
|
||||
owner_type: None,
|
||||
owner_ref: None,
|
||||
visibility: None,
|
||||
encrypted: None,
|
||||
execution_owner_identity_id: None,
|
||||
@@ -162,6 +163,15 @@ impl Grant {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(owner_refs) = &constraints.owner_refs {
|
||||
let Some(owner_ref) = &ctx.owner_ref else {
|
||||
return false;
|
||||
};
|
||||
if !owner_refs.contains(owner_ref) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(visibility) = &constraints.visibility {
|
||||
let Some(target_visibility) = ctx.visibility else {
|
||||
return false;
|
||||
@@ -289,4 +299,28 @@ mod tests {
|
||||
.insert("team".to_string(), json!("infra"));
|
||||
assert!(!grant.allows(Resource::Packs, Action::Read, &ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_ref_constraint_requires_exact_value_match() {
|
||||
let grant = Grant {
|
||||
resource: Resource::Artifacts,
|
||||
actions: vec![Action::Read],
|
||||
constraints: Some(GrantConstraints {
|
||||
owner_types: Some(vec![OwnerType::Pack]),
|
||||
owner_refs: Some(vec!["python_example".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
|
||||
let mut ctx = AuthorizationContext::new(1);
|
||||
ctx.owner_type = Some(OwnerType::Pack);
|
||||
ctx.owner_ref = Some("python_example".to_string());
|
||||
assert!(grant.allows(Resource::Artifacts, Action::Read, &ctx));
|
||||
|
||||
ctx.owner_ref = Some("other_pack".to_string());
|
||||
assert!(!grant.allows(Resource::Artifacts, Action::Read, &ctx));
|
||||
|
||||
ctx.owner_ref = None;
|
||||
assert!(!grant.allows(Resource::Artifacts, Action::Read, &ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub struct CreateActionInput {
|
||||
pub pack: Id,
|
||||
pub pack_ref: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
pub entrypoint: String,
|
||||
pub runtime: Option<Id>,
|
||||
pub runtime_version_constraint: Option<String>,
|
||||
@@ -64,7 +64,7 @@ pub struct CreateActionInput {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpdateActionInput {
|
||||
pub label: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub description: Option<Patch<String>>,
|
||||
pub entrypoint: Option<String>,
|
||||
pub runtime: Option<Id>,
|
||||
pub runtime_version_constraint: Option<Patch<String>>,
|
||||
@@ -210,7 +210,10 @@ impl Update for ActionRepository {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("description = ");
|
||||
query.push_bind(description);
|
||||
match description {
|
||||
Patch::Set(value) => query.push_bind(value),
|
||||
Patch::Clear => query.push_bind(Option::<String>::None),
|
||||
};
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -577,6 +577,14 @@ pub struct CreateArtifactVersionInput {
|
||||
}
|
||||
|
||||
impl ArtifactVersionRepository {
|
||||
fn select_columns_with_alias(alias: &str) -> String {
|
||||
format!(
|
||||
"{alias}.id, {alias}.artifact, {alias}.version, {alias}.content_type, \
|
||||
{alias}.size_bytes, NULL::bytea AS content, {alias}.content_json, \
|
||||
{alias}.file_path, {alias}.meta, {alias}.created_by, {alias}.created"
|
||||
)
|
||||
}
|
||||
|
||||
/// Find a version by ID (without binary content for performance)
|
||||
pub async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<ArtifactVersion>>
|
||||
where
|
||||
@@ -812,14 +820,11 @@ impl ArtifactVersionRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"SELECT av.{} \
|
||||
"SELECT {} \
|
||||
FROM artifact_version av \
|
||||
JOIN artifact a ON av.artifact = a.id \
|
||||
WHERE a.execution = $1 AND av.file_path IS NOT NULL",
|
||||
artifact_version::SELECT_COLUMNS
|
||||
.split(", ")
|
||||
.collect::<Vec<_>>()
|
||||
.join(", av.")
|
||||
Self::select_columns_with_alias("av")
|
||||
);
|
||||
sqlx::query_as::<_, ArtifactVersion>(&query)
|
||||
.bind(execution_id)
|
||||
@@ -847,3 +852,18 @@ impl ArtifactVersionRepository {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ArtifactVersionRepository;
|
||||
|
||||
#[test]
|
||||
fn aliased_select_columns_keep_null_content_expression_unqualified() {
|
||||
let columns = ArtifactVersionRepository::select_columns_with_alias("av");
|
||||
|
||||
assert!(columns.contains("av.id"));
|
||||
assert!(columns.contains("av.file_path"));
|
||||
assert!(columns.contains("NULL::bytea AS content"));
|
||||
assert!(!columns.contains("av.NULL::bytea AS content"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct UpdateIdentityInput {
|
||||
pub display_name: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
pub attributes: Option<JsonDict>,
|
||||
pub frozen: Option<bool>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -37,7 +38,7 @@ impl FindById for IdentityRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, Identity>(
|
||||
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE id = $1"
|
||||
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity WHERE id = $1"
|
||||
).bind(id).fetch_optional(executor).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +50,7 @@ impl List for IdentityRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, Identity>(
|
||||
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity ORDER BY login ASC"
|
||||
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity ORDER BY login ASC"
|
||||
).fetch_all(executor).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +63,7 @@ impl Create for IdentityRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, Identity>(
|
||||
"INSERT INTO identity (login, display_name, password_hash, attributes) VALUES ($1, $2, $3, $4) RETURNING id, login, display_name, password_hash, attributes, created, updated"
|
||||
"INSERT INTO identity (login, display_name, password_hash, attributes) VALUES ($1, $2, $3, $4) RETURNING id, login, display_name, password_hash, attributes, frozen, created, updated"
|
||||
)
|
||||
.bind(&input.login)
|
||||
.bind(&input.display_name)
|
||||
@@ -111,6 +112,13 @@ impl Update for IdentityRepository {
|
||||
query.push("attributes = ").push_bind(attributes);
|
||||
has_updates = true;
|
||||
}
|
||||
if let Some(frozen) = input.frozen {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("frozen = ").push_bind(frozen);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if !has_updates {
|
||||
// No updates requested, fetch and return existing entity
|
||||
@@ -119,7 +127,7 @@ impl Update for IdentityRepository {
|
||||
|
||||
query.push(", updated = NOW() WHERE id = ").push_bind(id);
|
||||
query.push(
|
||||
" RETURNING id, login, display_name, password_hash, attributes, created, updated",
|
||||
" RETURNING id, login, display_name, password_hash, attributes, frozen, created, updated",
|
||||
);
|
||||
|
||||
query
|
||||
@@ -156,7 +164,7 @@ impl IdentityRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, Identity>(
|
||||
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE login = $1"
|
||||
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity WHERE login = $1"
|
||||
).bind(login).fetch_optional(executor).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
@@ -169,7 +177,7 @@ impl IdentityRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, Identity>(
|
||||
"SELECT id, login, display_name, password_hash, attributes, created, updated
|
||||
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated
|
||||
FROM identity
|
||||
WHERE attributes->'oidc'->>'issuer' = $1
|
||||
AND attributes->'oidc'->>'sub' = $2",
|
||||
@@ -190,7 +198,7 @@ impl IdentityRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, Identity>(
|
||||
"SELECT id, login, display_name, password_hash, attributes, created, updated
|
||||
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated
|
||||
FROM identity
|
||||
WHERE attributes->'ldap'->>'server_url' = $1
|
||||
AND attributes->'ldap'->>'dn' = $2",
|
||||
@@ -363,6 +371,27 @@ impl PermissionSetRepository {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_by_roles<'e, E>(executor: E, roles: &[String]) -> Result<Vec<PermissionSet>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
if roles.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
sqlx::query_as::<_, PermissionSet>(
|
||||
"SELECT DISTINCT ps.id, ps.ref, ps.pack, ps.pack_ref, ps.label, ps.description, ps.grants, ps.created, ps.updated
|
||||
FROM permission_set ps
|
||||
INNER JOIN permission_set_role_assignment psra ON psra.permset = ps.id
|
||||
WHERE psra.role = ANY($1)
|
||||
ORDER BY ps.ref ASC",
|
||||
)
|
||||
.bind(roles)
|
||||
.fetch_all(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Delete permission sets belonging to a pack whose refs are NOT in the given set.
|
||||
///
|
||||
/// Used during pack reinstallation to clean up permission sets that were
|
||||
@@ -481,3 +510,231 @@ impl PermissionAssignmentRepository {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IdentityRoleAssignmentRepository;
|
||||
|
||||
impl Repository for IdentityRoleAssignmentRepository {
|
||||
type Entity = IdentityRoleAssignment;
|
||||
fn table_name() -> &'static str {
|
||||
"identity_role_assignment"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateIdentityRoleAssignmentInput {
|
||||
pub identity: Id,
|
||||
pub role: String,
|
||||
pub source: String,
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FindById for IdentityRoleAssignmentRepository {
|
||||
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, IdentityRoleAssignment>(
|
||||
"SELECT id, identity, role, source, managed, created, updated FROM identity_role_assignment WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Create for IdentityRoleAssignmentRepository {
|
||||
type CreateInput = CreateIdentityRoleAssignmentInput;
|
||||
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, IdentityRoleAssignment>(
|
||||
"INSERT INTO identity_role_assignment (identity, role, source, managed)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, identity, role, source, managed, created, updated",
|
||||
)
|
||||
.bind(input.identity)
|
||||
.bind(&input.role)
|
||||
.bind(&input.source)
|
||||
.bind(input.managed)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Delete for IdentityRoleAssignmentRepository {
|
||||
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result = sqlx::query("DELETE FROM identity_role_assignment WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityRoleAssignmentRepository {
|
||||
pub async fn find_by_identity<'e, E>(
|
||||
executor: E,
|
||||
identity_id: Id,
|
||||
) -> Result<Vec<IdentityRoleAssignment>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, IdentityRoleAssignment>(
|
||||
"SELECT id, identity, role, source, managed, created, updated
|
||||
FROM identity_role_assignment
|
||||
WHERE identity = $1
|
||||
ORDER BY role ASC",
|
||||
)
|
||||
.bind(identity_id)
|
||||
.fetch_all(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_role_names_by_identity<'e, E>(
|
||||
executor: E,
|
||||
identity_id: Id,
|
||||
) -> Result<Vec<String>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT role FROM identity_role_assignment WHERE identity = $1 ORDER BY role ASC",
|
||||
)
|
||||
.bind(identity_id)
|
||||
.fetch_all(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn replace_managed_roles<'e, E>(
|
||||
executor: E,
|
||||
identity_id: Id,
|
||||
source: &str,
|
||||
roles: &[String],
|
||||
) -> Result<()>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + Copy + 'e,
|
||||
{
|
||||
sqlx::query(
|
||||
"DELETE FROM identity_role_assignment WHERE identity = $1 AND source = $2 AND managed = true",
|
||||
)
|
||||
.bind(identity_id)
|
||||
.bind(source)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
for role in roles {
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_role_assignment (identity, role, source, managed)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT (identity, role) DO UPDATE
|
||||
SET source = EXCLUDED.source,
|
||||
managed = EXCLUDED.managed,
|
||||
updated = NOW()",
|
||||
)
|
||||
.bind(identity_id)
|
||||
.bind(role)
|
||||
.bind(source)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PermissionSetRoleAssignmentRepository;
|
||||
|
||||
impl Repository for PermissionSetRoleAssignmentRepository {
|
||||
type Entity = PermissionSetRoleAssignment;
|
||||
fn table_name() -> &'static str {
|
||||
"permission_set_role_assignment"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreatePermissionSetRoleAssignmentInput {
|
||||
pub permset: Id,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FindById for PermissionSetRoleAssignmentRepository {
|
||||
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, PermissionSetRoleAssignment>(
|
||||
"SELECT id, permset, role, created FROM permission_set_role_assignment WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Create for PermissionSetRoleAssignmentRepository {
|
||||
type CreateInput = CreatePermissionSetRoleAssignmentInput;
|
||||
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, PermissionSetRoleAssignment>(
|
||||
"INSERT INTO permission_set_role_assignment (permset, role)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, permset, role, created",
|
||||
)
|
||||
.bind(input.permset)
|
||||
.bind(&input.role)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Delete for PermissionSetRoleAssignmentRepository {
|
||||
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let result = sqlx::query("DELETE FROM permission_set_role_assignment WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionSetRoleAssignmentRepository {
|
||||
pub async fn find_by_permission_set<'e, E>(
|
||||
executor: E,
|
||||
permset_id: Id,
|
||||
) -> Result<Vec<PermissionSetRoleAssignment>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
sqlx::query_as::<_, PermissionSetRoleAssignment>(
|
||||
"SELECT id, permset, role, created
|
||||
FROM permission_set_role_assignment
|
||||
WHERE permset = $1
|
||||
ORDER BY role ASC",
|
||||
)
|
||||
.bind(permset_id)
|
||||
.fetch_all(executor)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::models::{rule::*, Id};
|
||||
use crate::{Error, Result};
|
||||
use sqlx::{Executor, Postgres, QueryBuilder};
|
||||
|
||||
use super::{Create, Delete, FindById, FindByRef, List, Repository, Update};
|
||||
use super::{Create, Delete, FindById, FindByRef, List, Patch, Repository, Update};
|
||||
|
||||
/// Filters for [`RuleRepository::list_search`].
|
||||
///
|
||||
@@ -41,7 +41,7 @@ pub struct RestoreRuleInput {
|
||||
pub pack: Id,
|
||||
pub pack_ref: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
pub action: Option<Id>,
|
||||
pub action_ref: String,
|
||||
pub trigger: Option<Id>,
|
||||
@@ -70,7 +70,7 @@ pub struct CreateRuleInput {
|
||||
pub pack: Id,
|
||||
pub pack_ref: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
pub action: Id,
|
||||
pub action_ref: String,
|
||||
pub trigger: Id,
|
||||
@@ -86,7 +86,7 @@ pub struct CreateRuleInput {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpdateRuleInput {
|
||||
pub label: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub description: Option<Patch<String>>,
|
||||
pub conditions: Option<serde_json::Value>,
|
||||
pub action_params: Option<serde_json::Value>,
|
||||
pub trigger_params: Option<serde_json::Value>,
|
||||
@@ -228,7 +228,10 @@ impl Update for RuleRepository {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("description = ");
|
||||
query.push_bind(description);
|
||||
match description {
|
||||
Patch::Set(value) => query.push_bind(value),
|
||||
Patch::Clear => query.push_bind(Option::<String>::None),
|
||||
};
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ impl Repository for RuntimeRepository {
|
||||
|
||||
/// Columns selected for all Runtime queries. Centralised here so that
|
||||
/// schema changes only need one update.
|
||||
pub const SELECT_COLUMNS: &str = "id, ref, pack, pack_ref, description, name, \
|
||||
pub const SELECT_COLUMNS: &str = "id, ref, pack, pack_ref, description, name, aliases, \
|
||||
distributions, installation, installers, execution_config, \
|
||||
auto_detected, detection_config, \
|
||||
created, updated";
|
||||
@@ -38,6 +38,7 @@ pub struct CreateRuntimeInput {
|
||||
pub pack_ref: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub name: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub distributions: JsonDict,
|
||||
pub installation: Option<JsonDict>,
|
||||
pub execution_config: JsonDict,
|
||||
@@ -50,6 +51,7 @@ pub struct CreateRuntimeInput {
|
||||
pub struct UpdateRuntimeInput {
|
||||
pub description: Option<Patch<String>>,
|
||||
pub name: Option<String>,
|
||||
pub aliases: Option<Vec<String>>,
|
||||
pub distributions: Option<JsonDict>,
|
||||
pub installation: Option<Patch<JsonDict>>,
|
||||
pub execution_config: Option<JsonDict>,
|
||||
@@ -113,10 +115,10 @@ impl Create for RuntimeRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"INSERT INTO runtime (ref, pack, pack_ref, description, name, \
|
||||
"INSERT INTO runtime (ref, pack, pack_ref, description, name, aliases, \
|
||||
distributions, installation, installers, execution_config, \
|
||||
auto_detected, detection_config) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||||
RETURNING {}",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
@@ -126,6 +128,7 @@ impl Create for RuntimeRepository {
|
||||
.bind(&input.pack_ref)
|
||||
.bind(&input.description)
|
||||
.bind(&input.name)
|
||||
.bind(&input.aliases)
|
||||
.bind(&input.distributions)
|
||||
.bind(&input.installation)
|
||||
.bind(serde_json::json!({}))
|
||||
@@ -170,6 +173,15 @@ impl Update for RuntimeRepository {
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(aliases) = &input.aliases {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("aliases = ");
|
||||
query.push_bind(aliases.as_slice());
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(distributions) = &input.distributions {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
@@ -225,7 +237,7 @@ impl Update for RuntimeRepository {
|
||||
|
||||
query.push(", updated = NOW() WHERE id = ");
|
||||
query.push_bind(id);
|
||||
query.push(&format!(" RETURNING {}", SELECT_COLUMNS));
|
||||
query.push(format!(" RETURNING {}", SELECT_COLUMNS));
|
||||
|
||||
let runtime = query
|
||||
.build_query_as::<Runtime>()
|
||||
@@ -286,6 +298,23 @@ impl RuntimeRepository {
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// Find a runtime where the given alias appears in its `aliases` array.
|
||||
/// Uses PostgreSQL's `@>` (array contains) operator with a GIN index.
|
||||
pub async fn find_by_alias<'e, E>(executor: E, alias: &str) -> Result<Option<Runtime>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"SELECT {} FROM runtime WHERE aliases @> ARRAY[$1]::text[] LIMIT 1",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
let runtime = sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(alias)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// Delete runtimes belonging to a pack whose refs are NOT in the given set.
|
||||
///
|
||||
/// Used during pack reinstallation to clean up runtimes that were removed
|
||||
|
||||
@@ -665,7 +665,7 @@ pub struct CreateSensorInput {
|
||||
pub pack: Option<Id>,
|
||||
pub pack_ref: Option<String>,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub description: Option<String>,
|
||||
pub entrypoint: String,
|
||||
pub runtime: Id,
|
||||
pub runtime_ref: String,
|
||||
@@ -681,7 +681,7 @@ pub struct CreateSensorInput {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpdateSensorInput {
|
||||
pub label: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub description: Option<Patch<String>>,
|
||||
pub entrypoint: Option<String>,
|
||||
pub runtime: Option<Id>,
|
||||
pub runtime_ref: Option<String>,
|
||||
@@ -830,7 +830,10 @@ impl Update for SensorRepository {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("description = ");
|
||||
query.push_bind(description);
|
||||
match description {
|
||||
Patch::Set(value) => query.push_bind(value),
|
||||
Patch::Clear => query.push_bind(Option::<String>::None),
|
||||
};
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
//! 2. Config file specification (medium priority)
|
||||
//! 3. Database-driven detection with verification (lowest priority)
|
||||
//!
|
||||
//! Also provides [`normalize_runtime_name`] for alias-aware runtime name
|
||||
//! comparison across the codebase (worker filters, env setup, etc.).
|
||||
//! Also provides alias-based matching functions ([`runtime_aliases_match_filter`]
|
||||
//! and [`runtime_aliases_contain`]) for comparing runtime alias lists against
|
||||
//! worker filters and capability strings. Aliases are declared per-runtime in
|
||||
//! pack manifests, so no hardcoded alias table is needed here.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::Result;
|
||||
@@ -19,51 +21,26 @@ use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Normalize a runtime name to its canonical short form.
|
||||
/// Check if a runtime's aliases overlap with a filter list.
|
||||
///
|
||||
/// This ensures that different ways of referring to the same runtime
|
||||
/// (e.g., "node", "nodejs", "node.js") all resolve to a single canonical
|
||||
/// name. Used by worker runtime filters and environment setup to match
|
||||
/// database runtime names against short filter values.
|
||||
/// The filter list comes from `ATTUNE_WORKER_RUNTIMES` (e.g., `["python", "shell"]`).
|
||||
/// A runtime matches if any of its declared aliases appear in the filter list.
|
||||
/// Comparison is case-insensitive.
|
||||
pub fn runtime_aliases_match_filter(aliases: &[String], filter: &[String]) -> bool {
|
||||
aliases.iter().any(|alias| {
|
||||
let lower_alias = alias.to_ascii_lowercase();
|
||||
filter.iter().any(|f| f.to_ascii_lowercase() == lower_alias)
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a runtime's aliases contain a specific name.
|
||||
///
|
||||
/// The canonical names mirror the alias groups in
|
||||
/// `PackComponentLoader::resolve_runtime`.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use attune_common::runtime_detection::normalize_runtime_name;
|
||||
/// assert_eq!(normalize_runtime_name("node.js"), "node");
|
||||
/// assert_eq!(normalize_runtime_name("nodejs"), "node");
|
||||
/// assert_eq!(normalize_runtime_name("Python3"), "python");
|
||||
/// assert_eq!(normalize_runtime_name("Shell"), "shell");
|
||||
/// ```
|
||||
pub fn normalize_runtime_name(name: &str) -> String {
|
||||
/// Used by the scheduler to check if a worker's capability string
|
||||
/// (e.g., "python") matches a runtime's aliases (e.g., ["python", "python3"]).
|
||||
/// Comparison is case-insensitive.
|
||||
pub fn runtime_aliases_contain(aliases: &[String], name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"node" | "nodejs" | "node.js" => "node".to_string(),
|
||||
"python" | "python3" => "python".to_string(),
|
||||
"bash" | "sh" | "shell" => "shell".to_string(),
|
||||
"native" | "builtin" | "standalone" => "native".to_string(),
|
||||
"ruby" | "rb" => "ruby".to_string(),
|
||||
"go" | "golang" => "go".to_string(),
|
||||
"java" | "jdk" | "openjdk" => "java".to_string(),
|
||||
"perl" | "perl5" => "perl".to_string(),
|
||||
"r" | "rscript" => "r".to_string(),
|
||||
_ => lower,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a runtime name matches a filter entry, supporting common aliases.
|
||||
///
|
||||
/// Both sides are lowercased and then normalized before comparison so that,
|
||||
/// e.g., a filter value of `"node"` matches a database runtime name `"Node.js"`.
|
||||
pub fn runtime_matches_filter(rt_name: &str, filter_entry: &str) -> bool {
|
||||
normalize_runtime_name(rt_name) == normalize_runtime_name(filter_entry)
|
||||
}
|
||||
|
||||
/// Check if a runtime name matches any entry in a filter list.
|
||||
pub fn runtime_in_filter(rt_name: &str, filter: &[String]) -> bool {
|
||||
filter.iter().any(|f| runtime_matches_filter(rt_name, f))
|
||||
aliases.iter().any(|a| a.to_ascii_lowercase() == lower)
|
||||
}
|
||||
|
||||
/// Runtime detection service
|
||||
@@ -335,125 +312,46 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_node_variants() {
|
||||
assert_eq!(normalize_runtime_name("node"), "node");
|
||||
assert_eq!(normalize_runtime_name("nodejs"), "node");
|
||||
assert_eq!(normalize_runtime_name("node.js"), "node");
|
||||
fn test_runtime_aliases_match_filter() {
|
||||
let aliases = vec!["python".to_string(), "python3".to_string()];
|
||||
let filter = vec!["python".to_string(), "shell".to_string()];
|
||||
assert!(runtime_aliases_match_filter(&aliases, &filter));
|
||||
|
||||
let filter_no_match = vec!["node".to_string(), "ruby".to_string()];
|
||||
assert!(!runtime_aliases_match_filter(&aliases, &filter_no_match));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_python_variants() {
|
||||
assert_eq!(normalize_runtime_name("python"), "python");
|
||||
assert_eq!(normalize_runtime_name("python3"), "python");
|
||||
fn test_runtime_aliases_match_filter_case_insensitive() {
|
||||
let aliases = vec!["Python".to_string(), "python3".to_string()];
|
||||
let filter = vec!["python".to_string()];
|
||||
assert!(runtime_aliases_match_filter(&aliases, &filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_shell_variants() {
|
||||
assert_eq!(normalize_runtime_name("shell"), "shell");
|
||||
assert_eq!(normalize_runtime_name("bash"), "shell");
|
||||
assert_eq!(normalize_runtime_name("sh"), "shell");
|
||||
fn test_runtime_aliases_match_filter_empty() {
|
||||
let aliases: Vec<String> = vec![];
|
||||
let filter = vec!["python".to_string()];
|
||||
assert!(!runtime_aliases_match_filter(&aliases, &filter));
|
||||
|
||||
let aliases = vec!["python".to_string()];
|
||||
let filter: Vec<String> = vec![];
|
||||
assert!(!runtime_aliases_match_filter(&aliases, &filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_native_variants() {
|
||||
assert_eq!(normalize_runtime_name("native"), "native");
|
||||
assert_eq!(normalize_runtime_name("builtin"), "native");
|
||||
assert_eq!(normalize_runtime_name("standalone"), "native");
|
||||
fn test_runtime_aliases_contain() {
|
||||
let aliases = vec!["ruby".to_string(), "rb".to_string()];
|
||||
assert!(runtime_aliases_contain(&aliases, "ruby"));
|
||||
assert!(runtime_aliases_contain(&aliases, "rb"));
|
||||
assert!(!runtime_aliases_contain(&aliases, "python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_ruby_variants() {
|
||||
assert_eq!(normalize_runtime_name("ruby"), "ruby");
|
||||
assert_eq!(normalize_runtime_name("rb"), "ruby");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_go_variants() {
|
||||
assert_eq!(normalize_runtime_name("go"), "go");
|
||||
assert_eq!(normalize_runtime_name("golang"), "go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_java_variants() {
|
||||
assert_eq!(normalize_runtime_name("java"), "java");
|
||||
assert_eq!(normalize_runtime_name("jdk"), "java");
|
||||
assert_eq!(normalize_runtime_name("openjdk"), "java");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_perl_variants() {
|
||||
assert_eq!(normalize_runtime_name("perl"), "perl");
|
||||
assert_eq!(normalize_runtime_name("perl5"), "perl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_r_variants() {
|
||||
assert_eq!(normalize_runtime_name("r"), "r");
|
||||
assert_eq!(normalize_runtime_name("rscript"), "r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_passthrough() {
|
||||
assert_eq!(normalize_runtime_name("custom_runtime"), "custom_runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_case_insensitive() {
|
||||
assert_eq!(normalize_runtime_name("Node"), "node");
|
||||
assert_eq!(normalize_runtime_name("NodeJS"), "node");
|
||||
assert_eq!(normalize_runtime_name("Node.js"), "node");
|
||||
assert_eq!(normalize_runtime_name("Python"), "python");
|
||||
assert_eq!(normalize_runtime_name("Python3"), "python");
|
||||
assert_eq!(normalize_runtime_name("Shell"), "shell");
|
||||
assert_eq!(normalize_runtime_name("BASH"), "shell");
|
||||
assert_eq!(normalize_runtime_name("Ruby"), "ruby");
|
||||
assert_eq!(normalize_runtime_name("Go"), "go");
|
||||
assert_eq!(normalize_runtime_name("GoLang"), "go");
|
||||
assert_eq!(normalize_runtime_name("Java"), "java");
|
||||
assert_eq!(normalize_runtime_name("JDK"), "java");
|
||||
assert_eq!(normalize_runtime_name("Perl"), "perl");
|
||||
assert_eq!(normalize_runtime_name("R"), "r");
|
||||
assert_eq!(normalize_runtime_name("Custom_Runtime"), "custom_runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_matches_filter() {
|
||||
// Node.js DB name lowercased vs worker filter "node"
|
||||
assert!(runtime_matches_filter("node.js", "node"));
|
||||
assert!(runtime_matches_filter("node", "nodejs"));
|
||||
assert!(runtime_matches_filter("nodejs", "node.js"));
|
||||
// Exact match
|
||||
assert!(runtime_matches_filter("shell", "shell"));
|
||||
// No match
|
||||
assert!(!runtime_matches_filter("python", "node"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_matches_filter_case_insensitive() {
|
||||
// Database stores capitalized names (e.g., "Node.js", "Python")
|
||||
// Worker capabilities store lowercase (e.g., "node", "python")
|
||||
assert!(runtime_matches_filter("Node.js", "node"));
|
||||
assert!(runtime_matches_filter("node", "Node.js"));
|
||||
assert!(runtime_matches_filter("Python", "python"));
|
||||
assert!(runtime_matches_filter("python", "Python"));
|
||||
assert!(runtime_matches_filter("Shell", "shell"));
|
||||
assert!(runtime_matches_filter("NODEJS", "node"));
|
||||
assert!(runtime_matches_filter("Ruby", "ruby"));
|
||||
assert!(runtime_matches_filter("ruby", "rb"));
|
||||
assert!(runtime_matches_filter("Go", "golang"));
|
||||
assert!(runtime_matches_filter("R", "rscript"));
|
||||
assert!(runtime_matches_filter("Java", "jdk"));
|
||||
assert!(runtime_matches_filter("Perl", "perl5"));
|
||||
assert!(!runtime_matches_filter("Python", "node"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_in_filter() {
|
||||
let filter = vec!["shell".to_string(), "node".to_string()];
|
||||
assert!(runtime_in_filter("shell", &filter));
|
||||
assert!(runtime_in_filter("node.js", &filter));
|
||||
assert!(runtime_in_filter("nodejs", &filter));
|
||||
assert!(!runtime_in_filter("python", &filter));
|
||||
fn test_runtime_aliases_contain_case_insensitive() {
|
||||
let aliases = vec!["ruby".to_string(), "rb".to_string()];
|
||||
assert!(runtime_aliases_contain(&aliases, "Ruby"));
|
||||
assert!(runtime_aliases_contain(&aliases, "RB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use crate::error::{Error, Result};
|
||||
use crate::repositories::action::{ActionRepository, CreateActionInput, UpdateActionInput};
|
||||
use crate::repositories::workflow::{CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput};
|
||||
use crate::repositories::Patch;
|
||||
use crate::repositories::{
|
||||
Create, Delete, FindByRef, PackRepository, Update, WorkflowDefinitionRepository,
|
||||
};
|
||||
@@ -270,7 +271,7 @@ impl WorkflowRegistrar {
|
||||
pack: pack_id,
|
||||
pack_ref: pack_ref.to_string(),
|
||||
label: effective_label.to_string(),
|
||||
description: workflow.description.clone().unwrap_or_default(),
|
||||
description: workflow.description.clone(),
|
||||
entrypoint,
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
@@ -317,7 +318,10 @@ impl WorkflowRegistrar {
|
||||
// Update the existing companion action to stay in sync
|
||||
let update_input = UpdateActionInput {
|
||||
label: Some(effective_label.to_string()),
|
||||
description: workflow.description.clone(),
|
||||
description: Some(match workflow.description.clone() {
|
||||
Some(description) => Patch::Set(description),
|
||||
None => Patch::Clear,
|
||||
}),
|
||||
entrypoint: Some(format!("workflows/{}.workflow.yaml", workflow_name)),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -66,7 +66,10 @@ async fn test_create_action_with_optional_fields() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(action.label, "Full Test Action");
|
||||
assert_eq!(action.description, "Action with all optional fields");
|
||||
assert_eq!(
|
||||
action.description,
|
||||
Some("Action with all optional fields".to_string())
|
||||
);
|
||||
assert_eq!(action.entrypoint, "custom.py");
|
||||
assert!(action.param_schema.is_some());
|
||||
assert!(action.out_schema.is_some());
|
||||
@@ -204,7 +207,9 @@ async fn test_update_action() {
|
||||
|
||||
let update = UpdateActionInput {
|
||||
label: Some("Updated Label".to_string()),
|
||||
description: Some("Updated description".to_string()),
|
||||
description: Some(attune_common::repositories::Patch::Set(
|
||||
"Updated description".to_string(),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -214,7 +219,7 @@ async fn test_update_action() {
|
||||
|
||||
assert_eq!(updated.id, action.id);
|
||||
assert_eq!(updated.label, "Updated Label");
|
||||
assert_eq!(updated.description, "Updated description");
|
||||
assert_eq!(updated.description, Some("Updated description".to_string()));
|
||||
assert_eq!(updated.entrypoint, action.entrypoint); // Unchanged
|
||||
assert!(updated.updated > original_updated);
|
||||
}
|
||||
@@ -338,7 +343,7 @@ async fn test_action_foreign_key_constraint() {
|
||||
pack: 99999,
|
||||
pack_ref: "nonexistent.pack".to_string(),
|
||||
label: "Test Action".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
entrypoint: "main.py".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -49,7 +49,7 @@ async fn test_create_enforcement_minimal() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -121,7 +121,7 @@ async fn test_create_enforcement_with_event() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -189,7 +189,7 @@ async fn test_create_enforcement_with_conditions() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -255,7 +255,7 @@ async fn test_create_enforcement_with_any_condition() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -397,7 +397,7 @@ async fn test_find_enforcement_by_id() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -471,7 +471,7 @@ async fn test_get_enforcement_by_id() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -552,7 +552,7 @@ async fn test_list_enforcements() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -624,7 +624,7 @@ async fn test_update_enforcement_status() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -690,7 +690,7 @@ async fn test_update_enforcement_status_transitions() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -769,7 +769,7 @@ async fn test_update_enforcement_payload() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -832,7 +832,7 @@ async fn test_update_enforcement_both_fields() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -896,7 +896,7 @@ async fn test_update_enforcement_no_changes() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -981,7 +981,7 @@ async fn test_delete_enforcement() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1056,7 +1056,7 @@ async fn test_find_enforcements_by_rule() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Rule 1".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1078,7 +1078,7 @@ async fn test_find_enforcements_by_rule() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Rule 2".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1149,7 +1149,7 @@ async fn test_find_enforcements_by_status() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1239,7 +1239,7 @@ async fn test_find_enforcements_by_event() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1324,7 +1324,7 @@ async fn test_delete_rule_sets_enforcement_rule_to_null() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1390,7 +1390,7 @@ async fn test_enforcement_resolved_at_lifecycle() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
|
||||
@@ -449,7 +449,7 @@ async fn test_delete_event_enforcement_retains_event_id() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
|
||||
@@ -454,7 +454,7 @@ impl ActionFixture {
|
||||
pack_ref: self.pack_ref,
|
||||
r#ref: self.r#ref,
|
||||
label: self.label,
|
||||
description: self.description,
|
||||
description: Some(self.description),
|
||||
entrypoint: self.entrypoint,
|
||||
runtime: self.runtime,
|
||||
runtime_version_constraint: None,
|
||||
@@ -961,6 +961,7 @@ impl RuntimeFixture {
|
||||
pack_ref: self.pack_ref,
|
||||
description: self.description,
|
||||
name: self.name,
|
||||
aliases: vec![],
|
||||
distributions: self.distributions,
|
||||
installation: self.installation,
|
||||
execution_config: self.execution_config,
|
||||
@@ -1087,7 +1088,7 @@ impl SensorFixture {
|
||||
pack: self.pack_id,
|
||||
pack_ref: self.pack_ref,
|
||||
label: self.label,
|
||||
description: self.description,
|
||||
description: Some(self.description),
|
||||
entrypoint: self.entrypoint,
|
||||
runtime: self.runtime_id,
|
||||
runtime_ref: self.runtime_ref,
|
||||
|
||||
@@ -219,6 +219,7 @@ async fn test_update_identity() {
|
||||
display_name: Some("Updated Name".to_string()),
|
||||
password_hash: None,
|
||||
attributes: Some(json!({"key": "updated", "new_key": "new_value"})),
|
||||
frozen: None,
|
||||
};
|
||||
|
||||
let updated = IdentityRepository::update(&pool, identity.id, update_input)
|
||||
@@ -252,6 +253,7 @@ async fn test_update_identity_partial() {
|
||||
display_name: Some("Only Display Name Changed".to_string()),
|
||||
password_hash: None,
|
||||
attributes: None,
|
||||
frozen: None,
|
||||
};
|
||||
|
||||
let updated = IdentityRepository::update(&pool, identity.id, update_input)
|
||||
@@ -274,6 +276,7 @@ async fn test_update_identity_not_found() {
|
||||
display_name: Some("Updated Name".to_string()),
|
||||
password_hash: None,
|
||||
attributes: None,
|
||||
frozen: None,
|
||||
};
|
||||
|
||||
let result = IdentityRepository::update(&pool, 999999, update_input).await;
|
||||
@@ -380,6 +383,7 @@ async fn test_identity_updated_changes_on_update() {
|
||||
display_name: Some("Updated".to_string()),
|
||||
password_hash: None,
|
||||
attributes: None,
|
||||
frozen: None,
|
||||
};
|
||||
|
||||
let updated = IdentityRepository::update(&pool, identity.id, update_input)
|
||||
|
||||
@@ -64,6 +64,7 @@ impl RuntimeFixture {
|
||||
pack_ref: None,
|
||||
description: Some(format!("Test runtime {}", seq)),
|
||||
name,
|
||||
aliases: vec![],
|
||||
distributions: json!({
|
||||
"linux": { "supported": true, "versions": ["ubuntu20.04", "ubuntu22.04"] },
|
||||
"darwin": { "supported": true, "versions": ["12", "13"] }
|
||||
@@ -95,6 +96,7 @@ impl RuntimeFixture {
|
||||
pack_ref: None,
|
||||
description: None,
|
||||
name,
|
||||
aliases: vec![],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
execution_config: json!({
|
||||
|
||||
@@ -574,6 +574,7 @@ async fn test_worker_with_runtime() {
|
||||
pack_ref: None,
|
||||
description: Some("Test runtime".to_string()),
|
||||
name: "test_runtime".to_string(),
|
||||
aliases: vec![],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
execution_config: json!({
|
||||
|
||||
@@ -8,7 +8,7 @@ mod helpers;
|
||||
use attune_common::{
|
||||
repositories::{
|
||||
rule::{CreateRuleInput, RuleRepository, UpdateRuleInput},
|
||||
Create, Delete, FindById, FindByRef, List, Update,
|
||||
Create, Delete, FindById, FindByRef, List, Patch, Update,
|
||||
},
|
||||
Error,
|
||||
};
|
||||
@@ -48,7 +48,7 @@ async fn test_create_rule() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "A test rule".to_string(),
|
||||
description: Some("A test rule".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -66,7 +66,7 @@ async fn test_create_rule() {
|
||||
assert_eq!(rule.pack, pack.id);
|
||||
assert_eq!(rule.pack_ref, pack.r#ref);
|
||||
assert_eq!(rule.label, "Test Rule");
|
||||
assert_eq!(rule.description, "A test rule");
|
||||
assert_eq!(rule.description, Some("A test rule".to_string()));
|
||||
assert_eq!(rule.action, Some(action.id));
|
||||
assert_eq!(rule.action_ref, action.r#ref);
|
||||
assert_eq!(rule.trigger, Some(trigger.id));
|
||||
@@ -105,7 +105,7 @@ async fn test_create_rule_disabled() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Disabled Rule".to_string(),
|
||||
description: "A disabled rule".to_string(),
|
||||
description: Some("A disabled rule".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -155,7 +155,7 @@ async fn test_create_rule_with_complex_conditions() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Complex Rule".to_string(),
|
||||
description: "Rule with complex conditions".to_string(),
|
||||
description: Some("Rule with complex conditions".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -200,7 +200,7 @@ async fn test_create_rule_duplicate_ref() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "First Rule".to_string(),
|
||||
description: "First".to_string(),
|
||||
description: Some("First".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -220,7 +220,7 @@ async fn test_create_rule_duplicate_ref() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Second Rule".to_string(),
|
||||
description: "Second".to_string(),
|
||||
description: Some("Second".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -274,7 +274,7 @@ async fn test_create_rule_invalid_ref_format_uppercase() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Upper Rule".to_string(),
|
||||
description: "Invalid uppercase ref".to_string(),
|
||||
description: Some("Invalid uppercase ref".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -316,7 +316,7 @@ async fn test_create_rule_invalid_ref_format_no_dot() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "No Dot Rule".to_string(),
|
||||
description: "Invalid ref without dot".to_string(),
|
||||
description: Some("Invalid ref without dot".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -362,7 +362,7 @@ async fn test_find_rule_by_id() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Find Rule".to_string(),
|
||||
description: "Rule to find".to_string(),
|
||||
description: Some("Rule to find".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -422,7 +422,7 @@ async fn test_find_rule_by_ref() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Find By Ref Rule".to_string(),
|
||||
description: "Find by ref".to_string(),
|
||||
description: Some("Find by ref".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -484,7 +484,7 @@ async fn test_list_rules() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: format!("List Rule {}", i),
|
||||
description: format!("Rule {}", i),
|
||||
description: Some(format!("Rule {}", i)),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -538,7 +538,7 @@ async fn test_list_rules_ordered_by_ref() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: name.to_string(),
|
||||
description: name.to_string(),
|
||||
description: Some(name.to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -594,7 +594,7 @@ async fn test_update_rule_label() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Original Label".to_string(),
|
||||
description: "Original".to_string(),
|
||||
description: Some("Original".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -618,7 +618,7 @@ async fn test_update_rule_label() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.label, "Updated Label");
|
||||
assert_eq!(updated.description, "Original"); // unchanged
|
||||
assert_eq!(updated.description, Some("Original".to_string())); // unchanged
|
||||
assert!(updated.updated > created.updated);
|
||||
}
|
||||
|
||||
@@ -647,7 +647,7 @@ async fn test_update_rule_description() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test".to_string(),
|
||||
description: "Old description".to_string(),
|
||||
description: Some("Old description".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -662,7 +662,7 @@ async fn test_update_rule_description() {
|
||||
let created = RuleRepository::create(&pool, input).await.unwrap();
|
||||
|
||||
let update = UpdateRuleInput {
|
||||
description: Some("New description".to_string()),
|
||||
description: Some(Patch::Set("New description".to_string())),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -670,7 +670,7 @@ async fn test_update_rule_description() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.description, "New description");
|
||||
assert_eq!(updated.description, Some("New description".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -698,7 +698,7 @@ async fn test_update_rule_conditions() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -750,7 +750,7 @@ async fn test_update_rule_enabled() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -803,7 +803,7 @@ async fn test_update_rule_multiple_fields() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Old".to_string(),
|
||||
description: "Old".to_string(),
|
||||
description: Some("Old".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -819,7 +819,7 @@ async fn test_update_rule_multiple_fields() {
|
||||
|
||||
let update = UpdateRuleInput {
|
||||
label: Some("New Label".to_string()),
|
||||
description: Some("New Description".to_string()),
|
||||
description: Some(Patch::Set("New Description".to_string())),
|
||||
conditions: Some(json!({"updated": true})),
|
||||
action_params: None,
|
||||
trigger_params: None,
|
||||
@@ -831,7 +831,7 @@ async fn test_update_rule_multiple_fields() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.label, "New Label");
|
||||
assert_eq!(updated.description, "New Description");
|
||||
assert_eq!(updated.description, Some("New Description".to_string()));
|
||||
assert_eq!(updated.conditions, json!({"updated": true}));
|
||||
assert!(!updated.enabled);
|
||||
}
|
||||
@@ -861,7 +861,7 @@ async fn test_update_rule_no_changes() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Test".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -914,7 +914,7 @@ async fn test_delete_rule() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "To Delete".to_string(),
|
||||
description: "Will be deleted".to_string(),
|
||||
description: Some("Will be deleted".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -995,7 +995,7 @@ async fn test_find_rules_by_pack() {
|
||||
pack: pack1.id,
|
||||
pack_ref: pack1.r#ref.clone(),
|
||||
label: format!("Rule {}", i),
|
||||
description: format!("Rule {}", i),
|
||||
description: Some(format!("Rule {}", i)),
|
||||
action: action1.id,
|
||||
action_ref: action1.r#ref.clone(),
|
||||
trigger: trigger1.id,
|
||||
@@ -1016,7 +1016,7 @@ async fn test_find_rules_by_pack() {
|
||||
pack: pack2.id,
|
||||
pack_ref: pack2.r#ref.clone(),
|
||||
label: "Pack2 Rule".to_string(),
|
||||
description: "Pack2".to_string(),
|
||||
description: Some("Pack2".to_string()),
|
||||
action: action2.id,
|
||||
action_ref: action2.r#ref.clone(),
|
||||
trigger: trigger2.id,
|
||||
@@ -1073,7 +1073,7 @@ async fn test_find_rules_by_action() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: format!("Action1 Rule {}", i),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action1.id,
|
||||
action_ref: action1.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1094,7 +1094,7 @@ async fn test_find_rules_by_action() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Action2 Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action2.id,
|
||||
action_ref: action2.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1155,7 +1155,7 @@ async fn test_find_rules_by_trigger() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: format!("Trigger1 Rule {}", i),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger1.id,
|
||||
@@ -1176,7 +1176,7 @@ async fn test_find_rules_by_trigger() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Trigger2 Rule".to_string(),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger2.id,
|
||||
@@ -1234,7 +1234,7 @@ async fn test_find_enabled_rules() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: format!("Enabled {}", i),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1256,7 +1256,7 @@ async fn test_find_enabled_rules() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: format!("Disabled {}", i),
|
||||
description: "Test".to_string(),
|
||||
description: Some("Test".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1312,7 +1312,7 @@ async fn test_cascade_delete_pack_deletes_rules() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Cascade Rule".to_string(),
|
||||
description: "Will be cascade deleted".to_string(),
|
||||
description: Some("Will be cascade deleted".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
@@ -1368,7 +1368,7 @@ async fn test_rule_timestamps() {
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: "Timestamp Rule".to_string(),
|
||||
description: "Test timestamps".to_string(),
|
||||
description: Some("Test timestamps".to_string()),
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
|
||||
@@ -179,7 +179,7 @@ async fn test_create_sensor_duplicate_ref_fails() {
|
||||
pack: Some(pack.id),
|
||||
pack_ref: Some(pack.r#ref.clone()),
|
||||
label: "Duplicate Sensor".to_string(),
|
||||
description: "Test sensor".to_string(),
|
||||
description: Some("Test sensor".to_string()),
|
||||
entrypoint: "sensors/dup.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
@@ -235,7 +235,7 @@ async fn test_create_sensor_invalid_ref_format_fails() {
|
||||
pack: Some(pack.id),
|
||||
pack_ref: Some(pack.r#ref.clone()),
|
||||
label: "Invalid Sensor".to_string(),
|
||||
description: "Test sensor".to_string(),
|
||||
description: Some("Test sensor".to_string()),
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
@@ -276,7 +276,7 @@ async fn test_create_sensor_invalid_pack_fails() {
|
||||
pack: Some(99999), // Non-existent pack
|
||||
pack_ref: Some("invalid".to_string()),
|
||||
label: "Invalid Pack Sensor".to_string(),
|
||||
description: "Test sensor".to_string(),
|
||||
description: Some("Test sensor".to_string()),
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
@@ -308,7 +308,7 @@ async fn test_create_sensor_invalid_trigger_fails() {
|
||||
pack: None,
|
||||
pack_ref: None,
|
||||
label: "Invalid Trigger Sensor".to_string(),
|
||||
description: "Test sensor".to_string(),
|
||||
description: Some("Test sensor".to_string()),
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
@@ -340,7 +340,7 @@ async fn test_create_sensor_invalid_runtime_fails() {
|
||||
pack: None,
|
||||
pack_ref: None,
|
||||
label: "Invalid Runtime Sensor".to_string(),
|
||||
description: "Test sensor".to_string(),
|
||||
description: Some("Test sensor".to_string()),
|
||||
entrypoint: "sensors/invalid.py".to_string(),
|
||||
runtime: 99999, // Non-existent runtime
|
||||
runtime_ref: "invalid.runtime".to_string(),
|
||||
@@ -728,7 +728,7 @@ async fn test_update_description() {
|
||||
.unwrap();
|
||||
|
||||
let input = UpdateSensorInput {
|
||||
description: Some("New description for the sensor".to_string()),
|
||||
description: Some(Patch::Set("New description for the sensor".to_string())),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -736,7 +736,10 @@ async fn test_update_description() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.description, "New description for the sensor");
|
||||
assert_eq!(
|
||||
updated.description,
|
||||
Some("New description for the sensor".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -934,7 +937,7 @@ async fn test_update_multiple_fields() {
|
||||
|
||||
let input = UpdateSensorInput {
|
||||
label: Some("Multi Update".to_string()),
|
||||
description: Some("Updated multiple fields".to_string()),
|
||||
description: Some(Patch::Set("Updated multiple fields".to_string())),
|
||||
entrypoint: Some("sensors/multi.py".to_string()),
|
||||
enabled: Some(false),
|
||||
param_schema: Some(Patch::Set(json!({"type": "object"}))),
|
||||
@@ -946,7 +949,10 @@ async fn test_update_multiple_fields() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.label, "Multi Update");
|
||||
assert_eq!(updated.description, "Updated multiple fields");
|
||||
assert_eq!(
|
||||
updated.description,
|
||||
Some("Updated multiple fields".to_string())
|
||||
);
|
||||
assert_eq!(updated.entrypoint, "sensors/multi.py");
|
||||
assert!(!updated.enabled);
|
||||
assert_eq!(updated.param_schema, Some(json!({"type": "object"})));
|
||||
|
||||
@@ -368,7 +368,7 @@ mod tests {
|
||||
pack: 1,
|
||||
pack_ref: "test".to_string(),
|
||||
label: "Test Rule".to_string(),
|
||||
description: "Test rule description".to_string(),
|
||||
description: Some("Test rule description".to_string()),
|
||||
trigger_ref: "test.trigger".to_string(),
|
||||
trigger: Some(1),
|
||||
action_ref: "test.action".to_string(),
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use attune_common::{
|
||||
models::{enums::ExecutionStatus, execution::WorkflowTaskMetadata, Action, Execution},
|
||||
mq::{Consumer, ExecutionRequestedPayload, MessageEnvelope, MessageType, Publisher},
|
||||
models::{enums::ExecutionStatus, execution::WorkflowTaskMetadata, Action, Execution, Runtime},
|
||||
mq::{
|
||||
Consumer, ExecutionCompletedPayload, ExecutionRequestedPayload, MessageEnvelope,
|
||||
MessageType, Publisher,
|
||||
},
|
||||
repositories::{
|
||||
action::ActionRepository,
|
||||
execution::{CreateExecutionInput, ExecutionRepository, UpdateExecutionInput},
|
||||
@@ -24,7 +27,7 @@ use attune_common::{
|
||||
},
|
||||
Create, FindById, FindByRef, Update,
|
||||
},
|
||||
runtime_detection::runtime_matches_filter,
|
||||
runtime_detection::runtime_aliases_contain,
|
||||
workflow::WorkflowDefinition,
|
||||
};
|
||||
use chrono::Utc;
|
||||
@@ -205,7 +208,23 @@ impl ExecutionScheduler {
|
||||
}
|
||||
|
||||
// Regular action: select appropriate worker (round-robin among compatible workers)
|
||||
let worker = Self::select_worker(pool, &action, round_robin_counter).await?;
|
||||
let worker = match Self::select_worker(pool, &action, round_robin_counter).await {
|
||||
Ok(worker) => worker,
|
||||
Err(err) if Self::is_unschedulable_error(&err) => {
|
||||
Self::fail_unschedulable_execution(
|
||||
pool,
|
||||
publisher,
|
||||
envelope,
|
||||
execution_id,
|
||||
action.id,
|
||||
&action.r#ref,
|
||||
&err.to_string(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
info!(
|
||||
"Selected worker {} for execution {}",
|
||||
@@ -1561,7 +1580,7 @@ impl ExecutionScheduler {
|
||||
let compatible_workers: Vec<_> = if let Some(ref runtime) = runtime {
|
||||
workers
|
||||
.into_iter()
|
||||
.filter(|w| Self::worker_supports_runtime(w, &runtime.name))
|
||||
.filter(|w| Self::worker_supports_runtime(w, runtime))
|
||||
.collect()
|
||||
} else {
|
||||
workers
|
||||
@@ -1619,20 +1638,26 @@ impl ExecutionScheduler {
|
||||
|
||||
/// Check if a worker supports a given runtime
|
||||
///
|
||||
/// This checks the worker's capabilities.runtimes array for the runtime name.
|
||||
/// Falls back to checking the deprecated runtime column if capabilities are not set.
|
||||
fn worker_supports_runtime(worker: &attune_common::models::Worker, runtime_name: &str) -> bool {
|
||||
// First, try to parse capabilities and check runtimes array
|
||||
/// This checks the worker's capabilities.runtimes array against the runtime's aliases.
|
||||
/// If aliases are missing, fall back to the runtime's canonical name.
|
||||
fn worker_supports_runtime(worker: &attune_common::models::Worker, runtime: &Runtime) -> bool {
|
||||
let runtime_names = Self::runtime_capability_names(runtime);
|
||||
|
||||
// Try to parse capabilities and check runtimes array
|
||||
if let Some(ref capabilities) = worker.capabilities {
|
||||
if let Some(runtimes) = capabilities.get("runtimes") {
|
||||
if let Some(runtime_array) = runtimes.as_array() {
|
||||
// Check if any runtime in the array matches (alias-aware)
|
||||
// Check if any runtime in the array matches via aliases
|
||||
for runtime_value in runtime_array {
|
||||
if let Some(runtime_str) = runtime_value.as_str() {
|
||||
if runtime_matches_filter(runtime_name, runtime_str) {
|
||||
if runtime_names
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(runtime_str))
|
||||
|| runtime_aliases_contain(&runtime.aliases, runtime_str)
|
||||
{
|
||||
debug!(
|
||||
"Worker {} supports runtime '{}' via capabilities (matched '{}')",
|
||||
worker.name, runtime_name, runtime_str
|
||||
"Worker {} supports runtime '{}' via capabilities (matched '{}', candidates: {:?})",
|
||||
worker.name, runtime.name, runtime_str, runtime_names
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -1642,25 +1667,90 @@ impl ExecutionScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check deprecated runtime column
|
||||
// This is kept for backward compatibility but should be removed in the future
|
||||
if worker.runtime.is_some() {
|
||||
debug!(
|
||||
"Worker {} using deprecated runtime column for matching",
|
||||
worker.name
|
||||
);
|
||||
// Note: This fallback is incomplete because we'd need to look up the runtime name
|
||||
// from the ID, which would require an async call. Since we're moving to capabilities,
|
||||
// we'll just return false here and require workers to set capabilities properly.
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Worker {} does not support runtime '{}'",
|
||||
worker.name, runtime_name
|
||||
"Worker {} does not support runtime '{}' (candidates: {:?})",
|
||||
worker.name, runtime.name, runtime_names
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
fn runtime_capability_names(runtime: &Runtime) -> Vec<String> {
|
||||
let mut names: Vec<String> = runtime
|
||||
.aliases
|
||||
.iter()
|
||||
.map(|alias| alias.to_ascii_lowercase())
|
||||
.filter(|alias| !alias.is_empty())
|
||||
.collect();
|
||||
|
||||
let runtime_name = runtime.name.to_ascii_lowercase();
|
||||
if !runtime_name.is_empty() && !names.iter().any(|name| name == &runtime_name) {
|
||||
names.push(runtime_name);
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
fn is_unschedulable_error(error: &anyhow::Error) -> bool {
|
||||
let message = error.to_string();
|
||||
message.starts_with("No compatible workers found")
|
||||
|| message.starts_with("No action workers available")
|
||||
|| message.starts_with("No active workers available")
|
||||
|| message.starts_with("No workers with fresh heartbeats available")
|
||||
}
|
||||
|
||||
async fn fail_unschedulable_execution(
|
||||
pool: &PgPool,
|
||||
publisher: &Publisher,
|
||||
envelope: &MessageEnvelope<ExecutionRequestedPayload>,
|
||||
execution_id: i64,
|
||||
action_id: i64,
|
||||
action_ref: &str,
|
||||
error_message: &str,
|
||||
) -> Result<()> {
|
||||
let completed_at = Utc::now();
|
||||
let result = serde_json::json!({
|
||||
"error": "Execution is unschedulable",
|
||||
"message": error_message,
|
||||
"action_ref": action_ref,
|
||||
"failed_by": "execution_scheduler",
|
||||
"failed_at": completed_at.to_rfc3339(),
|
||||
});
|
||||
|
||||
ExecutionRepository::update(
|
||||
pool,
|
||||
execution_id,
|
||||
UpdateExecutionInput {
|
||||
status: Some(ExecutionStatus::Failed),
|
||||
result: Some(result.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let completed = MessageEnvelope::new(
|
||||
MessageType::ExecutionCompleted,
|
||||
ExecutionCompletedPayload {
|
||||
execution_id,
|
||||
action_id,
|
||||
action_ref: action_ref.to_string(),
|
||||
status: "failed".to_string(),
|
||||
result: Some(result),
|
||||
completed_at,
|
||||
},
|
||||
)
|
||||
.with_correlation_id(envelope.correlation_id)
|
||||
.with_source("attune-executor");
|
||||
|
||||
publisher.publish_envelope(&completed).await?;
|
||||
|
||||
warn!(
|
||||
"Execution {} marked failed as unschedulable: {}",
|
||||
execution_id, error_message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a worker's heartbeat is fresh enough to schedule work
|
||||
///
|
||||
/// A worker is considered fresh if its last heartbeat is within
|
||||
@@ -1826,6 +1916,70 @@ mod tests {
|
||||
// Real tests will require database and message queue setup
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worker_supports_runtime_with_alias_match() {
|
||||
let worker = create_test_worker("test-worker", 5);
|
||||
let runtime = Runtime {
|
||||
id: 1,
|
||||
r#ref: "core.shell".to_string(),
|
||||
pack: None,
|
||||
pack_ref: Some("core".to_string()),
|
||||
description: Some("Shell runtime".to_string()),
|
||||
name: "Shell".to_string(),
|
||||
aliases: vec!["shell".to_string(), "bash".to_string()],
|
||||
distributions: serde_json::json!({}),
|
||||
installation: None,
|
||||
installers: serde_json::json!({}),
|
||||
execution_config: serde_json::json!({}),
|
||||
auto_detected: false,
|
||||
detection_config: serde_json::json!({}),
|
||||
created: Utc::now(),
|
||||
updated: Utc::now(),
|
||||
};
|
||||
|
||||
assert!(ExecutionScheduler::worker_supports_runtime(
|
||||
&worker, &runtime
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worker_supports_runtime_falls_back_to_runtime_name_when_aliases_missing() {
|
||||
let worker = create_test_worker("test-worker", 5);
|
||||
let runtime = Runtime {
|
||||
id: 1,
|
||||
r#ref: "core.shell".to_string(),
|
||||
pack: None,
|
||||
pack_ref: Some("core".to_string()),
|
||||
description: Some("Shell runtime".to_string()),
|
||||
name: "Shell".to_string(),
|
||||
aliases: vec![],
|
||||
distributions: serde_json::json!({}),
|
||||
installation: None,
|
||||
installers: serde_json::json!({}),
|
||||
execution_config: serde_json::json!({}),
|
||||
auto_detected: false,
|
||||
detection_config: serde_json::json!({}),
|
||||
created: Utc::now(),
|
||||
updated: Utc::now(),
|
||||
};
|
||||
|
||||
assert!(ExecutionScheduler::worker_supports_runtime(
|
||||
&worker, &runtime
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unschedulable_error_classification() {
|
||||
assert!(ExecutionScheduler::is_unschedulable_error(
|
||||
&anyhow::anyhow!(
|
||||
"No compatible workers found for action: core.sleep (requires runtime: Shell)"
|
||||
)
|
||||
));
|
||||
assert!(!ExecutionScheduler::is_unschedulable_error(
|
||||
&anyhow::anyhow!("database temporarily unavailable")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concurrency_limit_dispatch_count() {
|
||||
// Verify the dispatch_count calculation used by dispatch_with_items_task
|
||||
|
||||
@@ -72,6 +72,7 @@ async fn _create_test_runtime(pool: &PgPool, suffix: &str) -> i64 {
|
||||
pack_ref: None,
|
||||
description: Some(format!("Test runtime {}", suffix)),
|
||||
name: format!("Python {}", suffix),
|
||||
aliases: vec![],
|
||||
distributions: json!({"ubuntu": "python3"}),
|
||||
installation: Some(json!({"method": "apt"})),
|
||||
execution_config: json!({
|
||||
@@ -98,7 +99,7 @@ async fn create_test_action(pool: &PgPool, pack_id: i64, pack_ref: &str, suffix:
|
||||
pack: pack_id,
|
||||
pack_ref: pack_ref.to_string(),
|
||||
label: format!("FIFO Test Action {}", suffix),
|
||||
description: format!("Test action {}", suffix),
|
||||
description: Some(format!("Test action {}", suffix)),
|
||||
entrypoint: "echo test".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -67,6 +67,7 @@ async fn create_test_runtime(pool: &PgPool, suffix: &str) -> i64 {
|
||||
pack_ref: None,
|
||||
description: Some(format!("Test runtime {}", suffix)),
|
||||
name: format!("Python {}", suffix),
|
||||
aliases: vec![],
|
||||
distributions: json!({"ubuntu": "python3"}),
|
||||
installation: Some(json!({"method": "apt"})),
|
||||
execution_config: json!({
|
||||
@@ -93,7 +94,7 @@ async fn create_test_action(pool: &PgPool, pack_id: i64, suffix: &str) -> i64 {
|
||||
pack: pack_id,
|
||||
pack_ref: format!("test_pack_{}", suffix),
|
||||
label: format!("Test Action {}", suffix),
|
||||
description: format!("Test action {}", suffix),
|
||||
description: Some(format!("Test action {}", suffix)),
|
||||
entrypoint: "echo test".to_string(),
|
||||
runtime: None,
|
||||
runtime_version_constraint: None,
|
||||
|
||||
@@ -14,6 +14,10 @@ path = "src/lib.rs"
|
||||
name = "attune-sensor"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "attune-sensor-agent"
|
||||
path = "src/agent_main.rs"
|
||||
|
||||
[dependencies]
|
||||
attune-common = { path = "../common" }
|
||||
tokio = { workspace = true }
|
||||
|
||||
79
crates/sensor/src/agent_main.rs
Normal file
79
crates/sensor/src/agent_main.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Attune Universal Sensor Agent.
|
||||
|
||||
use anyhow::Result;
|
||||
use attune_common::agent_bootstrap::{bootstrap_runtime_env, print_detect_only_report};
|
||||
use attune_common::config::Config;
|
||||
use attune_sensor::startup::{
|
||||
apply_sensor_name_override, init_tracing, log_config_details, run_sensor_service,
|
||||
set_config_path,
|
||||
};
|
||||
use clap::Parser;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "attune-sensor-agent")]
|
||||
#[command(
|
||||
version,
|
||||
about = "Attune Universal Sensor Agent - Injected into runtime containers to auto-detect sensor runtimes"
|
||||
)]
|
||||
struct Args {
|
||||
/// Path to configuration file (optional)
|
||||
#[arg(short, long)]
|
||||
config: Option<String>,
|
||||
|
||||
/// Sensor worker name override
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Run runtime detection, print results, and exit
|
||||
#[arg(long)]
|
||||
detect_only: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
attune_common::auth::install_crypto_provider();
|
||||
init_tracing(tracing::Level::INFO);
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
info!("Starting Attune Universal Sensor Agent");
|
||||
info!(
|
||||
"Agent binary: attune-sensor-agent {}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
// Safe: no async runtime or worker threads are running yet.
|
||||
std::env::set_var("ATTUNE_SENSOR_AGENT_MODE", "true");
|
||||
std::env::set_var("ATTUNE_SENSOR_AGENT_BINARY_NAME", "attune-sensor-agent");
|
||||
std::env::set_var(
|
||||
"ATTUNE_SENSOR_AGENT_BINARY_VERSION",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
|
||||
let bootstrap = bootstrap_runtime_env("ATTUNE_SENSOR_RUNTIMES");
|
||||
|
||||
if args.detect_only {
|
||||
print_detect_only_report("ATTUNE_SENSOR_RUNTIMES", &bootstrap);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
set_config_path(args.config.as_deref());
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
runtime.block_on(async_main(args))
|
||||
}
|
||||
|
||||
async fn async_main(args: Args) -> Result<()> {
|
||||
let mut config = Config::load()?;
|
||||
config.validate()?;
|
||||
|
||||
if let Some(name) = args.name {
|
||||
apply_sensor_name_override(&mut config, name);
|
||||
}
|
||||
|
||||
log_config_details(&config);
|
||||
run_sensor_service(config, "Attune Sensor Agent is ready").await?;
|
||||
info!("Attune Sensor Agent shutdown complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod rule_lifecycle_listener;
|
||||
pub mod sensor_manager;
|
||||
pub mod sensor_worker_registration;
|
||||
pub mod service;
|
||||
pub mod startup;
|
||||
|
||||
// Re-export template resolver from common crate
|
||||
pub mod template_resolver {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
//! Attune Sensor Service
|
||||
//!
|
||||
//! The Sensor Service monitors for trigger conditions and generates events.
|
||||
//! It executes custom sensor code, manages sensor lifecycle, and publishes
|
||||
//! events to the message queue for rule matching and enforcement creation.
|
||||
|
||||
use anyhow::Result;
|
||||
use attune_common::config::Config;
|
||||
use attune_sensor::service::SensorService;
|
||||
use attune_sensor::startup::{
|
||||
init_tracing, log_config_details, run_sensor_service, set_config_path,
|
||||
};
|
||||
use clap::Parser;
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
use tracing::{error, info};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "attune-sensor")]
|
||||
@@ -26,114 +25,23 @@ struct Args {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Install HMAC-only JWT crypto provider (must be before any token operations)
|
||||
attune_common::auth::install_crypto_provider();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialize tracing with specified log level
|
||||
let log_level = args.log_level.parse().unwrap_or(tracing::Level::INFO);
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(log_level)
|
||||
.with_target(false)
|
||||
.with_thread_ids(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.init();
|
||||
init_tracing(log_level);
|
||||
|
||||
info!("Starting Attune Sensor Service");
|
||||
info!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Load configuration
|
||||
if let Some(config_path) = args.config {
|
||||
info!("Loading configuration from: {}", config_path);
|
||||
std::env::set_var("ATTUNE_CONFIG", config_path);
|
||||
}
|
||||
set_config_path(args.config.as_deref());
|
||||
|
||||
let config = Config::load()?;
|
||||
config.validate()?;
|
||||
|
||||
info!("Configuration loaded successfully");
|
||||
info!("Environment: {}", config.environment);
|
||||
info!("Database: {}", mask_connection_string(&config.database.url));
|
||||
if let Some(ref mq_config) = config.message_queue {
|
||||
info!("Message Queue: {}", mask_connection_string(&mq_config.url));
|
||||
}
|
||||
|
||||
// Create and start sensor service
|
||||
let service = SensorService::new(config).await?;
|
||||
|
||||
info!("Sensor Service initialized successfully");
|
||||
|
||||
// Start the service (spawns background tasks and returns)
|
||||
info!("Starting Sensor Service components...");
|
||||
service.start().await?;
|
||||
|
||||
info!("Attune Sensor Service is ready");
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
let mut sigint = signal(SignalKind::interrupt())?;
|
||||
let mut sigterm = signal(SignalKind::terminate())?;
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {
|
||||
info!("Received SIGINT signal");
|
||||
}
|
||||
_ = sigterm.recv() => {
|
||||
info!("Received SIGTERM signal");
|
||||
}
|
||||
}
|
||||
|
||||
info!("Shutting down gracefully...");
|
||||
|
||||
// Stop the service: deregister worker, stop sensors, clean up connections
|
||||
if let Err(e) = service.stop().await {
|
||||
error!("Error during shutdown: {}", e);
|
||||
}
|
||||
|
||||
log_config_details(&config);
|
||||
run_sensor_service(config, "Attune Sensor Service is ready").await?;
|
||||
info!("Attune Sensor Service shutdown complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mask sensitive parts of connection strings for logging
|
||||
fn mask_connection_string(url: &str) -> String {
|
||||
if let Some(at_pos) = url.find('@') {
|
||||
if let Some(proto_end) = url.find("://") {
|
||||
let protocol = &url[..proto_end + 3];
|
||||
let host_and_path = &url[at_pos..];
|
||||
return format!("{}***:***{}", protocol, host_and_path);
|
||||
}
|
||||
}
|
||||
"***:***@***".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mask_connection_string() {
|
||||
let url = "postgresql://user:password@localhost:5432/attune";
|
||||
let masked = mask_connection_string(url);
|
||||
assert!(!masked.contains("user"));
|
||||
assert!(!masked.contains("password"));
|
||||
assert!(masked.contains("@localhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_connection_string_no_credentials() {
|
||||
let url = "postgresql://localhost:5432/attune";
|
||||
let masked = mask_connection_string(url);
|
||||
assert_eq!(masked, "***:***@***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_rabbitmq_connection() {
|
||||
let url = "amqp://admin:secret@rabbitmq:5672/%2F";
|
||||
let masked = mask_connection_string(url);
|
||||
assert!(!masked.contains("admin"));
|
||||
assert!(!masked.contains("secret"));
|
||||
assert!(masked.contains("@rabbitmq"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! - Monitoring sensor health and restarting failed sensors
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use attune_common::models::{Id, Sensor, Trigger};
|
||||
use attune_common::models::{runtime::RuntimeExecutionConfig, Id, Sensor, Trigger};
|
||||
use attune_common::repositories::{FindById, List, RuntimeRepository};
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
@@ -27,6 +27,37 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
|
||||
fn existing_command_env(cmd: &Command, key: &str) -> Option<String> {
|
||||
cmd.as_std()
|
||||
.get_envs()
|
||||
.find_map(|(env_key, value)| {
|
||||
if env_key == key {
|
||||
value.map(|value| value.to_string_lossy().into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| std::env::var(key).ok())
|
||||
}
|
||||
|
||||
fn apply_runtime_env_vars(
|
||||
cmd: &mut Command,
|
||||
exec_config: &RuntimeExecutionConfig,
|
||||
pack_dir: &std::path::Path,
|
||||
env_dir: Option<&std::path::Path>,
|
||||
) {
|
||||
if exec_config.env_vars.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let vars = exec_config.build_template_vars_with_env(pack_dir, env_dir);
|
||||
for (key, env_var_config) in &exec_config.env_vars {
|
||||
let resolved = env_var_config.resolve(&vars, existing_command_env(cmd, key).as_deref());
|
||||
debug!("Setting sensor runtime env var: {}={}", key, resolved);
|
||||
cmd.env(key, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensor manager that coordinates all sensor instances
|
||||
#[derive(Clone)]
|
||||
pub struct SensorManager {
|
||||
@@ -162,6 +193,127 @@ impl SensorManager {
|
||||
Ok(enabled_sensors)
|
||||
}
|
||||
|
||||
async fn ensure_runtime_environment(
|
||||
&self,
|
||||
exec_config: &RuntimeExecutionConfig,
|
||||
pack_dir: &std::path::Path,
|
||||
env_dir: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let env_cfg = match &exec_config.environment {
|
||||
Some(cfg) if cfg.env_type != "none" => cfg,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let vars = exec_config.build_template_vars_with_env(pack_dir, Some(env_dir));
|
||||
|
||||
if !env_dir.exists() {
|
||||
if env_cfg.create_command.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Runtime environment '{}' requires create_command but none is configured",
|
||||
env_cfg.env_type
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(parent) = env_dir.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
anyhow!(
|
||||
"Failed to create runtime environment parent directory {}: {}",
|
||||
parent.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let resolved_cmd =
|
||||
RuntimeExecutionConfig::resolve_command(&env_cfg.create_command, &vars);
|
||||
let (program, args) = resolved_cmd
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow!("Empty create_command for runtime environment"))?;
|
||||
|
||||
info!(
|
||||
"Creating sensor runtime environment at {}: {:?}",
|
||||
env_dir.display(),
|
||||
resolved_cmd
|
||||
);
|
||||
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.current_dir(pack_dir)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to run create command '{}': {}", program, e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"Runtime environment creation failed (exit {}): {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let dep_cfg = match &exec_config.dependencies {
|
||||
Some(cfg) => cfg,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let manifest_path = pack_dir.join(&dep_cfg.manifest_file);
|
||||
if !manifest_path.exists() || dep_cfg.install_command.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let install_marker = env_dir.join(".attune_sensor_deps_installed");
|
||||
if install_marker.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let resolved_cmd = RuntimeExecutionConfig::resolve_command(&dep_cfg.install_command, &vars);
|
||||
let (program, args) = resolved_cmd
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow!("Empty install_command for runtime dependencies"))?;
|
||||
|
||||
info!(
|
||||
"Installing sensor runtime dependencies for {} using {:?}",
|
||||
pack_dir.display(),
|
||||
resolved_cmd
|
||||
);
|
||||
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.current_dir(pack_dir)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow!(
|
||||
"Failed to run dependency install command '{}': {}",
|
||||
program,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"Runtime dependency installation failed (exit {}): {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
tokio::fs::write(&install_marker, b"ok")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow!(
|
||||
"Failed to write dependency install marker {}: {}",
|
||||
install_marker.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a sensor instance
|
||||
async fn start_sensor(&self, sensor: Sensor) -> Result<()> {
|
||||
info!("Starting sensor {} ({})", sensor.r#ref, sensor.id);
|
||||
@@ -231,6 +383,12 @@ impl SensorManager {
|
||||
|
||||
let exec_config = runtime.parsed_execution_config();
|
||||
let rt_name = runtime.name.to_lowercase();
|
||||
let runtime_env_suffix = runtime
|
||||
.r#ref
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.filter(|suffix| !suffix.is_empty())
|
||||
.unwrap_or(&rt_name);
|
||||
|
||||
info!(
|
||||
"Sensor {} runtime details: id={}, ref='{}', name='{}', execution_config={}",
|
||||
@@ -242,7 +400,19 @@ impl SensorManager {
|
||||
let pack_dir = std::path::PathBuf::from(&self.inner.packs_base_dir).join(pack_ref);
|
||||
let env_dir = std::path::PathBuf::from(&self.inner.runtime_envs_dir)
|
||||
.join(pack_ref)
|
||||
.join(&rt_name);
|
||||
.join(runtime_env_suffix);
|
||||
if let Err(e) = self
|
||||
.ensure_runtime_environment(&exec_config, &pack_dir, &env_dir)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to ensure sensor runtime environment for {} at {}: {}",
|
||||
sensor.r#ref,
|
||||
env_dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
let env_dir_opt = if env_dir.exists() {
|
||||
Some(env_dir.as_path())
|
||||
} else {
|
||||
@@ -354,15 +524,18 @@ impl SensorManager {
|
||||
|
||||
// Start the standalone sensor with token and configuration
|
||||
// Pass sensor ref (e.g., "core.interval_timer_sensor") for proper identification
|
||||
let mut child = cmd
|
||||
.env("ATTUNE_API_URL", &self.inner.api_url)
|
||||
cmd.env("ATTUNE_API_URL", &self.inner.api_url)
|
||||
.env("ATTUNE_API_TOKEN", &token_response.token)
|
||||
.env("ATTUNE_SENSOR_ID", sensor.id.to_string())
|
||||
.env("ATTUNE_SENSOR_REF", &sensor.r#ref)
|
||||
.env("ATTUNE_SENSOR_TRIGGERS", &trigger_instances_json)
|
||||
.env("ATTUNE_MQ_URL", &self.inner.mq_url)
|
||||
.env("ATTUNE_MQ_EXCHANGE", "attune.events")
|
||||
.env("ATTUNE_LOG_LEVEL", "info")
|
||||
.env("ATTUNE_LOG_LEVEL", "info");
|
||||
|
||||
apply_runtime_env_vars(&mut cmd, &exec_config, &pack_dir, env_dir_opt);
|
||||
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -371,13 +544,14 @@ impl SensorManager {
|
||||
anyhow!(
|
||||
"Failed to start sensor process for '{}': {} \
|
||||
(binary='{}', is_native={}, runtime_ref='{}', \
|
||||
interpreter_config='{}')",
|
||||
interpreter_config='{}', env_dir='{}')",
|
||||
sensor.r#ref,
|
||||
e,
|
||||
spawn_binary,
|
||||
is_native,
|
||||
runtime.r#ref,
|
||||
interpreter_binary
|
||||
interpreter_binary,
|
||||
env_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -748,6 +922,10 @@ pub struct SensorStatus {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use attune_common::models::runtime::{
|
||||
RuntimeEnvVarConfig, RuntimeEnvVarOperation, RuntimeEnvVarSpec,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_sensor_status_default() {
|
||||
@@ -757,4 +935,46 @@ mod tests {
|
||||
assert_eq!(status.failure_count, 0);
|
||||
assert!(status.last_poll.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_runtime_env_vars_prepends_to_existing_command_env() {
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert(
|
||||
"PYTHONPATH".to_string(),
|
||||
RuntimeEnvVarConfig::Spec(RuntimeEnvVarSpec {
|
||||
value: "{pack_dir}/lib".to_string(),
|
||||
operation: RuntimeEnvVarOperation::Prepend,
|
||||
separator: ":".to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
let exec_config = RuntimeExecutionConfig {
|
||||
env_vars,
|
||||
..RuntimeExecutionConfig::default()
|
||||
};
|
||||
|
||||
let mut cmd = Command::new("python3");
|
||||
cmd.env("PYTHONPATH", "/existing/pythonpath");
|
||||
|
||||
apply_runtime_env_vars(
|
||||
&mut cmd,
|
||||
&exec_config,
|
||||
std::path::Path::new("/packs/testpack"),
|
||||
None,
|
||||
);
|
||||
|
||||
let resolved = cmd
|
||||
.as_std()
|
||||
.get_envs()
|
||||
.find_map(|(key, value)| {
|
||||
if key == "PYTHONPATH" {
|
||||
value.map(|value| value.to_string_lossy().into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.expect("PYTHONPATH should be set");
|
||||
|
||||
assert_eq!(resolved, "/packs/testpack/lib:/existing/pythonpath");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ use sqlx::{PgPool, Row};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const ATTUNE_SENSOR_AGENT_MODE_ENV: &str = "ATTUNE_SENSOR_AGENT_MODE";
|
||||
const ATTUNE_SENSOR_AGENT_BINARY_NAME_ENV: &str = "ATTUNE_SENSOR_AGENT_BINARY_NAME";
|
||||
const ATTUNE_SENSOR_AGENT_BINARY_VERSION_ENV: &str = "ATTUNE_SENSOR_AGENT_BINARY_VERSION";
|
||||
|
||||
/// Sensor worker registration manager
|
||||
pub struct SensorWorkerRegistration {
|
||||
pool: PgPool,
|
||||
@@ -25,6 +29,33 @@ pub struct SensorWorkerRegistration {
|
||||
}
|
||||
|
||||
impl SensorWorkerRegistration {
|
||||
fn env_truthy(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn inject_agent_capabilities(capabilities: &mut HashMap<String, serde_json::Value>) {
|
||||
if Self::env_truthy(ATTUNE_SENSOR_AGENT_MODE_ENV) {
|
||||
capabilities.insert("agent_mode".to_string(), json!(true));
|
||||
}
|
||||
|
||||
if let Ok(binary_name) = std::env::var(ATTUNE_SENSOR_AGENT_BINARY_NAME_ENV) {
|
||||
let binary_name = binary_name.trim();
|
||||
if !binary_name.is_empty() {
|
||||
capabilities.insert("agent_binary_name".to_string(), json!(binary_name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(binary_version) = std::env::var(ATTUNE_SENSOR_AGENT_BINARY_VERSION_ENV) {
|
||||
let binary_version = binary_version.trim();
|
||||
if !binary_version.is_empty() {
|
||||
capabilities.insert("agent_binary_version".to_string(), json!(binary_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new sensor worker registration manager
|
||||
pub fn new(pool: PgPool, config: &Config) -> Self {
|
||||
let worker_name = config
|
||||
@@ -67,6 +98,8 @@ impl SensorWorkerRegistration {
|
||||
json!(env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
|
||||
Self::inject_agent_capabilities(&mut capabilities);
|
||||
|
||||
// Placeholder for runtimes (will be detected asynchronously)
|
||||
capabilities.insert("runtimes".to_string(), json!(Vec::<String>::new()));
|
||||
|
||||
@@ -351,4 +384,28 @@ mod tests {
|
||||
|
||||
registration.deregister().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_agent_capabilities_from_env() {
|
||||
std::env::set_var(ATTUNE_SENSOR_AGENT_MODE_ENV, "1");
|
||||
std::env::set_var(ATTUNE_SENSOR_AGENT_BINARY_NAME_ENV, "attune-sensor-agent");
|
||||
std::env::set_var(ATTUNE_SENSOR_AGENT_BINARY_VERSION_ENV, "1.2.3");
|
||||
|
||||
let mut capabilities = HashMap::new();
|
||||
SensorWorkerRegistration::inject_agent_capabilities(&mut capabilities);
|
||||
|
||||
assert_eq!(capabilities.get("agent_mode"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
capabilities.get("agent_binary_name"),
|
||||
Some(&json!("attune-sensor-agent"))
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities.get("agent_binary_version"),
|
||||
Some(&json!("1.2.3"))
|
||||
);
|
||||
|
||||
std::env::remove_var(ATTUNE_SENSOR_AGENT_MODE_ENV);
|
||||
std::env::remove_var(ATTUNE_SENSOR_AGENT_BINARY_NAME_ENV);
|
||||
std::env::remove_var(ATTUNE_SENSOR_AGENT_BINARY_VERSION_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
119
crates/sensor/src/startup.rs
Normal file
119
crates/sensor/src/startup.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use crate::service::SensorService;
|
||||
use anyhow::Result;
|
||||
use attune_common::config::{Config, SensorConfig};
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub fn init_tracing(log_level: tracing::Level) {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(log_level)
|
||||
.with_target(false)
|
||||
.with_thread_ids(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.init();
|
||||
}
|
||||
|
||||
pub fn set_config_path(config_path: Option<&str>) {
|
||||
if let Some(config_path) = config_path {
|
||||
info!("Loading configuration from: {}", config_path);
|
||||
std::env::set_var("ATTUNE_CONFIG", config_path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_sensor_name_override(config: &mut Config, name: String) {
|
||||
if let Some(ref mut sensor_config) = config.sensor {
|
||||
sensor_config.worker_name = Some(name);
|
||||
} else {
|
||||
config.sensor = Some(SensorConfig {
|
||||
worker_name: Some(name),
|
||||
host: None,
|
||||
capabilities: None,
|
||||
max_concurrent_sensors: None,
|
||||
heartbeat_interval: 30,
|
||||
poll_interval: 30,
|
||||
sensor_timeout: 30,
|
||||
shutdown_timeout: 30,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_config_details(config: &Config) {
|
||||
info!("Configuration loaded successfully");
|
||||
info!("Environment: {}", config.environment);
|
||||
info!("Database: {}", mask_connection_string(&config.database.url));
|
||||
if let Some(ref mq_config) = config.message_queue {
|
||||
info!("Message Queue: {}", mask_connection_string(&mq_config.url));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_sensor_service(config: Config, ready_message: &str) -> Result<()> {
|
||||
let service = SensorService::new(config).await?;
|
||||
|
||||
info!("Sensor Service initialized successfully");
|
||||
info!("Starting Sensor Service components...");
|
||||
service.start().await?;
|
||||
info!("{}", ready_message);
|
||||
|
||||
let mut sigint = signal(SignalKind::interrupt())?;
|
||||
let mut sigterm = signal(SignalKind::terminate())?;
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {
|
||||
info!("Received SIGINT signal");
|
||||
}
|
||||
_ = sigterm.recv() => {
|
||||
info!("Received SIGTERM signal");
|
||||
}
|
||||
}
|
||||
|
||||
info!("Shutting down gracefully...");
|
||||
|
||||
if let Err(e) = service.stop().await {
|
||||
error!("Error during shutdown: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mask sensitive parts of connection strings for logging.
|
||||
pub fn mask_connection_string(url: &str) -> String {
|
||||
if let Some(at_pos) = url.find('@') {
|
||||
if let Some(proto_end) = url.find("://") {
|
||||
let protocol = &url[..proto_end + 3];
|
||||
let host_and_path = &url[at_pos..];
|
||||
return format!("{}***:***{}", protocol, host_and_path);
|
||||
}
|
||||
}
|
||||
"***:***@***".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mask_connection_string() {
|
||||
let url = "postgresql://user:password@localhost:5432/attune";
|
||||
let masked = mask_connection_string(url);
|
||||
assert!(!masked.contains("user"));
|
||||
assert!(!masked.contains("password"));
|
||||
assert!(masked.contains("@localhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_connection_string_no_credentials() {
|
||||
let url = "postgresql://localhost:5432/attune";
|
||||
let masked = mask_connection_string(url);
|
||||
assert_eq!(masked, "***:***@***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_rabbitmq_connection() {
|
||||
let url = "amqp://admin:secret@rabbitmq:5672/%2F";
|
||||
let masked = mask_connection_string(url);
|
||||
assert!(!masked.contains("admin"));
|
||||
assert!(!masked.contains("secret"));
|
||||
assert!(masked.contains("@rabbitmq"));
|
||||
}
|
||||
}
|
||||
@@ -28,18 +28,20 @@
|
||||
//! - `--detect-only` — Run runtime detection, print results, and exit
|
||||
|
||||
use anyhow::Result;
|
||||
use attune_common::agent_bootstrap::{bootstrap_runtime_env, print_detect_only_report};
|
||||
use attune_common::config::Config;
|
||||
use clap::Parser;
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use attune_worker::dynamic_runtime::auto_register_detected_runtimes;
|
||||
use attune_worker::runtime_detect::{detect_runtimes, print_detection_report};
|
||||
use attune_worker::runtime_detect::DetectedRuntime;
|
||||
use attune_worker::service::WorkerService;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "attune-agent")]
|
||||
#[command(
|
||||
version,
|
||||
about = "Attune Universal Worker Agent - Injected into any container to auto-detect and execute actions",
|
||||
long_about = "The Attune Agent automatically discovers available runtime interpreters \
|
||||
in the current environment and registers as a worker capable of executing \
|
||||
@@ -73,119 +75,19 @@ fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
info!("Starting Attune Universal Worker Agent");
|
||||
info!("Agent binary: attune-agent {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// --- Phase 1: Runtime auto-detection (synchronous, before tokio runtime) ---
|
||||
//
|
||||
// All std::env::set_var calls MUST happen here, before we create the tokio
|
||||
// runtime, to avoid undefined behavior from mutating the process environment
|
||||
// while other threads are running.
|
||||
//
|
||||
// Check if the user has explicitly set ATTUNE_WORKER_RUNTIMES. If so, skip
|
||||
// auto-detection and respect their override. Otherwise, probe the system for
|
||||
// available interpreters.
|
||||
let runtimes_override = std::env::var("ATTUNE_WORKER_RUNTIMES").ok();
|
||||
// Safe: no async runtime or worker threads are running yet.
|
||||
std::env::set_var("ATTUNE_AGENT_MODE", "true");
|
||||
std::env::set_var("ATTUNE_AGENT_BINARY_NAME", "attune-agent");
|
||||
std::env::set_var("ATTUNE_AGENT_BINARY_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Holds the detected runtimes so we can pass them to WorkerService later.
|
||||
// Populated in both branches: auto-detection and override (filtered to
|
||||
// match the override list).
|
||||
let mut agent_detected_runtimes: Option<Vec<attune_worker::runtime_detect::DetectedRuntime>> =
|
||||
None;
|
||||
|
||||
if let Some(ref override_value) = runtimes_override {
|
||||
info!(
|
||||
"ATTUNE_WORKER_RUNTIMES already set (override): {}",
|
||||
override_value
|
||||
);
|
||||
|
||||
// Even with an explicit override, run detection so we can register
|
||||
// the overridden runtimes in the database and advertise accurate
|
||||
// capability metadata (binary paths, versions). Without this, the
|
||||
// worker would accept work for runtimes that were never registered
|
||||
// locally — e.g. ruby/go on a fresh deployment.
|
||||
info!("Running auto-detection for override-specified runtimes...");
|
||||
let detected = detect_runtimes();
|
||||
|
||||
// Filter detected runtimes to only those matching the override list,
|
||||
// so we don't register runtimes the user explicitly excluded.
|
||||
let override_names: Vec<&str> = override_value.split(',').map(|s| s.trim()).collect();
|
||||
let filtered: Vec<_> = detected
|
||||
.into_iter()
|
||||
.filter(|rt| {
|
||||
let normalized = attune_common::runtime_detection::normalize_runtime_name(&rt.name);
|
||||
override_names.iter().any(|ov| {
|
||||
attune_common::runtime_detection::normalize_runtime_name(ov) == normalized
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
warn!(
|
||||
"None of the override runtimes ({}) were found on this system! \
|
||||
The agent may not be able to execute any actions.",
|
||||
override_value
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Matched {} override runtime(s) to detected interpreters:",
|
||||
filtered.len()
|
||||
);
|
||||
for rt in &filtered {
|
||||
match &rt.version {
|
||||
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
|
||||
None => info!(" ✓ {} — {}", rt.name, rt.path),
|
||||
}
|
||||
}
|
||||
agent_detected_runtimes = Some(filtered);
|
||||
}
|
||||
} else {
|
||||
info!("No ATTUNE_WORKER_RUNTIMES override — running auto-detection...");
|
||||
|
||||
let detected = detect_runtimes();
|
||||
|
||||
if detected.is_empty() {
|
||||
warn!("No runtimes detected! The agent may not be able to execute any actions.");
|
||||
} else {
|
||||
info!("Detected {} runtime(s):", detected.len());
|
||||
for rt in &detected {
|
||||
match &rt.version {
|
||||
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
|
||||
None => info!(" ✓ {} — {}", rt.name, rt.path),
|
||||
}
|
||||
}
|
||||
|
||||
// Build comma-separated runtime list and set the env var so that
|
||||
// Config::load() and WorkerService pick it up downstream.
|
||||
let runtime_list: Vec<&str> = detected.iter().map(|r| r.name.as_str()).collect();
|
||||
let runtime_csv = runtime_list.join(",");
|
||||
info!("Setting ATTUNE_WORKER_RUNTIMES={}", runtime_csv);
|
||||
// Safe: no other threads are running yet (tokio runtime not started).
|
||||
std::env::set_var("ATTUNE_WORKER_RUNTIMES", &runtime_csv);
|
||||
|
||||
// Stash for Phase 2: pass to WorkerService for rich capability registration
|
||||
agent_detected_runtimes = Some(detected);
|
||||
}
|
||||
}
|
||||
let bootstrap = bootstrap_runtime_env("ATTUNE_WORKER_RUNTIMES");
|
||||
let agent_detected_runtimes = bootstrap.detected_runtimes.clone();
|
||||
|
||||
// --- Handle --detect-only (synchronous, no async runtime needed) ---
|
||||
if args.detect_only {
|
||||
if runtimes_override.is_some() {
|
||||
// User set an override, but --detect-only should show what's actually
|
||||
// on this system regardless, so re-run detection.
|
||||
info!(
|
||||
"--detect-only: re-running detection to show what is available on this system..."
|
||||
);
|
||||
println!("NOTE: ATTUNE_WORKER_RUNTIMES is set — auto-detection was skipped during normal startup.");
|
||||
println!(" Showing what auto-detection would find on this system:");
|
||||
println!();
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report(&detected);
|
||||
} else if let Some(ref detected) = agent_detected_runtimes {
|
||||
print_detection_report(detected);
|
||||
} else {
|
||||
// No detection ran (empty results), run it fresh
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report(&detected);
|
||||
}
|
||||
print_detect_only_report("ATTUNE_WORKER_RUNTIMES", &bootstrap);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -204,7 +106,7 @@ fn main() -> Result<()> {
|
||||
/// `runtime.block_on()` after all environment variable mutations are complete.
|
||||
async fn async_main(
|
||||
args: Args,
|
||||
agent_detected_runtimes: Option<Vec<attune_worker::runtime_detect::DetectedRuntime>>,
|
||||
agent_detected_runtimes: Option<Vec<DetectedRuntime>>,
|
||||
) -> Result<()> {
|
||||
// --- Phase 2: Load configuration ---
|
||||
let mut config = Config::load()?;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
//!
|
||||
//! For each detected runtime the agent found:
|
||||
//!
|
||||
//! 1. **Look up by name** in the database using alias-aware matching
|
||||
//! (via [`normalize_runtime_name`]).
|
||||
//! 1. **Look up by name** in the database using alias-aware matching.
|
||||
//! 2. **If found** → already registered (either from a pack YAML or a previous
|
||||
//! agent run). Nothing to do.
|
||||
//! 3. **If not found** → search for a runtime *template* in loaded packs whose
|
||||
@@ -29,7 +28,7 @@ use attune_common::error::Result;
|
||||
use attune_common::models::runtime::Runtime;
|
||||
use attune_common::repositories::runtime::{CreateRuntimeInput, RuntimeRepository};
|
||||
use attune_common::repositories::{Create, FindByRef, List};
|
||||
use attune_common::runtime_detection::normalize_runtime_name;
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -80,14 +79,17 @@ pub async fn auto_register_detected_runtimes(
|
||||
let mut registered_count = 0;
|
||||
|
||||
for detected_rt in detected {
|
||||
let canonical_name = normalize_runtime_name(&detected_rt.name);
|
||||
let canonical_name = detected_rt.name.to_ascii_lowercase();
|
||||
|
||||
// Check if a runtime with a matching name already exists in the DB.
|
||||
// We normalize both sides for alias-aware comparison.
|
||||
// normalize_runtime_name lowercases internally, so no need to pre-lowercase.
|
||||
let already_exists = existing_runtimes
|
||||
.iter()
|
||||
.any(|r| normalize_runtime_name(&r.name) == canonical_name);
|
||||
// Primary: check if the detected name appears in any existing runtime's aliases.
|
||||
// Secondary: check if the ref ends with the canonical name (e.g., "core.ruby").
|
||||
let already_exists = existing_runtimes.iter().any(|r| {
|
||||
// Primary: check if the detected name is in this runtime's aliases
|
||||
r.aliases.iter().any(|a| a == &canonical_name)
|
||||
// Secondary: check if the ref ends with the canonical name (e.g., "core.ruby")
|
||||
|| r.r#ref.ends_with(&format!(".{}", canonical_name))
|
||||
});
|
||||
|
||||
if already_exists {
|
||||
debug!(
|
||||
@@ -143,6 +145,7 @@ pub async fn auto_register_detected_runtimes(
|
||||
detected_rt.name, tmpl.r#ref
|
||||
)),
|
||||
name: tmpl.name.clone(),
|
||||
aliases: tmpl.aliases.clone(),
|
||||
distributions: tmpl.distributions.clone(),
|
||||
installation: tmpl.installation.clone(),
|
||||
execution_config: build_execution_config_from_template(&tmpl, detected_rt),
|
||||
@@ -195,6 +198,7 @@ pub async fn auto_register_detected_runtimes(
|
||||
detected_rt.name, detected_rt.path
|
||||
)),
|
||||
name: capitalize_runtime_name(&canonical_name),
|
||||
aliases: default_aliases(&canonical_name),
|
||||
distributions: build_minimal_distributions(detected_rt),
|
||||
installation: None,
|
||||
execution_config,
|
||||
@@ -285,7 +289,7 @@ fn build_execution_config_from_template(
|
||||
/// This provides enough information for `ProcessRuntime` to invoke the
|
||||
/// interpreter directly, without environment or dependency management.
|
||||
fn build_minimal_execution_config(detected: &DetectedRuntime) -> serde_json::Value {
|
||||
let canonical = normalize_runtime_name(&detected.name);
|
||||
let canonical = detected.name.to_ascii_lowercase();
|
||||
let file_ext = default_file_extension(&canonical);
|
||||
|
||||
let mut config = json!({
|
||||
@@ -319,6 +323,23 @@ fn build_minimal_distributions(detected: &DetectedRuntime) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
/// Default aliases for auto-detected runtimes that have no template.
|
||||
/// These match what the core pack YAMLs declare but serve as fallback
|
||||
/// when the template hasn't been loaded.
|
||||
fn default_aliases(canonical_name: &str) -> Vec<String> {
|
||||
match canonical_name {
|
||||
"shell" => vec!["shell".into(), "bash".into(), "sh".into()],
|
||||
"python" => vec!["python".into(), "python3".into()],
|
||||
"node" => vec!["node".into(), "nodejs".into(), "node.js".into()],
|
||||
"ruby" => vec!["ruby".into(), "rb".into()],
|
||||
"go" => vec!["go".into(), "golang".into()],
|
||||
"java" => vec!["java".into(), "jdk".into(), "openjdk".into()],
|
||||
"perl" => vec!["perl".into(), "perl5".into()],
|
||||
"r" => vec!["r".into(), "rscript".into()],
|
||||
_ => vec![canonical_name.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Capitalize a runtime name for display (e.g., "ruby" → "Ruby", "r" → "R").
|
||||
fn capitalize_runtime_name(name: &str) -> String {
|
||||
let mut chars = name.chars();
|
||||
@@ -437,6 +458,7 @@ mod tests {
|
||||
pack_ref: Some("core".to_string()),
|
||||
description: Some("Ruby Runtime".to_string()),
|
||||
name: "Ruby".to_string(),
|
||||
aliases: vec!["ruby".to_string(), "rb".to_string()],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
installers: json!({}),
|
||||
@@ -480,6 +502,7 @@ mod tests {
|
||||
pack_ref: Some("core".to_string()),
|
||||
description: None,
|
||||
name: "Python".to_string(),
|
||||
aliases: vec!["python".to_string(), "python3".to_string()],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
installers: json!({}),
|
||||
|
||||
@@ -35,7 +35,7 @@ use attune_common::repositories::pack::PackRepository;
|
||||
use attune_common::repositories::runtime::RuntimeRepository;
|
||||
use attune_common::repositories::runtime_version::RuntimeVersionRepository;
|
||||
use attune_common::repositories::{FindById, List};
|
||||
use attune_common::runtime_detection::runtime_in_filter;
|
||||
use attune_common::runtime_detection::runtime_aliases_match_filter;
|
||||
|
||||
// Re-export the utility that the API also uses so callers can reach it from
|
||||
// either crate without adding a direct common dependency for this one function.
|
||||
@@ -207,7 +207,7 @@ pub async fn setup_environments_for_registered_pack(
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
if let Some(filter) = runtime_filter {
|
||||
runtime_in_filter(name, filter)
|
||||
runtime_aliases_match_filter(&[name.to_string()], filter)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@@ -463,12 +463,12 @@ async fn process_runtime_for_pack(
|
||||
runtime_envs_dir: &Path,
|
||||
pack_result: &mut PackEnvSetupResult,
|
||||
) {
|
||||
// Apply worker runtime filter (alias-aware matching)
|
||||
// Apply worker runtime filter (alias-aware matching via declared aliases)
|
||||
if let Some(filter) = runtime_filter {
|
||||
if !runtime_in_filter(rt_name, filter) {
|
||||
if !runtime_aliases_match_filter(&rt.aliases, filter) {
|
||||
debug!(
|
||||
"Runtime '{}' not in worker filter, skipping for pack '{}'",
|
||||
rt_name, pack_ref,
|
||||
"Runtime '{}' not in worker filter (aliases: {:?}), skipping for pack '{}'",
|
||||
rt_name, rt.aliases, pack_ref,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::runtime_detect::DetectedRuntime;
|
||||
|
||||
const ATTUNE_AGENT_MODE_ENV: &str = "ATTUNE_AGENT_MODE";
|
||||
const ATTUNE_AGENT_BINARY_NAME_ENV: &str = "ATTUNE_AGENT_BINARY_NAME";
|
||||
const ATTUNE_AGENT_BINARY_VERSION_ENV: &str = "ATTUNE_AGENT_BINARY_VERSION";
|
||||
|
||||
/// Worker registration manager
|
||||
pub struct WorkerRegistration {
|
||||
pool: PgPool,
|
||||
@@ -29,12 +33,60 @@ pub struct WorkerRegistration {
|
||||
}
|
||||
|
||||
impl WorkerRegistration {
|
||||
fn env_truthy(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn inject_agent_capabilities(capabilities: &mut HashMap<String, serde_json::Value>) {
|
||||
if Self::env_truthy(ATTUNE_AGENT_MODE_ENV) {
|
||||
capabilities.insert("agent_mode".to_string(), json!(true));
|
||||
}
|
||||
|
||||
if let Ok(binary_name) = std::env::var(ATTUNE_AGENT_BINARY_NAME_ENV) {
|
||||
let binary_name = binary_name.trim();
|
||||
if !binary_name.is_empty() {
|
||||
capabilities.insert("agent_binary_name".to_string(), json!(binary_name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(binary_version) = std::env::var(ATTUNE_AGENT_BINARY_VERSION_ENV) {
|
||||
let binary_version = binary_version.trim();
|
||||
if !binary_version.is_empty() {
|
||||
capabilities.insert("agent_binary_version".to_string(), json!(binary_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_worker_name() -> Option<String> {
|
||||
std::env::var("ATTUNE_WORKER_NAME")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn legacy_worker_type() -> Option<WorkerType> {
|
||||
let value = std::env::var("ATTUNE_WORKER_TYPE").ok()?;
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"local" => Some(WorkerType::Local),
|
||||
"remote" => Some(WorkerType::Remote),
|
||||
"container" => Some(WorkerType::Container),
|
||||
other => {
|
||||
warn!("Ignoring unrecognized ATTUNE_WORKER_TYPE value: {}", other);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new worker registration manager
|
||||
pub fn new(pool: PgPool, config: &Config) -> Self {
|
||||
let worker_name = config
|
||||
.worker
|
||||
.as_ref()
|
||||
.and_then(|w| w.name.clone())
|
||||
.or_else(Self::legacy_worker_name)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"worker-{}",
|
||||
@@ -48,6 +100,7 @@ impl WorkerRegistration {
|
||||
.worker
|
||||
.as_ref()
|
||||
.and_then(|w| w.worker_type)
|
||||
.or_else(Self::legacy_worker_type)
|
||||
.unwrap_or(WorkerType::Local);
|
||||
|
||||
let worker_role = WorkerRole::Action;
|
||||
@@ -86,6 +139,8 @@ impl WorkerRegistration {
|
||||
json!(env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
|
||||
Self::inject_agent_capabilities(&mut capabilities);
|
||||
|
||||
// Placeholder for runtimes (will be detected asynchronously)
|
||||
capabilities.insert("runtimes".to_string(), json!(Vec::<String>::new()));
|
||||
|
||||
@@ -397,7 +452,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_detected_runtimes_json_structure() {
|
||||
// Test the JSON structure that set_detected_runtimes builds
|
||||
let runtimes = vec![
|
||||
let runtimes = [
|
||||
DetectedRuntime {
|
||||
name: "python".to_string(),
|
||||
path: "/usr/bin/python3".to_string(),
|
||||
@@ -461,4 +516,28 @@ mod tests {
|
||||
let value = json!(false);
|
||||
assert_eq!(value, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_agent_capabilities_from_env() {
|
||||
std::env::set_var(ATTUNE_AGENT_MODE_ENV, "TRUE");
|
||||
std::env::set_var(ATTUNE_AGENT_BINARY_NAME_ENV, "attune-agent");
|
||||
std::env::set_var(ATTUNE_AGENT_BINARY_VERSION_ENV, "1.2.3");
|
||||
|
||||
let mut capabilities = HashMap::new();
|
||||
WorkerRegistration::inject_agent_capabilities(&mut capabilities);
|
||||
|
||||
assert_eq!(capabilities.get("agent_mode"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
capabilities.get("agent_binary_name"),
|
||||
Some(&json!("attune-agent"))
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities.get("agent_binary_version"),
|
||||
Some(&json!("1.2.3"))
|
||||
);
|
||||
|
||||
std::env::remove_var(ATTUNE_AGENT_MODE_ENV);
|
||||
std::env::remove_var(ATTUNE_AGENT_BINARY_NAME_ENV);
|
||||
std::env::remove_var(ATTUNE_AGENT_BINARY_VERSION_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,52 @@ fn bash_single_quote_escape(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
fn format_command_for_log(cmd: &Command) -> String {
|
||||
let program = cmd.as_std().get_program().to_string_lossy().into_owned();
|
||||
let args = cmd
|
||||
.as_std()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let cwd = cmd
|
||||
.as_std()
|
||||
.get_current_dir()
|
||||
.map(|dir| dir.display().to_string())
|
||||
.unwrap_or_else(|| "<inherit>".to_string());
|
||||
let env = cmd
|
||||
.as_std()
|
||||
.get_envs()
|
||||
.map(|(key, value)| {
|
||||
let key = key.to_string_lossy().into_owned();
|
||||
let value = value
|
||||
.map(|v| {
|
||||
if is_sensitive_env_var(&key) {
|
||||
"<redacted>".to_string()
|
||||
} else {
|
||||
v.to_string_lossy().into_owned()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "<unset>".to_string());
|
||||
format!("{key}={value}")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
format!(
|
||||
"program={program}, args={args:?}, cwd={cwd}, env={env:?}",
|
||||
args = args,
|
||||
env = env,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_sensitive_env_var(key: &str) -> bool {
|
||||
let upper = key.to_ascii_uppercase();
|
||||
upper.contains("TOKEN")
|
||||
|| upper.contains("SECRET")
|
||||
|| upper.contains("PASSWORD")
|
||||
|| upper.ends_with("_KEY")
|
||||
|| upper == "KEY"
|
||||
}
|
||||
|
||||
/// A generic runtime driven by `RuntimeExecutionConfig` from the database.
|
||||
///
|
||||
/// Each `ProcessRuntime` instance corresponds to a row in the `runtime` table.
|
||||
@@ -784,12 +830,9 @@ impl Runtime for ProcessRuntime {
|
||||
// resolved against the current pack/env directories.
|
||||
if !effective_config.env_vars.is_empty() {
|
||||
let vars = effective_config.build_template_vars_with_env(&pack_dir, env_dir_opt);
|
||||
for (key, value_template) in &effective_config.env_vars {
|
||||
let resolved = RuntimeExecutionConfig::resolve_template(value_template, &vars);
|
||||
debug!(
|
||||
"Setting runtime env var: {}={} (template: {})",
|
||||
key, resolved, value_template
|
||||
);
|
||||
for (key, env_var_config) in &effective_config.env_vars {
|
||||
let resolved = env_var_config.resolve(&vars, env.get(key).map(String::as_str));
|
||||
debug!("Setting runtime env var: {}={}", key, resolved);
|
||||
env.insert(key.clone(), resolved);
|
||||
}
|
||||
}
|
||||
@@ -897,10 +940,10 @@ impl Runtime for ProcessRuntime {
|
||||
}
|
||||
};
|
||||
|
||||
// Log the full command about to be executed
|
||||
// Log the spawned process accurately instead of using Command's shell-like Debug output.
|
||||
info!(
|
||||
"Running command: {:?} (action: '{}', execution_id: {}, working_dir: {:?})",
|
||||
cmd,
|
||||
"Running command: {} (action: '{}', execution_id: {}, working_dir: {:?})",
|
||||
format_command_for_log(&cmd),
|
||||
context.action_ref,
|
||||
context.execution_id,
|
||||
working_dir
|
||||
@@ -1016,7 +1059,8 @@ mod tests {
|
||||
use super::*;
|
||||
use attune_common::models::runtime::{
|
||||
DependencyConfig, EnvironmentConfig, InlineExecutionConfig, InlineExecutionStrategy,
|
||||
InterpreterConfig, RuntimeExecutionConfig,
|
||||
InterpreterConfig, RuntimeEnvVarConfig, RuntimeEnvVarOperation, RuntimeEnvVarSpec,
|
||||
RuntimeExecutionConfig,
|
||||
};
|
||||
use attune_common::models::{OutputFormat, ParameterDelivery, ParameterFormat};
|
||||
use std::collections::HashMap;
|
||||
@@ -1331,6 +1375,88 @@ mod tests {
|
||||
assert!(result.stdout.contains("hello from python process runtime"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_python_file_with_pack_lib_on_pythonpath() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let packs_dir = temp_dir.path().join("packs");
|
||||
let pack_dir = packs_dir.join("testpack");
|
||||
let actions_dir = pack_dir.join("actions");
|
||||
let lib_dir = pack_dir.join("lib");
|
||||
std::fs::create_dir_all(&actions_dir).unwrap();
|
||||
std::fs::create_dir_all(&lib_dir).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
lib_dir.join("helper.py"),
|
||||
"def message():\n return 'hello from pack lib'\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
actions_dir.join("hello.py"),
|
||||
"import helper\nimport os\nprint(helper.message())\nprint(os.environ['PYTHONPATH'])\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert(
|
||||
"PYTHONPATH".to_string(),
|
||||
RuntimeEnvVarConfig::Spec(RuntimeEnvVarSpec {
|
||||
value: "{pack_dir}/lib".to_string(),
|
||||
operation: RuntimeEnvVarOperation::Prepend,
|
||||
separator: ":".to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
let runtime = ProcessRuntime::new(
|
||||
"python".to_string(),
|
||||
RuntimeExecutionConfig {
|
||||
interpreter: InterpreterConfig {
|
||||
binary: "python3".to_string(),
|
||||
args: vec![],
|
||||
file_extension: Some(".py".to_string()),
|
||||
},
|
||||
inline_execution: InlineExecutionConfig::default(),
|
||||
environment: None,
|
||||
dependencies: None,
|
||||
env_vars,
|
||||
},
|
||||
packs_dir,
|
||||
temp_dir.path().join("runtime_envs"),
|
||||
);
|
||||
|
||||
let mut env = HashMap::new();
|
||||
env.insert("PYTHONPATH".to_string(), "/existing/pythonpath".to_string());
|
||||
|
||||
let context = ExecutionContext {
|
||||
execution_id: 3,
|
||||
action_ref: "testpack.hello".to_string(),
|
||||
parameters: HashMap::new(),
|
||||
env,
|
||||
secrets: HashMap::new(),
|
||||
timeout: Some(10),
|
||||
working_dir: None,
|
||||
entry_point: "hello.py".to_string(),
|
||||
code: None,
|
||||
code_path: Some(actions_dir.join("hello.py")),
|
||||
runtime_name: Some("python".to_string()),
|
||||
runtime_config_override: None,
|
||||
runtime_env_dir_suffix: None,
|
||||
selected_runtime_version: None,
|
||||
max_stdout_bytes: 1024 * 1024,
|
||||
max_stderr_bytes: 1024 * 1024,
|
||||
parameter_delivery: ParameterDelivery::default(),
|
||||
parameter_format: ParameterFormat::default(),
|
||||
output_format: OutputFormat::default(),
|
||||
cancel_token: None,
|
||||
};
|
||||
|
||||
let result = runtime.execute(context).await.unwrap();
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert!(result.stdout.contains("hello from pack lib"));
|
||||
assert!(result
|
||||
.stdout
|
||||
.contains(&format!("{}/lib:/existing/pythonpath", pack_dir.display())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_inline_code() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,544 +1,12 @@
|
||||
//! Runtime Auto-Detection Module
|
||||
//!
|
||||
//! Provides lightweight, database-free runtime detection for the Universal Worker Agent.
|
||||
//! Unlike [`attune_common::runtime_detection::RuntimeDetector`] which queries the database
|
||||
//! for runtime definitions and verification metadata, this module probes the local system
|
||||
//! directly by checking for well-known interpreter binaries on PATH.
|
||||
//!
|
||||
//! This is designed for the agent entrypoint (`attune-agent`) which is injected into
|
||||
//! arbitrary containers and must discover what runtimes are available without any
|
||||
//! database connectivity at detection time.
|
||||
//!
|
||||
//! # Detection Strategy
|
||||
//!
|
||||
//! For each candidate runtime, the detector:
|
||||
//! 1. Checks if a binary exists and is executable using `which`-style PATH lookup
|
||||
//! 2. Optionally runs a version command (e.g., `python3 --version`) to capture the version
|
||||
//! 3. Returns a list of [`DetectedRuntime`] structs with name, path, and version info
|
||||
//!
|
||||
//! # Supported Runtimes
|
||||
//!
|
||||
//! | Runtime | Binaries checked (in order) | Version command |
|
||||
//! |----------|-------------------------------|-------------------------|
|
||||
//! | shell | `bash`, `sh` | `bash --version` |
|
||||
//! | python | `python3`, `python` | `python3 --version` |
|
||||
//! | node | `node`, `nodejs` | `node --version` |
|
||||
//! | ruby | `ruby` | `ruby --version` |
|
||||
//! | go | `go` | `go version` |
|
||||
//! | java | `java` | `java -version` |
|
||||
//! | r | `Rscript` | `Rscript --version` |
|
||||
//! | perl | `perl` | `perl --version` |
|
||||
//! Compatibility wrapper around the shared agent runtime detection module.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, info};
|
||||
pub use attune_common::agent_runtime_detection::{
|
||||
detect_runtimes, format_as_env_value, DetectedRuntime,
|
||||
};
|
||||
|
||||
/// A runtime interpreter discovered on the local system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectedRuntime {
|
||||
/// Canonical runtime name (e.g., "shell", "python", "node").
|
||||
/// These names align with the normalized names from
|
||||
/// [`attune_common::runtime_detection::normalize_runtime_name`].
|
||||
pub name: String,
|
||||
|
||||
/// Absolute path to the interpreter binary (as resolved by `which`).
|
||||
pub path: String,
|
||||
|
||||
/// Version string if a version check command succeeded (e.g., "3.12.1").
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for DetectedRuntime {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.version {
|
||||
Some(v) => write!(f, "{} ({}, v{})", self.name, self.path, v),
|
||||
None => write!(f, "{} ({})", self.name, self.path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate runtime to probe for during detection.
|
||||
struct RuntimeCandidate {
|
||||
/// Canonical name for this runtime (used in ATTUNE_WORKER_RUNTIMES).
|
||||
name: &'static str,
|
||||
|
||||
/// Binary names to try, in priority order. The first one found wins.
|
||||
binaries: &'static [&'static str],
|
||||
|
||||
/// Arguments to pass to the binary to get a version string.
|
||||
version_args: &'static [&'static str],
|
||||
|
||||
/// How to extract the version from command output.
|
||||
version_parser: VersionParser,
|
||||
}
|
||||
|
||||
/// Strategy for parsing version output from a command.
|
||||
enum VersionParser {
|
||||
/// Extract a version pattern like "X.Y.Z" from the combined stdout+stderr output.
|
||||
/// This handles the common case where the version appears somewhere in the output
|
||||
/// (e.g., "Python 3.12.1", "node v20.11.0", "go1.22.0").
|
||||
SemverLike,
|
||||
|
||||
/// Java uses `-version` which writes to stderr, and the format is
|
||||
/// `openjdk version "21.0.1"` or `java version "1.8.0_392"`.
|
||||
JavaStyle,
|
||||
}
|
||||
|
||||
/// All candidate runtimes to probe, in detection order.
|
||||
fn candidates() -> Vec<RuntimeCandidate> {
|
||||
vec![
|
||||
RuntimeCandidate {
|
||||
name: "shell",
|
||||
binaries: &["bash", "sh"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "python",
|
||||
binaries: &["python3", "python"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "node",
|
||||
binaries: &["node", "nodejs"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "ruby",
|
||||
binaries: &["ruby"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "go",
|
||||
binaries: &["go"],
|
||||
version_args: &["version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "java",
|
||||
binaries: &["java"],
|
||||
version_args: &["-version"],
|
||||
version_parser: VersionParser::JavaStyle,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "r",
|
||||
binaries: &["Rscript"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "perl",
|
||||
binaries: &["perl"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Detect available runtimes by probing the local system for known interpreter binaries.
|
||||
///
|
||||
/// This function performs synchronous subprocess calls (`std::process::Command`) since
|
||||
/// it is a one-time startup operation. It checks each candidate runtime's binaries
|
||||
/// in priority order using `which`-style PATH lookup, and optionally captures the
|
||||
/// interpreter version.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of [`DetectedRuntime`] for each runtime that was found on the system.
|
||||
/// The order matches the detection order (shell first, then python, node, etc.).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use attune_worker::runtime_detect::detect_runtimes;
|
||||
///
|
||||
/// let runtimes = detect_runtimes();
|
||||
/// for rt in &runtimes {
|
||||
/// println!("Found: {}", rt);
|
||||
/// }
|
||||
/// // Convert to ATTUNE_WORKER_RUNTIMES format
|
||||
/// let names: Vec<&str> = runtimes.iter().map(|r| r.name.as_str()).collect();
|
||||
/// println!("ATTUNE_WORKER_RUNTIMES={}", names.join(","));
|
||||
/// ```
|
||||
pub fn detect_runtimes() -> Vec<DetectedRuntime> {
|
||||
info!("Starting runtime auto-detection...");
|
||||
|
||||
let mut detected = Vec::new();
|
||||
|
||||
for candidate in candidates() {
|
||||
match detect_single_runtime(&candidate) {
|
||||
Some(runtime) => {
|
||||
info!(" ✓ Detected: {}", runtime);
|
||||
detected.push(runtime);
|
||||
}
|
||||
None => {
|
||||
debug!(" ✗ Not found: {}", candidate.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Runtime auto-detection complete: found {} runtime(s): [{}]",
|
||||
detected.len(),
|
||||
detected
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
detected
|
||||
}
|
||||
|
||||
/// Attempt to detect a single runtime by checking its candidate binaries.
|
||||
fn detect_single_runtime(candidate: &RuntimeCandidate) -> Option<DetectedRuntime> {
|
||||
for binary in candidate.binaries {
|
||||
if let Some(path) = which_binary(binary) {
|
||||
debug!(
|
||||
"Found {} at {} (for runtime '{}')",
|
||||
binary, path, candidate.name
|
||||
);
|
||||
|
||||
// Attempt to get version info (non-fatal if it fails)
|
||||
let version = get_version(&path, candidate.version_args, &candidate.version_parser);
|
||||
|
||||
return Some(DetectedRuntime {
|
||||
name: candidate.name.to_string(),
|
||||
path,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Look up a binary on PATH, similar to the `which` command.
|
||||
///
|
||||
/// Uses `which <binary>` on the system to resolve the full path.
|
||||
/// Returns `None` if the binary is not found or `which` fails.
|
||||
fn which_binary(binary: &str) -> Option<String> {
|
||||
// First check well-known absolute paths for shell interpreters
|
||||
// (these may not be on PATH in minimal containers)
|
||||
if binary == "bash" || binary == "sh" {
|
||||
let absolute_path = format!("/bin/{}", binary);
|
||||
if std::path::Path::new(&absolute_path).exists() {
|
||||
return Some(absolute_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to PATH lookup via `which`
|
||||
match Command::new("which").arg(binary).output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// `which` itself not found — try `command -v` as fallback
|
||||
debug!("'which' command failed ({}), trying 'command -v'", e);
|
||||
match Command::new("sh")
|
||||
.args(["-c", &format!("command -v {}", binary)])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a version command and parse the version string from the output.
|
||||
fn get_version(binary_path: &str, version_args: &[&str], parser: &VersionParser) -> Option<String> {
|
||||
let output = match Command::new(binary_path).args(version_args).output() {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
debug!("Failed to run version command for {}: {}", binary_path, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
match parser {
|
||||
VersionParser::SemverLike => parse_semver_like(&combined),
|
||||
VersionParser::JavaStyle => parse_java_version(&combined),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a semver-like version (X.Y.Z or X.Y) from output text.
|
||||
///
|
||||
/// Handles common patterns:
|
||||
/// - "Python 3.12.1"
|
||||
/// - "node v20.11.0"
|
||||
/// - "go version go1.22.0 linux/amd64"
|
||||
/// - "GNU bash, version 5.2.15(1)-release"
|
||||
/// - "ruby 3.2.2 (2023-03-30 revision e51014f9c0)"
|
||||
/// - "perl 5, version 36, subversion 0 (v5.36.0)"
|
||||
fn parse_semver_like(output: &str) -> Option<String> {
|
||||
// Try to find a pattern like X.Y.Z or X.Y (with optional leading 'v')
|
||||
// Also handle go's "go1.22.0" format
|
||||
let re = regex::Regex::new(r"(?:v|go)?(\d+\.\d+(?:\.\d+)?)").ok()?;
|
||||
|
||||
if let Some(captures) = re.captures(output) {
|
||||
captures.get(1).map(|m| m.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Java's peculiar version output format.
|
||||
///
|
||||
/// Java writes to stderr and uses formats like:
|
||||
/// - `openjdk version "21.0.1" 2023-10-17`
|
||||
/// - `java version "1.8.0_392"`
|
||||
fn parse_java_version(output: &str) -> Option<String> {
|
||||
// Look for version inside quotes first
|
||||
let quoted_re = regex::Regex::new(r#"version\s+"([^"]+)""#).ok()?;
|
||||
if let Some(captures) = quoted_re.captures(output) {
|
||||
return captures.get(1).map(|m| m.as_str().to_string());
|
||||
}
|
||||
|
||||
// Fall back to semver-like parsing
|
||||
parse_semver_like(output)
|
||||
}
|
||||
|
||||
/// Format detected runtimes as a comma-separated string suitable for
|
||||
/// the `ATTUNE_WORKER_RUNTIMES` environment variable.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use attune_worker::runtime_detect::{detect_runtimes, format_as_env_value};
|
||||
///
|
||||
/// let runtimes = detect_runtimes();
|
||||
/// let env_val = format_as_env_value(&runtimes);
|
||||
/// // e.g., "shell,python,node"
|
||||
/// ```
|
||||
pub fn format_as_env_value(runtimes: &[DetectedRuntime]) -> String {
|
||||
runtimes
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// Print a human-readable detection report to stdout.
|
||||
///
|
||||
/// Used by the `--detect-only` flag to show detection results and exit.
|
||||
pub fn print_detection_report(runtimes: &[DetectedRuntime]) {
|
||||
println!("=== Attune Agent Runtime Detection Report ===");
|
||||
println!();
|
||||
|
||||
if runtimes.is_empty() {
|
||||
println!("No runtimes detected!");
|
||||
println!();
|
||||
println!("The agent could not find any supported interpreter binaries.");
|
||||
println!("Ensure at least one of the following is installed and on PATH:");
|
||||
println!(" - bash / sh (shell scripts)");
|
||||
println!(" - python3 / python (Python scripts)");
|
||||
println!(" - node / nodejs (Node.js scripts)");
|
||||
println!(" - ruby (Ruby scripts)");
|
||||
println!(" - go (Go programs)");
|
||||
println!(" - java (Java programs)");
|
||||
println!(" - Rscript (R scripts)");
|
||||
println!(" - perl (Perl scripts)");
|
||||
} else {
|
||||
println!("Detected {} runtime(s):", runtimes.len());
|
||||
println!();
|
||||
for rt in runtimes {
|
||||
let version_str = rt.version.as_deref().unwrap_or("unknown version");
|
||||
println!(" ✓ {:<10} {} ({})", rt.name, rt.path, version_str);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("ATTUNE_WORKER_RUNTIMES={}", format_as_env_value(runtimes));
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_python() {
|
||||
assert_eq!(
|
||||
parse_semver_like("Python 3.12.1"),
|
||||
Some("3.12.1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_node() {
|
||||
assert_eq!(parse_semver_like("v20.11.0"), Some("20.11.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_go() {
|
||||
assert_eq!(
|
||||
parse_semver_like("go version go1.22.0 linux/amd64"),
|
||||
Some("1.22.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_bash() {
|
||||
assert_eq!(
|
||||
parse_semver_like("GNU bash, version 5.2.15(1)-release (x86_64-pc-linux-gnu)"),
|
||||
Some("5.2.15".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_ruby() {
|
||||
assert_eq!(
|
||||
parse_semver_like("ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux]"),
|
||||
Some("3.2.2".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_two_part() {
|
||||
assert_eq!(
|
||||
parse_semver_like("SomeRuntime 1.5"),
|
||||
Some("1.5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_no_match() {
|
||||
assert_eq!(parse_semver_like("no version here"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_java_version_openjdk() {
|
||||
assert_eq!(
|
||||
parse_java_version(r#"openjdk version "21.0.1" 2023-10-17"#),
|
||||
Some("21.0.1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_java_version_legacy() {
|
||||
assert_eq!(
|
||||
parse_java_version(r#"java version "1.8.0_392""#),
|
||||
Some("1.8.0_392".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_as_env_value_empty() {
|
||||
let runtimes: Vec<DetectedRuntime> = vec![];
|
||||
assert_eq!(format_as_env_value(&runtimes), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_as_env_value_multiple() {
|
||||
let runtimes = vec![
|
||||
DetectedRuntime {
|
||||
name: "shell".to_string(),
|
||||
path: "/bin/bash".to_string(),
|
||||
version: Some("5.2.15".to_string()),
|
||||
},
|
||||
DetectedRuntime {
|
||||
name: "python".to_string(),
|
||||
path: "/usr/bin/python3".to_string(),
|
||||
version: Some("3.12.1".to_string()),
|
||||
},
|
||||
DetectedRuntime {
|
||||
name: "node".to_string(),
|
||||
path: "/usr/bin/node".to_string(),
|
||||
version: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(format_as_env_value(&runtimes), "shell,python,node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detected_runtime_display_with_version() {
|
||||
let rt = DetectedRuntime {
|
||||
name: "python".to_string(),
|
||||
path: "/usr/bin/python3".to_string(),
|
||||
version: Some("3.12.1".to_string()),
|
||||
};
|
||||
assert_eq!(format!("{}", rt), "python (/usr/bin/python3, v3.12.1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detected_runtime_display_without_version() {
|
||||
let rt = DetectedRuntime {
|
||||
name: "shell".to_string(),
|
||||
path: "/bin/bash".to_string(),
|
||||
version: None,
|
||||
};
|
||||
assert_eq!(format!("{}", rt), "shell (/bin/bash)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_runtimes_runs_without_panic() {
|
||||
// This test verifies the detection logic doesn't panic,
|
||||
// regardless of what's actually installed on the system.
|
||||
let runtimes = detect_runtimes();
|
||||
// We should at least find a shell on any Unix system
|
||||
// but we don't assert that since test environments vary.
|
||||
let _ = runtimes;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_which_binary_sh() {
|
||||
// /bin/sh should exist on virtually all Unix systems
|
||||
let result = which_binary("sh");
|
||||
assert!(result.is_some(), "Expected to find 'sh' on this system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_which_binary_nonexistent() {
|
||||
let result = which_binary("definitely_not_a_real_binary_xyz123");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_order() {
|
||||
let c = candidates();
|
||||
assert_eq!(c[0].name, "shell");
|
||||
assert_eq!(c[1].name, "python");
|
||||
assert_eq!(c[2].name, "node");
|
||||
assert_eq!(c[3].name, "ruby");
|
||||
assert_eq!(c[4].name, "go");
|
||||
assert_eq!(c[5].name, "java");
|
||||
assert_eq!(c[6].name, "r");
|
||||
assert_eq!(c[7].name, "perl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_binaries_priority() {
|
||||
let c = candidates();
|
||||
// shell prefers bash over sh
|
||||
assert_eq!(c[0].binaries, &["bash", "sh"]);
|
||||
// python prefers python3 over python
|
||||
assert_eq!(c[1].binaries, &["python3", "python"]);
|
||||
// node prefers node over nodejs
|
||||
assert_eq!(c[2].binaries, &["node", "nodejs"]);
|
||||
}
|
||||
attune_common::agent_runtime_detection::print_detection_report_for_env(
|
||||
"ATTUNE_WORKER_RUNTIMES",
|
||||
runtimes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use attune_common::mq::{
|
||||
MessageEnvelope, MessageType, PackRegisteredPayload, Publisher, PublisherConfig,
|
||||
};
|
||||
use attune_common::repositories::{execution::ExecutionRepository, FindById};
|
||||
use attune_common::runtime_detection::runtime_in_filter;
|
||||
use attune_common::runtime_detection::runtime_aliases_match_filter;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
@@ -253,10 +253,10 @@ impl WorkerService {
|
||||
// Uses alias-aware matching so that e.g. filter "node"
|
||||
// matches DB runtime name "Node.js" (lowercased to "node.js").
|
||||
if let Some(ref filter) = runtime_filter {
|
||||
if !runtime_in_filter(&rt_name, filter) {
|
||||
if !runtime_aliases_match_filter(&rt.aliases, filter) {
|
||||
debug!(
|
||||
"Skipping runtime '{}' (not in ATTUNE_WORKER_RUNTIMES filter)",
|
||||
rt_name
|
||||
"Skipping runtime '{}' (aliases {:?} not in ATTUNE_WORKER_RUNTIMES filter)",
|
||||
rt_name, rt.aliases
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use attune_common::models::RuntimeVersion;
|
||||
use attune_common::repositories::runtime_version::RuntimeVersionRepository;
|
||||
use attune_common::runtime_detection::runtime_in_filter;
|
||||
use attune_common::runtime_detection::runtime_aliases_match_filter;
|
||||
|
||||
/// Result of verifying all runtime versions at startup.
|
||||
#[derive(Debug)]
|
||||
@@ -95,7 +95,7 @@ pub async fn verify_all_runtime_versions(
|
||||
.to_lowercase();
|
||||
|
||||
if let Some(filter) = runtime_filter {
|
||||
if !runtime_in_filter(&rt_base_name, filter) {
|
||||
if !runtime_aliases_match_filter(&[rt_base_name.to_string()], filter) {
|
||||
debug!(
|
||||
"Skipping version '{}' of runtime '{}' (not in worker runtime filter)",
|
||||
version.version, version.runtime_ref,
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
#
|
||||
# Prerequisites:
|
||||
# The init-agent service (defined in docker-compose.yaml) must be present.
|
||||
# It builds the statically-linked agent binary and populates the agent_bin volume.
|
||||
# It builds the statically-linked worker and sensor agent binaries and populates the agent_bin volume.
|
||||
#
|
||||
# How it works:
|
||||
# 1. init-agent builds a musl-static attune-agent binary and copies it to the agent_bin volume
|
||||
# 1. init-agent builds musl-static injected agent binaries and copies them to the agent_bin volume
|
||||
# 2. Each worker service mounts agent_bin read-only and uses the agent as its entrypoint
|
||||
# 3. The agent auto-detects available runtimes in the container (python, ruby, node, etc.)
|
||||
# 4. No Dockerfile needed — just point at any container image with your desired runtime
|
||||
|
||||
@@ -126,8 +126,9 @@ services:
|
||||
restart: on-failure
|
||||
entrypoint: "" # Override Python image entrypoint
|
||||
|
||||
# Agent binary volume population (builds the statically-linked agent and copies it to a shared volume)
|
||||
# Other containers can use the agent binary by mounting agent_bin volume and running /opt/attune/agent/attune-agent
|
||||
# Agent binary volume population (builds the statically-linked worker and sensor agents)
|
||||
# Other containers can use these binaries by mounting agent_bin and running
|
||||
# /opt/attune/agent/attune-agent or /opt/attune/agent/attune-sensor-agent.
|
||||
init-agent:
|
||||
build:
|
||||
context: .
|
||||
@@ -142,7 +143,7 @@ services:
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"cp /usr/local/bin/attune-agent /opt/attune/agent/attune-agent && chmod +x /opt/attune/agent/attune-agent && echo 'Agent binary copied successfully'",
|
||||
"cp /usr/local/bin/attune-agent /opt/attune/agent/attune-agent && cp /usr/local/bin/attune-sensor-agent /opt/attune/agent/attune-sensor-agent && chmod +x /opt/attune/agent/attune-agent /opt/attune/agent/attune-sensor-agent && /usr/local/bin/attune-agent --version > /opt/attune/agent/attune-agent.version && /usr/local/bin/attune-sensor-agent --version > /opt/attune/agent/attune-sensor-agent.version && echo 'Agent binaries copied successfully'",
|
||||
]
|
||||
restart: "no"
|
||||
networks:
|
||||
@@ -223,6 +224,8 @@ services:
|
||||
- api_logs:/opt/attune/logs
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
@@ -294,19 +297,17 @@ services:
|
||||
# ============================================================================
|
||||
# Workers
|
||||
# ============================================================================
|
||||
# Default agent-based workers
|
||||
# These use stock runtime images and inject the statically-linked attune-agent
|
||||
# from the shared agent_bin volume instead of baking attune-worker into each image.
|
||||
worker-shell:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.worker.optimized
|
||||
target: worker-base
|
||||
args:
|
||||
BUILDKIT_INLINE_CACHE: 1
|
||||
image: debian:bookworm-slim
|
||||
container_name: attune-worker-shell
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_WORKER_RUNTIMES: shell
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-shell-01
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
@@ -315,6 +316,7 @@ services:
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- ./packs.dev:/opt/attune/packs.dev:rw
|
||||
@@ -322,6 +324,8 @@ services:
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_shell_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
@@ -333,7 +337,7 @@ services:
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-worker || exit 1"]
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -342,20 +346,15 @@ services:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Python worker - Shell + Python runtime
|
||||
# Python worker - official Python image with agent auto-detection
|
||||
worker-python:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.worker.optimized
|
||||
target: worker-python
|
||||
args:
|
||||
BUILDKIT_INLINE_CACHE: 1
|
||||
image: python:3.12-slim
|
||||
container_name: attune-worker-python
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_WORKER_RUNTIMES: shell,python
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-python-01
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
@@ -364,6 +363,7 @@ services:
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- ./packs.dev:/opt/attune/packs.dev:rw
|
||||
@@ -371,6 +371,8 @@ services:
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_python_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
@@ -382,7 +384,7 @@ services:
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-worker || exit 1"]
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -391,20 +393,15 @@ services:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Node worker - Shell + Node.js runtime
|
||||
# Node worker - official Node.js image with agent auto-detection
|
||||
worker-node:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.worker.optimized
|
||||
target: worker-node
|
||||
args:
|
||||
BUILDKIT_INLINE_CACHE: 1
|
||||
image: node:22-slim
|
||||
container_name: attune-worker-node
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_WORKER_RUNTIMES: shell,node
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-node-01
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
@@ -413,6 +410,7 @@ services:
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- ./packs.dev:/opt/attune/packs.dev:rw
|
||||
@@ -420,6 +418,8 @@ services:
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_node_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
@@ -431,7 +431,7 @@ services:
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-worker || exit 1"]
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -440,19 +440,17 @@ services:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Full worker - All runtimes (shell, python, node, native)
|
||||
# Full worker - Python + Node image with manual native capability override
|
||||
worker-full:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.worker.optimized
|
||||
target: worker-full
|
||||
args:
|
||||
BUILDKIT_INLINE_CACHE: 1
|
||||
image: nikolaik/python-nodejs:python3.12-nodejs22-slim
|
||||
container_name: attune-worker-full
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
# Keep native support enabled explicitly; the agent auto-detects interpreters
|
||||
# but "native" is a capability flag rather than a discoverable interpreter.
|
||||
ATTUNE_WORKER_RUNTIMES: shell,python,node,native
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-full-01
|
||||
@@ -462,6 +460,7 @@ services:
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- ./packs.dev:/opt/attune/packs.dev:rw
|
||||
@@ -469,6 +468,8 @@ services:
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_full_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
@@ -480,7 +481,7 @@ services:
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-worker || exit 1"]
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -489,18 +490,18 @@ services:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Default sensor service now uses the injected sensor agent inside a stock runtime image.
|
||||
sensor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.sensor.optimized
|
||||
target: sensor-full
|
||||
args:
|
||||
BUILDKIT_INLINE_CACHE: 1
|
||||
image: nikolaik/python-nodejs:python3.12-nodejs22-slim
|
||||
container_name: attune-sensor
|
||||
entrypoint: ["/opt/attune/agent/attune-sensor-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: debug
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
# Keep native support enabled explicitly; interpreter auto-detection does
|
||||
# not infer the synthetic "native" capability.
|
||||
ATTUNE_SENSOR_RUNTIMES: shell,python,node,native
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
@@ -511,12 +512,15 @@ services:
|
||||
ATTUNE_MQ_URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_PACKS_BASE_DIR: /opt/attune/packs
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:rw
|
||||
- ./packs.dev:/opt/attune/packs.dev:rw
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- sensor_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Multi-stage Dockerfile for the Attune universal worker agent
|
||||
# Multi-stage Dockerfile for the Attune injected agent binaries
|
||||
#
|
||||
# Builds a statically-linked attune-agent binary using musl, suitable for
|
||||
# injection into ANY container as a sidecar or init container. The binary
|
||||
# has zero runtime dependencies — no glibc, no libssl, no shared libraries.
|
||||
# Builds statically-linked `attune-agent` and `attune-sensor-agent` binaries
|
||||
# using musl, suitable for injection into arbitrary runtime containers.
|
||||
#
|
||||
# Stages:
|
||||
# builder - Cross-compile with musl for a fully static binary
|
||||
@@ -24,17 +23,20 @@
|
||||
# # volumes:
|
||||
# # - agent_binary:/shared
|
||||
#
|
||||
# Note: The agent binary is part of the worker crate (--bin attune-agent).
|
||||
# It connects to the Attune API and executes actions inside the target container.
|
||||
# Note: `attune-agent` lives in the worker crate and `attune-sensor-agent`
|
||||
# lives in the sensor crate.
|
||||
|
||||
ARG RUST_VERSION=1.92
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
ARG RUST_TARGET=x86_64-unknown-linux-musl
|
||||
|
||||
# ============================================================================
|
||||
# Stage 1: Builder - Cross-compile a statically-linked binary with musl
|
||||
# ============================================================================
|
||||
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS builder
|
||||
|
||||
ARG RUST_TARGET
|
||||
|
||||
# Install musl toolchain for static linking
|
||||
RUN apt-get update && apt-get install -y \
|
||||
musl-tools \
|
||||
@@ -43,8 +45,8 @@ RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add the musl target for fully static binaries
|
||||
RUN rustup target add x86_64-unknown-linux-musl
|
||||
# Add the requested musl target for fully static binaries
|
||||
RUN rustup target add ${RUST_TARGET}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -57,7 +59,8 @@ ENV SQLX_OFFLINE=true
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency caching layer
|
||||
# Copy only Cargo metadata first so `cargo fetch` is cached when only source
|
||||
# code changes. This follows the same pattern as Dockerfile.worker.optimized.
|
||||
# code changes. This follows the same selective-copy optimization pattern as
|
||||
# the other active Dockerfiles in this directory.
|
||||
# ---------------------------------------------------------------------------
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/common/Cargo.toml ./crates/common/Cargo.toml
|
||||
@@ -71,13 +74,14 @@ COPY crates/cli/Cargo.toml ./crates/cli/Cargo.toml
|
||||
|
||||
# Create minimal stub sources so cargo can resolve the workspace and fetch deps.
|
||||
# These are ONLY used for `cargo fetch` — never compiled.
|
||||
# NOTE: The worker crate has TWO binary targets (attune-worker and attune-agent),
|
||||
# so we create stubs for both to satisfy the workspace resolver.
|
||||
# NOTE: The worker crate has TWO binary targets and the sensor crate now has
|
||||
# two binary targets as well, so we create stubs for all of them.
|
||||
RUN mkdir -p crates/common/src && echo "" > crates/common/src/lib.rs && \
|
||||
mkdir -p crates/api/src && echo "fn main(){}" > crates/api/src/main.rs && \
|
||||
mkdir -p crates/executor/src && echo "fn main(){}" > crates/executor/src/main.rs && \
|
||||
mkdir -p crates/executor/benches && echo "fn main(){}" > crates/executor/benches/context_clone.rs && \
|
||||
mkdir -p crates/sensor/src && echo "fn main(){}" > crates/sensor/src/main.rs && \
|
||||
echo "fn main(){}" > crates/sensor/src/agent_main.rs && \
|
||||
mkdir -p crates/core-timer-sensor/src && echo "fn main(){}" > crates/core-timer-sensor/src/main.rs && \
|
||||
mkdir -p crates/worker/src && echo "fn main(){}" > crates/worker/src/main.rs && \
|
||||
echo "fn main(){}" > crates/worker/src/agent_main.rs && \
|
||||
@@ -97,22 +101,25 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||
COPY migrations/ ./migrations/
|
||||
COPY crates/ ./crates/
|
||||
|
||||
# Build ONLY the attune-agent binary, statically linked with musl.
|
||||
# Build the injected agent binaries, statically linked with musl.
|
||||
# Uses a dedicated cache ID (agent-target) so the musl target directory
|
||||
# doesn't collide with the glibc target cache used by other Dockerfiles.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
||||
--mount=type=cache,id=agent-target,target=/build/target,sharing=locked \
|
||||
cargo build --release --target x86_64-unknown-linux-musl --bin attune-agent && \
|
||||
cp /build/target/x86_64-unknown-linux-musl/release/attune-agent /build/attune-agent
|
||||
cargo build --release --target ${RUST_TARGET} --bin attune-agent --bin attune-sensor-agent && \
|
||||
cp /build/target/${RUST_TARGET}/release/attune-agent /build/attune-agent && \
|
||||
cp /build/target/${RUST_TARGET}/release/attune-sensor-agent /build/attune-sensor-agent
|
||||
|
||||
# Strip the binary to minimize size
|
||||
RUN strip /build/attune-agent
|
||||
# Strip the binaries to minimize size
|
||||
RUN strip /build/attune-agent && strip /build/attune-sensor-agent
|
||||
|
||||
# Verify the binary is statically linked and functional
|
||||
RUN ls -lh /build/attune-agent && \
|
||||
# Verify the binaries are statically linked and functional
|
||||
RUN ls -lh /build/attune-agent /build/attune-sensor-agent && \
|
||||
file /build/attune-agent && \
|
||||
ldd /build/attune-agent 2>&1 || true
|
||||
file /build/attune-sensor-agent && \
|
||||
ldd /build/attune-agent 2>&1 || true && \
|
||||
ldd /build/attune-sensor-agent 2>&1 || true
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2: agent-binary - Minimal image with just the static binary
|
||||
@@ -122,6 +129,7 @@ RUN ls -lh /build/attune-agent && \
|
||||
FROM scratch AS agent-binary
|
||||
|
||||
COPY --from=builder /build/attune-agent /usr/local/bin/attune-agent
|
||||
COPY --from=builder /build/attune-sensor-agent /usr/local/bin/attune-sensor-agent
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/attune-agent"]
|
||||
|
||||
@@ -149,5 +157,6 @@ ENTRYPOINT ["/usr/local/bin/attune-agent"]
|
||||
FROM busybox:1.36 AS agent-init
|
||||
|
||||
COPY --from=builder /build/attune-agent /usr/local/bin/attune-agent
|
||||
COPY --from=builder /build/attune-sensor-agent /usr/local/bin/attune-sensor-agent
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/attune-agent"]
|
||||
|
||||
6
docker/Dockerfile.agent-package
Normal file
6
docker/Dockerfile.agent-package
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM busybox:1.36
|
||||
|
||||
COPY dist/attune-agent /usr/local/bin/attune-agent
|
||||
COPY dist/attune-sensor-agent /usr/local/bin/attune-sensor-agent
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/attune-agent"]
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
COPY packs /source/packs
|
||||
COPY scripts/load_core_pack.py /scripts/load_core_pack.py
|
||||
COPY docker/init-packs.sh /init-packs.sh
|
||||
|
||||
RUN pip install --no-cache-dir psycopg2-binary pyyaml && \
|
||||
chmod +x /init-packs.sh
|
||||
|
||||
CMD ["/bin/sh", "/init-packs.sh"]
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM postgres:16-alpine
|
||||
|
||||
COPY docker/init-user.sh /init-user.sh
|
||||
|
||||
RUN chmod +x /init-user.sh
|
||||
|
||||
CMD ["/bin/sh", "/init-user.sh"]
|
||||
@@ -1,9 +0,0 @@
|
||||
FROM postgres:16-alpine
|
||||
|
||||
COPY migrations /migrations
|
||||
COPY docker/run-migrations.sh /run-migrations.sh
|
||||
COPY docker/init-roles.sql /docker/init-roles.sql
|
||||
|
||||
RUN chmod +x /run-migrations.sh
|
||||
|
||||
CMD ["/bin/sh", "/run-migrations.sh"]
|
||||
33
docker/Dockerfile.runtime
Normal file
33
docker/Dockerfile.runtime
Normal file
@@ -0,0 +1,33 @@
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -u 1000 attune && \
|
||||
mkdir -p /opt/attune/packs /opt/attune/logs /opt/attune/runtime_envs /opt/attune/config /opt/attune/artifacts /opt/attune/agent && \
|
||||
chown -R attune:attune /opt/attune
|
||||
|
||||
WORKDIR /opt/attune
|
||||
|
||||
COPY dist/attune-service-binary /usr/local/bin/attune-service
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
RUN chown -R attune:attune /opt/attune
|
||||
|
||||
USER attune
|
||||
|
||||
ENV RUST_LOG=info
|
||||
ENV ATTUNE_CONFIG=/opt/attune/config/config.yaml
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["/usr/local/bin/attune-service"]
|
||||
@@ -1,177 +0,0 @@
|
||||
# Multi-stage Dockerfile for Attune sensor service
|
||||
#
|
||||
# Simple and robust: build the entire workspace, then copy the sensor binary
|
||||
# into different runtime base images depending on language support needed.
|
||||
#
|
||||
# Targets:
|
||||
# sensor-base - Native sensors only (lightweight)
|
||||
# sensor-full - Native + Python + Node.js sensors
|
||||
#
|
||||
# Usage:
|
||||
# DOCKER_BUILDKIT=1 docker build --target sensor-base -t attune-sensor:base -f docker/Dockerfile.sensor.optimized .
|
||||
# DOCKER_BUILDKIT=1 docker build --target sensor-full -t attune-sensor:full -f docker/Dockerfile.sensor.optimized .
|
||||
#
|
||||
# Note: Packs are NOT copied into the image — they are mounted as volumes at runtime.
|
||||
|
||||
ARG RUST_VERSION=1.92
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
# ============================================================================
|
||||
# Stage 1: Builder - Compile the entire workspace
|
||||
# ============================================================================
|
||||
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Increase rustc stack size to prevent SIGSEGV during release builds
|
||||
ENV RUST_MIN_STACK=67108864
|
||||
|
||||
# Copy dependency metadata first so `cargo fetch` layer is cached
|
||||
# when only source code changes (Cargo.toml/Cargo.lock stay the same)
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/common/Cargo.toml ./crates/common/Cargo.toml
|
||||
COPY crates/api/Cargo.toml ./crates/api/Cargo.toml
|
||||
COPY crates/executor/Cargo.toml ./crates/executor/Cargo.toml
|
||||
COPY crates/sensor/Cargo.toml ./crates/sensor/Cargo.toml
|
||||
COPY crates/core-timer-sensor/Cargo.toml ./crates/core-timer-sensor/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml ./crates/worker/Cargo.toml
|
||||
COPY crates/notifier/Cargo.toml ./crates/notifier/Cargo.toml
|
||||
COPY crates/cli/Cargo.toml ./crates/cli/Cargo.toml
|
||||
|
||||
# Create minimal stub sources so cargo can resolve the workspace and fetch deps.
|
||||
# These are ONLY used for `cargo fetch` — never compiled.
|
||||
RUN mkdir -p crates/common/src && echo "" > crates/common/src/lib.rs && \
|
||||
mkdir -p crates/api/src && echo "fn main(){}" > crates/api/src/main.rs && \
|
||||
mkdir -p crates/executor/src && echo "fn main(){}" > crates/executor/src/main.rs && \
|
||||
mkdir -p crates/executor/benches && echo "fn main(){}" > crates/executor/benches/context_clone.rs && \
|
||||
mkdir -p crates/sensor/src && echo "fn main(){}" > crates/sensor/src/main.rs && \
|
||||
mkdir -p crates/core-timer-sensor/src && echo "fn main(){}" > crates/core-timer-sensor/src/main.rs && \
|
||||
mkdir -p crates/worker/src && echo "fn main(){}" > crates/worker/src/main.rs && \
|
||||
echo "fn main(){}" > crates/worker/src/agent_main.rs && \
|
||||
mkdir -p crates/notifier/src && echo "fn main(){}" > crates/notifier/src/main.rs && \
|
||||
mkdir -p crates/cli/src && echo "fn main(){}" > crates/cli/src/main.rs
|
||||
|
||||
# Download all dependencies (cached unless Cargo.toml/Cargo.lock change)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
||||
cargo fetch
|
||||
|
||||
# Now copy the real source code and migrations
|
||||
COPY migrations/ ./migrations/
|
||||
COPY crates/ ./crates/
|
||||
|
||||
# Build the entire workspace in release mode.
|
||||
# All binaries are compiled together, sharing dependency compilation.
|
||||
# target cache uses sharing=locked so concurrent service builds serialize
|
||||
# writes to the shared compilation cache instead of corrupting it.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
||||
--mount=type=cache,target=/build/target,sharing=locked \
|
||||
cargo build --release --workspace --bins -j 4 && \
|
||||
cp /build/target/release/attune-sensor /build/attune-sensor
|
||||
|
||||
# Verify the binary was built
|
||||
RUN ls -lh /build/attune-sensor && \
|
||||
file /build/attune-sensor
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2a: Base Sensor (Native sensors only)
|
||||
# Runtime capabilities: native binary sensors
|
||||
# ============================================================================
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS sensor-base
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
bash \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -u 1000 attune && \
|
||||
mkdir -p /opt/attune/packs /opt/attune/logs /opt/attune/runtime_envs /opt/attune/config && \
|
||||
chown -R attune:attune /opt/attune
|
||||
|
||||
WORKDIR /opt/attune
|
||||
|
||||
COPY --from=builder /build/attune-sensor /usr/local/bin/attune-sensor
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
USER attune
|
||||
|
||||
ENV RUST_LOG=info
|
||||
ENV ATTUNE_CONFIG=/opt/attune/config/config.yaml
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
CMD ["/usr/local/bin/attune-sensor"]
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2b: Full Sensor (Native + Python + Node.js sensors)
|
||||
# Runtime capabilities: native, python, node
|
||||
#
|
||||
# Uses debian-slim + apt python3 + NodeSource node so that interpreter
|
||||
# paths (/usr/bin/python3, /usr/bin/node) are identical to the worker
|
||||
# containers. This avoids broken symlinks and path mismatches when
|
||||
# sensors and workers share the runtime_envs volume.
|
||||
# ============================================================================
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS sensor-full
|
||||
|
||||
# Re-declare global ARG so it's available in RUN commands within this stage
|
||||
# (global ARGs are only automatically available in FROM instructions)
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
bash \
|
||||
build-essential \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js from NodeSource (same method and version as workers)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create python symlink for convenience
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
# Install common Python packages used by sensor scripts
|
||||
# Use --break-system-packages for Debian 12+ pip-in-system-python restrictions
|
||||
RUN pip3 install --no-cache-dir --break-system-packages \
|
||||
requests>=2.31.0 \
|
||||
pyyaml>=6.0 \
|
||||
jinja2>=3.1.0 \
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
RUN useradd -m -u 1000 attune && \
|
||||
mkdir -p /opt/attune/packs /opt/attune/logs /opt/attune/runtime_envs /opt/attune/config && \
|
||||
chown -R attune:attune /opt/attune
|
||||
|
||||
WORKDIR /opt/attune
|
||||
|
||||
COPY --from=builder /build/attune-sensor /usr/local/bin/attune-sensor
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
USER attune
|
||||
|
||||
ENV RUST_LOG=info
|
||||
ENV ATTUNE_CONFIG=/opt/attune/config/config.yaml
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
CMD ["/usr/local/bin/attune-sensor"]
|
||||
@@ -1,270 +0,0 @@
|
||||
# Multi-stage Dockerfile for Attune worker service
|
||||
#
|
||||
# Simple and robust: build the entire workspace, then copy the worker binary
|
||||
# into different runtime base images depending on language support needed.
|
||||
# No dummy source compilation, no selective crate copying, no fragile hacks.
|
||||
#
|
||||
# Targets:
|
||||
# worker-base - Shell only (lightweight)
|
||||
# worker-python - Shell + Python
|
||||
# worker-node - Shell + Node.js
|
||||
# worker-full - Shell + Python + Node.js + Native
|
||||
#
|
||||
# Usage:
|
||||
# DOCKER_BUILDKIT=1 docker build --target worker-base -t attune-worker:base -f docker/Dockerfile.worker.optimized .
|
||||
# DOCKER_BUILDKIT=1 docker build --target worker-python -t attune-worker:python -f docker/Dockerfile.worker.optimized .
|
||||
# DOCKER_BUILDKIT=1 docker build --target worker-node -t attune-worker:node -f docker/Dockerfile.worker.optimized .
|
||||
# DOCKER_BUILDKIT=1 docker build --target worker-full -t attune-worker:full -f docker/Dockerfile.worker.optimized .
|
||||
#
|
||||
# Note: Packs are NOT copied into the image — they are mounted as volumes at runtime.
|
||||
|
||||
ARG RUST_VERSION=1.92
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
# ============================================================================
|
||||
# Stage 1: Builder - Compile the entire workspace
|
||||
# ============================================================================
|
||||
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Increase rustc stack size to prevent SIGSEGV during release builds
|
||||
ENV RUST_MIN_STACK=67108864
|
||||
|
||||
# Copy dependency metadata first so `cargo fetch` layer is cached
|
||||
# when only source code changes (Cargo.toml/Cargo.lock stay the same)
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/common/Cargo.toml ./crates/common/Cargo.toml
|
||||
COPY crates/api/Cargo.toml ./crates/api/Cargo.toml
|
||||
COPY crates/executor/Cargo.toml ./crates/executor/Cargo.toml
|
||||
COPY crates/sensor/Cargo.toml ./crates/sensor/Cargo.toml
|
||||
COPY crates/core-timer-sensor/Cargo.toml ./crates/core-timer-sensor/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml ./crates/worker/Cargo.toml
|
||||
COPY crates/notifier/Cargo.toml ./crates/notifier/Cargo.toml
|
||||
COPY crates/cli/Cargo.toml ./crates/cli/Cargo.toml
|
||||
|
||||
# Create minimal stub sources so cargo can resolve the workspace and fetch deps.
|
||||
# Unlike the old approach, these are ONLY used for `cargo fetch` — never compiled.
|
||||
RUN mkdir -p crates/common/src && echo "" > crates/common/src/lib.rs && \
|
||||
mkdir -p crates/api/src && echo "fn main(){}" > crates/api/src/main.rs && \
|
||||
mkdir -p crates/executor/src && echo "fn main(){}" > crates/executor/src/main.rs && \
|
||||
mkdir -p crates/executor/benches && echo "fn main(){}" > crates/executor/benches/context_clone.rs && \
|
||||
mkdir -p crates/sensor/src && echo "fn main(){}" > crates/sensor/src/main.rs && \
|
||||
mkdir -p crates/core-timer-sensor/src && echo "fn main(){}" > crates/core-timer-sensor/src/main.rs && \
|
||||
mkdir -p crates/worker/src && echo "fn main(){}" > crates/worker/src/main.rs && \
|
||||
echo "fn main(){}" > crates/worker/src/agent_main.rs && \
|
||||
mkdir -p crates/notifier/src && echo "fn main(){}" > crates/notifier/src/main.rs && \
|
||||
mkdir -p crates/cli/src && echo "fn main(){}" > crates/cli/src/main.rs
|
||||
|
||||
# Download all dependencies (cached unless Cargo.toml/Cargo.lock change)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
||||
cargo fetch
|
||||
|
||||
# Now copy the real source code and migrations
|
||||
COPY migrations/ ./migrations/
|
||||
COPY crates/ ./crates/
|
||||
|
||||
# Build the entire workspace in release mode.
|
||||
# All binaries are compiled together, sharing dependency compilation.
|
||||
# target cache uses sharing=locked so concurrent service builds serialize
|
||||
# writes to the shared compilation cache instead of corrupting it.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
||||
--mount=type=cache,target=/build/target,sharing=locked \
|
||||
cargo build --release --workspace --bins -j 4 && \
|
||||
cp /build/target/release/attune-worker /build/attune-worker
|
||||
|
||||
# Verify the binary was built
|
||||
RUN ls -lh /build/attune-worker && \
|
||||
file /build/attune-worker
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2a: Base Worker (Shell only)
|
||||
# Runtime capabilities: shell
|
||||
# ============================================================================
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS worker-base
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
bash \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -u 1000 attune && \
|
||||
mkdir -p /opt/attune/packs /opt/attune/logs /opt/attune/runtime_envs /opt/attune/config && \
|
||||
chown -R attune:attune /opt/attune
|
||||
|
||||
WORKDIR /opt/attune
|
||||
|
||||
COPY --from=builder /build/attune-worker /usr/local/bin/attune-worker
|
||||
|
||||
USER attune
|
||||
|
||||
ENV ATTUNE_WORKER_RUNTIMES="shell"
|
||||
ENV ATTUNE_WORKER_TYPE="container"
|
||||
ENV RUST_LOG=info
|
||||
ENV ATTUNE_CONFIG=/opt/attune/config/config.yaml
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD pgrep -f attune-worker || exit 1
|
||||
|
||||
CMD ["/usr/local/bin/attune-worker"]
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2b: Python Worker (Shell + Python)
|
||||
# Runtime capabilities: shell, python
|
||||
#
|
||||
# Uses debian-slim + apt python3 (NOT the python: Docker image) so that
|
||||
# python3 lives at /usr/bin/python3 — the same path as worker-full.
|
||||
# This avoids broken venv symlinks when multiple workers share the
|
||||
# runtime_envs volume.
|
||||
# ============================================================================
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS worker-python
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
build-essential \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create python symlink for convenience
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
# Use --break-system-packages for Debian 12+ pip-in-system-python restrictions
|
||||
RUN pip3 install --no-cache-dir --break-system-packages \
|
||||
requests>=2.31.0 \
|
||||
pyyaml>=6.0 \
|
||||
jinja2>=3.1.0 \
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
RUN useradd -m -u 1000 attune && \
|
||||
mkdir -p /opt/attune/packs /opt/attune/logs /opt/attune/runtime_envs /opt/attune/config && \
|
||||
chown -R attune:attune /opt/attune
|
||||
|
||||
WORKDIR /opt/attune
|
||||
|
||||
COPY --from=builder /build/attune-worker /usr/local/bin/attune-worker
|
||||
|
||||
USER attune
|
||||
|
||||
ENV ATTUNE_WORKER_RUNTIMES="shell,python"
|
||||
ENV ATTUNE_WORKER_TYPE="container"
|
||||
ENV RUST_LOG=info
|
||||
ENV ATTUNE_CONFIG=/opt/attune/config/config.yaml
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD pgrep -f attune-worker || exit 1
|
||||
|
||||
CMD ["/usr/local/bin/attune-worker"]
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2c: Node Worker (Shell + Node.js)
|
||||
# Runtime capabilities: shell, node
|
||||
#
|
||||
# Uses debian-slim + NodeSource apt repo (NOT the node: Docker image) so that
|
||||
# node lives at /usr/bin/node — the same path as worker-full.
|
||||
# This avoids path mismatches when multiple workers share volumes.
|
||||
# ============================================================================
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS worker-node
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js from NodeSource (same method as worker-full)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -u 1000 attune && \
|
||||
mkdir -p /opt/attune/packs /opt/attune/logs /opt/attune/runtime_envs /opt/attune/config && \
|
||||
chown -R attune:attune /opt/attune
|
||||
|
||||
WORKDIR /opt/attune
|
||||
|
||||
COPY --from=builder /build/attune-worker /usr/local/bin/attune-worker
|
||||
|
||||
USER attune
|
||||
|
||||
ENV ATTUNE_WORKER_RUNTIMES="shell,node"
|
||||
ENV ATTUNE_WORKER_TYPE="container"
|
||||
ENV RUST_LOG=info
|
||||
ENV ATTUNE_CONFIG=/opt/attune/config/config.yaml
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD pgrep -f attune-worker || exit 1
|
||||
|
||||
CMD ["/usr/local/bin/attune-worker"]
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2d: Full Worker (All runtimes)
|
||||
# Runtime capabilities: shell, python, node, native
|
||||
# ============================================================================
|
||||
FROM debian:${DEBIAN_VERSION} AS worker-full
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
curl \
|
||||
build-essential \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js from NodeSource (same method and version as worker-node)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
# Use --break-system-packages for Debian 12+ pip-in-system-python restrictions
|
||||
RUN pip3 install --no-cache-dir --break-system-packages \
|
||||
requests>=2.31.0 \
|
||||
pyyaml>=6.0 \
|
||||
jinja2>=3.1.0 \
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
RUN useradd -m -u 1000 attune && \
|
||||
mkdir -p /opt/attune/packs /opt/attune/logs /opt/attune/runtime_envs /opt/attune/config && \
|
||||
chown -R attune:attune /opt/attune
|
||||
|
||||
WORKDIR /opt/attune
|
||||
|
||||
COPY --from=builder /build/attune-worker /usr/local/bin/attune-worker
|
||||
|
||||
USER attune
|
||||
|
||||
ENV ATTUNE_WORKER_RUNTIMES="shell,python,node,native"
|
||||
ENV ATTUNE_WORKER_TYPE="container"
|
||||
ENV RUST_LOG=info
|
||||
ENV ATTUNE_CONFIG=/opt/attune/config/config.yaml
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD pgrep -f attune-worker || exit 1
|
||||
|
||||
CMD ["/usr/local/bin/attune-worker"]
|
||||
@@ -33,12 +33,10 @@ curl -X POST http://localhost:8080/auth/login \
|
||||
- Uses build argument `SERVICE` to specify which service to build
|
||||
- Example: `docker build --build-arg SERVICE=api -f docker/Dockerfile.optimized -t attune-api .`
|
||||
|
||||
- **`Dockerfile.worker.optimized`** - Multi-stage Dockerfile for containerized workers with different runtime capabilities
|
||||
- Supports 4 variants: `worker-base`, `worker-python`, `worker-node`, `worker-full`
|
||||
- See [README.worker.md](./README.worker.md) for details
|
||||
- **`Dockerfile.agent`** - Multi-stage Dockerfile for the statically-linked agent image
|
||||
- Builds the `agent-init` image used to populate the shared agent binary volume
|
||||
|
||||
- **`Dockerfile.sensor.optimized`** - Multi-stage Dockerfile for the sensor service
|
||||
- Supports `sensor-base` and `sensor-full`
|
||||
- **`Dockerfile.pack-binaries`** - Pack binary builder used by `scripts/build-pack-binaries.sh`
|
||||
|
||||
- **`Dockerfile.web`** - Multi-stage Dockerfile for React Web UI
|
||||
- Builds with Node.js and serves with Nginx
|
||||
@@ -122,8 +120,8 @@ docker compose build api
|
||||
# Web UI
|
||||
docker compose build web
|
||||
|
||||
# Worker service
|
||||
docker compose build worker
|
||||
# Notifier service
|
||||
docker compose build notifier
|
||||
```
|
||||
|
||||
### Build with Custom Args
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
# Attune Worker Containers
|
||||
|
||||
This directory contains Docker configurations for building Attune worker containers with different runtime capabilities.
|
||||
|
||||
## Overview
|
||||
|
||||
Attune workers can run in containers with specialized runtime environments. Workers automatically declare their capabilities when they register with the system, enabling intelligent action scheduling based on runtime requirements.
|
||||
|
||||
## Worker Variants
|
||||
|
||||
### Base Worker (`worker-base`)
|
||||
- **Runtimes**: `shell`
|
||||
- **Base Image**: Debian Bookworm Slim
|
||||
- **Size**: ~580 MB
|
||||
- **Use Case**: Lightweight workers for shell scripts and basic automation
|
||||
- **Build**: `make docker-build-worker-base`
|
||||
|
||||
### Python Worker (`worker-python`)
|
||||
- **Runtimes**: `shell`, `python`
|
||||
- **Base Image**: Python 3.11 Slim
|
||||
- **Size**: ~1.2 GB
|
||||
- **Includes**: pip, virtualenv, common Python libraries (requests, pyyaml, jinja2, python-dateutil)
|
||||
- **Use Case**: Python actions and scripts with dependencies
|
||||
- **Build**: `make docker-build-worker-python`
|
||||
|
||||
### Node.js Worker (`worker-node`)
|
||||
- **Runtimes**: `shell`, `node`
|
||||
- **Base Image**: Node 20 Slim
|
||||
- **Size**: ~760 MB
|
||||
- **Includes**: npm, yarn
|
||||
- **Use Case**: JavaScript/TypeScript actions and npm packages
|
||||
- **Build**: `make docker-build-worker-node`
|
||||
|
||||
### Full Worker (`worker-full`)
|
||||
- **Runtimes**: `shell`, `python`, `node`, `native`
|
||||
- **Base Image**: Debian Bookworm
|
||||
- **Size**: ~1.6 GB
|
||||
- **Includes**: Python 3.x, Node.js 20, build tools
|
||||
- **Use Case**: General-purpose automation requiring multiple runtimes
|
||||
- **Build**: `make docker-build-worker-full`
|
||||
|
||||
## Building Worker Images
|
||||
|
||||
### Build All Variants
|
||||
```bash
|
||||
make docker-build-workers
|
||||
```
|
||||
|
||||
### Build Individual Variants
|
||||
```bash
|
||||
# Base worker (shell only)
|
||||
make docker-build-worker-base
|
||||
|
||||
# Python worker
|
||||
make docker-build-worker-python
|
||||
|
||||
# Node.js worker
|
||||
make docker-build-worker-node
|
||||
|
||||
# Full worker (all runtimes)
|
||||
make docker-build-worker-full
|
||||
```
|
||||
|
||||
### Direct Docker Build
|
||||
```bash
|
||||
# Using Docker directly with BuildKit
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--target worker-python \
|
||||
-t attune-worker:python \
|
||||
-f docker/Dockerfile.worker.optimized \
|
||||
.
|
||||
```
|
||||
|
||||
## Running Workers
|
||||
|
||||
### Using Docker Compose
|
||||
```bash
|
||||
# Start specific worker type
|
||||
docker-compose up -d worker-python
|
||||
|
||||
# Start all workers
|
||||
docker-compose up -d worker-shell worker-python worker-node worker-full
|
||||
|
||||
# Scale workers
|
||||
docker-compose up -d --scale worker-python=3
|
||||
```
|
||||
|
||||
### Using Docker Run
|
||||
```bash
|
||||
docker run -d \
|
||||
--name worker-python-01 \
|
||||
--network attune_attune-network \
|
||||
-e ATTUNE_WORKER_NAME=worker-python-01 \
|
||||
-e ATTUNE_WORKER_RUNTIMES=shell,python \
|
||||
-e ATTUNE__DATABASE__URL=postgresql://attune:attune@postgres:5432/attune \
|
||||
-e ATTUNE__MESSAGE_QUEUE__URL=amqp://attune:attune@rabbitmq:5672 \
|
||||
-v $(pwd)/packs:/opt/attune/packs:ro \
|
||||
attune-worker:python
|
||||
```
|
||||
|
||||
## Runtime Capability Declaration
|
||||
|
||||
Workers declare their capabilities in three ways (in order of precedence):
|
||||
|
||||
### 1. Environment Variable (Highest Priority)
|
||||
```bash
|
||||
ATTUNE_WORKER_RUNTIMES="shell,python,custom"
|
||||
```
|
||||
|
||||
### 2. Configuration File
|
||||
```yaml
|
||||
worker:
|
||||
capabilities:
|
||||
runtimes: ["shell", "python"]
|
||||
```
|
||||
|
||||
### 3. Auto-Detection (Fallback)
|
||||
Workers automatically detect available runtimes by checking for binaries:
|
||||
- `python3` or `python` → adds `python`
|
||||
- `node` → adds `node`
|
||||
- Always includes `shell` and `native`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Key Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `ATTUNE_WORKER_NAME` | Unique worker identifier | `worker-python-01` |
|
||||
| `ATTUNE_WORKER_RUNTIMES` | Comma-separated runtime list | `shell,python` |
|
||||
| `ATTUNE_WORKER_TYPE` | Worker type | `container` |
|
||||
| `ATTUNE__DATABASE__URL` | PostgreSQL connection | `postgresql://...` |
|
||||
| `ATTUNE__MESSAGE_QUEUE__URL` | RabbitMQ connection | `amqp://...` |
|
||||
| `RUST_LOG` | Log level | `info`, `debug`, `trace` |
|
||||
|
||||
### Resource Limits
|
||||
|
||||
Set CPU and memory limits in `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
worker-python:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
## Custom Worker Images
|
||||
|
||||
### Extend Python Worker
|
||||
|
||||
Create a custom worker with additional packages:
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile.worker.ml
|
||||
FROM attune-worker:python
|
||||
|
||||
USER root
|
||||
|
||||
# Install ML packages
|
||||
RUN pip install --no-cache-dir \
|
||||
pandas \
|
||||
numpy \
|
||||
scikit-learn \
|
||||
torch
|
||||
|
||||
USER attune
|
||||
|
||||
ENV ATTUNE_WORKER_RUNTIMES="shell,python,ml"
|
||||
```
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
docker build -t attune-worker:ml -f Dockerfile.worker.ml .
|
||||
docker run -d --name worker-ml-01 ... attune-worker:ml
|
||||
```
|
||||
|
||||
### Add New Runtime
|
||||
|
||||
Example: Adding Ruby support
|
||||
|
||||
```dockerfile
|
||||
FROM attune-worker:base
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ruby-full \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER attune
|
||||
|
||||
ENV ATTUNE_WORKER_RUNTIMES="shell,ruby"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Multi-stage Build
|
||||
|
||||
The `Dockerfile.worker.optimized` uses a multi-stage build pattern:
|
||||
|
||||
1. **Builder Stage**: Compiles the Rust worker binary
|
||||
- Uses BuildKit cache mounts for fast incremental builds
|
||||
- Shared across all worker variants
|
||||
|
||||
2. **Runtime Stages**: Creates specialized worker images
|
||||
- `worker-base`: Minimal shell runtime
|
||||
- `worker-python`: Python runtime
|
||||
- `worker-node`: Node.js runtime
|
||||
- `worker-full`: All runtimes
|
||||
|
||||
### Build Cache
|
||||
|
||||
BuildKit cache mounts dramatically speed up builds:
|
||||
- First build: ~5-6 minutes
|
||||
- Incremental builds: ~30-60 seconds
|
||||
|
||||
Cache is shared across builds using `sharing=locked` to prevent race conditions.
|
||||
|
||||
## Security
|
||||
|
||||
### Non-root Execution
|
||||
All workers run as user `attune` (UID 1000)
|
||||
|
||||
### Read-only Packs
|
||||
Pack files are mounted read-only to prevent modification:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./packs:/opt/attune/packs:ro # :ro = read-only
|
||||
```
|
||||
|
||||
### Network Isolation
|
||||
Workers run in isolated Docker network with only necessary service access
|
||||
|
||||
### Secret Management
|
||||
Use environment variables for sensitive data; never hardcode in images
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check Worker Registration
|
||||
```bash
|
||||
docker-compose exec postgres psql -U attune -d attune -c \
|
||||
"SELECT name, worker_type, status, capabilities->>'runtimes' as runtimes FROM worker;"
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker-compose logs -f worker-python
|
||||
```
|
||||
|
||||
### Check Resource Usage
|
||||
```bash
|
||||
docker stats attune-worker-python
|
||||
```
|
||||
|
||||
### Verify Health
|
||||
```bash
|
||||
docker-compose ps | grep worker
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Worker Not Registering
|
||||
|
||||
**Check database connectivity:**
|
||||
```bash
|
||||
docker-compose logs worker-python | grep -i database
|
||||
```
|
||||
|
||||
**Verify environment:**
|
||||
```bash
|
||||
docker-compose exec worker-python env | grep ATTUNE
|
||||
```
|
||||
|
||||
### Runtime Not Detected
|
||||
|
||||
**Check runtime availability:**
|
||||
```bash
|
||||
docker-compose exec worker-python python3 --version
|
||||
docker-compose exec worker-python node --version
|
||||
```
|
||||
|
||||
**Force runtime declaration:**
|
||||
```bash
|
||||
ATTUNE_WORKER_RUNTIMES=shell,python
|
||||
```
|
||||
|
||||
### Actions Not Scheduled
|
||||
|
||||
**Verify runtime match:**
|
||||
```sql
|
||||
-- Check action runtime requirement
|
||||
SELECT a.ref, r.name as runtime
|
||||
FROM action a
|
||||
JOIN runtime r ON a.runtime = r.id
|
||||
WHERE a.ref = 'core.my_action';
|
||||
|
||||
-- Check worker capabilities
|
||||
SELECT name, capabilities->>'runtimes'
|
||||
FROM worker
|
||||
WHERE status = 'active';
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Image Sizes
|
||||
|
||||
| Image | Size | Build Time (Cold) | Build Time (Cached) |
|
||||
|-------|------|-------------------|---------------------|
|
||||
| worker-base | ~580 MB | ~5 min | ~30 sec |
|
||||
| worker-python | ~1.2 GB | ~6 min | ~45 sec |
|
||||
| worker-node | ~760 MB | ~6 min | ~45 sec |
|
||||
| worker-full | ~1.6 GB | ~7 min | ~60 sec |
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Use specific variants**: Don't use `worker-full` if you only need Python
|
||||
2. **Enable BuildKit**: Dramatically speeds up builds
|
||||
3. **Layer caching**: Order Dockerfile commands from least to most frequently changed
|
||||
4. **Multi-stage builds**: Keeps runtime images small
|
||||
|
||||
## Files
|
||||
|
||||
- `Dockerfile.worker.optimized` - Multi-stage worker Dockerfile with all variants
|
||||
- `README.worker.md` - This file
|
||||
- `../docker-compose.yaml` - Service definitions for all workers
|
||||
|
||||
## References
|
||||
|
||||
- [Worker Containerization Design](../docs/worker-containerization.md)
|
||||
- [Quick Start Guide](../docs/worker-containers-quickstart.md)
|
||||
- [Worker Service Architecture](../docs/architecture/worker-service.md)
|
||||
- [Production Deployment](../docs/production-deployment.md)
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Build all workers
|
||||
make docker-build-workers
|
||||
|
||||
# Start all workers
|
||||
docker-compose up -d worker-shell worker-python worker-node worker-full
|
||||
|
||||
# Check worker status
|
||||
docker-compose exec postgres psql -U attune -d attune -c \
|
||||
"SELECT name, status, capabilities FROM worker;"
|
||||
|
||||
# View Python worker logs
|
||||
docker-compose logs -f worker-python
|
||||
|
||||
# Restart worker
|
||||
docker-compose restart worker-python
|
||||
|
||||
# Scale Python workers
|
||||
docker-compose up -d --scale worker-python=3
|
||||
|
||||
# Stop all workers
|
||||
docker-compose stop worker-shell worker-python worker-node worker-full
|
||||
```
|
||||
64
docker/distributable/README.md
Normal file
64
docker/distributable/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Attune Docker Dist Bundle
|
||||
|
||||
This directory is a distributable Docker bundle built from the main workspace compose setup.
|
||||
|
||||
It is designed to run Attune without building the Rust services locally:
|
||||
|
||||
- `api`, `executor`, `notifier`, `agent`, and `web` pull published images
|
||||
- database bootstrap, user bootstrap, and pack loading run from local scripts shipped in this bundle
|
||||
- workers and sensor still use stock runtime images plus the published injected agent binaries
|
||||
|
||||
## Registry Defaults
|
||||
|
||||
The compose file defaults to:
|
||||
|
||||
- registry: `git.rdrx.app/attune-system`
|
||||
- tag: `latest`
|
||||
|
||||
Override them with env vars:
|
||||
|
||||
```bash
|
||||
export ATTUNE_IMAGE_REGISTRY=git.rdrx.app/attune-system
|
||||
export ATTUNE_IMAGE_TAG=latest
|
||||
```
|
||||
|
||||
If the registry requires auth:
|
||||
|
||||
```bash
|
||||
docker login git.rdrx.app
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or with an explicit tag:
|
||||
|
||||
```bash
|
||||
ATTUNE_IMAGE_TAG=sha-xxxxxxxxxxxx docker compose up -d
|
||||
```
|
||||
|
||||
## Rebuild Bundle
|
||||
|
||||
Refresh this bundle and create a tarball from the workspace root:
|
||||
|
||||
```bash
|
||||
bash scripts/package-docker-dist.sh
|
||||
```
|
||||
|
||||
## Included Assets
|
||||
|
||||
- `docker-compose.yaml` - published-image compose stack
|
||||
- `config.docker.yaml` - container config mounted into services
|
||||
- `docker/` - init scripts and SQL helpers
|
||||
- `migrations/` - schema migrations for the bootstrap job
|
||||
- `packs/core/` - builtin core pack content
|
||||
- `scripts/load_core_pack.py` - pack loader used by `init-packs`
|
||||
|
||||
## Current Limitation
|
||||
|
||||
The publish workflow does not currently publish dedicated worker or sensor runtime images. This bundle therefore keeps using stock runtime images with the published `attune/agent` image for injection.
|
||||
159
docker/distributable/config.docker.yaml
Normal file
159
docker/distributable/config.docker.yaml
Normal file
@@ -0,0 +1,159 @@
|
||||
# Attune Docker Environment Configuration
|
||||
# This file overrides base config.yaml settings for Docker deployments
|
||||
|
||||
environment: docker
|
||||
|
||||
# Docker database (PostgreSQL container)
|
||||
database:
|
||||
url: postgresql://attune:attune@postgres:5432/attune
|
||||
max_connections: 20
|
||||
min_connections: 5
|
||||
acquire_timeout: 30
|
||||
idle_timeout: 600
|
||||
max_lifetime: 1800
|
||||
log_statements: false
|
||||
schema: "attune"
|
||||
|
||||
# Docker message queue (RabbitMQ container)
|
||||
message_queue:
|
||||
url: amqp://attune:attune@rabbitmq:5672
|
||||
connection_timeout: 30
|
||||
heartbeat: 60
|
||||
prefetch_count: 10
|
||||
rabbitmq:
|
||||
worker_queue_ttl_ms: 300000 # 5 minutes - expire unprocessed executions
|
||||
dead_letter:
|
||||
enabled: true
|
||||
exchange: attune.dlx
|
||||
ttl_ms: 86400000 # 24 hours - retain DLQ messages for debugging
|
||||
|
||||
# Docker cache (Redis container - optional)
|
||||
cache:
|
||||
enabled: true
|
||||
url: redis://redis:6379
|
||||
connection_timeout: 5
|
||||
default_ttl: 3600
|
||||
|
||||
# API server configuration
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
cors_origins:
|
||||
- http://localhost
|
||||
- http://localhost:3000
|
||||
- http://localhost:3001
|
||||
- http://localhost:3002
|
||||
- http://localhost:5173
|
||||
- http://127.0.0.1:3000
|
||||
- http://127.0.0.1:3001
|
||||
- http://127.0.0.1:3002
|
||||
- http://127.0.0.1:5173
|
||||
- http://web
|
||||
request_timeout: 60
|
||||
max_request_size: 10485760 # 10MB
|
||||
|
||||
# Logging configuration
|
||||
log:
|
||||
level: info
|
||||
format: json # Structured logs for container environments
|
||||
console: true
|
||||
|
||||
# Security settings (MUST override via environment variables in production)
|
||||
security:
|
||||
jwt_secret: ${JWT_SECRET}
|
||||
jwt_access_expiration: 3600 # 1 hour
|
||||
jwt_refresh_expiration: 604800 # 7 days
|
||||
encryption_key: ${ENCRYPTION_KEY}
|
||||
enable_auth: true
|
||||
allow_self_registration: false
|
||||
login_page:
|
||||
show_local_login: true
|
||||
show_oidc_login: true
|
||||
oidc:
|
||||
# example local dev
|
||||
enabled: false
|
||||
discovery_url: https://my.sso.provider.com/.well-known/openid-configuration
|
||||
client_id: 31d194737840d32bd3afe6474826976bae346d77247a158c4dc43887278eb605
|
||||
client_secret: xL2C9WOC8shZ2QrZs9VFa10JK1Ob95xcMtZU3N86H1Pz0my5
|
||||
provider_name: my-sso-provider
|
||||
provider_label: My SSO Provider
|
||||
provider_icon_url: https://my.sso.provider.com/favicon.ico
|
||||
redirect_uri: http://localhost:3000/auth/callback
|
||||
post_logout_redirect_uri: http://localhost:3000/login
|
||||
scopes:
|
||||
- groups
|
||||
|
||||
# Packs directory (mounted volume in containers)
|
||||
packs_base_dir: /opt/attune/packs
|
||||
|
||||
# Runtime environments directory (isolated envs like virtualenvs, node_modules).
|
||||
# Kept separate from packs so pack directories remain clean and read-only.
|
||||
# Pattern: {runtime_envs_dir}/{pack_ref}/{runtime_name}
|
||||
runtime_envs_dir: /opt/attune/runtime_envs
|
||||
|
||||
# Artifacts directory (shared volume for file-based artifact storage).
|
||||
# File-type artifacts are written here by execution processes and served by the API.
|
||||
# Pattern: {artifacts_dir}/{ref_slug}/v{version}.{ext}
|
||||
artifacts_dir: /opt/attune/artifacts
|
||||
|
||||
# Executor service configuration
|
||||
executor:
|
||||
service_name: attune-executor
|
||||
max_concurrent_executions: 50
|
||||
heartbeat_interval: 30
|
||||
task_timeout: 300
|
||||
cleanup_interval: 120
|
||||
scheduling_interval: 5
|
||||
retry_max_attempts: 3
|
||||
retry_backoff_multiplier: 2.0
|
||||
retry_backoff_max: 300
|
||||
scheduled_timeout: 300 # 5 minutes - fail executions stuck in SCHEDULED
|
||||
timeout_check_interval: 60 # Check every minute for stale executions
|
||||
enable_timeout_monitor: true
|
||||
|
||||
# Worker service configuration
|
||||
worker:
|
||||
service_name: attune-worker
|
||||
worker_type: container
|
||||
max_concurrent_tasks: 20
|
||||
heartbeat_interval: 10 # Reduced from 30s for faster stale detection (staleness = 30s)
|
||||
task_timeout: 300
|
||||
cleanup_interval: 120
|
||||
work_dir: /tmp/attune-worker
|
||||
python:
|
||||
executable: python3
|
||||
venv_dir: /tmp/attune-worker/venvs
|
||||
requirements_timeout: 300
|
||||
nodejs:
|
||||
executable: node
|
||||
npm_executable: npm
|
||||
modules_dir: /tmp/attune-worker/node_modules
|
||||
install_timeout: 300
|
||||
shell:
|
||||
executable: /bin/bash
|
||||
allowed_shells:
|
||||
- /bin/bash
|
||||
- /bin/sh
|
||||
|
||||
# Sensor service configuration
|
||||
sensor:
|
||||
service_name: attune-sensor
|
||||
heartbeat_interval: 10 # Reduced from 30s for faster stale detection
|
||||
max_concurrent_sensors: 50
|
||||
sensor_timeout: 300
|
||||
polling_interval: 10
|
||||
cleanup_interval: 120
|
||||
|
||||
# Notifier service configuration
|
||||
notifier:
|
||||
service_name: attune-notifier
|
||||
websocket_host: 0.0.0.0
|
||||
websocket_port: 8081
|
||||
heartbeat_interval: 30
|
||||
connection_timeout: 60
|
||||
max_connections: 1000
|
||||
message_buffer_size: 10000
|
||||
|
||||
# Agent binary distribution (serves the agent binary via API for remote downloads)
|
||||
agent:
|
||||
binary_dir: /opt/attune/agent
|
||||
581
docker/distributable/docker-compose.yaml
Normal file
581
docker/distributable/docker-compose.yaml
Normal file
@@ -0,0 +1,581 @@
|
||||
name: attune
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: timescale/timescaledb:2.17.2-pg16
|
||||
container_name: attune-postgres
|
||||
environment:
|
||||
POSTGRES_USER: attune
|
||||
POSTGRES_PASSWORD: attune
|
||||
POSTGRES_DB: attune
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U attune"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
migrations:
|
||||
image: postgres:16-alpine
|
||||
container_name: attune-migrations
|
||||
volumes:
|
||||
- ./migrations:/migrations:ro
|
||||
- ./docker/run-migrations.sh:/run-migrations.sh:ro
|
||||
- ./docker/init-roles.sql:/docker/init-roles.sql:ro
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: attune
|
||||
DB_PASSWORD: attune
|
||||
DB_NAME: attune
|
||||
MIGRATIONS_DIR: /migrations
|
||||
command: ["/bin/sh", "/run-migrations.sh"]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- attune-network
|
||||
restart: on-failure
|
||||
|
||||
init-user:
|
||||
image: postgres:16-alpine
|
||||
container_name: attune-init-user
|
||||
volumes:
|
||||
- ./docker/init-user.sh:/init-user.sh:ro
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: attune
|
||||
DB_PASSWORD: attune
|
||||
DB_NAME: attune
|
||||
DB_SCHEMA: public
|
||||
TEST_LOGIN: ${ATTUNE_TEST_LOGIN:-test@attune.local}
|
||||
TEST_PASSWORD: ${ATTUNE_TEST_PASSWORD:-TestPass123!}
|
||||
TEST_DISPLAY_NAME: ${ATTUNE_TEST_DISPLAY_NAME:-Test User}
|
||||
command: ["/bin/sh", "/init-user.sh"]
|
||||
depends_on:
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- attune-network
|
||||
restart: on-failure
|
||||
|
||||
init-packs:
|
||||
image: python:3.11-slim
|
||||
container_name: attune-init-packs
|
||||
volumes:
|
||||
- ./packs:/source/packs:ro
|
||||
- ./scripts/load_core_pack.py:/scripts/load_core_pack.py:ro
|
||||
- ./docker/init-packs.sh:/init-packs.sh:ro
|
||||
- packs_data:/opt/attune/packs
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: attune
|
||||
DB_PASSWORD: attune
|
||||
DB_NAME: attune
|
||||
DB_SCHEMA: public
|
||||
SOURCE_PACKS_DIR: /source/packs
|
||||
TARGET_PACKS_DIR: /opt/attune/packs
|
||||
LOADER_SCRIPT: /scripts/load_core_pack.py
|
||||
DEFAULT_ADMIN_LOGIN: ${ATTUNE_TEST_LOGIN:-test@attune.local}
|
||||
DEFAULT_ADMIN_PERMISSION_SET_REF: core.admin
|
||||
command: ["/bin/sh", "/init-packs.sh"]
|
||||
depends_on:
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- attune-network
|
||||
restart: on-failure
|
||||
entrypoint: ""
|
||||
|
||||
init-agent:
|
||||
image: ${ATTUNE_IMAGE_REGISTRY:-git.rdrx.app/attune-system}/attune/agent:${ATTUNE_IMAGE_TAG:-latest}
|
||||
container_name: attune-init-agent
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent
|
||||
entrypoint:
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"cp /usr/local/bin/attune-agent /opt/attune/agent/attune-agent && cp /usr/local/bin/attune-sensor-agent /opt/attune/agent/attune-sensor-agent && chmod +x /opt/attune/agent/attune-agent /opt/attune/agent/attune-sensor-agent && /usr/local/bin/attune-agent --version > /opt/attune/agent/attune-agent.version && /usr/local/bin/attune-sensor-agent --version > /opt/attune/agent/attune-sensor-agent.version && echo 'Agent binaries copied successfully'",
|
||||
]
|
||||
restart: "no"
|
||||
networks:
|
||||
- attune-network
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13-management-alpine
|
||||
container_name: attune-rabbitmq
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: attune
|
||||
RABBITMQ_DEFAULT_PASS: attune
|
||||
RABBITMQ_DEFAULT_VHOST: /
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: attune-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
|
||||
api:
|
||||
image: ${ATTUNE_IMAGE_REGISTRY:-git.rdrx.app/attune-system}/attune/api:${ATTUNE_IMAGE_TAG:-latest}
|
||||
container_name: attune-api
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE__CACHE__URL: redis://redis:6379
|
||||
ATTUNE__WORKER__WORKER_TYPE: container
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:rw
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- api_logs:/opt/attune/logs
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
condition: service_completed_successfully
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
executor:
|
||||
image: ${ATTUNE_IMAGE_REGISTRY:-git.rdrx.app/attune-system}/attune/executor:${ATTUNE_IMAGE_TAG:-latest}
|
||||
container_name: attune-executor
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE__CACHE__URL: redis://redis:6379
|
||||
ATTUNE__WORKER__WORKER_TYPE: container
|
||||
volumes:
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- artifacts_data:/opt/attune/artifacts:ro
|
||||
- executor_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
condition: service_completed_successfully
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker-shell:
|
||||
image: debian:bookworm-slim
|
||||
container_name: attune-worker-shell
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-shell-01
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_shell_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
condition: service_completed_successfully
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker-python:
|
||||
image: python:3.12-slim
|
||||
container_name: attune-worker-python
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-python-01
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_python_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
condition: service_completed_successfully
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker-node:
|
||||
image: node:22-slim
|
||||
container_name: attune-worker-node
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-node-01
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_node_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
condition: service_completed_successfully
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
worker-full:
|
||||
image: nikolaik/python-nodejs:python3.12-nodejs22-slim
|
||||
container_name: attune-worker-full
|
||||
entrypoint: ["/opt/attune/agent/attune-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_WORKER_RUNTIMES: shell,python,node,native
|
||||
ATTUNE_WORKER_TYPE: container
|
||||
ATTUNE_WORKER_NAME: worker-full-01
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:ro
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- artifacts_data:/opt/attune/artifacts
|
||||
- worker_full_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
condition: service_completed_successfully
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f attune-agent || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
sensor:
|
||||
image: nikolaik/python-nodejs:python3.12-nodejs22-slim
|
||||
container_name: attune-sensor
|
||||
entrypoint: ["/opt/attune/agent/attune-sensor-agent"]
|
||||
stop_grace_period: 45s
|
||||
environment:
|
||||
RUST_LOG: debug
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE_SENSOR_RUNTIMES: shell,python,node,native
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE__WORKER__WORKER_TYPE: container
|
||||
ATTUNE_API_URL: http://attune-api:8080
|
||||
ATTUNE_MQ_URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE_PACKS_BASE_DIR: /opt/attune/packs
|
||||
volumes:
|
||||
- agent_bin:/opt/attune/agent:ro
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- packs_data:/opt/attune/packs:rw
|
||||
- runtime_envs:/opt/attune/runtime_envs
|
||||
- sensor_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
init-agent:
|
||||
condition: service_completed_successfully
|
||||
init-packs:
|
||||
condition: service_completed_successfully
|
||||
init-user:
|
||||
condition: service_completed_successfully
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
notifier:
|
||||
image: ${ATTUNE_IMAGE_REGISTRY:-git.rdrx.app/attune-system}/attune/notifier:${ATTUNE_IMAGE_TAG:-latest}
|
||||
container_name: attune-notifier
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||
ATTUNE__DATABASE__SCHEMA: public
|
||||
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
|
||||
ATTUNE__WORKER__WORKER_TYPE: container
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
|
||||
- notifier_logs:/opt/attune/logs
|
||||
depends_on:
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
image: ${ATTUNE_IMAGE_REGISTRY:-git.rdrx.app/attune-system}/attune/web:${ATTUNE_IMAGE_TAG:-latest}
|
||||
container_name: attune-web
|
||||
environment:
|
||||
API_URL: ${API_URL:-http://localhost:8080}
|
||||
WS_URL: ${WS_URL:-ws://localhost:8081}
|
||||
ENVIRONMENT: docker
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
notifier:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost/health",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- attune-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
rabbitmq_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
api_logs:
|
||||
driver: local
|
||||
executor_logs:
|
||||
driver: local
|
||||
worker_shell_logs:
|
||||
driver: local
|
||||
worker_python_logs:
|
||||
driver: local
|
||||
worker_node_logs:
|
||||
driver: local
|
||||
worker_full_logs:
|
||||
driver: local
|
||||
sensor_logs:
|
||||
driver: local
|
||||
notifier_logs:
|
||||
driver: local
|
||||
packs_data:
|
||||
driver: local
|
||||
runtime_envs:
|
||||
driver: local
|
||||
artifacts_data:
|
||||
driver: local
|
||||
agent_bin:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
attune-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
296
docker/distributable/docker/init-packs.sh
Executable file
296
docker/distributable/docker/init-packs.sh
Executable file
@@ -0,0 +1,296 @@
|
||||
#!/bin/sh
|
||||
# Initialize builtin packs for Attune
|
||||
# This script copies pack files to the shared volume and registers them in the database
|
||||
# Designed to run on python:3.11-slim (Debian-based) image
|
||||
|
||||
set -e
|
||||
|
||||
# Color output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration from environment
|
||||
DB_HOST="${DB_HOST:-postgres}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${DB_USER:-attune}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-attune}"
|
||||
DB_NAME="${DB_NAME:-attune}"
|
||||
DB_SCHEMA="${DB_SCHEMA:-public}"
|
||||
|
||||
# Pack directories
|
||||
SOURCE_PACKS_DIR="${SOURCE_PACKS_DIR:-/source/packs}"
|
||||
TARGET_PACKS_DIR="${TARGET_PACKS_DIR:-/opt/attune/packs}"
|
||||
|
||||
# Python loader script
|
||||
LOADER_SCRIPT="${LOADER_SCRIPT:-/scripts/load_core_pack.py}"
|
||||
DEFAULT_ADMIN_LOGIN="${DEFAULT_ADMIN_LOGIN:-}"
|
||||
DEFAULT_ADMIN_PERMISSION_SET_REF="${DEFAULT_ADMIN_PERMISSION_SET_REF:-core.admin}"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Attune Builtin Packs Initialization ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Install Python dependencies
|
||||
echo -e "${YELLOW}→${NC} Installing Python dependencies..."
|
||||
if pip install --quiet --no-cache-dir psycopg2-binary pyyaml; then
|
||||
echo -e "${GREEN}✓${NC} Python dependencies installed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to install Python dependencies"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Wait for database to be ready (using Python instead of psql to avoid needing postgresql-client)
|
||||
echo -e "${YELLOW}→${NC} Waiting for database to be ready..."
|
||||
until python3 -c "
|
||||
import psycopg2, sys
|
||||
try:
|
||||
conn = psycopg2.connect(host='$DB_HOST', port=$DB_PORT, user='$DB_USER', password='$DB_PASSWORD', dbname='$DB_NAME', connect_timeout=3)
|
||||
conn.close()
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; do
|
||||
echo -e "${YELLOW} ...${NC} Database is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
echo -e "${GREEN}✓${NC} Database is ready"
|
||||
|
||||
# Create target packs directory if it doesn't exist
|
||||
echo -e "${YELLOW}→${NC} Ensuring packs directory exists..."
|
||||
mkdir -p "$TARGET_PACKS_DIR"
|
||||
# Ensure the attune user (uid 1000) can write to the packs directory
|
||||
# so the API service can install packs at runtime
|
||||
chown -R 1000:1000 "$TARGET_PACKS_DIR"
|
||||
echo -e "${GREEN}✓${NC} Packs directory ready at: $TARGET_PACKS_DIR"
|
||||
|
||||
# Initialise runtime environments volume with correct ownership.
|
||||
# Workers (running as attune uid 1000) need write access to create
|
||||
# virtualenvs, node_modules, etc. at runtime.
|
||||
RUNTIME_ENVS_DIR="${RUNTIME_ENVS_DIR:-/opt/attune/runtime_envs}"
|
||||
if [ -d "$RUNTIME_ENVS_DIR" ] || mkdir -p "$RUNTIME_ENVS_DIR" 2>/dev/null; then
|
||||
chown -R 1000:1000 "$RUNTIME_ENVS_DIR"
|
||||
echo -e "${GREEN}✓${NC} Runtime environments directory ready at: $RUNTIME_ENVS_DIR"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Runtime environments directory not mounted, skipping"
|
||||
fi
|
||||
|
||||
# Initialise artifacts volume with correct ownership.
|
||||
# The API service (creates directories for file-backed artifact versions) and
|
||||
# workers (write artifact files during execution) both run as attune uid 1000.
|
||||
ARTIFACTS_DIR="${ARTIFACTS_DIR:-/opt/attune/artifacts}"
|
||||
if [ -d "$ARTIFACTS_DIR" ] || mkdir -p "$ARTIFACTS_DIR" 2>/dev/null; then
|
||||
chown -R 1000:1000 "$ARTIFACTS_DIR"
|
||||
echo -e "${GREEN}✓${NC} Artifacts directory ready at: $ARTIFACTS_DIR"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Artifacts directory not mounted, skipping"
|
||||
fi
|
||||
|
||||
# Check if source packs directory exists
|
||||
if [ ! -d "$SOURCE_PACKS_DIR" ]; then
|
||||
echo -e "${RED}✗${NC} Source packs directory not found: $SOURCE_PACKS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find all pack directories (directories with pack.yaml)
|
||||
echo ""
|
||||
echo -e "${BLUE}Discovering builtin packs...${NC}"
|
||||
echo "----------------------------------------"
|
||||
|
||||
PACK_COUNT=0
|
||||
COPIED_COUNT=0
|
||||
LOADED_COUNT=0
|
||||
|
||||
for pack_dir in "$SOURCE_PACKS_DIR"/*; do
|
||||
if [ -d "$pack_dir" ]; then
|
||||
pack_name=$(basename "$pack_dir")
|
||||
pack_yaml="$pack_dir/pack.yaml"
|
||||
|
||||
if [ -f "$pack_yaml" ]; then
|
||||
PACK_COUNT=$((PACK_COUNT + 1))
|
||||
echo -e "${BLUE}→${NC} Found pack: ${GREEN}$pack_name${NC}"
|
||||
|
||||
# Check if pack already exists in target
|
||||
target_pack_dir="$TARGET_PACKS_DIR/$pack_name"
|
||||
|
||||
if [ -d "$target_pack_dir" ]; then
|
||||
# Pack exists, update files to ensure we have latest (especially binaries)
|
||||
echo -e "${YELLOW} ⟳${NC} Pack exists at: $target_pack_dir, updating files..."
|
||||
if cp -rf "$pack_dir"/* "$target_pack_dir"/; then
|
||||
echo -e "${GREEN} ✓${NC} Updated pack files at: $target_pack_dir"
|
||||
else
|
||||
echo -e "${RED} ✗${NC} Failed to update pack"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Copy pack to target directory
|
||||
echo -e "${YELLOW} →${NC} Copying pack files..."
|
||||
if cp -r "$pack_dir" "$target_pack_dir"; then
|
||||
COPIED_COUNT=$((COPIED_COUNT + 1))
|
||||
echo -e "${GREEN} ✓${NC} Copied to: $target_pack_dir"
|
||||
else
|
||||
echo -e "${RED} ✗${NC} Failed to copy pack"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo ""
|
||||
|
||||
if [ $PACK_COUNT -eq 0 ]; then
|
||||
echo -e "${YELLOW}⚠${NC} No builtin packs found in $SOURCE_PACKS_DIR"
|
||||
echo -e "${BLUE}ℹ${NC} This is OK if you're running with no packs"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Pack Discovery Summary:${NC}"
|
||||
echo " Total packs found: $PACK_COUNT"
|
||||
echo " Newly copied: $COPIED_COUNT"
|
||||
echo " Already present: $((PACK_COUNT - COPIED_COUNT))"
|
||||
echo ""
|
||||
|
||||
# Load packs into database using Python loader
|
||||
if [ -f "$LOADER_SCRIPT" ]; then
|
||||
echo -e "${BLUE}Loading packs into database...${NC}"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Build database URL with schema support
|
||||
DATABASE_URL="postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME"
|
||||
|
||||
# Set search_path for the Python script if not using default schema
|
||||
if [ "$DB_SCHEMA" != "public" ]; then
|
||||
export PGOPTIONS="-c search_path=$DB_SCHEMA,public"
|
||||
fi
|
||||
|
||||
# Run the Python loader for each pack
|
||||
for pack_dir in "$TARGET_PACKS_DIR"/*; do
|
||||
if [ -d "$pack_dir" ]; then
|
||||
pack_name=$(basename "$pack_dir")
|
||||
pack_yaml="$pack_dir/pack.yaml"
|
||||
|
||||
if [ -f "$pack_yaml" ]; then
|
||||
echo -e "${YELLOW}→${NC} Loading pack: ${GREEN}$pack_name${NC}"
|
||||
|
||||
# Run Python loader
|
||||
if python3 "$LOADER_SCRIPT" \
|
||||
--database-url "$DATABASE_URL" \
|
||||
--pack-dir "$TARGET_PACKS_DIR" \
|
||||
--pack-name "$pack_name" \
|
||||
--schema "$DB_SCHEMA"; then
|
||||
LOADED_COUNT=$((LOADED_COUNT + 1))
|
||||
echo -e "${GREEN}✓${NC} Loaded pack: $pack_name"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to load pack: $pack_name"
|
||||
echo -e "${YELLOW}⚠${NC} Continuing with other packs..."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo ""
|
||||
echo -e "${BLUE}Database Loading Summary:${NC}"
|
||||
echo " Successfully loaded: $LOADED_COUNT"
|
||||
echo " Failed: $((PACK_COUNT - LOADED_COUNT))"
|
||||
echo ""
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Pack loader script not found: $LOADER_SCRIPT"
|
||||
echo -e "${BLUE}ℹ${NC} Packs copied but not registered in database"
|
||||
echo -e "${BLUE}ℹ${NC} You can manually load them later"
|
||||
fi
|
||||
|
||||
if [ -n "$DEFAULT_ADMIN_LOGIN" ] && [ "$LOADED_COUNT" -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Bootstrapping local admin assignment...${NC}"
|
||||
if python3 - <<PY
|
||||
import psycopg2
|
||||
import sys
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host="${DB_HOST}",
|
||||
port=${DB_PORT},
|
||||
user="${DB_USER}",
|
||||
password="${DB_PASSWORD}",
|
||||
dbname="${DB_NAME}",
|
||||
)
|
||||
conn.autocommit = False
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SET search_path TO ${DB_SCHEMA}, public")
|
||||
cur.execute("SELECT id FROM identity WHERE login = %s", ("${DEFAULT_ADMIN_LOGIN}",))
|
||||
identity_row = cur.fetchone()
|
||||
if identity_row is None:
|
||||
print(" ⚠ Default admin identity not found; skipping assignment")
|
||||
conn.rollback()
|
||||
sys.exit(0)
|
||||
|
||||
cur.execute("SELECT id FROM permission_set WHERE ref = %s", ("${DEFAULT_ADMIN_PERMISSION_SET_REF}",))
|
||||
permset_row = cur.fetchone()
|
||||
if permset_row is None:
|
||||
print(" ⚠ Default admin permission set not found; skipping assignment")
|
||||
conn.rollback()
|
||||
sys.exit(0)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO permission_assignment (identity, permset)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (identity, permset) DO NOTHING
|
||||
""",
|
||||
(identity_row[0], permset_row[0]),
|
||||
)
|
||||
conn.commit()
|
||||
print(" ✓ Default admin permission assignment ensured")
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
print(f" ✗ Failed to ensure default admin assignment: {exc}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
PY
|
||||
then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Builtin Packs Initialization Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Packs Location:${NC} ${GREEN}$TARGET_PACKS_DIR${NC}"
|
||||
echo -e "${BLUE}Packs Available:${NC}"
|
||||
|
||||
for pack_dir in "$TARGET_PACKS_DIR"/*; do
|
||||
if [ -d "$pack_dir" ]; then
|
||||
pack_name=$(basename "$pack_dir")
|
||||
pack_yaml="$pack_dir/pack.yaml"
|
||||
if [ -f "$pack_yaml" ]; then
|
||||
# Try to extract version from pack.yaml
|
||||
version=$(grep "^version:" "$pack_yaml" | head -1 | sed 's/version:[[:space:]]*//' | tr -d '"')
|
||||
echo -e " • ${GREEN}$pack_name${NC} ${BLUE}($version)${NC}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
# Ensure ownership is correct after all packs have been copied
|
||||
# The API service (running as attune uid 1000) needs write access to install new packs
|
||||
chown -R 1000:1000 "$TARGET_PACKS_DIR"
|
||||
|
||||
echo -e "${BLUE}ℹ${NC} Pack files are accessible to all services via shared volume"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
29
docker/distributable/docker/init-roles.sql
Normal file
29
docker/distributable/docker/init-roles.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Docker initialization script
|
||||
-- Creates the svc_attune role needed by migrations
|
||||
-- This runs before migrations via docker-compose
|
||||
|
||||
-- Create service role for the application
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'svc_attune') THEN
|
||||
CREATE ROLE svc_attune WITH LOGIN PASSWORD 'attune_service_password';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Create API role
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'attune_api') THEN
|
||||
CREATE ROLE attune_api WITH LOGIN PASSWORD 'attune_api_password';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Grant basic permissions
|
||||
GRANT ALL PRIVILEGES ON DATABASE attune TO svc_attune;
|
||||
GRANT ALL PRIVILEGES ON DATABASE attune TO attune_api;
|
||||
|
||||
-- Enable required extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
108
docker/distributable/docker/init-user.sh
Executable file
108
docker/distributable/docker/init-user.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/bin/sh
|
||||
# Initialize default test user for Attune
|
||||
# This script creates a default test user if it doesn't already exist
|
||||
|
||||
set -e
|
||||
|
||||
# Color output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Database configuration from environment
|
||||
DB_HOST="${DB_HOST:-postgres}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${DB_USER:-attune}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-attune}"
|
||||
DB_NAME="${DB_NAME:-attune}"
|
||||
DB_SCHEMA="${DB_SCHEMA:-public}"
|
||||
|
||||
# Test user configuration
|
||||
TEST_LOGIN="${TEST_LOGIN:-test@attune.local}"
|
||||
TEST_DISPLAY_NAME="${TEST_DISPLAY_NAME:-Test User}"
|
||||
TEST_PASSWORD="${TEST_PASSWORD:-TestPass123!}"
|
||||
|
||||
# Pre-computed Argon2id hash for "TestPass123!"
|
||||
# Using: m=19456, t=2, p=1 (default Argon2id parameters)
|
||||
DEFAULT_PASSWORD_HASH='$argon2id$v=19$m=19456,t=2,p=1$AuZJ0xsGuSRk6LdCd58OOA$vBZnaflJwR9L4LPWoGGrcnRsIOf95FV4uIsoe3PjRE0'
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Attune Default User Initialization ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Wait for database to be ready
|
||||
echo -e "${YELLOW}→${NC} Waiting for database to be ready..."
|
||||
until PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||
echo -e "${YELLOW} ...${NC} Database is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
echo -e "${GREEN}✓${NC} Database is ready"
|
||||
|
||||
# Check if user already exists
|
||||
echo -e "${YELLOW}→${NC} Checking if user exists..."
|
||||
USER_EXISTS=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc \
|
||||
"SELECT COUNT(*) FROM ${DB_SCHEMA}.identity WHERE login = '$TEST_LOGIN';")
|
||||
|
||||
if [ "$USER_EXISTS" -gt 0 ]; then
|
||||
echo -e "${GREEN}✓${NC} User '$TEST_LOGIN' already exists"
|
||||
echo -e "${BLUE}ℹ${NC} Skipping user creation"
|
||||
else
|
||||
echo -e "${YELLOW}→${NC} Creating default test user..."
|
||||
|
||||
# Use the pre-computed hash for default password
|
||||
if [ "$TEST_PASSWORD" = "TestPass123!" ]; then
|
||||
PASSWORD_HASH="$DEFAULT_PASSWORD_HASH"
|
||||
echo -e "${BLUE}ℹ${NC} Using default password hash"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Custom password detected - using basic hash"
|
||||
echo -e "${YELLOW}⚠${NC} For production, generate proper Argon2id hash"
|
||||
# Note: For custom passwords in Docker, you should pre-generate the hash
|
||||
# This is a fallback that will work but is less secure
|
||||
PASSWORD_HASH="$DEFAULT_PASSWORD_HASH"
|
||||
fi
|
||||
|
||||
# Insert the user
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" << EOF
|
||||
INSERT INTO ${DB_SCHEMA}.identity (login, display_name, password_hash, attributes)
|
||||
VALUES (
|
||||
'$TEST_LOGIN',
|
||||
'$TEST_DISPLAY_NAME',
|
||||
'$PASSWORD_HASH',
|
||||
jsonb_build_object(
|
||||
'email', '$TEST_LOGIN',
|
||||
'created_via', 'docker-init',
|
||||
'is_test_user', true
|
||||
)
|
||||
);
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓${NC} User created successfully"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to create user"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Default User Initialization Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Default User Credentials:${NC}"
|
||||
echo -e " Login: ${GREEN}$TEST_LOGIN${NC}"
|
||||
echo -e " Password: ${GREEN}$TEST_PASSWORD${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Test Login:${NC}"
|
||||
echo -e " ${YELLOW}curl -X POST http://localhost:8080/auth/login \\${NC}"
|
||||
echo -e " ${YELLOW}-H 'Content-Type: application/json' \\${NC}"
|
||||
echo -e " ${YELLOW}-d '{\"login\":\"$TEST_LOGIN\",\"password\":\"$TEST_PASSWORD\"}'${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}ℹ${NC} For custom users, see: docs/testing/test-user-setup.md"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
24
docker/distributable/docker/inject-env.sh
Executable file
24
docker/distributable/docker/inject-env.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# inject-env.sh - Injects runtime environment variables into the Web UI
|
||||
# This script runs at container startup to make environment variables available to the browser
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
API_URL="${API_URL:-http://localhost:8080}"
|
||||
WS_URL="${WS_URL:-ws://localhost:8081}"
|
||||
|
||||
# Create runtime configuration file
|
||||
cat > /usr/share/nginx/html/config/runtime-config.js <<EOF
|
||||
// Runtime configuration injected at container startup
|
||||
window.ATTUNE_CONFIG = {
|
||||
apiUrl: '${API_URL}',
|
||||
wsUrl: '${WS_URL}',
|
||||
environment: '${ENVIRONMENT:-production}'
|
||||
};
|
||||
EOF
|
||||
|
||||
echo "Runtime configuration injected:"
|
||||
echo " API_URL: ${API_URL}"
|
||||
echo " WS_URL: ${WS_URL}"
|
||||
echo " ENVIRONMENT: ${ENVIRONMENT:-production}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user