diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..008e65c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +target/ +.git/ +.ruff_cache/ +.vscode/ +services/ws-server/storage/ +**/.DS_Store diff --git a/README.md b/README.md index 559bdcd..43e84a7 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,28 @@ command to start the demo scenario: mise run generated-scenario ``` +To generate a Docker Compose deployment instead, pass +`--output-type docker-compose` or set `deployment_type: docker-compose` in the +scenario input YAML. This writes `compose.yaml` to the output directory: + +```bash +et-cli generate-deployment \ + --input-file verification/local/input/facility-security-scenario.yaml \ + --output-dir verification/local/output/facility-security-scenario \ + --output-type docker-compose +cd verification/local/output/facility-security-scenario +docker compose up --build +``` + The generated scenario config only selects which prebuilt modules `ws-server` serves. Module builds are expected to be handled separately from the repository root. To regenerate all checked-in verification outputs from `verification/*/input`, writing each scenario to -the matching `verification/*/output/` folder: +the matching `verification/*/output/` folder. This generates +all supported deployment files for each scenario, currently `mise.toml` and +`compose.yaml`: ```bash mise run regen-verification diff --git a/services/ws-server/Dockerfile b/services/ws-server/Dockerfile new file mode 100644 index 0000000..28a752f --- /dev/null +++ b/services/ws-server/Dockerfile @@ -0,0 +1,43 @@ +FROM rust:1-bookworm AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + clang \ + cmake \ + npm \ + pkg-config \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +COPY . . + +RUN npm install --omit=dev --prefix /workspace/runtime-deps onnxruntime-web +RUN cargo build -p et-ws-server --release --locked + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 10001 --user-group app + +WORKDIR /app + +COPY --from=builder /workspace/target/release/et-ws-server /usr/local/bin/et-ws-server +COPY --from=builder /workspace/services/ws-server/static ./services/ws-server/static +COPY --from=builder /workspace/services/ws-wasm-agent/pkg ./services/ws-wasm-agent/pkg +COPY --from=builder /workspace/services/ws-modules ./services/ws-modules +COPY --from=builder /workspace/data/model-modules ./data/model-modules +COPY --from=builder /workspace/runtime-deps/node_modules/onnxruntime-web ./node_modules/onnxruntime-web + +RUN mkdir -p /app/storage \ + && chown -R app:app /app + +USER app + +EXPOSE 8080 8443 + +CMD ["et-ws-server"] diff --git a/utilities/cli/README.md b/utilities/cli/README.md index b5ca636..6cdd221 100644 --- a/utilities/cli/README.md +++ b/utilities/cli/README.md @@ -43,21 +43,50 @@ the scenario's selected workflow modules plus `ws-wasm-agent`, using the configurable module-path logic in `ws-server`. The generated `mise.toml` does not build modules; it assumes builds are handled externally. +## Generate a Docker Compose Deployment + +Run `generate-deployment` with `--output-type docker-compose` to generate a +`compose.yaml` file: + +```bash +et-cli generate-deployment \ + --input-file verification/local/input/.yaml \ + --output-dir verification/local/output/ \ + --output-type docker-compose +``` + +Then, to run the deployment from the output directory: + +```bash +docker compose up --build +``` + +The generated compose stack starts OpenObserve and builds `ws-server` from the +repository Dockerfile. Native build dependencies such as `protoc` are installed +in the image build stage, so the runtime container does not depend on host Rust, +Cargo caches, or `mise` tools. The generated service sets `MODULES_PATHS`, +OpenTelemetry auth, and the in-compose OpenObserve collector URL for the +selected scenario. + ## Regenerate Verification Outputs Run `regen-verification` to regenerate all checked-in verification outputs from -the verification root. By default it reads `verification`, discovers -scenario files under `verification/*/input`, and writes each output set to the -matching `verification/*/output/` folder. +the verification root. By default it reads `verification`, discovers scenario +files under `verification/*/input`, and writes every supported deployment type +to the matching `verification/*/output/` folder. + +Currently this writes both `mise.toml` and `compose.yaml`. Future deployment +types should be added to the shared supported output type list so +`regen-verification` picks them up automatically. ```bash et-cli regen-verification ``` For example, `verification/local/input/facility-security-scenario.yaml` -regenerates into `verification/local/output/facility-security-scenario`, and a -scenario under `verification/ci/input/...` would regenerate into -`verification/ci/output/...`. +regenerates all deployment outputs into +`verification/local/output/facility-security-scenario`, and a scenario under +`verification/ci/input/...` would regenerate into `verification/ci/output/...`. ## Input YAML @@ -82,22 +111,21 @@ Required fields: Optional fields: -- `deployment_type`: currently only `mise`; defaults to `mise` when omitted. +- `deployment_type`: `mise` or `docker-compose`; defaults to `mise` when + omitted. -The generated `mise.toml` uses `agents[].resources[].type` values to decide -which module directories to expose through `MODULES_PATHS`. It only serves -`ws-wasm-agent` and the selected workflow modules for that scenario, without -adding any build tasks. +The generated deployment config uses `agents[].resources[].type` values to +decide which module directories to expose through `MODULES_PATHS`. It only +serves `ws-wasm-agent` and the selected workflow modules for that scenario, +without adding any module build tasks. #### Notes on deployment_type -The input YAML can also choose the output type with `deployment_type`. `mise` is -the only supported value for now; the field and CLI option are present so more -deployment types can be added later. +The input YAML can also choose the output type with `deployment_type`. ```yaml cluster_name: example -deployment_type: mise +deployment_type: docker-compose agents: - name: camera resources: @@ -115,7 +143,7 @@ You can also run the CLI through Cargo from the repository root: cargo run -p et-cli -- generate-deployment \ --input-file verification/local/input/.yaml \ --output-dir verification/local/output/ \ - --output-type mise + --output-type docker-compose ``` To regenerate all convention-defined verification outputs through Cargo: diff --git a/utilities/cli/src/lib.rs b/utilities/cli/src/lib.rs index 89a3201..705465b 100644 --- a/utilities/cli/src/lib.rs +++ b/utilities/cli/src/lib.rs @@ -14,6 +14,27 @@ use toml::{Table, Value}; pub enum OutputType { #[default] Mise, + #[serde(rename = "docker-compose", alias = "docker_compose")] + DockerCompose, +} + +impl OutputType { + pub const ALL: &'static [Self] = &[Self::Mise, Self::DockerCompose]; + + pub const fn output_file_name(self) -> &'static str { + match self { + Self::Mise => "mise.toml", + Self::DockerCompose => "compose.yaml", + } + } +} + +fn generated_output_files(output_types: &[OutputType]) -> Vec<&'static str> { + let mut files = Vec::new(); + for output_type in output_types { + files.push(output_type.output_file_name()); + } + files } #[derive(Debug, Clone, PartialEq, Eq)] @@ -41,22 +62,14 @@ pub fn generate_deployment( .or_else(|| cluster.deployment_type.as_deref().map(output_type_from_input)) .unwrap_or(Ok(OutputType::Mise))?; - if !output_dir.exists() { - fs::create_dir_all(output_dir) - .with_context(|| format!("Failed to create output directory: {:?}", output_dir))?; - } - let module_names = cluster_module_names(&cluster); + generate_deployment_outputs(&cluster, output_dir, &[output_type])?; - match output_type { - OutputType::Mise => generate_mise_deployment(&cluster, output_dir)?, - } - - Ok(DeploymentSummary { - cluster_name: cluster.cluster_name, - agent_templates: cluster.agents.len(), + Ok(deployment_summary( + cluster.cluster_name, + cluster.agents.len(), module_names, - }) + )) } pub fn load_cluster_input(input_file: &Path) -> Result { @@ -82,7 +95,15 @@ pub fn regenerate_verification( output_dir )); } - let summary = generate_deployment(&input_file, &output_dir, output_type)?; + let cluster = load_cluster_input(&input_file)?; + let module_names = cluster_module_names(&cluster); + let output_types = match &output_type { + Some(output_type) => std::slice::from_ref(output_type), + None => OutputType::ALL, + }; + + generate_deployment_outputs(&cluster, &output_dir, output_types)?; + let summary = deployment_summary(cluster.cluster_name, cluster.agents.len(), module_names); regenerated.push(RegeneratedScenario { input_file, output_dir, @@ -96,14 +117,45 @@ pub fn regenerate_verification( pub fn output_type_from_input(value: &str) -> Result { if value.eq_ignore_ascii_case("mise") { Ok(OutputType::Mise) + } else if matches!(value.to_ascii_lowercase().as_str(), "docker-compose" | "docker_compose") { + Ok(OutputType::DockerCompose) } else { Err(anyhow!( - "Unsupported deployment_type {:?}. Supported values are currently: mise", + "Unsupported deployment_type {:?}. Supported values are currently: mise, docker-compose", value )) } } +fn deployment_summary(cluster_name: String, agent_templates: usize, module_names: Vec) -> DeploymentSummary { + DeploymentSummary { + cluster_name, + agent_templates, + module_names, + } +} + +fn generate_deployment_outputs(cluster: &ClusterInput, output_dir: &Path, output_types: &[OutputType]) -> Result<()> { + if !output_dir.exists() { + fs::create_dir_all(output_dir) + .with_context(|| format!("Failed to create output directory: {:?}", output_dir))?; + } + + for output_type in output_types { + match output_type { + OutputType::Mise => generate_mise_deployment(cluster, output_dir)?, + OutputType::DockerCompose => generate_docker_compose_deployment(cluster, output_dir)?, + } + } + + let readme_path = output_dir.join("README.md"); + let module_names = cluster_module_names(cluster); + fs::write(&readme_path, generated_readme(cluster, &module_names, output_types)) + .with_context(|| format!("Failed to write output file: {:?}", readme_path))?; + + Ok(()) +} + fn discover_verification_scenarios(verification_root: &Path) -> Result> { let mut scenarios = Vec::new(); let verification_sets = fs::read_dir(verification_root) @@ -166,7 +218,6 @@ fn discover_verification_scenarios(verification_root: &Path) -> Result Result<()> { let output_path = output_dir.join("mise.toml"); - let readme_path = output_dir.join("README.md"); let workspace_root = std::env::current_dir().with_context(|| "Failed to resolve current working directory for mise tasks")?; let output_abs = absolute_from(&workspace_root, output_dir); @@ -181,7 +232,7 @@ fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result .collect::>() .join(",\\\n"); let ws_server_run = format!( - "MODULES_PATHS=\"\\\n{},\\\n $(mise where npm:onnxruntime-web)/lib/node_modules\"\ncargo run\n", + "export MODULES_PATHS=\"\\\n{},\\\n $(mise where npm:onnxruntime-web)/lib/node_modules\"\ncargo run\n", module_paths_lines ); let ws_server_rel = relative_path_from(&output_abs, &ws_server_dir).display().to_string(); @@ -244,13 +295,253 @@ fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result openobserve_env_file_rel, ); fs::write(&output_path, content).with_context(|| format!("Failed to write output file: {:?}", output_path))?; - fs::write(&readme_path, generated_readme(cluster, &module_names)) - .with_context(|| format!("Failed to write output file: {:?}", readme_path))?; Ok(()) } -fn generated_readme(cluster: &ClusterInput, module_names: &[String]) -> String { +fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &Path) -> Result<()> { + let output_path = output_dir.join(OutputType::DockerCompose.output_file_name()); + let workspace_root = + std::env::current_dir().with_context(|| "Failed to resolve current working directory for compose services")?; + let output_abs = absolute_from(&workspace_root, output_dir); + let workspace_rel = relative_path_from(&output_abs, &workspace_root).display().to_string(); + let openobserve_env_file_rel = relative_path_from(&output_abs, &workspace_root.join("config/o2.env")) + .display() + .to_string(); + let module_names = cluster_module_names(cluster); + let module_paths = docker_image_module_paths(&module_names); + let compose = ComposeFile { + services: vec![ + ( + "openobserve".to_string(), + ComposeService { + image: Some("openobserve/openobserve:v0.70.3".to_string()), + healthcheck: Some(ComposeHealthcheck { + test: vec![ + "CMD".to_string(), + "/openobserve".to_string(), + "node".to_string(), + "status".to_string(), + ], + interval: "5s".to_string(), + timeout: "3s".to_string(), + retries: 20, + start_period: "10s".to_string(), + }), + ports: vec!["5080:5080".to_string()], + env_file: vec![openobserve_env_file_rel], + environment: vec![("ZO_DATA_DIR".to_string(), ComposeValue::Plain("/data".to_string()))], + volumes: vec!["openobserve-data:/data".to_string()], + ..ComposeService::default() + }, + ), + ( + "ws-server".to_string(), + ComposeService { + build: Some(ComposeBuild { + context: workspace_rel, + dockerfile: "services/ws-server/Dockerfile".to_string(), + }), + network_mode: Some("host".to_string()), + environment: vec![ + ( + "MODULES_PATHS".to_string(), + ComposeValue::WrappedDoubleQuoted(module_paths), + ), + ( + "OTLP_AUTH_PASSWORD".to_string(), + ComposeValue::DoubleQuoted("1234".to_string()), + ), + ( + "OTLP_AUTH_USERNAME".to_string(), + ComposeValue::Plain("root@example.com".to_string()), + ), + ( + "OTLP_COLLECTOR_URL".to_string(), + ComposeValue::Plain("http://127.0.0.1:5080/api/default/v1".to_string()), + ), + ( + "STORAGE_PATH".to_string(), + ComposeValue::Plain("/app/storage".to_string()), + ), + ], + volumes: vec!["ws-server-storage:/app/storage".to_string()], + depends_on: vec![( + "openobserve".to_string(), + ComposeDependsOnCondition { + condition: "service_healthy".to_string(), + }, + )], + ..ComposeService::default() + }, + ), + ], + volumes: vec![ + ("openobserve-data".to_string(), ComposeVolume), + ("ws-server-storage".to_string(), ComposeVolume), + ], + }; + let content = render_compose_yaml(&compose); + fs::write(&output_path, content).with_context(|| format!("Failed to write output file: {:?}", output_path))?; + + Ok(()) +} + +#[derive(Debug, Default)] +struct ComposeFile { + services: Vec<(String, ComposeService)>, + volumes: Vec<(String, ComposeVolume)>, +} + +#[derive(Debug, Default)] +struct ComposeService { + build: Option, + image: Option, + healthcheck: Option, + network_mode: Option, + ports: Vec, + env_file: Vec, + environment: Vec<(String, ComposeValue)>, + volumes: Vec, + depends_on: Vec<(String, ComposeDependsOnCondition)>, +} + +#[derive(Debug)] +struct ComposeBuild { + context: String, + dockerfile: String, +} + +#[derive(Debug)] +struct ComposeHealthcheck { + test: Vec, + interval: String, + timeout: String, + retries: u32, + start_period: String, +} + +#[derive(Debug)] +struct ComposeDependsOnCondition { + condition: String, +} + +#[derive(Debug, Default)] +struct ComposeVolume; + +#[derive(Debug)] +enum ComposeValue { + Plain(String), + DoubleQuoted(String), + WrappedDoubleQuoted(Vec), +} + +fn render_compose_yaml(compose: &ComposeFile) -> String { + let mut renderer = ComposeRenderer::default(); + renderer.push_line(0, "services:"); + for (name, service) in &compose.services { + renderer.render_service(name, service); + } + renderer.push_line(0, "volumes:"); + for (name, _) in &compose.volumes { + renderer.push_line(1, &format!("{name}: {{}}")); + } + renderer.finish() +} + +#[derive(Default)] +struct ComposeRenderer { + output: String, +} + +impl ComposeRenderer { + fn finish(self) -> String { + self.output + } + + fn push_line(&mut self, indent: usize, line: &str) { + self.output.push_str(&" ".repeat(indent)); + self.output.push_str(line); + self.output.push('\n'); + } + + fn render_service(&mut self, name: &str, service: &ComposeService) { + self.push_line(1, &format!("{name}:")); + if let Some(image) = &service.image { + self.push_line(2, &format!("image: {image}")); + } + if let Some(healthcheck) = &service.healthcheck { + self.push_line(2, "healthcheck:"); + self.push_line(3, "test:"); + for item in &healthcheck.test { + self.push_line(4, &format!("- {item}")); + } + self.push_line(3, &format!("interval: {}", healthcheck.interval)); + self.push_line(3, &format!("timeout: {}", healthcheck.timeout)); + self.push_line(3, &format!("retries: {}", healthcheck.retries)); + self.push_line(3, &format!("start_period: {}", healthcheck.start_period)); + } + if !service.ports.is_empty() { + self.push_line(2, "ports:"); + for port in &service.ports { + self.push_line(3, &format!("- {port}")); + } + } + if !service.env_file.is_empty() { + self.push_line(2, "env_file:"); + for env_file in &service.env_file { + self.push_line(3, &format!("- {env_file}")); + } + } + if let Some(build) = &service.build { + self.push_line(2, "build:"); + self.push_line(3, &format!("context: {}", build.context)); + self.push_line(3, &format!("dockerfile: {}", build.dockerfile)); + } + if let Some(network_mode) = &service.network_mode { + self.push_line(2, &format!("network_mode: {network_mode}")); + } + if !service.environment.is_empty() { + self.push_line(2, "environment:"); + for (key, value) in &service.environment { + self.render_environment_value(key, value); + } + } + if !service.volumes.is_empty() { + self.push_line(2, "volumes:"); + for volume in &service.volumes { + self.push_line(3, &format!("- {volume}")); + } + } + if !service.depends_on.is_empty() { + self.push_line(2, "depends_on:"); + for (name, condition) in &service.depends_on { + self.push_line(3, &format!("{name}:")); + self.push_line(4, &format!("condition: {}", condition.condition)); + } + } + } + + fn render_environment_value(&mut self, key: &str, value: &ComposeValue) { + match value { + ComposeValue::Plain(value) => self.push_line(3, &format!("{key}: {value}")), + ComposeValue::DoubleQuoted(value) => self.push_line(3, &format!("{key}: \"{value}\"")), + ComposeValue::WrappedDoubleQuoted(parts) => { + if let Some((first, rest)) = parts.split_first() { + self.push_line(3, &format!("{key}: \"{first},\\")); + for (index, part) in rest.iter().enumerate() { + let suffix = if index + 1 == rest.len() { "\"" } else { ",\\" }; + self.push_line(4, &format!("{part}{suffix}")); + } + } else { + self.push_line(3, &format!("{key}: \"\"")); + } + } + } + } +} + +fn generated_readme(cluster: &ClusterInput, module_names: &[String], output_types: &[OutputType]) -> String { let module_summary = if module_names.is_empty() { "No workflow modules were selected in the scenario input.".to_string() } else { @@ -260,26 +551,78 @@ fn generated_readme(cluster: &ClusterInput, module_names: &[String]) -> String { ) }; + let output_files = generated_output_files(output_types); + let output_summary = if output_files.len() == 1 { + format!( + "This directory contains the generated `{}` for the `{}` scenario.", + output_files[0], cluster.cluster_name + ) + } else { + let output_files = output_files + .iter() + .map(|output_file| format!("`{}`", output_file)) + .collect::>() + .join(", "); + format!( + "This directory contains generated deployment configs for the `{}` scenario.\n\ +Files: {}.", + cluster.cluster_name, output_files + ) + }; + let run_instructions = output_types + .iter() + .map(|output_type| generated_run_instructions(*output_type)) + .collect::>() + .join("\n"); + format!( "# {name}\n\n\ -This directory contains the generated `mise.toml` for the `{name}` scenario.\n\n\ +{output_summary}\n\n\ {module_summary}\n\n\ -## Run The Scenario\n\n\ -From this directory, start the scenario with:\n\n\ -```bash\n\ -mise run generated-scenario\n\ -```\n\n\ -That task starts both OpenObserve and `ws-server` for this scenario.\n\n\ -## Open The OpenObserve UI\n\n\ -From this directory, open the OpenObserve UI with:\n\n\ -```bash\n\ -mise run open-o2\n\ -```\n", +{run_instructions}", name = cluster.cluster_name, + output_summary = output_summary, module_summary = module_summary, + run_instructions = run_instructions, ) } +fn generated_run_instructions(output_type: OutputType) -> String { + match output_type { + OutputType::Mise => concat!( + "## Run With Mise\n\n", + "From this directory, start the scenario with:\n\n", + "```bash\n", + "mise run generated-scenario\n", + "```\n\n", + "That task starts both OpenObserve and `ws-server` for this scenario.\n\n", + "### Open The OpenObserve UI\n\n", + "From this directory, open the OpenObserve UI with:\n\n", + "```bash\n", + "mise run open-o2\n", + "```\n" + ) + .to_string(), + OutputType::DockerCompose => concat!( + "## Run With Docker Compose\n\n", + "From this directory, start the scenario with:\n\n", + "```bash\n", + "docker compose up --build\n", + "```\n\n", + "The compose stack starts OpenObserve and builds a `ws-server` image from the repository Dockerfile.\n", + "`ws-server` runs with host networking so it advertises the same LAN IP as the `mise` deployment.\n\n", + "### Open The UIs\n\n", + "OpenObserve is available at .\n", + "`ws-server` is available at and .\n\n", + "Stop the scenario with:\n\n", + "```bash\n", + "docker compose down\n", + "```\n" + ) + .to_string(), + } +} + fn format_mise_toml(content: String, openobserve_env_file_rel: &str) -> String { let openobserve_run = format!( concat!( @@ -360,11 +703,17 @@ fn mise_depends(depends: [&str; N]) -> Table { fn scenario_module_paths(ws_server_dir: &Path, module_names: &[String]) -> Vec { let project_root = edge_toolkit::config::get_project_root(); let ws_modules_dir = project_root.join("services/ws-modules"); - let mut paths: Vec = edge_toolkit::config::default_modules_folders() - .into_iter() - .filter(|p| p != &ws_modules_dir && p.starts_with(&project_root)) - .map(|p| relative_path_from(ws_server_dir, &p).display().to_string()) - .collect(); + let mut paths = vec![ + relative_path_from(ws_server_dir, &project_root.join("services/ws-server/static")) + .display() + .to_string(), + relative_path_from(ws_server_dir, &project_root.join("services/ws-wasm-agent")) + .display() + .to_string(), + relative_path_from(ws_server_dir, &project_root.join("data/model-modules")) + .display() + .to_string(), + ]; for module_name in module_names { paths.push( relative_path_from(ws_server_dir, &ws_modules_dir.join(module_name)) @@ -375,6 +724,18 @@ fn scenario_module_paths(ws_server_dir: &Path, module_names: &[String]) -> Vec Vec { + let mut paths = Vec::with_capacity(module_names.len() + 4); + paths.push("/app/services/ws-server/static".to_string()); + paths.push("/app/services/ws-wasm-agent".to_string()); + paths.push("/app/data/model-modules".to_string()); + paths.push("/app/node_modules/onnxruntime-web".to_string()); + for module_name in module_names { + paths.push(format!("/app/services/ws-modules/{module_name}")); + } + paths +} + fn absolute_from(base: &Path, path: &Path) -> PathBuf { if path.is_absolute() { normalize_path(path) diff --git a/utilities/cli/src/main.rs b/utilities/cli/src/main.rs index de8f775..b2b2fbb 100644 --- a/utilities/cli/src/main.rs +++ b/utilities/cli/src/main.rs @@ -46,7 +46,7 @@ fn main() -> Result<()> { summary.agent_templates, summary.module_names.join(", ") ); - println!("Generated: {:?}", output_dir.join("mise.toml")); + println!("Generated: {:?}", output_dir.join(output_type.output_file_name())); println!("See the generated README.md in {:?} for instructions.", output_dir); } Commands::RegenVerification { verification_root } => { diff --git a/utilities/cli/src/tests.rs b/utilities/cli/src/tests.rs index 4db67bd..417ec56 100644 --- a/utilities/cli/src/tests.rs +++ b/utilities/cli/src/tests.rs @@ -2,7 +2,7 @@ use std::fs; use tempfile::tempdir; -use crate::{generate_deployment, regenerate_verification}; +use crate::{docker_image_module_paths, generate_deployment, regenerate_verification, scenario_module_paths}; #[test] fn generate_deployment_rejects_unsupported_deployment_type() { @@ -26,7 +26,38 @@ agents: [] } #[test] -fn regenerate_verification_uses_input_name_for_output_folder() { +fn docker_image_module_paths_include_static_root_module() { + let paths = docker_image_module_paths(&["face-detection".to_string()]); + + assert_eq!(paths[0], "/app/services/ws-server/static"); + assert!(paths.contains(&"/app/services/ws-wasm-agent".to_string())); + assert!(paths.contains(&"/app/data/model-modules".to_string())); + assert!(paths.contains(&"/app/node_modules/onnxruntime-web".to_string())); + assert!(paths.contains(&"/app/services/ws-modules/face-detection".to_string())); +} + +#[test] +fn scenario_module_paths_include_only_selected_workflow_modules() { + let project_root = edge_toolkit::config::get_project_root(); + let ws_server_dir = project_root.join("services/ws-server"); + let paths = scenario_module_paths(&ws_server_dir, &["face-detection".to_string(), "har1".to_string()]); + + assert_eq!( + paths, + vec![ + "static".to_string(), + "../ws-wasm-agent".to_string(), + "../../data/model-modules".to_string(), + "../ws-modules/face-detection".to_string(), + "../ws-modules/har1".to_string(), + ], + ); + assert!(!paths.contains(&"../ws-modules".to_string())); + assert!(!paths.contains(&"../ws-modules/data1".to_string())); +} + +#[test] +fn regenerate_verification_generates_all_deployment_types() { let test_root = tempdir().unwrap(); let verification_root = test_root.path().join("verification"); let input_dir = verification_root.join("local/input"); @@ -54,7 +85,15 @@ agents: assert_eq!(regenerated[0].output_dir, output_dir); assert_eq!(regenerated[0].summary.cluster_name, "manifest-cluster"); assert!(output_dir.join("mise.toml").exists()); + assert!(output_dir.join("compose.yaml").exists()); assert!(output_dir.join("README.md").exists()); + let mise = fs::read_to_string(output_dir.join("mise.toml")).unwrap(); + assert!(mise.contains("export MODULES_PATHS=")); + let readme = fs::read_to_string(output_dir.join("README.md")).unwrap(); + assert!(readme.contains("`mise.toml`")); + assert!(readme.contains("`compose.yaml`")); + assert!(readme.contains("mise run generated-scenario")); + assert!(readme.contains("docker compose up")); } #[test] @@ -96,5 +135,7 @@ agents: [] assert_eq!(regenerated[1].input_file, local_input); assert_eq!(regenerated[1].output_dir, local_output_dir); assert!(local_output_dir.join("mise.toml").exists()); + assert!(local_output_dir.join("compose.yaml").exists()); assert!(ci_output_dir.join("mise.toml").exists()); + assert!(ci_output_dir.join("compose.yaml").exists()); } diff --git a/verification/local/output/facility-security-scenario/README.md b/verification/local/output/facility-security-scenario/README.md index e2e90b5..7555b12 100644 --- a/verification/local/output/facility-security-scenario/README.md +++ b/verification/local/output/facility-security-scenario/README.md @@ -1,10 +1,11 @@ # facility-security-scenario -This directory contains the generated `mise.toml` for the `facility-security-scenario` scenario. +This directory contains generated deployment configs for the `facility-security-scenario` scenario. +Files: `mise.toml`, `compose.yaml`. The scenario exposes these workflow modules: face-detection, har1. -## Run The Scenario +## Run With Mise From this directory, start the scenario with: @@ -14,10 +15,32 @@ mise run generated-scenario That task starts both OpenObserve and `ws-server` for this scenario. -## Open The OpenObserve UI +### Open The OpenObserve UI From this directory, open the OpenObserve UI with: ```bash mise run open-o2 ``` + +## Run With Docker Compose + +From this directory, start the scenario with: + +```bash +docker compose up --build +``` + +The compose stack starts OpenObserve and builds a `ws-server` image from the repository Dockerfile. +`ws-server` runs with host networking so it advertises the same LAN IP as the `mise` deployment. + +### Open The UIs + +OpenObserve is available at . +`ws-server` is available at and . + +Stop the scenario with: + +```bash +docker compose down +``` diff --git a/verification/local/output/facility-security-scenario/compose.yaml b/verification/local/output/facility-security-scenario/compose.yaml new file mode 100644 index 0000000..e3e7ef2 --- /dev/null +++ b/verification/local/output/facility-security-scenario/compose.yaml @@ -0,0 +1,45 @@ +services: + openobserve: + image: openobserve/openobserve:v0.70.3 + healthcheck: + test: + - CMD + - /openobserve + - node + - status + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + ports: + - 5080:5080 + env_file: + - ../../../../config/o2.env + environment: + ZO_DATA_DIR: /data + volumes: + - openobserve-data:/data + ws-server: + build: + context: ../../../.. + dockerfile: services/ws-server/Dockerfile + network_mode: host + environment: + MODULES_PATHS: "/app/services/ws-server/static,\ + /app/services/ws-wasm-agent,\ + /app/data/model-modules,\ + /app/node_modules/onnxruntime-web,\ + /app/services/ws-modules/face-detection,\ + /app/services/ws-modules/har1" + OTLP_AUTH_PASSWORD: "1234" + OTLP_AUTH_USERNAME: root@example.com + OTLP_COLLECTOR_URL: http://127.0.0.1:5080/api/default/v1 + STORAGE_PATH: /app/storage + volumes: + - ws-server-storage:/app/storage + depends_on: + openobserve: + condition: service_healthy +volumes: + openobserve-data: {} + ws-server-storage: {} diff --git a/verification/local/output/facility-security-scenario/mise.toml b/verification/local/output/facility-security-scenario/mise.toml index 8782be7..b8f960b 100644 --- a/verification/local/output/facility-security-scenario/mise.toml +++ b/verification/local/output/facility-security-scenario/mise.toml @@ -19,7 +19,7 @@ docker run --rm --name openobserve -p 5080:5080 \ description = "Run the WebSocket server" dir = "../../../../services/ws-server" run = ''' -MODULES_PATHS="\ +export MODULES_PATHS="\ static,\ ../ws-wasm-agent,\ ../../data/model-modules,\