diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 4dcdbd8..f9e40c7 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -1,7 +1,27 @@ -name: Publish Images And Chart +name: Publish Images on: workflow_dispatch: + inputs: + target_arch: + description: Architecture to publish + type: choice + options: + - all + - amd64 + - arm64 + default: all + target_image: + description: Image to publish + type: choice + options: + - all + - api + - executor + - notifier + - agent + - web + default: all push: branches: - main @@ -13,21 +33,26 @@ env: REGISTRY_HOST: ${{ vars.CLUSTER_GITEA_HOST }} REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }} REGISTRY_PLAIN_HTTP: ${{ vars.CONTAINER_REGISTRY_INSECURE }} - CHART_NAME: attune + ARTIFACT_REPOSITORY: attune-build-artifacts + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + RUSTUP_MAX_RETRIES: 10 + RUST_MIN_STACK: 67108864 + SQLX_OFFLINE: true + RUNNER_TOOL_CACHE: /toolcache jobs: metadata: name: Resolve Publish Metadata - runs-on: ubuntu-latest + runs-on: build-amd64 outputs: registry: ${{ steps.meta.outputs.registry }} namespace: ${{ steps.meta.outputs.namespace }} registry_plain_http: ${{ steps.meta.outputs.registry_plain_http }} image_tag: ${{ steps.meta.outputs.image_tag }} image_tags: ${{ steps.meta.outputs.image_tags }} - chart_version: ${{ steps.meta.outputs.chart_version }} - app_version: ${{ steps.meta.outputs.app_version }} - release_channel: ${{ steps.meta.outputs.release_channel }} + artifact_ref_base: ${{ steps.meta.outputs.artifact_ref_base }} steps: - name: Resolve tags and registry paths id: meta @@ -78,97 +103,400 @@ jobs: if [ "$ref_type" = "tag" ] && printf '%s' "$ref_name" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.].*)?$'; then version="${ref_name#v}" image_tags="${version},latest,sha-${short_sha}" - chart_version="$version" - release_channel="release" else version="sha-${short_sha}" image_tags="edge,sha-${short_sha}" - chart_version="0.0.0-dev.${{ github.run_number }}" - release_channel="edge" fi + artifact_ref_base="${registry}/${namespace}/${ARTIFACT_REPOSITORY}" + { echo "registry=$registry" echo "namespace=$namespace" echo "registry_plain_http=$registry_plain_http" echo "image_tag=$version" echo "image_tags=$image_tags" - echo "chart_version=$chart_version" - echo "app_version=$version" - echo "release_channel=$release_channel" + echo "artifact_ref_base=$artifact_ref_base" } >> "$GITHUB_OUTPUT" - publish-images: - name: Publish ${{ matrix.image.name }} - runs-on: ubuntu-latest + build-rust-bundles: + name: Build Rust Bundles (${{ matrix.arch }}) + runs-on: ${{ matrix.runner_label }} needs: metadata + if: | + github.event_name != 'workflow_dispatch' || + inputs.target_arch == 'all' || + inputs.target_arch == matrix.arch strategy: fail-fast: false matrix: - image: - - name: api - repository: attune-api - dockerfile: docker/Dockerfile.optimized - context: . - target: "" - build_args: | - SERVICE=api - - name: executor - repository: attune-executor - dockerfile: docker/Dockerfile.optimized - context: . - target: "" - build_args: | - SERVICE=executor - - name: notifier - repository: attune-notifier - dockerfile: docker/Dockerfile.optimized - context: . - target: "" - build_args: | - SERVICE=notifier - - name: sensor - repository: attune-sensor - dockerfile: docker/Dockerfile.sensor.optimized - context: . - target: sensor-full - build_args: "" - - name: worker - repository: attune-worker - dockerfile: docker/Dockerfile.worker.optimized - context: . - target: worker-full - build_args: "" - - name: web - repository: attune-web - dockerfile: docker/Dockerfile.web - context: . - target: "" - build_args: "" - - name: migrations - repository: attune-migrations - dockerfile: docker/Dockerfile.migrations - context: . - target: "" - build_args: "" - - name: init-user - repository: attune-init-user - dockerfile: docker/Dockerfile.init-user - context: . - target: "" - build_args: "" - - name: init-packs - repository: attune-init-packs - dockerfile: docker/Dockerfile.init-packs - context: . - target: "" - build_args: "" - - name: agent - repository: attune-agent - dockerfile: docker/Dockerfile.agent - context: . - target: agent-init - build_args: "" + include: + - arch: amd64 + runner_label: build-amd64 + musl_target: x86_64-unknown-linux-musl + - arch: arm64 + runner_label: build-arm64 + musl_target: aarch64-unknown-linux-musl + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache Rust toolchain + uses: actions/cache@v4 + with: + path: | + ~/.rustup/toolchains + ~/.rustup/update-hashes + key: rustup-publish-${{ runner.os }}-${{ matrix.arch }}-stable-v1 + restore-keys: | + rustup-${{ runner.os }}-${{ matrix.arch }}-stable-v1 + rustup-${{ runner.os }}-stable-v1 + rustup- + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.musl_target }} + + - name: Cache Cargo registry + index + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: cargo-registry-publish-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-registry-publish-${{ matrix.arch }}- + cargo-registry- + + - name: Cache Cargo build artifacts + uses: actions/cache@v4 + with: + path: target + key: cargo-publish-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('**/*.rs', '**/Cargo.toml') }} + restore-keys: | + cargo-publish-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}- + cargo-publish-${{ matrix.arch }}- + + - name: Install native build dependencies + shell: bash + run: | + set -euo pipefail + apt-get update + apt-get install -y pkg-config libssl-dev musl-tools file + + - name: Build release binaries + shell: bash + run: | + set -euo pipefail + cargo build --release \ + --bin attune-api \ + --bin attune-executor \ + --bin attune-notifier + + - name: Build static agent binaries + shell: bash + run: | + set -euo pipefail + cargo build --release \ + --target "${{ matrix.musl_target }}" \ + --bin attune-agent \ + --bin attune-sensor-agent + + - name: Assemble binary bundle + shell: bash + run: | + set -euo pipefail + + bundle_root="dist/bundle/${{ matrix.arch }}" + mkdir -p "$bundle_root/bin" "$bundle_root/agent" + + cp target/release/attune-api "$bundle_root/bin/" + cp target/release/attune-executor "$bundle_root/bin/" + cp target/release/attune-notifier "$bundle_root/bin/" + cp target/${{ matrix.musl_target }}/release/attune-agent "$bundle_root/agent/" + cp target/${{ matrix.musl_target }}/release/attune-sensor-agent "$bundle_root/agent/" + + cat > "$bundle_root/metadata.json" < "$HOME/.docker/config.json" < "$HOME/.docker/config.json" <> "$GITHUB_OUTPUT" - - name: Build and push image - shell: bash - run: | - set -euo pipefail - image_names_csv="" + image_ref="${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/attune-web:${{ needs.metadata.outputs.image_tag }}-${{ matrix.arch }}" + build_cmd=( docker buildx build - "${{ matrix.image.context }}" - --file "${{ matrix.image.dockerfile }}" + . + --platform "${{ matrix.platform }}" + --file docker/Dockerfile.web ) - if [ -n "${{ matrix.image.target }}" ]; then - build_cmd+=(--target "${{ matrix.image.target }}") - fi - - while IFS= read -r tag; do - if [ -n "$tag" ]; then - if [ -n "$image_names_csv" ]; then - image_names_csv="${image_names_csv},${tag}" - else - image_names_csv="${tag}" - fi - - if [ "${{ needs.metadata.outputs.registry_plain_http }}" != "true" ]; then - build_cmd+=(--tag "$tag") - fi - fi - done <<< "${{ steps.tags.outputs.tags }}" - - while IFS= read -r build_arg; do - [ -n "$build_arg" ] && build_cmd+=(--build-arg "$build_arg") - done <<< "${{ matrix.image.build_args }}" - if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then - build_cmd+=(--output "type=image,\"name=${image_names_csv}\",push=true,registry.insecure=true") + build_cmd+=(--output "type=image,\"name=${image_ref}\",push=true,registry.insecure=true") else - build_cmd+=(--push) + build_cmd+=(--tag "$image_ref" --push) fi "${build_cmd[@]}" - publish-chart: - name: Publish Helm Chart - runs-on: ubuntu-latest + publish-manifests: + name: Publish manifest ${{ matrix.repository }} + runs-on: build-amd64 needs: - metadata - - publish-images + - publish-rust-images + - publish-web-images + if: | + github.event_name != 'workflow_dispatch' || + (inputs.target_arch == 'all' && inputs.target_image == 'all') + strategy: + fail-fast: false + matrix: + repository: + - attune-api + - attune-executor + - attune-notifier + - attune-agent + - attune-web steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Helm - uses: azure/setup-helm@v4 - - - name: Log in to Gitea OCI registry + - name: Configure OCI registry auth shell: bash env: REGISTRY_USERNAME: ${{ secrets.CONTAINER_REGISTRY_USERNAME }} @@ -291,43 +593,48 @@ jobs: GITHUB_TOKEN_FALLBACK: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - registry_username="${REGISTRY_USERNAME:-${{ github.actor }}}" - registry_password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}" - login_args=() + username="${REGISTRY_USERNAME:-${{ github.actor }}}" + password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}" - if [ -z "$registry_password" ]; then + if [ -z "$password" ]; then echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes" exit 1 fi - if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then - login_args+=(--plain-http) - fi + mkdir -p "$HOME/.docker" + auth="$(printf '%s:%s' "$username" "$password" | base64 | tr -d '\n')" - printf '%s' "$registry_password" | helm registry login "${{ needs.metadata.outputs.registry }}" \ - --username "$registry_username" \ - "${login_args[@]}" \ - --password-stdin + cat > "$HOME/.docker/config.json" </dev/null 2>&1 || true + docker manifest create "$manifest_ref" "$amd64_ref" "$arm64_ref" + docker manifest annotate "$manifest_ref" "$amd64_ref" --os linux --arch amd64 + docker manifest annotate "$manifest_ref" "$arm64_ref" --os linux --arch arm64 + docker manifest push "${push_args[@]}" "$manifest_ref" + done diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 44c3063..3b7d011 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -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, + pub env_vars: HashMap, + } + + /// 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, @@ -1640,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" + ); + } +} diff --git a/crates/sensor/src/sensor_manager.rs b/crates/sensor/src/sensor_manager.rs index 8ab5c2f..065c041 100644 --- a/crates/sensor/src/sensor_manager.rs +++ b/crates/sensor/src/sensor_manager.rs @@ -27,6 +27,37 @@ use tracing::{debug, error, info, warn}; use crate::api_client::ApiClient; +fn existing_command_env(cmd: &Command, key: &str) -> Option { + 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 { @@ -502,20 +533,7 @@ impl SensorManager { .env("ATTUNE_MQ_EXCHANGE", "attune.events") .env("ATTUNE_LOG_LEVEL", "info"); - if !exec_config.env_vars.is_empty() { - let vars = exec_config.build_template_vars_with_env(&pack_dir, env_dir_opt); - for (key, value_template) in &exec_config.env_vars { - let resolved = attune_common::models::RuntimeExecutionConfig::resolve_template( - value_template, - &vars, - ); - debug!( - "Setting sensor runtime env var: {}={} (template: {})", - key, resolved, value_template - ); - cmd.env(key, resolved); - } - } + apply_runtime_env_vars(&mut cmd, &exec_config, &pack_dir, env_dir_opt); let mut child = cmd .stdin(Stdio::null()) @@ -904,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() { @@ -913,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"); + } } diff --git a/crates/worker/src/runtime/process.rs b/crates/worker/src/runtime/process.rs index d86a767..01b5852 100644 --- a/crates/worker/src/runtime/process.rs +++ b/crates/worker/src/runtime/process.rs @@ -830,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); } } @@ -1062,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; @@ -1377,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(); diff --git a/docker/Dockerfile.agent-package b/docker/Dockerfile.agent-package new file mode 100644 index 0000000..702151c --- /dev/null +++ b/docker/Dockerfile.agent-package @@ -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"] diff --git a/docker/Dockerfile.runtime b/docker/Dockerfile.runtime new file mode 100644 index 0000000..e7f3b37 --- /dev/null +++ b/docker/Dockerfile.runtime @@ -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"] diff --git a/packs/core/runtimes/README.md b/packs/core/runtimes/README.md index c954e6d..9a6d589 100644 --- a/packs/core/runtimes/README.md +++ b/packs/core/runtimes/README.md @@ -12,6 +12,41 @@ Each runtime YAML file contains only the fields that are stored in the database: - `description` - Brief description of the runtime - `distributions` - Runtime verification and capability metadata (JSONB) - `installation` - Installation requirements and metadata (JSONB) +- `execution_config` - Interpreter, environment, dependency, and execution-time env var metadata + +## `execution_config.env_vars` + +Runtime authors can declare execution-time environment variables in a purely declarative way. + +String values replace the variable entirely: + +```yaml +env_vars: + NODE_PATH: "{env_dir}/node_modules" +``` + +Object values support merge semantics against an existing value already present in the execution environment: + +```yaml +env_vars: + PYTHONPATH: + operation: prepend + value: "{pack_dir}/lib" + separator: ":" +``` + +Supported operations: + +- `set` - Replace the variable with the resolved value +- `prepend` - Add the resolved value before the existing value +- `append` - Add the resolved value after the existing value + +Supported template variables: + +- `{pack_dir}` +- `{env_dir}` +- `{interpreter}` +- `{manifest_path}` ## Available Runtimes diff --git a/packs/core/runtimes/python.yaml b/packs/core/runtimes/python.yaml index e787d0f..09ea6a1 100644 --- a/packs/core/runtimes/python.yaml +++ b/packs/core/runtimes/python.yaml @@ -54,6 +54,11 @@ execution_config: - install - "-r" - "{manifest_path}" + env_vars: + PYTHONPATH: + operation: prepend + value: "{pack_dir}/lib" + separator: ":" # Version-specific execution configurations. # Each entry describes how to invoke a particular Python version. @@ -96,6 +101,11 @@ versions: - install - "-r" - "{manifest_path}" + env_vars: + PYTHONPATH: + operation: prepend + value: "{pack_dir}/lib" + separator: ":" - version: "3.12" is_default: true @@ -133,6 +143,11 @@ versions: - install - "-r" - "{manifest_path}" + env_vars: + PYTHONPATH: + operation: prepend + value: "{pack_dir}/lib" + separator: ":" - version: "3.13" distributions: @@ -169,3 +184,8 @@ versions: - install - "-r" - "{manifest_path}" + env_vars: + PYTHONPATH: + operation: prepend + value: "{pack_dir}/lib" + separator: ":"