Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
17a50d776b
|
|||
|
550dcd1f0c
|
|||
|
cbbeaf6d67
|
|||
|
6dffb91626
|
|||
|
c63bf7b814
|
|||
|
7a9af8015e
|
|||
|
d437e6ff36
|
|||
|
93f1b726ce
|
|||
|
c58c5d3eff
|
|||
|
60e8c7f9a8
|
|||
|
272ddf17c2
|
|||
|
b0bf71aa61
|
|||
|
0cb26df68b
|
|||
|
a7c2eb1d9b
|
|||
|
496a192198
|
|||
|
d9e2c485f4
|
|||
|
ed9c97ca32
|
|||
|
31a46ecbbd
|
|||
|
d3426e5d82
|
|||
|
ed38caecec
|
|||
|
f0cc531ada
|
|||
|
b1a1098fbc
|
|||
|
04c52c8158
|
|||
|
1f14c9ac9a
|
|||
|
6c11473999
|
|||
|
ced1916def
|
|||
|
57d4bdfb79
|
|||
|
dd724e0a3c
|
|||
|
ab1dbea329
|
|||
|
9c90f0a477
|
|||
|
aff3df6fcf
|
|||
|
a71fa531f9
|
|||
|
aeb51614cb
|
|||
|
39b3daf57c
|
|||
|
fe65d2debc
|
|||
|
20f32531b7
|
|||
|
856edbd22e
|
|||
|
bf252c51f0
|
|||
|
821ef2f570
|
|||
|
a3211552a5
|
|||
|
0317c6adea
|
|||
|
2f861a9192
|
|||
|
27ce28e2ea
|
|||
|
d71f86a38b
|
|||
|
b02da21aac
|
|||
|
30b26ca5f0
|
|||
|
d4519e862f
|
|||
|
4fc16646eb
|
|||
|
a26a088c69
|
|||
|
71d9821c4c
|
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[registries.sunbeam]
|
||||||
|
index = "sparse+https://src.sunbeam.pt/api/packages/studio/cargo/"
|
||||||
@@ -34,6 +34,17 @@ retries = 2
|
|||||||
[profile.ci.junit]
|
[profile.ci.junit]
|
||||||
path = "target/nextest/ci/junit.xml"
|
path = "target/nextest/ci/junit.xml"
|
||||||
|
|
||||||
|
[profile.cover]
|
||||||
|
# Coverage profile — used with cargo llvm-cov nextest
|
||||||
|
fail-fast = false
|
||||||
|
test-threads = "num-cpus"
|
||||||
|
failure-output = "immediate-final"
|
||||||
|
success-output = "never"
|
||||||
|
slow-timeout = { period = "60s", terminate-after = 2 }
|
||||||
|
|
||||||
|
[profile.cover.junit]
|
||||||
|
path = "target/nextest/cover/junit.xml"
|
||||||
|
|
||||||
# Postgres tests must run serially (shared database state)
|
# Postgres tests must run serially (shared database state)
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = "package(wfe-postgres)"
|
filter = "package(wfe-postgres)"
|
||||||
|
|||||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "wfe-containerd-protos/vendor/containerd"]
|
||||||
|
path = wfe-containerd-protos/vendor/containerd
|
||||||
|
url = https://github.com/containerd/containerd.git
|
||||||
|
[submodule "wfe-buildkit-protos/vendor/buildkit"]
|
||||||
|
path = wfe-buildkit-protos/vendor/buildkit
|
||||||
|
url = https://github.com/moby/buildkit.git
|
||||||
113
CHANGELOG.md
Normal file
113
CHANGELOG.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-04-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **wfe-server**: Headless workflow server (single binary)
|
||||||
|
- gRPC API with 13 RPCs: workflow CRUD, lifecycle streaming, log streaming, log search
|
||||||
|
- HTTP webhooks: GitHub and Gitea with HMAC-SHA256 verification, configurable triggers
|
||||||
|
- OIDC/JWT authentication with JWKS discovery and asymmetric algorithm allowlist
|
||||||
|
- Static bearer token auth with constant-time comparison
|
||||||
|
- Lifecycle event broadcasting via `WatchLifecycle` server-streaming RPC
|
||||||
|
- Real-time log streaming via `StreamLogs` with follow mode and history replay
|
||||||
|
- Full-text log search via OpenSearch with `SearchLogs` RPC
|
||||||
|
- Layered config: CLI flags > env vars > TOML file
|
||||||
|
- **wfe-server-protos**: gRPC service definitions (tonic 0.14, server + client stubs)
|
||||||
|
- **wfe-core**: `LogSink` trait for real-time step output streaming
|
||||||
|
- **wfe-core**: Lifecycle publisher wired into executor (StepStarted, StepCompleted, Error, Completed, Terminated)
|
||||||
|
- **wfe**: `use_log_sink()` on `WorkflowHostBuilder`
|
||||||
|
- **wfe-yaml**: Shell step streaming mode with `tokio::select!` interleaved stdout/stderr
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- JWT algorithm confusion prevention: derive algorithm from JWK, reject symmetric algorithms
|
||||||
|
- Constant-time static token comparison via `subtle` crate
|
||||||
|
- OIDC issuer HTTPS validation to prevent SSRF
|
||||||
|
- Fail-closed on OIDC discovery failure (server won't start with broken auth)
|
||||||
|
- Authenticated generic webhook endpoint
|
||||||
|
- 2MB webhook payload size limit
|
||||||
|
- Config parse errors fail loudly (no silent fallback to open defaults)
|
||||||
|
- Blocked sensitive env var injection (PATH, LD_PRELOAD, etc.) from workflow data
|
||||||
|
- Security regression tests for all critical and high findings
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Shell step streaming path now respects `timeout_ms` with `child.kill()` on timeout
|
||||||
|
- LogSink properly threaded from WorkflowHostBuilder through executor to StepExecutionContext
|
||||||
|
- LogStore.with_search() wired in server main.rs for OpenSearch indexing
|
||||||
|
- OpenSearch `index_chunk` returns Err on HTTP failure instead of swallowing it
|
||||||
|
- Webhook publish failures return 500 instead of 200
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **wfe-rustlang**: New crate with Rust toolchain step executors
|
||||||
|
- Cargo steps: `cargo-build`, `cargo-test`, `cargo-check`, `cargo-clippy`, `cargo-fmt`, `cargo-doc`, `cargo-publish`
|
||||||
|
- External tool steps with auto-install: `cargo-audit`, `cargo-deny`, `cargo-nextest`, `cargo-llvm-cov`
|
||||||
|
- Rustup steps: `rust-install`, `rustup-toolchain`, `rustup-component`, `rustup-target`
|
||||||
|
- `cargo-doc-mdx`: generates MDX documentation from rustdoc JSON output using the `rustdoc-types` crate
|
||||||
|
- **wfe-yaml**: `rustlang` feature flag enabling all cargo/rustup step types
|
||||||
|
- **wfe-yaml**: Schema fields for Rust steps (`package`, `features`, `toolchain`, `profile`, `output_dir`, etc.)
|
||||||
|
- **wfe-containerd**: Remote daemon support via `WFE_IO_DIR` environment variable
|
||||||
|
- **wfe-containerd**: Image chain ID resolution from content store for proper rootfs snapshots
|
||||||
|
- **wfe-containerd**: Docker-default Linux capabilities for root containers
|
||||||
|
- Lima `wfe-test` VM config (Alpine + containerd + BuildKit, TCP socat proxy)
|
||||||
|
- Containerd integration tests running Rust toolchain in containers
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **wfe-containerd**: Empty rootfs — snapshot parent now resolved from image chain ID instead of empty string
|
||||||
|
- **wfe-containerd**: FIFO deadlock with remote daemons — replaced with regular file I/O
|
||||||
|
- **wfe-containerd**: `sh: not found` — use absolute `/bin/sh` path in OCI process spec
|
||||||
|
- **wfe-containerd**: `setgroups: Operation not permitted` — grant capabilities when running as UID 0
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Lima `wfe-test` VM uses Alpine apk packages instead of GitHub release binaries
|
||||||
|
- Container tests use TCP proxy (`http://127.0.0.1:2500`) instead of Unix socket forwarding
|
||||||
|
- CI pipeline (`workflows.yaml`) updated with `wfe-rustlang` in test, package, and publish steps
|
||||||
|
|
||||||
|
879 tests. 88.8% coverage on wfe-rustlang.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-03-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Type-safe `when:` conditions on workflow steps with compile-time validation
|
||||||
|
- Full boolean combinator set: `all` (AND), `any` (OR), `none` (NOR), `one_of` (XOR), `not` (NOT)
|
||||||
|
- Task file includes with cycle detection
|
||||||
|
- Self-hosting CI pipeline (`workflows.yaml`) demonstrating all features
|
||||||
|
- `readFile()` op for deno runtime
|
||||||
|
- Auto-typed `##wfe[output]` annotations (bool, number conversion)
|
||||||
|
- Multi-workflow YAML files, SubWorkflow step type, typed input/output schemas
|
||||||
|
- HostContext for programmatic child workflow invocation
|
||||||
|
- BuildKit image builder and containerd container runner as standalone crates
|
||||||
|
- gRPC clients generated from official upstream proto files (tonic 0.14)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Pipeline coverage step produces valid JSON, deno reads it with `readFile()`
|
||||||
|
- Host context field added to container executor test contexts
|
||||||
|
- `.outputs.` paths resolved flat for child workflows
|
||||||
|
- Pointer status conversion for Skipped in postgres provider
|
||||||
|
|
||||||
|
629 tests. 87.7% coverage.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **wfe-core**: Workflow engine with step primitives, executor, fluent builder API
|
||||||
|
- **wfe**: WorkflowHost, registry, sync runner, and purger
|
||||||
|
- **wfe-sqlite**: SQLite persistence provider
|
||||||
|
- **wfe-postgres**: PostgreSQL persistence provider
|
||||||
|
- **wfe-opensearch**: OpenSearch search index provider
|
||||||
|
- **wfe-valkey**: Valkey provider for locks, queues, and lifecycle events
|
||||||
|
- **wfe-yaml**: YAML workflow definitions with shell and deno executors
|
||||||
|
- **wfe-yaml**: Deno JS/TS runtime with sandboxed permissions, HTTP ops, npm support via esm.sh
|
||||||
|
- OpenTelemetry tracing support behind `otel` feature flag
|
||||||
|
- In-memory test support providers
|
||||||
22
Cargo.toml
22
Cargo.toml
@@ -1,11 +1,13 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["wfe-core", "wfe-sqlite", "wfe-postgres", "wfe-opensearch", "wfe-valkey", "wfe", "wfe-yaml"]
|
members = ["wfe-core", "wfe-sqlite", "wfe-postgres", "wfe-opensearch", "wfe-valkey", "wfe", "wfe-yaml", "wfe-buildkit", "wfe-containerd", "wfe-containerd-protos", "wfe-buildkit-protos", "wfe-rustlang", "wfe-server-protos", "wfe-server"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "1.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
repository = "https://src.sunbeam.pt/studio/wfe"
|
||||||
|
homepage = "https://src.sunbeam.pt/studio/wfe"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Core
|
# Core
|
||||||
@@ -36,15 +38,19 @@ redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
|||||||
opensearch = "2"
|
opensearch = "2"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
wfe-core = { path = "wfe-core" }
|
wfe-core = { version = "1.6.0", path = "wfe-core", registry = "sunbeam" }
|
||||||
wfe-sqlite = { path = "wfe-sqlite" }
|
wfe-sqlite = { version = "1.6.0", path = "wfe-sqlite", registry = "sunbeam" }
|
||||||
wfe-postgres = { path = "wfe-postgres" }
|
wfe-postgres = { version = "1.6.0", path = "wfe-postgres", registry = "sunbeam" }
|
||||||
wfe-opensearch = { path = "wfe-opensearch" }
|
wfe-opensearch = { version = "1.6.0", path = "wfe-opensearch", registry = "sunbeam" }
|
||||||
wfe-valkey = { path = "wfe-valkey" }
|
wfe-valkey = { version = "1.6.0", path = "wfe-valkey", registry = "sunbeam" }
|
||||||
wfe-yaml = { path = "wfe-yaml" }
|
wfe-yaml = { version = "1.6.0", path = "wfe-yaml", registry = "sunbeam" }
|
||||||
|
wfe-buildkit = { version = "1.6.0", path = "wfe-buildkit", registry = "sunbeam" }
|
||||||
|
wfe-containerd = { version = "1.6.0", path = "wfe-containerd", registry = "sunbeam" }
|
||||||
|
wfe-rustlang = { version = "1.6.0", path = "wfe-rustlang", registry = "sunbeam" }
|
||||||
|
|
||||||
# YAML
|
# YAML
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
yaml-merge-keys = { version = "0.8", features = ["serde_yaml"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
# Deno runtime
|
# Deno runtime
|
||||||
|
|||||||
90
README.md
90
README.md
@@ -1,20 +1,24 @@
|
|||||||
# WFE
|
# WFE
|
||||||
|
|
||||||
A persistent, embeddable workflow engine for Rust. Trait-based, pluggable, built for real infrastructure.
|
A persistent, embeddable workflow engine for Rust. Trait-based, pluggable, built for large, highly complex build, test, deployment, and release pipelines of the highest levels of complexity.
|
||||||
|
|
||||||
> Rust port of [workflow-core](https://github.com/danielgerlag/workflow-core), rebuilt from scratch with async/await, pluggable persistence, and a YAML frontend with shell and Deno executors.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is WFE?
|
## What is WFE?
|
||||||
|
|
||||||
WFE is a workflow engine you embed directly into your Rust application. Define workflows as code using a fluent builder API, or as YAML files with shell and JavaScript steps. Workflows persist across restarts, support event-driven pausing, parallel execution, saga compensation, and distributed locking.
|
WFE is a technical love letter from a former [VMware](https://www.vmware.com) and [Pivotal](https://pivotal.io) engineer (@siennathesane). Its internal workflow architecture is based on the amazing [workflow-core](https://github.com/danielgerlag/workflow-core) library by Daniel Gerlag, and the YAML structure and support is based on [Concourse CI](https://concourse-ci.org). WFE is a pluggable, extendable library that can be used to design embedded business workflows, CLI applications, and CI/CD pipelines. It is designed to be embedded into your application, and can scale open-ended with pluggable architectures. You can deploy Cloud Foundry, Kubernetes, or even bootstrap a public cloud with WFE.
|
||||||
|
|
||||||
Built for:
|
You can define workflows as code using a fluent builder API, or as YAML files with shell and JavaScript steps. Workflows persist across restarts, support event-driven pausing, parallel execution, saga compensation, and distributed locking. It also comes with native support for containerd and buildkitd embedded into the application.
|
||||||
|
|
||||||
- **Persistent workflows** — steps survive process restarts. Pick up where you left off.
|
The only thing not included is a server and a web UI (open to contributions that have been agreed upon and discussed ahead of time).
|
||||||
- **Embeddable CLIs** — drop it into a binary, no external orchestrator required.
|
|
||||||
- **Portable CI pipelines** — YAML workflows with shell and Deno steps, variable interpolation, structured outputs.
|
## Why?
|
||||||
|
|
||||||
|
Every CI/CD system [I've](https://src.sunbeam.pt/siennathesane) ever used has been either lovely or terrible. I wanted something that was lovely, but also flexible enough to be used in a variety of contexts. The list of CI systems I've used over the years has been extensive, and they all have their _thing_ that makes them stand out in their own ways. I wanted something that could meet every single requirement I have: a distributed workflow engine that can be embedded into any application, has pluggable executors, a statically-verifiable workflow definition language, a simple, easy to use API, YAML 1.1 merge anchors, YAML 1.2 support, multi-file workflows, can be used as a library, can be used as a CLI, and can be used to write servers.
|
||||||
|
|
||||||
|
With that, I wanted the user experience to be essentially identical for embedded application developers, systems engineers, and CI/CD engineers. Whether you write your workflows in code, YAML, or you have written a web server endpoint that accepts gRPC workflow definitions and you're relying on this library as a hosted service, it should feel like the same piece of software in every context.
|
||||||
|
|
||||||
|
Hell, I'm so dedicated to this being the most useful, most pragmatic, most embeddable workflow engine library ever that I'm willing to accept a C ABI contribution to make it embeddable in any language.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -245,6 +249,76 @@ SQLite tests use temporary files and run everywhere.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Self-hosting CI pipeline
|
||||||
|
|
||||||
|
WFE includes a self-hosting CI pipeline defined in `workflows.yaml` at the repository root. The pipeline uses WFE's own YAML workflow engine to build, test, and publish WFE itself.
|
||||||
|
|
||||||
|
### Pipeline architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ci (orchestrator)
|
||||||
|
|
|
||||||
|
+-------------------+--------------------+
|
||||||
|
| | |
|
||||||
|
preflight lint test (fan-out)
|
||||||
|
(tool check) (fmt + clippy) |
|
||||||
|
+----------+----------+
|
||||||
|
| | |
|
||||||
|
test-unit test-integration test-containers
|
||||||
|
| (docker compose) (lima VM)
|
||||||
|
| | |
|
||||||
|
+----------+----------+
|
||||||
|
|
|
||||||
|
+---------+---------+
|
||||||
|
| | |
|
||||||
|
cover package tag
|
||||||
|
| | |
|
||||||
|
+---------+---------+
|
||||||
|
|
|
||||||
|
+---------+---------+
|
||||||
|
| |
|
||||||
|
publish release
|
||||||
|
(crates.io) (git tags + notes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the pipeline
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Default — uses current directory as workspace
|
||||||
|
cargo run --example run_pipeline -p wfe -- workflows.yaml
|
||||||
|
|
||||||
|
# With explicit configuration
|
||||||
|
WFE_CONFIG='{"workspace_dir":"/path/to/wfe","registry":"sunbeam","git_remote":"origin","coverage_threshold":85}' \
|
||||||
|
cargo run --example run_pipeline -p wfe -- workflows.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### WFE features demonstrated
|
||||||
|
|
||||||
|
The pipeline exercises every major WFE feature:
|
||||||
|
|
||||||
|
- **Workflow composition** — the `ci` orchestrator invokes child workflows (`lint`, `test`, `cover`, `package`, `tag`, `publish`, `release`) using the `workflow` step type.
|
||||||
|
- **Shell executor** — most steps run bash commands with configurable timeouts.
|
||||||
|
- **Deno executor** — the `cover` workflow uses a Deno step to parse coverage JSON; the `release` workflow uses Deno to generate release notes.
|
||||||
|
- **YAML anchors/templates** — `_templates` defines `shell_defaults` and `long_running` anchors, reused across steps via `<<: *shell_defaults`.
|
||||||
|
- **Structured outputs** — steps emit `##wfe[output key=value]` markers to pass data between steps and workflows.
|
||||||
|
- **Variable interpolation** — `((workspace_dir))` syntax passes inputs through workflow composition.
|
||||||
|
- **Error handling** — `on_failure` handlers, `error_behavior` with retry policies, and `ensure` blocks for cleanup (e.g., `docker-down`, `lima-down`).
|
||||||
|
|
||||||
|
### Preflight tool check
|
||||||
|
|
||||||
|
The `preflight` workflow runs first and checks for all required tools: `cargo`, `cargo-nextest`, `cargo-llvm-cov`, `docker`, `limactl`, `buildctl`, and `git`. Essential tools (cargo, nextest, git) cause a hard failure if missing. Optional tools (docker, lima, buildctl, llvm-cov) are reported but do not block the pipeline.
|
||||||
|
|
||||||
|
### Graceful infrastructure skipping
|
||||||
|
|
||||||
|
Integration and container tests handle missing infrastructure without failing:
|
||||||
|
|
||||||
|
- **test-integration**: The `docker-up` step checks if Docker is available. If `docker info` fails, it sets `docker_started=false` and exits cleanly. Subsequent steps (`postgres-tests`, `valkey-tests`, `opensearch-tests`) check this flag and skip if Docker is not running.
|
||||||
|
- **test-containers**: The `lima-up` step checks if `limactl` is installed. If missing, it sets `lima_started=false` and exits cleanly. The `buildkit-tests` and `containerd-tests` steps check this flag and skip accordingly.
|
||||||
|
|
||||||
|
This means the pipeline runs successfully on any machine with the essential Rust toolchain, reporting which optional tests were skipped rather than failing outright.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
141
test/lima/wfe-test.yaml
Normal file
141
test/lima/wfe-test.yaml
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# WFE Test VM — Alpine + containerd + BuildKit
|
||||||
|
#
|
||||||
|
# Lightweight VM for running wfe-buildkit and wfe-containerd integration tests.
|
||||||
|
# Provides system-level containerd and BuildKit daemons with Unix sockets
|
||||||
|
# forwarded to the host.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# limactl create --name wfe-test ./test/lima/wfe-test.yaml
|
||||||
|
# limactl start wfe-test
|
||||||
|
#
|
||||||
|
# Sockets (on host after start):
|
||||||
|
# BuildKit: unix://$HOME/.lima/wfe-test/buildkitd.sock
|
||||||
|
# containerd: unix://$HOME/.lima/wfe-test/containerd.sock
|
||||||
|
#
|
||||||
|
# Run tests:
|
||||||
|
# WFE_BUILDKIT_ADDR="unix://$HOME/.lima/wfe-test/buildkitd.sock" \
|
||||||
|
# WFE_CONTAINERD_ADDR="unix://$HOME/.lima/wfe-test/containerd.sock" \
|
||||||
|
# cargo test -p wfe-buildkit -p wfe-containerd --test integration
|
||||||
|
# cargo test -p wfe-yaml --features rustlang,containerd --test rustlang_containerd -- --ignored
|
||||||
|
#
|
||||||
|
# Teardown:
|
||||||
|
# limactl stop wfe-test
|
||||||
|
# limactl delete wfe-test
|
||||||
|
|
||||||
|
message: |
|
||||||
|
WFE integration test VM is ready.
|
||||||
|
|
||||||
|
containerd: http://127.0.0.1:2500 (TCP proxy, use for gRPC)
|
||||||
|
BuildKit: http://127.0.0.1:2501 (TCP proxy, use for gRPC)
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
WFE_CONTAINERD_ADDR="http://127.0.0.1:2500" \
|
||||||
|
WFE_BUILDKIT_ADDR="http://127.0.0.1:2501" \
|
||||||
|
cargo test -p wfe-yaml --features rustlang,containerd --test rustlang_containerd -- --ignored
|
||||||
|
|
||||||
|
minimumLimaVersion: "2.0.0"
|
||||||
|
|
||||||
|
vmType: vz
|
||||||
|
mountType: virtiofs
|
||||||
|
cpus: 2
|
||||||
|
memory: 4GiB
|
||||||
|
disk: 20GiB
|
||||||
|
|
||||||
|
images:
|
||||||
|
- location: "https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/cloud/nocloud_alpine-3.21.6-aarch64-uefi-cloudinit-r0.qcow2"
|
||||||
|
arch: "aarch64"
|
||||||
|
- location: "https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/cloud/nocloud_alpine-3.21.6-x86_64-uefi-cloudinit-r0.qcow2"
|
||||||
|
arch: "x86_64"
|
||||||
|
|
||||||
|
mounts:
|
||||||
|
# Share /tmp so the containerd shim can access FIFOs created by the host-side executor
|
||||||
|
- location: /tmp/wfe-io
|
||||||
|
mountPoint: /tmp/wfe-io
|
||||||
|
writable: true
|
||||||
|
|
||||||
|
containerd:
|
||||||
|
system: false
|
||||||
|
user: false
|
||||||
|
|
||||||
|
provision:
|
||||||
|
# 1. Base packages + containerd + buildkit from Alpine repos (musl-compatible)
|
||||||
|
- mode: system
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eux
|
||||||
|
apk update
|
||||||
|
apk add --no-cache \
|
||||||
|
curl bash coreutils findutils grep tar gzip pigz \
|
||||||
|
containerd containerd-openrc \
|
||||||
|
runc \
|
||||||
|
buildkit buildkit-openrc \
|
||||||
|
nerdctl
|
||||||
|
|
||||||
|
# 2. Start containerd
|
||||||
|
- mode: system
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eux
|
||||||
|
rc-update add containerd default 2>/dev/null || true
|
||||||
|
rc-service containerd start 2>/dev/null || true
|
||||||
|
# Wait for socket
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
[ -S /run/containerd/containerd.sock ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
chmod 666 /run/containerd/containerd.sock 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Start BuildKit (Alpine package names the service "buildkitd")
|
||||||
|
- mode: system
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eux
|
||||||
|
rc-update add buildkitd default 2>/dev/null || true
|
||||||
|
rc-service buildkitd start 2>/dev/null || true
|
||||||
|
|
||||||
|
# 4. Fix socket permissions + TCP proxy for gRPC access (persists across reboots)
|
||||||
|
- mode: system
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eux
|
||||||
|
apk add --no-cache socat
|
||||||
|
mkdir -p /etc/local.d
|
||||||
|
cat > /etc/local.d/fix-sockets.start << 'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
# Wait for daemons
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
[ -S /run/buildkit/buildkitd.sock ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
# Fix permissions for Lima socket forwarding
|
||||||
|
chmod 755 /run/buildkit /run/containerd 2>/dev/null
|
||||||
|
chmod 666 /run/buildkit/buildkitd.sock /run/containerd/containerd.sock 2>/dev/null
|
||||||
|
# TCP proxy for gRPC (Lima socket forwarding breaks HTTP/2)
|
||||||
|
socat TCP4-LISTEN:2500,fork,reuseaddr UNIX-CONNECT:/run/containerd/containerd.sock &
|
||||||
|
socat TCP4-LISTEN:2501,fork,reuseaddr UNIX-CONNECT:/run/buildkit/buildkitd.sock &
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/local.d/fix-sockets.start
|
||||||
|
rc-update add local default 2>/dev/null || true
|
||||||
|
/etc/local.d/fix-sockets.start
|
||||||
|
|
||||||
|
probes:
|
||||||
|
- script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eux
|
||||||
|
sudo test -S /run/containerd/containerd.sock
|
||||||
|
sudo chmod 755 /run/containerd 2>/dev/null
|
||||||
|
sudo chmod 666 /run/containerd/containerd.sock 2>/dev/null
|
||||||
|
hint: "Waiting for containerd socket"
|
||||||
|
- script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eux
|
||||||
|
sudo test -S /run/buildkit/buildkitd.sock
|
||||||
|
sudo chmod 755 /run/buildkit 2>/dev/null
|
||||||
|
sudo chmod 666 /run/buildkit/buildkitd.sock 2>/dev/null
|
||||||
|
hint: "Waiting for BuildKit socket"
|
||||||
|
|
||||||
|
portForwards:
|
||||||
|
- guestSocket: "/run/buildkit/buildkitd.sock"
|
||||||
|
hostSocket: "{{.Dir}}/buildkitd.sock"
|
||||||
|
- guestSocket: "/run/containerd/containerd.sock"
|
||||||
|
hostSocket: "{{.Dir}}/containerd.sock"
|
||||||
19
wfe-buildkit-protos/Cargo.toml
Normal file
19
wfe-buildkit-protos/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "wfe-buildkit-protos"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "Generated gRPC stubs for the full BuildKit API"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tonic = "0.14"
|
||||||
|
tonic-prost = "0.14"
|
||||||
|
prost = "0.14"
|
||||||
|
prost-types = "0.14"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tonic-build = "0.14"
|
||||||
|
tonic-prost-build = "0.14"
|
||||||
|
prost-build = "0.14"
|
||||||
56
wfe-buildkit-protos/build.rs
Normal file
56
wfe-buildkit-protos/build.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Use Go-style import paths so protoc sees each file only once
|
||||||
|
let proto_dir = PathBuf::from("proto");
|
||||||
|
let go_prefix = "github.com/moby/buildkit";
|
||||||
|
|
||||||
|
let proto_files: Vec<PathBuf> = vec![
|
||||||
|
// Core control service (Solve, Status, ListWorkers, etc.)
|
||||||
|
"api/services/control/control.proto",
|
||||||
|
// Types
|
||||||
|
"api/types/worker.proto",
|
||||||
|
// Solver / LLB definitions
|
||||||
|
"solver/pb/ops.proto",
|
||||||
|
// Source policy
|
||||||
|
"sourcepolicy/pb/policy.proto",
|
||||||
|
// Session protocols
|
||||||
|
"session/auth/auth.proto",
|
||||||
|
"session/filesync/filesync.proto",
|
||||||
|
"session/secrets/secrets.proto",
|
||||||
|
"session/sshforward/ssh.proto",
|
||||||
|
"session/upload/upload.proto",
|
||||||
|
"session/exporter/exporter.proto",
|
||||||
|
// Utilities
|
||||||
|
"util/apicaps/pb/caps.proto",
|
||||||
|
"util/stack/stack.proto",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| proto_dir.join(go_prefix).join(p))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"cargo:warning=Compiling {} buildkit proto files",
|
||||||
|
proto_files.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut prost_config = prost_build::Config::new();
|
||||||
|
prost_config.include_file("mod.rs");
|
||||||
|
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_server(false)
|
||||||
|
.compile_with_config(
|
||||||
|
prost_config,
|
||||||
|
&proto_files,
|
||||||
|
// Include paths for import resolution:
|
||||||
|
// 1. The vendor dir inside buildkit (for Go-style github.com/... imports)
|
||||||
|
// 2. The buildkit root itself (for relative imports)
|
||||||
|
// 3. Our proto/ dir (for google/rpc/status.proto)
|
||||||
|
&[
|
||||||
|
// proto/ has symlinks that resolve Go-style github.com/... imports
|
||||||
|
PathBuf::from("proto"),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
1
wfe-buildkit-protos/proto/github.com/moby/buildkit
Symbolic link
1
wfe-buildkit-protos/proto/github.com/moby/buildkit
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/Users/sienna/Development/sunbeam/wfe/wfe-buildkit-protos/vendor/buildkit
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Stub for vtprotobuf extensions — not needed for Rust codegen
|
||||||
|
syntax = "proto3";
|
||||||
|
package vtproto;
|
||||||
|
import "google/protobuf/descriptor.proto";
|
||||||
|
extend google.protobuf.MessageOptions {
|
||||||
|
bool mempool = 64101;
|
||||||
|
}
|
||||||
1
wfe-buildkit-protos/proto/github.com/tonistiigi/fsutil
Symbolic link
1
wfe-buildkit-protos/proto/github.com/tonistiigi/fsutil
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/Users/sienna/Development/sunbeam/wfe/wfe-buildkit-protos/vendor/buildkit/vendor/github.com/tonistiigi/fsutil
|
||||||
49
wfe-buildkit-protos/proto/google/rpc/status.proto
Normal file
49
wfe-buildkit-protos/proto/google/rpc/status.proto
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2025 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package google.rpc;
|
||||||
|
|
||||||
|
import "google/protobuf/any.proto";
|
||||||
|
|
||||||
|
option cc_enable_arenas = true;
|
||||||
|
option go_package = "google.golang.org/genproto/googleapis/rpc/status;status";
|
||||||
|
option java_multiple_files = true;
|
||||||
|
option java_outer_classname = "StatusProto";
|
||||||
|
option java_package = "com.google.rpc";
|
||||||
|
option objc_class_prefix = "RPC";
|
||||||
|
|
||||||
|
// The `Status` type defines a logical error model that is suitable for
|
||||||
|
// different programming environments, including REST APIs and RPC APIs. It is
|
||||||
|
// used by [gRPC](https://github.com/grpc). Each `Status` message contains
|
||||||
|
// three pieces of data: error code, error message, and error details.
|
||||||
|
//
|
||||||
|
// You can find out more about this error model and how to work with it in the
|
||||||
|
// [API Design Guide](https://cloud.google.com/apis/design/errors).
|
||||||
|
message Status {
|
||||||
|
// The status code, which should be an enum value of
|
||||||
|
// [google.rpc.Code][google.rpc.Code].
|
||||||
|
int32 code = 1;
|
||||||
|
|
||||||
|
// A developer-facing error message, which should be in English. Any
|
||||||
|
// user-facing error message should be localized and sent in the
|
||||||
|
// [google.rpc.Status.details][google.rpc.Status.details] field, or localized
|
||||||
|
// by the client.
|
||||||
|
string message = 2;
|
||||||
|
|
||||||
|
// A list of messages that carry the error details. There is a common set of
|
||||||
|
// message types for APIs to use.
|
||||||
|
repeated google.protobuf.Any details = 3;
|
||||||
|
}
|
||||||
19
wfe-buildkit-protos/src/lib.rs
Normal file
19
wfe-buildkit-protos/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//! Generated gRPC stubs for the full BuildKit API.
|
||||||
|
//!
|
||||||
|
//! Built from the official BuildKit proto files at
|
||||||
|
//! <https://github.com/moby/buildkit>.
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use wfe_buildkit_protos::moby::buildkit::v1::control_client::ControlClient;
|
||||||
|
//! use wfe_buildkit_protos::moby::buildkit::v1::StatusResponse;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![allow(clippy::all)]
|
||||||
|
#![allow(warnings)]
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/mod.rs"));
|
||||||
|
|
||||||
|
/// Re-export tonic and prost for downstream convenience.
|
||||||
|
pub use prost;
|
||||||
|
pub use prost_types;
|
||||||
|
pub use tonic;
|
||||||
1
wfe-buildkit-protos/vendor/buildkit
vendored
Submodule
1
wfe-buildkit-protos/vendor/buildkit
vendored
Submodule
Submodule wfe-buildkit-protos/vendor/buildkit added at 7ea9fa1c7c
30
wfe-buildkit/Cargo.toml
Normal file
30
wfe-buildkit/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "wfe-buildkit"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "BuildKit image builder executor for WFE"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wfe-core = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
regex = { workspace = true }
|
||||||
|
wfe-buildkit-protos = { version = "1.6.0", path = "../wfe-buildkit-protos", registry = "sunbeam" }
|
||||||
|
tonic = "0.14"
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
95
wfe-buildkit/README.md
Normal file
95
wfe-buildkit/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# wfe-buildkit
|
||||||
|
|
||||||
|
BuildKit image builder executor for WFE.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
`wfe-buildkit` provides a `BuildkitStep` that implements the `StepBody` trait from `wfe-core`. It shells out to the `buildctl` CLI to build container images using BuildKit, capturing stdout/stderr and parsing image digests from the output.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Use it standalone:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wfe_buildkit::{BuildkitConfig, BuildkitStep};
|
||||||
|
|
||||||
|
let config = BuildkitConfig {
|
||||||
|
dockerfile: "Dockerfile".to_string(),
|
||||||
|
context: ".".to_string(),
|
||||||
|
tags: vec!["myapp:latest".to_string()],
|
||||||
|
push: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
|
||||||
|
// Inspect the command that would be executed.
|
||||||
|
let args = step.build_command();
|
||||||
|
println!("{}", args.join(" "));
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use it through `wfe-yaml` with the `buildkit` feature:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflow:
|
||||||
|
id: build-image
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
type: buildkit
|
||||||
|
config:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
context: .
|
||||||
|
tags:
|
||||||
|
- myapp:latest
|
||||||
|
- myapp:v1.0
|
||||||
|
push: true
|
||||||
|
build_args:
|
||||||
|
RUST_VERSION: "1.78"
|
||||||
|
cache_from:
|
||||||
|
- type=registry,ref=myapp:cache
|
||||||
|
cache_to:
|
||||||
|
- type=registry,ref=myapp:cache,mode=max
|
||||||
|
timeout: 10m
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `dockerfile` | String | Yes | - | Path to the Dockerfile |
|
||||||
|
| `context` | String | Yes | - | Build context directory |
|
||||||
|
| `target` | String | No | - | Multi-stage build target |
|
||||||
|
| `tags` | Vec\<String\> | No | [] | Image tags |
|
||||||
|
| `build_args` | Map\<String, String\> | No | {} | Build arguments |
|
||||||
|
| `cache_from` | Vec\<String\> | No | [] | Cache import sources |
|
||||||
|
| `cache_to` | Vec\<String\> | No | [] | Cache export destinations |
|
||||||
|
| `push` | bool | No | false | Push image after build |
|
||||||
|
| `output_type` | String | No | "image" | Output type: image, local, tar |
|
||||||
|
| `buildkit_addr` | String | No | unix:///run/buildkit/buildkitd.sock | BuildKit daemon address |
|
||||||
|
| `tls` | TlsConfig | No | - | TLS certificate paths |
|
||||||
|
| `registry_auth` | Map\<String, RegistryAuth\> | No | {} | Registry credentials |
|
||||||
|
| `timeout_ms` | u64 | No | - | Execution timeout in milliseconds |
|
||||||
|
|
||||||
|
## Output data
|
||||||
|
|
||||||
|
After execution, the step writes the following keys into `output_data`:
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|---|---|
|
||||||
|
| `{step_name}.digest` | Image digest (sha256:...), if found in output |
|
||||||
|
| `{step_name}.tags` | Array of tags applied to the image |
|
||||||
|
| `{step_name}.stdout` | Full stdout from buildctl |
|
||||||
|
| `{step_name}.stderr` | Full stderr from buildctl |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p wfe-buildkit
|
||||||
|
```
|
||||||
|
|
||||||
|
The `build_command()` method returns the full argument list without executing, making it possible to test command construction without a running BuildKit daemon.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
358
wfe-buildkit/src/config.rs
Normal file
358
wfe-buildkit/src/config.rs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Configuration for a BuildKit image build step.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BuildkitConfig {
|
||||||
|
/// Path to the Dockerfile (or directory containing it).
|
||||||
|
pub dockerfile: String,
|
||||||
|
/// Build context directory.
|
||||||
|
pub context: String,
|
||||||
|
/// Multi-stage build target.
|
||||||
|
pub target: Option<String>,
|
||||||
|
/// Image tags to apply.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Build arguments passed as `--opt build-arg:KEY=VALUE`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub build_args: HashMap<String, String>,
|
||||||
|
/// Cache import sources.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_from: Vec<String>,
|
||||||
|
/// Cache export destinations.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_to: Vec<String>,
|
||||||
|
/// Whether to push the built image.
|
||||||
|
#[serde(default)]
|
||||||
|
pub push: bool,
|
||||||
|
/// Output type: "image", "local", "tar".
|
||||||
|
pub output_type: Option<String>,
|
||||||
|
/// BuildKit daemon address.
|
||||||
|
#[serde(default = "default_buildkit_addr")]
|
||||||
|
pub buildkit_addr: String,
|
||||||
|
/// TLS configuration for the BuildKit connection.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls: TlsConfig,
|
||||||
|
/// Registry authentication credentials keyed by registry host.
|
||||||
|
#[serde(default)]
|
||||||
|
pub registry_auth: HashMap<String, RegistryAuth>,
|
||||||
|
/// Execution timeout in milliseconds.
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TLS certificate paths for securing the BuildKit connection.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct TlsConfig {
|
||||||
|
/// Path to the CA certificate.
|
||||||
|
pub ca: Option<String>,
|
||||||
|
/// Path to the client certificate.
|
||||||
|
pub cert: Option<String>,
|
||||||
|
/// Path to the client private key.
|
||||||
|
pub key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials for authenticating with a container registry.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistryAuth {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_buildkit_addr() -> String {
|
||||||
|
"unix:///run/buildkit/buildkitd.sock".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_full_config() {
|
||||||
|
let mut build_args = HashMap::new();
|
||||||
|
build_args.insert("RUST_VERSION".to_string(), "1.78".to_string());
|
||||||
|
|
||||||
|
let mut registry_auth = HashMap::new();
|
||||||
|
registry_auth.insert(
|
||||||
|
"ghcr.io".to_string(),
|
||||||
|
RegistryAuth {
|
||||||
|
username: "user".to_string(),
|
||||||
|
password: "pass".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = BuildkitConfig {
|
||||||
|
dockerfile: "./Dockerfile".to_string(),
|
||||||
|
context: ".".to_string(),
|
||||||
|
target: Some("runtime".to_string()),
|
||||||
|
tags: vec!["myapp:latest".to_string(), "myapp:v1.0".to_string()],
|
||||||
|
build_args,
|
||||||
|
cache_from: vec!["type=registry,ref=myapp:cache".to_string()],
|
||||||
|
cache_to: vec!["type=registry,ref=myapp:cache,mode=max".to_string()],
|
||||||
|
push: true,
|
||||||
|
output_type: Some("image".to_string()),
|
||||||
|
buildkit_addr: "tcp://buildkitd:1234".to_string(),
|
||||||
|
tls: TlsConfig {
|
||||||
|
ca: Some("/certs/ca.pem".to_string()),
|
||||||
|
cert: Some("/certs/cert.pem".to_string()),
|
||||||
|
key: Some("/certs/key.pem".to_string()),
|
||||||
|
},
|
||||||
|
registry_auth,
|
||||||
|
timeout_ms: Some(300_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let deserialized: BuildkitConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.dockerfile, deserialized.dockerfile);
|
||||||
|
assert_eq!(config.context, deserialized.context);
|
||||||
|
assert_eq!(config.target, deserialized.target);
|
||||||
|
assert_eq!(config.tags, deserialized.tags);
|
||||||
|
assert_eq!(config.build_args, deserialized.build_args);
|
||||||
|
assert_eq!(config.cache_from, deserialized.cache_from);
|
||||||
|
assert_eq!(config.cache_to, deserialized.cache_to);
|
||||||
|
assert_eq!(config.push, deserialized.push);
|
||||||
|
assert_eq!(config.output_type, deserialized.output_type);
|
||||||
|
assert_eq!(config.buildkit_addr, deserialized.buildkit_addr);
|
||||||
|
assert_eq!(config.tls.ca, deserialized.tls.ca);
|
||||||
|
assert_eq!(config.tls.cert, deserialized.tls.cert);
|
||||||
|
assert_eq!(config.tls.key, deserialized.tls.key);
|
||||||
|
assert_eq!(config.timeout_ms, deserialized.timeout_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_minimal_config() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": "."
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.dockerfile, "Dockerfile");
|
||||||
|
assert_eq!(config.context, ".");
|
||||||
|
assert_eq!(config.target, None);
|
||||||
|
assert!(config.tags.is_empty());
|
||||||
|
assert!(config.build_args.is_empty());
|
||||||
|
assert!(config.cache_from.is_empty());
|
||||||
|
assert!(config.cache_to.is_empty());
|
||||||
|
assert!(!config.push);
|
||||||
|
assert_eq!(config.output_type, None);
|
||||||
|
assert_eq!(config.buildkit_addr, "unix:///run/buildkit/buildkitd.sock");
|
||||||
|
assert_eq!(config.timeout_ms, None);
|
||||||
|
|
||||||
|
// Round-trip
|
||||||
|
let serialized = serde_json::to_string(&config).unwrap();
|
||||||
|
let deserialized: BuildkitConfig = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(config.dockerfile, deserialized.dockerfile);
|
||||||
|
assert_eq!(config.context, deserialized.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_buildkit_addr_value() {
|
||||||
|
let addr = default_buildkit_addr();
|
||||||
|
assert_eq!(addr, "unix:///run/buildkit/buildkitd.sock");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_config_defaults_to_none() {
|
||||||
|
let tls = TlsConfig::default();
|
||||||
|
assert_eq!(tls.ca, None);
|
||||||
|
assert_eq!(tls.cert, None);
|
||||||
|
assert_eq!(tls.key, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_auth_serde() {
|
||||||
|
let auth = RegistryAuth {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
password: "secret123".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&auth).unwrap();
|
||||||
|
let deserialized: RegistryAuth = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(auth.username, deserialized.username);
|
||||||
|
assert_eq!(auth.password, deserialized.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_custom_addr() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"buildkit_addr": "tcp://remote:1234"
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.buildkit_addr, "tcp://remote:1234");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_timeout() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"timeout_ms": 60000
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.timeout_ms, Some(60000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_tags_and_push() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tags": ["myapp:latest", "myapp:v1.0"],
|
||||||
|
"push": true
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.tags, vec!["myapp:latest", "myapp:v1.0"]);
|
||||||
|
assert!(config.push);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_build_args() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"build_args": {"VERSION": "1.0", "DEBUG": "false"}
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.build_args.len(), 2);
|
||||||
|
assert_eq!(config.build_args["VERSION"], "1.0");
|
||||||
|
assert_eq!(config.build_args["DEBUG"], "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_cache_config() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"cache_from": ["type=registry,ref=cache:latest"],
|
||||||
|
"cache_to": ["type=registry,ref=cache:latest,mode=max"]
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.cache_from.len(), 1);
|
||||||
|
assert_eq!(config.cache_to.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_output_type() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"output_type": "tar"
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.output_type, Some("tar".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_registry_auth() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"registry_auth": {
|
||||||
|
"ghcr.io": {"username": "bot", "password": "tok"},
|
||||||
|
"docker.io": {"username": "u", "password": "p"}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.registry_auth.len(), 2);
|
||||||
|
assert_eq!(config.registry_auth["ghcr.io"].username, "bot");
|
||||||
|
assert_eq!(config.registry_auth["docker.io"].password, "p");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_tls() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tls": {
|
||||||
|
"ca": "/certs/ca.pem",
|
||||||
|
"cert": "/certs/cert.pem",
|
||||||
|
"key": "/certs/key.pem"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.tls.ca, Some("/certs/ca.pem".to_string()));
|
||||||
|
assert_eq!(config.tls.cert, Some("/certs/cert.pem".to_string()));
|
||||||
|
assert_eq!(config.tls.key, Some("/certs/key.pem".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_partial_tls() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tls": {"ca": "/certs/ca.pem"}
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.tls.ca, Some("/certs/ca.pem".to_string()));
|
||||||
|
assert_eq!(config.tls.cert, None);
|
||||||
|
assert_eq!(config.tls.key, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_empty_tls_object() {
|
||||||
|
let json = r#"{
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
"tls": {}
|
||||||
|
}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.tls.ca, None);
|
||||||
|
assert_eq!(config.tls.cert, None);
|
||||||
|
assert_eq!(config.tls.key, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_config_clone() {
|
||||||
|
let tls = TlsConfig {
|
||||||
|
ca: Some("ca".to_string()),
|
||||||
|
cert: Some("cert".to_string()),
|
||||||
|
key: Some("key".to_string()),
|
||||||
|
};
|
||||||
|
let cloned = tls.clone();
|
||||||
|
assert_eq!(tls.ca, cloned.ca);
|
||||||
|
assert_eq!(tls.cert, cloned.cert);
|
||||||
|
assert_eq!(tls.key, cloned.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_config_debug() {
|
||||||
|
let tls = TlsConfig::default();
|
||||||
|
let debug = format!("{:?}", tls);
|
||||||
|
assert!(debug.contains("TlsConfig"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buildkit_config_debug() {
|
||||||
|
let json = r#"{"dockerfile": "Dockerfile", "context": "."}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
let debug = format!("{:?}", config);
|
||||||
|
assert!(debug.contains("BuildkitConfig"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_auth_clone() {
|
||||||
|
let auth = RegistryAuth {
|
||||||
|
username: "u".to_string(),
|
||||||
|
password: "p".to_string(),
|
||||||
|
};
|
||||||
|
let cloned = auth.clone();
|
||||||
|
assert_eq!(auth.username, cloned.username);
|
||||||
|
assert_eq!(auth.password, cloned.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buildkit_config_clone() {
|
||||||
|
let json = r#"{"dockerfile": "Dockerfile", "context": "."}"#;
|
||||||
|
let config: BuildkitConfig = serde_json::from_str(json).unwrap();
|
||||||
|
let cloned = config.clone();
|
||||||
|
assert_eq!(config.dockerfile, cloned.dockerfile);
|
||||||
|
assert_eq!(config.context, cloned.context);
|
||||||
|
assert_eq!(config.buildkit_addr, cloned.buildkit_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
wfe-buildkit/src/lib.rs
Normal file
5
wfe-buildkit/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod step;
|
||||||
|
|
||||||
|
pub use config::{BuildkitConfig, RegistryAuth, TlsConfig};
|
||||||
|
pub use step::{build_output_data, parse_digest, BuildkitStep};
|
||||||
905
wfe-buildkit/src/step.rs
Normal file
905
wfe-buildkit/src/step.rs
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use regex::Regex;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use tonic::transport::{Channel, Endpoint, Uri};
|
||||||
|
use wfe_buildkit_protos::moby::buildkit::v1::control_client::ControlClient;
|
||||||
|
use wfe_buildkit_protos::moby::buildkit::v1::{
|
||||||
|
CacheOptions, CacheOptionsEntry, Exporter, SolveRequest, StatusRequest,
|
||||||
|
};
|
||||||
|
use wfe_core::models::ExecutionResult;
|
||||||
|
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||||
|
use wfe_core::WfeError;
|
||||||
|
|
||||||
|
use crate::config::BuildkitConfig;
|
||||||
|
|
||||||
|
/// Result of a BuildKit solve operation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct BuildResult {
|
||||||
|
/// Image digest produced by the build, if any.
|
||||||
|
pub digest: Option<String>,
|
||||||
|
/// Full exporter response metadata from the daemon.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub metadata: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A workflow step that builds container images via the BuildKit gRPC API.
|
||||||
|
pub struct BuildkitStep {
|
||||||
|
config: BuildkitConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BuildkitStep {
|
||||||
|
/// Create a new BuildKit step from configuration.
|
||||||
|
pub fn new(config: BuildkitConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to the BuildKit daemon and return a raw `ControlClient`.
|
||||||
|
///
|
||||||
|
/// Supports Unix socket (`unix://`), TCP (`tcp://`), and HTTP (`http://`)
|
||||||
|
/// endpoints.
|
||||||
|
async fn connect(&self) -> Result<ControlClient<Channel>, WfeError> {
|
||||||
|
let addr = &self.config.buildkit_addr;
|
||||||
|
tracing::info!(addr = %addr, "connecting to BuildKit daemon");
|
||||||
|
|
||||||
|
let channel = if addr.starts_with("unix://") {
|
||||||
|
let socket_path = addr
|
||||||
|
.strip_prefix("unix://")
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Verify the socket exists before attempting connection.
|
||||||
|
if !Path::new(&socket_path).exists() {
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"BuildKit socket not found: {socket_path}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// tonic requires a dummy URI for Unix sockets; the actual path
|
||||||
|
// is provided via the connector.
|
||||||
|
Endpoint::try_from("http://[::]:50051")
|
||||||
|
.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("failed to create endpoint: {e}"))
|
||||||
|
})?
|
||||||
|
.connect_with_connector(tower::service_fn(move |_: Uri| {
|
||||||
|
let path = socket_path.clone();
|
||||||
|
async move {
|
||||||
|
tokio::net::UnixStream::connect(path)
|
||||||
|
.await
|
||||||
|
.map(hyper_util::rt::TokioIo::new)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!(
|
||||||
|
"failed to connect to buildkitd via Unix socket at {addr}: {e}"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
// TCP or HTTP endpoint.
|
||||||
|
let connect_addr = if addr.starts_with("tcp://") {
|
||||||
|
addr.replacen("tcp://", "http://", 1)
|
||||||
|
} else {
|
||||||
|
addr.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
Endpoint::from_shared(connect_addr.clone())
|
||||||
|
.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!(
|
||||||
|
"invalid BuildKit endpoint {connect_addr}: {e}"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!(
|
||||||
|
"failed to connect to buildkitd at {connect_addr}: {e}"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ControlClient::new(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build frontend attributes from the current configuration.
|
||||||
|
///
|
||||||
|
/// These attributes tell the BuildKit dockerfile frontend how to process
|
||||||
|
/// the build: which file to use, which target stage, build arguments, etc.
|
||||||
|
fn build_frontend_attrs(&self) -> HashMap<String, String> {
|
||||||
|
let mut attrs = HashMap::new();
|
||||||
|
|
||||||
|
// Dockerfile filename (relative to context).
|
||||||
|
if self.config.dockerfile != "Dockerfile" {
|
||||||
|
attrs.insert("filename".to_string(), self.config.dockerfile.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target stage for multi-stage builds.
|
||||||
|
if let Some(ref target) = self.config.target {
|
||||||
|
attrs.insert("target".to_string(), target.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build arguments (sorted for determinism).
|
||||||
|
let mut sorted_args: Vec<_> = self.config.build_args.iter().collect();
|
||||||
|
sorted_args.sort_by_key(|(k, _)| (*k).clone());
|
||||||
|
for (key, value) in &sorted_args {
|
||||||
|
attrs.insert(format!("build-arg:{key}"), value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build exporter configuration for image output.
|
||||||
|
fn build_exporters(&self) -> Vec<Exporter> {
|
||||||
|
if self.config.tags.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut export_attrs = HashMap::new();
|
||||||
|
export_attrs.insert("name".to_string(), self.config.tags.join(","));
|
||||||
|
if self.config.push {
|
||||||
|
export_attrs.insert("push".to_string(), "true".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![Exporter {
|
||||||
|
r#type: "image".to_string(),
|
||||||
|
attrs: export_attrs,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build cache options from the configuration.
|
||||||
|
fn build_cache_options(&self) -> Option<CacheOptions> {
|
||||||
|
if self.config.cache_from.is_empty() && self.config.cache_to.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let imports = self
|
||||||
|
.config
|
||||||
|
.cache_from
|
||||||
|
.iter()
|
||||||
|
.map(|source| {
|
||||||
|
let mut attrs = HashMap::new();
|
||||||
|
attrs.insert("ref".to_string(), source.clone());
|
||||||
|
CacheOptionsEntry {
|
||||||
|
r#type: "registry".to_string(),
|
||||||
|
attrs,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let exports = self
|
||||||
|
.config
|
||||||
|
.cache_to
|
||||||
|
.iter()
|
||||||
|
.map(|dest| {
|
||||||
|
let mut attrs = HashMap::new();
|
||||||
|
attrs.insert("ref".to_string(), dest.clone());
|
||||||
|
attrs.insert("mode".to_string(), "max".to_string());
|
||||||
|
CacheOptionsEntry {
|
||||||
|
r#type: "registry".to_string(),
|
||||||
|
attrs,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(CacheOptions {
|
||||||
|
export_ref_deprecated: String::new(),
|
||||||
|
import_refs_deprecated: vec![],
|
||||||
|
export_attrs_deprecated: HashMap::new(),
|
||||||
|
exports,
|
||||||
|
imports,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute the build against a connected BuildKit daemon.
|
||||||
|
///
|
||||||
|
/// Constructs a `SolveRequest` using the dockerfile.v0 frontend and
|
||||||
|
/// sends it via the Control gRPC service. The build context must be
|
||||||
|
/// accessible to the daemon on its local filesystem (shared mount or
|
||||||
|
/// same machine).
|
||||||
|
///
|
||||||
|
/// # Session protocol
|
||||||
|
///
|
||||||
|
/// TODO: For remote daemons where the build context is not on the same
|
||||||
|
/// filesystem, a full session protocol implementation is needed to
|
||||||
|
/// transfer files. Currently we rely on the context directory being
|
||||||
|
/// available to buildkitd (e.g., via a shared mount in Lima/colima).
|
||||||
|
async fn execute_build(
|
||||||
|
&self,
|
||||||
|
control: &mut ControlClient<Channel>,
|
||||||
|
) -> Result<BuildResult, WfeError> {
|
||||||
|
let build_ref = format!("wfe-build-{}", uuid::Uuid::new_v4());
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Resolve the absolute context path.
|
||||||
|
let abs_context = std::fs::canonicalize(&self.config.context).map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!(
|
||||||
|
"failed to resolve context path {}: {e}",
|
||||||
|
self.config.context
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Build frontend attributes with local context references.
|
||||||
|
let mut frontend_attrs = self.build_frontend_attrs();
|
||||||
|
|
||||||
|
// Point the frontend at the daemon-local context directory.
|
||||||
|
// The "context" attr tells the dockerfile frontend where to find
|
||||||
|
// the build context. For local builds we use the local source type
|
||||||
|
// with a shared-key reference.
|
||||||
|
let context_name = "context";
|
||||||
|
let dockerfile_name = "dockerfile";
|
||||||
|
|
||||||
|
frontend_attrs.insert(
|
||||||
|
"context".to_string(),
|
||||||
|
format!("local://{context_name}"),
|
||||||
|
);
|
||||||
|
frontend_attrs.insert(
|
||||||
|
format!("local-sessionid:{context_name}"),
|
||||||
|
session_id.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also provide the dockerfile source as a local reference.
|
||||||
|
frontend_attrs.insert(
|
||||||
|
"dockerfilekey".to_string(),
|
||||||
|
format!("local://{dockerfile_name}"),
|
||||||
|
);
|
||||||
|
frontend_attrs.insert(
|
||||||
|
format!("local-sessionid:{dockerfile_name}"),
|
||||||
|
session_id.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = SolveRequest {
|
||||||
|
r#ref: build_ref.clone(),
|
||||||
|
definition: None,
|
||||||
|
exporter_deprecated: String::new(),
|
||||||
|
exporter_attrs_deprecated: HashMap::new(),
|
||||||
|
session: session_id.clone(),
|
||||||
|
frontend: "dockerfile.v0".to_string(),
|
||||||
|
frontend_attrs,
|
||||||
|
cache: self.build_cache_options(),
|
||||||
|
entitlements: vec![],
|
||||||
|
frontend_inputs: HashMap::new(),
|
||||||
|
internal: false,
|
||||||
|
source_policy: None,
|
||||||
|
exporters: self.build_exporters(),
|
||||||
|
enable_session_exporter: false,
|
||||||
|
source_policy_session: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach session metadata headers so buildkitd knows which
|
||||||
|
// session provides the local source content.
|
||||||
|
let mut grpc_request = tonic::Request::new(request);
|
||||||
|
let metadata = grpc_request.metadata_mut();
|
||||||
|
|
||||||
|
// The x-docker-expose-session-uuid header tells buildkitd which
|
||||||
|
// session owns the local sources. The x-docker-expose-session-grpc-method
|
||||||
|
// header lists the gRPC methods the session implements.
|
||||||
|
if let Ok(key) =
|
||||||
|
"x-docker-expose-session-uuid"
|
||||||
|
.parse::<tonic::metadata::MetadataKey<tonic::metadata::Ascii>>()
|
||||||
|
&& let Ok(val) = session_id
|
||||||
|
.parse::<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>()
|
||||||
|
{
|
||||||
|
metadata.insert(key, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advertise the filesync method so the daemon knows it can request
|
||||||
|
// local file content from our session.
|
||||||
|
if let Ok(key) =
|
||||||
|
"x-docker-expose-session-grpc-method"
|
||||||
|
.parse::<tonic::metadata::MetadataKey<tonic::metadata::Ascii>>()
|
||||||
|
{
|
||||||
|
if let Ok(val) = "/moby.filesync.v1.FileSync/DiffCopy"
|
||||||
|
.parse::<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>()
|
||||||
|
{
|
||||||
|
metadata.append(key.clone(), val);
|
||||||
|
}
|
||||||
|
if let Ok(val) = "/moby.filesync.v1.Auth/Credentials"
|
||||||
|
.parse::<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>()
|
||||||
|
{
|
||||||
|
metadata.append(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
context = %abs_context.display(),
|
||||||
|
session_id = %session_id,
|
||||||
|
"sending solve request to BuildKit"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = control
|
||||||
|
.solve(grpc_request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WfeError::StepExecution(format!("BuildKit solve failed: {e}")))?;
|
||||||
|
let solve_response = response.into_inner();
|
||||||
|
|
||||||
|
// Monitor progress (non-blocking, best effort).
|
||||||
|
let status_request = StatusRequest {
|
||||||
|
r#ref: build_ref.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(stream_resp) = control.status(status_request).await {
|
||||||
|
let mut stream = stream_resp.into_inner();
|
||||||
|
while let Some(Ok(status)) = stream.next().await {
|
||||||
|
for vertex in &status.vertexes {
|
||||||
|
if !vertex.name.is_empty() {
|
||||||
|
tracing::debug!(vertex = %vertex.name, "build progress");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract digest.
|
||||||
|
let digest = solve_response
|
||||||
|
.exporter_response
|
||||||
|
.get("containerimage.digest")
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
tracing::info!(digest = ?digest, "build completed");
|
||||||
|
|
||||||
|
Ok(BuildResult {
|
||||||
|
digest,
|
||||||
|
metadata: solve_response.exporter_response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build environment variables for registry authentication.
|
||||||
|
///
|
||||||
|
/// This is still useful when the BuildKit daemon reads credentials from
|
||||||
|
/// environment variables rather than session-based auth.
|
||||||
|
pub fn build_registry_env(&self) -> HashMap<String, String> {
|
||||||
|
let mut env = HashMap::new();
|
||||||
|
for (host, auth) in &self.config.registry_auth {
|
||||||
|
let sanitized_host = host.replace(['.', '-'], "_").to_uppercase();
|
||||||
|
env.insert(
|
||||||
|
format!("BUILDKIT_HOST_{sanitized_host}_USERNAME"),
|
||||||
|
auth.username.clone(),
|
||||||
|
);
|
||||||
|
env.insert(
|
||||||
|
format!("BUILDKIT_HOST_{sanitized_host}_PASSWORD"),
|
||||||
|
auth.password.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the image digest from buildctl or BuildKit progress output.
|
||||||
|
///
|
||||||
|
/// Looks for patterns like `exporting manifest sha256:<hex>` or
|
||||||
|
/// `digest: sha256:<hex>` or the raw `containerimage.digest` value.
|
||||||
|
pub fn parse_digest(output: &str) -> Option<String> {
|
||||||
|
let re = Regex::new(r"(?:exporting manifest |digest: )sha256:([a-f0-9]{64})").unwrap();
|
||||||
|
re.captures(output)
|
||||||
|
.map(|caps| format!("sha256:{}", &caps[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the output data JSON object from step execution results.
|
||||||
|
///
|
||||||
|
/// Assembles a `serde_json::Value::Object` containing the step's stdout,
|
||||||
|
/// stderr, digest (if found), and tags (if any).
|
||||||
|
pub fn build_output_data(
|
||||||
|
step_name: &str,
|
||||||
|
stdout: &str,
|
||||||
|
stderr: &str,
|
||||||
|
digest: Option<&str>,
|
||||||
|
tags: &[String],
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut outputs = serde_json::Map::new();
|
||||||
|
|
||||||
|
if let Some(digest) = digest {
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.digest"),
|
||||||
|
serde_json::Value::String(digest.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tags.is_empty() {
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.tags"),
|
||||||
|
serde_json::Value::Array(
|
||||||
|
tags.iter()
|
||||||
|
.map(|t| serde_json::Value::String(t.clone()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.stdout"),
|
||||||
|
serde_json::Value::String(stdout.to_string()),
|
||||||
|
);
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.stderr"),
|
||||||
|
serde_json::Value::String(stderr.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
serde_json::Value::Object(outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StepBody for BuildkitStep {
|
||||||
|
async fn run(
|
||||||
|
&mut self,
|
||||||
|
context: &StepExecutionContext<'_>,
|
||||||
|
) -> wfe_core::Result<ExecutionResult> {
|
||||||
|
let step_name = context.step.name.as_deref().unwrap_or("unknown");
|
||||||
|
|
||||||
|
// Connect to the BuildKit daemon.
|
||||||
|
let mut control = self.connect().await?;
|
||||||
|
|
||||||
|
tracing::info!(step = step_name, "submitting build to BuildKit");
|
||||||
|
|
||||||
|
// Execute the build with optional timeout.
|
||||||
|
let result = if let Some(timeout_ms) = self.config.timeout_ms {
|
||||||
|
let duration = std::time::Duration::from_millis(timeout_ms);
|
||||||
|
match tokio::time::timeout(duration, self.execute_build(&mut control)).await {
|
||||||
|
Ok(Ok(result)) => result,
|
||||||
|
Ok(Err(e)) => return Err(e),
|
||||||
|
Err(_) => {
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"BuildKit build timed out after {timeout_ms}ms"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.execute_build(&mut control).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract digest from BuildResult.
|
||||||
|
let digest = result.digest.clone();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
step = step_name,
|
||||||
|
digest = ?digest,
|
||||||
|
"build completed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let output_data = build_output_data(
|
||||||
|
step_name,
|
||||||
|
"", // gRPC builds don't produce traditional stdout
|
||||||
|
"", // gRPC builds don't produce traditional stderr
|
||||||
|
digest.as_deref(),
|
||||||
|
&self.config.tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ExecutionResult {
|
||||||
|
proceed: true,
|
||||||
|
output_data: Some(output_data),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::config::{BuildkitConfig, RegistryAuth, TlsConfig};
|
||||||
|
|
||||||
|
fn minimal_config() -> BuildkitConfig {
|
||||||
|
BuildkitConfig {
|
||||||
|
dockerfile: "Dockerfile".to_string(),
|
||||||
|
context: ".".to_string(),
|
||||||
|
target: None,
|
||||||
|
tags: vec![],
|
||||||
|
build_args: HashMap::new(),
|
||||||
|
cache_from: vec![],
|
||||||
|
cache_to: vec![],
|
||||||
|
push: false,
|
||||||
|
output_type: None,
|
||||||
|
buildkit_addr: "unix:///run/buildkit/buildkitd.sock".to_string(),
|
||||||
|
tls: TlsConfig::default(),
|
||||||
|
registry_auth: HashMap::new(),
|
||||||
|
timeout_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// build_registry_env tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_registry_env_with_auth() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.registry_auth.insert(
|
||||||
|
"ghcr.io".to_string(),
|
||||||
|
RegistryAuth {
|
||||||
|
username: "user".to_string(),
|
||||||
|
password: "token".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let env = step.build_registry_env();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.get("BUILDKIT_HOST_GHCR_IO_USERNAME"),
|
||||||
|
Some(&"user".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
env.get("BUILDKIT_HOST_GHCR_IO_PASSWORD"),
|
||||||
|
Some(&"token".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_registry_env_sanitizes_host() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.registry_auth.insert(
|
||||||
|
"my-registry.example.com".to_string(),
|
||||||
|
RegistryAuth {
|
||||||
|
username: "u".to_string(),
|
||||||
|
password: "p".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let env = step.build_registry_env();
|
||||||
|
|
||||||
|
assert!(env.contains_key("BUILDKIT_HOST_MY_REGISTRY_EXAMPLE_COM_USERNAME"));
|
||||||
|
assert!(env.contains_key("BUILDKIT_HOST_MY_REGISTRY_EXAMPLE_COM_PASSWORD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_registry_env_empty_when_no_auth() {
|
||||||
|
let step = BuildkitStep::new(minimal_config());
|
||||||
|
let env = step.build_registry_env();
|
||||||
|
assert!(env.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_registry_env_multiple_registries() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.registry_auth.insert(
|
||||||
|
"ghcr.io".to_string(),
|
||||||
|
RegistryAuth {
|
||||||
|
username: "gh_user".to_string(),
|
||||||
|
password: "gh_pass".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
config.registry_auth.insert(
|
||||||
|
"docker.io".to_string(),
|
||||||
|
RegistryAuth {
|
||||||
|
username: "dh_user".to_string(),
|
||||||
|
password: "dh_pass".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let env = step.build_registry_env();
|
||||||
|
|
||||||
|
assert_eq!(env.len(), 4);
|
||||||
|
assert_eq!(env["BUILDKIT_HOST_GHCR_IO_USERNAME"], "gh_user");
|
||||||
|
assert_eq!(env["BUILDKIT_HOST_GHCR_IO_PASSWORD"], "gh_pass");
|
||||||
|
assert_eq!(env["BUILDKIT_HOST_DOCKER_IO_USERNAME"], "dh_user");
|
||||||
|
assert_eq!(env["BUILDKIT_HOST_DOCKER_IO_PASSWORD"], "dh_pass");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// parse_digest tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_from_output() {
|
||||||
|
let output = "some build output\nexporting manifest sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\ndone";
|
||||||
|
let digest = parse_digest(output);
|
||||||
|
assert_eq!(
|
||||||
|
digest,
|
||||||
|
Some(
|
||||||
|
"sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_with_digest_prefix() {
|
||||||
|
let output = "digest: sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\n";
|
||||||
|
let digest = parse_digest(output);
|
||||||
|
assert_eq!(
|
||||||
|
digest,
|
||||||
|
Some(
|
||||||
|
"sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_missing_returns_none() {
|
||||||
|
let output = "building image...\nall done!";
|
||||||
|
let digest = parse_digest(output);
|
||||||
|
assert_eq!(digest, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_partial_hash_returns_none() {
|
||||||
|
let output = "exporting manifest sha256:abcdef";
|
||||||
|
let digest = parse_digest(output);
|
||||||
|
assert_eq!(digest, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_empty_input() {
|
||||||
|
assert_eq!(parse_digest(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_wrong_prefix() {
|
||||||
|
let output =
|
||||||
|
"sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||||
|
assert_eq!(parse_digest(output), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_uppercase_hex_returns_none() {
|
||||||
|
let output = "exporting manifest sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789";
|
||||||
|
assert_eq!(parse_digest(output), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_multiline_with_noise() {
|
||||||
|
let output = r#"
|
||||||
|
[+] Building 12.3s (8/8) FINISHED
|
||||||
|
=> exporting to image
|
||||||
|
=> exporting manifest sha256:aabbccdd0011223344556677aabbccdd0011223344556677aabbccdd00112233
|
||||||
|
=> done
|
||||||
|
"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_digest(output),
|
||||||
|
Some("sha256:aabbccdd0011223344556677aabbccdd0011223344556677aabbccdd00112233".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_digest_first_match_wins() {
|
||||||
|
let hash1 = "a".repeat(64);
|
||||||
|
let hash2 = "b".repeat(64);
|
||||||
|
let output = format!(
|
||||||
|
"exporting manifest sha256:{hash1}\ndigest: sha256:{hash2}"
|
||||||
|
);
|
||||||
|
let digest = parse_digest(&output).unwrap();
|
||||||
|
assert_eq!(digest, format!("sha256:{hash1}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// build_output_data tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_output_data_with_digest_and_tags() {
|
||||||
|
let digest = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||||
|
let tags = vec!["myapp:latest".to_string(), "myapp:v1".to_string()];
|
||||||
|
let result = build_output_data("build", "out", "err", Some(digest), &tags);
|
||||||
|
|
||||||
|
let obj = result.as_object().unwrap();
|
||||||
|
assert_eq!(obj["build.digest"], digest);
|
||||||
|
assert_eq!(
|
||||||
|
obj["build.tags"],
|
||||||
|
serde_json::json!(["myapp:latest", "myapp:v1"])
|
||||||
|
);
|
||||||
|
assert_eq!(obj["build.stdout"], "out");
|
||||||
|
assert_eq!(obj["build.stderr"], "err");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_output_data_without_digest() {
|
||||||
|
let result = build_output_data("step1", "hello", "", None, &[]);
|
||||||
|
|
||||||
|
let obj = result.as_object().unwrap();
|
||||||
|
assert!(!obj.contains_key("step1.digest"));
|
||||||
|
assert!(!obj.contains_key("step1.tags"));
|
||||||
|
assert_eq!(obj["step1.stdout"], "hello");
|
||||||
|
assert_eq!(obj["step1.stderr"], "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_output_data_with_digest_no_tags() {
|
||||||
|
let digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
let result = build_output_data("img", "ok", "warn", Some(digest), &[]);
|
||||||
|
|
||||||
|
let obj = result.as_object().unwrap();
|
||||||
|
assert_eq!(obj["img.digest"], digest);
|
||||||
|
assert!(!obj.contains_key("img.tags"));
|
||||||
|
assert_eq!(obj["img.stdout"], "ok");
|
||||||
|
assert_eq!(obj["img.stderr"], "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_output_data_no_digest_with_tags() {
|
||||||
|
let tags = vec!["app:v2".to_string()];
|
||||||
|
let result = build_output_data("s", "", "", None, &tags);
|
||||||
|
|
||||||
|
let obj = result.as_object().unwrap();
|
||||||
|
assert!(!obj.contains_key("s.digest"));
|
||||||
|
assert_eq!(obj["s.tags"], serde_json::json!(["app:v2"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_output_data_empty_strings() {
|
||||||
|
let result = build_output_data("x", "", "", None, &[]);
|
||||||
|
let obj = result.as_object().unwrap();
|
||||||
|
assert_eq!(obj["x.stdout"], "");
|
||||||
|
assert_eq!(obj["x.stderr"], "");
|
||||||
|
assert_eq!(obj.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// build_frontend_attrs tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_attrs_minimal() {
|
||||||
|
let step = BuildkitStep::new(minimal_config());
|
||||||
|
let attrs = step.build_frontend_attrs();
|
||||||
|
// Default Dockerfile name is not included (only non-default).
|
||||||
|
assert!(!attrs.contains_key("filename"));
|
||||||
|
assert!(!attrs.contains_key("target"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_attrs_with_target() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.target = Some("runtime".to_string());
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let attrs = step.build_frontend_attrs();
|
||||||
|
assert_eq!(attrs.get("target"), Some(&"runtime".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_attrs_with_custom_dockerfile() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.dockerfile = "docker/Dockerfile.prod".to_string();
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let attrs = step.build_frontend_attrs();
|
||||||
|
assert_eq!(
|
||||||
|
attrs.get("filename"),
|
||||||
|
Some(&"docker/Dockerfile.prod".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_attrs_with_build_args() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config
|
||||||
|
.build_args
|
||||||
|
.insert("RUST_VERSION".to_string(), "1.78".to_string());
|
||||||
|
config
|
||||||
|
.build_args
|
||||||
|
.insert("BUILD_MODE".to_string(), "release".to_string());
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let attrs = step.build_frontend_attrs();
|
||||||
|
assert_eq!(
|
||||||
|
attrs.get("build-arg:BUILD_MODE"),
|
||||||
|
Some(&"release".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
attrs.get("build-arg:RUST_VERSION"),
|
||||||
|
Some(&"1.78".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// build_exporters tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exporters_empty_when_no_tags() {
|
||||||
|
let step = BuildkitStep::new(minimal_config());
|
||||||
|
assert!(step.build_exporters().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exporters_with_tags_and_push() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.tags = vec!["myapp:latest".to_string(), "myapp:v1.0".to_string()];
|
||||||
|
config.push = true;
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let exporters = step.build_exporters();
|
||||||
|
assert_eq!(exporters.len(), 1);
|
||||||
|
assert_eq!(exporters[0].r#type, "image");
|
||||||
|
assert_eq!(
|
||||||
|
exporters[0].attrs.get("name"),
|
||||||
|
Some(&"myapp:latest,myapp:v1.0".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
exporters[0].attrs.get("push"),
|
||||||
|
Some(&"true".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exporters_with_tags_no_push() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.tags = vec!["myapp:latest".to_string()];
|
||||||
|
config.push = false;
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let exporters = step.build_exporters();
|
||||||
|
assert_eq!(exporters.len(), 1);
|
||||||
|
assert!(!exporters[0].attrs.contains_key("push"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// build_cache_options tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_options_none_when_empty() {
|
||||||
|
let step = BuildkitStep::new(minimal_config());
|
||||||
|
assert!(step.build_cache_options().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_options_with_imports_and_exports() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.cache_from = vec!["type=registry,ref=myapp:cache".to_string()];
|
||||||
|
config.cache_to = vec!["type=registry,ref=myapp:cache,mode=max".to_string()];
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let opts = step.build_cache_options().unwrap();
|
||||||
|
assert_eq!(opts.imports.len(), 1);
|
||||||
|
assert_eq!(opts.exports.len(), 1);
|
||||||
|
assert_eq!(opts.imports[0].r#type, "registry");
|
||||||
|
assert_eq!(opts.exports[0].r#type, "registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// connect helper tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tcp_addr_converted_to_http() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.buildkit_addr = "tcp://buildkitd:1234".to_string();
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
assert_eq!(step.config.buildkit_addr, "tcp://buildkitd:1234");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_addr_preserved() {
|
||||||
|
let config = minimal_config();
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
assert!(step.config.buildkit_addr.starts_with("unix://"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_to_missing_unix_socket_returns_error() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.buildkit_addr = "unix:///tmp/nonexistent-wfe-test.sock".to_string();
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let err = step.connect().await.unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("socket not found"),
|
||||||
|
"expected 'socket not found' error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_to_invalid_tcp_returns_error() {
|
||||||
|
let mut config = minimal_config();
|
||||||
|
config.buildkit_addr = "tcp://127.0.0.1:1".to_string();
|
||||||
|
let step = BuildkitStep::new(config);
|
||||||
|
let err = step.connect().await.unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("failed to connect"),
|
||||||
|
"expected connection error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// BuildkitStep construction tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_step_stores_config() {
|
||||||
|
let config = minimal_config();
|
||||||
|
let step = BuildkitStep::new(config.clone());
|
||||||
|
assert_eq!(step.config.dockerfile, "Dockerfile");
|
||||||
|
assert_eq!(step.config.context, ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
237
wfe-buildkit/tests/integration_test.rs
Normal file
237
wfe-buildkit/tests/integration_test.rs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
//! Integration tests for wfe-buildkit using a real BuildKit daemon.
|
||||||
|
//!
|
||||||
|
//! These tests require a running BuildKit daemon. The socket path is read
|
||||||
|
//! from `WFE_BUILDKIT_ADDR`, falling back to
|
||||||
|
//! `unix:///Users/sienna/.lima/wfe-test/sock/buildkitd.sock`.
|
||||||
|
//!
|
||||||
|
//! If the daemon is not available, the tests are skipped gracefully.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use wfe_buildkit::config::{BuildkitConfig, TlsConfig};
|
||||||
|
use wfe_buildkit::BuildkitStep;
|
||||||
|
|
||||||
|
use wfe_core::models::{ExecutionPointer, WorkflowInstance, WorkflowStep};
|
||||||
|
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||||
|
|
||||||
|
/// Get the BuildKit daemon address from the environment or use the default.
|
||||||
|
fn buildkit_addr() -> String {
|
||||||
|
std::env::var("WFE_BUILDKIT_ADDR").unwrap_or_else(|_| {
|
||||||
|
"unix:///Users/sienna/.lima/wfe-test/sock/buildkitd.sock".to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the BuildKit daemon socket is reachable.
|
||||||
|
fn buildkitd_available() -> bool {
|
||||||
|
let addr = buildkit_addr();
|
||||||
|
if let Some(path) = addr.strip_prefix("unix://") {
|
||||||
|
Path::new(path).exists()
|
||||||
|
} else {
|
||||||
|
// For TCP endpoints, optimistically assume available.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_test_context(
|
||||||
|
step_name: &str,
|
||||||
|
) -> (
|
||||||
|
WorkflowStep,
|
||||||
|
ExecutionPointer,
|
||||||
|
WorkflowInstance,
|
||||||
|
) {
|
||||||
|
let mut step = WorkflowStep::new(0, "buildkit");
|
||||||
|
step.name = Some(step_name.to_string());
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let instance = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
(step, pointer, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_simple_dockerfile_via_grpc() {
|
||||||
|
if !buildkitd_available() {
|
||||||
|
eprintln!(
|
||||||
|
"SKIP: BuildKit daemon not available at {}",
|
||||||
|
buildkit_addr()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temp directory with a trivial Dockerfile.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dockerfile = tmp.path().join("Dockerfile");
|
||||||
|
std::fs::write(
|
||||||
|
&dockerfile,
|
||||||
|
"FROM alpine:latest\nRUN echo built\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = BuildkitConfig {
|
||||||
|
dockerfile: "Dockerfile".to_string(),
|
||||||
|
context: tmp.path().to_string_lossy().to_string(),
|
||||||
|
target: None,
|
||||||
|
tags: vec![],
|
||||||
|
build_args: HashMap::new(),
|
||||||
|
cache_from: vec![],
|
||||||
|
cache_to: vec![],
|
||||||
|
push: false,
|
||||||
|
output_type: None,
|
||||||
|
buildkit_addr: buildkit_addr(),
|
||||||
|
tls: TlsConfig::default(),
|
||||||
|
registry_auth: HashMap::new(),
|
||||||
|
timeout_ms: Some(120_000), // 2 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut step = BuildkitStep::new(config);
|
||||||
|
|
||||||
|
let (ws, pointer, instance) = make_test_context("integration-build");
|
||||||
|
let cancel = tokio_util::sync::CancellationToken::new();
|
||||||
|
let ctx = StepExecutionContext {
|
||||||
|
item: None,
|
||||||
|
execution_pointer: &pointer,
|
||||||
|
persistence_data: None,
|
||||||
|
step: &ws,
|
||||||
|
workflow: &instance,
|
||||||
|
cancellation_token: cancel,
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.expect("build should succeed");
|
||||||
|
|
||||||
|
assert!(result.proceed);
|
||||||
|
|
||||||
|
let data = result.output_data.expect("should have output_data");
|
||||||
|
let obj = data.as_object().expect("output_data should be an object");
|
||||||
|
|
||||||
|
// Without tags/push, BuildKit does not produce a digest in the exporter
|
||||||
|
// response. The build succeeds but the digest is absent.
|
||||||
|
assert!(
|
||||||
|
obj.contains_key("integration-build.stdout"),
|
||||||
|
"expected stdout key, got: {:?}",
|
||||||
|
obj.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
obj.contains_key("integration-build.stderr"),
|
||||||
|
"expected stderr key, got: {:?}",
|
||||||
|
obj.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// If a digest IS present (e.g., newer buildkitd versions), validate its format.
|
||||||
|
if let Some(digest_val) = obj.get("integration-build.digest") {
|
||||||
|
let digest = digest_val.as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
digest.starts_with("sha256:"),
|
||||||
|
"digest should start with sha256:, got: {digest}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
digest.len(),
|
||||||
|
7 + 64,
|
||||||
|
"digest should be sha256:<64hex>, got: {digest}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_with_build_args() {
|
||||||
|
if !buildkitd_available() {
|
||||||
|
eprintln!(
|
||||||
|
"SKIP: BuildKit daemon not available at {}",
|
||||||
|
buildkit_addr()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dockerfile = tmp.path().join("Dockerfile");
|
||||||
|
std::fs::write(
|
||||||
|
&dockerfile,
|
||||||
|
"FROM alpine:latest\nARG MY_VAR=default\nRUN echo \"value=$MY_VAR\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut build_args = HashMap::new();
|
||||||
|
build_args.insert("MY_VAR".to_string(), "custom_value".to_string());
|
||||||
|
|
||||||
|
let config = BuildkitConfig {
|
||||||
|
dockerfile: "Dockerfile".to_string(),
|
||||||
|
context: tmp.path().to_string_lossy().to_string(),
|
||||||
|
target: None,
|
||||||
|
tags: vec![],
|
||||||
|
build_args,
|
||||||
|
cache_from: vec![],
|
||||||
|
cache_to: vec![],
|
||||||
|
push: false,
|
||||||
|
output_type: None,
|
||||||
|
buildkit_addr: buildkit_addr(),
|
||||||
|
tls: TlsConfig::default(),
|
||||||
|
registry_auth: HashMap::new(),
|
||||||
|
timeout_ms: Some(120_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut step = BuildkitStep::new(config);
|
||||||
|
|
||||||
|
let (ws, pointer, instance) = make_test_context("build-args-test");
|
||||||
|
let cancel = tokio_util::sync::CancellationToken::new();
|
||||||
|
let ctx = StepExecutionContext {
|
||||||
|
item: None,
|
||||||
|
execution_pointer: &pointer,
|
||||||
|
persistence_data: None,
|
||||||
|
step: &ws,
|
||||||
|
workflow: &instance,
|
||||||
|
cancellation_token: cancel,
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.expect("build with args should succeed");
|
||||||
|
assert!(result.proceed);
|
||||||
|
|
||||||
|
let data = result.output_data.expect("should have output_data");
|
||||||
|
let obj = data.as_object().unwrap();
|
||||||
|
|
||||||
|
// Build should complete and produce output data entries.
|
||||||
|
assert!(
|
||||||
|
obj.contains_key("build-args-test.stdout"),
|
||||||
|
"expected stdout key, got: {:?}",
|
||||||
|
obj.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_to_unavailable_daemon_returns_error() {
|
||||||
|
// Use a deliberately wrong address to test error handling.
|
||||||
|
let config = BuildkitConfig {
|
||||||
|
dockerfile: "Dockerfile".to_string(),
|
||||||
|
context: ".".to_string(),
|
||||||
|
target: None,
|
||||||
|
tags: vec![],
|
||||||
|
build_args: HashMap::new(),
|
||||||
|
cache_from: vec![],
|
||||||
|
cache_to: vec![],
|
||||||
|
push: false,
|
||||||
|
output_type: None,
|
||||||
|
buildkit_addr: "unix:///tmp/nonexistent-buildkitd.sock".to_string(),
|
||||||
|
tls: TlsConfig::default(),
|
||||||
|
registry_auth: HashMap::new(),
|
||||||
|
timeout_ms: Some(5_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut step = BuildkitStep::new(config);
|
||||||
|
|
||||||
|
let (ws, pointer, instance) = make_test_context("error-test");
|
||||||
|
let cancel = tokio_util::sync::CancellationToken::new();
|
||||||
|
let ctx = StepExecutionContext {
|
||||||
|
item: None,
|
||||||
|
execution_pointer: &pointer,
|
||||||
|
persistence_data: None,
|
||||||
|
step: &ws,
|
||||||
|
workflow: &instance,
|
||||||
|
cancellation_token: cancel,
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = step.run(&ctx).await;
|
||||||
|
assert!(err.is_err(), "should fail when daemon is unavailable");
|
||||||
|
}
|
||||||
19
wfe-containerd-protos/Cargo.toml
Normal file
19
wfe-containerd-protos/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "wfe-containerd-protos"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "Generated gRPC stubs for the full containerd API"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tonic = "0.14"
|
||||||
|
tonic-prost = "0.14"
|
||||||
|
prost = "0.14"
|
||||||
|
prost-types = "0.14"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tonic-build = "0.14"
|
||||||
|
tonic-prost-build = "0.14"
|
||||||
|
prost-build = "0.14"
|
||||||
50
wfe-containerd-protos/build.rs
Normal file
50
wfe-containerd-protos/build.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let api_dir = PathBuf::from("vendor/containerd/api");
|
||||||
|
|
||||||
|
// Collect all .proto files, excluding internal runtime shim protos
|
||||||
|
let proto_files: Vec<PathBuf> = walkdir(&api_dir)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| {
|
||||||
|
let s = p.to_string_lossy();
|
||||||
|
!s.contains("/runtime/task/") && !s.contains("/runtime/sandbox/")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"cargo:warning=Compiling {} containerd proto files",
|
||||||
|
proto_files.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let _out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
|
||||||
|
|
||||||
|
// Use tonic-prost-build (the tonic 0.14 way)
|
||||||
|
let mut prost_config = prost_build::Config::new();
|
||||||
|
prost_config.include_file("mod.rs");
|
||||||
|
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_server(false)
|
||||||
|
.compile_with_config(
|
||||||
|
prost_config,
|
||||||
|
&proto_files,
|
||||||
|
&[api_dir, PathBuf::from("proto")],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect all .proto files under a directory.
|
||||||
|
fn walkdir(dir: &PathBuf) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
|
||||||
|
let mut protos = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
protos.extend(walkdir(&path)?);
|
||||||
|
} else if path.extension().is_some_and(|ext| ext == "proto") {
|
||||||
|
protos.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(protos)
|
||||||
|
}
|
||||||
49
wfe-containerd-protos/proto/google/rpc/status.proto
Normal file
49
wfe-containerd-protos/proto/google/rpc/status.proto
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2025 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package google.rpc;
|
||||||
|
|
||||||
|
import "google/protobuf/any.proto";
|
||||||
|
|
||||||
|
option cc_enable_arenas = true;
|
||||||
|
option go_package = "google.golang.org/genproto/googleapis/rpc/status;status";
|
||||||
|
option java_multiple_files = true;
|
||||||
|
option java_outer_classname = "StatusProto";
|
||||||
|
option java_package = "com.google.rpc";
|
||||||
|
option objc_class_prefix = "RPC";
|
||||||
|
|
||||||
|
// The `Status` type defines a logical error model that is suitable for
|
||||||
|
// different programming environments, including REST APIs and RPC APIs. It is
|
||||||
|
// used by [gRPC](https://github.com/grpc). Each `Status` message contains
|
||||||
|
// three pieces of data: error code, error message, and error details.
|
||||||
|
//
|
||||||
|
// You can find out more about this error model and how to work with it in the
|
||||||
|
// [API Design Guide](https://cloud.google.com/apis/design/errors).
|
||||||
|
message Status {
|
||||||
|
// The status code, which should be an enum value of
|
||||||
|
// [google.rpc.Code][google.rpc.Code].
|
||||||
|
int32 code = 1;
|
||||||
|
|
||||||
|
// A developer-facing error message, which should be in English. Any
|
||||||
|
// user-facing error message should be localized and sent in the
|
||||||
|
// [google.rpc.Status.details][google.rpc.Status.details] field, or localized
|
||||||
|
// by the client.
|
||||||
|
string message = 2;
|
||||||
|
|
||||||
|
// A list of messages that carry the error details. There is a common set of
|
||||||
|
// message types for APIs to use.
|
||||||
|
repeated google.protobuf.Any details = 3;
|
||||||
|
}
|
||||||
27
wfe-containerd-protos/src/lib.rs
Normal file
27
wfe-containerd-protos/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//! Generated gRPC stubs for the full containerd API.
|
||||||
|
//!
|
||||||
|
//! Built from the official containerd proto files at
|
||||||
|
//! <https://github.com/containerd/containerd/tree/main/api>.
|
||||||
|
//!
|
||||||
|
//! The module structure mirrors the protobuf package hierarchy:
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use wfe_containerd_protos::containerd::services::containers::v1::containers_client::ContainersClient;
|
||||||
|
//! use wfe_containerd_protos::containerd::services::tasks::v1::tasks_client::TasksClient;
|
||||||
|
//! use wfe_containerd_protos::containerd::services::images::v1::images_client::ImagesClient;
|
||||||
|
//! use wfe_containerd_protos::containerd::services::version::v1::version_client::VersionClient;
|
||||||
|
//! use wfe_containerd_protos::containerd::types::Mount;
|
||||||
|
//! use wfe_containerd_protos::containerd::types::Descriptor;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![allow(clippy::all)]
|
||||||
|
#![allow(warnings)]
|
||||||
|
|
||||||
|
// tonic-build generates a mod.rs that defines the full module tree
|
||||||
|
// matching the protobuf package structure.
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/mod.rs"));
|
||||||
|
|
||||||
|
/// Re-export tonic and prost for downstream convenience.
|
||||||
|
pub use prost;
|
||||||
|
pub use prost_types;
|
||||||
|
pub use tonic;
|
||||||
1
wfe-containerd-protos/vendor/containerd
vendored
Submodule
1
wfe-containerd-protos/vendor/containerd
vendored
Submodule
Submodule wfe-containerd-protos/vendor/containerd added at 546ce38287
31
wfe-containerd/Cargo.toml
Normal file
31
wfe-containerd/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "wfe-containerd"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "containerd container runner executor for WFE"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wfe-core = { workspace = true }
|
||||||
|
wfe-containerd-protos = { version = "1.6.0", path = "../wfe-containerd-protos", registry = "sunbeam" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tonic = "0.14"
|
||||||
|
tower = "0.5"
|
||||||
|
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||||
|
prost-types = "0.14"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
tokio-util = "0.7"
|
||||||
70
wfe-containerd/README.md
Normal file
70
wfe-containerd/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# wfe-containerd
|
||||||
|
|
||||||
|
Containerd container runner executor for WFE.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
`wfe-containerd` runs containers via `nerdctl` as workflow steps. It pulls images, manages registry authentication, and executes containers with configurable networking, resource limits, volume mounts, and TLS settings. Output is captured and parsed for `##wfe[output key=value]` directives, following the same convention as the shell executor.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Add a containerd step to your YAML workflow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflow:
|
||||||
|
id: container-pipeline
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-tests
|
||||||
|
type: containerd
|
||||||
|
config:
|
||||||
|
image: node:20-alpine
|
||||||
|
run: npm test
|
||||||
|
network: none
|
||||||
|
memory: 512m
|
||||||
|
cpu: "1.0"
|
||||||
|
timeout: 5m
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
volumes:
|
||||||
|
- source: /workspace
|
||||||
|
target: /app
|
||||||
|
readonly: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the feature in `wfe-yaml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
wfe-yaml = { version = "1.0.0", features = ["containerd"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `image` | `String` | required | Container image to run |
|
||||||
|
| `run` | `String` | - | Shell command (uses `sh -c`) |
|
||||||
|
| `command` | `Vec<String>` | - | Command array (mutually exclusive with `run`) |
|
||||||
|
| `env` | `HashMap` | `{}` | Environment variables |
|
||||||
|
| `volumes` | `Vec<VolumeMount>` | `[]` | Volume mounts |
|
||||||
|
| `working_dir` | `String` | - | Working directory inside container |
|
||||||
|
| `user` | `String` | `65534:65534` | User/group to run as (nobody by default) |
|
||||||
|
| `network` | `String` | `none` | Network mode: `none`, `host`, or `bridge` |
|
||||||
|
| `memory` | `String` | - | Memory limit (e.g. `512m`, `1g`) |
|
||||||
|
| `cpu` | `String` | - | CPU limit (e.g. `1.0`, `0.5`) |
|
||||||
|
| `pull` | `String` | `if-not-present` | Pull policy: `always`, `if-not-present`, `never` |
|
||||||
|
| `containerd_addr` | `String` | `/run/containerd/containerd.sock` | Containerd socket address |
|
||||||
|
| `tls` | `TlsConfig` | - | TLS configuration for containerd connection |
|
||||||
|
| `registry_auth` | `HashMap` | `{}` | Registry authentication per registry hostname |
|
||||||
|
| `timeout` | `String` | - | Execution timeout (e.g. `30s`, `5m`) |
|
||||||
|
|
||||||
|
## Output parsing
|
||||||
|
|
||||||
|
The step captures stdout and stderr. Lines matching `##wfe[output key=value]` are extracted as workflow outputs. Raw stdout, stderr, and exit code are also available under `{step_name}.stdout`, `{step_name}.stderr`, and `{step_name}.exit_code`.
|
||||||
|
|
||||||
|
## Security defaults
|
||||||
|
|
||||||
|
- Runs as nobody (`65534:65534`) by default
|
||||||
|
- Network disabled (`none`) by default
|
||||||
|
- Containers are always `--rm` (removed after execution)
|
||||||
226
wfe-containerd/src/config.rs
Normal file
226
wfe-containerd/src/config.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ContainerdConfig {
|
||||||
|
pub image: String,
|
||||||
|
pub command: Option<Vec<String>>,
|
||||||
|
pub run: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub volumes: Vec<VolumeMountConfig>,
|
||||||
|
pub working_dir: Option<String>,
|
||||||
|
#[serde(default = "default_user")]
|
||||||
|
pub user: String,
|
||||||
|
#[serde(default = "default_network")]
|
||||||
|
pub network: String,
|
||||||
|
pub memory: Option<String>,
|
||||||
|
pub cpu: Option<String>,
|
||||||
|
#[serde(default = "default_pull")]
|
||||||
|
pub pull: String,
|
||||||
|
#[serde(default = "default_containerd_addr")]
|
||||||
|
pub containerd_addr: String,
|
||||||
|
/// CLI binary name: "nerdctl" (default) or "docker".
|
||||||
|
#[serde(default = "default_cli")]
|
||||||
|
pub cli: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls: TlsConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub registry_auth: HashMap<String, RegistryAuth>,
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeMountConfig {
|
||||||
|
pub source: String,
|
||||||
|
pub target: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub readonly: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct TlsConfig {
|
||||||
|
pub ca: Option<String>,
|
||||||
|
pub cert: Option<String>,
|
||||||
|
pub key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistryAuth {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_user() -> String {
|
||||||
|
"65534:65534".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_network() -> String {
|
||||||
|
"none".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_pull() -> String {
|
||||||
|
"if-not-present".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_containerd_addr() -> String {
|
||||||
|
"/run/containerd/containerd.sock".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cli() -> String {
|
||||||
|
"nerdctl".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_full_config() {
|
||||||
|
let config = ContainerdConfig {
|
||||||
|
image: "alpine:3.18".to_string(),
|
||||||
|
command: Some(vec!["echo".to_string(), "hello".to_string()]),
|
||||||
|
run: None,
|
||||||
|
env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
|
||||||
|
volumes: vec![VolumeMountConfig {
|
||||||
|
source: "/host/path".to_string(),
|
||||||
|
target: "/container/path".to_string(),
|
||||||
|
readonly: true,
|
||||||
|
}],
|
||||||
|
working_dir: Some("/app".to_string()),
|
||||||
|
user: "1000:1000".to_string(),
|
||||||
|
network: "host".to_string(),
|
||||||
|
memory: Some("512m".to_string()),
|
||||||
|
cpu: Some("1.0".to_string()),
|
||||||
|
pull: "always".to_string(),
|
||||||
|
containerd_addr: "/custom/containerd.sock".to_string(),
|
||||||
|
cli: "nerdctl".to_string(),
|
||||||
|
tls: TlsConfig {
|
||||||
|
ca: Some("/ca.pem".to_string()),
|
||||||
|
cert: Some("/cert.pem".to_string()),
|
||||||
|
key: Some("/key.pem".to_string()),
|
||||||
|
},
|
||||||
|
registry_auth: HashMap::from([(
|
||||||
|
"registry.example.com".to_string(),
|
||||||
|
RegistryAuth {
|
||||||
|
username: "user".to_string(),
|
||||||
|
password: "pass".to_string(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
timeout_ms: Some(30000),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let deserialized: ContainerdConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(deserialized.image, config.image);
|
||||||
|
assert_eq!(deserialized.command, config.command);
|
||||||
|
assert_eq!(deserialized.run, config.run);
|
||||||
|
assert_eq!(deserialized.env, config.env);
|
||||||
|
assert_eq!(deserialized.volumes.len(), 1);
|
||||||
|
assert_eq!(deserialized.volumes[0].source, "/host/path");
|
||||||
|
assert_eq!(deserialized.volumes[0].readonly, true);
|
||||||
|
assert_eq!(deserialized.working_dir, Some("/app".to_string()));
|
||||||
|
assert_eq!(deserialized.user, "1000:1000");
|
||||||
|
assert_eq!(deserialized.network, "host");
|
||||||
|
assert_eq!(deserialized.memory, Some("512m".to_string()));
|
||||||
|
assert_eq!(deserialized.cpu, Some("1.0".to_string()));
|
||||||
|
assert_eq!(deserialized.pull, "always");
|
||||||
|
assert_eq!(deserialized.containerd_addr, "/custom/containerd.sock");
|
||||||
|
assert_eq!(deserialized.tls.ca, Some("/ca.pem".to_string()));
|
||||||
|
assert_eq!(deserialized.tls.cert, Some("/cert.pem".to_string()));
|
||||||
|
assert_eq!(deserialized.tls.key, Some("/key.pem".to_string()));
|
||||||
|
assert!(deserialized.registry_auth.contains_key("registry.example.com"));
|
||||||
|
assert_eq!(deserialized.timeout_ms, Some(30000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_minimal_config() {
|
||||||
|
let json = r#"{"image": "alpine:latest"}"#;
|
||||||
|
let config: ContainerdConfig = serde_json::from_str(json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.image, "alpine:latest");
|
||||||
|
assert_eq!(config.command, None);
|
||||||
|
assert_eq!(config.run, None);
|
||||||
|
assert!(config.env.is_empty());
|
||||||
|
assert!(config.volumes.is_empty());
|
||||||
|
assert_eq!(config.working_dir, None);
|
||||||
|
assert_eq!(config.user, "65534:65534");
|
||||||
|
assert_eq!(config.network, "none");
|
||||||
|
assert_eq!(config.memory, None);
|
||||||
|
assert_eq!(config.cpu, None);
|
||||||
|
assert_eq!(config.pull, "if-not-present");
|
||||||
|
assert_eq!(config.containerd_addr, "/run/containerd/containerd.sock");
|
||||||
|
assert_eq!(config.timeout_ms, None);
|
||||||
|
|
||||||
|
// Round-trip
|
||||||
|
let serialized = serde_json::to_string(&config).unwrap();
|
||||||
|
let deserialized: ContainerdConfig = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(deserialized.image, "alpine:latest");
|
||||||
|
assert_eq!(deserialized.user, "65534:65534");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_values() {
|
||||||
|
let json = r#"{"image": "busybox"}"#;
|
||||||
|
let config: ContainerdConfig = serde_json::from_str(json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.user, "65534:65534");
|
||||||
|
assert_eq!(config.network, "none");
|
||||||
|
assert_eq!(config.pull, "if-not-present");
|
||||||
|
assert_eq!(config.containerd_addr, "/run/containerd/containerd.sock");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_mount_serde() {
|
||||||
|
let vol = VolumeMountConfig {
|
||||||
|
source: "/data".to_string(),
|
||||||
|
target: "/mnt/data".to_string(),
|
||||||
|
readonly: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&vol).unwrap();
|
||||||
|
let deserialized: VolumeMountConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.source, "/data");
|
||||||
|
assert_eq!(deserialized.target, "/mnt/data");
|
||||||
|
assert_eq!(deserialized.readonly, false);
|
||||||
|
|
||||||
|
// With readonly=true
|
||||||
|
let vol_ro = VolumeMountConfig {
|
||||||
|
source: "/src".to_string(),
|
||||||
|
target: "/dest".to_string(),
|
||||||
|
readonly: true,
|
||||||
|
};
|
||||||
|
let json_ro = serde_json::to_string(&vol_ro).unwrap();
|
||||||
|
let deserialized_ro: VolumeMountConfig = serde_json::from_str(&json_ro).unwrap();
|
||||||
|
assert_eq!(deserialized_ro.readonly, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_config_defaults() {
|
||||||
|
let tls = TlsConfig::default();
|
||||||
|
assert_eq!(tls.ca, None);
|
||||||
|
assert_eq!(tls.cert, None);
|
||||||
|
assert_eq!(tls.key, None);
|
||||||
|
|
||||||
|
let json = r#"{}"#;
|
||||||
|
let deserialized: TlsConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(deserialized.ca, None);
|
||||||
|
assert_eq!(deserialized.cert, None);
|
||||||
|
assert_eq!(deserialized.key, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_auth_serde() {
|
||||||
|
let auth = RegistryAuth {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
password: "secret123".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&auth).unwrap();
|
||||||
|
let deserialized: RegistryAuth = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.username, "admin");
|
||||||
|
assert_eq!(deserialized.password, "secret123");
|
||||||
|
}
|
||||||
|
}
|
||||||
52
wfe-containerd/src/lib.rs
Normal file
52
wfe-containerd/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! Containerd container executor for WFE.
|
||||||
|
//!
|
||||||
|
//! Runs workflow steps as isolated OCI containers via the containerd gRPC API.
|
||||||
|
//!
|
||||||
|
//! # Remote daemon support
|
||||||
|
//!
|
||||||
|
//! The executor creates named pipes (FIFOs) on the **local** filesystem for
|
||||||
|
//! stdout/stderr capture, then passes those paths to the containerd task spec.
|
||||||
|
//! The containerd shim opens the FIFOs from **its** side. This means the FIFO
|
||||||
|
//! paths must be accessible to both the executor process and the containerd
|
||||||
|
//! daemon.
|
||||||
|
//!
|
||||||
|
//! When containerd runs on a different machine (e.g. a Lima VM), you need:
|
||||||
|
//!
|
||||||
|
//! 1. **Shared filesystem** — mount a host directory into the VM so both sides
|
||||||
|
//! see the same FIFO files. With Lima + virtiofs:
|
||||||
|
//! ```yaml
|
||||||
|
//! # lima config
|
||||||
|
//! mounts:
|
||||||
|
//! - location: /tmp/wfe-io
|
||||||
|
//! mountPoint: /tmp/wfe-io
|
||||||
|
//! writable: true
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 2. **`WFE_IO_DIR` env var** — point the executor at the shared directory:
|
||||||
|
//! ```sh
|
||||||
|
//! export WFE_IO_DIR=/tmp/wfe-io
|
||||||
|
//! ```
|
||||||
|
//! Without this, FIFOs are created under `std::env::temp_dir()` which is
|
||||||
|
//! only visible to the host.
|
||||||
|
//!
|
||||||
|
//! 3. **gRPC transport** — Lima's Unix socket forwarding is unreliable for
|
||||||
|
//! HTTP/2 (gRPC). Use a TCP socat proxy inside the VM instead:
|
||||||
|
//! ```sh
|
||||||
|
//! # Inside the VM:
|
||||||
|
//! socat TCP4-LISTEN:2500,fork,reuseaddr UNIX-CONNECT:/run/containerd/containerd.sock &
|
||||||
|
//! ```
|
||||||
|
//! Then connect via `WFE_CONTAINERD_ADDR=http://127.0.0.1:2500` (Lima
|
||||||
|
//! auto-forwards guest TCP ports).
|
||||||
|
//!
|
||||||
|
//! 4. **FIFO permissions** — the FIFOs are created with mode `0666` and a
|
||||||
|
//! temporarily cleared umask so the remote shim (running as root) can open
|
||||||
|
//! them through the shared mount.
|
||||||
|
//!
|
||||||
|
//! See `test/lima/wfe-test.yaml` for a complete VM configuration that sets all
|
||||||
|
//! of this up.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod step;
|
||||||
|
|
||||||
|
pub use config::{ContainerdConfig, RegistryAuth, TlsConfig, VolumeMountConfig};
|
||||||
|
pub use step::ContainerdStep;
|
||||||
1211
wfe-containerd/src/step.rs
Normal file
1211
wfe-containerd/src/step.rs
Normal file
File diff suppressed because it is too large
Load Diff
311
wfe-containerd/tests/integration.rs
Normal file
311
wfe-containerd/tests/integration.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
//! Integration tests for the containerd gRPC-based runner.
|
||||||
|
//!
|
||||||
|
//! These tests require a live containerd daemon. They are skipped when the
|
||||||
|
//! socket is not available. Set `WFE_CONTAINERD_ADDR` to point to a custom
|
||||||
|
//! socket, or use the default `~/.lima/wfe-test/containerd.sock`.
|
||||||
|
//!
|
||||||
|
//! Before running, ensure the test image is pre-pulled:
|
||||||
|
//! ctr -n default image pull docker.io/library/alpine:3.18
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use wfe_containerd::config::{ContainerdConfig, TlsConfig};
|
||||||
|
use wfe_containerd::ContainerdStep;
|
||||||
|
use wfe_core::models::{ExecutionPointer, WorkflowInstance, WorkflowStep};
|
||||||
|
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||||
|
|
||||||
|
/// Returns the containerd address if available, or None.
|
||||||
|
/// Set `WFE_CONTAINERD_ADDR` to a TCP address (http://host:port) or
|
||||||
|
/// Unix socket path (unix:///path). Defaults to the Lima wfe-test
|
||||||
|
/// TCP proxy at http://127.0.0.1:2500.
|
||||||
|
fn containerd_addr() -> Option<String> {
|
||||||
|
if let Ok(addr) = std::env::var("WFE_CONTAINERD_ADDR") {
|
||||||
|
if addr.starts_with("http://") || addr.starts_with("tcp://") {
|
||||||
|
return Some(addr);
|
||||||
|
}
|
||||||
|
let socket_path = addr.strip_prefix("unix://").unwrap_or(addr.as_str());
|
||||||
|
if Path::new(socket_path).exists() {
|
||||||
|
return Some(addr);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: check if the Lima wfe-test socket exists (for lightweight tests).
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||||
|
let socket = format!("{home}/.lima/wfe-test/containerd.sock");
|
||||||
|
if Path::new(&socket).exists() {
|
||||||
|
Some(format!("unix://{socket}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minimal_config(addr: &str) -> ContainerdConfig {
|
||||||
|
ContainerdConfig {
|
||||||
|
image: "docker.io/library/alpine:3.18".to_string(),
|
||||||
|
command: None,
|
||||||
|
run: Some("echo hello".to_string()),
|
||||||
|
env: HashMap::new(),
|
||||||
|
volumes: vec![],
|
||||||
|
working_dir: None,
|
||||||
|
user: "0:0".to_string(),
|
||||||
|
network: "none".to_string(),
|
||||||
|
memory: None,
|
||||||
|
cpu: None,
|
||||||
|
pull: "never".to_string(),
|
||||||
|
containerd_addr: addr.to_string(),
|
||||||
|
cli: "nerdctl".to_string(),
|
||||||
|
tls: TlsConfig::default(),
|
||||||
|
registry_auth: HashMap::new(),
|
||||||
|
timeout_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_context<'a>(
|
||||||
|
step: &'a WorkflowStep,
|
||||||
|
workflow: &'a WorkflowInstance,
|
||||||
|
pointer: &'a ExecutionPointer,
|
||||||
|
) -> StepExecutionContext<'a> {
|
||||||
|
StepExecutionContext {
|
||||||
|
item: None,
|
||||||
|
execution_pointer: pointer,
|
||||||
|
persistence_data: None,
|
||||||
|
step,
|
||||||
|
workflow,
|
||||||
|
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection error for missing socket ──────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_error_for_missing_socket() {
|
||||||
|
let config = minimal_config("/tmp/nonexistent-wfe-containerd-integ.sock");
|
||||||
|
let mut step = ContainerdStep::new(config);
|
||||||
|
|
||||||
|
let wf_step = WorkflowStep::new(0, "containerd");
|
||||||
|
let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let ctx = make_context(&wf_step, &workflow, &pointer);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await;
|
||||||
|
let err = result.expect_err("should fail with socket not found");
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("socket not found"),
|
||||||
|
"expected 'socket not found' error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image check failure for non-existent image ──────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn image_not_found_error() {
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd socket not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = minimal_config(&addr);
|
||||||
|
config.image = "nonexistent-image-wfe-test:latest".to_string();
|
||||||
|
config.pull = "if-not-present".to_string();
|
||||||
|
let mut step = ContainerdStep::new(config);
|
||||||
|
|
||||||
|
let wf_step = WorkflowStep::new(0, "containerd");
|
||||||
|
let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let ctx = make_context(&wf_step, &workflow, &pointer);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await;
|
||||||
|
let err = result.expect_err("should fail with image not found");
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("not found"),
|
||||||
|
"expected 'not found' error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pull=never skips image check ─────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skip_image_check_when_pull_never() {
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd socket not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Using a non-existent image but pull=never should skip the check.
|
||||||
|
// The step will fail later at container creation, but the image check is skipped.
|
||||||
|
let mut config = minimal_config(&addr);
|
||||||
|
config.image = "nonexistent-image-wfe-test-never:latest".to_string();
|
||||||
|
config.pull = "never".to_string();
|
||||||
|
let mut step = ContainerdStep::new(config);
|
||||||
|
|
||||||
|
let wf_step = WorkflowStep::new(0, "containerd");
|
||||||
|
let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let ctx = make_context(&wf_step, &workflow, &pointer);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await;
|
||||||
|
// It should fail, but NOT with "not found in containerd" (image check).
|
||||||
|
// It should fail later (container creation, snapshot, etc.).
|
||||||
|
let err = result.expect_err("should fail at container or task creation");
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
!msg.contains("Pre-pull it with"),
|
||||||
|
"image check should have been skipped for pull=never, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run a real container end-to-end ──────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_echo_hello_in_container() {
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd socket not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = minimal_config(&addr);
|
||||||
|
config.image = "docker.io/library/alpine:3.18".to_string();
|
||||||
|
config.run = Some("echo hello-from-container".to_string());
|
||||||
|
config.pull = "if-not-present".to_string();
|
||||||
|
config.user = "0:0".to_string();
|
||||||
|
config.timeout_ms = Some(30_000);
|
||||||
|
let mut step = ContainerdStep::new(config);
|
||||||
|
|
||||||
|
let mut wf_step = WorkflowStep::new(0, "containerd");
|
||||||
|
wf_step.name = Some("echo-test".to_string());
|
||||||
|
let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let ctx = make_context(&wf_step, &workflow, &pointer);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await;
|
||||||
|
match &result {
|
||||||
|
Ok(r) => {
|
||||||
|
eprintln!("SUCCESS: {:?}", r.output_data);
|
||||||
|
let data = r.output_data.as_ref().unwrap().as_object().unwrap();
|
||||||
|
let stdout = data.get("echo-test.stdout").unwrap().as_str().unwrap();
|
||||||
|
assert!(stdout.contains("hello-from-container"), "stdout: {stdout}");
|
||||||
|
}
|
||||||
|
Err(e) => panic!("container step failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run a container with a volume mount ──────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_container_with_volume_mount() {
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd socket not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let shared_dir = std::env::var("WFE_IO_DIR")
|
||||||
|
.unwrap_or_else(|_| "/tmp/wfe-io".to_string());
|
||||||
|
let vol_dir = format!("{shared_dir}/test-vol");
|
||||||
|
std::fs::create_dir_all(&vol_dir).unwrap();
|
||||||
|
|
||||||
|
let mut config = minimal_config(&addr);
|
||||||
|
config.image = "docker.io/library/alpine:3.18".to_string();
|
||||||
|
config.run = Some("echo hello > /mnt/test/output.txt && cat /mnt/test/output.txt".to_string());
|
||||||
|
config.pull = "if-not-present".to_string();
|
||||||
|
config.user = "0:0".to_string();
|
||||||
|
config.timeout_ms = Some(30_000);
|
||||||
|
config.volumes = vec![wfe_containerd::VolumeMountConfig {
|
||||||
|
source: vol_dir.clone(),
|
||||||
|
target: "/mnt/test".to_string(),
|
||||||
|
readonly: false,
|
||||||
|
}];
|
||||||
|
let mut step = ContainerdStep::new(config);
|
||||||
|
|
||||||
|
let mut wf_step = WorkflowStep::new(0, "containerd");
|
||||||
|
wf_step.name = Some("vol-test".to_string());
|
||||||
|
let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let ctx = make_context(&wf_step, &workflow, &pointer);
|
||||||
|
|
||||||
|
match step.run(&ctx).await {
|
||||||
|
Ok(r) => {
|
||||||
|
let data = r.output_data.as_ref().unwrap().as_object().unwrap();
|
||||||
|
let stdout = data.get("vol-test.stdout").unwrap().as_str().unwrap();
|
||||||
|
assert!(stdout.contains("hello"), "stdout: {stdout}");
|
||||||
|
}
|
||||||
|
Err(e) => panic!("container step with volume failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&vol_dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run a container with volume mount and network (simulates install step) ──
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_debian_with_volume_and_network() {
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd socket not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let shared_dir = std::env::var("WFE_IO_DIR")
|
||||||
|
.unwrap_or_else(|_| "/tmp/wfe-io".to_string());
|
||||||
|
let cargo_dir = format!("{shared_dir}/test-cargo");
|
||||||
|
let rustup_dir = format!("{shared_dir}/test-rustup");
|
||||||
|
std::fs::create_dir_all(&cargo_dir).unwrap();
|
||||||
|
std::fs::create_dir_all(&rustup_dir).unwrap();
|
||||||
|
|
||||||
|
let mut config = minimal_config(&addr);
|
||||||
|
config.image = "docker.io/library/debian:bookworm-slim".to_string();
|
||||||
|
config.run = Some("echo hello && ls /cargo && ls /rustup".to_string());
|
||||||
|
config.pull = "if-not-present".to_string();
|
||||||
|
config.user = "0:0".to_string();
|
||||||
|
config.network = "host".to_string();
|
||||||
|
config.timeout_ms = Some(30_000);
|
||||||
|
config.env.insert("CARGO_HOME".to_string(), "/cargo".to_string());
|
||||||
|
config.env.insert("RUSTUP_HOME".to_string(), "/rustup".to_string());
|
||||||
|
config.volumes = vec![
|
||||||
|
wfe_containerd::VolumeMountConfig {
|
||||||
|
source: cargo_dir.clone(),
|
||||||
|
target: "/cargo".to_string(),
|
||||||
|
readonly: false,
|
||||||
|
},
|
||||||
|
wfe_containerd::VolumeMountConfig {
|
||||||
|
source: rustup_dir.clone(),
|
||||||
|
target: "/rustup".to_string(),
|
||||||
|
readonly: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut step = ContainerdStep::new(config);
|
||||||
|
|
||||||
|
let mut wf_step = WorkflowStep::new(0, "containerd");
|
||||||
|
wf_step.name = Some("debian-test".to_string());
|
||||||
|
let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let ctx = make_context(&wf_step, &workflow, &pointer);
|
||||||
|
|
||||||
|
match step.run(&ctx).await {
|
||||||
|
Ok(r) => {
|
||||||
|
eprintln!("SUCCESS: {:?}", r.output_data);
|
||||||
|
}
|
||||||
|
Err(e) => panic!("debian container with volumes failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&cargo_dir).ok();
|
||||||
|
std::fs::remove_dir_all(&rustup_dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step name defaults to "unknown" when None ────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unnamed_step_uses_unknown_in_output_keys() {
|
||||||
|
// This test only verifies build_output_data behavior — no socket needed.
|
||||||
|
let parsed = HashMap::from([("result".to_string(), "ok".to_string())]);
|
||||||
|
let data = ContainerdStep::build_output_data("unknown", "out", "err", 0, &parsed);
|
||||||
|
let obj = data.as_object().unwrap();
|
||||||
|
assert!(obj.contains_key("unknown.stdout"));
|
||||||
|
assert!(obj.contains_key("unknown.stderr"));
|
||||||
|
assert!(obj.contains_key("unknown.exit_code"));
|
||||||
|
assert_eq!(obj.get("result").unwrap(), "ok");
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ name = "wfe-core"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "Core traits, models, builder, and executor for the WFE workflow engine"
|
description = "Core traits, models, builder, and executor for the WFE workflow engine"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
761
wfe-core/src/executor/condition.rs
Normal file
761
wfe-core/src/executor/condition.rs
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
use crate::models::condition::{ComparisonOp, FieldComparison, StepCondition};
|
||||||
|
use crate::WfeError;
|
||||||
|
|
||||||
|
/// Evaluate a step condition against workflow data.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(true)` if the step should run, `Ok(false)` if it should be skipped.
|
||||||
|
/// Missing field paths return `Ok(false)` (cascade skip behavior).
|
||||||
|
pub fn evaluate(
|
||||||
|
condition: &StepCondition,
|
||||||
|
workflow_data: &serde_json::Value,
|
||||||
|
) -> Result<bool, WfeError> {
|
||||||
|
match evaluate_inner(condition, workflow_data) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(EvalError::FieldNotPresent) => Ok(false), // cascade skip
|
||||||
|
Err(EvalError::Wfe(e)) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal error type that distinguishes missing-field from real errors.
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum EvalError {
|
||||||
|
FieldNotPresent,
|
||||||
|
Wfe(WfeError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<WfeError> for EvalError {
|
||||||
|
fn from(e: WfeError) -> Self {
|
||||||
|
EvalError::Wfe(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_inner(
|
||||||
|
condition: &StepCondition,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
match condition {
|
||||||
|
StepCondition::All(conditions) => {
|
||||||
|
for c in conditions {
|
||||||
|
if !evaluate_inner(c, data)? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
StepCondition::Any(conditions) => {
|
||||||
|
for c in conditions {
|
||||||
|
if evaluate_inner(c, data)? {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
StepCondition::None(conditions) => {
|
||||||
|
for c in conditions {
|
||||||
|
if evaluate_inner(c, data)? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
StepCondition::OneOf(conditions) => {
|
||||||
|
let mut count = 0;
|
||||||
|
for c in conditions {
|
||||||
|
if evaluate_inner(c, data)? {
|
||||||
|
count += 1;
|
||||||
|
if count > 1 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(count == 1)
|
||||||
|
}
|
||||||
|
StepCondition::Not(inner) => {
|
||||||
|
let result = evaluate_inner(inner, data)?;
|
||||||
|
Ok(!result)
|
||||||
|
}
|
||||||
|
StepCondition::Comparison(comp) => evaluate_comparison(comp, data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a dot-separated field path against JSON data.
|
||||||
|
///
|
||||||
|
/// Path starts with `.` which is stripped, then split by `.`.
|
||||||
|
/// Segments that parse as `usize` are treated as array indices.
|
||||||
|
fn resolve_field_path<'a>(
|
||||||
|
path: &str,
|
||||||
|
data: &'a serde_json::Value,
|
||||||
|
) -> Result<&'a serde_json::Value, EvalError> {
|
||||||
|
let path = path.strip_prefix('.').unwrap_or(path);
|
||||||
|
if path.is_empty() {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments: Vec<&str> = path.split('.').collect();
|
||||||
|
|
||||||
|
// Try resolving the full path first (for nested data like {"outputs": {"x": 1}}).
|
||||||
|
// If the first segment is "outputs"/"inputs" and doesn't exist as a key,
|
||||||
|
// strip it and resolve flat (for workflow data where outputs merge flat).
|
||||||
|
if segments.len() >= 2
|
||||||
|
&& (segments[0] == "outputs" || segments[0] == "inputs")
|
||||||
|
&& data.get(segments[0]).is_none()
|
||||||
|
{
|
||||||
|
return walk_segments(&segments[1..], data);
|
||||||
|
}
|
||||||
|
|
||||||
|
walk_segments(&segments, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_segments<'a>(
|
||||||
|
segments: &[&str],
|
||||||
|
data: &'a serde_json::Value,
|
||||||
|
) -> Result<&'a serde_json::Value, EvalError> {
|
||||||
|
let mut current = data;
|
||||||
|
|
||||||
|
for segment in segments {
|
||||||
|
if let Ok(idx) = segment.parse::<usize>() {
|
||||||
|
match current.as_array() {
|
||||||
|
Some(arr) => {
|
||||||
|
current = arr.get(idx).ok_or(EvalError::FieldNotPresent)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(EvalError::FieldNotPresent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match current.as_object() {
|
||||||
|
Some(obj) => {
|
||||||
|
current = obj.get(*segment).ok_or(EvalError::FieldNotPresent)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(EvalError::FieldNotPresent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_comparison(
|
||||||
|
comp: &FieldComparison,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
let resolved = resolve_field_path(&comp.field, data)?;
|
||||||
|
|
||||||
|
match &comp.operator {
|
||||||
|
ComparisonOp::IsNull => Ok(resolved.is_null()),
|
||||||
|
ComparisonOp::IsNotNull => Ok(!resolved.is_null()),
|
||||||
|
ComparisonOp::Equals => {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(
|
||||||
|
"Equals operator requires a value".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(resolved == expected)
|
||||||
|
}
|
||||||
|
ComparisonOp::NotEquals => {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(
|
||||||
|
"NotEquals operator requires a value".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(resolved != expected)
|
||||||
|
}
|
||||||
|
ComparisonOp::Gt => compare_numeric(resolved, comp, |a, b| a > b),
|
||||||
|
ComparisonOp::Gte => compare_numeric(resolved, comp, |a, b| a >= b),
|
||||||
|
ComparisonOp::Lt => compare_numeric(resolved, comp, |a, b| a < b),
|
||||||
|
ComparisonOp::Lte => compare_numeric(resolved, comp, |a, b| a <= b),
|
||||||
|
ComparisonOp::Contains => evaluate_contains(resolved, comp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_numeric(
|
||||||
|
resolved: &serde_json::Value,
|
||||||
|
comp: &FieldComparison,
|
||||||
|
cmp_fn: fn(f64, f64) -> bool,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"{:?} operator requires a value",
|
||||||
|
comp.operator
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let a = resolved.as_f64().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"cannot compare non-numeric field value: {}",
|
||||||
|
resolved
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let b = expected.as_f64().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"cannot compare with non-numeric value: {}",
|
||||||
|
expected
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(cmp_fn(a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_contains(
|
||||||
|
resolved: &serde_json::Value,
|
||||||
|
comp: &FieldComparison,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(
|
||||||
|
"Contains operator requires a value".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// String contains substring.
|
||||||
|
if let Some(s) = resolved.as_str()
|
||||||
|
&& let Some(substr) = expected.as_str()
|
||||||
|
{
|
||||||
|
return Ok(s.contains(substr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array contains element.
|
||||||
|
if let Some(arr) = resolved.as_array() {
|
||||||
|
return Ok(arr.contains(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"Contains requires a string or array field, got {}",
|
||||||
|
value_type_name(resolved)
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_type_name(value: &serde_json::Value) -> &'static str {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Null => "null",
|
||||||
|
serde_json::Value::Bool(_) => "bool",
|
||||||
|
serde_json::Value::Number(_) => "number",
|
||||||
|
serde_json::Value::String(_) => "string",
|
||||||
|
serde_json::Value::Array(_) => "array",
|
||||||
|
serde_json::Value::Object(_) => "object",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::condition::{ComparisonOp, FieldComparison, StepCondition};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// -- resolve_field_path tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_field() {
|
||||||
|
let data = json!({"name": "alice"});
|
||||||
|
let result = resolve_field_path(".name", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_nested_field() {
|
||||||
|
let data = json!({"outputs": {"status": "success"}});
|
||||||
|
let result = resolve_field_path(".outputs.status", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!("success"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_missing_field() {
|
||||||
|
let data = json!({"name": "alice"});
|
||||||
|
let result = resolve_field_path(".missing", &data);
|
||||||
|
assert!(matches!(result, Err(EvalError::FieldNotPresent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_array_index() {
|
||||||
|
let data = json!({"items": [10, 20, 30]});
|
||||||
|
let result = resolve_field_path(".items.1", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_array_index_out_of_bounds() {
|
||||||
|
let data = json!({"items": [10, 20]});
|
||||||
|
let result = resolve_field_path(".items.5", &data);
|
||||||
|
assert!(matches!(result, Err(EvalError::FieldNotPresent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_deeply_nested() {
|
||||||
|
let data = json!({"a": {"b": {"c": {"d": 42}}}});
|
||||||
|
let result = resolve_field_path(".a.b.c.d", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_empty_path_returns_root() {
|
||||||
|
let data = json!({"x": 1});
|
||||||
|
let result = resolve_field_path(".", &data).unwrap();
|
||||||
|
assert_eq!(result, &data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_field_on_non_object() {
|
||||||
|
let data = json!({"x": 42});
|
||||||
|
let result = resolve_field_path(".x.y", &data);
|
||||||
|
assert!(matches!(result, Err(EvalError::FieldNotPresent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Comparison operator tests --
|
||||||
|
|
||||||
|
fn comp(field: &str, op: ComparisonOp, value: Option<serde_json::Value>) -> StepCondition {
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: field.to_string(),
|
||||||
|
operator: op,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_match() {
|
||||||
|
let data = json!({"status": "ok"});
|
||||||
|
let cond = comp(".status", ComparisonOp::Equals, Some(json!("ok")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_mismatch() {
|
||||||
|
let data = json!({"status": "fail"});
|
||||||
|
let cond = comp(".status", ComparisonOp::Equals, Some(json!("ok")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_numeric() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Equals, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_equals_match() {
|
||||||
|
let data = json!({"status": "fail"});
|
||||||
|
let cond = comp(".status", ComparisonOp::NotEquals, Some(json!("ok")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_equals_mismatch() {
|
||||||
|
let data = json!({"status": "ok"});
|
||||||
|
let cond = comp(".status", ComparisonOp::NotEquals, Some(json!("ok")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_match() {
|
||||||
|
let data = json!({"count": 10});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_mismatch() {
|
||||||
|
let data = json!({"count": 3});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_equal_is_false() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gte_match() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gte, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gte_mismatch() {
|
||||||
|
let data = json!({"count": 4});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gte, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lt_match() {
|
||||||
|
let data = json!({"count": 3});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lt, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lt_mismatch() {
|
||||||
|
let data = json!({"count": 7});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lt, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lte_match() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lte, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lte_mismatch() {
|
||||||
|
let data = json!({"count": 6});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lte, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_string_match() {
|
||||||
|
let data = json!({"msg": "hello world"});
|
||||||
|
let cond = comp(".msg", ComparisonOp::Contains, Some(json!("world")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_string_mismatch() {
|
||||||
|
let data = json!({"msg": "hello world"});
|
||||||
|
let cond = comp(".msg", ComparisonOp::Contains, Some(json!("xyz")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_array_match() {
|
||||||
|
let data = json!({"tags": ["a", "b", "c"]});
|
||||||
|
let cond = comp(".tags", ComparisonOp::Contains, Some(json!("b")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_array_mismatch() {
|
||||||
|
let data = json!({"tags": ["a", "b", "c"]});
|
||||||
|
let cond = comp(".tags", ComparisonOp::Contains, Some(json!("z")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_null_true() {
|
||||||
|
let data = json!({"val": null});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNull, None);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_null_false() {
|
||||||
|
let data = json!({"val": 42});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNull, None);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_not_null_true() {
|
||||||
|
let data = json!({"val": 42});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNotNull, None);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_not_null_false() {
|
||||||
|
let data = json!({"val": null});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNotNull, None);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Combinator tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_both_true() {
|
||||||
|
let data = json!({"a": 1, "b": 2});
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_one_false() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_empty_is_true() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::All(vec![]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_one_true() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::Any(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_none_true() {
|
||||||
|
let data = json!({"a": 99, "b": 99});
|
||||||
|
let cond = StepCondition::Any(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_empty_is_false() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::Any(vec![]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_all_false() {
|
||||||
|
let data = json!({"a": 99, "b": 99});
|
||||||
|
let cond = StepCondition::None(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_one_true() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::None(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_empty_is_true() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::None(vec![]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_exactly_one_true() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::OneOf(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_both_true() {
|
||||||
|
let data = json!({"a": 1, "b": 2});
|
||||||
|
let cond = StepCondition::OneOf(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_none_true() {
|
||||||
|
let data = json!({"a": 99, "b": 99});
|
||||||
|
let cond = StepCondition::OneOf(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_true_becomes_false() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = StepCondition::Not(Box::new(comp(
|
||||||
|
".a",
|
||||||
|
ComparisonOp::Equals,
|
||||||
|
Some(json!(1)),
|
||||||
|
)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_false_becomes_true() {
|
||||||
|
let data = json!({"a": 99});
|
||||||
|
let cond = StepCondition::Not(Box::new(comp(
|
||||||
|
".a",
|
||||||
|
ComparisonOp::Equals,
|
||||||
|
Some(json!(1)),
|
||||||
|
)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Cascade skip tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_field_returns_false_cascade_skip() {
|
||||||
|
let data = json!({"other": 1});
|
||||||
|
let cond = comp(".missing", ComparisonOp::Equals, Some(json!(1)));
|
||||||
|
// Missing field -> cascade skip -> Ok(false)
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_nested_field_returns_false() {
|
||||||
|
let data = json!({"a": {"b": 1}});
|
||||||
|
let cond = comp(".a.c", ComparisonOp::Equals, Some(json!(1)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_field_in_all_returns_false() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".missing", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Nested combinator tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_all_any_not() {
|
||||||
|
let data = json!({"a": 1, "b": 2, "c": 3});
|
||||||
|
// All(Any(a==1, a==99), Not(c==99))
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
StepCondition::Any(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(99))),
|
||||||
|
]),
|
||||||
|
StepCondition::Not(Box::new(comp(
|
||||||
|
".c",
|
||||||
|
ComparisonOp::Equals,
|
||||||
|
Some(json!(99)),
|
||||||
|
))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_any_of_alls() {
|
||||||
|
let data = json!({"x": 10, "y": 20});
|
||||||
|
// Any(All(x>5, y>25), All(x>5, y>15))
|
||||||
|
let cond = StepCondition::Any(vec![
|
||||||
|
StepCondition::All(vec![
|
||||||
|
comp(".x", ComparisonOp::Gt, Some(json!(5))),
|
||||||
|
comp(".y", ComparisonOp::Gt, Some(json!(25))),
|
||||||
|
]),
|
||||||
|
StepCondition::All(vec![
|
||||||
|
comp(".x", ComparisonOp::Gt, Some(json!(5))),
|
||||||
|
comp(".y", ComparisonOp::Gt, Some(json!(15))),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Edge cases / error cases --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_on_string_errors() {
|
||||||
|
let data = json!({"name": "alice"});
|
||||||
|
let cond = comp(".name", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_with_string_value_errors() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!("not a number")));
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_on_number_errors() {
|
||||||
|
let data = json!({"count": 42});
|
||||||
|
let cond = comp(".count", ComparisonOp::Contains, Some(json!("4")));
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_without_value_errors() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = comp(".a", ComparisonOp::Equals, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_equals_without_value_errors() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = comp(".a", ComparisonOp::NotEquals, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_without_value_errors() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = comp(".a", ComparisonOp::Gt, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_without_value_errors() {
|
||||||
|
let data = json!({"msg": "hello"});
|
||||||
|
let cond = comp(".msg", ComparisonOp::Contains, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_bool_values() {
|
||||||
|
let data = json!({"active": true});
|
||||||
|
let cond = comp(".active", ComparisonOp::Equals, Some(json!(true)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_null_value() {
|
||||||
|
let data = json!({"val": null});
|
||||||
|
let cond = comp(".val", ComparisonOp::Equals, Some(json!(null)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float_comparison() {
|
||||||
|
let data = json!({"score": 3.14});
|
||||||
|
assert!(evaluate(&comp(".score", ComparisonOp::Gt, Some(json!(3.0))), &data).unwrap());
|
||||||
|
assert!(evaluate(&comp(".score", ComparisonOp::Lt, Some(json!(4.0))), &data).unwrap());
|
||||||
|
assert!(!evaluate(&comp(".score", ComparisonOp::Equals, Some(json!(3.0))), &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_array_numeric_element() {
|
||||||
|
let data = json!({"nums": [1, 2, 3]});
|
||||||
|
let cond = comp(".nums", ComparisonOp::Contains, Some(json!(2)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_empty_is_false() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::OneOf(vec![]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod condition;
|
||||||
mod error_handler;
|
mod error_handler;
|
||||||
mod result_processor;
|
mod result_processor;
|
||||||
mod step_registry;
|
mod step_registry;
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ use std::sync::Arc;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use super::condition;
|
||||||
use super::error_handler;
|
use super::error_handler;
|
||||||
use super::result_processor;
|
use super::result_processor;
|
||||||
use super::step_registry::StepRegistry;
|
use super::step_registry::StepRegistry;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
ExecutionError, PointerStatus, QueueType, WorkflowDefinition, WorkflowStatus,
|
Event, ExecutionError, PointerStatus, QueueType, WorkflowDefinition, WorkflowStatus,
|
||||||
};
|
};
|
||||||
use crate::traits::{
|
use crate::traits::{
|
||||||
DistributedLockProvider, LifecyclePublisher, PersistenceProvider, QueueProvider, SearchIndex,
|
DistributedLockProvider, LifecyclePublisher, PersistenceProvider, QueueProvider, SearchIndex,
|
||||||
@@ -22,6 +23,7 @@ pub struct WorkflowExecutor {
|
|||||||
pub queue_provider: Arc<dyn QueueProvider>,
|
pub queue_provider: Arc<dyn QueueProvider>,
|
||||||
pub lifecycle: Option<Arc<dyn LifecyclePublisher>>,
|
pub lifecycle: Option<Arc<dyn LifecyclePublisher>>,
|
||||||
pub search: Option<Arc<dyn SearchIndex>>,
|
pub search: Option<Arc<dyn SearchIndex>>,
|
||||||
|
pub log_sink: Option<Arc<dyn crate::traits::LogSink>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkflowExecutor {
|
impl WorkflowExecutor {
|
||||||
@@ -36,9 +38,15 @@ impl WorkflowExecutor {
|
|||||||
queue_provider,
|
queue_provider,
|
||||||
lifecycle: None,
|
lifecycle: None,
|
||||||
search: None,
|
search: None,
|
||||||
|
log_sink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_log_sink(mut self, sink: Arc<dyn crate::traits::LogSink>) -> Self {
|
||||||
|
self.log_sink = Some(sink);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_lifecycle(mut self, lifecycle: Arc<dyn LifecyclePublisher>) -> Self {
|
pub fn with_lifecycle(mut self, lifecycle: Arc<dyn LifecyclePublisher>) -> Self {
|
||||||
self.lifecycle = Some(lifecycle);
|
self.lifecycle = Some(lifecycle);
|
||||||
self
|
self
|
||||||
@@ -49,6 +57,15 @@ impl WorkflowExecutor {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish a lifecycle event if a publisher is configured.
|
||||||
|
async fn publish_lifecycle(&self, event: crate::models::LifecycleEvent) {
|
||||||
|
if let Some(ref publisher) = self.lifecycle {
|
||||||
|
if let Err(e) = publisher.publish(event).await {
|
||||||
|
warn!(error = %e, "failed to publish lifecycle event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute a single workflow instance.
|
/// Execute a single workflow instance.
|
||||||
///
|
///
|
||||||
/// 1. Acquire lock
|
/// 1. Acquire lock
|
||||||
@@ -61,7 +78,7 @@ impl WorkflowExecutor {
|
|||||||
/// 8. Release lock
|
/// 8. Release lock
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
name = "workflow.execute",
|
name = "workflow.execute",
|
||||||
skip(self, definition, step_registry),
|
skip(self, definition, step_registry, host_context),
|
||||||
fields(
|
fields(
|
||||||
workflow.id = %workflow_id,
|
workflow.id = %workflow_id,
|
||||||
workflow.definition_id,
|
workflow.definition_id,
|
||||||
@@ -73,6 +90,7 @@ impl WorkflowExecutor {
|
|||||||
workflow_id: &str,
|
workflow_id: &str,
|
||||||
definition: &WorkflowDefinition,
|
definition: &WorkflowDefinition,
|
||||||
step_registry: &StepRegistry,
|
step_registry: &StepRegistry,
|
||||||
|
host_context: Option<&dyn crate::traits::HostContext>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// 1. Acquire distributed lock.
|
// 1. Acquire distributed lock.
|
||||||
let acquired = self.lock_provider.acquire_lock(workflow_id).await?;
|
let acquired = self.lock_provider.acquire_lock(workflow_id).await?;
|
||||||
@@ -82,7 +100,7 @@ impl WorkflowExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
.execute_inner(workflow_id, definition, step_registry)
|
.execute_inner(workflow_id, definition, step_registry, host_context)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// 7. Release lock (always).
|
// 7. Release lock (always).
|
||||||
@@ -98,6 +116,7 @@ impl WorkflowExecutor {
|
|||||||
workflow_id: &str,
|
workflow_id: &str,
|
||||||
definition: &WorkflowDefinition,
|
definition: &WorkflowDefinition,
|
||||||
step_registry: &StepRegistry,
|
step_registry: &StepRegistry,
|
||||||
|
host_context: Option<&dyn crate::traits::HostContext>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// 2. Load workflow instance.
|
// 2. Load workflow instance.
|
||||||
let mut workflow = self
|
let mut workflow = self
|
||||||
@@ -142,6 +161,41 @@ impl WorkflowExecutor {
|
|||||||
.find(|s| s.id == step_id)
|
.find(|s| s.id == step_id)
|
||||||
.ok_or(WfeError::StepNotFound(step_id))?;
|
.ok_or(WfeError::StepNotFound(step_id))?;
|
||||||
|
|
||||||
|
// Check step condition before executing.
|
||||||
|
if let Some(ref when) = step.when {
|
||||||
|
match condition::evaluate(when, &workflow.data) {
|
||||||
|
Ok(true) => { /* condition met, proceed */ }
|
||||||
|
Ok(false) => {
|
||||||
|
info!(
|
||||||
|
workflow_id,
|
||||||
|
step_id,
|
||||||
|
step_name = step.name.as_deref().unwrap_or("(unnamed)"),
|
||||||
|
"Step skipped (condition not met)"
|
||||||
|
);
|
||||||
|
workflow.execution_pointers[idx].status = PointerStatus::Skipped;
|
||||||
|
workflow.execution_pointers[idx].active = false;
|
||||||
|
workflow.execution_pointers[idx].end_time = Some(Utc::now());
|
||||||
|
|
||||||
|
// Activate next step via outcomes (same as Complete).
|
||||||
|
let next_step_id = step.outcomes.first().map(|o| o.next_step);
|
||||||
|
if let Some(next_id) = next_step_id {
|
||||||
|
let mut next_pointer =
|
||||||
|
crate::models::ExecutionPointer::new(next_id);
|
||||||
|
next_pointer.predecessor_id =
|
||||||
|
Some(workflow.execution_pointers[idx].id.clone());
|
||||||
|
next_pointer.scope =
|
||||||
|
workflow.execution_pointers[idx].scope.clone();
|
||||||
|
workflow.execution_pointers.push(next_pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
workflow_id,
|
workflow_id,
|
||||||
step_id,
|
step_id,
|
||||||
@@ -164,6 +218,16 @@ impl WorkflowExecutor {
|
|||||||
}
|
}
|
||||||
workflow.execution_pointers[idx].status = PointerStatus::Running;
|
workflow.execution_pointers[idx].status = PointerStatus::Running;
|
||||||
|
|
||||||
|
self.publish_lifecycle(crate::models::LifecycleEvent::new(
|
||||||
|
&workflow.id,
|
||||||
|
&workflow.workflow_definition_id,
|
||||||
|
workflow.version,
|
||||||
|
crate::models::LifecycleEventType::StepStarted {
|
||||||
|
step_id,
|
||||||
|
step_name: step.name.clone(),
|
||||||
|
},
|
||||||
|
)).await;
|
||||||
|
|
||||||
// c. Build StepExecutionContext (borrows workflow immutably).
|
// c. Build StepExecutionContext (borrows workflow immutably).
|
||||||
let cancellation_token = tokio_util::sync::CancellationToken::new();
|
let cancellation_token = tokio_util::sync::CancellationToken::new();
|
||||||
let context = StepExecutionContext {
|
let context = StepExecutionContext {
|
||||||
@@ -173,6 +237,8 @@ impl WorkflowExecutor {
|
|||||||
step,
|
step,
|
||||||
workflow: &workflow,
|
workflow: &workflow,
|
||||||
cancellation_token,
|
cancellation_token,
|
||||||
|
host_context,
|
||||||
|
log_sink: self.log_sink.as_deref(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// d. Call step.run(context).
|
// d. Call step.run(context).
|
||||||
@@ -199,6 +265,17 @@ impl WorkflowExecutor {
|
|||||||
has_branches = result.branch_values.is_some(),
|
has_branches = result.branch_values.is_some(),
|
||||||
"Step completed"
|
"Step completed"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
self.publish_lifecycle(crate::models::LifecycleEvent::new(
|
||||||
|
&workflow.id,
|
||||||
|
&workflow.workflow_definition_id,
|
||||||
|
workflow.version,
|
||||||
|
crate::models::LifecycleEventType::StepCompleted {
|
||||||
|
step_id,
|
||||||
|
step_name: step.name.clone(),
|
||||||
|
},
|
||||||
|
)).await;
|
||||||
|
|
||||||
// e. Process the ExecutionResult.
|
// e. Process the ExecutionResult.
|
||||||
// Extract workflow_id before mutable borrow.
|
// Extract workflow_id before mutable borrow.
|
||||||
let wf_id = workflow.id.clone();
|
let wf_id = workflow.id.clone();
|
||||||
@@ -233,6 +310,15 @@ impl WorkflowExecutor {
|
|||||||
tracing::Span::current().record("step.status", "failed");
|
tracing::Span::current().record("step.status", "failed");
|
||||||
warn!(workflow_id, step_id, error = %error_msg, "Step execution failed");
|
warn!(workflow_id, step_id, error = %error_msg, "Step execution failed");
|
||||||
|
|
||||||
|
self.publish_lifecycle(crate::models::LifecycleEvent::new(
|
||||||
|
&workflow.id,
|
||||||
|
&workflow.workflow_definition_id,
|
||||||
|
workflow.version,
|
||||||
|
crate::models::LifecycleEventType::Error {
|
||||||
|
message: error_msg.clone(),
|
||||||
|
},
|
||||||
|
)).await;
|
||||||
|
|
||||||
let pointer_id = workflow.execution_pointers[idx].id.clone();
|
let pointer_id = workflow.execution_pointers[idx].id.clone();
|
||||||
execution_errors.push(ExecutionError::new(
|
execution_errors.push(ExecutionError::new(
|
||||||
workflow_id,
|
workflow_id,
|
||||||
@@ -254,6 +340,12 @@ impl WorkflowExecutor {
|
|||||||
workflow.status = new_status;
|
workflow.status = new_status;
|
||||||
if new_status == WorkflowStatus::Terminated {
|
if new_status == WorkflowStatus::Terminated {
|
||||||
workflow.complete_time = Some(Utc::now());
|
workflow.complete_time = Some(Utc::now());
|
||||||
|
self.publish_lifecycle(crate::models::LifecycleEvent::new(
|
||||||
|
&workflow.id,
|
||||||
|
&workflow.workflow_definition_id,
|
||||||
|
workflow.version,
|
||||||
|
crate::models::LifecycleEventType::Terminated,
|
||||||
|
)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +362,7 @@ impl WorkflowExecutor {
|
|||||||
matches!(
|
matches!(
|
||||||
p.status,
|
p.status,
|
||||||
PointerStatus::Complete
|
PointerStatus::Complete
|
||||||
|
| PointerStatus::Skipped
|
||||||
| PointerStatus::Compensated
|
| PointerStatus::Compensated
|
||||||
| PointerStatus::Cancelled
|
| PointerStatus::Cancelled
|
||||||
| PointerStatus::Failed
|
| PointerStatus::Failed
|
||||||
@@ -280,6 +373,25 @@ impl WorkflowExecutor {
|
|||||||
info!(workflow_id, "All pointers complete, workflow finished");
|
info!(workflow_id, "All pointers complete, workflow finished");
|
||||||
workflow.status = WorkflowStatus::Complete;
|
workflow.status = WorkflowStatus::Complete;
|
||||||
workflow.complete_time = Some(Utc::now());
|
workflow.complete_time = Some(Utc::now());
|
||||||
|
|
||||||
|
self.publish_lifecycle(crate::models::LifecycleEvent::new(
|
||||||
|
&workflow.id,
|
||||||
|
&workflow.workflow_definition_id,
|
||||||
|
workflow.version,
|
||||||
|
crate::models::LifecycleEventType::Completed,
|
||||||
|
)).await;
|
||||||
|
|
||||||
|
// Publish completion event for SubWorkflow parents.
|
||||||
|
let completion_event = Event::new(
|
||||||
|
"wfe.workflow.completed",
|
||||||
|
workflow_id,
|
||||||
|
serde_json::json!({ "status": "Complete", "data": workflow.data }),
|
||||||
|
);
|
||||||
|
let _ = self.persistence.create_event(&completion_event).await;
|
||||||
|
let _ = self
|
||||||
|
.queue_provider
|
||||||
|
.queue_work(&completion_event.id, QueueType::Event)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::Span::current().record("workflow.status", tracing::field::debug(&workflow.status));
|
tracing::Span::current().record("workflow.status", tracing::field::debug(&workflow.status));
|
||||||
@@ -573,7 +685,7 @@ mod tests {
|
|||||||
|
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Complete);
|
assert_eq!(updated.status, WorkflowStatus::Complete);
|
||||||
@@ -604,7 +716,7 @@ mod tests {
|
|||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
// First execution: step 0 completes, step 1 pointer created.
|
// First execution: step 0 completes, step 1 pointer created.
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers.len(), 2);
|
assert_eq!(updated.execution_pointers.len(), 2);
|
||||||
@@ -613,7 +725,7 @@ mod tests {
|
|||||||
assert_eq!(updated.execution_pointers[1].step_id, 1);
|
assert_eq!(updated.execution_pointers[1].step_id, 1);
|
||||||
|
|
||||||
// Second execution: step 1 completes.
|
// Second execution: step 1 completes.
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Complete);
|
assert_eq!(updated.status, WorkflowStatus::Complete);
|
||||||
@@ -644,7 +756,7 @@ mod tests {
|
|||||||
|
|
||||||
// Execute three times for three steps.
|
// Execute three times for three steps.
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
@@ -684,7 +796,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers.len(), 2);
|
assert_eq!(updated.execution_pointers.len(), 2);
|
||||||
@@ -707,7 +819,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Runnable);
|
assert_eq!(updated.status, WorkflowStatus::Runnable);
|
||||||
@@ -733,7 +845,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Sleeping);
|
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Sleeping);
|
||||||
@@ -756,7 +868,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -796,7 +908,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(pointer);
|
instance.execution_pointers.push(pointer);
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Complete);
|
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Complete);
|
||||||
@@ -822,7 +934,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
// 1 original + 3 children.
|
// 1 original + 3 children.
|
||||||
@@ -858,7 +970,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers[0].retry_count, 1);
|
assert_eq!(updated.execution_pointers[0].retry_count, 1);
|
||||||
@@ -884,7 +996,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Suspended);
|
assert_eq!(updated.status, WorkflowStatus::Suspended);
|
||||||
@@ -908,7 +1020,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Terminated);
|
assert_eq!(updated.status, WorkflowStatus::Terminated);
|
||||||
@@ -936,7 +1048,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Failed);
|
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Failed);
|
||||||
@@ -964,7 +1076,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(1));
|
instance.execution_pointers.push(ExecutionPointer::new(1));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Complete);
|
assert_eq!(updated.status, WorkflowStatus::Complete);
|
||||||
@@ -999,7 +1111,7 @@ mod tests {
|
|||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
// Should not error on a completed workflow.
|
// Should not error on a completed workflow.
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Complete);
|
assert_eq!(updated.status, WorkflowStatus::Complete);
|
||||||
@@ -1024,7 +1136,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(pointer);
|
instance.execution_pointers.push(pointer);
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
// Should still be sleeping since sleep_until is in the future.
|
// Should still be sleeping since sleep_until is in the future.
|
||||||
@@ -1048,7 +1160,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let errors = persistence.get_errors().await;
|
let errors = persistence.get_errors().await;
|
||||||
assert_eq!(errors.len(), 1);
|
assert_eq!(errors.len(), 1);
|
||||||
@@ -1072,7 +1184,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
// Executor itself doesn't publish lifecycle events in the current implementation,
|
// Executor itself doesn't publish lifecycle events in the current implementation,
|
||||||
// but the with_lifecycle builder works correctly.
|
// but the with_lifecycle builder works correctly.
|
||||||
@@ -1097,7 +1209,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Terminated);
|
assert_eq!(updated.status, WorkflowStatus::Terminated);
|
||||||
@@ -1118,7 +1230,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert!(updated.execution_pointers[0].start_time.is_some());
|
assert!(updated.execution_pointers[0].start_time.is_some());
|
||||||
@@ -1148,7 +1260,7 @@ mod tests {
|
|||||||
instance.execution_pointers.push(ExecutionPointer::new(0));
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1203,13 +1315,13 @@ mod tests {
|
|||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
// First execution: fails, retry scheduled.
|
// First execution: fails, retry scheduled.
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers[0].retry_count, 1);
|
assert_eq!(updated.execution_pointers[0].retry_count, 1);
|
||||||
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Sleeping);
|
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Sleeping);
|
||||||
|
|
||||||
// Second execution: succeeds (sleep_until is in the past with 0ms interval).
|
// Second execution: succeeds (sleep_until is in the past with 0ms interval).
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Complete);
|
assert_eq!(updated.execution_pointers[0].status, PointerStatus::Complete);
|
||||||
assert_eq!(updated.status, WorkflowStatus::Complete);
|
assert_eq!(updated.status, WorkflowStatus::Complete);
|
||||||
@@ -1227,7 +1339,7 @@ mod tests {
|
|||||||
// No execution pointers at all.
|
// No execution pointers at all.
|
||||||
persistence.create_new_workflow(&instance).await.unwrap();
|
persistence.create_new_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
executor.execute(&instance.id, &def, ®istry).await.unwrap();
|
executor.execute(&instance.id, &def, ®istry, None).await.unwrap();
|
||||||
|
|
||||||
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
let updated = persistence.get_workflow_instance(&instance.id).await.unwrap();
|
||||||
assert_eq!(updated.status, WorkflowStatus::Runnable);
|
assert_eq!(updated.status, WorkflowStatus::Runnable);
|
||||||
|
|||||||
209
wfe-core/src/models/condition.rs
Normal file
209
wfe-core/src/models/condition.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A condition that determines whether a workflow step should execute.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum StepCondition {
|
||||||
|
/// All sub-conditions must be true (AND).
|
||||||
|
All(Vec<StepCondition>),
|
||||||
|
/// At least one sub-condition must be true (OR).
|
||||||
|
Any(Vec<StepCondition>),
|
||||||
|
/// No sub-conditions may be true (NOR).
|
||||||
|
None(Vec<StepCondition>),
|
||||||
|
/// Exactly one sub-condition must be true (XOR).
|
||||||
|
OneOf(Vec<StepCondition>),
|
||||||
|
/// Negation of a single condition (NOT).
|
||||||
|
Not(Box<StepCondition>),
|
||||||
|
/// A leaf comparison against a field in workflow data.
|
||||||
|
Comparison(FieldComparison),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A comparison of a workflow data field against an expected value.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct FieldComparison {
|
||||||
|
/// Dot-separated field path, e.g. ".outputs.docker_started".
|
||||||
|
pub field: String,
|
||||||
|
/// The comparison operator.
|
||||||
|
pub operator: ComparisonOp,
|
||||||
|
/// The value to compare against. Required for all operators except IsNull/IsNotNull.
|
||||||
|
pub value: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comparison operators for field conditions.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum ComparisonOp {
|
||||||
|
Equals,
|
||||||
|
NotEquals,
|
||||||
|
Gt,
|
||||||
|
Gte,
|
||||||
|
Lt,
|
||||||
|
Lte,
|
||||||
|
Contains,
|
||||||
|
IsNull,
|
||||||
|
IsNotNull,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_op_serde_round_trip() {
|
||||||
|
for op in [
|
||||||
|
ComparisonOp::Equals,
|
||||||
|
ComparisonOp::NotEquals,
|
||||||
|
ComparisonOp::Gt,
|
||||||
|
ComparisonOp::Gte,
|
||||||
|
ComparisonOp::Lt,
|
||||||
|
ComparisonOp::Lte,
|
||||||
|
ComparisonOp::Contains,
|
||||||
|
ComparisonOp::IsNull,
|
||||||
|
ComparisonOp::IsNotNull,
|
||||||
|
] {
|
||||||
|
let json_str = serde_json::to_string(&op).unwrap();
|
||||||
|
let deserialized: ComparisonOp = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(op, deserialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_comparison_serde_round_trip() {
|
||||||
|
let comp = FieldComparison {
|
||||||
|
field: ".outputs.status".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!("success")),
|
||||||
|
};
|
||||||
|
let json_str = serde_json::to_string(&comp).unwrap();
|
||||||
|
let deserialized: FieldComparison = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(comp, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_comparison_without_value_serde_round_trip() {
|
||||||
|
let comp = FieldComparison {
|
||||||
|
field: ".outputs.result".to_string(),
|
||||||
|
operator: ComparisonOp::IsNull,
|
||||||
|
value: None,
|
||||||
|
};
|
||||||
|
let json_str = serde_json::to_string(&comp).unwrap();
|
||||||
|
let deserialized: FieldComparison = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(comp, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_condition_comparison_serde_round_trip() {
|
||||||
|
let condition = StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".count".to_string(),
|
||||||
|
operator: ComparisonOp::Gt,
|
||||||
|
value: Some(json!(5)),
|
||||||
|
});
|
||||||
|
let json_str = serde_json::to_string(&condition).unwrap();
|
||||||
|
let deserialized: StepCondition = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(condition, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_condition_not_serde_round_trip() {
|
||||||
|
let condition = StepCondition::Not(Box::new(StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".active".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!(false)),
|
||||||
|
})));
|
||||||
|
let json_str = serde_json::to_string(&condition).unwrap();
|
||||||
|
let deserialized: StepCondition = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(condition, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_condition_all_serde_round_trip() {
|
||||||
|
let condition = StepCondition::All(vec![
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".a".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!(1)),
|
||||||
|
}),
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".b".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!(2)),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let json_str = serde_json::to_string(&condition).unwrap();
|
||||||
|
let deserialized: StepCondition = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(condition, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_condition_any_serde_round_trip() {
|
||||||
|
let condition = StepCondition::Any(vec![
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".x".to_string(),
|
||||||
|
operator: ComparisonOp::IsNull,
|
||||||
|
value: None,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let json_str = serde_json::to_string(&condition).unwrap();
|
||||||
|
let deserialized: StepCondition = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(condition, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_condition_none_serde_round_trip() {
|
||||||
|
let condition = StepCondition::None(vec![
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".err".to_string(),
|
||||||
|
operator: ComparisonOp::IsNotNull,
|
||||||
|
value: None,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let json_str = serde_json::to_string(&condition).unwrap();
|
||||||
|
let deserialized: StepCondition = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(condition, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_condition_one_of_serde_round_trip() {
|
||||||
|
let condition = StepCondition::OneOf(vec![
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".mode".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!("fast")),
|
||||||
|
}),
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".mode".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!("slow")),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let json_str = serde_json::to_string(&condition).unwrap();
|
||||||
|
let deserialized: StepCondition = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(condition, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_combinator_serde_round_trip() {
|
||||||
|
let condition = StepCondition::All(vec![
|
||||||
|
StepCondition::Any(vec![
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".a".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!(1)),
|
||||||
|
}),
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".b".to_string(),
|
||||||
|
operator: ComparisonOp::Equals,
|
||||||
|
value: Some(json!(2)),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
StepCondition::Not(Box::new(StepCondition::Comparison(FieldComparison {
|
||||||
|
field: ".c".to_string(),
|
||||||
|
operator: ComparisonOp::IsNull,
|
||||||
|
value: None,
|
||||||
|
}))),
|
||||||
|
]);
|
||||||
|
let json_str = serde_json::to_string(&condition).unwrap();
|
||||||
|
let deserialized: StepCondition = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(condition, deserialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod condition;
|
||||||
pub mod error_behavior;
|
pub mod error_behavior;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod execution_error;
|
pub mod execution_error;
|
||||||
@@ -7,10 +8,12 @@ pub mod lifecycle;
|
|||||||
pub mod poll_config;
|
pub mod poll_config;
|
||||||
pub mod queue_type;
|
pub mod queue_type;
|
||||||
pub mod scheduled_command;
|
pub mod scheduled_command;
|
||||||
|
pub mod schema;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod workflow_definition;
|
pub mod workflow_definition;
|
||||||
pub mod workflow_instance;
|
pub mod workflow_instance;
|
||||||
|
|
||||||
|
pub use condition::{ComparisonOp, FieldComparison, StepCondition};
|
||||||
pub use error_behavior::ErrorBehavior;
|
pub use error_behavior::ErrorBehavior;
|
||||||
pub use event::{Event, EventSubscription};
|
pub use event::{Event, EventSubscription};
|
||||||
pub use execution_error::ExecutionError;
|
pub use execution_error::ExecutionError;
|
||||||
@@ -20,6 +23,7 @@ pub use lifecycle::{LifecycleEvent, LifecycleEventType};
|
|||||||
pub use poll_config::{HttpMethod, PollCondition, PollEndpointConfig};
|
pub use poll_config::{HttpMethod, PollCondition, PollEndpointConfig};
|
||||||
pub use queue_type::QueueType;
|
pub use queue_type::QueueType;
|
||||||
pub use scheduled_command::{CommandName, ScheduledCommand};
|
pub use scheduled_command::{CommandName, ScheduledCommand};
|
||||||
|
pub use schema::{SchemaType, WorkflowSchema};
|
||||||
pub use status::{PointerStatus, WorkflowStatus};
|
pub use status::{PointerStatus, WorkflowStatus};
|
||||||
pub use workflow_definition::{StepOutcome, WorkflowDefinition, WorkflowStep};
|
pub use workflow_definition::{StepOutcome, WorkflowDefinition, WorkflowStep};
|
||||||
pub use workflow_instance::WorkflowInstance;
|
pub use workflow_instance::WorkflowInstance;
|
||||||
|
|||||||
483
wfe-core/src/models/schema.rs
Normal file
483
wfe-core/src/models/schema.rs
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Describes a single type in the workflow schema type system.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SchemaType {
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Integer,
|
||||||
|
Bool,
|
||||||
|
Optional(Box<SchemaType>),
|
||||||
|
List(Box<SchemaType>),
|
||||||
|
Map(Box<SchemaType>),
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defines expected input and output schemas for a workflow.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct WorkflowSchema {
|
||||||
|
#[serde(default)]
|
||||||
|
pub inputs: HashMap<String, SchemaType>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub outputs: HashMap<String, SchemaType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a type string into a [`SchemaType`].
|
||||||
|
///
|
||||||
|
/// Supported formats:
|
||||||
|
/// - `"string"`, `"number"`, `"integer"`, `"bool"`, `"any"`
|
||||||
|
/// - `"string?"` (optional)
|
||||||
|
/// - `"list<string>"`, `"map<number>"` (generic containers)
|
||||||
|
/// - Nested: `"list<list<string>>"`
|
||||||
|
pub fn parse_type(s: &str) -> crate::Result<SchemaType> {
|
||||||
|
let s = s.trim();
|
||||||
|
|
||||||
|
// Handle optional suffix.
|
||||||
|
if let Some(inner) = s.strip_suffix('?') {
|
||||||
|
let inner_type = parse_type(inner)?;
|
||||||
|
return Ok(SchemaType::Optional(Box::new(inner_type)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle generic containers: list<...> and map<...>.
|
||||||
|
if let Some(rest) = s.strip_prefix("list<") {
|
||||||
|
let inner = rest
|
||||||
|
.strip_suffix('>')
|
||||||
|
.ok_or_else(|| crate::WfeError::StepExecution(format!("Invalid type syntax: {s}")))?;
|
||||||
|
let inner_type = parse_type(inner)?;
|
||||||
|
return Ok(SchemaType::List(Box::new(inner_type)));
|
||||||
|
}
|
||||||
|
if let Some(rest) = s.strip_prefix("map<") {
|
||||||
|
let inner = rest
|
||||||
|
.strip_suffix('>')
|
||||||
|
.ok_or_else(|| crate::WfeError::StepExecution(format!("Invalid type syntax: {s}")))?;
|
||||||
|
let inner_type = parse_type(inner)?;
|
||||||
|
return Ok(SchemaType::Map(Box::new(inner_type)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primitive types.
|
||||||
|
match s {
|
||||||
|
"string" => Ok(SchemaType::String),
|
||||||
|
"number" => Ok(SchemaType::Number),
|
||||||
|
"integer" => Ok(SchemaType::Integer),
|
||||||
|
"bool" => Ok(SchemaType::Bool),
|
||||||
|
"any" => Ok(SchemaType::Any),
|
||||||
|
_ => Err(crate::WfeError::StepExecution(format!(
|
||||||
|
"Unknown type: {s}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a JSON value matches the expected [`SchemaType`].
|
||||||
|
pub fn validate_value(value: &serde_json::Value, expected: &SchemaType) -> Result<(), String> {
|
||||||
|
match expected {
|
||||||
|
SchemaType::String => {
|
||||||
|
if value.is_string() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("expected string, got {}", value_type_name(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SchemaType::Number => {
|
||||||
|
if value.is_number() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("expected number, got {}", value_type_name(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SchemaType::Integer => {
|
||||||
|
if value.is_i64() || value.is_u64() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("expected integer, got {}", value_type_name(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SchemaType::Bool => {
|
||||||
|
if value.is_boolean() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("expected bool, got {}", value_type_name(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SchemaType::Optional(inner) => {
|
||||||
|
if value.is_null() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
validate_value(value, inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SchemaType::List(inner) => {
|
||||||
|
if let Some(arr) = value.as_array() {
|
||||||
|
for (i, item) in arr.iter().enumerate() {
|
||||||
|
validate_value(item, inner)
|
||||||
|
.map_err(|e| format!("list element [{i}]: {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("expected list, got {}", value_type_name(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SchemaType::Map(inner) => {
|
||||||
|
if let Some(obj) = value.as_object() {
|
||||||
|
for (key, val) in obj {
|
||||||
|
validate_value(val, inner)
|
||||||
|
.map_err(|e| format!("map key \"{key}\": {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("expected map, got {}", value_type_name(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SchemaType::Any => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_type_name(value: &serde_json::Value) -> &'static str {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Null => "null",
|
||||||
|
serde_json::Value::Bool(_) => "bool",
|
||||||
|
serde_json::Value::Number(_) => "number",
|
||||||
|
serde_json::Value::String(_) => "string",
|
||||||
|
serde_json::Value::Array(_) => "array",
|
||||||
|
serde_json::Value::Object(_) => "object",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkflowSchema {
|
||||||
|
/// Validate that the given data satisfies all input field requirements.
|
||||||
|
pub fn validate_inputs(&self, data: &serde_json::Value) -> Result<(), Vec<String>> {
|
||||||
|
self.validate_fields(&self.inputs, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that the given data satisfies all output field requirements.
|
||||||
|
pub fn validate_outputs(&self, data: &serde_json::Value) -> Result<(), Vec<String>> {
|
||||||
|
self.validate_fields(&self.outputs, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_fields(
|
||||||
|
&self,
|
||||||
|
fields: &HashMap<String, SchemaType>,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> Result<(), Vec<String>> {
|
||||||
|
let obj = match data.as_object() {
|
||||||
|
Some(o) => o,
|
||||||
|
None => {
|
||||||
|
return Err(vec!["expected an object".to_string()]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (name, schema_type) in fields {
|
||||||
|
match obj.get(name) {
|
||||||
|
Some(value) => {
|
||||||
|
if let Err(e) = validate_value(value, schema_type) {
|
||||||
|
errors.push(format!("field \"{name}\": {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Missing field is OK for optional types (null is acceptable).
|
||||||
|
if !matches!(schema_type, SchemaType::Optional(_)) {
|
||||||
|
errors.push(format!("missing required field: \"{name}\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// -- parse_type tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_string() {
|
||||||
|
assert_eq!(parse_type("string").unwrap(), SchemaType::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_number() {
|
||||||
|
assert_eq!(parse_type("number").unwrap(), SchemaType::Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_integer() {
|
||||||
|
assert_eq!(parse_type("integer").unwrap(), SchemaType::Integer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_bool() {
|
||||||
|
assert_eq!(parse_type("bool").unwrap(), SchemaType::Bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_any() {
|
||||||
|
assert_eq!(parse_type("any").unwrap(), SchemaType::Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_optional_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type("string?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_optional_number() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type("number?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_list_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type("list<string>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_list_number() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type("list<number>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_map_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type("map<string>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_map_number() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type("map<number>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_nested_list() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type("list<list<string>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_unknown_errors() {
|
||||||
|
assert!(parse_type("foobar").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_type_trims_whitespace() {
|
||||||
|
assert_eq!(parse_type(" string ").unwrap(), SchemaType::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- validate_value tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_string_match() {
|
||||||
|
assert!(validate_value(&json!("hello"), &SchemaType::String).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_string_mismatch() {
|
||||||
|
assert!(validate_value(&json!(42), &SchemaType::String).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_number_match() {
|
||||||
|
assert!(validate_value(&json!(2.78), &SchemaType::Number).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_number_mismatch() {
|
||||||
|
assert!(validate_value(&json!("not a number"), &SchemaType::Number).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_integer_match() {
|
||||||
|
assert!(validate_value(&json!(42), &SchemaType::Integer).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_integer_mismatch_float() {
|
||||||
|
assert!(validate_value(&json!(2.78), &SchemaType::Integer).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_bool_match() {
|
||||||
|
assert!(validate_value(&json!(true), &SchemaType::Bool).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_bool_mismatch() {
|
||||||
|
assert!(validate_value(&json!(1), &SchemaType::Bool).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_optional_null_passes() {
|
||||||
|
let ty = SchemaType::Optional(Box::new(SchemaType::String));
|
||||||
|
assert!(validate_value(&json!(null), &ty).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_optional_correct_inner_passes() {
|
||||||
|
let ty = SchemaType::Optional(Box::new(SchemaType::String));
|
||||||
|
assert!(validate_value(&json!("hello"), &ty).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_optional_wrong_inner_fails() {
|
||||||
|
let ty = SchemaType::Optional(Box::new(SchemaType::String));
|
||||||
|
assert!(validate_value(&json!(42), &ty).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_list_match() {
|
||||||
|
let ty = SchemaType::List(Box::new(SchemaType::Number));
|
||||||
|
assert!(validate_value(&json!([1, 2, 3]), &ty).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_list_mismatch_element() {
|
||||||
|
let ty = SchemaType::List(Box::new(SchemaType::Number));
|
||||||
|
assert!(validate_value(&json!([1, "two", 3]), &ty).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_list_not_array() {
|
||||||
|
let ty = SchemaType::List(Box::new(SchemaType::Number));
|
||||||
|
assert!(validate_value(&json!("not a list"), &ty).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_map_match() {
|
||||||
|
let ty = SchemaType::Map(Box::new(SchemaType::Number));
|
||||||
|
assert!(validate_value(&json!({"a": 1, "b": 2}), &ty).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_map_mismatch_value() {
|
||||||
|
let ty = SchemaType::Map(Box::new(SchemaType::Number));
|
||||||
|
assert!(validate_value(&json!({"a": 1, "b": "two"}), &ty).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_map_not_object() {
|
||||||
|
let ty = SchemaType::Map(Box::new(SchemaType::Number));
|
||||||
|
assert!(validate_value(&json!([1, 2]), &ty).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_any_always_passes() {
|
||||||
|
assert!(validate_value(&json!(null), &SchemaType::Any).is_ok());
|
||||||
|
assert!(validate_value(&json!("str"), &SchemaType::Any).is_ok());
|
||||||
|
assert!(validate_value(&json!(42), &SchemaType::Any).is_ok());
|
||||||
|
assert!(validate_value(&json!([1, 2]), &SchemaType::Any).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- WorkflowSchema validate_inputs / validate_outputs tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_inputs_all_present() {
|
||||||
|
let schema = WorkflowSchema {
|
||||||
|
inputs: HashMap::from([
|
||||||
|
("name".into(), SchemaType::String),
|
||||||
|
("age".into(), SchemaType::Integer),
|
||||||
|
]),
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
};
|
||||||
|
let data = json!({"name": "Alice", "age": 30});
|
||||||
|
assert!(schema.validate_inputs(&data).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_inputs_missing_required_field() {
|
||||||
|
let schema = WorkflowSchema {
|
||||||
|
inputs: HashMap::from([
|
||||||
|
("name".into(), SchemaType::String),
|
||||||
|
("age".into(), SchemaType::Integer),
|
||||||
|
]),
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
};
|
||||||
|
let data = json!({"name": "Alice"});
|
||||||
|
let errs = schema.validate_inputs(&data).unwrap_err();
|
||||||
|
assert!(errs.iter().any(|e| e.contains("age")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_inputs_wrong_type() {
|
||||||
|
let schema = WorkflowSchema {
|
||||||
|
inputs: HashMap::from([("count".into(), SchemaType::Integer)]),
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
};
|
||||||
|
let data = json!({"count": "not-a-number"});
|
||||||
|
let errs = schema.validate_inputs(&data).unwrap_err();
|
||||||
|
assert!(!errs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_outputs_missing_field() {
|
||||||
|
let schema = WorkflowSchema {
|
||||||
|
inputs: HashMap::new(),
|
||||||
|
outputs: HashMap::from([("result".into(), SchemaType::String)]),
|
||||||
|
};
|
||||||
|
let data = json!({});
|
||||||
|
let errs = schema.validate_outputs(&data).unwrap_err();
|
||||||
|
assert!(errs.iter().any(|e| e.contains("result")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_inputs_optional_field_missing_is_ok() {
|
||||||
|
let schema = WorkflowSchema {
|
||||||
|
inputs: HashMap::from([(
|
||||||
|
"nickname".into(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::String)),
|
||||||
|
)]),
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
};
|
||||||
|
let data = json!({});
|
||||||
|
assert!(schema.validate_inputs(&data).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_not_object_errors() {
|
||||||
|
let schema = WorkflowSchema {
|
||||||
|
inputs: HashMap::from([("x".into(), SchemaType::String)]),
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
};
|
||||||
|
let errs = schema.validate_inputs(&json!("not an object")).unwrap_err();
|
||||||
|
assert!(errs[0].contains("expected an object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_serde_round_trip() {
|
||||||
|
let schema = WorkflowSchema {
|
||||||
|
inputs: HashMap::from([("name".into(), SchemaType::String)]),
|
||||||
|
outputs: HashMap::from([("result".into(), SchemaType::Bool)]),
|
||||||
|
};
|
||||||
|
let json_str = serde_json::to_string(&schema).unwrap();
|
||||||
|
let deserialized: WorkflowSchema = serde_json::from_str(&json_str).unwrap();
|
||||||
|
assert_eq!(deserialized.inputs["name"], SchemaType::String);
|
||||||
|
assert_eq!(deserialized.outputs["result"], SchemaType::Bool);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ pub enum PointerStatus {
|
|||||||
Pending,
|
Pending,
|
||||||
Running,
|
Running,
|
||||||
Complete,
|
Complete,
|
||||||
|
Skipped,
|
||||||
Sleeping,
|
Sleeping,
|
||||||
WaitingForEvent,
|
WaitingForEvent,
|
||||||
Failed,
|
Failed,
|
||||||
@@ -58,6 +59,7 @@ mod tests {
|
|||||||
PointerStatus::Pending,
|
PointerStatus::Pending,
|
||||||
PointerStatus::Running,
|
PointerStatus::Running,
|
||||||
PointerStatus::Complete,
|
PointerStatus::Complete,
|
||||||
|
PointerStatus::Skipped,
|
||||||
PointerStatus::Sleeping,
|
PointerStatus::Sleeping,
|
||||||
PointerStatus::WaitingForEvent,
|
PointerStatus::WaitingForEvent,
|
||||||
PointerStatus::Failed,
|
PointerStatus::Failed,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::condition::StepCondition;
|
||||||
use super::error_behavior::ErrorBehavior;
|
use super::error_behavior::ErrorBehavior;
|
||||||
|
|
||||||
/// A compiled workflow definition ready for execution.
|
/// A compiled workflow definition ready for execution.
|
||||||
@@ -46,6 +47,9 @@ pub struct WorkflowStep {
|
|||||||
/// Serializable configuration for primitive steps (e.g. event_name, duration).
|
/// Serializable configuration for primitive steps (e.g. event_name, duration).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub step_config: Option<serde_json::Value>,
|
pub step_config: Option<serde_json::Value>,
|
||||||
|
/// Optional condition that must evaluate to true for this step to execute.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub when: Option<StepCondition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkflowStep {
|
impl WorkflowStep {
|
||||||
@@ -62,6 +66,7 @@ impl WorkflowStep {
|
|||||||
do_compensate: false,
|
do_compensate: false,
|
||||||
saga: false,
|
saga: false,
|
||||||
step_config: None,
|
step_config: None,
|
||||||
|
when: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ impl WorkflowInstance {
|
|||||||
matches!(
|
matches!(
|
||||||
p.status,
|
p.status,
|
||||||
PointerStatus::Complete
|
PointerStatus::Complete
|
||||||
|
| PointerStatus::Skipped
|
||||||
| PointerStatus::Compensated
|
| PointerStatus::Compensated
|
||||||
| PointerStatus::Cancelled
|
| PointerStatus::Cancelled
|
||||||
| PointerStatus::Failed
|
| PointerStatus::Failed
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod recur;
|
|||||||
pub mod saga_container;
|
pub mod saga_container;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod sequence;
|
pub mod sequence;
|
||||||
|
pub mod sub_workflow;
|
||||||
pub mod wait_for;
|
pub mod wait_for;
|
||||||
pub mod while_step;
|
pub mod while_step;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ pub use recur::RecurStep;
|
|||||||
pub use saga_container::SagaContainerStep;
|
pub use saga_container::SagaContainerStep;
|
||||||
pub use schedule::ScheduleStep;
|
pub use schedule::ScheduleStep;
|
||||||
pub use sequence::SequenceStep;
|
pub use sequence::SequenceStep;
|
||||||
|
pub use sub_workflow::SubWorkflowStep;
|
||||||
pub use wait_for::WaitForStep;
|
pub use wait_for::WaitForStep;
|
||||||
pub use while_step::WhileStep;
|
pub use while_step::WhileStep;
|
||||||
|
|
||||||
@@ -42,6 +44,8 @@ mod test_helpers {
|
|||||||
step,
|
step,
|
||||||
workflow,
|
workflow,
|
||||||
cancellation_token: CancellationToken::new(),
|
cancellation_token: CancellationToken::new(),
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
445
wfe-core/src/primitives/sub_workflow.rs
Normal file
445
wfe-core/src/primitives/sub_workflow.rs
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::models::schema::WorkflowSchema;
|
||||||
|
use crate::models::ExecutionResult;
|
||||||
|
use crate::traits::step::{StepBody, StepExecutionContext};
|
||||||
|
|
||||||
|
/// A step that starts a child workflow and waits for its completion.
|
||||||
|
///
|
||||||
|
/// On first invocation, it validates inputs against `input_schema`, starts the
|
||||||
|
/// child workflow via the host context, and returns a "wait for event" result.
|
||||||
|
///
|
||||||
|
/// When the child workflow completes, the event data arrives, output keys are
|
||||||
|
/// extracted, and the step proceeds.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SubWorkflowStep {
|
||||||
|
/// The definition ID of the child workflow to start.
|
||||||
|
pub workflow_id: String,
|
||||||
|
/// The version of the child workflow definition.
|
||||||
|
pub version: u32,
|
||||||
|
/// Input data to pass to the child workflow.
|
||||||
|
pub inputs: serde_json::Value,
|
||||||
|
/// Keys to extract from the child workflow's completion event data.
|
||||||
|
pub output_keys: Vec<String>,
|
||||||
|
/// Optional schema to validate inputs before starting the child.
|
||||||
|
pub input_schema: Option<WorkflowSchema>,
|
||||||
|
/// Optional schema to validate outputs from the child.
|
||||||
|
pub output_schema: Option<WorkflowSchema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StepBody for SubWorkflowStep {
|
||||||
|
async fn run(&mut self, context: &StepExecutionContext<'_>) -> crate::Result<ExecutionResult> {
|
||||||
|
// If event data has arrived, the child workflow completed.
|
||||||
|
if let Some(event_data) = &context.execution_pointer.event_data {
|
||||||
|
// Extract output_keys from event data.
|
||||||
|
let mut output = serde_json::Map::new();
|
||||||
|
|
||||||
|
// The event data contains { "status": "...", "data": { ... } }.
|
||||||
|
let child_data = event_data
|
||||||
|
.get("data")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
if self.output_keys.is_empty() {
|
||||||
|
// If no specific keys requested, pass all child data through.
|
||||||
|
if let serde_json::Value::Object(map) = child_data {
|
||||||
|
output = map;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Extract only the requested keys.
|
||||||
|
for key in &self.output_keys {
|
||||||
|
if let Some(val) = child_data.get(key) {
|
||||||
|
output.insert(key.clone(), val.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_value = serde_json::Value::Object(output);
|
||||||
|
|
||||||
|
// Validate against output schema if present.
|
||||||
|
if let Some(ref schema) = self.output_schema
|
||||||
|
&& let Err(errors) = schema.validate_outputs(&output_value)
|
||||||
|
{
|
||||||
|
return Err(crate::WfeError::StepExecution(format!(
|
||||||
|
"SubWorkflow output validation failed: {}",
|
||||||
|
errors.join("; ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = ExecutionResult::next();
|
||||||
|
result.output_data = Some(output_value);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate from step_config if our fields are empty (created via Default).
|
||||||
|
if self.workflow_id.is_empty()
|
||||||
|
&& let Some(config) = &context.step.step_config
|
||||||
|
{
|
||||||
|
if let Some(wf_id) = config.get("workflow_id").and_then(|v| v.as_str()) {
|
||||||
|
self.workflow_id = wf_id.to_string();
|
||||||
|
}
|
||||||
|
if let Some(ver) = config.get("version").and_then(|v| v.as_u64()) {
|
||||||
|
self.version = ver as u32;
|
||||||
|
}
|
||||||
|
if let Some(inputs) = config.get("inputs") {
|
||||||
|
self.inputs = inputs.clone();
|
||||||
|
}
|
||||||
|
if let Some(keys) = config.get("output_keys").and_then(|v| v.as_array()) {
|
||||||
|
self.output_keys = keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call: validate inputs and start child workflow.
|
||||||
|
if let Some(ref schema) = self.input_schema
|
||||||
|
&& let Err(errors) = schema.validate_inputs(&self.inputs)
|
||||||
|
{
|
||||||
|
return Err(crate::WfeError::StepExecution(format!(
|
||||||
|
"SubWorkflow input validation failed: {}",
|
||||||
|
errors.join("; ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = context.host_context.ok_or_else(|| {
|
||||||
|
crate::WfeError::StepExecution(
|
||||||
|
"SubWorkflowStep requires a host context to start child workflows".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Use inputs if set, otherwise pass an empty object so the child
|
||||||
|
// workflow has a valid JSON object for storing step outputs.
|
||||||
|
let child_data = if self.inputs.is_null() {
|
||||||
|
serde_json::json!({})
|
||||||
|
} else {
|
||||||
|
self.inputs.clone()
|
||||||
|
};
|
||||||
|
let child_instance_id = host
|
||||||
|
.start_workflow(&self.workflow_id, self.version, child_data)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ExecutionResult::wait_for_event(
|
||||||
|
"wfe.workflow.completed",
|
||||||
|
child_instance_id,
|
||||||
|
Utc::now(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::schema::SchemaType;
|
||||||
|
use crate::models::ExecutionPointer;
|
||||||
|
use crate::primitives::test_helpers::*;
|
||||||
|
use crate::traits::step::HostContext;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// A mock HostContext that records calls and returns a fixed instance ID.
|
||||||
|
struct MockHostContext {
|
||||||
|
started: Mutex<Vec<(String, u32, serde_json::Value)>>,
|
||||||
|
result_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockHostContext {
|
||||||
|
fn new(result_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
started: Mutex::new(Vec::new()),
|
||||||
|
result_id: result_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self) -> Vec<(String, u32, serde_json::Value)> {
|
||||||
|
self.started.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostContext for MockHostContext {
|
||||||
|
fn start_workflow(
|
||||||
|
&self,
|
||||||
|
definition_id: &str,
|
||||||
|
version: u32,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<String>> + Send + '_>>
|
||||||
|
{
|
||||||
|
let def_id = definition_id.to_string();
|
||||||
|
let result_id = self.result_id.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
self.started
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((def_id, version, data));
|
||||||
|
Ok(result_id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mock HostContext that returns an error.
|
||||||
|
struct FailingHostContext;
|
||||||
|
|
||||||
|
impl HostContext for FailingHostContext {
|
||||||
|
fn start_workflow(
|
||||||
|
&self,
|
||||||
|
_definition_id: &str,
|
||||||
|
_version: u32,
|
||||||
|
_data: serde_json::Value,
|
||||||
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<String>> + Send + '_>>
|
||||||
|
{
|
||||||
|
Box::pin(async {
|
||||||
|
Err(crate::WfeError::StepExecution(
|
||||||
|
"failed to start child".to_string(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_context_with_host<'a>(
|
||||||
|
pointer: &'a ExecutionPointer,
|
||||||
|
step: &'a crate::models::WorkflowStep,
|
||||||
|
workflow: &'a crate::models::WorkflowInstance,
|
||||||
|
host: &'a dyn HostContext,
|
||||||
|
) -> StepExecutionContext<'a> {
|
||||||
|
StepExecutionContext {
|
||||||
|
item: None,
|
||||||
|
execution_pointer: pointer,
|
||||||
|
persistence_data: pointer.persistence_data.as_ref(),
|
||||||
|
step,
|
||||||
|
workflow,
|
||||||
|
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||||
|
host_context: Some(host),
|
||||||
|
log_sink: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_call_starts_child_and_waits() {
|
||||||
|
let host = MockHostContext::new("child-123");
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({"x": 10}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context_with_host(&pointer, &wf_step, &workflow, &host);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.unwrap();
|
||||||
|
assert!(!result.proceed);
|
||||||
|
assert_eq!(result.event_name.as_deref(), Some("wfe.workflow.completed"));
|
||||||
|
assert_eq!(result.event_key.as_deref(), Some("child-123"));
|
||||||
|
assert!(result.event_as_of.is_some());
|
||||||
|
|
||||||
|
let calls = host.calls();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].0, "child-def");
|
||||||
|
assert_eq!(calls[0].1, 1);
|
||||||
|
assert_eq!(calls[0].2, json!({"x": 10}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn child_completed_proceeds_with_output() {
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({}),
|
||||||
|
output_keys: vec!["result".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pointer = ExecutionPointer::new(0);
|
||||||
|
pointer.event_data = Some(json!({
|
||||||
|
"status": "Complete",
|
||||||
|
"data": {"result": "success", "extra": "ignored"}
|
||||||
|
}));
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context(&pointer, &wf_step, &workflow);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.unwrap();
|
||||||
|
assert!(result.proceed);
|
||||||
|
assert_eq!(
|
||||||
|
result.output_data,
|
||||||
|
Some(json!({"result": "success"}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn child_completed_no_output_keys_passes_all() {
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({}),
|
||||||
|
output_keys: vec![],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pointer = ExecutionPointer::new(0);
|
||||||
|
pointer.event_data = Some(json!({
|
||||||
|
"status": "Complete",
|
||||||
|
"data": {"a": 1, "b": 2}
|
||||||
|
}));
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context(&pointer, &wf_step, &workflow);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.unwrap();
|
||||||
|
assert!(result.proceed);
|
||||||
|
assert_eq!(
|
||||||
|
result.output_data,
|
||||||
|
Some(json!({"a": 1, "b": 2}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_host_context_errors() {
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context(&pointer, &wf_step, &workflow);
|
||||||
|
|
||||||
|
let err = step.run(&ctx).await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("host context"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn input_validation_failure() {
|
||||||
|
let host = MockHostContext::new("child-123");
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({"name": 42}), // wrong type
|
||||||
|
input_schema: Some(WorkflowSchema {
|
||||||
|
inputs: HashMap::from([("name".into(), SchemaType::String)]),
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context_with_host(&pointer, &wf_step, &workflow, &host);
|
||||||
|
|
||||||
|
let err = step.run(&ctx).await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("input validation failed"));
|
||||||
|
assert!(host.calls().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn output_validation_failure() {
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({}),
|
||||||
|
output_keys: vec![],
|
||||||
|
output_schema: Some(WorkflowSchema {
|
||||||
|
inputs: HashMap::new(),
|
||||||
|
outputs: HashMap::from([("result".into(), SchemaType::String)]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pointer = ExecutionPointer::new(0);
|
||||||
|
pointer.event_data = Some(json!({
|
||||||
|
"status": "Complete",
|
||||||
|
"data": {"result": 42}
|
||||||
|
}));
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context(&pointer, &wf_step, &workflow);
|
||||||
|
|
||||||
|
let err = step.run(&ctx).await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("output validation failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn input_validation_passes_then_starts_child() {
|
||||||
|
let host = MockHostContext::new("child-456");
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 2,
|
||||||
|
inputs: json!({"name": "Alice"}),
|
||||||
|
input_schema: Some(WorkflowSchema {
|
||||||
|
inputs: HashMap::from([("name".into(), SchemaType::String)]),
|
||||||
|
outputs: HashMap::new(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context_with_host(&pointer, &wf_step, &workflow, &host);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.unwrap();
|
||||||
|
assert!(!result.proceed);
|
||||||
|
assert_eq!(result.event_key.as_deref(), Some("child-456"));
|
||||||
|
assert_eq!(host.calls().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn host_start_workflow_error_propagates() {
|
||||||
|
let host = FailingHostContext;
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context_with_host(&pointer, &wf_step, &workflow, &host);
|
||||||
|
|
||||||
|
let err = step.run(&ctx).await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("failed to start child"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn event_data_without_data_field_returns_empty_output() {
|
||||||
|
let mut step = SubWorkflowStep {
|
||||||
|
workflow_id: "child-def".into(),
|
||||||
|
version: 1,
|
||||||
|
inputs: json!({}),
|
||||||
|
output_keys: vec!["foo".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pointer = ExecutionPointer::new(0);
|
||||||
|
pointer.event_data = Some(json!({"status": "Complete"}));
|
||||||
|
let wf_step = default_step();
|
||||||
|
let workflow = default_workflow();
|
||||||
|
let ctx = make_context(&pointer, &wf_step, &workflow);
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.unwrap();
|
||||||
|
assert!(result.proceed);
|
||||||
|
assert_eq!(result.output_data, Some(json!({})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_step_has_empty_fields() {
|
||||||
|
let step = SubWorkflowStep::default();
|
||||||
|
assert!(step.workflow_id.is_empty());
|
||||||
|
assert_eq!(step.version, 0);
|
||||||
|
assert_eq!(step.inputs, json!(null));
|
||||||
|
assert!(step.output_keys.is_empty());
|
||||||
|
assert!(step.input_schema.is_none());
|
||||||
|
assert!(step.output_schema.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
59
wfe-core/src/traits/log_sink.rs
Normal file
59
wfe-core/src/traits/log_sink.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
/// A chunk of log output from a step execution.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LogChunk {
|
||||||
|
pub workflow_id: String,
|
||||||
|
pub definition_id: String,
|
||||||
|
pub step_id: usize,
|
||||||
|
pub step_name: String,
|
||||||
|
pub stream: LogStreamType,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a log chunk is from stdout or stderr.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LogStreamType {
|
||||||
|
Stdout,
|
||||||
|
Stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receives log chunks as they're produced during step execution.
|
||||||
|
///
|
||||||
|
/// Implementations can broadcast to live subscribers, persist to a database,
|
||||||
|
/// index for search, or any combination. The trait is designed to be called
|
||||||
|
/// from within step executors (shell, containerd, etc.) as lines are produced.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait LogSink: Send + Sync {
|
||||||
|
async fn write_chunk(&self, chunk: LogChunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_stream_type_equality() {
|
||||||
|
assert_eq!(LogStreamType::Stdout, LogStreamType::Stdout);
|
||||||
|
assert_ne!(LogStreamType::Stdout, LogStreamType::Stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_chunk_clone() {
|
||||||
|
let chunk = LogChunk {
|
||||||
|
workflow_id: "wf-1".to_string(),
|
||||||
|
definition_id: "def-1".to_string(),
|
||||||
|
step_id: 0,
|
||||||
|
step_name: "build".to_string(),
|
||||||
|
stream: LogStreamType::Stdout,
|
||||||
|
data: b"hello\n".to_vec(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
let cloned = chunk.clone();
|
||||||
|
assert_eq!(cloned.workflow_id, "wf-1");
|
||||||
|
assert_eq!(cloned.stream, LogStreamType::Stdout);
|
||||||
|
assert_eq!(cloned.data, b"hello\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ mod tests {
|
|||||||
step: &step,
|
step: &step,
|
||||||
workflow: &instance,
|
workflow: &instance,
|
||||||
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
};
|
};
|
||||||
mw.pre_step(&ctx).await.unwrap();
|
mw.pre_step(&ctx).await.unwrap();
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,8 @@ mod tests {
|
|||||||
step: &step,
|
step: &step,
|
||||||
workflow: &instance,
|
workflow: &instance,
|
||||||
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
};
|
};
|
||||||
let result = ExecutionResult::next();
|
let result = ExecutionResult::next();
|
||||||
mw.post_step(&ctx, &result).await.unwrap();
|
mw.post_step(&ctx, &result).await.unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
|
pub mod log_sink;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
@@ -9,6 +10,7 @@ pub mod step;
|
|||||||
|
|
||||||
pub use lifecycle::LifecyclePublisher;
|
pub use lifecycle::LifecyclePublisher;
|
||||||
pub use lock::DistributedLockProvider;
|
pub use lock::DistributedLockProvider;
|
||||||
|
pub use log_sink::{LogChunk, LogSink, LogStreamType};
|
||||||
pub use middleware::{StepMiddleware, WorkflowMiddleware};
|
pub use middleware::{StepMiddleware, WorkflowMiddleware};
|
||||||
pub use persistence::{
|
pub use persistence::{
|
||||||
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
||||||
@@ -17,4 +19,4 @@ pub use persistence::{
|
|||||||
pub use queue::QueueProvider;
|
pub use queue::QueueProvider;
|
||||||
pub use registry::WorkflowRegistry;
|
pub use registry::WorkflowRegistry;
|
||||||
pub use search::{Page, SearchFilter, SearchIndex, WorkflowSearchResult};
|
pub use search::{Page, SearchFilter, SearchIndex, WorkflowSearchResult};
|
||||||
pub use step::{StepBody, StepExecutionContext, WorkflowData};
|
pub use step::{HostContext, StepBody, StepExecutionContext, WorkflowData};
|
||||||
|
|||||||
@@ -11,8 +11,18 @@ pub trait WorkflowData: Serialize + DeserializeOwned + Send + Sync + Clone + 'st
|
|||||||
/// Blanket implementation: any type satisfying the bounds is WorkflowData.
|
/// Blanket implementation: any type satisfying the bounds is WorkflowData.
|
||||||
impl<T> WorkflowData for T where T: Serialize + DeserializeOwned + Send + Sync + Clone + 'static {}
|
impl<T> WorkflowData for T where T: Serialize + DeserializeOwned + Send + Sync + Clone + 'static {}
|
||||||
|
|
||||||
|
/// Context for steps that need to interact with the workflow host.
|
||||||
|
/// Implemented by WorkflowHost to allow steps like SubWorkflow to start child workflows.
|
||||||
|
pub trait HostContext: Send + Sync {
|
||||||
|
fn start_workflow(
|
||||||
|
&self,
|
||||||
|
definition_id: &str,
|
||||||
|
version: u32,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<String>> + Send + '_>>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Context available to a step during execution.
|
/// Context available to a step during execution.
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct StepExecutionContext<'a> {
|
pub struct StepExecutionContext<'a> {
|
||||||
/// The current item when iterating (ForEach).
|
/// The current item when iterating (ForEach).
|
||||||
pub item: Option<&'a serde_json::Value>,
|
pub item: Option<&'a serde_json::Value>,
|
||||||
@@ -26,6 +36,25 @@ pub struct StepExecutionContext<'a> {
|
|||||||
pub workflow: &'a WorkflowInstance,
|
pub workflow: &'a WorkflowInstance,
|
||||||
/// Cancellation token.
|
/// Cancellation token.
|
||||||
pub cancellation_token: tokio_util::sync::CancellationToken,
|
pub cancellation_token: tokio_util::sync::CancellationToken,
|
||||||
|
/// Host context for starting child workflows. None if not available.
|
||||||
|
pub host_context: Option<&'a dyn HostContext>,
|
||||||
|
/// Log sink for streaming step output. None if not configured.
|
||||||
|
pub log_sink: Option<&'a dyn super::LogSink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual Debug impl since dyn HostContext is not Debug.
|
||||||
|
impl<'a> std::fmt::Debug for StepExecutionContext<'a> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("StepExecutionContext")
|
||||||
|
.field("item", &self.item)
|
||||||
|
.field("execution_pointer", &self.execution_pointer)
|
||||||
|
.field("persistence_data", &self.persistence_data)
|
||||||
|
.field("step", &self.step)
|
||||||
|
.field("workflow", &self.workflow)
|
||||||
|
.field("host_context", &self.host_context.is_some())
|
||||||
|
.field("log_sink", &self.log_sink.is_some())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The core unit of work in a workflow. Each step implements this trait.
|
/// The core unit of work in a workflow. Each step implements this trait.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ name = "wfe-opensearch"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "OpenSearch index provider for WFE"
|
description = "OpenSearch index provider for WFE"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ name = "wfe-postgres"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "PostgreSQL persistence provider for WFE"
|
description = "PostgreSQL persistence provider for WFE"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ impl PostgresPersistenceProvider {
|
|||||||
PointerStatus::Pending => "Pending",
|
PointerStatus::Pending => "Pending",
|
||||||
PointerStatus::Running => "Running",
|
PointerStatus::Running => "Running",
|
||||||
PointerStatus::Complete => "Complete",
|
PointerStatus::Complete => "Complete",
|
||||||
|
PointerStatus::Skipped => "Skipped",
|
||||||
PointerStatus::Sleeping => "Sleeping",
|
PointerStatus::Sleeping => "Sleeping",
|
||||||
PointerStatus::WaitingForEvent => "WaitingForEvent",
|
PointerStatus::WaitingForEvent => "WaitingForEvent",
|
||||||
PointerStatus::Failed => "Failed",
|
PointerStatus::Failed => "Failed",
|
||||||
@@ -80,6 +81,7 @@ impl PostgresPersistenceProvider {
|
|||||||
"Pending" => Ok(PointerStatus::Pending),
|
"Pending" => Ok(PointerStatus::Pending),
|
||||||
"Running" => Ok(PointerStatus::Running),
|
"Running" => Ok(PointerStatus::Running),
|
||||||
"Complete" => Ok(PointerStatus::Complete),
|
"Complete" => Ok(PointerStatus::Complete),
|
||||||
|
"Skipped" => Ok(PointerStatus::Skipped),
|
||||||
"Sleeping" => Ok(PointerStatus::Sleeping),
|
"Sleeping" => Ok(PointerStatus::Sleeping),
|
||||||
"WaitingForEvent" => Ok(PointerStatus::WaitingForEvent),
|
"WaitingForEvent" => Ok(PointerStatus::WaitingForEvent),
|
||||||
"Failed" => Ok(PointerStatus::Failed),
|
"Failed" => Ok(PointerStatus::Failed),
|
||||||
|
|||||||
22
wfe-rustlang/Cargo.toml
Normal file
22
wfe-rustlang/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "wfe-rustlang"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "Rust toolchain step executors (cargo, rustup) for WFE"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wfe-core = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
rustdoc-types = "0.38"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["test-util", "process"] }
|
||||||
|
tempfile = { workspace = true }
|
||||||
301
wfe-rustlang/src/cargo/config.rs
Normal file
301
wfe-rustlang/src/cargo/config.rs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Which cargo subcommand to run.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum CargoCommand {
|
||||||
|
Build,
|
||||||
|
Test,
|
||||||
|
Check,
|
||||||
|
Clippy,
|
||||||
|
Fmt,
|
||||||
|
Doc,
|
||||||
|
Publish,
|
||||||
|
Audit,
|
||||||
|
Deny,
|
||||||
|
Nextest,
|
||||||
|
LlvmCov,
|
||||||
|
DocMdx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CargoCommand {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Build => "build",
|
||||||
|
Self::Test => "test",
|
||||||
|
Self::Check => "check",
|
||||||
|
Self::Clippy => "clippy",
|
||||||
|
Self::Fmt => "fmt",
|
||||||
|
Self::Doc => "doc",
|
||||||
|
Self::Publish => "publish",
|
||||||
|
Self::Audit => "audit",
|
||||||
|
Self::Deny => "deny",
|
||||||
|
Self::Nextest => "nextest",
|
||||||
|
Self::LlvmCov => "llvm-cov",
|
||||||
|
Self::DocMdx => "doc-mdx",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the subcommand arg(s) to pass to cargo.
|
||||||
|
/// Most commands are a single arg, but nextest needs "nextest run".
|
||||||
|
/// DocMdx uses `rustdoc` (the actual cargo subcommand).
|
||||||
|
pub fn subcommand_args(&self) -> Vec<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Nextest => vec!["nextest", "run"],
|
||||||
|
Self::DocMdx => vec!["rustdoc"],
|
||||||
|
other => vec![other.as_str()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cargo-install package name if this is an external tool.
|
||||||
|
/// Returns `None` for built-in cargo subcommands.
|
||||||
|
pub fn install_package(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Audit => Some("cargo-audit"),
|
||||||
|
Self::Deny => Some("cargo-deny"),
|
||||||
|
Self::Nextest => Some("cargo-nextest"),
|
||||||
|
Self::LlvmCov => Some("cargo-llvm-cov"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the binary name to probe for availability.
|
||||||
|
pub fn binary_name(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Audit => Some("cargo-audit"),
|
||||||
|
Self::Deny => Some("cargo-deny"),
|
||||||
|
Self::Nextest => Some("cargo-nextest"),
|
||||||
|
Self::LlvmCov => Some("cargo-llvm-cov"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared configuration for all cargo step types.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CargoConfig {
|
||||||
|
pub command: CargoCommand,
|
||||||
|
/// Rust toolchain override (e.g. "nightly", "1.78.0").
|
||||||
|
#[serde(default)]
|
||||||
|
pub toolchain: Option<String>,
|
||||||
|
/// Target package (`-p`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub package: Option<String>,
|
||||||
|
/// Features to enable (`--features`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub features: Vec<String>,
|
||||||
|
/// Enable all features (`--all-features`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub all_features: bool,
|
||||||
|
/// Disable default features (`--no-default-features`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub no_default_features: bool,
|
||||||
|
/// Build in release mode (`--release`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub release: bool,
|
||||||
|
/// Compilation target triple (`--target`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub target: Option<String>,
|
||||||
|
/// Build profile (`--profile`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: Option<String>,
|
||||||
|
/// Additional arguments appended to the command.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_args: Vec<String>,
|
||||||
|
/// Environment variables.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: HashMap<String, String>,
|
||||||
|
/// Working directory.
|
||||||
|
#[serde(default)]
|
||||||
|
pub working_dir: Option<String>,
|
||||||
|
/// Execution timeout in milliseconds.
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
/// Output directory for generated files (e.g., MDX docs).
|
||||||
|
#[serde(default)]
|
||||||
|
pub output_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_minimal() {
|
||||||
|
let config = CargoConfig {
|
||||||
|
command: CargoCommand::Build,
|
||||||
|
toolchain: None,
|
||||||
|
package: None,
|
||||||
|
features: vec![],
|
||||||
|
all_features: false,
|
||||||
|
no_default_features: false,
|
||||||
|
release: false,
|
||||||
|
target: None,
|
||||||
|
profile: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
env: HashMap::new(),
|
||||||
|
working_dir: None,
|
||||||
|
timeout_ms: None,
|
||||||
|
output_dir: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let de: CargoConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(de.command, CargoCommand::Build);
|
||||||
|
assert!(de.features.is_empty());
|
||||||
|
assert!(!de.release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_full() {
|
||||||
|
let mut env = HashMap::new();
|
||||||
|
env.insert("RUSTFLAGS".to_string(), "-D warnings".to_string());
|
||||||
|
|
||||||
|
let config = CargoConfig {
|
||||||
|
command: CargoCommand::Clippy,
|
||||||
|
toolchain: Some("nightly".to_string()),
|
||||||
|
package: Some("my-crate".to_string()),
|
||||||
|
features: vec!["feat1".to_string(), "feat2".to_string()],
|
||||||
|
all_features: false,
|
||||||
|
no_default_features: true,
|
||||||
|
release: true,
|
||||||
|
target: Some("x86_64-unknown-linux-gnu".to_string()),
|
||||||
|
profile: None,
|
||||||
|
extra_args: vec!["--".to_string(), "-D".to_string(), "warnings".to_string()],
|
||||||
|
env,
|
||||||
|
working_dir: Some("/src".to_string()),
|
||||||
|
timeout_ms: Some(60_000),
|
||||||
|
output_dir: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let de: CargoConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(de.command, CargoCommand::Clippy);
|
||||||
|
assert_eq!(de.toolchain, Some("nightly".to_string()));
|
||||||
|
assert_eq!(de.package, Some("my-crate".to_string()));
|
||||||
|
assert_eq!(de.features, vec!["feat1", "feat2"]);
|
||||||
|
assert!(de.no_default_features);
|
||||||
|
assert!(de.release);
|
||||||
|
assert_eq!(de.extra_args, vec!["--", "-D", "warnings"]);
|
||||||
|
assert_eq!(de.timeout_ms, Some(60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_as_str() {
|
||||||
|
assert_eq!(CargoCommand::Build.as_str(), "build");
|
||||||
|
assert_eq!(CargoCommand::Test.as_str(), "test");
|
||||||
|
assert_eq!(CargoCommand::Check.as_str(), "check");
|
||||||
|
assert_eq!(CargoCommand::Clippy.as_str(), "clippy");
|
||||||
|
assert_eq!(CargoCommand::Fmt.as_str(), "fmt");
|
||||||
|
assert_eq!(CargoCommand::Doc.as_str(), "doc");
|
||||||
|
assert_eq!(CargoCommand::Publish.as_str(), "publish");
|
||||||
|
assert_eq!(CargoCommand::Audit.as_str(), "audit");
|
||||||
|
assert_eq!(CargoCommand::Deny.as_str(), "deny");
|
||||||
|
assert_eq!(CargoCommand::Nextest.as_str(), "nextest");
|
||||||
|
assert_eq!(CargoCommand::LlvmCov.as_str(), "llvm-cov");
|
||||||
|
assert_eq!(CargoCommand::DocMdx.as_str(), "doc-mdx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_serde_kebab_case() {
|
||||||
|
let json = r#""build""#;
|
||||||
|
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(cmd, CargoCommand::Build);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&CargoCommand::Build).unwrap();
|
||||||
|
assert_eq!(serialized, r#""build""#);
|
||||||
|
|
||||||
|
// External tools
|
||||||
|
let json = r#""llvm-cov""#;
|
||||||
|
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(cmd, CargoCommand::LlvmCov);
|
||||||
|
|
||||||
|
let json = r#""nextest""#;
|
||||||
|
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(cmd, CargoCommand::Nextest);
|
||||||
|
|
||||||
|
let json = r#""doc-mdx""#;
|
||||||
|
let cmd: CargoCommand = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(cmd, CargoCommand::DocMdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_args_single() {
|
||||||
|
assert_eq!(CargoCommand::Build.subcommand_args(), vec!["build"]);
|
||||||
|
assert_eq!(CargoCommand::Audit.subcommand_args(), vec!["audit"]);
|
||||||
|
assert_eq!(CargoCommand::LlvmCov.subcommand_args(), vec!["llvm-cov"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_args_nextest_has_run() {
|
||||||
|
assert_eq!(CargoCommand::Nextest.subcommand_args(), vec!["nextest", "run"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subcommand_args_doc_mdx_uses_rustdoc() {
|
||||||
|
assert_eq!(CargoCommand::DocMdx.subcommand_args(), vec!["rustdoc"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_package_external_tools() {
|
||||||
|
assert_eq!(CargoCommand::Audit.install_package(), Some("cargo-audit"));
|
||||||
|
assert_eq!(CargoCommand::Deny.install_package(), Some("cargo-deny"));
|
||||||
|
assert_eq!(CargoCommand::Nextest.install_package(), Some("cargo-nextest"));
|
||||||
|
assert_eq!(CargoCommand::LlvmCov.install_package(), Some("cargo-llvm-cov"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_package_builtin_returns_none() {
|
||||||
|
assert_eq!(CargoCommand::Build.install_package(), None);
|
||||||
|
assert_eq!(CargoCommand::Test.install_package(), None);
|
||||||
|
assert_eq!(CargoCommand::Check.install_package(), None);
|
||||||
|
assert_eq!(CargoCommand::Clippy.install_package(), None);
|
||||||
|
assert_eq!(CargoCommand::Fmt.install_package(), None);
|
||||||
|
assert_eq!(CargoCommand::Doc.install_package(), None);
|
||||||
|
assert_eq!(CargoCommand::Publish.install_package(), None);
|
||||||
|
assert_eq!(CargoCommand::DocMdx.install_package(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_name_external_tools() {
|
||||||
|
assert_eq!(CargoCommand::Audit.binary_name(), Some("cargo-audit"));
|
||||||
|
assert_eq!(CargoCommand::Deny.binary_name(), Some("cargo-deny"));
|
||||||
|
assert_eq!(CargoCommand::Nextest.binary_name(), Some("cargo-nextest"));
|
||||||
|
assert_eq!(CargoCommand::LlvmCov.binary_name(), Some("cargo-llvm-cov"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_name_builtin_returns_none() {
|
||||||
|
assert_eq!(CargoCommand::Build.binary_name(), None);
|
||||||
|
assert_eq!(CargoCommand::Test.binary_name(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_defaults() {
|
||||||
|
let json = r#"{"command": "test"}"#;
|
||||||
|
let config: CargoConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.command, CargoCommand::Test);
|
||||||
|
assert!(config.toolchain.is_none());
|
||||||
|
assert!(config.package.is_none());
|
||||||
|
assert!(config.features.is_empty());
|
||||||
|
assert!(!config.all_features);
|
||||||
|
assert!(!config.no_default_features);
|
||||||
|
assert!(!config.release);
|
||||||
|
assert!(config.target.is_none());
|
||||||
|
assert!(config.profile.is_none());
|
||||||
|
assert!(config.extra_args.is_empty());
|
||||||
|
assert!(config.env.is_empty());
|
||||||
|
assert!(config.working_dir.is_none());
|
||||||
|
assert!(config.timeout_ms.is_none());
|
||||||
|
assert!(config.output_dir.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_with_output_dir() {
|
||||||
|
let json = r#"{"command": "doc-mdx", "output_dir": "docs/api"}"#;
|
||||||
|
let config: CargoConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.command, CargoCommand::DocMdx);
|
||||||
|
assert_eq!(config.output_dir, Some("docs/api".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
5
wfe-rustlang/src/cargo/mod.rs
Normal file
5
wfe-rustlang/src/cargo/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod step;
|
||||||
|
|
||||||
|
pub use config::{CargoCommand, CargoConfig};
|
||||||
|
pub use step::CargoStep;
|
||||||
532
wfe-rustlang/src/cargo/step.rs
Normal file
532
wfe-rustlang/src/cargo/step.rs
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use wfe_core::models::ExecutionResult;
|
||||||
|
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||||
|
use wfe_core::WfeError;
|
||||||
|
|
||||||
|
use crate::cargo::config::{CargoCommand, CargoConfig};
|
||||||
|
|
||||||
|
pub struct CargoStep {
|
||||||
|
config: CargoConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CargoStep {
|
||||||
|
pub fn new(config: CargoConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_command(&self) -> tokio::process::Command {
|
||||||
|
// DocMdx requires nightly for --output-format json.
|
||||||
|
let toolchain = if matches!(self.config.command, CargoCommand::DocMdx) {
|
||||||
|
Some(self.config.toolchain.as_deref().unwrap_or("nightly"))
|
||||||
|
} else {
|
||||||
|
self.config.toolchain.as_deref()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cmd = if let Some(tc) = toolchain {
|
||||||
|
let mut c = tokio::process::Command::new("rustup");
|
||||||
|
c.args(["run", tc, "cargo"]);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
tokio::process::Command::new("cargo")
|
||||||
|
};
|
||||||
|
|
||||||
|
for arg in self.config.command.subcommand_args() {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref pkg) = self.config.package {
|
||||||
|
cmd.args(["-p", pkg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.config.features.is_empty() {
|
||||||
|
cmd.args(["--features", &self.config.features.join(",")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.all_features {
|
||||||
|
cmd.arg("--all-features");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.no_default_features {
|
||||||
|
cmd.arg("--no-default-features");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.release {
|
||||||
|
cmd.arg("--release");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref target) = self.config.target {
|
||||||
|
cmd.args(["--target", target]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref profile) = self.config.profile {
|
||||||
|
cmd.args(["--profile", profile]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in &self.config.extra_args {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocMdx appends rustdoc-specific flags after user extra_args.
|
||||||
|
if matches!(self.config.command, CargoCommand::DocMdx) {
|
||||||
|
cmd.args(["--", "-Z", "unstable-options", "--output-format", "json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value) in &self.config.env {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref dir) = self.config.working_dir {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures an external cargo tool is installed before running it.
|
||||||
|
/// For built-in cargo subcommands, this is a no-op.
|
||||||
|
async fn ensure_tool_available(&self) -> Result<(), WfeError> {
|
||||||
|
let (binary, package) = match (self.config.command.binary_name(), self.config.command.install_package()) {
|
||||||
|
(Some(b), Some(p)) => (b, p),
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probe for the binary.
|
||||||
|
let probe = tokio::process::Command::new(binary)
|
||||||
|
.arg("--version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(status) = probe {
|
||||||
|
if status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(package = package, "cargo tool not found, installing");
|
||||||
|
|
||||||
|
// For llvm-cov, ensure the rustup component is present first.
|
||||||
|
if matches!(self.config.command, CargoCommand::LlvmCov) {
|
||||||
|
let component = tokio::process::Command::new("rustup")
|
||||||
|
.args(["component", "add", "llvm-tools-preview"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| WfeError::StepExecution(format!(
|
||||||
|
"Failed to add llvm-tools-preview component: {e}"
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
if !component.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&component.stderr);
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"Failed to add llvm-tools-preview component: {stderr}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let install = tokio::process::Command::new("cargo")
|
||||||
|
.args(["install", package])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| WfeError::StepExecution(format!(
|
||||||
|
"Failed to install {package}: {e}"
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
if !install.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&install.stderr);
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"Failed to install {package}: {stderr}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(package = package, "cargo tool installed successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post-process rustdoc JSON output into MDX files.
|
||||||
|
fn transform_rustdoc_json(
|
||||||
|
&self,
|
||||||
|
outputs: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
) -> Result<(), WfeError> {
|
||||||
|
use crate::rustdoc::transformer::{transform_to_mdx, write_mdx_files};
|
||||||
|
|
||||||
|
// Find the JSON file in target/doc/.
|
||||||
|
let working_dir = self.config.working_dir.as_deref().unwrap_or(".");
|
||||||
|
let doc_dir = std::path::Path::new(working_dir).join("target/doc");
|
||||||
|
|
||||||
|
let json_path = std::fs::read_dir(&doc_dir)
|
||||||
|
.map_err(|e| WfeError::StepExecution(format!(
|
||||||
|
"failed to read target/doc: {e}"
|
||||||
|
)))?
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.find(|entry| {
|
||||||
|
entry.path().extension().is_some_and(|ext| ext == "json")
|
||||||
|
})
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.ok_or_else(|| WfeError::StepExecution(
|
||||||
|
"no JSON file found in target/doc/ — did rustdoc --output-format json succeed?".to_string()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
tracing::info!(path = %json_path.display(), "reading rustdoc JSON");
|
||||||
|
|
||||||
|
let json_content = std::fs::read_to_string(&json_path).map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("failed to read {}: {e}", json_path.display()))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let krate: rustdoc_types::Crate = serde_json::from_str(&json_content).map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("failed to parse rustdoc JSON: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mdx_files = transform_to_mdx(&krate);
|
||||||
|
|
||||||
|
let output_dir = self.config.output_dir
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("target/doc/mdx");
|
||||||
|
let output_path = std::path::Path::new(working_dir).join(output_dir);
|
||||||
|
|
||||||
|
write_mdx_files(&mdx_files, &output_path).map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("failed to write MDX files: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let file_count = mdx_files.len();
|
||||||
|
tracing::info!(
|
||||||
|
output_dir = %output_path.display(),
|
||||||
|
file_count,
|
||||||
|
"generated MDX documentation"
|
||||||
|
);
|
||||||
|
|
||||||
|
outputs.insert(
|
||||||
|
"mdx.output_dir".to_string(),
|
||||||
|
serde_json::Value::String(output_path.to_string_lossy().to_string()),
|
||||||
|
);
|
||||||
|
outputs.insert(
|
||||||
|
"mdx.file_count".to_string(),
|
||||||
|
serde_json::Value::Number(file_count.into()),
|
||||||
|
);
|
||||||
|
let file_paths: Vec<_> = mdx_files.iter().map(|f| f.path.clone()).collect();
|
||||||
|
outputs.insert(
|
||||||
|
"mdx.files".to_string(),
|
||||||
|
serde_json::Value::Array(
|
||||||
|
file_paths.into_iter().map(serde_json::Value::String).collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StepBody for CargoStep {
|
||||||
|
async fn run(&mut self, context: &StepExecutionContext<'_>) -> wfe_core::Result<ExecutionResult> {
|
||||||
|
let step_name = context.step.name.as_deref().unwrap_or("unknown");
|
||||||
|
let subcmd = self.config.command.as_str();
|
||||||
|
|
||||||
|
// Ensure external tools are installed before running.
|
||||||
|
self.ensure_tool_available().await?;
|
||||||
|
|
||||||
|
tracing::info!(step = step_name, command = subcmd, "running cargo");
|
||||||
|
|
||||||
|
let mut cmd = self.build_command();
|
||||||
|
|
||||||
|
let output = if let Some(timeout_ms) = self.config.timeout_ms {
|
||||||
|
let duration = std::time::Duration::from_millis(timeout_ms);
|
||||||
|
match tokio::time::timeout(duration, cmd.output()).await {
|
||||||
|
Ok(result) => result.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("Failed to spawn cargo {subcmd}: {e}"))
|
||||||
|
})?,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"cargo {subcmd} timed out after {timeout_ms}ms"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| WfeError::StepExecution(format!("Failed to spawn cargo {subcmd}: {e}")))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let code = output.status.code().unwrap_or(-1);
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"cargo {subcmd} exited with code {code}\nstdout: {stdout}\nstderr: {stderr}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut outputs = serde_json::Map::new();
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.stdout"),
|
||||||
|
serde_json::Value::String(stdout),
|
||||||
|
);
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.stderr"),
|
||||||
|
serde_json::Value::String(stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
// DocMdx post-processing: transform rustdoc JSON → MDX files.
|
||||||
|
if matches!(self.config.command, CargoCommand::DocMdx) {
|
||||||
|
self.transform_rustdoc_json(&mut outputs)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ExecutionResult {
|
||||||
|
proceed: true,
|
||||||
|
output_data: Some(serde_json::Value::Object(outputs)),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cargo::config::{CargoCommand, CargoConfig};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn minimal_config(command: CargoCommand) -> CargoConfig {
|
||||||
|
CargoConfig {
|
||||||
|
command,
|
||||||
|
toolchain: None,
|
||||||
|
package: None,
|
||||||
|
features: vec![],
|
||||||
|
all_features: false,
|
||||||
|
no_default_features: false,
|
||||||
|
release: false,
|
||||||
|
target: None,
|
||||||
|
profile: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
env: HashMap::new(),
|
||||||
|
working_dir: None,
|
||||||
|
timeout_ms: None,
|
||||||
|
output_dir: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_minimal() {
|
||||||
|
let step = CargoStep::new(minimal_config(CargoCommand::Build));
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let prog = cmd.as_std().get_program().to_str().unwrap();
|
||||||
|
assert_eq!(prog, "cargo");
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["build"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_with_toolchain() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Test);
|
||||||
|
config.toolchain = Some("nightly".to_string());
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let prog = cmd.as_std().get_program().to_str().unwrap();
|
||||||
|
assert_eq!(prog, "rustup");
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["run", "nightly", "cargo", "test"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_with_package_and_features() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Check);
|
||||||
|
config.package = Some("my-crate".to_string());
|
||||||
|
config.features = vec!["feat1".to_string(), "feat2".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["check", "-p", "my-crate", "--features", "feat1,feat2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_release_and_target() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Build);
|
||||||
|
config.release = true;
|
||||||
|
config.target = Some("aarch64-unknown-linux-gnu".to_string());
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["build", "--release", "--target", "aarch64-unknown-linux-gnu"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_all_flags() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Clippy);
|
||||||
|
config.all_features = true;
|
||||||
|
config.no_default_features = true;
|
||||||
|
config.profile = Some("dev".to_string());
|
||||||
|
config.extra_args = vec!["--".to_string(), "-D".to_string(), "warnings".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec!["clippy", "--all-features", "--no-default-features", "--profile", "dev", "--", "-D", "warnings"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_fmt() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Fmt);
|
||||||
|
config.extra_args = vec!["--check".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["fmt", "--check"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_publish_dry_run() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Publish);
|
||||||
|
config.extra_args = vec!["--dry-run".to_string(), "--registry".to_string(), "my-reg".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["publish", "--dry-run", "--registry", "my-reg"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_doc() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Doc);
|
||||||
|
config.extra_args = vec!["--no-deps".to_string()];
|
||||||
|
config.release = true;
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["doc", "--release", "--no-deps"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_env_vars() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Build);
|
||||||
|
config.env.insert("RUSTFLAGS".to_string(), "-D warnings".to_string());
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let envs: Vec<_> = cmd.as_std().get_envs().collect();
|
||||||
|
assert!(envs.iter().any(|(k, v)| *k == "RUSTFLAGS" && v == &Some("-D warnings".as_ref())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_working_dir() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Test);
|
||||||
|
config.working_dir = Some("/my/project".to_string());
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
assert_eq!(cmd.as_std().get_current_dir(), Some(std::path::Path::new("/my/project")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_audit() {
|
||||||
|
let step = CargoStep::new(minimal_config(CargoCommand::Audit));
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["audit"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_deny() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Deny);
|
||||||
|
config.extra_args = vec!["check".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["deny", "check"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_nextest() {
|
||||||
|
let step = CargoStep::new(minimal_config(CargoCommand::Nextest));
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["nextest", "run"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_nextest_with_features() {
|
||||||
|
let mut config = minimal_config(CargoCommand::Nextest);
|
||||||
|
config.features = vec!["feat1".to_string()];
|
||||||
|
config.extra_args = vec!["--no-fail-fast".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["nextest", "run", "--features", "feat1", "--no-fail-fast"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_llvm_cov() {
|
||||||
|
let step = CargoStep::new(minimal_config(CargoCommand::LlvmCov));
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["llvm-cov"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_llvm_cov_with_args() {
|
||||||
|
let mut config = minimal_config(CargoCommand::LlvmCov);
|
||||||
|
config.extra_args = vec!["--html".to_string(), "--output-dir".to_string(), "coverage".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["llvm-cov", "--html", "--output-dir", "coverage"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_doc_mdx_forces_nightly() {
|
||||||
|
let step = CargoStep::new(minimal_config(CargoCommand::DocMdx));
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let prog = cmd.as_std().get_program().to_str().unwrap();
|
||||||
|
assert_eq!(prog, "rustup");
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec!["run", "nightly", "cargo", "rustdoc", "--", "-Z", "unstable-options", "--output-format", "json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_doc_mdx_with_package() {
|
||||||
|
let mut config = minimal_config(CargoCommand::DocMdx);
|
||||||
|
config.package = Some("my-crate".to_string());
|
||||||
|
config.extra_args = vec!["--no-deps".to_string()];
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec!["run", "nightly", "cargo", "rustdoc", "-p", "my-crate", "--no-deps", "--", "-Z", "unstable-options", "--output-format", "json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_command_doc_mdx_custom_toolchain() {
|
||||||
|
let mut config = minimal_config(CargoCommand::DocMdx);
|
||||||
|
config.toolchain = Some("nightly-2024-06-01".to_string());
|
||||||
|
let step = CargoStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert!(args.contains(&"nightly-2024-06-01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_tool_builtin_is_noop() {
|
||||||
|
let step = CargoStep::new(minimal_config(CargoCommand::Build));
|
||||||
|
// Should return Ok immediately for built-in commands.
|
||||||
|
step.ensure_tool_available().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_tool_already_installed_succeeds() {
|
||||||
|
// cargo-audit/nextest/etc may or may not be installed,
|
||||||
|
// but we can test with a known-installed tool: cargo itself
|
||||||
|
// is always available. Test the flow by verifying the
|
||||||
|
// built-in path returns Ok.
|
||||||
|
let step = CargoStep::new(minimal_config(CargoCommand::Check));
|
||||||
|
step.ensure_tool_available().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
6
wfe-rustlang/src/lib.rs
Normal file
6
wfe-rustlang/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod cargo;
|
||||||
|
pub mod rustdoc;
|
||||||
|
pub mod rustup;
|
||||||
|
|
||||||
|
pub use cargo::{CargoCommand, CargoConfig, CargoStep};
|
||||||
|
pub use rustup::{RustupCommand, RustupConfig, RustupStep};
|
||||||
3
wfe-rustlang/src/rustdoc/mod.rs
Normal file
3
wfe-rustlang/src/rustdoc/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod transformer;
|
||||||
|
|
||||||
|
pub use transformer::transform_to_mdx;
|
||||||
847
wfe-rustlang/src/rustdoc/transformer.rs
Normal file
847
wfe-rustlang/src/rustdoc/transformer.rs
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use rustdoc_types::{Crate, Id, Item, ItemEnum, Type};
|
||||||
|
|
||||||
|
/// A generated MDX file with its relative path and content.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MdxFile {
|
||||||
|
/// Relative path (e.g., `my_crate/utils.mdx`).
|
||||||
|
pub path: String,
|
||||||
|
/// MDX content.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transform a rustdoc JSON `Crate` into a set of MDX files.
|
||||||
|
///
|
||||||
|
/// Generates one MDX file per module, with all items in that module
|
||||||
|
/// grouped by kind (structs, enums, functions, traits, etc.).
|
||||||
|
pub fn transform_to_mdx(krate: &Crate) -> Vec<MdxFile> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut module_items: HashMap<String, Vec<(&Item, &str)>> = HashMap::new();
|
||||||
|
|
||||||
|
for (id, item) in &krate.index {
|
||||||
|
let module_path = resolve_module_path(krate, id);
|
||||||
|
let kind_label = item_kind_label(&item.inner);
|
||||||
|
if let Some(label) = kind_label {
|
||||||
|
module_items
|
||||||
|
.entry(module_path)
|
||||||
|
.or_default()
|
||||||
|
.push((item, label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths: Vec<_> = module_items.keys().cloned().collect();
|
||||||
|
paths.sort();
|
||||||
|
|
||||||
|
for module_path in paths {
|
||||||
|
let items = &module_items[&module_path];
|
||||||
|
let content = render_module(&module_path, items, krate);
|
||||||
|
let file_path = if module_path.is_empty() {
|
||||||
|
"index.mdx".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}.mdx", module_path.replace("::", "/"))
|
||||||
|
};
|
||||||
|
files.push(MdxFile {
|
||||||
|
path: file_path,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write MDX files to the output directory.
|
||||||
|
pub fn write_mdx_files(files: &[MdxFile], output_dir: &Path) -> std::io::Result<()> {
|
||||||
|
for file in files {
|
||||||
|
let path = output_dir.join(&file.path);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, &file.content)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_module_path(krate: &Crate, id: &Id) -> String {
|
||||||
|
if let Some(summary) = krate.paths.get(id) {
|
||||||
|
let path = &summary.path;
|
||||||
|
if path.len() > 1 {
|
||||||
|
path[..path.len() - 1].join("::")
|
||||||
|
} else if !path.is_empty() {
|
||||||
|
path[0].clone()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_kind_label(inner: &ItemEnum) -> Option<&'static str> {
|
||||||
|
match inner {
|
||||||
|
ItemEnum::Module(_) => Some("Modules"),
|
||||||
|
ItemEnum::Struct(_) => Some("Structs"),
|
||||||
|
ItemEnum::Enum(_) => Some("Enums"),
|
||||||
|
ItemEnum::Function(_) => Some("Functions"),
|
||||||
|
ItemEnum::Trait(_) => Some("Traits"),
|
||||||
|
ItemEnum::TypeAlias(_) => Some("Type Aliases"),
|
||||||
|
ItemEnum::Constant { .. } => Some("Constants"),
|
||||||
|
ItemEnum::Static(_) => Some("Statics"),
|
||||||
|
ItemEnum::Macro(_) => Some("Macros"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_module(module_path: &str, items: &[(&Item, &str)], krate: &Crate) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
let title = if module_path.is_empty() {
|
||||||
|
krate
|
||||||
|
.index
|
||||||
|
.get(&krate.root)
|
||||||
|
.and_then(|i| i.name.clone())
|
||||||
|
.unwrap_or_else(|| "crate".to_string())
|
||||||
|
} else {
|
||||||
|
module_path.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let description = if module_path.is_empty() {
|
||||||
|
krate
|
||||||
|
.index
|
||||||
|
.get(&krate.root)
|
||||||
|
.and_then(|i| i.docs.as_ref())
|
||||||
|
.map(|d| first_sentence(d))
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.find(|(item, kind)| {
|
||||||
|
*kind == "Modules"
|
||||||
|
&& item.name.as_deref() == module_path.split("::").last()
|
||||||
|
})
|
||||||
|
.and_then(|(item, _)| item.docs.as_ref())
|
||||||
|
.map(|d| first_sentence(d))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
out.push_str(&format!(
|
||||||
|
"---\ntitle: \"{title}\"\ndescription: \"{}\"\n---\n\n",
|
||||||
|
description.replace('"', "\\\"")
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut by_kind: HashMap<&str, Vec<&Item>> = HashMap::new();
|
||||||
|
for (item, kind) in items {
|
||||||
|
by_kind.entry(kind).or_default().push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind_order = [
|
||||||
|
"Modules", "Structs", "Enums", "Traits", "Functions",
|
||||||
|
"Type Aliases", "Constants", "Statics", "Macros",
|
||||||
|
];
|
||||||
|
|
||||||
|
for kind in &kind_order {
|
||||||
|
if let Some(kind_items) = by_kind.get(kind) {
|
||||||
|
let mut sorted: Vec<_> = kind_items.iter().collect();
|
||||||
|
sorted.sort_by_key(|item| &item.name);
|
||||||
|
|
||||||
|
out.push_str(&format!("## {kind}\n\n"));
|
||||||
|
|
||||||
|
for item in sorted {
|
||||||
|
render_item(&mut out, item, krate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_item(out: &mut String, item: &Item, krate: &Crate) {
|
||||||
|
let name = item.name.as_deref().unwrap_or("_");
|
||||||
|
|
||||||
|
out.push_str(&format!("### `{name}`\n\n"));
|
||||||
|
|
||||||
|
if let Some(sig) = render_signature(item, krate) {
|
||||||
|
out.push_str("```rust\n");
|
||||||
|
out.push_str(&sig);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str("```\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref docs) = item.docs {
|
||||||
|
out.push_str(docs);
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_signature(item: &Item, krate: &Crate) -> Option<String> {
|
||||||
|
let name = item.name.as_deref()?;
|
||||||
|
match &item.inner {
|
||||||
|
ItemEnum::Function(f) => {
|
||||||
|
let mut sig = String::new();
|
||||||
|
if f.header.is_const {
|
||||||
|
sig.push_str("const ");
|
||||||
|
}
|
||||||
|
if f.header.is_async {
|
||||||
|
sig.push_str("async ");
|
||||||
|
}
|
||||||
|
if f.header.is_unsafe {
|
||||||
|
sig.push_str("unsafe ");
|
||||||
|
}
|
||||||
|
sig.push_str("fn ");
|
||||||
|
sig.push_str(name);
|
||||||
|
if !f.generics.params.is_empty() {
|
||||||
|
sig.push('<');
|
||||||
|
let params: Vec<_> = f.generics.params.iter().map(|p| p.name.clone()).collect();
|
||||||
|
sig.push_str(¶ms.join(", "));
|
||||||
|
sig.push('>');
|
||||||
|
}
|
||||||
|
sig.push('(');
|
||||||
|
let params: Vec<_> = f
|
||||||
|
.sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.map(|(pname, ty)| format!("{pname}: {}", render_type(ty, krate)))
|
||||||
|
.collect();
|
||||||
|
sig.push_str(¶ms.join(", "));
|
||||||
|
sig.push(')');
|
||||||
|
if let Some(ref output) = f.sig.output {
|
||||||
|
sig.push_str(&format!(" -> {}", render_type(output, krate)));
|
||||||
|
}
|
||||||
|
Some(sig)
|
||||||
|
}
|
||||||
|
ItemEnum::Struct(s) => {
|
||||||
|
let mut sig = String::from("pub struct ");
|
||||||
|
sig.push_str(name);
|
||||||
|
if !s.generics.params.is_empty() {
|
||||||
|
sig.push('<');
|
||||||
|
let params: Vec<_> = s.generics.params.iter().map(|p| p.name.clone()).collect();
|
||||||
|
sig.push_str(¶ms.join(", "));
|
||||||
|
sig.push('>');
|
||||||
|
}
|
||||||
|
match &s.kind {
|
||||||
|
rustdoc_types::StructKind::Unit => sig.push(';'),
|
||||||
|
rustdoc_types::StructKind::Tuple(_) => sig.push_str("(...)"),
|
||||||
|
rustdoc_types::StructKind::Plain { fields, .. } => {
|
||||||
|
sig.push_str(" { ");
|
||||||
|
let field_names: Vec<_> = fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|fid| krate.index.get(fid))
|
||||||
|
.filter_map(|f| f.name.as_deref())
|
||||||
|
.collect();
|
||||||
|
sig.push_str(&field_names.join(", "));
|
||||||
|
sig.push_str(" }");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(sig)
|
||||||
|
}
|
||||||
|
ItemEnum::Enum(e) => {
|
||||||
|
let mut sig = String::from("pub enum ");
|
||||||
|
sig.push_str(name);
|
||||||
|
if !e.generics.params.is_empty() {
|
||||||
|
sig.push('<');
|
||||||
|
let params: Vec<_> = e.generics.params.iter().map(|p| p.name.clone()).collect();
|
||||||
|
sig.push_str(¶ms.join(", "));
|
||||||
|
sig.push('>');
|
||||||
|
}
|
||||||
|
sig.push_str(" { ");
|
||||||
|
let variant_names: Vec<_> = e
|
||||||
|
.variants
|
||||||
|
.iter()
|
||||||
|
.filter_map(|vid| krate.index.get(vid))
|
||||||
|
.filter_map(|v| v.name.as_deref())
|
||||||
|
.collect();
|
||||||
|
sig.push_str(&variant_names.join(", "));
|
||||||
|
sig.push_str(" }");
|
||||||
|
Some(sig)
|
||||||
|
}
|
||||||
|
ItemEnum::Trait(t) => {
|
||||||
|
let mut sig = String::from("pub trait ");
|
||||||
|
sig.push_str(name);
|
||||||
|
if !t.generics.params.is_empty() {
|
||||||
|
sig.push('<');
|
||||||
|
let params: Vec<_> = t.generics.params.iter().map(|p| p.name.clone()).collect();
|
||||||
|
sig.push_str(¶ms.join(", "));
|
||||||
|
sig.push('>');
|
||||||
|
}
|
||||||
|
Some(sig)
|
||||||
|
}
|
||||||
|
ItemEnum::TypeAlias(ta) => {
|
||||||
|
Some(format!("pub type {name} = {}", render_type(&ta.type_, krate)))
|
||||||
|
}
|
||||||
|
ItemEnum::Constant { type_, const_: c } => {
|
||||||
|
Some(format!(
|
||||||
|
"pub const {name}: {} = {}",
|
||||||
|
render_type(type_, krate),
|
||||||
|
c.value.as_deref().unwrap_or("...")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ItemEnum::Macro(_) => Some(format!("macro_rules! {name} {{ ... }}")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_type(ty: &Type, krate: &Crate) -> String {
|
||||||
|
match ty {
|
||||||
|
Type::ResolvedPath(p) => {
|
||||||
|
let mut s = p.path.clone();
|
||||||
|
if let Some(ref args) = p.args {
|
||||||
|
if let rustdoc_types::GenericArgs::AngleBracketed { args, .. } = args.as_ref() {
|
||||||
|
if !args.is_empty() {
|
||||||
|
s.push('<');
|
||||||
|
let rendered: Vec<_> = args
|
||||||
|
.iter()
|
||||||
|
.map(|a| match a {
|
||||||
|
rustdoc_types::GenericArg::Type(t) => render_type(t, krate),
|
||||||
|
rustdoc_types::GenericArg::Lifetime(l) => l.clone(),
|
||||||
|
rustdoc_types::GenericArg::Const(c) => {
|
||||||
|
c.value.clone().unwrap_or_else(|| c.expr.clone())
|
||||||
|
}
|
||||||
|
rustdoc_types::GenericArg::Infer => "_".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
s.push_str(&rendered.join(", "));
|
||||||
|
s.push('>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Type::Generic(name) => name.clone(),
|
||||||
|
Type::Primitive(name) => name.clone(),
|
||||||
|
Type::BorrowedRef { lifetime, is_mutable, type_ } => {
|
||||||
|
let mut s = String::from("&");
|
||||||
|
if let Some(lt) = lifetime {
|
||||||
|
s.push_str(lt);
|
||||||
|
s.push(' ');
|
||||||
|
}
|
||||||
|
if *is_mutable {
|
||||||
|
s.push_str("mut ");
|
||||||
|
}
|
||||||
|
s.push_str(&render_type(type_, krate));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Type::Tuple(types) => {
|
||||||
|
let inner: Vec<_> = types.iter().map(|t| render_type(t, krate)).collect();
|
||||||
|
format!("({})", inner.join(", "))
|
||||||
|
}
|
||||||
|
Type::Slice(ty) => format!("[{}]", render_type(ty, krate)),
|
||||||
|
Type::Array { type_, len } => format!("[{}; {}]", render_type(type_, krate), len),
|
||||||
|
Type::RawPointer { is_mutable, type_ } => {
|
||||||
|
if *is_mutable {
|
||||||
|
format!("*mut {}", render_type(type_, krate))
|
||||||
|
} else {
|
||||||
|
format!("*const {}", render_type(type_, krate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Type::ImplTrait(bounds) => {
|
||||||
|
let rendered: Vec<_> = bounds
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| match b {
|
||||||
|
rustdoc_types::GenericBound::TraitBound { trait_, .. } => {
|
||||||
|
Some(trait_.path.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("impl {}", rendered.join(" + "))
|
||||||
|
}
|
||||||
|
Type::QualifiedPath { name, self_type, trait_, .. } => {
|
||||||
|
let self_str = render_type(self_type, krate);
|
||||||
|
if let Some(t) = trait_ {
|
||||||
|
format!("<{self_str} as {}>::{name}", t.path)
|
||||||
|
} else {
|
||||||
|
format!("{self_str}::{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Type::DynTrait(dt) => {
|
||||||
|
let traits: Vec<_> = dt.traits.iter().map(|pb| pb.trait_.path.clone()).collect();
|
||||||
|
format!("dyn {}", traits.join(" + "))
|
||||||
|
}
|
||||||
|
Type::FunctionPointer(fp) => {
|
||||||
|
let params: Vec<_> = fp
|
||||||
|
.sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.map(|(_, t)| render_type(t, krate))
|
||||||
|
.collect();
|
||||||
|
let ret = fp
|
||||||
|
.sig
|
||||||
|
.output
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| format!(" -> {}", render_type(t, krate)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("fn({}){ret}", params.join(", "))
|
||||||
|
}
|
||||||
|
Type::Pat { type_, .. } => render_type(type_, krate),
|
||||||
|
Type::Infer => "_".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_sentence(docs: &str) -> String {
|
||||||
|
docs.split('\n')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rustdoc_types::*;
|
||||||
|
|
||||||
|
fn empty_crate() -> Crate {
|
||||||
|
Crate {
|
||||||
|
root: Id(0),
|
||||||
|
crate_version: Some("0.1.0".to_string()),
|
||||||
|
includes_private: false,
|
||||||
|
index: HashMap::new(),
|
||||||
|
paths: HashMap::new(),
|
||||||
|
external_crates: HashMap::new(),
|
||||||
|
format_version: 38,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_function(name: &str, params: Vec<(&str, Type)>, output: Option<Type>) -> Item {
|
||||||
|
Item {
|
||||||
|
id: Id(1),
|
||||||
|
crate_id: 0,
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
span: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
docs: Some(format!("Documentation for {name}.")),
|
||||||
|
links: HashMap::new(),
|
||||||
|
attrs: vec![],
|
||||||
|
deprecation: None,
|
||||||
|
inner: ItemEnum::Function(Function {
|
||||||
|
sig: FunctionSignature {
|
||||||
|
inputs: params.into_iter().map(|(n, t)| (n.to_string(), t)).collect(),
|
||||||
|
output,
|
||||||
|
is_c_variadic: false,
|
||||||
|
},
|
||||||
|
generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
header: FunctionHeader {
|
||||||
|
is_const: false,
|
||||||
|
is_unsafe: false,
|
||||||
|
is_async: false,
|
||||||
|
abi: Abi::Rust,
|
||||||
|
},
|
||||||
|
has_body: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_struct(name: &str) -> Item {
|
||||||
|
Item {
|
||||||
|
id: Id(2),
|
||||||
|
crate_id: 0,
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
span: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
docs: Some(format!("A {name} struct.")),
|
||||||
|
links: HashMap::new(),
|
||||||
|
attrs: vec![],
|
||||||
|
deprecation: None,
|
||||||
|
inner: ItemEnum::Struct(Struct {
|
||||||
|
kind: StructKind::Unit,
|
||||||
|
generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
impls: vec![],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_enum(name: &str) -> Item {
|
||||||
|
Item {
|
||||||
|
id: Id(3),
|
||||||
|
crate_id: 0,
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
span: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
docs: Some(format!("The {name} enum.")),
|
||||||
|
links: HashMap::new(),
|
||||||
|
attrs: vec![],
|
||||||
|
deprecation: None,
|
||||||
|
inner: ItemEnum::Enum(Enum {
|
||||||
|
generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
variants: vec![],
|
||||||
|
has_stripped_variants: false,
|
||||||
|
impls: vec![],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_trait(name: &str) -> Item {
|
||||||
|
Item {
|
||||||
|
id: Id(4),
|
||||||
|
crate_id: 0,
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
span: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
docs: Some(format!("The {name} trait.")),
|
||||||
|
links: HashMap::new(),
|
||||||
|
attrs: vec![],
|
||||||
|
deprecation: None,
|
||||||
|
inner: ItemEnum::Trait(Trait {
|
||||||
|
is_auto: false,
|
||||||
|
is_unsafe: false,
|
||||||
|
is_dyn_compatible: true,
|
||||||
|
items: vec![],
|
||||||
|
generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
bounds: vec![],
|
||||||
|
implementations: vec![],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_sentence_basic() {
|
||||||
|
assert_eq!(first_sentence("Hello world."), "Hello world");
|
||||||
|
assert_eq!(first_sentence("First line.\nSecond line."), "First line");
|
||||||
|
assert_eq!(first_sentence(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_primitives() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
assert_eq!(render_type(&Type::Primitive("u32".into()), &krate), "u32");
|
||||||
|
assert_eq!(render_type(&Type::Primitive("bool".into()), &krate), "bool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_generic() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
assert_eq!(render_type(&Type::Generic("T".into()), &krate), "T");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_reference() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::BorrowedRef {
|
||||||
|
lifetime: Some("'a".into()),
|
||||||
|
is_mutable: false,
|
||||||
|
type_: Box::new(Type::Primitive("str".into())),
|
||||||
|
};
|
||||||
|
assert_eq!(render_type(&ty, &krate), "&'a str");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_mut_reference() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::BorrowedRef {
|
||||||
|
lifetime: None,
|
||||||
|
is_mutable: true,
|
||||||
|
type_: Box::new(Type::Primitive("u8".into())),
|
||||||
|
};
|
||||||
|
assert_eq!(render_type(&ty, &krate), "&mut u8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_tuple() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::Tuple(vec![Type::Primitive("u32".into()), Type::Primitive("String".into())]);
|
||||||
|
assert_eq!(render_type(&ty, &krate), "(u32, String)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_slice() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
assert_eq!(render_type(&Type::Slice(Box::new(Type::Primitive("u8".into()))), &krate), "[u8]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_array() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::Array { type_: Box::new(Type::Primitive("u8".into())), len: "32".into() };
|
||||||
|
assert_eq!(render_type(&ty, &krate), "[u8; 32]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_raw_pointer() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::RawPointer { is_mutable: true, type_: Box::new(Type::Primitive("u8".into())) };
|
||||||
|
assert_eq!(render_type(&ty, &krate), "*mut u8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_function_signature() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let item = make_function("add", vec![("a", Type::Primitive("u32".into())), ("b", Type::Primitive("u32".into()))], Some(Type::Primitive("u32".into())));
|
||||||
|
assert_eq!(render_signature(&item, &krate).unwrap(), "fn add(a: u32, b: u32) -> u32");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_function_no_return() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let item = make_function("do_thing", vec![], None);
|
||||||
|
assert_eq!(render_signature(&item, &krate).unwrap(), "fn do_thing()");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_struct_signature() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
assert_eq!(render_signature(&make_struct("MyStruct"), &krate).unwrap(), "pub struct MyStruct;");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_enum_signature() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
assert_eq!(render_signature(&make_enum("Color"), &krate).unwrap(), "pub enum Color { }");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_trait_signature() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
assert_eq!(render_signature(&make_trait("Drawable"), &krate).unwrap(), "pub trait Drawable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_kind_labels() {
|
||||||
|
assert_eq!(item_kind_label(&ItemEnum::Module(Module { is_crate: false, items: vec![], is_stripped: false })), Some("Modules"));
|
||||||
|
assert_eq!(item_kind_label(&ItemEnum::Struct(Struct { kind: StructKind::Unit, generics: Generics { params: vec![], where_predicates: vec![] }, impls: vec![] })), Some("Structs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transform_empty_crate() {
|
||||||
|
assert!(transform_to_mdx(&empty_crate()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transform_crate_with_function() {
|
||||||
|
let mut krate = empty_crate();
|
||||||
|
let func = make_function("hello", vec![], None);
|
||||||
|
let id = Id(1);
|
||||||
|
krate.index.insert(id.clone(), func);
|
||||||
|
krate.paths.insert(id, ItemSummary { crate_id: 0, path: vec!["my_crate".into(), "hello".into()], kind: ItemKind::Function });
|
||||||
|
|
||||||
|
let files = transform_to_mdx(&krate);
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
assert_eq!(files[0].path, "my_crate.mdx");
|
||||||
|
assert!(files[0].content.contains("### `hello`"));
|
||||||
|
assert!(files[0].content.contains("fn hello()"));
|
||||||
|
assert!(files[0].content.contains("Documentation for hello."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transform_crate_with_multiple_kinds() {
|
||||||
|
let mut krate = empty_crate();
|
||||||
|
let func = make_function("do_thing", vec![], None);
|
||||||
|
krate.index.insert(Id(1), func);
|
||||||
|
krate.paths.insert(Id(1), ItemSummary { crate_id: 0, path: vec!["mc".into(), "do_thing".into()], kind: ItemKind::Function });
|
||||||
|
|
||||||
|
let st = make_struct("Widget");
|
||||||
|
krate.index.insert(Id(2), st);
|
||||||
|
krate.paths.insert(Id(2), ItemSummary { crate_id: 0, path: vec!["mc".into(), "Widget".into()], kind: ItemKind::Struct });
|
||||||
|
|
||||||
|
let files = transform_to_mdx(&krate);
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
let content = &files[0].content;
|
||||||
|
assert!(content.find("## Structs").unwrap() < content.find("## Functions").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontmatter_escapes_quotes() {
|
||||||
|
// Put a module with quoted docs as the root so it becomes the frontmatter description.
|
||||||
|
let mut krate = empty_crate();
|
||||||
|
let root_module = Item {
|
||||||
|
id: Id(0),
|
||||||
|
crate_id: 0,
|
||||||
|
name: Some("mylib".into()),
|
||||||
|
span: None,
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
docs: Some("A \"quoted\" crate.".into()),
|
||||||
|
links: HashMap::new(),
|
||||||
|
attrs: vec![],
|
||||||
|
deprecation: None,
|
||||||
|
inner: ItemEnum::Module(Module { is_crate: true, items: vec![Id(1)], is_stripped: false }),
|
||||||
|
};
|
||||||
|
krate.root = Id(0);
|
||||||
|
krate.index.insert(Id(0), root_module);
|
||||||
|
|
||||||
|
// Add a function so the module generates a file.
|
||||||
|
let func = make_function("f", vec![], None);
|
||||||
|
krate.index.insert(Id(1), func);
|
||||||
|
krate.paths.insert(Id(1), ItemSummary { crate_id: 0, path: vec!["f".into()], kind: ItemKind::Function });
|
||||||
|
|
||||||
|
let files = transform_to_mdx(&krate);
|
||||||
|
// The root module's description in frontmatter should have escaped quotes.
|
||||||
|
let index = files.iter().find(|f| f.path == "index.mdx").unwrap();
|
||||||
|
assert!(index.content.contains("\\\"quoted\\\""), "content: {}", index.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_resolved_path_with_args() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::ResolvedPath(rustdoc_types::Path {
|
||||||
|
path: "Option".into(),
|
||||||
|
id: Id(99),
|
||||||
|
args: Some(Box::new(rustdoc_types::GenericArgs::AngleBracketed {
|
||||||
|
args: vec![rustdoc_types::GenericArg::Type(Type::Primitive("u32".into()))],
|
||||||
|
constraints: vec![],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
assert_eq!(render_type(&ty, &krate), "Option<u32>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_impl_trait() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::ImplTrait(vec![
|
||||||
|
rustdoc_types::GenericBound::TraitBound {
|
||||||
|
trait_: rustdoc_types::Path { path: "Display".into(), id: Id(99), args: None },
|
||||||
|
generic_params: vec![],
|
||||||
|
modifier: rustdoc_types::TraitBoundModifier::None,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert_eq!(render_type(&ty, &krate), "impl Display");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_dyn_trait() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::DynTrait(rustdoc_types::DynTrait {
|
||||||
|
traits: vec![rustdoc_types::PolyTrait {
|
||||||
|
trait_: rustdoc_types::Path { path: "Error".into(), id: Id(99), args: None },
|
||||||
|
generic_params: vec![],
|
||||||
|
}],
|
||||||
|
lifetime: None,
|
||||||
|
});
|
||||||
|
assert_eq!(render_type(&ty, &krate), "dyn Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_function_pointer() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::FunctionPointer(Box::new(rustdoc_types::FunctionPointer {
|
||||||
|
sig: FunctionSignature {
|
||||||
|
inputs: vec![("x".into(), Type::Primitive("u32".into()))],
|
||||||
|
output: Some(Type::Primitive("bool".into())),
|
||||||
|
is_c_variadic: false,
|
||||||
|
},
|
||||||
|
generic_params: vec![],
|
||||||
|
header: FunctionHeader { is_const: false, is_unsafe: false, is_async: false, abi: Abi::Rust },
|
||||||
|
}));
|
||||||
|
assert_eq!(render_type(&ty, &krate), "fn(u32) -> bool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_const_pointer() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::RawPointer { is_mutable: false, type_: Box::new(Type::Primitive("u8".into())) };
|
||||||
|
assert_eq!(render_type(&ty, &krate), "*const u8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_infer() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
assert_eq!(render_type(&Type::Infer, &krate), "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_qualified_path() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let ty = Type::QualifiedPath {
|
||||||
|
name: "Item".into(),
|
||||||
|
args: Box::new(rustdoc_types::GenericArgs::AngleBracketed { args: vec![], constraints: vec![] }),
|
||||||
|
self_type: Box::new(Type::Generic("T".into())),
|
||||||
|
trait_: Some(rustdoc_types::Path { path: "Iterator".into(), id: Id(99), args: None }),
|
||||||
|
};
|
||||||
|
assert_eq!(render_type(&ty, &krate), "<T as Iterator>::Item");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_kind_label_all_variants() {
|
||||||
|
// Test the remaining untested variants
|
||||||
|
assert_eq!(item_kind_label(&ItemEnum::Enum(Enum {
|
||||||
|
generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
variants: vec![], has_stripped_variants: false, impls: vec![],
|
||||||
|
})), Some("Enums"));
|
||||||
|
assert_eq!(item_kind_label(&ItemEnum::Trait(Trait {
|
||||||
|
is_auto: false, is_unsafe: false, is_dyn_compatible: true,
|
||||||
|
items: vec![], generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
bounds: vec![], implementations: vec![],
|
||||||
|
})), Some("Traits"));
|
||||||
|
assert_eq!(item_kind_label(&ItemEnum::Macro("".into())), Some("Macros"));
|
||||||
|
assert_eq!(item_kind_label(&ItemEnum::Static(rustdoc_types::Static {
|
||||||
|
type_: Type::Primitive("u32".into()),
|
||||||
|
is_mutable: false,
|
||||||
|
is_unsafe: false,
|
||||||
|
expr: String::new(),
|
||||||
|
})), Some("Statics"));
|
||||||
|
// Impl blocks should be skipped
|
||||||
|
assert_eq!(item_kind_label(&ItemEnum::Impl(rustdoc_types::Impl {
|
||||||
|
is_unsafe: false, generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
provided_trait_methods: vec![], trait_: None, for_: Type::Primitive("u32".into()),
|
||||||
|
items: vec![], is_negative: false, is_synthetic: false,
|
||||||
|
blanket_impl: None,
|
||||||
|
})), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_constant_signature() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let item = Item {
|
||||||
|
id: Id(5), crate_id: 0,
|
||||||
|
name: Some("MAX_SIZE".into()), span: None,
|
||||||
|
visibility: Visibility::Public, docs: None,
|
||||||
|
links: HashMap::new(), attrs: vec![], deprecation: None,
|
||||||
|
inner: ItemEnum::Constant {
|
||||||
|
type_: Type::Primitive("usize".into()),
|
||||||
|
const_: rustdoc_types::Constant { expr: "1024".into(), value: Some("1024".into()), is_literal: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(render_signature(&item, &krate).unwrap(), "pub const MAX_SIZE: usize = 1024");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_type_alias_signature() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let item = Item {
|
||||||
|
id: Id(6), crate_id: 0,
|
||||||
|
name: Some("Result".into()), span: None,
|
||||||
|
visibility: Visibility::Public, docs: None,
|
||||||
|
links: HashMap::new(), attrs: vec![], deprecation: None,
|
||||||
|
inner: ItemEnum::TypeAlias(rustdoc_types::TypeAlias {
|
||||||
|
type_: Type::Primitive("u32".into()),
|
||||||
|
generics: Generics { params: vec![], where_predicates: vec![] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert_eq!(render_signature(&item, &krate).unwrap(), "pub type Result = u32");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_macro_signature() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let item = Item {
|
||||||
|
id: Id(7), crate_id: 0,
|
||||||
|
name: Some("my_macro".into()), span: None,
|
||||||
|
visibility: Visibility::Public, docs: None,
|
||||||
|
links: HashMap::new(), attrs: vec![], deprecation: None,
|
||||||
|
inner: ItemEnum::Macro("macro body".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(render_signature(&item, &krate).unwrap(), "macro_rules! my_macro { ... }");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_item_without_docs() {
|
||||||
|
let krate = empty_crate();
|
||||||
|
let mut item = make_struct("NoDocs");
|
||||||
|
item.docs = None;
|
||||||
|
let mut out = String::new();
|
||||||
|
render_item(&mut out, &item, &krate);
|
||||||
|
assert!(out.contains("### `NoDocs`"));
|
||||||
|
assert!(out.contains("pub struct NoDocs;"));
|
||||||
|
// Should not have trailing doc content
|
||||||
|
assert!(!out.contains("A NoDocs struct."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_mdx_files_creates_directories() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let files = vec![MdxFile { path: "nested/module.mdx".into(), content: "# Test\n".into() }];
|
||||||
|
write_mdx_files(&files, tmp.path()).unwrap();
|
||||||
|
assert!(tmp.path().join("nested/module.mdx").exists());
|
||||||
|
assert_eq!(std::fs::read_to_string(tmp.path().join("nested/module.mdx")).unwrap(), "# Test\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
183
wfe-rustlang/src/rustup/config.rs
Normal file
183
wfe-rustlang/src/rustup/config.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Which rustup operation to perform.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum RustupCommand {
|
||||||
|
/// Install Rust via rustup-init.
|
||||||
|
Install,
|
||||||
|
/// Install a toolchain (`rustup toolchain install`).
|
||||||
|
ToolchainInstall,
|
||||||
|
/// Add a component (`rustup component add`).
|
||||||
|
ComponentAdd,
|
||||||
|
/// Add a compilation target (`rustup target add`).
|
||||||
|
TargetAdd,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RustupCommand {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Install => "install",
|
||||||
|
Self::ToolchainInstall => "toolchain-install",
|
||||||
|
Self::ComponentAdd => "component-add",
|
||||||
|
Self::TargetAdd => "target-add",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for rustup step types.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RustupConfig {
|
||||||
|
pub command: RustupCommand,
|
||||||
|
/// Toolchain to install or scope components/targets to (e.g. "nightly", "1.78.0").
|
||||||
|
#[serde(default)]
|
||||||
|
pub toolchain: Option<String>,
|
||||||
|
/// Components to add (e.g. ["clippy", "rustfmt", "rust-src"]).
|
||||||
|
#[serde(default)]
|
||||||
|
pub components: Vec<String>,
|
||||||
|
/// Compilation targets to add (e.g. ["wasm32-unknown-unknown"]).
|
||||||
|
#[serde(default)]
|
||||||
|
pub targets: Vec<String>,
|
||||||
|
/// Rustup profile for initial install: "minimal", "default", or "complete".
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: Option<String>,
|
||||||
|
/// Default toolchain to set during install.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_toolchain: Option<String>,
|
||||||
|
/// Additional arguments appended to the command.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_args: Vec<String>,
|
||||||
|
/// Execution timeout in milliseconds.
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_as_str() {
|
||||||
|
assert_eq!(RustupCommand::Install.as_str(), "install");
|
||||||
|
assert_eq!(RustupCommand::ToolchainInstall.as_str(), "toolchain-install");
|
||||||
|
assert_eq!(RustupCommand::ComponentAdd.as_str(), "component-add");
|
||||||
|
assert_eq!(RustupCommand::TargetAdd.as_str(), "target-add");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_serde_kebab_case() {
|
||||||
|
let json = r#""toolchain-install""#;
|
||||||
|
let cmd: RustupCommand = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(cmd, RustupCommand::ToolchainInstall);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&RustupCommand::ComponentAdd).unwrap();
|
||||||
|
assert_eq!(serialized, r#""component-add""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_install() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::Install,
|
||||||
|
toolchain: None,
|
||||||
|
components: vec![],
|
||||||
|
targets: vec![],
|
||||||
|
profile: Some("minimal".to_string()),
|
||||||
|
default_toolchain: Some("stable".to_string()),
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: Some(300_000),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let de: RustupConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(de.command, RustupCommand::Install);
|
||||||
|
assert_eq!(de.profile, Some("minimal".to_string()));
|
||||||
|
assert_eq!(de.default_toolchain, Some("stable".to_string()));
|
||||||
|
assert_eq!(de.timeout_ms, Some(300_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_toolchain_install() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::ToolchainInstall,
|
||||||
|
toolchain: Some("nightly-2024-06-01".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
targets: vec![],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let de: RustupConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(de.command, RustupCommand::ToolchainInstall);
|
||||||
|
assert_eq!(de.toolchain, Some("nightly-2024-06-01".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_component_add() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::ComponentAdd,
|
||||||
|
toolchain: Some("nightly".to_string()),
|
||||||
|
components: vec!["clippy".to_string(), "rustfmt".to_string(), "rust-src".to_string()],
|
||||||
|
targets: vec![],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let de: RustupConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(de.command, RustupCommand::ComponentAdd);
|
||||||
|
assert_eq!(de.components, vec!["clippy", "rustfmt", "rust-src"]);
|
||||||
|
assert_eq!(de.toolchain, Some("nightly".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trip_target_add() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::TargetAdd,
|
||||||
|
toolchain: Some("stable".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
targets: vec!["wasm32-unknown-unknown".to_string(), "aarch64-linux-android".to_string()],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let de: RustupConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(de.command, RustupCommand::TargetAdd);
|
||||||
|
assert_eq!(de.targets, vec!["wasm32-unknown-unknown", "aarch64-linux-android"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_defaults() {
|
||||||
|
let json = r#"{"command": "install"}"#;
|
||||||
|
let config: RustupConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.command, RustupCommand::Install);
|
||||||
|
assert!(config.toolchain.is_none());
|
||||||
|
assert!(config.components.is_empty());
|
||||||
|
assert!(config.targets.is_empty());
|
||||||
|
assert!(config.profile.is_none());
|
||||||
|
assert!(config.default_toolchain.is_none());
|
||||||
|
assert!(config.extra_args.is_empty());
|
||||||
|
assert!(config.timeout_ms.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_with_extra_args() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::ToolchainInstall,
|
||||||
|
toolchain: Some("nightly".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
targets: vec![],
|
||||||
|
profile: Some("minimal".to_string()),
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec!["--force".to_string()],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let de: RustupConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(de.extra_args, vec!["--force"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
wfe-rustlang/src/rustup/mod.rs
Normal file
5
wfe-rustlang/src/rustup/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod step;
|
||||||
|
|
||||||
|
pub use config::{RustupCommand, RustupConfig};
|
||||||
|
pub use step::RustupStep;
|
||||||
357
wfe-rustlang/src/rustup/step.rs
Normal file
357
wfe-rustlang/src/rustup/step.rs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use wfe_core::models::ExecutionResult;
|
||||||
|
use wfe_core::traits::step::{StepBody, StepExecutionContext};
|
||||||
|
use wfe_core::WfeError;
|
||||||
|
|
||||||
|
use crate::rustup::config::{RustupCommand, RustupConfig};
|
||||||
|
|
||||||
|
pub struct RustupStep {
|
||||||
|
config: RustupConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RustupStep {
|
||||||
|
pub fn new(config: RustupConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_command(&self) -> tokio::process::Command {
|
||||||
|
match self.config.command {
|
||||||
|
RustupCommand::Install => self.build_install_command(),
|
||||||
|
RustupCommand::ToolchainInstall => self.build_toolchain_install_command(),
|
||||||
|
RustupCommand::ComponentAdd => self.build_component_add_command(),
|
||||||
|
RustupCommand::TargetAdd => self.build_target_add_command(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_install_command(&self) -> tokio::process::Command {
|
||||||
|
let mut cmd = tokio::process::Command::new("sh");
|
||||||
|
// Pipe rustup-init through sh with non-interactive flag.
|
||||||
|
let mut script = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y".to_string();
|
||||||
|
|
||||||
|
if let Some(ref profile) = self.config.profile {
|
||||||
|
script.push_str(&format!(" --profile {profile}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref tc) = self.config.default_toolchain {
|
||||||
|
script.push_str(&format!(" --default-toolchain {tc}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in &self.config.extra_args {
|
||||||
|
script.push_str(&format!(" {arg}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg("-c").arg(&script);
|
||||||
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_toolchain_install_command(&self) -> tokio::process::Command {
|
||||||
|
let mut cmd = tokio::process::Command::new("rustup");
|
||||||
|
cmd.args(["toolchain", "install"]);
|
||||||
|
|
||||||
|
if let Some(ref tc) = self.config.toolchain {
|
||||||
|
cmd.arg(tc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref profile) = self.config.profile {
|
||||||
|
cmd.args(["--profile", profile]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in &self.config.extra_args {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_component_add_command(&self) -> tokio::process::Command {
|
||||||
|
let mut cmd = tokio::process::Command::new("rustup");
|
||||||
|
cmd.args(["component", "add"]);
|
||||||
|
|
||||||
|
for component in &self.config.components {
|
||||||
|
cmd.arg(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref tc) = self.config.toolchain {
|
||||||
|
cmd.args(["--toolchain", tc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in &self.config.extra_args {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_target_add_command(&self) -> tokio::process::Command {
|
||||||
|
let mut cmd = tokio::process::Command::new("rustup");
|
||||||
|
cmd.args(["target", "add"]);
|
||||||
|
|
||||||
|
for target in &self.config.targets {
|
||||||
|
cmd.arg(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref tc) = self.config.toolchain {
|
||||||
|
cmd.args(["--toolchain", tc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in &self.config.extra_args {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StepBody for RustupStep {
|
||||||
|
async fn run(&mut self, context: &StepExecutionContext<'_>) -> wfe_core::Result<ExecutionResult> {
|
||||||
|
let step_name = context.step.name.as_deref().unwrap_or("unknown");
|
||||||
|
let subcmd = self.config.command.as_str();
|
||||||
|
|
||||||
|
tracing::info!(step = step_name, command = subcmd, "running rustup");
|
||||||
|
|
||||||
|
let mut cmd = self.build_command();
|
||||||
|
|
||||||
|
let output = if let Some(timeout_ms) = self.config.timeout_ms {
|
||||||
|
let duration = std::time::Duration::from_millis(timeout_ms);
|
||||||
|
match tokio::time::timeout(duration, cmd.output()).await {
|
||||||
|
Ok(result) => result.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("Failed to spawn rustup {subcmd}: {e}"))
|
||||||
|
})?,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"rustup {subcmd} timed out after {timeout_ms}ms"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| WfeError::StepExecution(format!("Failed to spawn rustup {subcmd}: {e}")))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let code = output.status.code().unwrap_or(-1);
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"rustup {subcmd} exited with code {code}\nstdout: {stdout}\nstderr: {stderr}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut outputs = serde_json::Map::new();
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.stdout"),
|
||||||
|
serde_json::Value::String(stdout),
|
||||||
|
);
|
||||||
|
outputs.insert(
|
||||||
|
format!("{step_name}.stderr"),
|
||||||
|
serde_json::Value::String(stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ExecutionResult {
|
||||||
|
proceed: true,
|
||||||
|
output_data: Some(serde_json::Value::Object(outputs)),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn install_config() -> RustupConfig {
|
||||||
|
RustupConfig {
|
||||||
|
command: RustupCommand::Install,
|
||||||
|
toolchain: None,
|
||||||
|
components: vec![],
|
||||||
|
targets: vec![],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_install_command_minimal() {
|
||||||
|
let step = RustupStep::new(install_config());
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let prog = cmd.as_std().get_program().to_str().unwrap();
|
||||||
|
assert_eq!(prog, "sh");
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args[0], "-c");
|
||||||
|
assert!(args[1].contains("rustup.rs"));
|
||||||
|
assert!(args[1].contains("-y"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_install_command_with_profile_and_toolchain() {
|
||||||
|
let mut config = install_config();
|
||||||
|
config.profile = Some("minimal".to_string());
|
||||||
|
config.default_toolchain = Some("nightly".to_string());
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert!(args[1].contains("--profile minimal"));
|
||||||
|
assert!(args[1].contains("--default-toolchain nightly"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_install_command_with_extra_args() {
|
||||||
|
let mut config = install_config();
|
||||||
|
config.extra_args = vec!["--no-modify-path".to_string()];
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert!(args[1].contains("--no-modify-path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_toolchain_install_command() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::ToolchainInstall,
|
||||||
|
toolchain: Some("nightly-2024-06-01".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
targets: vec![],
|
||||||
|
profile: Some("minimal".to_string()),
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let prog = cmd.as_std().get_program().to_str().unwrap();
|
||||||
|
assert_eq!(prog, "rustup");
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["toolchain", "install", "nightly-2024-06-01", "--profile", "minimal"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_toolchain_install_with_extra_args() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::ToolchainInstall,
|
||||||
|
toolchain: Some("stable".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
targets: vec![],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec!["--force".to_string()],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["toolchain", "install", "stable", "--force"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_component_add_command() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::ComponentAdd,
|
||||||
|
toolchain: Some("nightly".to_string()),
|
||||||
|
components: vec!["clippy".to_string(), "rustfmt".to_string()],
|
||||||
|
targets: vec![],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let prog = cmd.as_std().get_program().to_str().unwrap();
|
||||||
|
assert_eq!(prog, "rustup");
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["component", "add", "clippy", "rustfmt", "--toolchain", "nightly"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_component_add_without_toolchain() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::ComponentAdd,
|
||||||
|
toolchain: None,
|
||||||
|
components: vec!["rust-src".to_string()],
|
||||||
|
targets: vec![],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["component", "add", "rust-src"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_target_add_command() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::TargetAdd,
|
||||||
|
toolchain: Some("stable".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
targets: vec!["wasm32-unknown-unknown".to_string()],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let prog = cmd.as_std().get_program().to_str().unwrap();
|
||||||
|
assert_eq!(prog, "rustup");
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["target", "add", "wasm32-unknown-unknown", "--toolchain", "stable"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_target_add_multiple_targets() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::TargetAdd,
|
||||||
|
toolchain: None,
|
||||||
|
components: vec![],
|
||||||
|
targets: vec![
|
||||||
|
"wasm32-unknown-unknown".to_string(),
|
||||||
|
"aarch64-linux-android".to_string(),
|
||||||
|
],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec![],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(args, vec!["target", "add", "wasm32-unknown-unknown", "aarch64-linux-android"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_target_add_with_extra_args() {
|
||||||
|
let config = RustupConfig {
|
||||||
|
command: RustupCommand::TargetAdd,
|
||||||
|
toolchain: Some("nightly".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
targets: vec!["x86_64-unknown-linux-musl".to_string()],
|
||||||
|
profile: None,
|
||||||
|
default_toolchain: None,
|
||||||
|
extra_args: vec!["--force".to_string()],
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
let step = RustupStep::new(config);
|
||||||
|
let cmd = step.build_command();
|
||||||
|
let args: Vec<_> = cmd.as_std().get_args().map(|a| a.to_str().unwrap()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec!["target", "add", "x86_64-unknown-linux-musl", "--toolchain", "nightly", "--force"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
wfe-server-protos/Cargo.toml
Normal file
19
wfe-server-protos/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "wfe-server-protos"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "gRPC service definitions for the WFE workflow server"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tonic = "0.14"
|
||||||
|
tonic-prost = "0.14"
|
||||||
|
prost = "0.14"
|
||||||
|
prost-types = "0.14"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tonic-build = "0.14"
|
||||||
|
tonic-prost-build = "0.14"
|
||||||
|
prost-build = "0.14"
|
||||||
17
wfe-server-protos/build.rs
Normal file
17
wfe-server-protos/build.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let proto_files = vec!["proto/wfe/v1/wfe.proto"];
|
||||||
|
|
||||||
|
let mut prost_config = prost_build::Config::new();
|
||||||
|
prost_config.include_file("mod.rs");
|
||||||
|
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_server(true)
|
||||||
|
.build_client(true)
|
||||||
|
.compile_with_config(
|
||||||
|
prost_config,
|
||||||
|
&proto_files,
|
||||||
|
&["proto"],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
263
wfe-server-protos/proto/wfe/v1/wfe.proto
Normal file
263
wfe-server-protos/proto/wfe/v1/wfe.proto
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
package wfe.v1;
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
import "google/protobuf/struct.proto";
|
||||||
|
|
||||||
|
service Wfe {
|
||||||
|
// === Definitions ===
|
||||||
|
rpc RegisterWorkflow(RegisterWorkflowRequest) returns (RegisterWorkflowResponse);
|
||||||
|
rpc ListDefinitions(ListDefinitionsRequest) returns (ListDefinitionsResponse);
|
||||||
|
|
||||||
|
// === Instances ===
|
||||||
|
rpc StartWorkflow(StartWorkflowRequest) returns (StartWorkflowResponse);
|
||||||
|
rpc GetWorkflow(GetWorkflowRequest) returns (GetWorkflowResponse);
|
||||||
|
rpc CancelWorkflow(CancelWorkflowRequest) returns (CancelWorkflowResponse);
|
||||||
|
rpc SuspendWorkflow(SuspendWorkflowRequest) returns (SuspendWorkflowResponse);
|
||||||
|
rpc ResumeWorkflow(ResumeWorkflowRequest) returns (ResumeWorkflowResponse);
|
||||||
|
rpc SearchWorkflows(SearchWorkflowsRequest) returns (SearchWorkflowsResponse);
|
||||||
|
|
||||||
|
// === Events ===
|
||||||
|
rpc PublishEvent(PublishEventRequest) returns (PublishEventResponse);
|
||||||
|
|
||||||
|
// === Streaming ===
|
||||||
|
rpc WatchLifecycle(WatchLifecycleRequest) returns (stream LifecycleEvent);
|
||||||
|
rpc StreamLogs(StreamLogsRequest) returns (stream LogEntry);
|
||||||
|
|
||||||
|
// === Search ===
|
||||||
|
rpc SearchLogs(SearchLogsRequest) returns (SearchLogsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Definitions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message RegisterWorkflowRequest {
|
||||||
|
// Raw YAML content. The server compiles it via wfe-yaml.
|
||||||
|
string yaml = 1;
|
||||||
|
// Optional config map for ((variable)) interpolation.
|
||||||
|
map<string, string> config = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RegisterWorkflowResponse {
|
||||||
|
repeated RegisteredDefinition definitions = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RegisteredDefinition {
|
||||||
|
string definition_id = 1;
|
||||||
|
uint32 version = 2;
|
||||||
|
uint32 step_count = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListDefinitionsRequest {}
|
||||||
|
|
||||||
|
message ListDefinitionsResponse {
|
||||||
|
repeated DefinitionSummary definitions = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DefinitionSummary {
|
||||||
|
string id = 1;
|
||||||
|
uint32 version = 2;
|
||||||
|
string description = 3;
|
||||||
|
uint32 step_count = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Instances ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message StartWorkflowRequest {
|
||||||
|
string definition_id = 1;
|
||||||
|
uint32 version = 2;
|
||||||
|
google.protobuf.Struct data = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StartWorkflowResponse {
|
||||||
|
string workflow_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetWorkflowRequest {
|
||||||
|
string workflow_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetWorkflowResponse {
|
||||||
|
WorkflowInstance instance = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CancelWorkflowRequest {
|
||||||
|
string workflow_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CancelWorkflowResponse {}
|
||||||
|
|
||||||
|
message SuspendWorkflowRequest {
|
||||||
|
string workflow_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SuspendWorkflowResponse {}
|
||||||
|
|
||||||
|
message ResumeWorkflowRequest {
|
||||||
|
string workflow_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ResumeWorkflowResponse {}
|
||||||
|
|
||||||
|
message SearchWorkflowsRequest {
|
||||||
|
string query = 1;
|
||||||
|
WorkflowStatus status_filter = 2;
|
||||||
|
uint64 skip = 3;
|
||||||
|
uint64 take = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchWorkflowsResponse {
|
||||||
|
repeated WorkflowSearchResult results = 1;
|
||||||
|
uint64 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Events ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message PublishEventRequest {
|
||||||
|
string event_name = 1;
|
||||||
|
string event_key = 2;
|
||||||
|
google.protobuf.Struct data = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PublishEventResponse {
|
||||||
|
string event_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle streaming ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
message WatchLifecycleRequest {
|
||||||
|
// Empty = all workflows. Set to filter to one.
|
||||||
|
string workflow_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LifecycleEvent {
|
||||||
|
google.protobuf.Timestamp event_time = 1;
|
||||||
|
string workflow_id = 2;
|
||||||
|
string definition_id = 3;
|
||||||
|
uint32 version = 4;
|
||||||
|
LifecycleEventType event_type = 5;
|
||||||
|
// Populated for step events.
|
||||||
|
uint32 step_id = 6;
|
||||||
|
string step_name = 7;
|
||||||
|
// Populated for error events.
|
||||||
|
string error_message = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Log streaming ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message StreamLogsRequest {
|
||||||
|
string workflow_id = 1;
|
||||||
|
// Filter to a specific step. Empty = all steps.
|
||||||
|
string step_name = 2;
|
||||||
|
// If true, keep streaming as new logs arrive (tail -f).
|
||||||
|
bool follow = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogEntry {
|
||||||
|
string workflow_id = 1;
|
||||||
|
string step_name = 2;
|
||||||
|
uint32 step_id = 3;
|
||||||
|
LogStream stream = 4;
|
||||||
|
bytes data = 5;
|
||||||
|
google.protobuf.Timestamp timestamp = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Log search ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message SearchLogsRequest {
|
||||||
|
// Full-text search query.
|
||||||
|
string query = 1;
|
||||||
|
// Optional filters.
|
||||||
|
string workflow_id = 2;
|
||||||
|
string step_name = 3;
|
||||||
|
LogStream stream_filter = 4;
|
||||||
|
uint64 skip = 5;
|
||||||
|
uint64 take = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchLogsResponse {
|
||||||
|
repeated LogSearchResult results = 1;
|
||||||
|
uint64 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogSearchResult {
|
||||||
|
string workflow_id = 1;
|
||||||
|
string definition_id = 2;
|
||||||
|
string step_name = 3;
|
||||||
|
string line = 4;
|
||||||
|
LogStream stream = 5;
|
||||||
|
google.protobuf.Timestamp timestamp = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message WorkflowInstance {
|
||||||
|
string id = 1;
|
||||||
|
string definition_id = 2;
|
||||||
|
uint32 version = 3;
|
||||||
|
string description = 4;
|
||||||
|
string reference = 5;
|
||||||
|
WorkflowStatus status = 6;
|
||||||
|
google.protobuf.Struct data = 7;
|
||||||
|
google.protobuf.Timestamp create_time = 8;
|
||||||
|
google.protobuf.Timestamp complete_time = 9;
|
||||||
|
repeated ExecutionPointer execution_pointers = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExecutionPointer {
|
||||||
|
string id = 1;
|
||||||
|
uint32 step_id = 2;
|
||||||
|
string step_name = 3;
|
||||||
|
PointerStatus status = 4;
|
||||||
|
google.protobuf.Timestamp start_time = 5;
|
||||||
|
google.protobuf.Timestamp end_time = 6;
|
||||||
|
uint32 retry_count = 7;
|
||||||
|
bool active = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WorkflowSearchResult {
|
||||||
|
string id = 1;
|
||||||
|
string definition_id = 2;
|
||||||
|
uint32 version = 3;
|
||||||
|
WorkflowStatus status = 4;
|
||||||
|
string reference = 5;
|
||||||
|
string description = 6;
|
||||||
|
google.protobuf.Timestamp create_time = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkflowStatus {
|
||||||
|
WORKFLOW_STATUS_UNSPECIFIED = 0;
|
||||||
|
WORKFLOW_STATUS_RUNNABLE = 1;
|
||||||
|
WORKFLOW_STATUS_SUSPENDED = 2;
|
||||||
|
WORKFLOW_STATUS_COMPLETE = 3;
|
||||||
|
WORKFLOW_STATUS_TERMINATED = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PointerStatus {
|
||||||
|
POINTER_STATUS_UNSPECIFIED = 0;
|
||||||
|
POINTER_STATUS_PENDING = 1;
|
||||||
|
POINTER_STATUS_RUNNING = 2;
|
||||||
|
POINTER_STATUS_COMPLETE = 3;
|
||||||
|
POINTER_STATUS_SLEEPING = 4;
|
||||||
|
POINTER_STATUS_WAITING_FOR_EVENT = 5;
|
||||||
|
POINTER_STATUS_FAILED = 6;
|
||||||
|
POINTER_STATUS_SKIPPED = 7;
|
||||||
|
POINTER_STATUS_CANCELLED = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LifecycleEventType {
|
||||||
|
LIFECYCLE_EVENT_TYPE_UNSPECIFIED = 0;
|
||||||
|
LIFECYCLE_EVENT_TYPE_STARTED = 1;
|
||||||
|
LIFECYCLE_EVENT_TYPE_COMPLETED = 2;
|
||||||
|
LIFECYCLE_EVENT_TYPE_TERMINATED = 3;
|
||||||
|
LIFECYCLE_EVENT_TYPE_SUSPENDED = 4;
|
||||||
|
LIFECYCLE_EVENT_TYPE_RESUMED = 5;
|
||||||
|
LIFECYCLE_EVENT_TYPE_ERROR = 6;
|
||||||
|
LIFECYCLE_EVENT_TYPE_STEP_STARTED = 7;
|
||||||
|
LIFECYCLE_EVENT_TYPE_STEP_COMPLETED = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogStream {
|
||||||
|
LOG_STREAM_UNSPECIFIED = 0;
|
||||||
|
LOG_STREAM_STDOUT = 1;
|
||||||
|
LOG_STREAM_STDERR = 2;
|
||||||
|
}
|
||||||
17
wfe-server-protos/src/lib.rs
Normal file
17
wfe-server-protos/src/lib.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//! Generated gRPC stubs for the WFE workflow server API.
|
||||||
|
//!
|
||||||
|
//! Built from `proto/wfe/v1/wfe.proto`. Includes both server and client code.
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use wfe_server_protos::wfe::v1::wfe_server::WfeServer;
|
||||||
|
//! use wfe_server_protos::wfe::v1::wfe_client::WfeClient;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![allow(clippy::all)]
|
||||||
|
#![allow(warnings)]
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/mod.rs"));
|
||||||
|
|
||||||
|
pub use prost;
|
||||||
|
pub use prost_types;
|
||||||
|
pub use tonic;
|
||||||
72
wfe-server/Cargo.toml
Normal file
72
wfe-server/Cargo.toml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
[package]
|
||||||
|
name = "wfe-server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "Headless workflow server with gRPC API and HTTP webhooks"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "wfe-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Internal
|
||||||
|
wfe-core = { workspace = true, features = ["test-support"] }
|
||||||
|
wfe = { path = "../wfe" }
|
||||||
|
wfe-yaml = { path = "../wfe-yaml", features = ["rustlang", "buildkit", "containerd"] }
|
||||||
|
wfe-server-protos = { path = "../wfe-server-protos" }
|
||||||
|
wfe-sqlite = { workspace = true }
|
||||||
|
wfe-postgres = { workspace = true }
|
||||||
|
wfe-valkey = { workspace = true }
|
||||||
|
wfe-opensearch = { workspace = true }
|
||||||
|
opensearch = { workspace = true }
|
||||||
|
|
||||||
|
# gRPC
|
||||||
|
tonic = "0.14"
|
||||||
|
tonic-health = "0.14"
|
||||||
|
prost-types = "0.14"
|
||||||
|
|
||||||
|
# HTTP (webhooks)
|
||||||
|
axum = { version = "0.8", features = ["json", "macros"] }
|
||||||
|
hyper = "1"
|
||||||
|
tower = "0.5"
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
tokio = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
subtle = "2"
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
# Utils
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
dashmap = "6"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
rsa = { version = "0.9", features = ["pem"] }
|
||||||
|
rand = "0.8"
|
||||||
|
base64 = "0.22"
|
||||||
769
wfe-server/src/auth.rs
Normal file
769
wfe-server/src/auth.rs
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tonic::{Request, Status};
|
||||||
|
|
||||||
|
use crate::config::AuthConfig;
|
||||||
|
|
||||||
|
/// Asymmetric algorithms we accept. NEVER trust the JWT header's alg claim.
|
||||||
|
/// This prevents algorithm confusion attacks (CVE-2016-5431).
|
||||||
|
const ALLOWED_ALGORITHMS: &[Algorithm] = &[
|
||||||
|
Algorithm::RS256,
|
||||||
|
Algorithm::RS384,
|
||||||
|
Algorithm::RS512,
|
||||||
|
Algorithm::ES256,
|
||||||
|
Algorithm::ES384,
|
||||||
|
Algorithm::PS256,
|
||||||
|
Algorithm::PS384,
|
||||||
|
Algorithm::PS512,
|
||||||
|
Algorithm::EdDSA,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// JWT claims we validate.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
sub: Option<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
iss: Option<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
aud: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached JWKS keys fetched from the OIDC provider.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct JwksCache {
|
||||||
|
keys: Vec<jsonwebtoken::jwk::Jwk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth state shared across gRPC interceptor calls.
|
||||||
|
pub struct AuthState {
|
||||||
|
pub(crate) config: AuthConfig,
|
||||||
|
jwks: RwLock<Option<JwksCache>>,
|
||||||
|
jwks_uri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthState {
|
||||||
|
/// Create auth state. If OIDC is configured, discovers the JWKS URI.
|
||||||
|
/// Panics if OIDC is configured but discovery fails (fail-closed).
|
||||||
|
pub async fn new(config: AuthConfig) -> Self {
|
||||||
|
let jwks_uri = if let Some(ref issuer) = config.oidc_issuer {
|
||||||
|
// HIGH-03: Validate issuer URL uses HTTPS in production.
|
||||||
|
if !issuer.starts_with("https://") && !issuer.starts_with("http://localhost") {
|
||||||
|
panic!(
|
||||||
|
"OIDC issuer must use HTTPS (got: {issuer}). \
|
||||||
|
Use http://localhost only for development."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match discover_jwks_uri(issuer).await {
|
||||||
|
Ok(uri) => {
|
||||||
|
// Validate JWKS URI also uses HTTPS (second-order SSRF prevention).
|
||||||
|
if !uri.starts_with("https://") && !uri.starts_with("http://localhost") {
|
||||||
|
panic!("JWKS URI from OIDC discovery must use HTTPS (got: {uri})");
|
||||||
|
}
|
||||||
|
tracing::info!(issuer = %issuer, jwks_uri = %uri, "OIDC discovery complete");
|
||||||
|
Some(uri)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// HIGH-05: Fail startup if OIDC is configured but discovery fails.
|
||||||
|
panic!("OIDC issuer configured but discovery failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = Self {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-fetch JWKS.
|
||||||
|
if state.jwks_uri.is_some() {
|
||||||
|
state
|
||||||
|
.refresh_jwks()
|
||||||
|
.await
|
||||||
|
.expect("initial JWKS fetch failed — cannot start with OIDC enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the cached JWKS from the provider.
|
||||||
|
pub async fn refresh_jwks(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let uri = self.jwks_uri.as_ref().ok_or("no JWKS URI")?;
|
||||||
|
let resp: JwksResponse = reqwest::get(uri).await?.json().await?;
|
||||||
|
let mut cache = self.jwks.write().await;
|
||||||
|
*cache = Some(JwksCache { keys: resp.keys });
|
||||||
|
tracing::debug!(key_count = cache.as_ref().unwrap().keys.len(), "JWKS refreshed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a request's authorization.
|
||||||
|
pub async fn check<T>(&self, request: &Request<T>) -> Result<(), Status> {
|
||||||
|
// No auth configured = open access.
|
||||||
|
if self.config.tokens.is_empty() && self.config.oidc_issuer.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = extract_bearer_token(request)?;
|
||||||
|
|
||||||
|
// CRITICAL-02: Use constant-time comparison for static tokens.
|
||||||
|
if check_static_tokens(&self.config.tokens, token) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JWT/OIDC validation.
|
||||||
|
if self.config.oidc_issuer.is_some() {
|
||||||
|
return self.validate_jwt_cached(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Status::unauthenticated("invalid token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a JWT against the cached JWKS (synchronous — for use in interceptors).
|
||||||
|
/// Shared logic used by both `check()` and `make_interceptor()`.
|
||||||
|
fn validate_jwt_cached(&self, token: &str) -> Result<(), Status> {
|
||||||
|
let cache = self.jwks.try_read()
|
||||||
|
.map_err(|_| Status::unavailable("JWKS refresh in progress"))?;
|
||||||
|
let jwks = cache
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| Status::unavailable("JWKS not loaded"))?;
|
||||||
|
|
||||||
|
let header = jsonwebtoken::decode_header(token)
|
||||||
|
.map_err(|e| Status::unauthenticated(format!("invalid JWT header: {e}")))?;
|
||||||
|
|
||||||
|
// CRITICAL-01: Never trust the JWT header's alg claim.
|
||||||
|
// Derive the algorithm from the JWK, not the token.
|
||||||
|
let kid = header.kid.as_deref();
|
||||||
|
|
||||||
|
// MEDIUM-06: Require kid when JWKS has multiple keys.
|
||||||
|
if kid.is_none() && jwks.keys.len() > 1 {
|
||||||
|
return Err(Status::unauthenticated(
|
||||||
|
"JWT missing kid header but JWKS has multiple keys",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwk = jwks
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.find(|k| match (kid, &k.common.key_id) {
|
||||||
|
(Some(kid), Some(k_kid)) => kid == k_kid,
|
||||||
|
(None, _) if jwks.keys.len() == 1 => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| Status::unauthenticated("no matching key in JWKS"))?;
|
||||||
|
|
||||||
|
let decoding_key = DecodingKey::from_jwk(jwk)
|
||||||
|
.map_err(|e| Status::unauthenticated(format!("invalid JWK: {e}")))?;
|
||||||
|
|
||||||
|
// CRITICAL-01: Use the JWK's algorithm, NOT the token header's.
|
||||||
|
let alg = jwk
|
||||||
|
.common
|
||||||
|
.key_algorithm
|
||||||
|
.and_then(|ka| key_algorithm_to_jwt_algorithm(ka))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Status::unauthenticated("JWK has no algorithm or unsupported algorithm")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Double-check it's in our allowlist (no symmetric algorithms).
|
||||||
|
if !ALLOWED_ALGORITHMS.contains(&alg) {
|
||||||
|
return Err(Status::unauthenticated(format!(
|
||||||
|
"algorithm {alg:?} not in allowlist"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut validation = Validation::new(alg);
|
||||||
|
if let Some(ref issuer) = self.config.oidc_issuer {
|
||||||
|
validation.set_issuer(&[issuer]);
|
||||||
|
}
|
||||||
|
if let Some(ref audience) = self.config.oidc_audience {
|
||||||
|
validation.set_audience(&[audience]);
|
||||||
|
} else {
|
||||||
|
validation.validate_aud = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
decode::<Claims>(token, &decoding_key, &validation)
|
||||||
|
.map_err(|e| Status::unauthenticated(format!("JWT validation failed: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CRITICAL-02: Constant-time token comparison to prevent timing attacks.
|
||||||
|
/// Public for use in webhook auth.
|
||||||
|
pub fn check_static_tokens_pub(tokens: &[String], candidate: &str) -> bool {
|
||||||
|
check_static_tokens(tokens, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_static_tokens(tokens: &[String], candidate: &str) -> bool {
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
let candidate_bytes = candidate.as_bytes();
|
||||||
|
for token in tokens {
|
||||||
|
let token_bytes = token.as_bytes();
|
||||||
|
if token_bytes.len() == candidate_bytes.len()
|
||||||
|
&& bool::from(token_bytes.ct_eq(candidate_bytes))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract bearer token from gRPC metadata or HTTP Authorization header.
|
||||||
|
fn extract_bearer_token<T>(request: &Request<T>) -> Result<&str, Status> {
|
||||||
|
let auth = request
|
||||||
|
.metadata()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or_else(|| Status::unauthenticated("missing authorization header"))?;
|
||||||
|
|
||||||
|
auth.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| auth.strip_prefix("bearer "))
|
||||||
|
.ok_or_else(|| Status::unauthenticated("expected Bearer token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map JWK key algorithm to jsonwebtoken Algorithm.
|
||||||
|
fn key_algorithm_to_jwt_algorithm(
|
||||||
|
ka: jsonwebtoken::jwk::KeyAlgorithm,
|
||||||
|
) -> Option<Algorithm> {
|
||||||
|
use jsonwebtoken::jwk::KeyAlgorithm as KA;
|
||||||
|
match ka {
|
||||||
|
KA::RS256 => Some(Algorithm::RS256),
|
||||||
|
KA::RS384 => Some(Algorithm::RS384),
|
||||||
|
KA::RS512 => Some(Algorithm::RS512),
|
||||||
|
KA::ES256 => Some(Algorithm::ES256),
|
||||||
|
KA::ES384 => Some(Algorithm::ES384),
|
||||||
|
KA::PS256 => Some(Algorithm::PS256),
|
||||||
|
KA::PS384 => Some(Algorithm::PS384),
|
||||||
|
KA::PS512 => Some(Algorithm::PS512),
|
||||||
|
KA::EdDSA => Some(Algorithm::EdDSA),
|
||||||
|
_ => None, // Reject HS256, HS384, HS512 and unknown algorithms.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OIDC discovery response (minimal — we only need jwks_uri).
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OidcDiscovery {
|
||||||
|
jwks_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JWKS response.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JwksResponse {
|
||||||
|
keys: Vec<jsonwebtoken::jwk::Jwk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the JWKS URI from the OIDC discovery endpoint.
|
||||||
|
async fn discover_jwks_uri(
|
||||||
|
issuer: &str,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let discovery_url = format!(
|
||||||
|
"{}/.well-known/openid-configuration",
|
||||||
|
issuer.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let resp: OidcDiscovery = reqwest::get(&discovery_url).await?.json().await?;
|
||||||
|
Ok(resp.jwks_uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a tonic interceptor that checks auth on every request.
|
||||||
|
pub fn make_interceptor(
|
||||||
|
auth: Arc<AuthState>,
|
||||||
|
) -> impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone {
|
||||||
|
move |req: Request<()>| {
|
||||||
|
let auth = auth.clone();
|
||||||
|
|
||||||
|
// No auth configured = pass through.
|
||||||
|
if auth.config.tokens.is_empty() && auth.config.oidc_issuer.is_none() {
|
||||||
|
return Ok(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = match extract_bearer_token(&req) {
|
||||||
|
Ok(t) => t.to_string(),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
// CRITICAL-02: Constant-time static token check.
|
||||||
|
if check_static_tokens(&auth.config.tokens, &token) {
|
||||||
|
return Ok(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JWT via shared validate_jwt_cached (deduplicated logic).
|
||||||
|
if auth.config.oidc_issuer.is_some() {
|
||||||
|
auth.validate_jwt_cached(&token)?;
|
||||||
|
return Ok(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Status::unauthenticated("invalid token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_bearer_from_metadata() {
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer mytoken".parse().unwrap());
|
||||||
|
assert_eq!(extract_bearer_token(&req).unwrap(), "mytoken");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_bearer_lowercase() {
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "bearer mytoken".parse().unwrap());
|
||||||
|
assert_eq!(extract_bearer_token(&req).unwrap(), "mytoken");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_bearer_missing_header() {
|
||||||
|
let req = Request::new(());
|
||||||
|
assert!(extract_bearer_token(&req).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_bearer_wrong_scheme() {
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Basic abc".parse().unwrap());
|
||||||
|
assert!(extract_bearer_token(&req).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_time_token_check_valid() {
|
||||||
|
let tokens = vec!["secret123".to_string()];
|
||||||
|
assert!(check_static_tokens(&tokens, "secret123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_time_token_check_invalid() {
|
||||||
|
let tokens = vec!["secret123".to_string()];
|
||||||
|
assert!(!check_static_tokens(&tokens, "wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_time_token_check_empty() {
|
||||||
|
let tokens: Vec<String> = vec![];
|
||||||
|
assert!(!check_static_tokens(&tokens, "anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_time_token_check_length_mismatch() {
|
||||||
|
let tokens = vec!["short".to_string()];
|
||||||
|
assert!(!check_static_tokens(&tokens, "muchlongertoken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_auth_configured_allows_all() {
|
||||||
|
let state = AuthState {
|
||||||
|
config: AuthConfig::default(),
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let req = Request::new(());
|
||||||
|
assert!(state.check(&req).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn static_token_valid() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
tokens: vec!["secret123".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer secret123".parse().unwrap());
|
||||||
|
assert!(state.check(&req).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn static_token_invalid() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
tokens: vec!["secret123".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer wrong".parse().unwrap());
|
||||||
|
assert!(state.check(&req).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn static_token_missing_header() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
tokens: vec!["secret123".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let req = Request::new(());
|
||||||
|
assert!(state.check(&req).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interceptor_no_auth_passes() {
|
||||||
|
let state = Arc::new(AuthState {
|
||||||
|
config: AuthConfig::default(),
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
});
|
||||||
|
let interceptor = make_interceptor(state);
|
||||||
|
let req = Request::new(());
|
||||||
|
assert!(interceptor(req).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interceptor_static_token_valid() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
tokens: vec!["tok".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = Arc::new(AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
});
|
||||||
|
let interceptor = make_interceptor(state);
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer tok".parse().unwrap());
|
||||||
|
assert!(interceptor(req).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interceptor_static_token_invalid() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
tokens: vec!["tok".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = Arc::new(AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
});
|
||||||
|
let interceptor = make_interceptor(state);
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer bad".parse().unwrap());
|
||||||
|
assert!(interceptor(req).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: create a test RSA key pair, JWK, and signed JWT.
|
||||||
|
fn make_test_jwt(
|
||||||
|
issuer: &str,
|
||||||
|
audience: Option<&str>,
|
||||||
|
) -> (Vec<jsonwebtoken::jwk::Jwk>, String) {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use rsa::RsaPrivateKey;
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
|
||||||
|
let public_key = private_key.to_public_key();
|
||||||
|
|
||||||
|
use rsa::traits::PublicKeyParts;
|
||||||
|
let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
|
||||||
|
let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
|
||||||
|
|
||||||
|
let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(serde_json::json!({
|
||||||
|
"kty": "RSA",
|
||||||
|
"use": "sig",
|
||||||
|
"alg": "RS256",
|
||||||
|
"kid": "test-key-1",
|
||||||
|
"n": n,
|
||||||
|
"e": e,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
use rsa::pkcs1::EncodeRsaPrivateKey;
|
||||||
|
let pem = private_key
|
||||||
|
.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
|
||||||
|
.unwrap();
|
||||||
|
let encoding_key =
|
||||||
|
jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
|
||||||
|
header.kid = Some("test-key-1".to_string());
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct TestClaims {
|
||||||
|
sub: String,
|
||||||
|
iss: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
aud: Option<String>,
|
||||||
|
exp: u64,
|
||||||
|
iat: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let claims = TestClaims {
|
||||||
|
sub: "user@example.com".to_string(),
|
||||||
|
iss: issuer.to_string(),
|
||||||
|
aud: audience.map(String::from),
|
||||||
|
exp: now + 3600,
|
||||||
|
iat: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = jsonwebtoken::encode(&header, &claims, &encoding_key).unwrap();
|
||||||
|
(vec![jwk], token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jwt_validation_valid_token() {
|
||||||
|
let issuer = "https://auth.example.com";
|
||||||
|
let (jwks, token) = make_test_jwt(issuer, None);
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some(issuer.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: jwks })),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||||
|
assert!(state.check(&req).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jwt_validation_wrong_issuer() {
|
||||||
|
let (jwks, token) = make_test_jwt("https://wrong-issuer.com", None);
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some("https://expected-issuer.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: jwks })),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||||
|
assert!(state.check(&req).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jwt_validation_with_audience() {
|
||||||
|
let issuer = "https://auth.example.com";
|
||||||
|
let (jwks, token) = make_test_jwt(issuer, Some("wfe-server"));
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some(issuer.to_string()),
|
||||||
|
oidc_audience: Some("wfe-server".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: jwks })),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||||
|
assert!(state.check(&req).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jwt_validation_wrong_audience() {
|
||||||
|
let issuer = "https://auth.example.com";
|
||||||
|
let (jwks, token) = make_test_jwt(issuer, Some("wrong-audience"));
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some(issuer.to_string()),
|
||||||
|
oidc_audience: Some("wfe-server".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: jwks })),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||||
|
assert!(state.check(&req).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jwt_validation_garbage_token() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some("https://auth.example.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: vec![] })),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer not.a.jwt".parse().unwrap());
|
||||||
|
assert!(state.check(&req).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jwt_validation_no_jwks_loaded() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some("https://auth.example.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(None),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer some.jwt.token".parse().unwrap());
|
||||||
|
let err = state.check(&req).await.unwrap_err();
|
||||||
|
assert_eq!(err.code(), tonic::Code::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interceptor_jwt_valid() {
|
||||||
|
let issuer = "https://auth.example.com";
|
||||||
|
let (jwks, token) = make_test_jwt(issuer, None);
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some(issuer.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = Arc::new(AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: jwks })),
|
||||||
|
jwks_uri: None,
|
||||||
|
});
|
||||||
|
let interceptor = make_interceptor(state);
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||||
|
assert!(interceptor(req).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interceptor_jwt_invalid() {
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some("https://auth.example.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = Arc::new(AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: vec![] })),
|
||||||
|
jwks_uri: None,
|
||||||
|
});
|
||||||
|
let interceptor = make_interceptor(state);
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer bad.jwt.token".parse().unwrap());
|
||||||
|
assert!(interceptor(req).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_algorithm_mapping() {
|
||||||
|
use jsonwebtoken::jwk::KeyAlgorithm as KA;
|
||||||
|
assert_eq!(key_algorithm_to_jwt_algorithm(KA::RS256), Some(Algorithm::RS256));
|
||||||
|
assert_eq!(key_algorithm_to_jwt_algorithm(KA::ES256), Some(Algorithm::ES256));
|
||||||
|
assert_eq!(key_algorithm_to_jwt_algorithm(KA::EdDSA), Some(Algorithm::EdDSA));
|
||||||
|
// HS256 should be rejected (symmetric algorithm).
|
||||||
|
assert_eq!(key_algorithm_to_jwt_algorithm(KA::HS256), None);
|
||||||
|
assert_eq!(key_algorithm_to_jwt_algorithm(KA::HS384), None);
|
||||||
|
assert_eq!(key_algorithm_to_jwt_algorithm(KA::HS512), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowed_algorithms_rejects_symmetric() {
|
||||||
|
assert!(!ALLOWED_ALGORITHMS.contains(&Algorithm::HS256));
|
||||||
|
assert!(!ALLOWED_ALGORITHMS.contains(&Algorithm::HS384));
|
||||||
|
assert!(!ALLOWED_ALGORITHMS.contains(&Algorithm::HS512));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Security regression tests ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn security_hs256_rejected_in_allowlist() {
|
||||||
|
// CRITICAL-01: HS256 must NEVER be in the allowlist.
|
||||||
|
// An attacker with the public RSA key could forge tokens if HS256 is allowed.
|
||||||
|
assert!(!ALLOWED_ALGORITHMS.contains(&Algorithm::HS256));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn security_key_algorithm_rejects_all_symmetric() {
|
||||||
|
// CRITICAL-01: key_algorithm_to_jwt_algorithm must return None for symmetric algs.
|
||||||
|
use jsonwebtoken::jwk::KeyAlgorithm as KA;
|
||||||
|
assert!(key_algorithm_to_jwt_algorithm(KA::HS256).is_none());
|
||||||
|
assert!(key_algorithm_to_jwt_algorithm(KA::HS384).is_none());
|
||||||
|
assert!(key_algorithm_to_jwt_algorithm(KA::HS512).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn security_constant_time_comparison_used() {
|
||||||
|
// CRITICAL-02: Static token check must use constant-time comparison.
|
||||||
|
// Verify that equal-length wrong tokens don't short-circuit.
|
||||||
|
let tokens = vec!["abcdefgh".to_string()];
|
||||||
|
// Both are 8 chars — a timing attack would try this.
|
||||||
|
assert!(!check_static_tokens(&tokens, "abcdefgX"));
|
||||||
|
assert!(check_static_tokens(&tokens, "abcdefgh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[should_panic(expected = "OIDC issuer must use HTTPS")]
|
||||||
|
async fn security_oidc_issuer_requires_https() {
|
||||||
|
// HIGH-03: Non-HTTPS issuers must be rejected (SSRF prevention).
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some("http://evil.internal:8080".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
AuthState::new(config).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn security_jwt_requires_kid_with_multiple_keys() {
|
||||||
|
// MEDIUM-06: When JWKS has multiple keys, JWT must have kid header.
|
||||||
|
let (mut jwks, token) = make_test_jwt("https://auth.example.com", None);
|
||||||
|
// Duplicate the key with a different kid.
|
||||||
|
let mut key2 = jwks[0].clone();
|
||||||
|
key2.common.key_id = Some("test-key-2".to_string());
|
||||||
|
jwks.push(key2);
|
||||||
|
|
||||||
|
// Strip kid from the token by decoding, modifying header, re-encoding.
|
||||||
|
// Easier: just test the validate path with multiple keys and a token that has kid.
|
||||||
|
// The token from make_test_jwt has kid="test-key-1", so it should work.
|
||||||
|
let config = AuthConfig {
|
||||||
|
oidc_issuer: Some("https://auth.example.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = AuthState {
|
||||||
|
config,
|
||||||
|
jwks: RwLock::new(Some(JwksCache { keys: jwks })),
|
||||||
|
jwks_uri: None,
|
||||||
|
};
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||||
|
// Should succeed because the token has kid="test-key-1" which matches.
|
||||||
|
assert!(state.check(&req).await.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
363
wfe-server/src/config.rs
Normal file
363
wfe-server/src/config.rs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// WFE workflow server.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "wfe-server", version, about)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Config file path.
|
||||||
|
#[arg(short, long, default_value = "wfe-server.toml")]
|
||||||
|
pub config: PathBuf,
|
||||||
|
|
||||||
|
/// gRPC listen address.
|
||||||
|
#[arg(long, env = "WFE_GRPC_ADDR")]
|
||||||
|
pub grpc_addr: Option<SocketAddr>,
|
||||||
|
|
||||||
|
/// HTTP listen address (webhooks).
|
||||||
|
#[arg(long, env = "WFE_HTTP_ADDR")]
|
||||||
|
pub http_addr: Option<SocketAddr>,
|
||||||
|
|
||||||
|
/// Persistence backend: sqlite or postgres.
|
||||||
|
#[arg(long, env = "WFE_PERSISTENCE")]
|
||||||
|
pub persistence: Option<String>,
|
||||||
|
|
||||||
|
/// Database URL or path.
|
||||||
|
#[arg(long, env = "WFE_DB_URL")]
|
||||||
|
pub db_url: Option<String>,
|
||||||
|
|
||||||
|
/// Queue backend: memory or valkey.
|
||||||
|
#[arg(long, env = "WFE_QUEUE")]
|
||||||
|
pub queue: Option<String>,
|
||||||
|
|
||||||
|
/// Queue URL (for valkey).
|
||||||
|
#[arg(long, env = "WFE_QUEUE_URL")]
|
||||||
|
pub queue_url: Option<String>,
|
||||||
|
|
||||||
|
/// OpenSearch URL (enables log + workflow search).
|
||||||
|
#[arg(long, env = "WFE_SEARCH_URL")]
|
||||||
|
pub search_url: Option<String>,
|
||||||
|
|
||||||
|
/// Directory to auto-load YAML workflow definitions from.
|
||||||
|
#[arg(long, env = "WFE_WORKFLOWS_DIR")]
|
||||||
|
pub workflows_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Comma-separated bearer tokens for API auth.
|
||||||
|
#[arg(long, env = "WFE_AUTH_TOKENS")]
|
||||||
|
pub auth_tokens: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server configuration (deserialized from TOML).
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub grpc_addr: SocketAddr,
|
||||||
|
pub http_addr: SocketAddr,
|
||||||
|
pub persistence: PersistenceConfig,
|
||||||
|
pub queue: QueueConfig,
|
||||||
|
pub search: Option<SearchConfig>,
|
||||||
|
pub auth: AuthConfig,
|
||||||
|
pub webhook: WebhookConfig,
|
||||||
|
pub workflows_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
grpc_addr: "0.0.0.0:50051".parse().unwrap(),
|
||||||
|
http_addr: "0.0.0.0:8080".parse().unwrap(),
|
||||||
|
persistence: PersistenceConfig::default(),
|
||||||
|
queue: QueueConfig::default(),
|
||||||
|
search: None,
|
||||||
|
auth: AuthConfig::default(),
|
||||||
|
webhook: WebhookConfig::default(),
|
||||||
|
workflows_dir: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "backend")]
|
||||||
|
pub enum PersistenceConfig {
|
||||||
|
#[serde(rename = "sqlite")]
|
||||||
|
Sqlite { path: String },
|
||||||
|
#[serde(rename = "postgres")]
|
||||||
|
Postgres { url: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PersistenceConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Sqlite {
|
||||||
|
path: "wfe.db".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "backend")]
|
||||||
|
pub enum QueueConfig {
|
||||||
|
#[serde(rename = "memory")]
|
||||||
|
InMemory,
|
||||||
|
#[serde(rename = "valkey")]
|
||||||
|
Valkey { url: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for QueueConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::InMemory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct SearchConfig {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
/// Static bearer tokens (simple auth, no OIDC needed).
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens: Vec<String>,
|
||||||
|
/// OIDC issuer URL (e.g., https://auth.example.com/realms/myapp).
|
||||||
|
/// Enables JWT validation via OIDC discovery + JWKS.
|
||||||
|
#[serde(default)]
|
||||||
|
pub oidc_issuer: Option<String>,
|
||||||
|
/// Expected JWT audience claim.
|
||||||
|
#[serde(default)]
|
||||||
|
pub oidc_audience: Option<String>,
|
||||||
|
/// Webhook HMAC secrets per source.
|
||||||
|
#[serde(default)]
|
||||||
|
pub webhook_secrets: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
pub struct WebhookConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub triggers: Vec<WebhookTrigger>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct WebhookTrigger {
|
||||||
|
pub source: String,
|
||||||
|
pub event: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub match_ref: Option<String>,
|
||||||
|
pub workflow_id: String,
|
||||||
|
pub version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub data_mapping: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configuration with layered overrides: CLI > env > file.
|
||||||
|
pub fn load(cli: &Cli) -> ServerConfig {
|
||||||
|
let mut config = if cli.config.exists() {
|
||||||
|
let content = std::fs::read_to_string(&cli.config)
|
||||||
|
.unwrap_or_else(|e| panic!("failed to read config file {}: {e}", cli.config.display()));
|
||||||
|
toml::from_str(&content)
|
||||||
|
.unwrap_or_else(|e| panic!("failed to parse config file {}: {e}", cli.config.display()))
|
||||||
|
} else {
|
||||||
|
ServerConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(addr) = cli.grpc_addr {
|
||||||
|
config.grpc_addr = addr;
|
||||||
|
}
|
||||||
|
if let Some(addr) = cli.http_addr {
|
||||||
|
config.http_addr = addr;
|
||||||
|
}
|
||||||
|
if let Some(ref dir) = cli.workflows_dir {
|
||||||
|
config.workflows_dir = Some(dir.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistence override.
|
||||||
|
if let Some(ref backend) = cli.persistence {
|
||||||
|
let url = cli
|
||||||
|
.db_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "wfe.db".to_string());
|
||||||
|
config.persistence = match backend.as_str() {
|
||||||
|
"postgres" => PersistenceConfig::Postgres { url },
|
||||||
|
_ => PersistenceConfig::Sqlite { path: url },
|
||||||
|
};
|
||||||
|
} else if let Some(ref url) = cli.db_url {
|
||||||
|
// Infer backend from URL.
|
||||||
|
if url.starts_with("postgres") {
|
||||||
|
config.persistence = PersistenceConfig::Postgres { url: url.clone() };
|
||||||
|
} else {
|
||||||
|
config.persistence = PersistenceConfig::Sqlite { path: url.clone() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue override.
|
||||||
|
if let Some(ref backend) = cli.queue {
|
||||||
|
config.queue = match backend.as_str() {
|
||||||
|
"valkey" | "redis" => {
|
||||||
|
let url = cli
|
||||||
|
.queue_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "redis://127.0.0.1:6379".to_string());
|
||||||
|
QueueConfig::Valkey { url }
|
||||||
|
}
|
||||||
|
_ => QueueConfig::InMemory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search override.
|
||||||
|
if let Some(ref url) = cli.search_url {
|
||||||
|
config.search = Some(SearchConfig { url: url.clone() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth tokens override.
|
||||||
|
if let Some(ref tokens) = cli.auth_tokens {
|
||||||
|
config.auth.tokens = tokens
|
||||||
|
.split(',')
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config() {
|
||||||
|
let config = ServerConfig::default();
|
||||||
|
assert_eq!(config.grpc_addr, "0.0.0.0:50051".parse().unwrap());
|
||||||
|
assert_eq!(config.http_addr, "0.0.0.0:8080".parse().unwrap());
|
||||||
|
assert!(matches!(config.persistence, PersistenceConfig::Sqlite { .. }));
|
||||||
|
assert!(matches!(config.queue, QueueConfig::InMemory));
|
||||||
|
assert!(config.search.is_none());
|
||||||
|
assert!(config.auth.tokens.is_empty());
|
||||||
|
assert!(config.webhook.triggers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_toml_config() {
|
||||||
|
let toml = r#"
|
||||||
|
grpc_addr = "127.0.0.1:9090"
|
||||||
|
http_addr = "127.0.0.1:8081"
|
||||||
|
|
||||||
|
[persistence]
|
||||||
|
backend = "postgres"
|
||||||
|
url = "postgres://localhost/wfe"
|
||||||
|
|
||||||
|
[queue]
|
||||||
|
backend = "valkey"
|
||||||
|
url = "redis://localhost:6379"
|
||||||
|
|
||||||
|
[search]
|
||||||
|
url = "http://localhost:9200"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
tokens = ["token1", "token2"]
|
||||||
|
|
||||||
|
[auth.webhook_secrets]
|
||||||
|
github = "mysecret"
|
||||||
|
|
||||||
|
[[webhook.triggers]]
|
||||||
|
source = "github"
|
||||||
|
event = "push"
|
||||||
|
match_ref = "refs/heads/main"
|
||||||
|
workflow_id = "ci"
|
||||||
|
version = 1
|
||||||
|
"#;
|
||||||
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.grpc_addr, "127.0.0.1:9090".parse().unwrap());
|
||||||
|
assert!(matches!(config.persistence, PersistenceConfig::Postgres { .. }));
|
||||||
|
assert!(matches!(config.queue, QueueConfig::Valkey { .. }));
|
||||||
|
assert!(config.search.is_some());
|
||||||
|
assert_eq!(config.auth.tokens.len(), 2);
|
||||||
|
assert_eq!(config.auth.webhook_secrets.get("github").unwrap(), "mysecret");
|
||||||
|
assert_eq!(config.webhook.triggers.len(), 1);
|
||||||
|
assert_eq!(config.webhook.triggers[0].workflow_id, "ci");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_overrides_file() {
|
||||||
|
let cli = Cli {
|
||||||
|
config: PathBuf::from("/nonexistent"),
|
||||||
|
grpc_addr: Some("127.0.0.1:9999".parse().unwrap()),
|
||||||
|
http_addr: None,
|
||||||
|
persistence: Some("postgres".to_string()),
|
||||||
|
db_url: Some("postgres://db/wfe".to_string()),
|
||||||
|
queue: Some("valkey".to_string()),
|
||||||
|
queue_url: Some("redis://valkey:6379".to_string()),
|
||||||
|
search_url: Some("http://os:9200".to_string()),
|
||||||
|
workflows_dir: Some(PathBuf::from("/workflows")),
|
||||||
|
auth_tokens: Some("tok1, tok2".to_string()),
|
||||||
|
};
|
||||||
|
let config = load(&cli);
|
||||||
|
assert_eq!(config.grpc_addr, "127.0.0.1:9999".parse().unwrap());
|
||||||
|
assert!(matches!(config.persistence, PersistenceConfig::Postgres { ref url } if url == "postgres://db/wfe"));
|
||||||
|
assert!(matches!(config.queue, QueueConfig::Valkey { ref url } if url == "redis://valkey:6379"));
|
||||||
|
assert_eq!(config.search.unwrap().url, "http://os:9200");
|
||||||
|
assert_eq!(config.workflows_dir.unwrap(), PathBuf::from("/workflows"));
|
||||||
|
assert_eq!(config.auth.tokens, vec!["tok1", "tok2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infer_postgres_from_url() {
|
||||||
|
let cli = Cli {
|
||||||
|
config: PathBuf::from("/nonexistent"),
|
||||||
|
grpc_addr: None,
|
||||||
|
http_addr: None,
|
||||||
|
persistence: None,
|
||||||
|
db_url: Some("postgres://localhost/wfe".to_string()),
|
||||||
|
queue: None,
|
||||||
|
queue_url: None,
|
||||||
|
search_url: None,
|
||||||
|
workflows_dir: None,
|
||||||
|
auth_tokens: None,
|
||||||
|
};
|
||||||
|
let config = load(&cli);
|
||||||
|
assert!(matches!(config.persistence, PersistenceConfig::Postgres { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Security regression tests ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "failed to parse config file")]
|
||||||
|
fn security_malformed_config_panics() {
|
||||||
|
// HIGH-19: Malformed config must NOT silently fall back to defaults.
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
std::fs::write(tmp.path(), "this is not { valid toml @@@@").unwrap();
|
||||||
|
let cli = Cli {
|
||||||
|
config: tmp.path().to_path_buf(),
|
||||||
|
grpc_addr: None,
|
||||||
|
http_addr: None,
|
||||||
|
persistence: None,
|
||||||
|
db_url: None,
|
||||||
|
queue: None,
|
||||||
|
queue_url: None,
|
||||||
|
search_url: None,
|
||||||
|
workflows_dir: None,
|
||||||
|
auth_tokens: None,
|
||||||
|
};
|
||||||
|
load(&cli);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trigger_data_mapping() {
|
||||||
|
let toml = r#"
|
||||||
|
[[triggers]]
|
||||||
|
source = "github"
|
||||||
|
event = "push"
|
||||||
|
workflow_id = "ci"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[triggers.data_mapping]
|
||||||
|
repo = "$.repository.full_name"
|
||||||
|
commit = "$.head_commit.id"
|
||||||
|
"#;
|
||||||
|
let config: WebhookConfig = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.triggers[0].data_mapping.len(), 2);
|
||||||
|
assert_eq!(config.triggers[0].data_mapping["repo"], "$.repository.full_name");
|
||||||
|
}
|
||||||
|
}
|
||||||
862
wfe-server/src/grpc.rs
Normal file
862
wfe-server/src/grpc.rs
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use wfe_server_protos::wfe::v1::*;
|
||||||
|
use wfe_server_protos::wfe::v1::wfe_server::Wfe;
|
||||||
|
|
||||||
|
pub struct WfeService {
|
||||||
|
host: Arc<wfe::WorkflowHost>,
|
||||||
|
lifecycle_bus: Arc<crate::lifecycle_bus::BroadcastLifecyclePublisher>,
|
||||||
|
log_store: Arc<crate::log_store::LogStore>,
|
||||||
|
log_search: Option<Arc<crate::log_search::LogSearchIndex>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WfeService {
|
||||||
|
pub fn new(
|
||||||
|
host: Arc<wfe::WorkflowHost>,
|
||||||
|
lifecycle_bus: Arc<crate::lifecycle_bus::BroadcastLifecyclePublisher>,
|
||||||
|
log_store: Arc<crate::log_store::LogStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self { host, lifecycle_bus, log_store, log_search: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_log_search(mut self, index: Arc<crate::log_search::LogSearchIndex>) -> Self {
|
||||||
|
self.log_search = Some(index);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl Wfe for WfeService {
|
||||||
|
// ── Definitions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn register_workflow(
|
||||||
|
&self,
|
||||||
|
request: Request<RegisterWorkflowRequest>,
|
||||||
|
) -> Result<Response<RegisterWorkflowResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
let config: HashMap<String, serde_json::Value> = req
|
||||||
|
.config
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, serde_json::Value::String(v)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let workflows = wfe_yaml::load_workflow_from_str(&req.yaml, &config)
|
||||||
|
.map_err(|e| Status::invalid_argument(format!("YAML compilation failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut definitions = Vec::new();
|
||||||
|
|
||||||
|
for compiled in workflows {
|
||||||
|
for (key, factory) in compiled.step_factories {
|
||||||
|
self.host.register_step_factory(&key, factory).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = compiled.definition.id.clone();
|
||||||
|
let version = compiled.definition.version;
|
||||||
|
let step_count = compiled.definition.steps.len() as u32;
|
||||||
|
|
||||||
|
self.host
|
||||||
|
.register_workflow_definition(compiled.definition)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
definitions.push(RegisteredDefinition {
|
||||||
|
definition_id: id,
|
||||||
|
version,
|
||||||
|
step_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Response::new(RegisterWorkflowResponse { definitions }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_definitions(
|
||||||
|
&self,
|
||||||
|
_request: Request<ListDefinitionsRequest>,
|
||||||
|
) -> Result<Response<ListDefinitionsResponse>, Status> {
|
||||||
|
// TODO: add list_definitions() to WorkflowHost
|
||||||
|
Ok(Response::new(ListDefinitionsResponse {
|
||||||
|
definitions: vec![],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Instances ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn start_workflow(
|
||||||
|
&self,
|
||||||
|
request: Request<StartWorkflowRequest>,
|
||||||
|
) -> Result<Response<StartWorkflowResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
let data = req
|
||||||
|
.data
|
||||||
|
.map(struct_to_json)
|
||||||
|
.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
let workflow_id = self
|
||||||
|
.host
|
||||||
|
.start_workflow(&req.definition_id, req.version, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("failed to start workflow: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(StartWorkflowResponse { workflow_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_workflow(
|
||||||
|
&self,
|
||||||
|
request: Request<GetWorkflowRequest>,
|
||||||
|
) -> Result<Response<GetWorkflowResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
let instance = self
|
||||||
|
.host
|
||||||
|
.get_workflow(&req.workflow_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(format!("workflow not found: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(GetWorkflowResponse {
|
||||||
|
instance: Some(workflow_to_proto(&instance)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel_workflow(
|
||||||
|
&self,
|
||||||
|
request: Request<CancelWorkflowRequest>,
|
||||||
|
) -> Result<Response<CancelWorkflowResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
self.host
|
||||||
|
.terminate_workflow(&req.workflow_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("failed to cancel: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(CancelWorkflowResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn suspend_workflow(
|
||||||
|
&self,
|
||||||
|
request: Request<SuspendWorkflowRequest>,
|
||||||
|
) -> Result<Response<SuspendWorkflowResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
self.host
|
||||||
|
.suspend_workflow(&req.workflow_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("failed to suspend: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(SuspendWorkflowResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resume_workflow(
|
||||||
|
&self,
|
||||||
|
request: Request<ResumeWorkflowRequest>,
|
||||||
|
) -> Result<Response<ResumeWorkflowResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
self.host
|
||||||
|
.resume_workflow(&req.workflow_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("failed to resume: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(ResumeWorkflowResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_workflows(
|
||||||
|
&self,
|
||||||
|
_request: Request<SearchWorkflowsRequest>,
|
||||||
|
) -> Result<Response<SearchWorkflowsResponse>, Status> {
|
||||||
|
// TODO: implement with SearchIndex
|
||||||
|
Ok(Response::new(SearchWorkflowsResponse {
|
||||||
|
results: vec![],
|
||||||
|
total: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn publish_event(
|
||||||
|
&self,
|
||||||
|
request: Request<PublishEventRequest>,
|
||||||
|
) -> Result<Response<PublishEventResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
let data = req
|
||||||
|
.data
|
||||||
|
.map(struct_to_json)
|
||||||
|
.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
self.host
|
||||||
|
.publish_event(&req.event_name, &req.event_key, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("failed to publish event: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(PublishEventResponse {
|
||||||
|
event_id: String::new(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Streaming (stubs for now) ────────────────────────────────────
|
||||||
|
|
||||||
|
type WatchLifecycleStream =
|
||||||
|
tokio_stream::wrappers::ReceiverStream<Result<LifecycleEvent, Status>>;
|
||||||
|
|
||||||
|
async fn watch_lifecycle(
|
||||||
|
&self,
|
||||||
|
request: Request<WatchLifecycleRequest>,
|
||||||
|
) -> Result<Response<Self::WatchLifecycleStream>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let filter_workflow_id = if req.workflow_id.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(req.workflow_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut broadcast_rx = self.lifecycle_bus.subscribe();
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match broadcast_rx.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
// Apply workflow_id filter.
|
||||||
|
if let Some(ref filter) = filter_workflow_id {
|
||||||
|
if event.workflow_instance_id != *filter {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let proto_event = lifecycle_event_to_proto(&event);
|
||||||
|
if tx.send(Ok(proto_event)).await.is_err() {
|
||||||
|
break; // Client disconnected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!(lagged = n, "lifecycle watcher lagged, skipping events");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamLogsStream = tokio_stream::wrappers::ReceiverStream<Result<LogEntry, Status>>;
|
||||||
|
|
||||||
|
async fn stream_logs(
|
||||||
|
&self,
|
||||||
|
request: Request<StreamLogsRequest>,
|
||||||
|
) -> Result<Response<Self::StreamLogsStream>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let workflow_id = req.workflow_id.clone();
|
||||||
|
let step_name_filter = if req.step_name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(req.step_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
let log_store = self.log_store.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// 1. Replay history first.
|
||||||
|
let history = log_store.get_history(&workflow_id, None);
|
||||||
|
for chunk in history {
|
||||||
|
if let Some(ref filter) = step_name_filter {
|
||||||
|
if chunk.step_name != *filter {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entry = log_chunk_to_proto(&chunk);
|
||||||
|
if tx.send(Ok(entry)).await.is_err() {
|
||||||
|
return; // Client disconnected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If follow mode, switch to live broadcast.
|
||||||
|
if req.follow {
|
||||||
|
let mut broadcast_rx = log_store.subscribe(&workflow_id);
|
||||||
|
loop {
|
||||||
|
match broadcast_rx.recv().await {
|
||||||
|
Ok(chunk) => {
|
||||||
|
if let Some(ref filter) = step_name_filter {
|
||||||
|
if chunk.step_name != *filter {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entry = log_chunk_to_proto(&chunk);
|
||||||
|
if tx.send(Ok(entry)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!(lagged = n, "log stream lagged");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not follow mode, the stream ends after history replay.
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn search_logs(
|
||||||
|
&self,
|
||||||
|
request: Request<SearchLogsRequest>,
|
||||||
|
) -> Result<Response<SearchLogsResponse>, Status> {
|
||||||
|
let Some(ref search) = self.log_search else {
|
||||||
|
return Err(Status::unavailable("log search not configured — set --search-url"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = request.into_inner();
|
||||||
|
let workflow_id = if req.workflow_id.is_empty() { None } else { Some(req.workflow_id.as_str()) };
|
||||||
|
let step_name = if req.step_name.is_empty() { None } else { Some(req.step_name.as_str()) };
|
||||||
|
let stream_filter = match req.stream_filter {
|
||||||
|
x if x == LogStream::Stdout as i32 => Some("stdout"),
|
||||||
|
x if x == LogStream::Stderr as i32 => Some("stderr"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let take = if req.take == 0 { 50 } else { req.take };
|
||||||
|
|
||||||
|
let (hits, total) = search
|
||||||
|
.search(&req.query, workflow_id, step_name, stream_filter, req.skip, take)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("search failed: {e}")))?;
|
||||||
|
|
||||||
|
let results = hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| {
|
||||||
|
let stream = match h.stream.as_str() {
|
||||||
|
"stdout" => LogStream::Stdout as i32,
|
||||||
|
"stderr" => LogStream::Stderr as i32,
|
||||||
|
_ => LogStream::Unspecified as i32,
|
||||||
|
};
|
||||||
|
LogSearchResult {
|
||||||
|
workflow_id: h.workflow_id,
|
||||||
|
definition_id: h.definition_id,
|
||||||
|
step_name: h.step_name,
|
||||||
|
line: h.line,
|
||||||
|
stream,
|
||||||
|
timestamp: Some(datetime_to_timestamp(&h.timestamp)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Response::new(SearchLogsResponse { results, total }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversion helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn struct_to_json(s: prost_types::Struct) -> serde_json::Value {
|
||||||
|
let map: serde_json::Map<String, serde_json::Value> = s
|
||||||
|
.fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, prost_value_to_json(v)))
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prost_value_to_json(v: prost_types::Value) -> serde_json::Value {
|
||||||
|
use prost_types::value::Kind;
|
||||||
|
match v.kind {
|
||||||
|
Some(Kind::NullValue(_)) => serde_json::Value::Null,
|
||||||
|
Some(Kind::NumberValue(n)) => serde_json::json!(n),
|
||||||
|
Some(Kind::StringValue(s)) => serde_json::Value::String(s),
|
||||||
|
Some(Kind::BoolValue(b)) => serde_json::Value::Bool(b),
|
||||||
|
Some(Kind::StructValue(s)) => struct_to_json(s),
|
||||||
|
Some(Kind::ListValue(l)) => {
|
||||||
|
serde_json::Value::Array(l.values.into_iter().map(prost_value_to_json).collect())
|
||||||
|
}
|
||||||
|
None => serde_json::Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_struct(v: &serde_json::Value) -> prost_types::Struct {
|
||||||
|
let fields: BTreeMap<String, prost_types::Value> = match v.as_object() {
|
||||||
|
Some(obj) => obj
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), json_to_prost_value(v)))
|
||||||
|
.collect(),
|
||||||
|
None => BTreeMap::new(),
|
||||||
|
};
|
||||||
|
prost_types::Struct { fields }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_prost_value(v: &serde_json::Value) -> prost_types::Value {
|
||||||
|
use prost_types::value::Kind;
|
||||||
|
let kind = match v {
|
||||||
|
serde_json::Value::Null => Kind::NullValue(0),
|
||||||
|
serde_json::Value::Bool(b) => Kind::BoolValue(*b),
|
||||||
|
serde_json::Value::Number(n) => Kind::NumberValue(n.as_f64().unwrap_or(0.0)),
|
||||||
|
serde_json::Value::String(s) => Kind::StringValue(s.clone()),
|
||||||
|
serde_json::Value::Array(arr) => Kind::ListValue(prost_types::ListValue {
|
||||||
|
values: arr.iter().map(json_to_prost_value).collect(),
|
||||||
|
}),
|
||||||
|
serde_json::Value::Object(_) => Kind::StructValue(json_to_struct(v)),
|
||||||
|
};
|
||||||
|
prost_types::Value { kind: Some(kind) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_chunk_to_proto(chunk: &wfe_core::traits::LogChunk) -> LogEntry {
|
||||||
|
use wfe_core::traits::LogStreamType;
|
||||||
|
let stream = match chunk.stream {
|
||||||
|
LogStreamType::Stdout => LogStream::Stdout as i32,
|
||||||
|
LogStreamType::Stderr => LogStream::Stderr as i32,
|
||||||
|
};
|
||||||
|
LogEntry {
|
||||||
|
workflow_id: chunk.workflow_id.clone(),
|
||||||
|
step_name: chunk.step_name.clone(),
|
||||||
|
step_id: chunk.step_id as u32,
|
||||||
|
stream,
|
||||||
|
data: chunk.data.clone(),
|
||||||
|
timestamp: Some(datetime_to_timestamp(&chunk.timestamp)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lifecycle_event_to_proto(e: &wfe_core::models::LifecycleEvent) -> LifecycleEvent {
|
||||||
|
use wfe_core::models::LifecycleEventType as LET;
|
||||||
|
// Proto enum — prost strips the LIFECYCLE_EVENT_TYPE_ prefix.
|
||||||
|
use wfe_server_protos::wfe::v1::LifecycleEventType as PLET;
|
||||||
|
let (event_type, step_id, step_name, error_message) = match &e.event_type {
|
||||||
|
LET::Started => (PLET::Started as i32, 0, String::new(), String::new()),
|
||||||
|
LET::Completed => (PLET::Completed as i32, 0, String::new(), String::new()),
|
||||||
|
LET::Terminated => (PLET::Terminated as i32, 0, String::new(), String::new()),
|
||||||
|
LET::Suspended => (PLET::Suspended as i32, 0, String::new(), String::new()),
|
||||||
|
LET::Resumed => (PLET::Resumed as i32, 0, String::new(), String::new()),
|
||||||
|
LET::Error { message } => (PLET::Error as i32, 0, String::new(), message.clone()),
|
||||||
|
LET::StepStarted { step_id, step_name } => (PLET::StepStarted as i32, *step_id as u32, step_name.clone().unwrap_or_default(), String::new()),
|
||||||
|
LET::StepCompleted { step_id, step_name } => (PLET::StepCompleted as i32, *step_id as u32, step_name.clone().unwrap_or_default(), String::new()),
|
||||||
|
};
|
||||||
|
LifecycleEvent {
|
||||||
|
event_time: Some(datetime_to_timestamp(&e.event_time_utc)),
|
||||||
|
workflow_id: e.workflow_instance_id.clone(),
|
||||||
|
definition_id: e.workflow_definition_id.clone(),
|
||||||
|
version: e.version,
|
||||||
|
event_type,
|
||||||
|
step_id,
|
||||||
|
step_name,
|
||||||
|
error_message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn datetime_to_timestamp(dt: &chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
|
||||||
|
prost_types::Timestamp {
|
||||||
|
seconds: dt.timestamp(),
|
||||||
|
nanos: dt.timestamp_subsec_nanos() as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workflow_to_proto(w: &wfe_core::models::WorkflowInstance) -> WorkflowInstance {
|
||||||
|
WorkflowInstance {
|
||||||
|
id: w.id.clone(),
|
||||||
|
definition_id: w.workflow_definition_id.clone(),
|
||||||
|
version: w.version,
|
||||||
|
description: w.description.clone().unwrap_or_default(),
|
||||||
|
reference: w.reference.clone().unwrap_or_default(),
|
||||||
|
status: match w.status {
|
||||||
|
wfe_core::models::WorkflowStatus::Runnable => WorkflowStatus::Runnable as i32,
|
||||||
|
wfe_core::models::WorkflowStatus::Suspended => WorkflowStatus::Suspended as i32,
|
||||||
|
wfe_core::models::WorkflowStatus::Complete => WorkflowStatus::Complete as i32,
|
||||||
|
wfe_core::models::WorkflowStatus::Terminated => WorkflowStatus::Terminated as i32,
|
||||||
|
},
|
||||||
|
data: Some(json_to_struct(&w.data)),
|
||||||
|
create_time: Some(datetime_to_timestamp(&w.create_time)),
|
||||||
|
complete_time: w.complete_time.as_ref().map(datetime_to_timestamp),
|
||||||
|
execution_pointers: w
|
||||||
|
.execution_pointers
|
||||||
|
.iter()
|
||||||
|
.map(pointer_to_proto)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pointer_to_proto(p: &wfe_core::models::ExecutionPointer) -> ExecutionPointer {
|
||||||
|
use wfe_core::models::PointerStatus as PS;
|
||||||
|
let status = match p.status {
|
||||||
|
PS::Pending | PS::PendingPredecessor => PointerStatus::Pending as i32,
|
||||||
|
PS::Running => PointerStatus::Running as i32,
|
||||||
|
PS::Complete => PointerStatus::Complete as i32,
|
||||||
|
PS::Sleeping => PointerStatus::Sleeping as i32,
|
||||||
|
PS::WaitingForEvent => PointerStatus::WaitingForEvent as i32,
|
||||||
|
PS::Failed => PointerStatus::Failed as i32,
|
||||||
|
PS::Skipped => PointerStatus::Skipped as i32,
|
||||||
|
PS::Compensated | PS::Cancelled => PointerStatus::Cancelled as i32,
|
||||||
|
};
|
||||||
|
ExecutionPointer {
|
||||||
|
id: p.id.clone(),
|
||||||
|
step_id: p.step_id as u32,
|
||||||
|
step_name: p.step_name.clone().unwrap_or_default(),
|
||||||
|
status,
|
||||||
|
start_time: p.start_time.as_ref().map(datetime_to_timestamp),
|
||||||
|
end_time: p.end_time.as_ref().map(datetime_to_timestamp),
|
||||||
|
retry_count: p.retry_count,
|
||||||
|
active: p.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn struct_to_json_roundtrip() {
|
||||||
|
let original = serde_json::json!({
|
||||||
|
"name": "test",
|
||||||
|
"count": 42.0,
|
||||||
|
"active": true,
|
||||||
|
"tags": ["a", "b"],
|
||||||
|
"nested": { "key": "value" }
|
||||||
|
});
|
||||||
|
let proto_struct = json_to_struct(&original);
|
||||||
|
let back = struct_to_json(proto_struct);
|
||||||
|
assert_eq!(original, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_null_roundtrip() {
|
||||||
|
let v = serde_json::Value::Null;
|
||||||
|
let pv = json_to_prost_value(&v);
|
||||||
|
let back = prost_value_to_json(pv);
|
||||||
|
assert_eq!(back, serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_string_roundtrip() {
|
||||||
|
let v = serde_json::Value::String("hello".to_string());
|
||||||
|
let pv = json_to_prost_value(&v);
|
||||||
|
let back = prost_value_to_json(pv);
|
||||||
|
assert_eq!(back, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_bool_roundtrip() {
|
||||||
|
let v = serde_json::Value::Bool(true);
|
||||||
|
let pv = json_to_prost_value(&v);
|
||||||
|
let back = prost_value_to_json(pv);
|
||||||
|
assert_eq!(back, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_number_roundtrip() {
|
||||||
|
let v = serde_json::json!(3.14);
|
||||||
|
let pv = json_to_prost_value(&v);
|
||||||
|
let back = prost_value_to_json(pv);
|
||||||
|
assert_eq!(back, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_array_roundtrip() {
|
||||||
|
let v = serde_json::json!(["a", 1.0, true, null]);
|
||||||
|
let pv = json_to_prost_value(&v);
|
||||||
|
let back = prost_value_to_json(pv);
|
||||||
|
assert_eq!(back, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_struct_roundtrip() {
|
||||||
|
let v = serde_json::json!({});
|
||||||
|
let proto_struct = json_to_struct(&v);
|
||||||
|
let back = struct_to_json(proto_struct);
|
||||||
|
assert_eq!(back, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prost_value_none_kind() {
|
||||||
|
let v = prost_types::Value { kind: None };
|
||||||
|
assert_eq!(prost_value_to_json(v), serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_to_struct_from_non_object() {
|
||||||
|
let v = serde_json::json!("not an object");
|
||||||
|
let s = json_to_struct(&v);
|
||||||
|
assert!(s.fields.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn datetime_to_timestamp_conversion() {
|
||||||
|
let dt = chrono::DateTime::parse_from_rfc3339("2026-03-29T12:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
let ts = datetime_to_timestamp(&dt);
|
||||||
|
assert_eq!(ts.seconds, dt.timestamp());
|
||||||
|
assert_eq!(ts.nanos, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_status_mapping() {
|
||||||
|
use wfe_core::models::{WorkflowInstance as WI, WorkflowStatus as WS};
|
||||||
|
let mut w = WI::new("test", 1, serde_json::json!({}));
|
||||||
|
|
||||||
|
w.status = WS::Runnable;
|
||||||
|
let p = workflow_to_proto(&w);
|
||||||
|
assert_eq!(p.status, WorkflowStatus::Runnable as i32);
|
||||||
|
|
||||||
|
w.status = WS::Complete;
|
||||||
|
let p = workflow_to_proto(&w);
|
||||||
|
assert_eq!(p.status, WorkflowStatus::Complete as i32);
|
||||||
|
|
||||||
|
w.status = WS::Suspended;
|
||||||
|
let p = workflow_to_proto(&w);
|
||||||
|
assert_eq!(p.status, WorkflowStatus::Suspended as i32);
|
||||||
|
|
||||||
|
w.status = WS::Terminated;
|
||||||
|
let p = workflow_to_proto(&w);
|
||||||
|
assert_eq!(p.status, WorkflowStatus::Terminated as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pointer_status_mapping() {
|
||||||
|
use wfe_core::models::{ExecutionPointer as EP, PointerStatus as PS};
|
||||||
|
let mut p = EP::new(0);
|
||||||
|
|
||||||
|
p.status = PS::Pending;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::Pending as i32);
|
||||||
|
|
||||||
|
p.status = PS::Running;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::Running as i32);
|
||||||
|
|
||||||
|
p.status = PS::Complete;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::Complete as i32);
|
||||||
|
|
||||||
|
p.status = PS::Sleeping;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::Sleeping as i32);
|
||||||
|
|
||||||
|
p.status = PS::WaitingForEvent;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::WaitingForEvent as i32);
|
||||||
|
|
||||||
|
p.status = PS::Failed;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::Failed as i32);
|
||||||
|
|
||||||
|
p.status = PS::Skipped;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::Skipped as i32);
|
||||||
|
|
||||||
|
p.status = PS::Cancelled;
|
||||||
|
assert_eq!(pointer_to_proto(&p).status, PointerStatus::Cancelled as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_to_proto_basic() {
|
||||||
|
let w = wfe_core::models::WorkflowInstance::new("my-wf", 1, serde_json::json!({"key": "val"}));
|
||||||
|
let p = workflow_to_proto(&w);
|
||||||
|
assert_eq!(p.definition_id, "my-wf");
|
||||||
|
assert_eq!(p.version, 1);
|
||||||
|
assert!(p.create_time.is_some());
|
||||||
|
assert!(p.complete_time.is_none());
|
||||||
|
let data = struct_to_json(p.data.unwrap());
|
||||||
|
assert_eq!(data["key"], "val");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── gRPC integration tests with real WorkflowHost ────────────────
|
||||||
|
|
||||||
|
async fn make_test_service() -> WfeService {
|
||||||
|
use wfe::WorkflowHostBuilder;
|
||||||
|
use wfe_core::test_support::{
|
||||||
|
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
let host = WorkflowHostBuilder::new()
|
||||||
|
.use_persistence(std::sync::Arc::new(InMemoryPersistenceProvider::new())
|
||||||
|
as std::sync::Arc<dyn wfe_core::traits::PersistenceProvider>)
|
||||||
|
.use_lock_provider(std::sync::Arc::new(InMemoryLockProvider::new())
|
||||||
|
as std::sync::Arc<dyn wfe_core::traits::DistributedLockProvider>)
|
||||||
|
.use_queue_provider(std::sync::Arc::new(InMemoryQueueProvider::new())
|
||||||
|
as std::sync::Arc<dyn wfe_core::traits::QueueProvider>)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
host.start().await.unwrap();
|
||||||
|
|
||||||
|
let lifecycle_bus = std::sync::Arc::new(crate::lifecycle_bus::BroadcastLifecyclePublisher::new(64));
|
||||||
|
let log_store = std::sync::Arc::new(crate::log_store::LogStore::new());
|
||||||
|
|
||||||
|
WfeService::new(std::sync::Arc::new(host), lifecycle_bus, log_store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_register_and_start_workflow() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
|
||||||
|
// Register a workflow.
|
||||||
|
let req = Request::new(RegisterWorkflowRequest {
|
||||||
|
yaml: r#"
|
||||||
|
workflow:
|
||||||
|
id: test-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: hello
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hi
|
||||||
|
"#.to_string(),
|
||||||
|
config: Default::default(),
|
||||||
|
});
|
||||||
|
let resp = svc.register_workflow(req).await.unwrap().into_inner();
|
||||||
|
assert_eq!(resp.definitions.len(), 1);
|
||||||
|
assert_eq!(resp.definitions[0].definition_id, "test-wf");
|
||||||
|
assert_eq!(resp.definitions[0].version, 1);
|
||||||
|
assert_eq!(resp.definitions[0].step_count, 1);
|
||||||
|
|
||||||
|
// Start the workflow.
|
||||||
|
let req = Request::new(StartWorkflowRequest {
|
||||||
|
definition_id: "test-wf".to_string(),
|
||||||
|
version: 1,
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
let resp = svc.start_workflow(req).await.unwrap().into_inner();
|
||||||
|
assert!(!resp.workflow_id.is_empty());
|
||||||
|
|
||||||
|
// Get the workflow.
|
||||||
|
let req = Request::new(GetWorkflowRequest {
|
||||||
|
workflow_id: resp.workflow_id.clone(),
|
||||||
|
});
|
||||||
|
let resp = svc.get_workflow(req).await.unwrap().into_inner();
|
||||||
|
let instance = resp.instance.unwrap();
|
||||||
|
assert_eq!(instance.definition_id, "test-wf");
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Runnable as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_register_invalid_yaml() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
let req = Request::new(RegisterWorkflowRequest {
|
||||||
|
yaml: "not: valid: yaml: {{{}}}".to_string(),
|
||||||
|
config: Default::default(),
|
||||||
|
});
|
||||||
|
let err = svc.register_workflow(req).await.unwrap_err();
|
||||||
|
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_start_nonexistent_workflow() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
let req = Request::new(StartWorkflowRequest {
|
||||||
|
definition_id: "nonexistent".to_string(),
|
||||||
|
version: 1,
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
let err = svc.start_workflow(req).await.unwrap_err();
|
||||||
|
assert_eq!(err.code(), tonic::Code::Internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_get_nonexistent_workflow() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
let req = Request::new(GetWorkflowRequest {
|
||||||
|
workflow_id: "nonexistent".to_string(),
|
||||||
|
});
|
||||||
|
let err = svc.get_workflow(req).await.unwrap_err();
|
||||||
|
assert_eq!(err.code(), tonic::Code::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_cancel_workflow() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
|
||||||
|
// Register + start.
|
||||||
|
let req = Request::new(RegisterWorkflowRequest {
|
||||||
|
yaml: "workflow:\n id: cancel-test\n version: 1\n steps:\n - name: s\n type: shell\n config:\n run: echo ok\n".to_string(),
|
||||||
|
config: Default::default(),
|
||||||
|
});
|
||||||
|
svc.register_workflow(req).await.unwrap();
|
||||||
|
|
||||||
|
let req = Request::new(StartWorkflowRequest {
|
||||||
|
definition_id: "cancel-test".to_string(),
|
||||||
|
version: 1,
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
let wf_id = svc.start_workflow(req).await.unwrap().into_inner().workflow_id;
|
||||||
|
|
||||||
|
// Cancel it.
|
||||||
|
let req = Request::new(CancelWorkflowRequest { workflow_id: wf_id.clone() });
|
||||||
|
svc.cancel_workflow(req).await.unwrap();
|
||||||
|
|
||||||
|
// Verify it's terminated.
|
||||||
|
let req = Request::new(GetWorkflowRequest { workflow_id: wf_id });
|
||||||
|
let instance = svc.get_workflow(req).await.unwrap().into_inner().instance.unwrap();
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Terminated as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_suspend_resume_workflow() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
|
||||||
|
let req = Request::new(RegisterWorkflowRequest {
|
||||||
|
yaml: "workflow:\n id: sr-test\n version: 1\n steps:\n - name: s\n type: shell\n config:\n run: echo ok\n".to_string(),
|
||||||
|
config: Default::default(),
|
||||||
|
});
|
||||||
|
svc.register_workflow(req).await.unwrap();
|
||||||
|
|
||||||
|
let req = Request::new(StartWorkflowRequest {
|
||||||
|
definition_id: "sr-test".to_string(),
|
||||||
|
version: 1,
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
let wf_id = svc.start_workflow(req).await.unwrap().into_inner().workflow_id;
|
||||||
|
|
||||||
|
// Suspend.
|
||||||
|
let req = Request::new(SuspendWorkflowRequest { workflow_id: wf_id.clone() });
|
||||||
|
svc.suspend_workflow(req).await.unwrap();
|
||||||
|
|
||||||
|
let req = Request::new(GetWorkflowRequest { workflow_id: wf_id.clone() });
|
||||||
|
let instance = svc.get_workflow(req).await.unwrap().into_inner().instance.unwrap();
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Suspended as i32);
|
||||||
|
|
||||||
|
// Resume.
|
||||||
|
let req = Request::new(ResumeWorkflowRequest { workflow_id: wf_id.clone() });
|
||||||
|
svc.resume_workflow(req).await.unwrap();
|
||||||
|
|
||||||
|
let req = Request::new(GetWorkflowRequest { workflow_id: wf_id });
|
||||||
|
let instance = svc.get_workflow(req).await.unwrap().into_inner().instance.unwrap();
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Runnable as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_publish_event() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
let req = Request::new(PublishEventRequest {
|
||||||
|
event_name: "test.event".to_string(),
|
||||||
|
event_key: "key-1".to_string(),
|
||||||
|
data: None,
|
||||||
|
});
|
||||||
|
// Should succeed even with no waiting workflows.
|
||||||
|
svc.publish_event(req).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_search_logs_not_configured() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
let req = Request::new(SearchLogsRequest {
|
||||||
|
query: "test".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let err = svc.search_logs(req).await.unwrap_err();
|
||||||
|
assert_eq!(err.code(), tonic::Code::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_list_definitions_empty() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
let req = Request::new(ListDefinitionsRequest {});
|
||||||
|
let resp = svc.list_definitions(req).await.unwrap().into_inner();
|
||||||
|
assert!(resp.definitions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rpc_search_workflows_empty() {
|
||||||
|
let svc = make_test_service().await;
|
||||||
|
let req = Request::new(SearchWorkflowsRequest {
|
||||||
|
query: "test".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let resp = svc.search_workflows(req).await.unwrap().into_inner();
|
||||||
|
assert_eq!(resp.total, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
wfe-server/src/lifecycle_bus.rs
Normal file
125
wfe-server/src/lifecycle_bus.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use wfe_core::models::LifecycleEvent;
|
||||||
|
use wfe_core::traits::LifecyclePublisher;
|
||||||
|
|
||||||
|
/// Broadcasts lifecycle events to multiple subscribers via tokio broadcast channels.
|
||||||
|
pub struct BroadcastLifecyclePublisher {
|
||||||
|
sender: broadcast::Sender<LifecycleEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BroadcastLifecyclePublisher {
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
let (sender, _) = broadcast::channel(capacity);
|
||||||
|
Self { sender }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<LifecycleEvent> {
|
||||||
|
self.sender.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LifecyclePublisher for BroadcastLifecyclePublisher {
|
||||||
|
async fn publish(&self, event: LifecycleEvent) -> wfe_core::Result<()> {
|
||||||
|
// Ignore send errors (no active subscribers).
|
||||||
|
let _ = self.sender.send(event);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use wfe_core::models::LifecycleEventType;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn publish_and_receive() {
|
||||||
|
let bus = BroadcastLifecyclePublisher::new(16);
|
||||||
|
let mut rx = bus.subscribe();
|
||||||
|
|
||||||
|
let event = LifecycleEvent::new("wf-1", "def-1", 1, LifecycleEventType::Started);
|
||||||
|
bus.publish(event.clone()).await.unwrap();
|
||||||
|
|
||||||
|
let received = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(received.workflow_instance_id, "wf-1");
|
||||||
|
assert_eq!(received.event_type, LifecycleEventType::Started);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_subscribers() {
|
||||||
|
let bus = BroadcastLifecyclePublisher::new(16);
|
||||||
|
let mut rx1 = bus.subscribe();
|
||||||
|
let mut rx2 = bus.subscribe();
|
||||||
|
|
||||||
|
bus.publish(LifecycleEvent::new("wf-1", "def-1", 1, LifecycleEventType::Completed))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let e1 = rx1.recv().await.unwrap();
|
||||||
|
let e2 = rx2.recv().await.unwrap();
|
||||||
|
assert_eq!(e1.event_type, LifecycleEventType::Completed);
|
||||||
|
assert_eq!(e2.event_type, LifecycleEventType::Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_subscribers_does_not_error() {
|
||||||
|
let bus = BroadcastLifecyclePublisher::new(16);
|
||||||
|
// No subscribers — should not panic.
|
||||||
|
bus.publish(LifecycleEvent::new("wf-1", "def-1", 1, LifecycleEventType::Started))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn step_events_propagate() {
|
||||||
|
let bus = BroadcastLifecyclePublisher::new(16);
|
||||||
|
let mut rx = bus.subscribe();
|
||||||
|
|
||||||
|
bus.publish(LifecycleEvent::new(
|
||||||
|
"wf-1",
|
||||||
|
"def-1",
|
||||||
|
1,
|
||||||
|
LifecycleEventType::StepStarted {
|
||||||
|
step_id: 3,
|
||||||
|
step_name: Some("build".to_string()),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let received = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
received.event_type,
|
||||||
|
LifecycleEventType::StepStarted {
|
||||||
|
step_id: 3,
|
||||||
|
step_name: Some("build".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn error_events_include_message() {
|
||||||
|
let bus = BroadcastLifecyclePublisher::new(16);
|
||||||
|
let mut rx = bus.subscribe();
|
||||||
|
|
||||||
|
bus.publish(LifecycleEvent::new(
|
||||||
|
"wf-1",
|
||||||
|
"def-1",
|
||||||
|
1,
|
||||||
|
LifecycleEventType::Error {
|
||||||
|
message: "step failed".to_string(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let received = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
received.event_type,
|
||||||
|
LifecycleEventType::Error {
|
||||||
|
message: "step failed".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
529
wfe-server/src/log_search.rs
Normal file
529
wfe-server/src/log_search.rs
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use opensearch::http::transport::Transport;
|
||||||
|
use opensearch::{IndexParts, OpenSearch, SearchParts};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use wfe_core::traits::{LogChunk, LogStreamType};
|
||||||
|
|
||||||
|
const LOG_INDEX: &str = "wfe-build-logs";
|
||||||
|
|
||||||
|
/// Document structure for a log line stored in OpenSearch.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct LogDocument {
|
||||||
|
workflow_id: String,
|
||||||
|
definition_id: String,
|
||||||
|
step_id: usize,
|
||||||
|
step_name: String,
|
||||||
|
stream: String,
|
||||||
|
line: String,
|
||||||
|
timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogDocument {
|
||||||
|
fn from_chunk(chunk: &LogChunk) -> Self {
|
||||||
|
Self {
|
||||||
|
workflow_id: chunk.workflow_id.clone(),
|
||||||
|
definition_id: chunk.definition_id.clone(),
|
||||||
|
step_id: chunk.step_id,
|
||||||
|
step_name: chunk.step_name.clone(),
|
||||||
|
stream: match chunk.stream {
|
||||||
|
LogStreamType::Stdout => "stdout".to_string(),
|
||||||
|
LogStreamType::Stderr => "stderr".to_string(),
|
||||||
|
},
|
||||||
|
line: String::from_utf8_lossy(&chunk.data).trim_end().to_string(),
|
||||||
|
timestamp: chunk.timestamp.to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result from a log search query.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LogSearchHit {
|
||||||
|
pub workflow_id: String,
|
||||||
|
pub definition_id: String,
|
||||||
|
pub step_name: String,
|
||||||
|
pub line: String,
|
||||||
|
pub stream: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenSearch-backed log search index.
|
||||||
|
pub struct LogSearchIndex {
|
||||||
|
client: OpenSearch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogSearchIndex {
|
||||||
|
pub fn new(url: &str) -> wfe_core::Result<Self> {
|
||||||
|
let transport = Transport::single_node(url)
|
||||||
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||||
|
Ok(Self {
|
||||||
|
client: OpenSearch::new(transport),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the log index if it doesn't exist.
|
||||||
|
pub async fn ensure_index(&self) -> wfe_core::Result<()> {
|
||||||
|
let exists = self
|
||||||
|
.client
|
||||||
|
.indices()
|
||||||
|
.exists(opensearch::indices::IndicesExistsParts::Index(&[LOG_INDEX]))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||||
|
|
||||||
|
if exists.status_code().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"mappings": {
|
||||||
|
"properties": {
|
||||||
|
"workflow_id": { "type": "keyword" },
|
||||||
|
"definition_id": { "type": "keyword" },
|
||||||
|
"step_id": { "type": "integer" },
|
||||||
|
"step_name": { "type": "keyword" },
|
||||||
|
"stream": { "type": "keyword" },
|
||||||
|
"line": { "type": "text", "analyzer": "standard" },
|
||||||
|
"timestamp": { "type": "date" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.indices()
|
||||||
|
.create(opensearch::indices::IndicesCreateParts::Index(LOG_INDEX))
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||||
|
|
||||||
|
if !response.status_code().is_success() {
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(wfe_core::WfeError::Persistence(format!(
|
||||||
|
"Failed to create log index: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(index = LOG_INDEX, "log search index created");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index a single log chunk.
|
||||||
|
pub async fn index_chunk(&self, chunk: &LogChunk) -> wfe_core::Result<()> {
|
||||||
|
let doc = LogDocument::from_chunk(chunk);
|
||||||
|
let body = serde_json::to_value(&doc)?;
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.index(IndexParts::Index(LOG_INDEX))
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||||
|
|
||||||
|
if !response.status_code().is_success() {
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(wfe_core::WfeError::Persistence(format!(
|
||||||
|
"failed to index log chunk: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search log lines.
|
||||||
|
pub async fn search(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
workflow_id: Option<&str>,
|
||||||
|
step_name: Option<&str>,
|
||||||
|
stream_filter: Option<&str>,
|
||||||
|
skip: u64,
|
||||||
|
take: u64,
|
||||||
|
) -> wfe_core::Result<(Vec<LogSearchHit>, u64)> {
|
||||||
|
let mut must_clauses = Vec::new();
|
||||||
|
let mut filter_clauses = Vec::new();
|
||||||
|
|
||||||
|
if !query.is_empty() {
|
||||||
|
must_clauses.push(json!({
|
||||||
|
"match": { "line": query }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(wf_id) = workflow_id {
|
||||||
|
filter_clauses.push(json!({ "term": { "workflow_id": wf_id } }));
|
||||||
|
}
|
||||||
|
if let Some(sn) = step_name {
|
||||||
|
filter_clauses.push(json!({ "term": { "step_name": sn } }));
|
||||||
|
}
|
||||||
|
if let Some(stream) = stream_filter {
|
||||||
|
filter_clauses.push(json!({ "term": { "stream": stream } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_body = if must_clauses.is_empty() && filter_clauses.is_empty() {
|
||||||
|
json!({ "match_all": {} })
|
||||||
|
} else {
|
||||||
|
let mut bool_q = serde_json::Map::new();
|
||||||
|
if !must_clauses.is_empty() {
|
||||||
|
bool_q.insert("must".to_string(), json!(must_clauses));
|
||||||
|
}
|
||||||
|
if !filter_clauses.is_empty() {
|
||||||
|
bool_q.insert("filter".to_string(), json!(filter_clauses));
|
||||||
|
}
|
||||||
|
json!({ "bool": bool_q })
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"query": query_body,
|
||||||
|
"from": skip,
|
||||||
|
"size": take,
|
||||||
|
"sort": [{ "timestamp": "asc" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.search(SearchParts::Index(&[LOG_INDEX]))
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||||
|
|
||||||
|
if !response.status_code().is_success() {
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(wfe_core::WfeError::Persistence(format!(
|
||||||
|
"Log search failed: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_body: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||||
|
|
||||||
|
let total = resp_body["hits"]["total"]["value"].as_u64().unwrap_or(0);
|
||||||
|
let hits = resp_body["hits"]["hits"]
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let results = hits
|
||||||
|
.iter()
|
||||||
|
.filter_map(|hit| {
|
||||||
|
let src = &hit["_source"];
|
||||||
|
Some(LogSearchHit {
|
||||||
|
workflow_id: src["workflow_id"].as_str()?.to_string(),
|
||||||
|
definition_id: src["definition_id"].as_str()?.to_string(),
|
||||||
|
step_name: src["step_name"].as_str()?.to_string(),
|
||||||
|
line: src["line"].as_str()?.to_string(),
|
||||||
|
stream: src["stream"].as_str()?.to_string(),
|
||||||
|
timestamp: src["timestamp"]
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt| dt.with_timezone(&Utc))
|
||||||
|
.unwrap_or_else(Utc::now),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok((results, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_document_from_chunk_stdout() {
|
||||||
|
let chunk = LogChunk {
|
||||||
|
workflow_id: "wf-1".to_string(),
|
||||||
|
definition_id: "ci".to_string(),
|
||||||
|
step_id: 0,
|
||||||
|
step_name: "build".to_string(),
|
||||||
|
stream: LogStreamType::Stdout,
|
||||||
|
data: b"compiling wfe-core\n".to_vec(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
let doc = LogDocument::from_chunk(&chunk);
|
||||||
|
assert_eq!(doc.workflow_id, "wf-1");
|
||||||
|
assert_eq!(doc.stream, "stdout");
|
||||||
|
assert_eq!(doc.line, "compiling wfe-core");
|
||||||
|
assert_eq!(doc.step_name, "build");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_document_from_chunk_stderr() {
|
||||||
|
let chunk = LogChunk {
|
||||||
|
workflow_id: "wf-2".to_string(),
|
||||||
|
definition_id: "deploy".to_string(),
|
||||||
|
step_id: 1,
|
||||||
|
step_name: "test".to_string(),
|
||||||
|
stream: LogStreamType::Stderr,
|
||||||
|
data: b"warning: unused variable\n".to_vec(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
let doc = LogDocument::from_chunk(&chunk);
|
||||||
|
assert_eq!(doc.stream, "stderr");
|
||||||
|
assert_eq!(doc.line, "warning: unused variable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_document_trims_trailing_newline() {
|
||||||
|
let chunk = LogChunk {
|
||||||
|
workflow_id: "wf-1".to_string(),
|
||||||
|
definition_id: "ci".to_string(),
|
||||||
|
step_id: 0,
|
||||||
|
step_name: "build".to_string(),
|
||||||
|
stream: LogStreamType::Stdout,
|
||||||
|
data: b"line with newline\n".to_vec(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
let doc = LogDocument::from_chunk(&chunk);
|
||||||
|
assert_eq!(doc.line, "line with newline");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_document_serializes_to_json() {
|
||||||
|
let chunk = LogChunk {
|
||||||
|
workflow_id: "wf-1".to_string(),
|
||||||
|
definition_id: "ci".to_string(),
|
||||||
|
step_id: 2,
|
||||||
|
step_name: "clippy".to_string(),
|
||||||
|
stream: LogStreamType::Stdout,
|
||||||
|
data: b"all good\n".to_vec(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
let doc = LogDocument::from_chunk(&chunk);
|
||||||
|
let json = serde_json::to_value(&doc).unwrap();
|
||||||
|
assert_eq!(json["step_name"], "clippy");
|
||||||
|
assert_eq!(json["step_id"], 2);
|
||||||
|
assert!(json["timestamp"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenSearch integration tests ────────────────────────────────
|
||||||
|
|
||||||
|
fn opensearch_url() -> Option<String> {
|
||||||
|
let url = std::env::var("WFE_SEARCH_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:9200".to_string());
|
||||||
|
// Quick TCP probe to check if OpenSearch is reachable.
|
||||||
|
let addr = url
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.or_else(|| url.strip_prefix("https://"))
|
||||||
|
.unwrap_or("localhost:9200");
|
||||||
|
match std::net::TcpStream::connect_timeout(
|
||||||
|
&addr.parse().ok()?,
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
) {
|
||||||
|
Ok(_) => Some(url),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_test_chunk(
|
||||||
|
workflow_id: &str,
|
||||||
|
step_name: &str,
|
||||||
|
stream: LogStreamType,
|
||||||
|
line: &str,
|
||||||
|
) -> LogChunk {
|
||||||
|
LogChunk {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
definition_id: "test-def".to_string(),
|
||||||
|
step_id: 0,
|
||||||
|
step_name: step_name.to_string(),
|
||||||
|
stream,
|
||||||
|
data: format!("{line}\n").into_bytes(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the test index to start clean.
|
||||||
|
async fn cleanup_index(url: &str) {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let _ = client
|
||||||
|
.delete(format!("{url}/{LOG_INDEX}"))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opensearch_ensure_index_creates_index() {
|
||||||
|
let Some(url) = opensearch_url() else {
|
||||||
|
eprintln!("SKIP: OpenSearch not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
|
||||||
|
let index = LogSearchIndex::new(&url).unwrap();
|
||||||
|
index.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
// Calling again should be idempotent.
|
||||||
|
index.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opensearch_index_and_search_chunk() {
|
||||||
|
let Some(url) = opensearch_url() else {
|
||||||
|
eprintln!("SKIP: OpenSearch not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
|
||||||
|
let index = LogSearchIndex::new(&url).unwrap();
|
||||||
|
index.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
// Index some log chunks.
|
||||||
|
let chunk = make_test_chunk("wf-search-1", "build", LogStreamType::Stdout, "compiling wfe-core v1.5.0");
|
||||||
|
index.index_chunk(&chunk).await.unwrap();
|
||||||
|
|
||||||
|
let chunk = make_test_chunk("wf-search-1", "build", LogStreamType::Stderr, "warning: unused variable");
|
||||||
|
index.index_chunk(&chunk).await.unwrap();
|
||||||
|
|
||||||
|
let chunk = make_test_chunk("wf-search-1", "test", LogStreamType::Stdout, "test result: ok. 79 passed");
|
||||||
|
index.index_chunk(&chunk).await.unwrap();
|
||||||
|
|
||||||
|
// OpenSearch needs a refresh to make docs searchable.
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client.post(format!("{url}/{LOG_INDEX}/_refresh")).send().await.unwrap();
|
||||||
|
|
||||||
|
// Search by text.
|
||||||
|
let (results, total) = index
|
||||||
|
.search("wfe-core", None, None, None, 0, 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(total >= 1, "expected at least 1 hit, got {total}");
|
||||||
|
assert!(results.iter().any(|r| r.line.contains("wfe-core")));
|
||||||
|
|
||||||
|
// Search by workflow_id filter.
|
||||||
|
let (results, _) = index
|
||||||
|
.search("", Some("wf-search-1"), None, None, 0, 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
|
||||||
|
// Search by step_name filter.
|
||||||
|
let (results, _) = index
|
||||||
|
.search("", Some("wf-search-1"), Some("test"), None, 0, 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert!(results[0].line.contains("79 passed"));
|
||||||
|
|
||||||
|
// Search by stream filter.
|
||||||
|
let (results, _) = index
|
||||||
|
.search("", Some("wf-search-1"), None, Some("stderr"), 0, 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert!(results[0].line.contains("unused variable"));
|
||||||
|
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opensearch_search_empty_index() {
|
||||||
|
let Some(url) = opensearch_url() else {
|
||||||
|
eprintln!("SKIP: OpenSearch not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
|
||||||
|
let index = LogSearchIndex::new(&url).unwrap();
|
||||||
|
index.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
let (results, total) = index
|
||||||
|
.search("nonexistent", None, None, None, 0, 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 0);
|
||||||
|
assert!(results.is_empty());
|
||||||
|
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opensearch_search_pagination() {
|
||||||
|
let Some(url) = opensearch_url() else {
|
||||||
|
eprintln!("SKIP: OpenSearch not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
|
||||||
|
let index = LogSearchIndex::new(&url).unwrap();
|
||||||
|
index.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
// Index 5 chunks.
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = make_test_chunk("wf-page", "build", LogStreamType::Stdout, &format!("line {i}"));
|
||||||
|
index.index_chunk(&chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client.post(format!("{url}/{LOG_INDEX}/_refresh")).send().await.unwrap();
|
||||||
|
|
||||||
|
// Get first 2.
|
||||||
|
let (results, total) = index
|
||||||
|
.search("", Some("wf-page"), None, None, 0, 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total, 5);
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
|
||||||
|
// Get next 2.
|
||||||
|
let (results, _) = index
|
||||||
|
.search("", Some("wf-page"), None, None, 2, 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
|
||||||
|
// Get last 1.
|
||||||
|
let (results, _) = index
|
||||||
|
.search("", Some("wf-page"), None, None, 4, 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_search_index_new_constructs_ok() {
|
||||||
|
// Construction should succeed even for unreachable URLs (fails on first use).
|
||||||
|
let result = LogSearchIndex::new("http://localhost:19876");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opensearch_index_chunk_result_fields() {
|
||||||
|
let Some(url) = opensearch_url() else {
|
||||||
|
eprintln!("SKIP: OpenSearch not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
|
||||||
|
let index = LogSearchIndex::new(&url).unwrap();
|
||||||
|
index.ensure_index().await.unwrap();
|
||||||
|
|
||||||
|
let chunk = make_test_chunk("wf-fields", "clippy", LogStreamType::Stderr, "error: type mismatch");
|
||||||
|
index.index_chunk(&chunk).await.unwrap();
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client.post(format!("{url}/{LOG_INDEX}/_refresh")).send().await.unwrap();
|
||||||
|
|
||||||
|
let (results, _) = index
|
||||||
|
.search("type mismatch", None, None, None, 0, 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
let hit = &results[0];
|
||||||
|
assert_eq!(hit.workflow_id, "wf-fields");
|
||||||
|
assert_eq!(hit.definition_id, "test-def");
|
||||||
|
assert_eq!(hit.step_name, "clippy");
|
||||||
|
assert_eq!(hit.stream, "stderr");
|
||||||
|
assert!(hit.line.contains("type mismatch"));
|
||||||
|
|
||||||
|
cleanup_index(&url).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
wfe-server/src/log_store.rs
Normal file
203
wfe-server/src/log_store.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use wfe_core::traits::log_sink::{LogChunk, LogSink};
|
||||||
|
|
||||||
|
/// Stores and broadcasts log chunks for workflow step executions.
|
||||||
|
///
|
||||||
|
/// Three tiers:
|
||||||
|
/// 1. **Live broadcast** — per-workflow broadcast channel for StreamLogs subscribers
|
||||||
|
/// 2. **In-memory history** — append-only buffer per (workflow_id, step_id) for replay
|
||||||
|
/// 3. **Search index** — OpenSearch log indexing via LogSearchIndex (optional)
|
||||||
|
pub struct LogStore {
|
||||||
|
/// Per-workflow broadcast channels for live streaming.
|
||||||
|
live: DashMap<String, broadcast::Sender<LogChunk>>,
|
||||||
|
/// In-memory history per (workflow_id, step_id).
|
||||||
|
history: DashMap<(String, usize), Vec<LogChunk>>,
|
||||||
|
/// Optional search index for log lines.
|
||||||
|
search: Option<Arc<crate::log_search::LogSearchIndex>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
live: DashMap::new(),
|
||||||
|
history: DashMap::new(),
|
||||||
|
search: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_search(mut self, index: Arc<crate::log_search::LogSearchIndex>) -> Self {
|
||||||
|
self.search = Some(index);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to live log chunks for a workflow.
|
||||||
|
pub fn subscribe(&self, workflow_id: &str) -> broadcast::Receiver<LogChunk> {
|
||||||
|
self.live
|
||||||
|
.entry(workflow_id.to_string())
|
||||||
|
.or_insert_with(|| broadcast::channel(4096).0)
|
||||||
|
.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get historical logs for a workflow, optionally filtered by step.
|
||||||
|
pub fn get_history(&self, workflow_id: &str, step_id: Option<usize>) -> Vec<LogChunk> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for entry in self.history.iter() {
|
||||||
|
let (wf_id, s_id) = entry.key();
|
||||||
|
if wf_id != workflow_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(filter_step) = step_id {
|
||||||
|
if *s_id != filter_step {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.extend(entry.value().iter().cloned());
|
||||||
|
}
|
||||||
|
// Sort by timestamp.
|
||||||
|
result.sort_by_key(|c| c.timestamp);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LogSink for LogStore {
|
||||||
|
async fn write_chunk(&self, chunk: LogChunk) {
|
||||||
|
// Store in history.
|
||||||
|
self.history
|
||||||
|
.entry((chunk.workflow_id.clone(), chunk.step_id))
|
||||||
|
.or_default()
|
||||||
|
.push(chunk.clone());
|
||||||
|
|
||||||
|
// Broadcast to live subscribers.
|
||||||
|
let sender = self
|
||||||
|
.live
|
||||||
|
.entry(chunk.workflow_id.clone())
|
||||||
|
.or_insert_with(|| broadcast::channel(4096).0);
|
||||||
|
let _ = sender.send(chunk.clone());
|
||||||
|
|
||||||
|
// Index to OpenSearch (best-effort, don't block on failure).
|
||||||
|
if let Some(ref search) = self.search {
|
||||||
|
if let Err(e) = search.index_chunk(&chunk).await {
|
||||||
|
tracing::warn!(error = %e, "failed to index log chunk to OpenSearch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use wfe_core::traits::LogStreamType;
|
||||||
|
|
||||||
|
fn make_chunk(workflow_id: &str, step_id: usize, step_name: &str, data: &str) -> LogChunk {
|
||||||
|
LogChunk {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
definition_id: "def-1".to_string(),
|
||||||
|
step_id,
|
||||||
|
step_name: step_name.to_string(),
|
||||||
|
stream: LogStreamType::Stdout,
|
||||||
|
data: data.as_bytes().to_vec(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_and_read_history() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
store.write_chunk(make_chunk("wf-1", 0, "build", "line 1\n")).await;
|
||||||
|
store.write_chunk(make_chunk("wf-1", 0, "build", "line 2\n")).await;
|
||||||
|
|
||||||
|
let history = store.get_history("wf-1", None);
|
||||||
|
assert_eq!(history.len(), 2);
|
||||||
|
assert_eq!(history[0].data, b"line 1\n");
|
||||||
|
assert_eq!(history[1].data, b"line 2\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn history_filtered_by_step() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
store.write_chunk(make_chunk("wf-1", 0, "build", "build log\n")).await;
|
||||||
|
store.write_chunk(make_chunk("wf-1", 1, "test", "test log\n")).await;
|
||||||
|
|
||||||
|
let build_only = store.get_history("wf-1", Some(0));
|
||||||
|
assert_eq!(build_only.len(), 1);
|
||||||
|
assert_eq!(build_only[0].step_name, "build");
|
||||||
|
|
||||||
|
let test_only = store.get_history("wf-1", Some(1));
|
||||||
|
assert_eq!(test_only.len(), 1);
|
||||||
|
assert_eq!(test_only[0].step_name, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_history_for_unknown_workflow() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
assert!(store.get_history("nonexistent", None).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_broadcast() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
let mut rx = store.subscribe("wf-1");
|
||||||
|
|
||||||
|
store.write_chunk(make_chunk("wf-1", 0, "build", "hello\n")).await;
|
||||||
|
|
||||||
|
let received = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(received.data, b"hello\n");
|
||||||
|
assert_eq!(received.workflow_id, "wf-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn broadcast_different_workflows_isolated() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
let mut rx1 = store.subscribe("wf-1");
|
||||||
|
let mut rx2 = store.subscribe("wf-2");
|
||||||
|
|
||||||
|
store.write_chunk(make_chunk("wf-1", 0, "build", "wf1 log\n")).await;
|
||||||
|
store.write_chunk(make_chunk("wf-2", 0, "test", "wf2 log\n")).await;
|
||||||
|
|
||||||
|
let e1 = rx1.recv().await.unwrap();
|
||||||
|
assert_eq!(e1.workflow_id, "wf-1");
|
||||||
|
|
||||||
|
let e2 = rx2.recv().await.unwrap();
|
||||||
|
assert_eq!(e2.workflow_id, "wf-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_subscribers_does_not_error() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
// No subscribers — should not panic.
|
||||||
|
store.write_chunk(make_chunk("wf-1", 0, "build", "orphan log\n")).await;
|
||||||
|
// History should still be stored.
|
||||||
|
assert_eq!(store.get_history("wf-1", None).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_subscribers_same_workflow() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
let mut rx1 = store.subscribe("wf-1");
|
||||||
|
let mut rx2 = store.subscribe("wf-1");
|
||||||
|
|
||||||
|
store.write_chunk(make_chunk("wf-1", 0, "build", "shared\n")).await;
|
||||||
|
|
||||||
|
let e1 = rx1.recv().await.unwrap();
|
||||||
|
let e2 = rx2.recv().await.unwrap();
|
||||||
|
assert_eq!(e1.data, b"shared\n");
|
||||||
|
assert_eq!(e2.data, b"shared\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn history_preserves_stream_type() {
|
||||||
|
let store = LogStore::new();
|
||||||
|
let mut chunk = make_chunk("wf-1", 0, "build", "error output\n");
|
||||||
|
chunk.stream = LogStreamType::Stderr;
|
||||||
|
store.write_chunk(chunk).await;
|
||||||
|
|
||||||
|
let history = store.get_history("wf-1", None);
|
||||||
|
assert_eq!(history[0].stream, LogStreamType::Stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
250
wfe-server/src/main.rs
Normal file
250
wfe-server/src/main.rs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod grpc;
|
||||||
|
mod lifecycle_bus;
|
||||||
|
mod log_search;
|
||||||
|
mod log_store;
|
||||||
|
mod webhook;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use tonic::transport::Server;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use wfe::WorkflowHostBuilder;
|
||||||
|
use wfe_core::test_support::{
|
||||||
|
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
||||||
|
};
|
||||||
|
use wfe_server_protos::wfe::v1::wfe_server::WfeServer;
|
||||||
|
|
||||||
|
use crate::config::{Cli, PersistenceConfig, QueueConfig};
|
||||||
|
use crate::grpc::WfeService;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// 1. Parse CLI + load config.
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config = config::load(&cli);
|
||||||
|
|
||||||
|
// 2. Init tracing.
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
grpc_addr = %config.grpc_addr,
|
||||||
|
http_addr = %config.http_addr,
|
||||||
|
"starting wfe-server"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Build providers based on config.
|
||||||
|
let (persistence, lock, queue): (
|
||||||
|
Arc<dyn wfe_core::traits::PersistenceProvider>,
|
||||||
|
Arc<dyn wfe_core::traits::DistributedLockProvider>,
|
||||||
|
Arc<dyn wfe_core::traits::QueueProvider>,
|
||||||
|
) = match (&config.persistence, &config.queue) {
|
||||||
|
(PersistenceConfig::Sqlite { path }, QueueConfig::InMemory) => {
|
||||||
|
tracing::info!(path = %path, "using SQLite + in-memory queue");
|
||||||
|
let persistence = Arc::new(
|
||||||
|
wfe_sqlite::SqlitePersistenceProvider::new(path)
|
||||||
|
.await
|
||||||
|
.expect("failed to init SQLite"),
|
||||||
|
);
|
||||||
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
|
let queue = Arc::new(InMemoryQueueProvider::new());
|
||||||
|
(persistence, lock, queue)
|
||||||
|
}
|
||||||
|
(PersistenceConfig::Postgres { url }, QueueConfig::Valkey { url: valkey_url }) => {
|
||||||
|
tracing::info!("using Postgres + Valkey");
|
||||||
|
let persistence = Arc::new(
|
||||||
|
wfe_postgres::PostgresPersistenceProvider::new(url)
|
||||||
|
.await
|
||||||
|
.expect("failed to init Postgres"),
|
||||||
|
);
|
||||||
|
let lock = Arc::new(
|
||||||
|
wfe_valkey::ValkeyLockProvider::new(valkey_url, "wfe")
|
||||||
|
.await
|
||||||
|
.expect("failed to init Valkey lock"),
|
||||||
|
);
|
||||||
|
let queue = Arc::new(
|
||||||
|
wfe_valkey::ValkeyQueueProvider::new(valkey_url, "wfe")
|
||||||
|
.await
|
||||||
|
.expect("failed to init Valkey queue"),
|
||||||
|
);
|
||||||
|
(
|
||||||
|
persistence as Arc<dyn wfe_core::traits::PersistenceProvider>,
|
||||||
|
lock as Arc<dyn wfe_core::traits::DistributedLockProvider>,
|
||||||
|
queue as Arc<dyn wfe_core::traits::QueueProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::info!("using in-memory providers (dev mode)");
|
||||||
|
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
||||||
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
|
let queue = Arc::new(InMemoryQueueProvider::new());
|
||||||
|
(
|
||||||
|
persistence as Arc<dyn wfe_core::traits::PersistenceProvider>,
|
||||||
|
lock as Arc<dyn wfe_core::traits::DistributedLockProvider>,
|
||||||
|
queue as Arc<dyn wfe_core::traits::QueueProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Build lifecycle broadcaster.
|
||||||
|
let lifecycle_bus = Arc::new(lifecycle_bus::BroadcastLifecyclePublisher::new(4096));
|
||||||
|
|
||||||
|
// 5. Build log search index (optional, needs to exist before log store).
|
||||||
|
let log_search_index = if let Some(ref search_config) = config.search {
|
||||||
|
match log_search::LogSearchIndex::new(&search_config.url) {
|
||||||
|
Ok(index) => {
|
||||||
|
let index = Arc::new(index);
|
||||||
|
if let Err(e) = index.ensure_index().await {
|
||||||
|
tracing::warn!(error = %e, "failed to create log search index");
|
||||||
|
}
|
||||||
|
tracing::info!(url = %search_config.url, "log search enabled");
|
||||||
|
Some(index)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "failed to connect to OpenSearch");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. Build log store (with optional search indexing).
|
||||||
|
let log_store = {
|
||||||
|
let store = log_store::LogStore::new();
|
||||||
|
if let Some(ref index) = log_search_index {
|
||||||
|
Arc::new(store.with_search(index.clone()))
|
||||||
|
} else {
|
||||||
|
Arc::new(store)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7. Build WorkflowHost with lifecycle + log_sink.
|
||||||
|
let host = WorkflowHostBuilder::new()
|
||||||
|
.use_persistence(persistence)
|
||||||
|
.use_lock_provider(lock)
|
||||||
|
.use_queue_provider(queue)
|
||||||
|
.use_lifecycle(lifecycle_bus.clone() as Arc<dyn wfe_core::traits::LifecyclePublisher>)
|
||||||
|
.use_log_sink(log_store.clone() as Arc<dyn wfe_core::traits::LogSink>)
|
||||||
|
.build()
|
||||||
|
.expect("failed to build workflow host");
|
||||||
|
|
||||||
|
// 8. Auto-load YAML definitions.
|
||||||
|
if let Some(ref dir) = config.workflows_dir {
|
||||||
|
load_yaml_definitions(&host, dir).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Start the workflow engine.
|
||||||
|
host.start().await.expect("failed to start workflow host");
|
||||||
|
tracing::info!("workflow engine started");
|
||||||
|
|
||||||
|
let host = Arc::new(host);
|
||||||
|
|
||||||
|
// 10. Build gRPC service.
|
||||||
|
let mut wfe_service = WfeService::new(host.clone(), lifecycle_bus, log_store);
|
||||||
|
if let Some(index) = log_search_index {
|
||||||
|
wfe_service = wfe_service.with_log_search(index);
|
||||||
|
}
|
||||||
|
let (health_reporter, health_service) = tonic_health::server::health_reporter();
|
||||||
|
health_reporter
|
||||||
|
.set_serving::<WfeServer<WfeService>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 11. Build auth state.
|
||||||
|
let auth_state = Arc::new(auth::AuthState::new(config.auth.clone()).await);
|
||||||
|
let auth_interceptor = auth::make_interceptor(auth_state);
|
||||||
|
|
||||||
|
// 12. Build axum HTTP server for webhooks.
|
||||||
|
let webhook_state = webhook::WebhookState {
|
||||||
|
host: host.clone(),
|
||||||
|
config: config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// HIGH-08: Limit webhook payload size to 2 MB to prevent OOM DoS.
|
||||||
|
let http_router = axum::Router::new()
|
||||||
|
.route("/webhooks/events", axum::routing::post(webhook::handle_generic_event))
|
||||||
|
.route("/webhooks/github", axum::routing::post(webhook::handle_github_webhook))
|
||||||
|
.route("/webhooks/gitea", axum::routing::post(webhook::handle_gitea_webhook))
|
||||||
|
.route("/healthz", axum::routing::get(webhook::health_check))
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||||
|
.with_state(webhook_state);
|
||||||
|
|
||||||
|
// 12. Run gRPC + HTTP servers with graceful shutdown.
|
||||||
|
let grpc_addr = config.grpc_addr;
|
||||||
|
let http_addr = config.http_addr;
|
||||||
|
tracing::info!(%grpc_addr, %http_addr, "servers listening");
|
||||||
|
|
||||||
|
let grpc_server = Server::builder()
|
||||||
|
.add_service(health_service)
|
||||||
|
.add_service(WfeServer::with_interceptor(wfe_service, auth_interceptor))
|
||||||
|
.serve(grpc_addr);
|
||||||
|
|
||||||
|
let http_listener = tokio::net::TcpListener::bind(http_addr)
|
||||||
|
.await
|
||||||
|
.expect("failed to bind HTTP address");
|
||||||
|
let http_server = axum::serve(http_listener, http_router);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
result = grpc_server => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::error!(error = %e, "gRPC server error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = http_server => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::error!(error = %e, "HTTP server error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
tracing::info!("shutdown signal received");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Graceful shutdown.
|
||||||
|
host.stop().await;
|
||||||
|
tracing::info!("wfe-server stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_yaml_definitions(host: &wfe::WorkflowHost, dir: &std::path::Path) {
|
||||||
|
let entries = match std::fs::read_dir(dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(dir = %dir.display(), error = %e, "failed to read workflows directory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml") {
|
||||||
|
match wfe_yaml::load_workflow_from_str(
|
||||||
|
&std::fs::read_to_string(&path).unwrap_or_default(),
|
||||||
|
&config,
|
||||||
|
) {
|
||||||
|
Ok(workflows) => {
|
||||||
|
for compiled in workflows {
|
||||||
|
for (key, factory) in compiled.step_factories {
|
||||||
|
host.register_step_factory(&key, factory).await;
|
||||||
|
}
|
||||||
|
let id = compiled.definition.id.clone();
|
||||||
|
let version = compiled.definition.version;
|
||||||
|
host.register_workflow_definition(compiled.definition).await;
|
||||||
|
tracing::info!(id = %id, version, path = %path.display(), "loaded workflow definition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %e, "failed to compile workflow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
556
wfe-server/src/webhook.rs
Normal file
556
wfe-server/src/webhook.rs
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::Json;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
use crate::config::{ServerConfig, WebhookTrigger};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// Shared state for webhook handlers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WebhookState {
|
||||||
|
pub host: Arc<wfe::WorkflowHost>,
|
||||||
|
pub config: ServerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic event webhook.
|
||||||
|
///
|
||||||
|
/// POST /webhooks/events
|
||||||
|
/// Body: { "event_name": "...", "event_key": "...", "data": { ... } }
|
||||||
|
/// Requires bearer token authentication (same tokens as gRPC auth).
|
||||||
|
pub async fn handle_generic_event(
|
||||||
|
State(state): State<WebhookState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<GenericEventPayload>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// HIGH-07: Authenticate generic event endpoint.
|
||||||
|
if !state.config.auth.tokens.is_empty() {
|
||||||
|
let auth_header = headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let token = auth_header
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| auth_header.strip_prefix("bearer "))
|
||||||
|
.unwrap_or("");
|
||||||
|
if !crate::auth::check_static_tokens_pub(&state.config.auth.tokens, token) {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "invalid token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = payload.data.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
match state
|
||||||
|
.host
|
||||||
|
.publish_event(&payload.event_name, &payload.event_key, data)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => (StatusCode::OK, "event published"),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "failed to publish generic event");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "failed to publish event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GitHub webhook handler.
|
||||||
|
///
|
||||||
|
/// POST /webhooks/github
|
||||||
|
/// Verifies X-Hub-Signature-256, parses X-GitHub-Event header.
|
||||||
|
pub async fn handle_github_webhook(
|
||||||
|
State(state): State<WebhookState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// 1. Verify HMAC signature if secret is configured.
|
||||||
|
if let Some(secret) = state.config.auth.webhook_secrets.get("github") {
|
||||||
|
let sig_header = headers
|
||||||
|
.get("x-hub-signature-256")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if !verify_hmac_sha256(secret.as_bytes(), &body, sig_header) {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "invalid signature");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse event type.
|
||||||
|
let event_type = headers
|
||||||
|
.get("x-github-event")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// 3. Parse payload.
|
||||||
|
let payload: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "invalid GitHub webhook JSON");
|
||||||
|
return (StatusCode::BAD_REQUEST, "invalid JSON");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
event = event_type,
|
||||||
|
repo = payload["repository"]["full_name"].as_str().unwrap_or(""),
|
||||||
|
"received GitHub webhook"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Map to WFE event + check triggers.
|
||||||
|
let forge_event = map_forge_event(event_type, &payload);
|
||||||
|
|
||||||
|
// Publish as event (for workflows waiting on events).
|
||||||
|
if let Err(e) = state
|
||||||
|
.host
|
||||||
|
.publish_event(&forge_event.event_name, &forge_event.event_key, forge_event.data.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %e, "failed to publish forge event");
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "failed to publish event");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check triggers and auto-start workflows.
|
||||||
|
for trigger in &state.config.webhook.triggers {
|
||||||
|
if trigger.source != "github" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trigger.event != event_type {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ref match_ref) = trigger.match_ref {
|
||||||
|
let payload_ref = payload["ref"].as_str().unwrap_or("");
|
||||||
|
if payload_ref != match_ref {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = map_trigger_data(trigger, &payload);
|
||||||
|
match state
|
||||||
|
.host
|
||||||
|
.start_workflow(&trigger.workflow_id, trigger.version, data)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(id) => {
|
||||||
|
tracing::info!(
|
||||||
|
workflow_id = %id,
|
||||||
|
trigger = %trigger.workflow_id,
|
||||||
|
"webhook triggered workflow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
trigger = %trigger.workflow_id,
|
||||||
|
"failed to start triggered workflow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gitea webhook handler.
|
||||||
|
///
|
||||||
|
/// POST /webhooks/gitea
|
||||||
|
/// Verifies X-Gitea-Signature, parses X-Gitea-Event (or X-GitHub-Event) header.
|
||||||
|
/// Gitea payloads are intentionally compatible with GitHub's format.
|
||||||
|
pub async fn handle_gitea_webhook(
|
||||||
|
State(state): State<WebhookState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// 1. Verify HMAC signature if secret is configured.
|
||||||
|
if let Some(secret) = state.config.auth.webhook_secrets.get("gitea") {
|
||||||
|
// Gitea uses X-Gitea-Signature (raw hex, no sha256= prefix in older versions).
|
||||||
|
let sig_header = headers
|
||||||
|
.get("x-gitea-signature")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Handle both raw hex and sha256= prefixed formats.
|
||||||
|
if !verify_hmac_sha256(secret.as_bytes(), &body, sig_header)
|
||||||
|
&& !verify_hmac_sha256_raw(secret.as_bytes(), &body, sig_header)
|
||||||
|
{
|
||||||
|
return (StatusCode::UNAUTHORIZED, "invalid signature");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse event type (try Gitea header first, fall back to GitHub compat header).
|
||||||
|
let event_type = headers
|
||||||
|
.get("x-gitea-event")
|
||||||
|
.or_else(|| headers.get("x-github-event"))
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// 3. Parse payload.
|
||||||
|
let payload: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "invalid Gitea webhook JSON");
|
||||||
|
return (StatusCode::BAD_REQUEST, "invalid JSON");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
event = event_type,
|
||||||
|
repo = payload["repository"]["full_name"].as_str().unwrap_or(""),
|
||||||
|
"received Gitea webhook"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Map to WFE event + check triggers (same logic as GitHub).
|
||||||
|
let forge_event = map_forge_event(event_type, &payload);
|
||||||
|
|
||||||
|
if let Err(e) = state
|
||||||
|
.host
|
||||||
|
.publish_event(&forge_event.event_name, &forge_event.event_key, forge_event.data.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %e, "failed to publish forge event");
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "failed to publish event");
|
||||||
|
}
|
||||||
|
|
||||||
|
for trigger in &state.config.webhook.triggers {
|
||||||
|
if trigger.source != "gitea" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trigger.event != event_type {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ref match_ref) = trigger.match_ref {
|
||||||
|
let payload_ref = payload["ref"].as_str().unwrap_or("");
|
||||||
|
if payload_ref != match_ref {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = map_trigger_data(trigger, &payload);
|
||||||
|
match state
|
||||||
|
.host
|
||||||
|
.start_workflow(&trigger.workflow_id, trigger.version, data)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(id) => {
|
||||||
|
tracing::info!(workflow_id = %id, trigger = %trigger.workflow_id, "webhook triggered workflow");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, trigger = %trigger.workflow_id, "failed to start triggered workflow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check endpoint.
|
||||||
|
pub async fn health_check() -> impl IntoResponse {
|
||||||
|
(StatusCode::OK, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct GenericEventPayload {
|
||||||
|
pub event_name: String,
|
||||||
|
pub event_key: String,
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ForgeEvent {
|
||||||
|
event_name: String,
|
||||||
|
event_key: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verify HMAC-SHA256 signature with `sha256=<hex>` prefix (GitHub format).
|
||||||
|
fn verify_hmac_sha256(secret: &[u8], body: &[u8], signature: &str) -> bool {
|
||||||
|
let hex_sig = signature.strip_prefix("sha256=").unwrap_or("");
|
||||||
|
if hex_sig.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let expected = match hex::decode(hex_sig) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key size");
|
||||||
|
mac.update(body);
|
||||||
|
mac.verify_slice(&expected).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify HMAC-SHA256 signature as raw hex (no prefix, Gitea legacy format).
|
||||||
|
fn verify_hmac_sha256_raw(secret: &[u8], body: &[u8], signature: &str) -> bool {
|
||||||
|
let expected = match hex::decode(signature) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key size");
|
||||||
|
mac.update(body);
|
||||||
|
mac.verify_slice(&expected).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a git forge event type + payload to a WFE event.
|
||||||
|
fn map_forge_event(event_type: &str, payload: &serde_json::Value) -> ForgeEvent {
|
||||||
|
let repo = payload["repository"]["full_name"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
match event_type {
|
||||||
|
"push" => {
|
||||||
|
let git_ref = payload["ref"].as_str().unwrap_or("").to_string();
|
||||||
|
ForgeEvent {
|
||||||
|
event_name: "git.push".to_string(),
|
||||||
|
event_key: format!("{repo}/{git_ref}"),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"repo": repo,
|
||||||
|
"ref": git_ref,
|
||||||
|
"before": payload["before"].as_str().unwrap_or(""),
|
||||||
|
"after": payload["after"].as_str().unwrap_or(""),
|
||||||
|
"commit": payload["head_commit"]["id"].as_str().unwrap_or(""),
|
||||||
|
"message": payload["head_commit"]["message"].as_str().unwrap_or(""),
|
||||||
|
"sender": payload["sender"]["login"].as_str().unwrap_or(""),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pull_request" => {
|
||||||
|
let number = payload["number"].as_u64().unwrap_or(0);
|
||||||
|
ForgeEvent {
|
||||||
|
event_name: "git.pr".to_string(),
|
||||||
|
event_key: format!("{repo}/{number}"),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"repo": repo,
|
||||||
|
"action": payload["action"].as_str().unwrap_or(""),
|
||||||
|
"number": number,
|
||||||
|
"title": payload["pull_request"]["title"].as_str().unwrap_or(""),
|
||||||
|
"head_ref": payload["pull_request"]["head"]["ref"].as_str().unwrap_or(""),
|
||||||
|
"base_ref": payload["pull_request"]["base"]["ref"].as_str().unwrap_or(""),
|
||||||
|
"sender": payload["sender"]["login"].as_str().unwrap_or(""),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"create" => {
|
||||||
|
let ref_name = payload["ref"].as_str().unwrap_or("").to_string();
|
||||||
|
let ref_type = payload["ref_type"].as_str().unwrap_or("").to_string();
|
||||||
|
ForgeEvent {
|
||||||
|
event_name: format!("git.{ref_type}"),
|
||||||
|
event_key: format!("{repo}/{ref_name}"),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"repo": repo,
|
||||||
|
"ref": ref_name,
|
||||||
|
"ref_type": ref_type,
|
||||||
|
"sender": payload["sender"]["login"].as_str().unwrap_or(""),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => ForgeEvent {
|
||||||
|
event_name: format!("git.{event_type}"),
|
||||||
|
event_key: repo.clone(),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"repo": repo,
|
||||||
|
"event_type": event_type,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract data fields from payload using simple JSONPath-like mapping.
|
||||||
|
/// Supports `$.field.nested` syntax.
|
||||||
|
fn map_trigger_data(
|
||||||
|
trigger: &WebhookTrigger,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut data = serde_json::Map::new();
|
||||||
|
for (key, path) in &trigger.data_mapping {
|
||||||
|
if let Some(value) = resolve_json_path(payload, path) {
|
||||||
|
data.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a simple JSONPath expression like `$.repository.full_name`.
|
||||||
|
fn resolve_json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
|
||||||
|
let path = path.strip_prefix("$.").unwrap_or(path);
|
||||||
|
let mut current = value;
|
||||||
|
for segment in path.split('.') {
|
||||||
|
current = current.get(segment)?;
|
||||||
|
}
|
||||||
|
Some(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_github_hmac_valid() {
|
||||||
|
let secret = b"mysecret";
|
||||||
|
let body = b"hello world";
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||||
|
mac.update(body);
|
||||||
|
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||||
|
assert!(verify_hmac_sha256(secret, body, &sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_github_hmac_invalid() {
|
||||||
|
assert!(!verify_hmac_sha256(b"secret", b"body", "sha256=deadbeef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_github_hmac_missing_prefix() {
|
||||||
|
assert!(!verify_hmac_sha256(b"secret", b"body", "not-a-signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_gitea_hmac_raw_valid() {
|
||||||
|
let secret = b"giteasecret";
|
||||||
|
let body = b"payload";
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||||
|
mac.update(body);
|
||||||
|
let sig = hex::encode(mac.finalize().into_bytes());
|
||||||
|
assert!(verify_hmac_sha256_raw(secret, body, &sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_gitea_hmac_raw_invalid() {
|
||||||
|
assert!(!verify_hmac_sha256_raw(b"secret", b"body", "badhex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_push_event() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"before": "aaa",
|
||||||
|
"after": "bbb",
|
||||||
|
"head_commit": { "id": "bbb", "message": "fix: stuff" },
|
||||||
|
"repository": { "full_name": "studio/wfe" },
|
||||||
|
"sender": { "login": "sienna" }
|
||||||
|
});
|
||||||
|
let event = map_forge_event("push", &payload);
|
||||||
|
assert_eq!(event.event_name, "git.push");
|
||||||
|
assert_eq!(event.event_key, "studio/wfe/refs/heads/main");
|
||||||
|
assert_eq!(event.data["commit"], "bbb");
|
||||||
|
assert_eq!(event.data["sender"], "sienna");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_pull_request_event() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"action": "opened",
|
||||||
|
"number": 42,
|
||||||
|
"pull_request": {
|
||||||
|
"title": "Add feature",
|
||||||
|
"head": { "ref": "feature-branch" },
|
||||||
|
"base": { "ref": "main" }
|
||||||
|
},
|
||||||
|
"repository": { "full_name": "studio/wfe" },
|
||||||
|
"sender": { "login": "sienna" }
|
||||||
|
});
|
||||||
|
let event = map_forge_event("pull_request", &payload);
|
||||||
|
assert_eq!(event.event_name, "git.pr");
|
||||||
|
assert_eq!(event.event_key, "studio/wfe/42");
|
||||||
|
assert_eq!(event.data["action"], "opened");
|
||||||
|
assert_eq!(event.data["title"], "Add feature");
|
||||||
|
assert_eq!(event.data["head_ref"], "feature-branch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_create_tag_event() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"ref": "v1.5.0",
|
||||||
|
"ref_type": "tag",
|
||||||
|
"repository": { "full_name": "studio/wfe" },
|
||||||
|
"sender": { "login": "sienna" }
|
||||||
|
});
|
||||||
|
let event = map_forge_event("create", &payload);
|
||||||
|
assert_eq!(event.event_name, "git.tag");
|
||||||
|
assert_eq!(event.event_key, "studio/wfe/v1.5.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_create_branch_event() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"ref": "feature-x",
|
||||||
|
"ref_type": "branch",
|
||||||
|
"repository": { "full_name": "studio/wfe" },
|
||||||
|
"sender": { "login": "sienna" }
|
||||||
|
});
|
||||||
|
let event = map_forge_event("create", &payload);
|
||||||
|
assert_eq!(event.event_name, "git.branch");
|
||||||
|
assert_eq!(event.event_key, "studio/wfe/feature-x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_unknown_event() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"repository": { "full_name": "studio/wfe" }
|
||||||
|
});
|
||||||
|
let event = map_forge_event("release", &payload);
|
||||||
|
assert_eq!(event.event_name, "git.release");
|
||||||
|
assert_eq!(event.event_key, "studio/wfe");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_json_path_simple() {
|
||||||
|
let v = serde_json::json!({"a": {"b": {"c": "value"}}});
|
||||||
|
assert_eq!(resolve_json_path(&v, "$.a.b.c").unwrap(), "value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_json_path_no_prefix() {
|
||||||
|
let v = serde_json::json!({"repo": "test"});
|
||||||
|
assert_eq!(resolve_json_path(&v, "repo").unwrap(), "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_json_path_missing() {
|
||||||
|
let v = serde_json::json!({"a": 1});
|
||||||
|
assert!(resolve_json_path(&v, "$.b.c").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_trigger_data_extracts_fields() {
|
||||||
|
let trigger = WebhookTrigger {
|
||||||
|
source: "github".to_string(),
|
||||||
|
event: "push".to_string(),
|
||||||
|
match_ref: None,
|
||||||
|
workflow_id: "ci".to_string(),
|
||||||
|
version: 1,
|
||||||
|
data_mapping: [
|
||||||
|
("repo".to_string(), "$.repository.full_name".to_string()),
|
||||||
|
("commit".to_string(), "$.head_commit.id".to_string()),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
};
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"repository": { "full_name": "studio/wfe" },
|
||||||
|
"head_commit": { "id": "abc123" }
|
||||||
|
});
|
||||||
|
let data = map_trigger_data(&trigger, &payload);
|
||||||
|
assert_eq!(data["repo"], "studio/wfe");
|
||||||
|
assert_eq!(data["commit"], "abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_trigger_data_missing_field_skipped() {
|
||||||
|
let trigger = WebhookTrigger {
|
||||||
|
source: "github".to_string(),
|
||||||
|
event: "push".to_string(),
|
||||||
|
match_ref: None,
|
||||||
|
workflow_id: "ci".to_string(),
|
||||||
|
version: 1,
|
||||||
|
data_mapping: [("missing".to_string(), "$.nonexistent.field".to_string())].into(),
|
||||||
|
};
|
||||||
|
let payload = serde_json::json!({"repo": "test"});
|
||||||
|
let data = map_trigger_data(&trigger, &payload);
|
||||||
|
assert!(data.get("missing").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ name = "wfe-sqlite"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "SQLite persistence provider for WFE"
|
description = "SQLite persistence provider for WFE"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ name = "wfe-valkey"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "Valkey/Redis provider for distributed locking, queues, and lifecycle events in WFE"
|
description = "Valkey/Redis provider for distributed locking, queues, and lifecycle events in WFE"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -7,21 +7,29 @@ description = "YAML workflow definitions for WFE"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
deno = ["deno_core", "deno_error", "url", "reqwest"]
|
deno = ["deno_core", "deno_error", "url", "reqwest"]
|
||||||
|
buildkit = ["wfe-buildkit"]
|
||||||
|
containerd = ["wfe-containerd"]
|
||||||
|
rustlang = ["wfe-rustlang"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wfe-core = { workspace = true }
|
wfe-core = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = { workspace = true }
|
serde_yaml = { workspace = true }
|
||||||
|
yaml-merge-keys = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
deno_core = { workspace = true, optional = true }
|
deno_core = { workspace = true, optional = true }
|
||||||
deno_error = { workspace = true, optional = true }
|
deno_error = { workspace = true, optional = true }
|
||||||
url = { workspace = true, optional = true }
|
url = { workspace = true, optional = true }
|
||||||
reqwest = { workspace = true, optional = true }
|
reqwest = { workspace = true, optional = true }
|
||||||
|
wfe-buildkit = { workspace = true, optional = true }
|
||||||
|
wfe-containerd = { workspace = true, optional = true }
|
||||||
|
wfe-rustlang = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
@@ -31,3 +39,4 @@ wfe-core = { workspace = true, features = ["test-support"] }
|
|||||||
wfe = { path = "../wfe" }
|
wfe = { path = "../wfe" }
|
||||||
wiremock = { workspace = true }
|
wiremock = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
use wfe_core::models::error_behavior::ErrorBehavior;
|
use wfe_core::models::error_behavior::ErrorBehavior;
|
||||||
use wfe_core::models::workflow_definition::{StepOutcome, WorkflowDefinition, WorkflowStep};
|
use wfe_core::models::workflow_definition::{StepOutcome, WorkflowDefinition, WorkflowStep};
|
||||||
use wfe_core::traits::StepBody;
|
use wfe_core::traits::StepBody;
|
||||||
@@ -8,7 +9,24 @@ use crate::error::YamlWorkflowError;
|
|||||||
use crate::executors::shell::{ShellConfig, ShellStep};
|
use crate::executors::shell::{ShellConfig, ShellStep};
|
||||||
#[cfg(feature = "deno")]
|
#[cfg(feature = "deno")]
|
||||||
use crate::executors::deno::{DenoConfig, DenoPermissions, DenoStep};
|
use crate::executors::deno::{DenoConfig, DenoPermissions, DenoStep};
|
||||||
use crate::schema::{WorkflowSpec, YamlErrorBehavior, YamlStep};
|
#[cfg(feature = "buildkit")]
|
||||||
|
use wfe_buildkit::{BuildkitConfig, BuildkitStep};
|
||||||
|
#[cfg(feature = "containerd")]
|
||||||
|
use wfe_containerd::{ContainerdConfig, ContainerdStep};
|
||||||
|
#[cfg(feature = "rustlang")]
|
||||||
|
use wfe_rustlang::{CargoCommand, CargoConfig, CargoStep, RustupCommand, RustupConfig, RustupStep};
|
||||||
|
use wfe_core::primitives::sub_workflow::SubWorkflowStep;
|
||||||
|
use wfe_core::models::condition::{ComparisonOp, FieldComparison, StepCondition};
|
||||||
|
|
||||||
|
use crate::schema::{WorkflowSpec, YamlCombinator, YamlComparison, YamlCondition, YamlErrorBehavior, YamlStep};
|
||||||
|
|
||||||
|
/// Configuration for a sub-workflow step.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SubWorkflowConfig {
|
||||||
|
pub workflow_id: String,
|
||||||
|
pub version: u32,
|
||||||
|
pub output_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Factory type alias for step creation closures.
|
/// Factory type alias for step creation closures.
|
||||||
pub type StepFactory = Box<dyn Fn() -> Box<dyn StepBody> + Send + Sync>;
|
pub type StepFactory = Box<dyn Fn() -> Box<dyn StepBody> + Send + Sync>;
|
||||||
@@ -68,6 +86,11 @@ fn compile_steps(
|
|||||||
compile_steps(parallel_children, definition, factories, next_id)?;
|
compile_steps(parallel_children, definition, factories, next_id)?;
|
||||||
container.children = child_ids;
|
container.children = child_ids;
|
||||||
|
|
||||||
|
// Compile condition if present.
|
||||||
|
if let Some(ref yaml_cond) = yaml_step.when {
|
||||||
|
container.when = Some(compile_condition(yaml_cond)?);
|
||||||
|
}
|
||||||
|
|
||||||
definition.steps.push(container);
|
definition.steps.push(container);
|
||||||
main_step_ids.push(container_id);
|
main_step_ids.push(container_id);
|
||||||
} else {
|
} else {
|
||||||
@@ -94,6 +117,11 @@ fn compile_steps(
|
|||||||
wf_step.error_behavior = Some(map_error_behavior(eb)?);
|
wf_step.error_behavior = Some(map_error_behavior(eb)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile condition if present.
|
||||||
|
if let Some(ref yaml_cond) = yaml_step.when {
|
||||||
|
wf_step.when = Some(compile_condition(yaml_cond)?);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle on_failure: create compensation step.
|
// Handle on_failure: create compensation step.
|
||||||
if let Some(ref on_failure) = yaml_step.on_failure {
|
if let Some(ref on_failure) = yaml_step.on_failure {
|
||||||
let comp_id = *next_id;
|
let comp_id = *next_id;
|
||||||
@@ -216,6 +244,154 @@ fn compile_steps(
|
|||||||
Ok(main_step_ids)
|
Ok(main_step_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a YAML condition tree into a `StepCondition` tree.
|
||||||
|
pub fn compile_condition(yaml_cond: &YamlCondition) -> Result<StepCondition, YamlWorkflowError> {
|
||||||
|
match yaml_cond {
|
||||||
|
YamlCondition::Comparison(cmp) => compile_comparison(cmp.as_ref()),
|
||||||
|
YamlCondition::Combinator(combinator) => compile_combinator(combinator),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_combinator(c: &YamlCombinator) -> Result<StepCondition, YamlWorkflowError> {
|
||||||
|
// Count how many combinator keys are set to detect ambiguity.
|
||||||
|
let mut count = 0;
|
||||||
|
if c.all.is_some() {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if c.any.is_some() {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if c.none.is_some() {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if c.one_of.is_some() {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if c.not.is_some() {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return Err(YamlWorkflowError::Compilation(
|
||||||
|
"Condition combinator must have at least one of: all, any, none, one_of, not"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if count > 1 {
|
||||||
|
return Err(YamlWorkflowError::Compilation(
|
||||||
|
"Condition combinator must have exactly one of: all, any, none, one_of, not"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref children) = c.all {
|
||||||
|
let compiled: Result<Vec<_>, _> = children.iter().map(compile_condition).collect();
|
||||||
|
Ok(StepCondition::All(compiled?))
|
||||||
|
} else if let Some(ref children) = c.any {
|
||||||
|
let compiled: Result<Vec<_>, _> = children.iter().map(compile_condition).collect();
|
||||||
|
Ok(StepCondition::Any(compiled?))
|
||||||
|
} else if let Some(ref children) = c.none {
|
||||||
|
let compiled: Result<Vec<_>, _> = children.iter().map(compile_condition).collect();
|
||||||
|
Ok(StepCondition::None(compiled?))
|
||||||
|
} else if let Some(ref children) = c.one_of {
|
||||||
|
let compiled: Result<Vec<_>, _> = children.iter().map(compile_condition).collect();
|
||||||
|
Ok(StepCondition::OneOf(compiled?))
|
||||||
|
} else if let Some(ref inner) = c.not {
|
||||||
|
Ok(StepCondition::Not(Box::new(compile_condition(inner)?)))
|
||||||
|
} else {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_comparison(cmp: &YamlComparison) -> Result<StepCondition, YamlWorkflowError> {
|
||||||
|
// Determine which operator is specified. Exactly one must be present.
|
||||||
|
let mut ops: Vec<(ComparisonOp, Option<serde_json::Value>)> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref v) = cmp.equals {
|
||||||
|
ops.push((ComparisonOp::Equals, Some(yaml_value_to_json(v))));
|
||||||
|
}
|
||||||
|
if let Some(ref v) = cmp.not_equals {
|
||||||
|
ops.push((ComparisonOp::NotEquals, Some(yaml_value_to_json(v))));
|
||||||
|
}
|
||||||
|
if let Some(ref v) = cmp.gt {
|
||||||
|
ops.push((ComparisonOp::Gt, Some(yaml_value_to_json(v))));
|
||||||
|
}
|
||||||
|
if let Some(ref v) = cmp.gte {
|
||||||
|
ops.push((ComparisonOp::Gte, Some(yaml_value_to_json(v))));
|
||||||
|
}
|
||||||
|
if let Some(ref v) = cmp.lt {
|
||||||
|
ops.push((ComparisonOp::Lt, Some(yaml_value_to_json(v))));
|
||||||
|
}
|
||||||
|
if let Some(ref v) = cmp.lte {
|
||||||
|
ops.push((ComparisonOp::Lte, Some(yaml_value_to_json(v))));
|
||||||
|
}
|
||||||
|
if let Some(ref v) = cmp.contains {
|
||||||
|
ops.push((ComparisonOp::Contains, Some(yaml_value_to_json(v))));
|
||||||
|
}
|
||||||
|
if let Some(true) = cmp.is_null {
|
||||||
|
ops.push((ComparisonOp::IsNull, None));
|
||||||
|
}
|
||||||
|
if let Some(true) = cmp.is_not_null {
|
||||||
|
ops.push((ComparisonOp::IsNotNull, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ops.is_empty() {
|
||||||
|
return Err(YamlWorkflowError::Compilation(format!(
|
||||||
|
"Comparison on field '{}' must specify an operator (equals, gt, etc.)",
|
||||||
|
cmp.field
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if ops.len() > 1 {
|
||||||
|
return Err(YamlWorkflowError::Compilation(format!(
|
||||||
|
"Comparison on field '{}' must specify exactly one operator, found {}",
|
||||||
|
cmp.field,
|
||||||
|
ops.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (operator, value) = ops.remove(0);
|
||||||
|
Ok(StepCondition::Comparison(FieldComparison {
|
||||||
|
field: cmp.field.clone(),
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a serde_yaml::Value to serde_json::Value.
|
||||||
|
fn yaml_value_to_json(v: &serde_yaml::Value) -> serde_json::Value {
|
||||||
|
match v {
|
||||||
|
serde_yaml::Value::Null => serde_json::Value::Null,
|
||||||
|
serde_yaml::Value::Bool(b) => serde_json::Value::Bool(*b),
|
||||||
|
serde_yaml::Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
serde_json::Value::Number(serde_json::Number::from(i))
|
||||||
|
} else if let Some(u) = n.as_u64() {
|
||||||
|
serde_json::Value::Number(serde_json::Number::from(u))
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
serde_json::Number::from_f64(f)
|
||||||
|
.map(serde_json::Value::Number)
|
||||||
|
.unwrap_or(serde_json::Value::Null)
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_yaml::Value::String(s) => serde_json::Value::String(s.clone()),
|
||||||
|
serde_yaml::Value::Sequence(seq) => {
|
||||||
|
serde_json::Value::Array(seq.iter().map(yaml_value_to_json).collect())
|
||||||
|
}
|
||||||
|
serde_yaml::Value::Mapping(map) => {
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
for (k, val) in map {
|
||||||
|
if let serde_yaml::Value::String(key) = k {
|
||||||
|
obj.insert(key.clone(), yaml_value_to_json(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(obj)
|
||||||
|
}
|
||||||
|
serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(&tagged.value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_step_config_and_factory(
|
fn build_step_config_and_factory(
|
||||||
step: &YamlStep,
|
step: &YamlStep,
|
||||||
step_type: &str,
|
step_type: &str,
|
||||||
@@ -250,6 +426,108 @@ fn build_step_config_and_factory(
|
|||||||
});
|
});
|
||||||
Ok((key, value, factory))
|
Ok((key, value, factory))
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "buildkit")]
|
||||||
|
"buildkit" => {
|
||||||
|
let config = build_buildkit_config(step)?;
|
||||||
|
let key = format!("wfe_yaml::buildkit::{}", step.name);
|
||||||
|
let value = serde_json::to_value(&config).map_err(|e| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Failed to serialize buildkit config: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let config_clone = config.clone();
|
||||||
|
let factory: StepFactory = Box::new(move || {
|
||||||
|
Box::new(BuildkitStep::new(config_clone.clone())) as Box<dyn StepBody>
|
||||||
|
});
|
||||||
|
Ok((key, value, factory))
|
||||||
|
}
|
||||||
|
#[cfg(feature = "containerd")]
|
||||||
|
"containerd" => {
|
||||||
|
let config = build_containerd_config(step)?;
|
||||||
|
let key = format!("wfe_yaml::containerd::{}", step.name);
|
||||||
|
let value = serde_json::to_value(&config).map_err(|e| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Failed to serialize containerd config: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let config_clone = config.clone();
|
||||||
|
let factory: StepFactory = Box::new(move || {
|
||||||
|
Box::new(ContainerdStep::new(config_clone.clone())) as Box<dyn StepBody>
|
||||||
|
});
|
||||||
|
Ok((key, value, factory))
|
||||||
|
}
|
||||||
|
#[cfg(feature = "rustlang")]
|
||||||
|
"cargo-build" | "cargo-test" | "cargo-check" | "cargo-clippy" | "cargo-fmt"
|
||||||
|
| "cargo-doc" | "cargo-publish" | "cargo-audit" | "cargo-deny" | "cargo-nextest"
|
||||||
|
| "cargo-llvm-cov" | "cargo-doc-mdx" => {
|
||||||
|
let config = build_cargo_config(step, step_type)?;
|
||||||
|
let key = format!("wfe_yaml::cargo::{}", step.name);
|
||||||
|
let value = serde_json::to_value(&config).map_err(|e| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Failed to serialize cargo config: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let config_clone = config.clone();
|
||||||
|
let factory: StepFactory = Box::new(move || {
|
||||||
|
Box::new(CargoStep::new(config_clone.clone())) as Box<dyn StepBody>
|
||||||
|
});
|
||||||
|
Ok((key, value, factory))
|
||||||
|
}
|
||||||
|
#[cfg(feature = "rustlang")]
|
||||||
|
"rust-install" | "rustup-toolchain" | "rustup-component" | "rustup-target" => {
|
||||||
|
let config = build_rustup_config(step, step_type)?;
|
||||||
|
let key = format!("wfe_yaml::rustup::{}", step.name);
|
||||||
|
let value = serde_json::to_value(&config).map_err(|e| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Failed to serialize rustup config: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let config_clone = config.clone();
|
||||||
|
let factory: StepFactory = Box::new(move || {
|
||||||
|
Box::new(RustupStep::new(config_clone.clone())) as Box<dyn StepBody>
|
||||||
|
});
|
||||||
|
Ok((key, value, factory))
|
||||||
|
}
|
||||||
|
"workflow" => {
|
||||||
|
let config = step.config.as_ref().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Workflow step '{}' is missing 'config' section",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let child_workflow_id = config.child_workflow.as_ref().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Workflow step '{}' must have 'config.workflow'",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let child_version = config.child_version.unwrap_or(1);
|
||||||
|
|
||||||
|
let sub_config = SubWorkflowConfig {
|
||||||
|
workflow_id: child_workflow_id.clone(),
|
||||||
|
version: child_version,
|
||||||
|
output_keys: step.outputs.iter().map(|o| o.name.clone()).collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = format!("wfe_yaml::workflow::{}", step.name);
|
||||||
|
let value = serde_json::to_value(&sub_config).map_err(|e| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Failed to serialize workflow config: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let config_clone = sub_config.clone();
|
||||||
|
let factory: StepFactory = Box::new(move || {
|
||||||
|
Box::new(SubWorkflowStep {
|
||||||
|
workflow_id: config_clone.workflow_id.clone(),
|
||||||
|
version: config_clone.version,
|
||||||
|
output_keys: config_clone.output_keys.clone(),
|
||||||
|
inputs: serde_json::Value::Null,
|
||||||
|
input_schema: None,
|
||||||
|
output_schema: None,
|
||||||
|
}) as Box<dyn StepBody>
|
||||||
|
});
|
||||||
|
Ok((key, value, factory))
|
||||||
|
}
|
||||||
other => Err(YamlWorkflowError::Compilation(format!(
|
other => Err(YamlWorkflowError::Compilation(format!(
|
||||||
"Unknown step type: '{other}'"
|
"Unknown step type: '{other}'"
|
||||||
))),
|
))),
|
||||||
@@ -332,6 +610,88 @@ fn build_shell_config(step: &YamlStep) -> Result<ShellConfig, YamlWorkflowError>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rustlang")]
|
||||||
|
fn build_cargo_config(
|
||||||
|
step: &YamlStep,
|
||||||
|
step_type: &str,
|
||||||
|
) -> Result<CargoConfig, YamlWorkflowError> {
|
||||||
|
let command = match step_type {
|
||||||
|
"cargo-build" => CargoCommand::Build,
|
||||||
|
"cargo-test" => CargoCommand::Test,
|
||||||
|
"cargo-check" => CargoCommand::Check,
|
||||||
|
"cargo-clippy" => CargoCommand::Clippy,
|
||||||
|
"cargo-fmt" => CargoCommand::Fmt,
|
||||||
|
"cargo-doc" => CargoCommand::Doc,
|
||||||
|
"cargo-publish" => CargoCommand::Publish,
|
||||||
|
"cargo-audit" => CargoCommand::Audit,
|
||||||
|
"cargo-deny" => CargoCommand::Deny,
|
||||||
|
"cargo-nextest" => CargoCommand::Nextest,
|
||||||
|
"cargo-llvm-cov" => CargoCommand::LlvmCov,
|
||||||
|
"cargo-doc-mdx" => CargoCommand::DocMdx,
|
||||||
|
_ => {
|
||||||
|
return Err(YamlWorkflowError::Compilation(format!(
|
||||||
|
"Unknown cargo step type: '{step_type}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = step.config.as_ref();
|
||||||
|
let timeout_ms = config
|
||||||
|
.and_then(|c| c.timeout.as_ref())
|
||||||
|
.and_then(|t| parse_duration_ms(t));
|
||||||
|
|
||||||
|
Ok(CargoConfig {
|
||||||
|
command,
|
||||||
|
toolchain: config.and_then(|c| c.toolchain.clone()),
|
||||||
|
package: config.and_then(|c| c.package.clone()),
|
||||||
|
features: config.map(|c| c.features.clone()).unwrap_or_default(),
|
||||||
|
all_features: config.and_then(|c| c.all_features).unwrap_or(false),
|
||||||
|
no_default_features: config.and_then(|c| c.no_default_features).unwrap_or(false),
|
||||||
|
release: config.and_then(|c| c.release).unwrap_or(false),
|
||||||
|
target: config.and_then(|c| c.target.clone()),
|
||||||
|
profile: config.and_then(|c| c.profile.clone()),
|
||||||
|
extra_args: config.map(|c| c.extra_args.clone()).unwrap_or_default(),
|
||||||
|
env: config.map(|c| c.env.clone()).unwrap_or_default(),
|
||||||
|
working_dir: config.and_then(|c| c.working_dir.clone()),
|
||||||
|
timeout_ms,
|
||||||
|
output_dir: config.and_then(|c| c.output_dir.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rustlang")]
|
||||||
|
fn build_rustup_config(
|
||||||
|
step: &YamlStep,
|
||||||
|
step_type: &str,
|
||||||
|
) -> Result<RustupConfig, YamlWorkflowError> {
|
||||||
|
let command = match step_type {
|
||||||
|
"rust-install" => RustupCommand::Install,
|
||||||
|
"rustup-toolchain" => RustupCommand::ToolchainInstall,
|
||||||
|
"rustup-component" => RustupCommand::ComponentAdd,
|
||||||
|
"rustup-target" => RustupCommand::TargetAdd,
|
||||||
|
_ => {
|
||||||
|
return Err(YamlWorkflowError::Compilation(format!(
|
||||||
|
"Unknown rustup step type: '{step_type}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = step.config.as_ref();
|
||||||
|
let timeout_ms = config
|
||||||
|
.and_then(|c| c.timeout.as_ref())
|
||||||
|
.and_then(|t| parse_duration_ms(t));
|
||||||
|
|
||||||
|
Ok(RustupConfig {
|
||||||
|
command,
|
||||||
|
toolchain: config.and_then(|c| c.toolchain.clone()),
|
||||||
|
components: config.map(|c| c.components.clone()).unwrap_or_default(),
|
||||||
|
targets: config.map(|c| c.targets.clone()).unwrap_or_default(),
|
||||||
|
profile: config.and_then(|c| c.profile.clone()),
|
||||||
|
default_toolchain: config.and_then(|c| c.default_toolchain.clone()),
|
||||||
|
extra_args: config.map(|c| c.extra_args.clone()).unwrap_or_default(),
|
||||||
|
timeout_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_duration_ms(s: &str) -> Option<u64> {
|
fn parse_duration_ms(s: &str) -> Option<u64> {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
// Check "ms" before "s" since strip_suffix('s') would also match "500ms"
|
// Check "ms" before "s" since strip_suffix('s') would also match "500ms"
|
||||||
@@ -346,6 +706,162 @@ fn parse_duration_ms(s: &str) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "buildkit")]
|
||||||
|
fn build_buildkit_config(
|
||||||
|
step: &YamlStep,
|
||||||
|
) -> Result<BuildkitConfig, YamlWorkflowError> {
|
||||||
|
let config = step.config.as_ref().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"BuildKit step '{}' is missing 'config' section",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let dockerfile = config.dockerfile.clone().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"BuildKit step '{}' must have 'config.dockerfile'",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let context = config.context.clone().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"BuildKit step '{}' must have 'config.context'",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let timeout_ms = config.timeout.as_ref().and_then(|t| parse_duration_ms(t));
|
||||||
|
|
||||||
|
let tls = config
|
||||||
|
.tls
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| wfe_buildkit::TlsConfig {
|
||||||
|
ca: t.ca.clone(),
|
||||||
|
cert: t.cert.clone(),
|
||||||
|
key: t.key.clone(),
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let registry_auth = config
|
||||||
|
.registry_auth
|
||||||
|
.as_ref()
|
||||||
|
.map(|ra| {
|
||||||
|
ra.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
(
|
||||||
|
k.clone(),
|
||||||
|
wfe_buildkit::RegistryAuth {
|
||||||
|
username: v.username.clone(),
|
||||||
|
password: v.password.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(BuildkitConfig {
|
||||||
|
dockerfile,
|
||||||
|
context,
|
||||||
|
target: config.target.clone(),
|
||||||
|
tags: config.tags.clone(),
|
||||||
|
build_args: config.build_args.clone(),
|
||||||
|
cache_from: config.cache_from.clone(),
|
||||||
|
cache_to: config.cache_to.clone(),
|
||||||
|
push: config.push.unwrap_or(false),
|
||||||
|
output_type: None,
|
||||||
|
buildkit_addr: config
|
||||||
|
.buildkit_addr
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unix:///run/buildkit/buildkitd.sock".to_string()),
|
||||||
|
tls,
|
||||||
|
registry_auth,
|
||||||
|
timeout_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "containerd")]
|
||||||
|
fn build_containerd_config(
|
||||||
|
step: &YamlStep,
|
||||||
|
) -> Result<ContainerdConfig, YamlWorkflowError> {
|
||||||
|
let config = step.config.as_ref().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Containerd step '{}' is missing 'config' section",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let image = config.image.clone().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Compilation(format!(
|
||||||
|
"Containerd step '{}' must have 'config.image'",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let timeout_ms = config.timeout.as_ref().and_then(|t| parse_duration_ms(t));
|
||||||
|
|
||||||
|
let tls = config
|
||||||
|
.tls
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| wfe_containerd::TlsConfig {
|
||||||
|
ca: t.ca.clone(),
|
||||||
|
cert: t.cert.clone(),
|
||||||
|
key: t.key.clone(),
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let registry_auth = config
|
||||||
|
.registry_auth
|
||||||
|
.as_ref()
|
||||||
|
.map(|ra| {
|
||||||
|
ra.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
(
|
||||||
|
k.clone(),
|
||||||
|
wfe_containerd::RegistryAuth {
|
||||||
|
username: v.username.clone(),
|
||||||
|
password: v.password.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let volumes = config
|
||||||
|
.volumes
|
||||||
|
.iter()
|
||||||
|
.map(|v| wfe_containerd::VolumeMountConfig {
|
||||||
|
source: v.source.clone(),
|
||||||
|
target: v.target.clone(),
|
||||||
|
readonly: v.readonly,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(ContainerdConfig {
|
||||||
|
image,
|
||||||
|
command: config.command.clone(),
|
||||||
|
run: config.run.clone(),
|
||||||
|
env: config.env.clone(),
|
||||||
|
volumes,
|
||||||
|
working_dir: config.working_dir.clone(),
|
||||||
|
user: config.user.clone().unwrap_or_else(|| "65534:65534".to_string()),
|
||||||
|
network: config.network.clone().unwrap_or_else(|| "none".to_string()),
|
||||||
|
memory: config.memory.clone(),
|
||||||
|
cpu: config.cpu.clone(),
|
||||||
|
pull: config.pull.clone().unwrap_or_else(|| "if-not-present".to_string()),
|
||||||
|
containerd_addr: config
|
||||||
|
.containerd_addr
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "/run/containerd/containerd.sock".to_string()),
|
||||||
|
cli: config.cli.clone().unwrap_or_else(|| "nerdctl".to_string()),
|
||||||
|
tls,
|
||||||
|
registry_auth,
|
||||||
|
timeout_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn map_error_behavior(eb: &YamlErrorBehavior) -> Result<ErrorBehavior, YamlWorkflowError> {
|
fn map_error_behavior(eb: &YamlErrorBehavior) -> Result<ErrorBehavior, YamlWorkflowError> {
|
||||||
match eb.behavior_type.as_str() {
|
match eb.behavior_type.as_str() {
|
||||||
"retry" => {
|
"retry" => {
|
||||||
|
|||||||
4
wfe-yaml/src/executors/deno/js/bootstrap.js
vendored
4
wfe-yaml/src/executors/deno/js/bootstrap.js
vendored
@@ -2,6 +2,10 @@ globalThis.inputs = () => Deno.core.ops.op_inputs();
|
|||||||
globalThis.output = (key, value) => Deno.core.ops.op_output(key, value);
|
globalThis.output = (key, value) => Deno.core.ops.op_output(key, value);
|
||||||
globalThis.log = (msg) => Deno.core.ops.op_log(msg);
|
globalThis.log = (msg) => Deno.core.ops.op_log(msg);
|
||||||
|
|
||||||
|
globalThis.readFile = async (path) => {
|
||||||
|
return await Deno.core.ops.op_read_file(path);
|
||||||
|
};
|
||||||
|
|
||||||
globalThis.fetch = async (url, options) => {
|
globalThis.fetch = async (url, options) => {
|
||||||
const resp = await Deno.core.ops.op_fetch(url, options || null);
|
const resp = await Deno.core.ops.op_fetch(url, options || null);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -44,9 +44,29 @@ pub fn op_log(state: &mut OpState, #[string] msg: String) {
|
|||||||
tracing::info!(step = %name, "{}", msg);
|
tracing::info!(step = %name, "{}", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a file from the filesystem and returns its contents as a string.
|
||||||
|
/// Permission-checked against the read allowlist.
|
||||||
|
#[op2]
|
||||||
|
#[string]
|
||||||
|
pub async fn op_read_file(
|
||||||
|
state: std::rc::Rc<std::cell::RefCell<OpState>>,
|
||||||
|
#[string] path: String,
|
||||||
|
) -> Result<String, deno_error::JsErrorBox> {
|
||||||
|
// Check read permission
|
||||||
|
{
|
||||||
|
let s = state.borrow();
|
||||||
|
let checker = s.borrow::<super::super::permissions::PermissionChecker>();
|
||||||
|
checker.check_read(&path)
|
||||||
|
.map_err(|e| deno_error::JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||||
|
}
|
||||||
|
tokio::fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| deno_error::JsErrorBox::generic(format!("Failed to read file '{path}': {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
deno_core::extension!(
|
deno_core::extension!(
|
||||||
wfe_ops,
|
wfe_ops,
|
||||||
ops = [op_inputs, op_output, op_log, super::http::op_fetch],
|
ops = [op_inputs, op_output, op_log, op_read_file, super::http::op_fetch],
|
||||||
esm_entry_point = "ext:wfe/bootstrap.js",
|
esm_entry_point = "ext:wfe/bootstrap.js",
|
||||||
esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"],
|
esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,18 +23,23 @@ impl ShellStep {
|
|||||||
pub fn new(config: ShellConfig) -> Self {
|
pub fn new(config: ShellConfig) -> Self {
|
||||||
Self { config }
|
Self { config }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
fn build_command(&self, context: &StepExecutionContext<'_>) -> tokio::process::Command {
|
||||||
impl StepBody for ShellStep {
|
|
||||||
async fn run(&mut self, context: &StepExecutionContext<'_>) -> wfe_core::Result<ExecutionResult> {
|
|
||||||
let mut cmd = tokio::process::Command::new(&self.config.shell);
|
let mut cmd = tokio::process::Command::new(&self.config.shell);
|
||||||
cmd.arg("-c").arg(&self.config.run);
|
cmd.arg("-c").arg(&self.config.run);
|
||||||
|
|
||||||
// Inject workflow data as UPPER_CASE env vars (top-level keys only).
|
// Inject workflow data as UPPER_CASE env vars (top-level keys only).
|
||||||
|
// Skip keys that would override security-sensitive environment variables.
|
||||||
|
const BLOCKED_KEYS: &[&str] = &[
|
||||||
|
"PATH", "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH",
|
||||||
|
"HOME", "SHELL", "USER", "LOGNAME", "TERM",
|
||||||
|
];
|
||||||
if let Some(data_obj) = context.workflow.data.as_object() {
|
if let Some(data_obj) = context.workflow.data.as_object() {
|
||||||
for (key, value) in data_obj {
|
for (key, value) in data_obj {
|
||||||
let env_key = key.to_uppercase();
|
let env_key = key.to_uppercase();
|
||||||
|
if BLOCKED_KEYS.contains(&env_key.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let env_val = match value {
|
let env_val = match value {
|
||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
other => other.to_string(),
|
other => other.to_string(),
|
||||||
@@ -43,12 +48,10 @@ impl StepBody for ShellStep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add extra env from config.
|
|
||||||
for (key, value) in &self.config.env {
|
for (key, value) in &self.config.env {
|
||||||
cmd.env(key, value);
|
cmd.env(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set working directory if specified.
|
|
||||||
if let Some(ref dir) = self.config.working_dir {
|
if let Some(ref dir) = self.config.working_dir {
|
||||||
cmd.current_dir(dir);
|
cmd.current_dir(dir);
|
||||||
}
|
}
|
||||||
@@ -56,15 +59,137 @@ impl StepBody for ShellStep {
|
|||||||
cmd.stdout(std::process::Stdio::piped());
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
cmd.stderr(std::process::Stdio::piped());
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
// Execute with optional timeout.
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run with streaming output via LogSink.
|
||||||
|
///
|
||||||
|
/// Reads stdout and stderr line-by-line, streaming each line to the
|
||||||
|
/// LogSink as it's produced. Uses `tokio::select!` to interleave both
|
||||||
|
/// streams without spawning tasks (avoids lifetime issues with &dyn LogSink).
|
||||||
|
async fn run_streaming(
|
||||||
|
&self,
|
||||||
|
context: &StepExecutionContext<'_>,
|
||||||
|
) -> wfe_core::Result<(String, String, i32)> {
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use wfe_core::traits::{LogChunk, LogStreamType};
|
||||||
|
|
||||||
|
let log_sink = context.log_sink.unwrap();
|
||||||
|
let workflow_id = context.workflow.id.clone();
|
||||||
|
let definition_id = context.workflow.workflow_definition_id.clone();
|
||||||
|
let step_id = context.step.id;
|
||||||
|
let step_name = context.step.name.clone().unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let mut cmd = self.build_command(context);
|
||||||
|
let mut child = cmd.spawn().map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("Failed to spawn shell command: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let stdout_pipe = child.stdout.take().ok_or_else(|| {
|
||||||
|
WfeError::StepExecution("failed to capture stdout pipe".to_string())
|
||||||
|
})?;
|
||||||
|
let stderr_pipe = child.stderr.take().ok_or_else(|| {
|
||||||
|
WfeError::StepExecution("failed to capture stderr pipe".to_string())
|
||||||
|
})?;
|
||||||
|
let mut stdout_lines = BufReader::new(stdout_pipe).lines();
|
||||||
|
let mut stderr_lines = BufReader::new(stderr_pipe).lines();
|
||||||
|
|
||||||
|
let mut stdout_buf = Vec::new();
|
||||||
|
let mut stderr_buf = Vec::new();
|
||||||
|
let mut stdout_done = false;
|
||||||
|
let mut stderr_done = false;
|
||||||
|
|
||||||
|
// Interleave stdout/stderr reads with optional timeout.
|
||||||
|
let read_future = async {
|
||||||
|
while !stdout_done || !stderr_done {
|
||||||
|
tokio::select! {
|
||||||
|
line = stdout_lines.next_line(), if !stdout_done => {
|
||||||
|
match line {
|
||||||
|
Ok(Some(line)) => {
|
||||||
|
log_sink.write_chunk(LogChunk {
|
||||||
|
workflow_id: workflow_id.clone(),
|
||||||
|
definition_id: definition_id.clone(),
|
||||||
|
step_id,
|
||||||
|
step_name: step_name.clone(),
|
||||||
|
stream: LogStreamType::Stdout,
|
||||||
|
data: format!("{line}\n").into_bytes(),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
}).await;
|
||||||
|
stdout_buf.push(line);
|
||||||
|
}
|
||||||
|
_ => stdout_done = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line = stderr_lines.next_line(), if !stderr_done => {
|
||||||
|
match line {
|
||||||
|
Ok(Some(line)) => {
|
||||||
|
log_sink.write_chunk(LogChunk {
|
||||||
|
workflow_id: workflow_id.clone(),
|
||||||
|
definition_id: definition_id.clone(),
|
||||||
|
step_id,
|
||||||
|
step_name: step_name.clone(),
|
||||||
|
stream: LogStreamType::Stderr,
|
||||||
|
data: format!("{line}\n").into_bytes(),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
}).await;
|
||||||
|
stderr_buf.push(line);
|
||||||
|
}
|
||||||
|
_ => stderr_done = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait().await
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = if let Some(timeout_ms) = self.config.timeout_ms {
|
||||||
|
let duration = std::time::Duration::from_millis(timeout_ms);
|
||||||
|
match tokio::time::timeout(duration, read_future).await {
|
||||||
|
Ok(result) => result.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("Failed to wait for shell command: {e}"))
|
||||||
|
})?,
|
||||||
|
Err(_) => {
|
||||||
|
// Kill the child on timeout.
|
||||||
|
let _ = child.kill().await;
|
||||||
|
return Err(WfeError::StepExecution(format!(
|
||||||
|
"Shell command timed out after {timeout_ms}ms"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
read_future.await.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("Failed to wait for shell command: {e}"))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stdout = stdout_buf.join("\n");
|
||||||
|
let mut stderr = stderr_buf.join("\n");
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
stdout.push('\n');
|
||||||
|
}
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
stderr.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((stdout, stderr, status.code().unwrap_or(-1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run with buffered output (original path, no LogSink).
|
||||||
|
async fn run_buffered(
|
||||||
|
&self,
|
||||||
|
context: &StepExecutionContext<'_>,
|
||||||
|
) -> wfe_core::Result<(String, String, i32)> {
|
||||||
|
let mut cmd = self.build_command(context);
|
||||||
|
|
||||||
let output = if let Some(timeout_ms) = self.config.timeout_ms {
|
let output = if let Some(timeout_ms) = self.config.timeout_ms {
|
||||||
let duration = std::time::Duration::from_millis(timeout_ms);
|
let duration = std::time::Duration::from_millis(timeout_ms);
|
||||||
match tokio::time::timeout(duration, cmd.output()).await {
|
match tokio::time::timeout(duration, cmd.output()).await {
|
||||||
Ok(result) => result.map_err(|e| WfeError::StepExecution(format!("Failed to spawn shell command: {e}")))?,
|
Ok(result) => result.map_err(|e| {
|
||||||
|
WfeError::StepExecution(format!("Failed to spawn shell command: {e}"))
|
||||||
|
})?,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err(WfeError::StepExecution(format!(
|
return Err(WfeError::StepExecution(format!(
|
||||||
"Shell command timed out after {}ms",
|
"Shell command timed out after {timeout_ms}ms"
|
||||||
timeout_ms
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,11 +201,24 @@ impl StepBody for ShellStep {
|
|||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
let code = output.status.code().unwrap_or(-1);
|
||||||
|
|
||||||
if !output.status.success() {
|
Ok((stdout, stderr, code))
|
||||||
let code = output.status.code().unwrap_or(-1);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StepBody for ShellStep {
|
||||||
|
async fn run(&mut self, context: &StepExecutionContext<'_>) -> wfe_core::Result<ExecutionResult> {
|
||||||
|
let (stdout, stderr, exit_code) = if context.log_sink.is_some() {
|
||||||
|
self.run_streaming(context).await?
|
||||||
|
} else {
|
||||||
|
self.run_buffered(context).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
if exit_code != 0 {
|
||||||
return Err(WfeError::StepExecution(format!(
|
return Err(WfeError::StepExecution(format!(
|
||||||
"Shell command exited with code {code}\nstdout: {stdout}\nstderr: {stderr}"
|
"Shell command exited with code {exit_code}\nstdout: {stdout}\nstderr: {stderr}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,20 +230,27 @@ impl StepBody for ShellStep {
|
|||||||
&& let Some(eq_pos) = rest.find('=')
|
&& let Some(eq_pos) = rest.find('=')
|
||||||
{
|
{
|
||||||
let name = rest[..eq_pos].trim().to_string();
|
let name = rest[..eq_pos].trim().to_string();
|
||||||
let value = rest[eq_pos + 1..].to_string();
|
let raw_value = rest[eq_pos + 1..].to_string();
|
||||||
outputs.insert(name, serde_json::Value::String(value));
|
let value = match raw_value.as_str() {
|
||||||
|
"true" => serde_json::Value::Bool(true),
|
||||||
|
"false" => serde_json::Value::Bool(false),
|
||||||
|
"null" => serde_json::Value::Null,
|
||||||
|
s if s.parse::<i64>().is_ok() => {
|
||||||
|
serde_json::Value::Number(s.parse::<i64>().unwrap().into())
|
||||||
|
}
|
||||||
|
s if s.parse::<f64>().is_ok() => {
|
||||||
|
serde_json::json!(s.parse::<f64>().unwrap())
|
||||||
|
}
|
||||||
|
_ => serde_json::Value::String(raw_value),
|
||||||
|
};
|
||||||
|
outputs.insert(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add raw stdout under the step name.
|
let step_name = context.step.name.as_deref().unwrap_or("unknown");
|
||||||
let step_name = context
|
|
||||||
.step
|
|
||||||
.name
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
outputs.insert(
|
outputs.insert(
|
||||||
format!("{step_name}.stdout"),
|
format!("{step_name}.stdout"),
|
||||||
serde_json::Value::String(stdout.clone()),
|
serde_json::Value::String(stdout),
|
||||||
);
|
);
|
||||||
outputs.insert(
|
outputs.insert(
|
||||||
format!("{step_name}.stderr"),
|
format!("{step_name}.stderr"),
|
||||||
|
|||||||
@@ -3,36 +3,219 @@ pub mod error;
|
|||||||
pub mod executors;
|
pub mod executors;
|
||||||
pub mod interpolation;
|
pub mod interpolation;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod types;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::de::Error as _;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::compiler::CompiledWorkflow;
|
use crate::compiler::CompiledWorkflow;
|
||||||
use crate::error::YamlWorkflowError;
|
use crate::error::YamlWorkflowError;
|
||||||
|
|
||||||
/// Load a workflow from a YAML file path, applying variable interpolation.
|
/// Top-level YAML file with optional includes.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct YamlWorkflowFileWithIncludes {
|
||||||
|
#[serde(default)]
|
||||||
|
pub include: Vec<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub file: schema::YamlWorkflowFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load workflows from a YAML file path, applying variable interpolation.
|
||||||
|
/// Returns a Vec of compiled workflows (supports multi-workflow files).
|
||||||
pub fn load_workflow(
|
pub fn load_workflow(
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
config: &HashMap<String, serde_json::Value>,
|
config: &HashMap<String, serde_json::Value>,
|
||||||
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
||||||
let yaml = std::fs::read_to_string(path)?;
|
let yaml = std::fs::read_to_string(path)?;
|
||||||
load_workflow_from_str(&yaml, config)
|
load_single_workflow_from_str(&yaml, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a workflow from a YAML string, applying variable interpolation.
|
/// Load workflows from a YAML string, applying variable interpolation.
|
||||||
|
/// Returns a Vec of compiled workflows (supports multi-workflow files).
|
||||||
|
///
|
||||||
|
/// Supports YAML 1.1 merge keys (`<<: *anchor`) via the `yaml-merge-keys`
|
||||||
|
/// crate. serde_yaml 0.9 implements YAML 1.2 which dropped merge keys;
|
||||||
|
/// we preprocess the YAML to resolve them before deserialization.
|
||||||
pub fn load_workflow_from_str(
|
pub fn load_workflow_from_str(
|
||||||
yaml: &str,
|
yaml: &str,
|
||||||
config: &HashMap<String, serde_json::Value>,
|
config: &HashMap<String, serde_json::Value>,
|
||||||
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
) -> Result<Vec<CompiledWorkflow>, YamlWorkflowError> {
|
||||||
// Interpolate variables.
|
// Interpolate variables.
|
||||||
let interpolated = interpolation::interpolate(yaml, config)?;
|
let interpolated = interpolation::interpolate(yaml, config)?;
|
||||||
|
|
||||||
// Parse YAML.
|
// Parse to a generic YAML value first, then resolve merge keys (<<:).
|
||||||
let workflow: schema::YamlWorkflow = serde_yaml::from_str(&interpolated)?;
|
// This adds YAML 1.1 merge key support on top of serde_yaml 0.9's YAML 1.2 parser.
|
||||||
|
let raw_value: serde_yaml::Value = serde_yaml::from_str(&interpolated)?;
|
||||||
|
let merged_value = yaml_merge_keys::merge_keys_serde(raw_value)
|
||||||
|
.map_err(|e| YamlWorkflowError::Parse(serde_yaml::Error::custom(format!("merge key resolution failed: {e}"))))?;
|
||||||
|
|
||||||
// Validate.
|
// Deserialize the merge-resolved value into our schema.
|
||||||
validation::validate(&workflow.workflow)?;
|
let file: schema::YamlWorkflowFile = serde_yaml::from_value(merged_value)?;
|
||||||
|
|
||||||
// Compile.
|
let specs = resolve_workflow_specs(file)?;
|
||||||
compiler::compile(&workflow.workflow)
|
|
||||||
|
// Validate (multi-workflow validation includes per-workflow + cross-references).
|
||||||
|
validation::validate_multi(&specs)?;
|
||||||
|
|
||||||
|
// Compile each workflow.
|
||||||
|
let mut results = Vec::with_capacity(specs.len());
|
||||||
|
for spec in &specs {
|
||||||
|
results.push(compiler::compile(spec)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a single workflow from a YAML string. Returns an error if the file
|
||||||
|
/// contains more than one workflow. This is a backward-compatible convenience
|
||||||
|
/// function.
|
||||||
|
pub fn load_single_workflow_from_str(
|
||||||
|
yaml: &str,
|
||||||
|
config: &HashMap<String, serde_json::Value>,
|
||||||
|
) -> Result<CompiledWorkflow, YamlWorkflowError> {
|
||||||
|
let mut workflows = load_workflow_from_str(yaml, config)?;
|
||||||
|
if workflows.len() != 1 {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Expected single workflow, got {}",
|
||||||
|
workflows.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(workflows.remove(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load workflows from a YAML string, resolving `include:` paths relative to `base_path`.
|
||||||
|
///
|
||||||
|
/// Processing:
|
||||||
|
/// 1. Parse the main YAML to get `include:` paths.
|
||||||
|
/// 2. For each include path, load and parse that YAML file.
|
||||||
|
/// 3. Merge workflow specs from included files into the main file's specs.
|
||||||
|
/// 4. Main file's workflows take precedence over included ones (by ID).
|
||||||
|
/// 5. Proceed with normal validation + compilation.
|
||||||
|
pub fn load_workflow_with_includes(
|
||||||
|
yaml: &str,
|
||||||
|
base_path: &Path,
|
||||||
|
config: &HashMap<String, serde_json::Value>,
|
||||||
|
) -> Result<Vec<CompiledWorkflow>, YamlWorkflowError> {
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
let canonical = base_path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| base_path.to_path_buf());
|
||||||
|
visited.insert(canonical.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
let interpolated = interpolation::interpolate(yaml, config)?;
|
||||||
|
let raw_value: serde_yaml::Value = serde_yaml::from_str(&interpolated)?;
|
||||||
|
let merged_value = yaml_merge_keys::merge_keys_serde(raw_value)
|
||||||
|
.map_err(|e| {
|
||||||
|
YamlWorkflowError::Parse(serde_yaml::Error::custom(format!(
|
||||||
|
"merge key resolution failed: {e}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let with_includes: YamlWorkflowFileWithIncludes = serde_yaml::from_value(merged_value)?;
|
||||||
|
|
||||||
|
let mut main_specs = resolve_workflow_specs(with_includes.file)?;
|
||||||
|
|
||||||
|
// Process includes.
|
||||||
|
for include_path_str in &with_includes.include {
|
||||||
|
let include_path = base_path.parent().unwrap_or(base_path).join(include_path_str);
|
||||||
|
load_includes_recursive(
|
||||||
|
&include_path,
|
||||||
|
config,
|
||||||
|
&mut main_specs,
|
||||||
|
&mut visited,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main file takes precedence: included specs are only added if their ID
|
||||||
|
// isn't already present. This is handled by load_includes_recursive.
|
||||||
|
|
||||||
|
validation::validate_multi(&main_specs)?;
|
||||||
|
|
||||||
|
let mut results = Vec::with_capacity(main_specs.len());
|
||||||
|
for spec in &main_specs {
|
||||||
|
results.push(compiler::compile(spec)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_includes_recursive(
|
||||||
|
path: &Path,
|
||||||
|
config: &HashMap<String, serde_json::Value>,
|
||||||
|
specs: &mut Vec<schema::WorkflowSpec>,
|
||||||
|
visited: &mut HashSet<String>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
let canonical = path
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| {
|
||||||
|
YamlWorkflowError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
format!("Include file not found: {}: {e}", path.display()),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let canonical_str = canonical.to_string_lossy().to_string();
|
||||||
|
if !visited.insert(canonical_str.clone()) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Circular include detected: '{}'",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let yaml = std::fs::read_to_string(&canonical)?;
|
||||||
|
let interpolated = interpolation::interpolate(&yaml, config)?;
|
||||||
|
let raw_value: serde_yaml::Value = serde_yaml::from_str(&interpolated)?;
|
||||||
|
let merged_value = yaml_merge_keys::merge_keys_serde(raw_value)
|
||||||
|
.map_err(|e| {
|
||||||
|
YamlWorkflowError::Parse(serde_yaml::Error::custom(format!(
|
||||||
|
"merge key resolution failed: {e}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let with_includes: YamlWorkflowFileWithIncludes = serde_yaml::from_value(merged_value)?;
|
||||||
|
|
||||||
|
let included_specs = resolve_workflow_specs(with_includes.file)?;
|
||||||
|
|
||||||
|
// Existing IDs in main specs take precedence.
|
||||||
|
let existing_ids: HashSet<String> = specs.iter().map(|s| s.id.clone()).collect();
|
||||||
|
for spec in included_specs {
|
||||||
|
if !existing_ids.contains(&spec.id) {
|
||||||
|
specs.push(spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into nested includes.
|
||||||
|
for nested_include in &with_includes.include {
|
||||||
|
let nested_path = canonical.parent().unwrap_or(&canonical).join(nested_include);
|
||||||
|
load_includes_recursive(&nested_path, config, specs, visited)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a YamlWorkflowFile into a list of WorkflowSpecs.
|
||||||
|
fn resolve_workflow_specs(
|
||||||
|
file: schema::YamlWorkflowFile,
|
||||||
|
) -> Result<Vec<schema::WorkflowSpec>, YamlWorkflowError> {
|
||||||
|
match (file.workflow, file.workflows) {
|
||||||
|
(Some(single), None) => Ok(vec![single]),
|
||||||
|
(None, Some(multi)) => {
|
||||||
|
if multi.is_empty() {
|
||||||
|
return Err(YamlWorkflowError::Validation(
|
||||||
|
"workflows list is empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(multi)
|
||||||
|
}
|
||||||
|
(Some(_), Some(_)) => Err(YamlWorkflowError::Validation(
|
||||||
|
"Cannot specify both 'workflow' and 'workflows' in the same file".to_string(),
|
||||||
|
)),
|
||||||
|
(None, None) => Err(YamlWorkflowError::Validation(
|
||||||
|
"Must specify either 'workflow' or 'workflows'".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,70 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// A condition in YAML that determines whether a step executes.
|
||||||
|
///
|
||||||
|
/// Uses `#[serde(untagged)]` so serde tries each variant in order.
|
||||||
|
/// A comparison has a `field:` key; a combinator has `all:/any:/none:/one_of:/not:`.
|
||||||
|
/// Comparison is listed first because it is more specific (requires `field`).
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum YamlCondition {
|
||||||
|
/// Leaf comparison (has a `field:` key).
|
||||||
|
Comparison(Box<YamlComparison>),
|
||||||
|
/// Combinator with sub-conditions.
|
||||||
|
Combinator(YamlCombinator),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A combinator condition containing sub-conditions.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct YamlCombinator {
|
||||||
|
#[serde(default)]
|
||||||
|
pub all: Option<Vec<YamlCondition>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub any: Option<Vec<YamlCondition>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub none: Option<Vec<YamlCondition>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub one_of: Option<Vec<YamlCondition>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub not: Option<Box<YamlCondition>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A leaf comparison condition that compares a field value.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct YamlComparison {
|
||||||
|
pub field: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub equals: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub not_equals: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gt: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gte: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lt: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lte: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub contains: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_null: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_not_null: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level YAML file structure supporting both single and multi-workflow files.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct YamlWorkflowFile {
|
||||||
|
/// Single workflow (backward compatible).
|
||||||
|
pub workflow: Option<WorkflowSpec>,
|
||||||
|
/// Multiple workflows in one file.
|
||||||
|
pub workflows: Option<Vec<WorkflowSpec>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy single-workflow top-level structure. Kept for backward compatibility
|
||||||
|
/// with code that deserializes `YamlWorkflow` directly.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct YamlWorkflow {
|
pub struct YamlWorkflow {
|
||||||
pub workflow: WorkflowSpec,
|
pub workflow: WorkflowSpec,
|
||||||
@@ -16,6 +80,13 @@ pub struct WorkflowSpec {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub error_behavior: Option<YamlErrorBehavior>,
|
pub error_behavior: Option<YamlErrorBehavior>,
|
||||||
pub steps: Vec<YamlStep>,
|
pub steps: Vec<YamlStep>,
|
||||||
|
/// Typed input schema: { field_name: type_string }.
|
||||||
|
/// Example: `"repo_url": "string"`, `"tags": "list<string>"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub inputs: HashMap<String, String>,
|
||||||
|
/// Typed output schema: { field_name: type_string }.
|
||||||
|
#[serde(default)]
|
||||||
|
pub outputs: HashMap<String, String>,
|
||||||
/// Allow unknown top-level keys (e.g. `_templates`) for YAML anchors.
|
/// Allow unknown top-level keys (e.g. `_templates`) for YAML anchors.
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub _extra: HashMap<String, serde_yaml::Value>,
|
pub _extra: HashMap<String, serde_yaml::Value>,
|
||||||
@@ -42,6 +113,9 @@ pub struct YamlStep {
|
|||||||
pub on_failure: Option<Box<YamlStep>>,
|
pub on_failure: Option<Box<YamlStep>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ensure: Option<Box<YamlStep>>,
|
pub ensure: Option<Box<YamlStep>>,
|
||||||
|
/// Optional condition that must be true for this step to execute.
|
||||||
|
#[serde(default)]
|
||||||
|
pub when: Option<YamlCondition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -58,6 +132,78 @@ pub struct StepConfig {
|
|||||||
pub permissions: Option<DenoPermissionsYaml>,
|
pub permissions: Option<DenoPermissionsYaml>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modules: Vec<String>,
|
pub modules: Vec<String>,
|
||||||
|
// BuildKit fields
|
||||||
|
pub dockerfile: Option<String>,
|
||||||
|
pub context: Option<String>,
|
||||||
|
pub target: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub build_args: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_from: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_to: Vec<String>,
|
||||||
|
pub push: Option<bool>,
|
||||||
|
pub buildkit_addr: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls: Option<TlsConfigYaml>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub registry_auth: Option<HashMap<String, RegistryAuthYaml>>,
|
||||||
|
// Containerd fields
|
||||||
|
pub image: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub command: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub volumes: Vec<VolumeMountYaml>,
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub network: Option<String>,
|
||||||
|
pub memory: Option<String>,
|
||||||
|
pub cpu: Option<String>,
|
||||||
|
pub pull: Option<String>,
|
||||||
|
pub containerd_addr: Option<String>,
|
||||||
|
/// CLI binary name for containerd steps: "nerdctl" (default) or "docker".
|
||||||
|
pub cli: Option<String>,
|
||||||
|
// Cargo fields
|
||||||
|
/// Target package for cargo steps (`-p`).
|
||||||
|
pub package: Option<String>,
|
||||||
|
/// Features to enable for cargo steps.
|
||||||
|
#[serde(default)]
|
||||||
|
pub features: Vec<String>,
|
||||||
|
/// Enable all features for cargo steps.
|
||||||
|
#[serde(default)]
|
||||||
|
pub all_features: Option<bool>,
|
||||||
|
/// Disable default features for cargo steps.
|
||||||
|
#[serde(default)]
|
||||||
|
pub no_default_features: Option<bool>,
|
||||||
|
/// Build in release mode for cargo steps.
|
||||||
|
#[serde(default)]
|
||||||
|
pub release: Option<bool>,
|
||||||
|
/// Build profile for cargo steps (`--profile`).
|
||||||
|
pub profile: Option<String>,
|
||||||
|
/// Rust toolchain override for cargo steps (e.g. "nightly").
|
||||||
|
pub toolchain: Option<String>,
|
||||||
|
/// Additional arguments for cargo/rustup steps.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_args: Vec<String>,
|
||||||
|
/// Output directory for generated files (e.g., MDX docs).
|
||||||
|
pub output_dir: Option<String>,
|
||||||
|
// Rustup fields
|
||||||
|
/// Components to add for rustup steps (e.g. ["clippy", "rustfmt"]).
|
||||||
|
#[serde(default)]
|
||||||
|
pub components: Vec<String>,
|
||||||
|
/// Compilation targets to add for rustup steps (e.g. ["wasm32-unknown-unknown"]).
|
||||||
|
#[serde(default)]
|
||||||
|
pub targets: Vec<String>,
|
||||||
|
/// Default toolchain for rust-install steps.
|
||||||
|
pub default_toolchain: Option<String>,
|
||||||
|
// Workflow (sub-workflow) fields
|
||||||
|
/// Child workflow ID (for `type: workflow` steps).
|
||||||
|
#[serde(rename = "workflow")]
|
||||||
|
pub child_workflow: Option<String>,
|
||||||
|
/// Child workflow version (for `type: workflow` steps).
|
||||||
|
#[serde(rename = "workflow_version")]
|
||||||
|
pub child_version: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// YAML-level permission configuration for Deno steps.
|
/// YAML-level permission configuration for Deno steps.
|
||||||
@@ -84,6 +230,30 @@ pub struct DataRef {
|
|||||||
pub json_path: Option<String>,
|
pub json_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// YAML-level TLS configuration for BuildKit steps.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct TlsConfigYaml {
|
||||||
|
pub ca: Option<String>,
|
||||||
|
pub cert: Option<String>,
|
||||||
|
pub key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// YAML-level registry auth configuration for BuildKit steps.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct RegistryAuthYaml {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// YAML-level volume mount configuration for containerd steps.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct VolumeMountYaml {
|
||||||
|
pub source: String,
|
||||||
|
pub target: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub readonly: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct YamlErrorBehavior {
|
pub struct YamlErrorBehavior {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
|
|||||||
252
wfe-yaml/src/types.rs
Normal file
252
wfe-yaml/src/types.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/// Parsed type representation for workflow input/output schemas.
|
||||||
|
///
|
||||||
|
/// This mirrors what wfe-core's `SchemaType` will provide, but is self-contained
|
||||||
|
/// so wfe-yaml can parse type strings without depending on wfe-core's schema module.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SchemaType {
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Integer,
|
||||||
|
Bool,
|
||||||
|
Any,
|
||||||
|
Optional(Box<SchemaType>),
|
||||||
|
List(Box<SchemaType>),
|
||||||
|
Map(Box<SchemaType>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SchemaType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SchemaType::String => write!(f, "string"),
|
||||||
|
SchemaType::Number => write!(f, "number"),
|
||||||
|
SchemaType::Integer => write!(f, "integer"),
|
||||||
|
SchemaType::Bool => write!(f, "bool"),
|
||||||
|
SchemaType::Any => write!(f, "any"),
|
||||||
|
SchemaType::Optional(inner) => write!(f, "{inner}?"),
|
||||||
|
SchemaType::List(inner) => write!(f, "list<{inner}>"),
|
||||||
|
SchemaType::Map(inner) => write!(f, "map<{inner}>"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a type string like `"string"`, `"string?"`, `"list<number>"`, `"map<string>"`.
|
||||||
|
///
|
||||||
|
/// Supports:
|
||||||
|
/// - Primitives: `"string"`, `"number"`, `"integer"`, `"bool"`, `"any"`
|
||||||
|
/// - Optional: `"string?"` -> `Optional(String)`
|
||||||
|
/// - List: `"list<string>"` -> `List(String)`
|
||||||
|
/// - Map: `"map<number>"` -> `Map(Number)`
|
||||||
|
/// - Nested generics: `"list<list<string>>"` -> `List(List(String))`
|
||||||
|
pub fn parse_type_string(s: &str) -> Result<SchemaType, String> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err("Empty type string".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for optional suffix (but not inside generics).
|
||||||
|
if s.ends_with('?') && !s.ends_with(">?") {
|
||||||
|
// Simple optional like "string?"
|
||||||
|
let inner = parse_type_string(&s[..s.len() - 1])?;
|
||||||
|
return Ok(SchemaType::Optional(Box::new(inner)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle optional on generic types like "list<string>?"
|
||||||
|
if s.ends_with(">?") {
|
||||||
|
let inner = parse_type_string(&s[..s.len() - 1])?;
|
||||||
|
return Ok(SchemaType::Optional(Box::new(inner)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for generic types: list<...> or map<...>
|
||||||
|
if let Some(inner_start) = s.find('<') {
|
||||||
|
if !s.ends_with('>') {
|
||||||
|
return Err(format!("Malformed generic type: '{s}' (missing closing '>')"));
|
||||||
|
}
|
||||||
|
let container = &s[..inner_start];
|
||||||
|
let inner_str = &s[inner_start + 1..s.len() - 1];
|
||||||
|
|
||||||
|
let inner_type = parse_type_string(inner_str)?;
|
||||||
|
|
||||||
|
match container {
|
||||||
|
"list" => Ok(SchemaType::List(Box::new(inner_type))),
|
||||||
|
"map" => Ok(SchemaType::Map(Box::new(inner_type))),
|
||||||
|
other => Err(format!("Unknown generic type: '{other}'")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Primitive types.
|
||||||
|
match s {
|
||||||
|
"string" => Ok(SchemaType::String),
|
||||||
|
"number" => Ok(SchemaType::Number),
|
||||||
|
"integer" => Ok(SchemaType::Integer),
|
||||||
|
"bool" => Ok(SchemaType::Bool),
|
||||||
|
"any" => Ok(SchemaType::Any),
|
||||||
|
other => Err(format!("Unknown type: '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_string() {
|
||||||
|
assert_eq!(parse_type_string("string").unwrap(), SchemaType::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_number() {
|
||||||
|
assert_eq!(parse_type_string("number").unwrap(), SchemaType::Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_integer() {
|
||||||
|
assert_eq!(parse_type_string("integer").unwrap(), SchemaType::Integer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_bool() {
|
||||||
|
assert_eq!(parse_type_string("bool").unwrap(), SchemaType::Bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primitive_any() {
|
||||||
|
assert_eq!(parse_type_string("any").unwrap(), SchemaType::Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("string?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_number() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("number?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_list_string() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_map_number() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<number>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nested_list() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<list<string>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nested_map_in_list() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<map<integer>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::Map(Box::new(SchemaType::Integer))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_list() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_type_error() {
|
||||||
|
let result = parse_type_string("foobar");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Unknown type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_generic_error() {
|
||||||
|
let result = parse_type_string("set<string>");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Unknown generic type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_string_error() {
|
||||||
|
let result = parse_type_string("");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Empty type string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_malformed_generic_error() {
|
||||||
|
let result = parse_type_string("list<string");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Malformed generic type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_whitespace_trimmed() {
|
||||||
|
assert_eq!(parse_type_string(" string ").unwrap(), SchemaType::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_deeply_nested() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<list<list<bool>>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::List(
|
||||||
|
Box::new(SchemaType::Bool)
|
||||||
|
)))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_roundtrip_primitives() {
|
||||||
|
for type_str in &["string", "number", "integer", "bool", "any"] {
|
||||||
|
let parsed = parse_type_string(type_str).unwrap();
|
||||||
|
assert_eq!(parsed.to_string(), *type_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_roundtrip_generics() {
|
||||||
|
for type_str in &["list<string>", "map<number>", "list<list<string>>"] {
|
||||||
|
let parsed = parse_type_string(type_str).unwrap();
|
||||||
|
assert_eq!(parsed.to_string(), *type_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_optional() {
|
||||||
|
let t = SchemaType::Optional(Box::new(SchemaType::String));
|
||||||
|
assert_eq!(t.to_string(), "string?");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_map_any() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<any>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::Any))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_bool() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("bool?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::Bool))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::error::YamlWorkflowError;
|
use crate::error::YamlWorkflowError;
|
||||||
use crate::schema::{WorkflowSpec, YamlStep};
|
use crate::schema::{WorkflowSpec, YamlCombinator, YamlComparison, YamlCondition, YamlStep};
|
||||||
|
use crate::types::{parse_type_string, SchemaType};
|
||||||
|
|
||||||
/// Validate a parsed workflow spec.
|
/// Validate a parsed workflow spec.
|
||||||
pub fn validate(spec: &WorkflowSpec) -> Result<(), YamlWorkflowError> {
|
pub fn validate(spec: &WorkflowSpec) -> Result<(), YamlWorkflowError> {
|
||||||
@@ -19,6 +20,149 @@ pub fn validate(spec: &WorkflowSpec) -> Result<(), YamlWorkflowError> {
|
|||||||
validate_error_behavior_type(&eb.behavior_type)?;
|
validate_error_behavior_type(&eb.behavior_type)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect known outputs (from step output data refs).
|
||||||
|
let known_outputs: HashSet<String> = collect_step_outputs(&spec.steps);
|
||||||
|
|
||||||
|
// Validate condition fields and types on all steps.
|
||||||
|
validate_step_conditions(&spec.steps, spec, &known_outputs)?;
|
||||||
|
|
||||||
|
// Detect unused declared outputs.
|
||||||
|
detect_unused_outputs(spec, &known_outputs)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate multiple workflow specs from a multi-workflow file.
|
||||||
|
/// Checks cross-workflow references and cycles in addition to per-workflow validation.
|
||||||
|
pub fn validate_multi(specs: &[WorkflowSpec]) -> Result<(), YamlWorkflowError> {
|
||||||
|
// Validate each workflow individually.
|
||||||
|
for spec in specs {
|
||||||
|
validate(spec)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate workflow IDs.
|
||||||
|
let mut seen_ids = HashSet::new();
|
||||||
|
for spec in specs {
|
||||||
|
if !seen_ids.insert(&spec.id) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Duplicate workflow ID: '{}'",
|
||||||
|
spec.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cross-workflow references and detect cycles.
|
||||||
|
validate_workflow_references(specs)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that workflow step references point to known workflows
|
||||||
|
/// and detect circular dependencies.
|
||||||
|
fn validate_workflow_references(specs: &[WorkflowSpec]) -> Result<(), YamlWorkflowError> {
|
||||||
|
let known_ids: HashSet<&str> = specs.iter().map(|s| s.id.as_str()).collect();
|
||||||
|
|
||||||
|
// Build a dependency graph: workflow_id -> set of referenced workflow_ids.
|
||||||
|
let mut deps: HashMap<&str, HashSet<&str>> = HashMap::new();
|
||||||
|
|
||||||
|
for spec in specs {
|
||||||
|
let mut spec_deps = HashSet::new();
|
||||||
|
collect_workflow_refs(&spec.steps, &mut spec_deps);
|
||||||
|
deps.insert(spec.id.as_str(), spec_deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect cycles using DFS with coloring.
|
||||||
|
detect_cycles(&known_ids, &deps)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all workflow IDs referenced by `type: workflow` steps.
|
||||||
|
fn collect_workflow_refs<'a>(steps: &'a [YamlStep], refs: &mut HashSet<&'a str>) {
|
||||||
|
for step in steps {
|
||||||
|
if step.step_type.as_deref() == Some("workflow")
|
||||||
|
&& let Some(ref config) = step.config
|
||||||
|
&& let Some(ref wf_id) = config.child_workflow
|
||||||
|
{
|
||||||
|
refs.insert(wf_id.as_str());
|
||||||
|
}
|
||||||
|
if let Some(ref children) = step.parallel {
|
||||||
|
collect_workflow_refs(children, refs);
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.on_success {
|
||||||
|
collect_workflow_refs(std::slice::from_ref(hook.as_ref()), refs);
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.on_failure {
|
||||||
|
collect_workflow_refs(std::slice::from_ref(hook.as_ref()), refs);
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.ensure {
|
||||||
|
collect_workflow_refs(std::slice::from_ref(hook.as_ref()), refs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect circular references in the workflow dependency graph.
|
||||||
|
fn detect_cycles(
|
||||||
|
known_ids: &HashSet<&str>,
|
||||||
|
deps: &HashMap<&str, HashSet<&str>>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Color {
|
||||||
|
White,
|
||||||
|
Gray,
|
||||||
|
Black,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut colors: HashMap<&str, Color> = known_ids.iter().map(|id| (*id, Color::White)).collect();
|
||||||
|
|
||||||
|
fn dfs<'a>(
|
||||||
|
node: &'a str,
|
||||||
|
deps: &HashMap<&str, HashSet<&'a str>>,
|
||||||
|
colors: &mut HashMap<&'a str, Color>,
|
||||||
|
path: &mut Vec<&'a str>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
colors.insert(node, Color::Gray);
|
||||||
|
path.push(node);
|
||||||
|
|
||||||
|
if let Some(neighbors) = deps.get(node) {
|
||||||
|
for &neighbor in neighbors {
|
||||||
|
match colors.get(neighbor) {
|
||||||
|
Some(Color::Gray) => {
|
||||||
|
// Found a cycle. Build the cycle path for the error message.
|
||||||
|
let cycle_start = path.iter().position(|&n| n == neighbor).unwrap();
|
||||||
|
let cycle: Vec<&str> = path[cycle_start..].to_vec();
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Circular workflow reference detected: {} -> {}",
|
||||||
|
cycle.join(" -> "),
|
||||||
|
neighbor
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Some(Color::White) | None => {
|
||||||
|
// Only recurse into nodes that are in our known set.
|
||||||
|
if colors.contains_key(neighbor) {
|
||||||
|
dfs(neighbor, deps, colors, path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Color::Black) => {
|
||||||
|
// Already fully processed, skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.pop();
|
||||||
|
colors.insert(node, Color::Black);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes: Vec<&str> = known_ids.iter().copied().collect();
|
||||||
|
for node in nodes {
|
||||||
|
if colors.get(node) == Some(&Color::White) {
|
||||||
|
let mut path = Vec::new();
|
||||||
|
dfs(node, deps, &mut colors, &mut path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +233,108 @@ fn validate_steps(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildKit steps must have config with dockerfile and context.
|
||||||
|
if let Some(ref step_type) = step.step_type
|
||||||
|
&& step_type == "buildkit"
|
||||||
|
{
|
||||||
|
let config = step.config.as_ref().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Validation(format!(
|
||||||
|
"BuildKit step '{}' must have a 'config' section",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if config.dockerfile.is_none() {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"BuildKit step '{}' must have 'config.dockerfile'",
|
||||||
|
step.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if config.context.is_none() {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"BuildKit step '{}' must have 'config.context'",
|
||||||
|
step.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if config.push.unwrap_or(false) && config.tags.is_empty() {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"BuildKit step '{}' has push=true but no tags specified",
|
||||||
|
step.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containerd steps must have config with image and exactly one of run or command.
|
||||||
|
if let Some(ref step_type) = step.step_type
|
||||||
|
&& step_type == "containerd"
|
||||||
|
{
|
||||||
|
let config = step.config.as_ref().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Validation(format!(
|
||||||
|
"Containerd step '{}' must have a 'config' section",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if config.image.is_none() {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Containerd step '{}' must have 'config.image'",
|
||||||
|
step.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let has_run = config.run.is_some();
|
||||||
|
let has_command = config.command.is_some();
|
||||||
|
if !has_run && !has_command {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Containerd step '{}' must have 'config.run' or 'config.command'",
|
||||||
|
step.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if has_run && has_command {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Containerd step '{}' cannot have both 'config.run' and 'config.command'",
|
||||||
|
step.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(ref network) = config.network {
|
||||||
|
match network.as_str() {
|
||||||
|
"none" | "host" | "bridge" => {}
|
||||||
|
other => {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Containerd step '{}' has invalid network '{}'. Must be none, host, or bridge",
|
||||||
|
step.name, other
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref pull) = config.pull {
|
||||||
|
match pull.as_str() {
|
||||||
|
"always" | "if-not-present" | "never" => {}
|
||||||
|
other => {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Containerd step '{}' has invalid pull policy '{}'. Must be always, if-not-present, or never",
|
||||||
|
step.name, other
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workflow steps must have config.workflow.
|
||||||
|
if let Some(ref step_type) = step.step_type
|
||||||
|
&& step_type == "workflow"
|
||||||
|
{
|
||||||
|
let config = step.config.as_ref().ok_or_else(|| {
|
||||||
|
YamlWorkflowError::Validation(format!(
|
||||||
|
"Workflow step '{}' must have a 'config' section",
|
||||||
|
step.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if config.child_workflow.is_none() {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Workflow step '{}' must have 'config.workflow'",
|
||||||
|
step.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate step-level error behavior.
|
// Validate step-level error behavior.
|
||||||
if let Some(ref eb) = step.error_behavior {
|
if let Some(ref eb) = step.error_behavior {
|
||||||
validate_error_behavior_type(&eb.behavior_type)?;
|
validate_error_behavior_type(&eb.behavior_type)?;
|
||||||
@@ -122,3 +368,300 @@ fn validate_error_behavior_type(behavior_type: &str) -> Result<(), YamlWorkflowE
|
|||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Condition validation ---
|
||||||
|
|
||||||
|
/// Collect all output field names produced by steps (via their `outputs:` list).
|
||||||
|
fn collect_step_outputs(steps: &[YamlStep]) -> HashSet<String> {
|
||||||
|
let mut outputs = HashSet::new();
|
||||||
|
for step in steps {
|
||||||
|
for out in &step.outputs {
|
||||||
|
outputs.insert(out.name.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref children) = step.parallel {
|
||||||
|
outputs.extend(collect_step_outputs(children));
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.on_success {
|
||||||
|
outputs.extend(collect_step_outputs(std::slice::from_ref(hook.as_ref())));
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.on_failure {
|
||||||
|
outputs.extend(collect_step_outputs(std::slice::from_ref(hook.as_ref())));
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.ensure {
|
||||||
|
outputs.extend(collect_step_outputs(std::slice::from_ref(hook.as_ref())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk all steps and validate their `when` conditions.
|
||||||
|
fn validate_step_conditions(
|
||||||
|
steps: &[YamlStep],
|
||||||
|
spec: &WorkflowSpec,
|
||||||
|
known_outputs: &HashSet<String>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
for step in steps {
|
||||||
|
if let Some(ref cond) = step.when {
|
||||||
|
validate_condition_fields(cond, spec, known_outputs)?;
|
||||||
|
validate_condition_types(cond, spec)?;
|
||||||
|
}
|
||||||
|
if let Some(ref children) = step.parallel {
|
||||||
|
validate_step_conditions(children, spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.on_success {
|
||||||
|
validate_step_conditions(std::slice::from_ref(hook.as_ref()), spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.on_failure {
|
||||||
|
validate_step_conditions(std::slice::from_ref(hook.as_ref()), spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
if let Some(ref hook) = step.ensure {
|
||||||
|
validate_step_conditions(std::slice::from_ref(hook.as_ref()), spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that all field paths in a condition tree resolve to known schema fields.
|
||||||
|
pub fn validate_condition_fields(
|
||||||
|
condition: &YamlCondition,
|
||||||
|
spec: &WorkflowSpec,
|
||||||
|
known_outputs: &HashSet<String>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
match condition {
|
||||||
|
YamlCondition::Comparison(cmp) => {
|
||||||
|
validate_field_path(&cmp.as_ref().field, spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
YamlCondition::Combinator(c) => {
|
||||||
|
validate_combinator_fields(c, spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_combinator_fields(
|
||||||
|
c: &YamlCombinator,
|
||||||
|
spec: &WorkflowSpec,
|
||||||
|
known_outputs: &HashSet<String>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
let all_children = c
|
||||||
|
.all
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.chain(c.any.iter().flatten())
|
||||||
|
.chain(c.none.iter().flatten())
|
||||||
|
.chain(c.one_of.iter().flatten());
|
||||||
|
|
||||||
|
for child in all_children {
|
||||||
|
validate_condition_fields(child, spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
if let Some(ref inner) = c.not {
|
||||||
|
validate_condition_fields(inner, spec, known_outputs)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a field path like `.inputs.foo` or `.outputs.bar` against the workflow schema.
|
||||||
|
fn validate_field_path(
|
||||||
|
field: &str,
|
||||||
|
spec: &WorkflowSpec,
|
||||||
|
known_outputs: &HashSet<String>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
// If the spec has no inputs and no outputs schema, skip field validation
|
||||||
|
// (schema-less workflow).
|
||||||
|
if spec.inputs.is_empty() && spec.outputs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = field.split('.').collect();
|
||||||
|
|
||||||
|
// Expect paths like ".inputs.x" or ".outputs.x" (leading dot is optional).
|
||||||
|
let parts = if parts.first() == Some(&"") {
|
||||||
|
&parts[1..] // skip leading empty from "."
|
||||||
|
} else {
|
||||||
|
&parts[..]
|
||||||
|
};
|
||||||
|
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Condition field path '{field}' must have at least two segments (e.g. '.inputs.name')"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
match parts[0] {
|
||||||
|
"inputs" => {
|
||||||
|
let field_name = parts[1];
|
||||||
|
if !spec.inputs.contains_key(field_name) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Condition references unknown input field '{field_name}'. \
|
||||||
|
Available inputs: [{}]",
|
||||||
|
spec.inputs
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"outputs" => {
|
||||||
|
let field_name = parts[1];
|
||||||
|
// Check both the declared output schema and step-produced outputs.
|
||||||
|
if !spec.outputs.contains_key(field_name) && !known_outputs.contains(field_name) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Condition references unknown output field '{field_name}'. \
|
||||||
|
Available outputs: [{}]",
|
||||||
|
spec.outputs
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Condition field path '{field}' must start with 'inputs' or 'outputs', got '{other}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate operator type compatibility for condition comparisons.
|
||||||
|
pub fn validate_condition_types(
|
||||||
|
condition: &YamlCondition,
|
||||||
|
spec: &WorkflowSpec,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
match condition {
|
||||||
|
YamlCondition::Comparison(cmp) => {
|
||||||
|
validate_comparison_type(cmp.as_ref(), spec)?;
|
||||||
|
}
|
||||||
|
YamlCondition::Combinator(c) => {
|
||||||
|
let all_children = c
|
||||||
|
.all
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.chain(c.any.iter().flatten())
|
||||||
|
.chain(c.none.iter().flatten())
|
||||||
|
.chain(c.one_of.iter().flatten());
|
||||||
|
|
||||||
|
for child in all_children {
|
||||||
|
validate_condition_types(child, spec)?;
|
||||||
|
}
|
||||||
|
if let Some(ref inner) = c.not {
|
||||||
|
validate_condition_types(inner, spec)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that the operator used in a comparison is compatible with the field type.
|
||||||
|
fn validate_comparison_type(
|
||||||
|
cmp: &YamlComparison,
|
||||||
|
spec: &WorkflowSpec,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
// Resolve the field type from the schema.
|
||||||
|
let field_type = resolve_field_type(&cmp.field, spec);
|
||||||
|
let field_type = match field_type {
|
||||||
|
Some(t) => t,
|
||||||
|
// If we can't resolve the type (no schema), skip type checking.
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check operator compatibility.
|
||||||
|
let has_gt = cmp.gt.is_some();
|
||||||
|
let has_gte = cmp.gte.is_some();
|
||||||
|
let has_lt = cmp.lt.is_some();
|
||||||
|
let has_lte = cmp.lte.is_some();
|
||||||
|
let has_contains = cmp.contains.is_some();
|
||||||
|
let has_is_null = cmp.is_null == Some(true);
|
||||||
|
let has_is_not_null = cmp.is_not_null == Some(true);
|
||||||
|
|
||||||
|
// gt/gte/lt/lte only valid for number/integer types.
|
||||||
|
if (has_gt || has_gte || has_lt || has_lte) && !is_numeric_type(&field_type) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Comparison operators gt/gte/lt/lte are only valid for number/integer types, \
|
||||||
|
but field '{}' has type '{}'",
|
||||||
|
cmp.field, field_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains only valid for string/list types.
|
||||||
|
if has_contains && !is_containable_type(&field_type) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Comparison operator 'contains' is only valid for string/list types, \
|
||||||
|
but field '{}' has type '{}'",
|
||||||
|
cmp.field, field_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// is_null/is_not_null only valid for optional types.
|
||||||
|
if (has_is_null || has_is_not_null) && !is_optional_type(&field_type) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Comparison operators is_null/is_not_null are only valid for optional types, \
|
||||||
|
but field '{}' has type '{}'",
|
||||||
|
cmp.field, field_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a field's SchemaType from the workflow spec.
|
||||||
|
fn resolve_field_type(field: &str, spec: &WorkflowSpec) -> Option<SchemaType> {
|
||||||
|
let parts: Vec<&str> = field.split('.').collect();
|
||||||
|
let parts = if parts.first() == Some(&"") {
|
||||||
|
&parts[1..]
|
||||||
|
} else {
|
||||||
|
&parts[..]
|
||||||
|
};
|
||||||
|
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let type_str = match parts[0] {
|
||||||
|
"inputs" => spec.inputs.get(parts[1]),
|
||||||
|
"outputs" => spec.outputs.get(parts[1]),
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
parse_type_string(type_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_numeric_type(t: &SchemaType) -> bool {
|
||||||
|
match t {
|
||||||
|
SchemaType::Number | SchemaType::Integer | SchemaType::Any => true,
|
||||||
|
SchemaType::Optional(inner) => is_numeric_type(inner),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_containable_type(t: &SchemaType) -> bool {
|
||||||
|
match t {
|
||||||
|
SchemaType::String | SchemaType::List(_) | SchemaType::Any => true,
|
||||||
|
SchemaType::Optional(inner) => is_containable_type(inner),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_optional_type(t: &SchemaType) -> bool {
|
||||||
|
matches!(t, SchemaType::Optional(_) | SchemaType::Any)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect output fields declared in `spec.outputs` that no step produces.
|
||||||
|
pub fn detect_unused_outputs(
|
||||||
|
spec: &WorkflowSpec,
|
||||||
|
known_outputs: &HashSet<String>,
|
||||||
|
) -> Result<(), YamlWorkflowError> {
|
||||||
|
for output_name in spec.outputs.keys() {
|
||||||
|
if !known_outputs.contains(output_name) {
|
||||||
|
return Err(YamlWorkflowError::Validation(format!(
|
||||||
|
"Declared output '{output_name}' is never produced by any step. \
|
||||||
|
Add an output data ref with name '{output_name}' to a step."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use wfe_core::models::error_behavior::ErrorBehavior;
|
use wfe_core::models::error_behavior::ErrorBehavior;
|
||||||
use wfe_yaml::load_workflow_from_str;
|
use wfe_yaml::{load_single_workflow_from_str, load_workflow_from_str};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn single_step_produces_one_workflow_step() {
|
fn single_step_produces_one_workflow_step() {
|
||||||
@@ -16,7 +16,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hello
|
run: echo hello
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
// The definition should have exactly 1 main step.
|
// The definition should have exactly 1 main step.
|
||||||
let main_steps: Vec<_> = compiled
|
let main_steps: Vec<_> = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -44,7 +44,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo b
|
run: echo b
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let step_a = compiled
|
let step_a = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -82,7 +82,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo b
|
run: echo b
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let container = compiled
|
let container = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -116,7 +116,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: rollback.sh
|
run: rollback.sh
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let deploy = compiled
|
let deploy = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -156,7 +156,7 @@ workflow:
|
|||||||
error_behavior:
|
error_behavior:
|
||||||
type: suspend
|
type: suspend
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.default_error_behavior,
|
compiled.definition.default_error_behavior,
|
||||||
@@ -193,7 +193,7 @@ workflow:
|
|||||||
type: shell
|
type: shell
|
||||||
config: *default_config
|
config: *default_config
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
// Should have 2 main steps + factories.
|
// Should have 2 main steps + factories.
|
||||||
let build_step = compiled
|
let build_step = compiled
|
||||||
@@ -241,7 +241,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo "build succeeded"
|
run: echo "build succeeded"
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let build = compiled
|
let build = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -279,7 +279,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: cleanup.sh
|
run: cleanup.sh
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let deploy = compiled
|
let deploy = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -322,7 +322,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: cleanup.sh
|
run: cleanup.sh
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let deploy = compiled
|
let deploy = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -351,7 +351,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hi
|
run: echo hi
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.default_error_behavior,
|
compiled.definition.default_error_behavior,
|
||||||
ErrorBehavior::Terminate
|
ErrorBehavior::Terminate
|
||||||
@@ -372,7 +372,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hi
|
run: echo hi
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.default_error_behavior,
|
compiled.definition.default_error_behavior,
|
||||||
ErrorBehavior::Compensate
|
ErrorBehavior::Compensate
|
||||||
@@ -394,7 +394,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hi
|
run: echo hi
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.default_error_behavior,
|
compiled.definition.default_error_behavior,
|
||||||
ErrorBehavior::Retry {
|
ErrorBehavior::Retry {
|
||||||
@@ -420,7 +420,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hi
|
run: echo hi
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.default_error_behavior,
|
compiled.definition.default_error_behavior,
|
||||||
ErrorBehavior::Retry {
|
ErrorBehavior::Retry {
|
||||||
@@ -447,7 +447,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hi
|
run: echo hi
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.default_error_behavior,
|
compiled.definition.default_error_behavior,
|
||||||
ErrorBehavior::Retry {
|
ErrorBehavior::Retry {
|
||||||
@@ -471,7 +471,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hi
|
run: echo hi
|
||||||
"#;
|
"#;
|
||||||
let result = load_workflow_from_str(yaml, &HashMap::new());
|
let result = load_single_workflow_from_str(yaml, &HashMap::new());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||||
assert!(err.contains("explode"), "Error should mention the invalid type, got: {err}");
|
assert!(err.contains("explode"), "Error should mention the invalid type, got: {err}");
|
||||||
@@ -499,7 +499,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo c
|
run: echo c
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let container = compiled
|
let container = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -541,7 +541,7 @@ workflow:
|
|||||||
RUST_LOG: debug
|
RUST_LOG: debug
|
||||||
working_dir: /tmp
|
working_dir: /tmp
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -571,7 +571,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
file: my_script.sh
|
file: my_script.sh
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
@@ -596,7 +596,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hello
|
run: echo hello
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
@@ -626,7 +626,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: rollback.sh
|
run: rollback.sh
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
// Should have factories for both deploy and rollback.
|
// Should have factories for both deploy and rollback.
|
||||||
let has_deploy = compiled
|
let has_deploy = compiled
|
||||||
@@ -658,7 +658,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo ok
|
run: echo ok
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let has_notify = compiled
|
let has_notify = compiled
|
||||||
.step_factories
|
.step_factories
|
||||||
@@ -684,7 +684,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: cleanup.sh
|
run: cleanup.sh
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let has_cleanup = compiled
|
let has_cleanup = compiled
|
||||||
.step_factories
|
.step_factories
|
||||||
@@ -713,7 +713,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo b
|
run: echo b
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let container = compiled
|
let container = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -746,7 +746,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo b
|
run: echo b
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
let step_a = compiled
|
let step_a = compiled
|
||||||
.definition
|
.definition
|
||||||
@@ -787,7 +787,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo hi
|
run: echo hi
|
||||||
"#;
|
"#;
|
||||||
let compiled = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.description.as_deref(),
|
compiled.definition.description.as_deref(),
|
||||||
Some("A test workflow")
|
Some("A test workflow")
|
||||||
@@ -804,7 +804,7 @@ workflow:
|
|||||||
- name: bad-step
|
- name: bad-step
|
||||||
type: shell
|
type: shell
|
||||||
"#;
|
"#;
|
||||||
let result = load_workflow_from_str(yaml, &HashMap::new());
|
let result = load_single_workflow_from_str(yaml, &HashMap::new());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||||
assert!(
|
assert!(
|
||||||
@@ -812,3 +812,495 @@ workflow:
|
|||||||
"Error should mention missing config, got: {err}"
|
"Error should mention missing config, got: {err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Workflow step compilation tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_step_compiles_correctly() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parent-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
workflow_version: 3
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
- name: status
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("run-child"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(step.step_type.contains("workflow"));
|
||||||
|
assert!(step.step_config.is_some());
|
||||||
|
|
||||||
|
// Verify the serialized config contains the workflow_id and version.
|
||||||
|
let config: serde_json::Value = step.step_config.clone().unwrap();
|
||||||
|
assert_eq!(config["workflow_id"].as_str(), Some("child-wf"));
|
||||||
|
assert_eq!(config["version"].as_u64(), Some(3));
|
||||||
|
assert_eq!(config["output_keys"].as_array().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_step_version_defaults_to_1() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parent-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("run-child"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config: serde_json::Value = step.step_config.clone().unwrap();
|
||||||
|
assert_eq!(config["version"].as_u64(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_step_factory_is_registered() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parent-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
|
let has_workflow_factory = compiled
|
||||||
|
.step_factories
|
||||||
|
.iter()
|
||||||
|
.any(|(key, _)| key.contains("workflow") && key.contains("run-child"));
|
||||||
|
assert!(
|
||||||
|
has_workflow_factory,
|
||||||
|
"Should have factory for workflow step"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_multi_workflow_file() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflows:
|
||||||
|
- id: build
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: compile
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: cargo build
|
||||||
|
- id: test
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-tests
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: cargo test
|
||||||
|
"#;
|
||||||
|
let workflows = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert_eq!(workflows.len(), 2);
|
||||||
|
assert_eq!(workflows[0].definition.id, "build");
|
||||||
|
assert_eq!(workflows[1].definition.id, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_multi_workflow_with_cross_references() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflows:
|
||||||
|
- id: pipeline
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-build
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: build
|
||||||
|
- id: build
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: compile
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: cargo build
|
||||||
|
"#;
|
||||||
|
let workflows = load_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert_eq!(workflows.len(), 2);
|
||||||
|
|
||||||
|
// The pipeline workflow should have a workflow step.
|
||||||
|
let pipeline = &workflows[0];
|
||||||
|
let step = pipeline
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("run-build"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(step.step_type.contains("workflow"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workflow_step_with_mixed_steps() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: mixed-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: setup
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo setup
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
- name: cleanup
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo cleanup
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
|
||||||
|
// Should have 3 main steps.
|
||||||
|
let step_names: Vec<_> = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| s.name.as_deref())
|
||||||
|
.collect();
|
||||||
|
assert!(step_names.contains(&"setup"));
|
||||||
|
assert!(step_names.contains(&"run-child"));
|
||||||
|
assert!(step_names.contains(&"cleanup"));
|
||||||
|
|
||||||
|
// setup -> run-child -> cleanup wiring.
|
||||||
|
let setup = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("setup"))
|
||||||
|
.unwrap();
|
||||||
|
let run_child = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("run-child"))
|
||||||
|
.unwrap();
|
||||||
|
let cleanup = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("cleanup"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(setup.outcomes[0].next_step, run_child.id);
|
||||||
|
assert_eq!(run_child.outcomes[0].next_step, cleanup.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression test: SubWorkflowStep must actually wait for child completion,
|
||||||
|
/// not return next() immediately. The compiled factory must produce a real
|
||||||
|
/// SubWorkflowStep (from wfe-core), not a placeholder.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workflow_step_factory_produces_real_sub_workflow_step() {
|
||||||
|
use wfe_core::models::{ExecutionPointer, WorkflowInstance, WorkflowStep as WfStep};
|
||||||
|
use wfe_core::traits::step::{HostContext, StepExecutionContext};
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
let yaml = r#"
|
||||||
|
workflows:
|
||||||
|
- id: child
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: do-work
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo done
|
||||||
|
|
||||||
|
- id: parent
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child
|
||||||
|
"#;
|
||||||
|
let config = HashMap::new();
|
||||||
|
let workflows = load_workflow_from_str(yaml, &config).unwrap();
|
||||||
|
|
||||||
|
// Find the parent workflow's factory for the "run-child" step
|
||||||
|
let parent = workflows.iter().find(|w| w.definition.id == "parent").unwrap();
|
||||||
|
let factory_key = parent.step_factories.iter()
|
||||||
|
.find(|(k, _)| k.contains("run-child"))
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.expect("run-child factory should exist");
|
||||||
|
|
||||||
|
// Create a step from the factory
|
||||||
|
let factory = &parent.step_factories.iter()
|
||||||
|
.find(|(k, _)| *k == factory_key)
|
||||||
|
.unwrap().1;
|
||||||
|
let mut step = factory();
|
||||||
|
|
||||||
|
// Mock host context that records the start_workflow call
|
||||||
|
struct MockHost { called: Mutex<bool> }
|
||||||
|
impl HostContext for MockHost {
|
||||||
|
fn start_workflow(&self, _def: &str, _ver: u32, _data: serde_json::Value)
|
||||||
|
-> Pin<Box<dyn Future<Output = wfe_core::Result<String>> + Send + '_>>
|
||||||
|
{
|
||||||
|
*self.called.lock().unwrap() = true;
|
||||||
|
Box::pin(async { Ok("child-instance-id".to_string()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = MockHost { called: Mutex::new(false) };
|
||||||
|
let pointer = ExecutionPointer::new(0);
|
||||||
|
let wf_step = WfStep::new(0, &factory_key);
|
||||||
|
let workflow = WorkflowInstance::new("parent", 1, serde_json::json!({}));
|
||||||
|
let ctx = StepExecutionContext {
|
||||||
|
item: None,
|
||||||
|
execution_pointer: &pointer,
|
||||||
|
persistence_data: None,
|
||||||
|
step: &wf_step,
|
||||||
|
workflow: &workflow,
|
||||||
|
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||||
|
host_context: Some(&host),
|
||||||
|
log_sink: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = step.run(&ctx).await.unwrap();
|
||||||
|
|
||||||
|
// THE KEY ASSERTION: must NOT proceed immediately.
|
||||||
|
// It must return wait_for_event so the parent waits for the child.
|
||||||
|
assert!(
|
||||||
|
!result.proceed,
|
||||||
|
"SubWorkflowStep must NOT proceed immediately — it should wait for child completion"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.event_name.as_deref(),
|
||||||
|
Some("wfe.workflow.completed"),
|
||||||
|
"SubWorkflowStep must wait for wfe.workflow.completed event"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
*host.called.lock().unwrap(),
|
||||||
|
"SubWorkflowStep must call host_context.start_workflow()"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Condition compilation tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_simple_condition_into_step_condition() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cond-compile
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: deploy.sh
|
||||||
|
when:
|
||||||
|
field: .inputs.enabled
|
||||||
|
equals: true
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("deploy"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(step.when.is_some(), "Step should have a when condition");
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
wfe_core::models::StepCondition::Comparison(cmp) => {
|
||||||
|
assert_eq!(cmp.field, ".inputs.enabled");
|
||||||
|
assert_eq!(cmp.operator, wfe_core::models::ComparisonOp::Equals);
|
||||||
|
assert_eq!(cmp.value, Some(serde_json::json!(true)));
|
||||||
|
}
|
||||||
|
other => panic!("Expected Comparison, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_nested_condition() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: nested-cond-compile
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: deploy.sh
|
||||||
|
when:
|
||||||
|
all:
|
||||||
|
- field: .inputs.count
|
||||||
|
gt: 5
|
||||||
|
- not:
|
||||||
|
field: .inputs.skip
|
||||||
|
equals: true
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("deploy"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(step.when.is_some());
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
wfe_core::models::StepCondition::All(children) => {
|
||||||
|
assert_eq!(children.len(), 2);
|
||||||
|
// First child: comparison
|
||||||
|
match &children[0] {
|
||||||
|
wfe_core::models::StepCondition::Comparison(cmp) => {
|
||||||
|
assert_eq!(cmp.field, ".inputs.count");
|
||||||
|
assert_eq!(cmp.operator, wfe_core::models::ComparisonOp::Gt);
|
||||||
|
assert_eq!(cmp.value, Some(serde_json::json!(5)));
|
||||||
|
}
|
||||||
|
other => panic!("Expected Comparison, got: {other:?}"),
|
||||||
|
}
|
||||||
|
// Second child: not
|
||||||
|
match &children[1] {
|
||||||
|
wfe_core::models::StepCondition::Not(inner) => {
|
||||||
|
match inner.as_ref() {
|
||||||
|
wfe_core::models::StepCondition::Comparison(cmp) => {
|
||||||
|
assert_eq!(cmp.field, ".inputs.skip");
|
||||||
|
assert_eq!(cmp.operator, wfe_core::models::ComparisonOp::Equals);
|
||||||
|
}
|
||||||
|
other => panic!("Expected Comparison inside Not, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("Expected Not, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("Expected All, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_without_when_has_none_condition() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: no-when-compile
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hi
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("step1"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(step.when.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_all_comparison_operators() {
|
||||||
|
use wfe_core::models::ComparisonOp;
|
||||||
|
|
||||||
|
let ops = vec![
|
||||||
|
("equals: 42", ComparisonOp::Equals),
|
||||||
|
("not_equals: foo", ComparisonOp::NotEquals),
|
||||||
|
("gt: 10", ComparisonOp::Gt),
|
||||||
|
("gte: 10", ComparisonOp::Gte),
|
||||||
|
("lt: 100", ComparisonOp::Lt),
|
||||||
|
("lte: 100", ComparisonOp::Lte),
|
||||||
|
("contains: needle", ComparisonOp::Contains),
|
||||||
|
("is_null: true", ComparisonOp::IsNull),
|
||||||
|
("is_not_null: true", ComparisonOp::IsNotNull),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (op_yaml, expected_op) in ops {
|
||||||
|
let yaml = format!(
|
||||||
|
r#"
|
||||||
|
workflow:
|
||||||
|
id: op-test
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hi
|
||||||
|
when:
|
||||||
|
field: .inputs.x
|
||||||
|
{op_yaml}
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let compiled = load_single_workflow_from_str(&yaml, &HashMap::new())
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to compile with {op_yaml}: {e}"));
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("step1"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
wfe_core::models::StepCondition::Comparison(cmp) => {
|
||||||
|
assert_eq!(cmp.operator, expected_op, "Operator mismatch for {op_yaml}");
|
||||||
|
}
|
||||||
|
other => panic!("Expected Comparison for {op_yaml}, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_condition_on_parallel_container() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parallel-cond
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: parallel-group
|
||||||
|
when:
|
||||||
|
field: .inputs.run_parallel
|
||||||
|
equals: true
|
||||||
|
parallel:
|
||||||
|
- name: task-a
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo a
|
||||||
|
- name: task-b
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo b
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let container = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("parallel-group"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
container.when.is_some(),
|
||||||
|
"Parallel container should have when condition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ fn make_context<'a>(
|
|||||||
step,
|
step,
|
||||||
workflow,
|
workflow,
|
||||||
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||||
|
host_context: None,
|
||||||
|
log_sink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +221,7 @@ workflow:
|
|||||||
type: deno
|
type: deno
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let result = wfe_yaml::load_workflow_from_str(yaml, &config);
|
let result = wfe_yaml::load_single_workflow_from_str(yaml, &config);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let msg = result.err().unwrap().to_string();
|
let msg = result.err().unwrap().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -242,7 +244,7 @@ workflow:
|
|||||||
FOO: bar
|
FOO: bar
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let result = wfe_yaml::load_workflow_from_str(yaml, &config);
|
let result = wfe_yaml::load_single_workflow_from_str(yaml, &config);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let msg = result.err().unwrap().to_string();
|
let msg = result.err().unwrap().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -264,7 +266,7 @@ workflow:
|
|||||||
script: "output('key', 'val');"
|
script: "output('key', 'val');"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = wfe_yaml::load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = wfe_yaml::load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
assert!(!compiled.step_factories.is_empty());
|
assert!(!compiled.step_factories.is_empty());
|
||||||
let (key, _factory) = &compiled.step_factories[0];
|
let (key, _factory) = &compiled.step_factories[0];
|
||||||
assert!(key.contains("deno"), "factory key should contain 'deno', got: {key}");
|
assert!(key.contains("deno"), "factory key should contain 'deno', got: {key}");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use wfe::{WorkflowHostBuilder, run_workflow_sync};
|
|||||||
use wfe_core::test_support::{
|
use wfe_core::test_support::{
|
||||||
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
||||||
};
|
};
|
||||||
use wfe_yaml::load_workflow_from_str;
|
use wfe_yaml::load_single_workflow_from_str;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -30,7 +30,7 @@ async fn run_yaml_workflow_with_data(
|
|||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
) -> wfe::models::WorkflowInstance {
|
) -> wfe::models::WorkflowInstance {
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
|
|
||||||
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
||||||
let lock = Arc::new(InMemoryLockProvider::new());
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
@@ -437,7 +437,7 @@ workflow:
|
|||||||
script: "output('x', 1);"
|
script: "output('x', 1);"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
assert!(!compiled.step_factories.is_empty());
|
assert!(!compiled.step_factories.is_empty());
|
||||||
let (key, _factory) = &compiled.step_factories[0];
|
let (key, _factory) = &compiled.step_factories[0];
|
||||||
assert!(
|
assert!(
|
||||||
@@ -467,7 +467,7 @@ workflow:
|
|||||||
dynamic_import: true
|
dynamic_import: true
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
assert!(!compiled.step_factories.is_empty());
|
assert!(!compiled.step_factories.is_empty());
|
||||||
// Verify the step config was serialized correctly.
|
// Verify the step config was serialized correctly.
|
||||||
let step = compiled
|
let step = compiled
|
||||||
@@ -497,7 +497,7 @@ workflow:
|
|||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
@@ -521,7 +521,7 @@ workflow:
|
|||||||
file: "./scripts/run.js"
|
file: "./scripts/run.js"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
@@ -543,7 +543,7 @@ workflow:
|
|||||||
type: deno
|
type: deno
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let result = load_workflow_from_str(yaml, &config);
|
let result = load_single_workflow_from_str(yaml, &config);
|
||||||
match result {
|
match result {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
@@ -570,7 +570,7 @@ workflow:
|
|||||||
FOO: bar
|
FOO: bar
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let result = load_workflow_from_str(yaml, &config);
|
let result = load_single_workflow_from_str(yaml, &config);
|
||||||
match result {
|
match result {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
@@ -596,7 +596,7 @@ workflow:
|
|||||||
script: "1+1;"
|
script: "1+1;"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
assert!(load_workflow_from_str(yaml, &config).is_ok());
|
assert!(load_single_workflow_from_str(yaml, &config).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -612,7 +612,7 @@ workflow:
|
|||||||
file: "./run.js"
|
file: "./run.js"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
assert!(load_workflow_from_str(yaml, &config).is_ok());
|
assert!(load_single_workflow_from_str(yaml, &config).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -632,7 +632,7 @@ workflow:
|
|||||||
script: "output('x', 1);"
|
script: "output('x', 1);"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
let has_shell = compiled.step_factories.iter().any(|(k, _)| k.contains("shell"));
|
let has_shell = compiled.step_factories.iter().any(|(k, _)| k.contains("shell"));
|
||||||
let has_deno = compiled.step_factories.iter().any(|(k, _)| k.contains("deno"));
|
let has_deno = compiled.step_factories.iter().any(|(k, _)| k.contains("deno"));
|
||||||
assert!(has_shell, "should have shell factory");
|
assert!(has_shell, "should have shell factory");
|
||||||
@@ -655,7 +655,7 @@ workflow:
|
|||||||
- "npm:is-number@7"
|
- "npm:is-number@7"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
@@ -685,7 +685,7 @@ workflow:
|
|||||||
BAZ: qux
|
BAZ: qux
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
@@ -744,7 +744,7 @@ workflow:
|
|||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
// This should compile without errors.
|
// This should compile without errors.
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
assert!(compiled.step_factories.len() >= 2);
|
assert!(compiled.step_factories.len() >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,7 +809,7 @@ workflow:
|
|||||||
script: "1;"
|
script: "1;"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compiled.definition.description.as_deref(),
|
compiled.definition.description.as_deref(),
|
||||||
Some("A workflow with deno steps")
|
Some("A workflow with deno steps")
|
||||||
@@ -834,7 +834,7 @@ workflow:
|
|||||||
interval: "2s"
|
interval: "2s"
|
||||||
"#;
|
"#;
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ use wfe::{WorkflowHostBuilder, run_workflow_sync};
|
|||||||
use wfe_core::test_support::{
|
use wfe_core::test_support::{
|
||||||
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
||||||
};
|
};
|
||||||
use wfe_yaml::load_workflow_from_str;
|
use wfe_yaml::load_single_workflow_from_str;
|
||||||
|
|
||||||
async fn run_yaml_workflow(yaml: &str) -> wfe::models::WorkflowInstance {
|
async fn run_yaml_workflow(yaml: &str) -> wfe::models::WorkflowInstance {
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
|
|
||||||
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
||||||
let lock = Arc::new(InMemoryLockProvider::new());
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
@@ -91,7 +91,7 @@ workflow:
|
|||||||
assert_eq!(greeting.as_str(), Some("hello"));
|
assert_eq!(greeting.as_str(), Some("hello"));
|
||||||
}
|
}
|
||||||
if let Some(count) = data.get("count") {
|
if let Some(count) = data.get("count") {
|
||||||
assert_eq!(count.as_str(), Some("42"));
|
assert_eq!(count.as_i64(), Some(42)); // auto-converted from string "42"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use wfe_yaml::{load_workflow, load_workflow_from_str};
|
use wfe_yaml::{load_workflow, load_single_workflow_from_str};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_workflow_from_file() {
|
fn load_workflow_from_file() {
|
||||||
@@ -45,7 +45,7 @@ fn load_workflow_from_nonexistent_file_returns_error() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn load_workflow_from_str_with_invalid_yaml_returns_error() {
|
fn load_workflow_from_str_with_invalid_yaml_returns_error() {
|
||||||
let yaml = "this is not valid yaml: [[[";
|
let yaml = "this is not valid yaml: [[[";
|
||||||
let result = load_workflow_from_str(yaml, &HashMap::new());
|
let result = load_single_workflow_from_str(yaml, &HashMap::new());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||||
assert!(
|
assert!(
|
||||||
@@ -69,7 +69,7 @@ workflow:
|
|||||||
let mut config = HashMap::new();
|
let mut config = HashMap::new();
|
||||||
config.insert("message".to_string(), serde_json::json!("hello world"));
|
config.insert("message".to_string(), serde_json::json!("hello world"));
|
||||||
|
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
let step = compiled
|
let step = compiled
|
||||||
.definition
|
.definition
|
||||||
.steps
|
.steps
|
||||||
@@ -94,7 +94,7 @@ workflow:
|
|||||||
config:
|
config:
|
||||||
run: echo ((missing))
|
run: echo ((missing))
|
||||||
"#;
|
"#;
|
||||||
let result = load_workflow_from_str(yaml, &HashMap::new());
|
let result = load_single_workflow_from_str(yaml, &HashMap::new());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
let err = match result { Err(e) => e.to_string(), Ok(_) => panic!("expected error") };
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
777
wfe-yaml/tests/rustlang.rs
Normal file
777
wfe-yaml/tests/rustlang.rs
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
#![cfg(feature = "rustlang")]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use wfe::models::WorkflowStatus;
|
||||||
|
use wfe::{WorkflowHostBuilder, run_workflow_sync};
|
||||||
|
use wfe_core::test_support::{
|
||||||
|
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
||||||
|
};
|
||||||
|
use wfe_yaml::load_single_workflow_from_str;
|
||||||
|
|
||||||
|
fn has_factory(compiled: &wfe_yaml::compiler::CompiledWorkflow, key: &str) -> bool {
|
||||||
|
compiled.step_factories.iter().any(|(k, _)| k == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_yaml_workflow(yaml: &str) -> wfe::models::WorkflowInstance {
|
||||||
|
let config = HashMap::new();
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
|
|
||||||
|
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
||||||
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
|
let queue = Arc::new(InMemoryQueueProvider::new());
|
||||||
|
|
||||||
|
let host = WorkflowHostBuilder::new()
|
||||||
|
.use_persistence(persistence as Arc<dyn wfe_core::traits::PersistenceProvider>)
|
||||||
|
.use_lock_provider(lock as Arc<dyn wfe_core::traits::DistributedLockProvider>)
|
||||||
|
.use_queue_provider(queue as Arc<dyn wfe_core::traits::QueueProvider>)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (key, factory) in compiled.step_factories {
|
||||||
|
host.register_step_factory(&key, factory).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
host.register_workflow_definition(compiled.definition.clone())
|
||||||
|
.await;
|
||||||
|
host.start().await.unwrap();
|
||||||
|
|
||||||
|
let instance = run_workflow_sync(
|
||||||
|
&host,
|
||||||
|
&compiled.definition.id,
|
||||||
|
compiled.definition.version,
|
||||||
|
serde_json::json!({}),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compiler tests — verify YAML compiles to correct step types and configs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_build_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cargo-build-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
type: cargo-build
|
||||||
|
config:
|
||||||
|
release: true
|
||||||
|
package: my-crate
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("build"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step.step_type, "wfe_yaml::cargo::build");
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::build"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_test_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cargo-test-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: test
|
||||||
|
type: cargo-test
|
||||||
|
config:
|
||||||
|
features:
|
||||||
|
- feat1
|
||||||
|
- feat2
|
||||||
|
extra_args:
|
||||||
|
- "--"
|
||||||
|
- "--nocapture"
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("test"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step.step_type, "wfe_yaml::cargo::test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_check_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cargo-check-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: check
|
||||||
|
type: cargo-check
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_clippy_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cargo-clippy-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: lint
|
||||||
|
type: cargo-clippy
|
||||||
|
config:
|
||||||
|
all_features: true
|
||||||
|
extra_args:
|
||||||
|
- "--"
|
||||||
|
- "-D"
|
||||||
|
- "warnings"
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::lint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_fmt_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cargo-fmt-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: format
|
||||||
|
type: cargo-fmt
|
||||||
|
config:
|
||||||
|
extra_args:
|
||||||
|
- "--check"
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_doc_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cargo-doc-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: docs
|
||||||
|
type: cargo-doc
|
||||||
|
config:
|
||||||
|
extra_args:
|
||||||
|
- "--no-deps"
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::docs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_publish_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cargo-publish-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: publish
|
||||||
|
type: cargo-publish
|
||||||
|
config:
|
||||||
|
extra_args:
|
||||||
|
- "--dry-run"
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::publish"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_step_with_toolchain() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: nightly-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: nightly-check
|
||||||
|
type: cargo-check
|
||||||
|
config:
|
||||||
|
toolchain: nightly
|
||||||
|
no_default_features: true
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::nightly-check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_step_with_timeout() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: timeout-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: slow-build
|
||||||
|
type: cargo-build
|
||||||
|
config:
|
||||||
|
timeout: 5m
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::slow-build"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_step_without_config() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: bare-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: bare-check
|
||||||
|
type: cargo-check
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::bare-check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_multi_step_pipeline() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: ci-pipeline
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: fmt
|
||||||
|
type: cargo-fmt
|
||||||
|
config:
|
||||||
|
extra_args: ["--check"]
|
||||||
|
- name: check
|
||||||
|
type: cargo-check
|
||||||
|
- name: clippy
|
||||||
|
type: cargo-clippy
|
||||||
|
config:
|
||||||
|
extra_args: ["--", "-D", "warnings"]
|
||||||
|
- name: test
|
||||||
|
type: cargo-test
|
||||||
|
- name: build
|
||||||
|
type: cargo-build
|
||||||
|
config:
|
||||||
|
release: true
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::fmt"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::check"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::clippy"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::test"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::build"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_step_with_all_shared_flags() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: full-flags-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: full
|
||||||
|
type: cargo-build
|
||||||
|
config:
|
||||||
|
package: my-crate
|
||||||
|
features: [foo, bar]
|
||||||
|
all_features: false
|
||||||
|
no_default_features: true
|
||||||
|
release: true
|
||||||
|
toolchain: stable
|
||||||
|
profile: release
|
||||||
|
extra_args: ["--jobs", "4"]
|
||||||
|
working_dir: /tmp/project
|
||||||
|
timeout: 30s
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: "-C target-cpu=native"
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::full"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_step_preserves_step_config_json() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: config-json-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
type: cargo-build
|
||||||
|
config:
|
||||||
|
release: true
|
||||||
|
package: wfe-core
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("build"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let step_config = step.step_config.as_ref().unwrap();
|
||||||
|
assert_eq!(step_config["command"], "build");
|
||||||
|
assert_eq!(step_config["release"], true);
|
||||||
|
assert_eq!(step_config["package"], "wfe-core");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Integration tests — run actual cargo commands through the workflow engine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cargo_check_on_self_succeeds() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: self-check
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: check
|
||||||
|
type: cargo-check
|
||||||
|
config:
|
||||||
|
working_dir: .
|
||||||
|
timeout: 120s
|
||||||
|
"#;
|
||||||
|
let instance = run_yaml_workflow(yaml).await;
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
|
||||||
|
let data = instance.data.as_object().unwrap();
|
||||||
|
assert!(data.contains_key("check.stdout") || data.contains_key("check.stderr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cargo_fmt_check_compiles() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: fmt-check
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: fmt
|
||||||
|
type: cargo-fmt
|
||||||
|
config:
|
||||||
|
working_dir: .
|
||||||
|
extra_args: ["--check"]
|
||||||
|
timeout: 60s
|
||||||
|
"#;
|
||||||
|
let config = HashMap::new();
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::fmt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rustup compiler tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rust_install_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: rust-install-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: install-rust
|
||||||
|
type: rust-install
|
||||||
|
config:
|
||||||
|
profile: minimal
|
||||||
|
default_toolchain: stable
|
||||||
|
timeout: 5m
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("install-rust"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step.step_type, "wfe_yaml::rustup::install-rust");
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::install-rust"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rustup_toolchain_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: tc-install-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: add-nightly
|
||||||
|
type: rustup-toolchain
|
||||||
|
config:
|
||||||
|
toolchain: nightly-2024-06-01
|
||||||
|
profile: minimal
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::add-nightly"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rustup_component_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: comp-add-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: add-tools
|
||||||
|
type: rustup-component
|
||||||
|
config:
|
||||||
|
components: [clippy, rustfmt, rust-src]
|
||||||
|
toolchain: nightly
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::add-tools"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rustup_target_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: target-add-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: add-wasm
|
||||||
|
type: rustup-target
|
||||||
|
config:
|
||||||
|
targets: [wasm32-unknown-unknown]
|
||||||
|
toolchain: stable
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::add-wasm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rustup_step_without_config() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: bare-install-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: install
|
||||||
|
type: rust-install
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::install"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rustup_step_preserves_config_json() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: config-json-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: tc
|
||||||
|
type: rustup-toolchain
|
||||||
|
config:
|
||||||
|
toolchain: nightly
|
||||||
|
profile: minimal
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
let step = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("tc"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let step_config = step.step_config.as_ref().unwrap();
|
||||||
|
assert_eq!(step_config["command"], "toolchain-install");
|
||||||
|
assert_eq!(step_config["toolchain"], "nightly");
|
||||||
|
assert_eq!(step_config["profile"], "minimal");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_full_rust_ci_pipeline() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: full-rust-ci
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: install
|
||||||
|
type: rust-install
|
||||||
|
config:
|
||||||
|
profile: minimal
|
||||||
|
default_toolchain: stable
|
||||||
|
- name: add-nightly
|
||||||
|
type: rustup-toolchain
|
||||||
|
config:
|
||||||
|
toolchain: nightly
|
||||||
|
- name: add-components
|
||||||
|
type: rustup-component
|
||||||
|
config:
|
||||||
|
components: [clippy, rustfmt]
|
||||||
|
- name: add-wasm
|
||||||
|
type: rustup-target
|
||||||
|
config:
|
||||||
|
targets: [wasm32-unknown-unknown]
|
||||||
|
- name: fmt
|
||||||
|
type: cargo-fmt
|
||||||
|
config:
|
||||||
|
extra_args: ["--check"]
|
||||||
|
- name: check
|
||||||
|
type: cargo-check
|
||||||
|
- name: clippy
|
||||||
|
type: cargo-clippy
|
||||||
|
config:
|
||||||
|
extra_args: ["--", "-D", "warnings"]
|
||||||
|
- name: test
|
||||||
|
type: cargo-test
|
||||||
|
- name: build
|
||||||
|
type: cargo-build
|
||||||
|
config:
|
||||||
|
release: true
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::install"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::add-nightly"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::add-components"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::add-wasm"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::fmt"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::check"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::clippy"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::test"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::build"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rustup_component_with_extra_args() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: comp-extra-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: add-llvm
|
||||||
|
type: rustup-component
|
||||||
|
config:
|
||||||
|
components: [llvm-tools-preview]
|
||||||
|
extra_args: ["--force"]
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::add-llvm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_rustup_target_multiple() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: multi-target-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: cross-targets
|
||||||
|
type: rustup-target
|
||||||
|
config:
|
||||||
|
targets:
|
||||||
|
- wasm32-unknown-unknown
|
||||||
|
- aarch64-linux-android
|
||||||
|
- x86_64-unknown-linux-musl
|
||||||
|
toolchain: nightly
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::rustup::cross-targets"));
|
||||||
|
|
||||||
|
let step_config = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("cross-targets"))
|
||||||
|
.unwrap()
|
||||||
|
.step_config
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step_config["command"], "target-add");
|
||||||
|
let targets = step_config["targets"].as_array().unwrap();
|
||||||
|
assert_eq!(targets.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// External cargo tool step compiler tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_audit_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: audit-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: audit
|
||||||
|
type: cargo-audit
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::audit"));
|
||||||
|
|
||||||
|
let step_config = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("audit"))
|
||||||
|
.unwrap()
|
||||||
|
.step_config
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step_config["command"], "audit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_deny_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: deny-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: license-check
|
||||||
|
type: cargo-deny
|
||||||
|
config:
|
||||||
|
extra_args: ["check"]
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::license-check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_nextest_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: nextest-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: fast-test
|
||||||
|
type: cargo-nextest
|
||||||
|
config:
|
||||||
|
features: [foo]
|
||||||
|
extra_args: ["--no-fail-fast"]
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::fast-test"));
|
||||||
|
|
||||||
|
let step_config = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("fast-test"))
|
||||||
|
.unwrap()
|
||||||
|
.step_config
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step_config["command"], "nextest");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_llvm_cov_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cov-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: coverage
|
||||||
|
type: cargo-llvm-cov
|
||||||
|
config:
|
||||||
|
extra_args: ["--html", "--output-dir", "coverage"]
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::coverage"));
|
||||||
|
|
||||||
|
let step_config = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("coverage"))
|
||||||
|
.unwrap()
|
||||||
|
.step_config
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step_config["command"], "llvm-cov");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_full_ci_with_external_tools() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: full-ci-external
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: audit
|
||||||
|
type: cargo-audit
|
||||||
|
- name: deny
|
||||||
|
type: cargo-deny
|
||||||
|
config:
|
||||||
|
extra_args: ["check", "licenses"]
|
||||||
|
- name: test
|
||||||
|
type: cargo-nextest
|
||||||
|
- name: coverage
|
||||||
|
type: cargo-llvm-cov
|
||||||
|
config:
|
||||||
|
extra_args: ["--summary-only"]
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::audit"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::deny"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::test"));
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::coverage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_doc_mdx_step() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: doc-mdx-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: docs
|
||||||
|
type: cargo-doc-mdx
|
||||||
|
config:
|
||||||
|
package: my-crate
|
||||||
|
output_dir: docs/api
|
||||||
|
extra_args: ["--no-deps"]
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::docs"));
|
||||||
|
|
||||||
|
let step_config = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("docs"))
|
||||||
|
.unwrap()
|
||||||
|
.step_config
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step_config["command"], "doc-mdx");
|
||||||
|
assert_eq!(step_config["package"], "my-crate");
|
||||||
|
assert_eq!(step_config["output_dir"], "docs/api");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_cargo_doc_mdx_minimal() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: doc-mdx-minimal-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: generate-docs
|
||||||
|
type: cargo-doc-mdx
|
||||||
|
"#;
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &HashMap::new()).unwrap();
|
||||||
|
assert!(has_factory(&compiled, "wfe_yaml::cargo::generate-docs"));
|
||||||
|
|
||||||
|
let step_config = compiled
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name.as_deref() == Some("generate-docs"))
|
||||||
|
.unwrap()
|
||||||
|
.step_config
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(step_config["command"], "doc-mdx");
|
||||||
|
assert!(step_config["output_dir"].is_null());
|
||||||
|
}
|
||||||
474
wfe-yaml/tests/rustlang_containerd.rs
Normal file
474
wfe-yaml/tests/rustlang_containerd.rs
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
//! End-to-end integration tests for the Rust toolchain steps running inside
|
||||||
|
//! containerd containers.
|
||||||
|
//!
|
||||||
|
//! These tests start from a bare Debian image (no Rust installed) and exercise
|
||||||
|
//! the full Rust CI pipeline: install Rust, install external tools, create a
|
||||||
|
//! test project, and run every cargo operation.
|
||||||
|
//!
|
||||||
|
//! Requirements:
|
||||||
|
//! - A running containerd daemon (Lima/colima or native)
|
||||||
|
//! - Set `WFE_CONTAINERD_ADDR` to point to the socket
|
||||||
|
//!
|
||||||
|
//! These tests are gated behind `rustlang` + `containerd` features and are
|
||||||
|
//! marked `#[ignore]` so they don't run in normal CI. Run them explicitly:
|
||||||
|
//! cargo test -p wfe-yaml --features rustlang,containerd --test rustlang_containerd -- --ignored
|
||||||
|
|
||||||
|
#![cfg(all(feature = "rustlang", feature = "containerd"))]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use wfe::models::WorkflowStatus;
|
||||||
|
use wfe::{WorkflowHostBuilder, run_workflow_sync};
|
||||||
|
use wfe_core::test_support::{
|
||||||
|
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
||||||
|
};
|
||||||
|
use wfe_yaml::load_single_workflow_from_str;
|
||||||
|
|
||||||
|
/// Returns the containerd address if available, or None.
|
||||||
|
/// Supports both Unix sockets (`unix:///path`) and TCP (`http://host:port`).
|
||||||
|
fn containerd_addr() -> Option<String> {
|
||||||
|
let addr = std::env::var("WFE_CONTAINERD_ADDR").unwrap_or_else(|_| {
|
||||||
|
// Default: TCP proxy on the Lima VM (socat forwarding containerd socket)
|
||||||
|
"http://127.0.0.1:2500".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// For TCP addresses, assume reachable (the test will fail fast if not).
|
||||||
|
if addr.starts_with("http://") || addr.starts_with("tcp://") {
|
||||||
|
return Some(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Unix sockets, check the file exists.
|
||||||
|
let socket_path = addr.strip_prefix("unix://").unwrap_or(addr.as_str());
|
||||||
|
if Path::new(socket_path).exists() {
|
||||||
|
Some(addr)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_yaml_workflow_with_config(
|
||||||
|
yaml: &str,
|
||||||
|
config: &HashMap<String, serde_json::Value>,
|
||||||
|
) -> wfe::models::WorkflowInstance {
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, config).unwrap();
|
||||||
|
for step in &compiled.definition.steps {
|
||||||
|
eprintln!(" step: {:?} type={} config={:?}", step.name, step.step_type, step.step_config);
|
||||||
|
}
|
||||||
|
eprintln!(" factories: {:?}", compiled.step_factories.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>());
|
||||||
|
|
||||||
|
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
||||||
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
|
let queue = Arc::new(InMemoryQueueProvider::new());
|
||||||
|
|
||||||
|
let host = WorkflowHostBuilder::new()
|
||||||
|
.use_persistence(persistence as Arc<dyn wfe_core::traits::PersistenceProvider>)
|
||||||
|
.use_lock_provider(lock as Arc<dyn wfe_core::traits::DistributedLockProvider>)
|
||||||
|
.use_queue_provider(queue as Arc<dyn wfe_core::traits::QueueProvider>)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (key, factory) in compiled.step_factories {
|
||||||
|
host.register_step_factory(&key, factory).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
host.register_workflow_definition(compiled.definition.clone())
|
||||||
|
.await;
|
||||||
|
host.start().await.unwrap();
|
||||||
|
|
||||||
|
let instance = run_workflow_sync(
|
||||||
|
&host,
|
||||||
|
&compiled.definition.id,
|
||||||
|
compiled.definition.version,
|
||||||
|
serde_json::json!({}),
|
||||||
|
Duration::from_secs(1800),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared env block and volume template for containerd steps.
|
||||||
|
/// Uses format! to avoid Rust 2024 reserved `##` token in raw strings.
|
||||||
|
fn containerd_step_yaml(
|
||||||
|
name: &str,
|
||||||
|
network: &str,
|
||||||
|
pull: &str,
|
||||||
|
timeout: &str,
|
||||||
|
working_dir: Option<&str>,
|
||||||
|
mount_workspace: bool,
|
||||||
|
run_script: &str,
|
||||||
|
) -> String {
|
||||||
|
let wfe = "##wfe";
|
||||||
|
let wd = working_dir
|
||||||
|
.map(|d| format!(" working_dir: {d}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let ws_volume = if mount_workspace {
|
||||||
|
" - source: ((workspace))\n target: /workspace"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#" - name: {name}
|
||||||
|
type: containerd
|
||||||
|
config:
|
||||||
|
image: docker.io/library/debian:bookworm-slim
|
||||||
|
containerd_addr: ((containerd_addr))
|
||||||
|
user: "0:0"
|
||||||
|
network: {network}
|
||||||
|
pull: {pull}
|
||||||
|
timeout: {timeout}
|
||||||
|
{wd}
|
||||||
|
env:
|
||||||
|
CARGO_HOME: /cargo
|
||||||
|
RUSTUP_HOME: /rustup
|
||||||
|
PATH: /cargo/bin:/rustup/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
volumes:
|
||||||
|
- source: ((cargo_home))
|
||||||
|
target: /cargo
|
||||||
|
- source: ((rustup_home))
|
||||||
|
target: /rustup
|
||||||
|
{ws_volume}
|
||||||
|
run: |
|
||||||
|
{run_script}
|
||||||
|
echo "{wfe}[output {name}.status=ok]"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base directory for shared state between host and containerd VM.
|
||||||
|
/// Must be inside the virtiofs mount defined in test/lima/wfe-test.yaml.
|
||||||
|
fn shared_dir() -> std::path::PathBuf {
|
||||||
|
let base = std::env::var("WFE_IO_DIR")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("/tmp/wfe-io"));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a temporary directory inside the shared mount so containerd can see it.
|
||||||
|
fn shared_tempdir(name: &str) -> std::path::PathBuf {
|
||||||
|
let id = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = shared_dir().join(format!("{name}-{id}"));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_config(
|
||||||
|
addr: &str,
|
||||||
|
cargo_home: &Path,
|
||||||
|
rustup_home: &Path,
|
||||||
|
workspace: Option<&Path>,
|
||||||
|
) -> HashMap<String, serde_json::Value> {
|
||||||
|
let mut config = HashMap::new();
|
||||||
|
config.insert(
|
||||||
|
"containerd_addr".to_string(),
|
||||||
|
serde_json::Value::String(addr.to_string()),
|
||||||
|
);
|
||||||
|
config.insert(
|
||||||
|
"cargo_home".to_string(),
|
||||||
|
serde_json::Value::String(cargo_home.to_str().unwrap().to_string()),
|
||||||
|
);
|
||||||
|
config.insert(
|
||||||
|
"rustup_home".to_string(),
|
||||||
|
serde_json::Value::String(rustup_home.to_str().unwrap().to_string()),
|
||||||
|
);
|
||||||
|
if let Some(ws) = workspace {
|
||||||
|
config.insert(
|
||||||
|
"workspace".to_string(),
|
||||||
|
serde_json::Value::String(ws.to_str().unwrap().to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal: just echo hello in a containerd step through the workflow engine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires containerd daemon"]
|
||||||
|
async fn minimal_echo_in_containerd_via_workflow() {
|
||||||
|
let _ = tracing_subscriber::fmt().with_env_filter("wfe_containerd=debug,wfe_core::executor=debug").try_init();
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = HashMap::new();
|
||||||
|
config.insert(
|
||||||
|
"containerd_addr".to_string(),
|
||||||
|
serde_json::Value::String(addr),
|
||||||
|
);
|
||||||
|
|
||||||
|
let wfe = "##wfe";
|
||||||
|
let yaml = format!(
|
||||||
|
r#"workflow:
|
||||||
|
id: minimal-containerd
|
||||||
|
version: 1
|
||||||
|
error_behavior:
|
||||||
|
type: terminate
|
||||||
|
steps:
|
||||||
|
- name: echo
|
||||||
|
type: containerd
|
||||||
|
config:
|
||||||
|
image: docker.io/library/alpine:3.18
|
||||||
|
containerd_addr: ((containerd_addr))
|
||||||
|
user: "0:0"
|
||||||
|
network: none
|
||||||
|
pull: if-not-present
|
||||||
|
timeout: 30s
|
||||||
|
run: |
|
||||||
|
echo hello-from-workflow
|
||||||
|
echo "{wfe}[output echo.status=ok]"
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let instance = run_yaml_workflow_with_config(&yaml, &config).await;
|
||||||
|
|
||||||
|
eprintln!("Status: {:?}, Data: {:?}", instance.status, instance.data);
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
let data = instance.data.as_object().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
data.get("echo.status").and_then(|v| v.as_str()),
|
||||||
|
Some("ok"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full Rust CI pipeline in a container: install → build → test → lint → cover
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires containerd daemon"]
|
||||||
|
async fn full_rust_pipeline_in_container() {
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd socket not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cargo_home = shared_tempdir("cargo");
|
||||||
|
let rustup_home = shared_tempdir("rustup");
|
||||||
|
let workspace = shared_tempdir("workspace");
|
||||||
|
|
||||||
|
let config = make_config(
|
||||||
|
&addr,
|
||||||
|
&cargo_home,
|
||||||
|
&rustup_home,
|
||||||
|
Some(&workspace),
|
||||||
|
);
|
||||||
|
|
||||||
|
let steps = [
|
||||||
|
containerd_step_yaml(
|
||||||
|
"install-rust", "host", "if-not-present", "10m", None, false,
|
||||||
|
" apt-get update && apt-get install -y curl gcc pkg-config libssl-dev\n\
|
||||||
|
\x20 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"install-tools", "host", "never", "10m", None, false,
|
||||||
|
" rustup component add clippy rustfmt llvm-tools-preview\n\
|
||||||
|
\x20 cargo install cargo-audit cargo-deny cargo-nextest cargo-llvm-cov",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"create-project", "host", "never", "2m", None, true,
|
||||||
|
" cargo init /workspace/test-crate --name test-crate\n\
|
||||||
|
\x20 cd /workspace/test-crate\n\
|
||||||
|
\x20 echo '#[cfg(test)] mod tests { #[test] fn it_works() { assert_eq!(2+2,4); } }' >> src/main.rs",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-fmt", "none", "never", "2m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo fmt -- --check || cargo fmt",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-check", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo check",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-clippy", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo clippy -- -D warnings",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-test", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo test",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-build", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo build --release",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-nextest", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo nextest run",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-llvm-cov", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo llvm-cov --summary-only",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-audit", "host", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo audit || true",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-deny", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo deny init\n\
|
||||||
|
\x20 cargo deny check || true",
|
||||||
|
),
|
||||||
|
containerd_step_yaml(
|
||||||
|
"cargo-doc", "none", "never", "5m",
|
||||||
|
Some("/workspace/test-crate"), true,
|
||||||
|
" cargo doc --no-deps",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let yaml = format!(
|
||||||
|
"workflow:\n id: rust-container-pipeline\n version: 1\n error_behavior:\n type: terminate\n steps:\n{}",
|
||||||
|
steps.join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
let instance = run_yaml_workflow_with_config(&yaml, &config).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
instance.status,
|
||||||
|
WorkflowStatus::Complete,
|
||||||
|
"workflow should complete successfully, data: {:?}",
|
||||||
|
instance.data
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = instance.data.as_object().unwrap();
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"install-rust.status",
|
||||||
|
"install-tools.status",
|
||||||
|
"create-project.status",
|
||||||
|
"cargo-fmt.status",
|
||||||
|
"cargo-check.status",
|
||||||
|
"cargo-clippy.status",
|
||||||
|
"cargo-test.status",
|
||||||
|
"cargo-build.status",
|
||||||
|
"cargo-nextest.status",
|
||||||
|
"cargo-llvm-cov.status",
|
||||||
|
"cargo-audit.status",
|
||||||
|
"cargo-deny.status",
|
||||||
|
"cargo-doc.status",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
data.get(key).and_then(|v| v.as_str()),
|
||||||
|
Some("ok"),
|
||||||
|
"step output '{key}' should be 'ok', got: {:?}",
|
||||||
|
data.get(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Focused test: just rust-install in a bare container
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires containerd daemon"]
|
||||||
|
async fn rust_install_in_bare_container() {
|
||||||
|
let Some(addr) = containerd_addr() else {
|
||||||
|
eprintln!("SKIP: containerd socket not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cargo_home = shared_tempdir("cargo");
|
||||||
|
let rustup_home = shared_tempdir("rustup");
|
||||||
|
|
||||||
|
let config = make_config(&addr, &cargo_home, &rustup_home, None);
|
||||||
|
|
||||||
|
let wfe = "##wfe";
|
||||||
|
let yaml = format!(
|
||||||
|
r#"workflow:
|
||||||
|
id: rust-install-container
|
||||||
|
version: 1
|
||||||
|
error_behavior:
|
||||||
|
type: terminate
|
||||||
|
steps:
|
||||||
|
- name: install
|
||||||
|
type: containerd
|
||||||
|
config:
|
||||||
|
image: docker.io/library/debian:bookworm-slim
|
||||||
|
containerd_addr: ((containerd_addr))
|
||||||
|
user: "0:0"
|
||||||
|
network: host
|
||||||
|
pull: if-not-present
|
||||||
|
timeout: 10m
|
||||||
|
env:
|
||||||
|
CARGO_HOME: /cargo
|
||||||
|
RUSTUP_HOME: /rustup
|
||||||
|
PATH: /cargo/bin:/rustup/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
volumes:
|
||||||
|
- source: ((cargo_home))
|
||||||
|
target: /cargo
|
||||||
|
- source: ((rustup_home))
|
||||||
|
target: /rustup
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y curl
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
echo "{wfe}[output rustc_installed=true]"
|
||||||
|
|
||||||
|
- name: verify
|
||||||
|
type: containerd
|
||||||
|
config:
|
||||||
|
image: docker.io/library/debian:bookworm-slim
|
||||||
|
containerd_addr: ((containerd_addr))
|
||||||
|
user: "0:0"
|
||||||
|
network: none
|
||||||
|
pull: if-not-present
|
||||||
|
timeout: 2m
|
||||||
|
env:
|
||||||
|
CARGO_HOME: /cargo
|
||||||
|
RUSTUP_HOME: /rustup
|
||||||
|
PATH: /cargo/bin:/rustup/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
volumes:
|
||||||
|
- source: ((cargo_home))
|
||||||
|
target: /cargo
|
||||||
|
- source: ((rustup_home))
|
||||||
|
target: /rustup
|
||||||
|
run: |
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
echo "{wfe}[output verify.status=ok]"
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let instance = run_yaml_workflow_with_config(&yaml, &config).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
instance.status,
|
||||||
|
WorkflowStatus::Complete,
|
||||||
|
"install workflow should complete, data: {:?}",
|
||||||
|
instance.data
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = instance.data.as_object().unwrap();
|
||||||
|
eprintln!("Workflow data: {:?}", instance.data);
|
||||||
|
assert!(
|
||||||
|
data.get("rustc_installed").is_some(),
|
||||||
|
"rustc_installed should be set, got data: {:?}",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
data.get("verify.status").and_then(|v| v.as_str()),
|
||||||
|
Some("ok"),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use wfe_yaml::schema::YamlWorkflow;
|
use wfe_yaml::schema::{YamlCondition, YamlWorkflow, YamlWorkflowFile};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_minimal_yaml() {
|
fn parse_minimal_yaml() {
|
||||||
@@ -192,3 +192,361 @@ workflow:
|
|||||||
assert_eq!(parsed.workflow.id, "template-wf");
|
assert_eq!(parsed.workflow.id, "template-wf");
|
||||||
assert_eq!(parsed.workflow.steps.len(), 1);
|
assert_eq!(parsed.workflow.steps.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Multi-workflow file tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_single_workflow_file() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: single
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hello
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflowFile = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(parsed.workflow.is_some());
|
||||||
|
assert!(parsed.workflows.is_none());
|
||||||
|
assert_eq!(parsed.workflow.unwrap().id, "single");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multi_workflow_file() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflows:
|
||||||
|
- id: build-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: cargo build
|
||||||
|
- id: test-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: test
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: cargo test
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflowFile = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(parsed.workflow.is_none());
|
||||||
|
assert!(parsed.workflows.is_some());
|
||||||
|
let workflows = parsed.workflows.unwrap();
|
||||||
|
assert_eq!(workflows.len(), 2);
|
||||||
|
assert_eq!(workflows[0].id, "build-wf");
|
||||||
|
assert_eq!(workflows[1].id, "test-wf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_workflow_with_input_output_schemas() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: typed-wf
|
||||||
|
version: 1
|
||||||
|
inputs:
|
||||||
|
repo_url: string
|
||||||
|
tags: "list<string>"
|
||||||
|
verbose: bool?
|
||||||
|
outputs:
|
||||||
|
artifact_path: string
|
||||||
|
exit_code: integer
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hello
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert_eq!(parsed.workflow.inputs.len(), 3);
|
||||||
|
assert_eq!(parsed.workflow.inputs.get("repo_url").unwrap(), "string");
|
||||||
|
assert_eq!(
|
||||||
|
parsed.workflow.inputs.get("tags").unwrap(),
|
||||||
|
"list<string>"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.workflow.inputs.get("verbose").unwrap(), "bool?");
|
||||||
|
assert_eq!(parsed.workflow.outputs.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.workflow.outputs.get("artifact_path").unwrap(),
|
||||||
|
"string"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.workflow.outputs.get("exit_code").unwrap(),
|
||||||
|
"integer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_with_workflow_type() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parent-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
workflow_version: 2
|
||||||
|
inputs:
|
||||||
|
- name: repo_url
|
||||||
|
path: data.repo
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
assert_eq!(step.step_type.as_deref(), Some("workflow"));
|
||||||
|
let config = step.config.as_ref().unwrap();
|
||||||
|
assert_eq!(config.child_workflow.as_deref(), Some("child-wf"));
|
||||||
|
assert_eq!(config.child_version, Some(2));
|
||||||
|
assert_eq!(step.inputs.len(), 1);
|
||||||
|
assert_eq!(step.outputs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_workflow_step_version_defaults() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: parent-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: run-child
|
||||||
|
type: workflow
|
||||||
|
config:
|
||||||
|
workflow: child-wf
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let config = parsed.workflow.steps[0].config.as_ref().unwrap();
|
||||||
|
assert_eq!(config.child_workflow.as_deref(), Some("child-wf"));
|
||||||
|
// version not specified, should be None in schema (compiler defaults to 1).
|
||||||
|
assert_eq!(config.child_version, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_inputs_outputs_default() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: no-schema-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hello
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(parsed.workflow.inputs.is_empty());
|
||||||
|
assert!(parsed.workflow.outputs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Condition schema tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_with_simple_when_condition() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: cond-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: deploy.sh
|
||||||
|
when:
|
||||||
|
field: .inputs.enabled
|
||||||
|
equals: true
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
assert!(step.when.is_some());
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
YamlCondition::Comparison(cmp) => {
|
||||||
|
assert_eq!(cmp.field, ".inputs.enabled");
|
||||||
|
assert!(cmp.equals.is_some());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Comparison variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_with_nested_combinator_conditions() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: nested-cond-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: deploy.sh
|
||||||
|
when:
|
||||||
|
all:
|
||||||
|
- field: .inputs.count
|
||||||
|
gt: 5
|
||||||
|
- any:
|
||||||
|
- field: .inputs.env
|
||||||
|
equals: prod
|
||||||
|
- field: .inputs.env
|
||||||
|
equals: staging
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
assert!(step.when.is_some());
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
YamlCondition::Combinator(c) => {
|
||||||
|
assert!(c.all.is_some());
|
||||||
|
let children = c.all.as_ref().unwrap();
|
||||||
|
assert_eq!(children.len(), 2);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Combinator variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_with_not_condition() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: not-cond-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: deploy.sh
|
||||||
|
when:
|
||||||
|
not:
|
||||||
|
field: .inputs.skip
|
||||||
|
equals: true
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
YamlCondition::Combinator(c) => {
|
||||||
|
assert!(c.not.is_some());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Combinator with not"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_with_none_condition() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: none-cond-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: deploy.sh
|
||||||
|
when:
|
||||||
|
none:
|
||||||
|
- field: .inputs.skip
|
||||||
|
equals: true
|
||||||
|
- field: .inputs.disabled
|
||||||
|
equals: true
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
YamlCondition::Combinator(c) => {
|
||||||
|
assert!(c.none.is_some());
|
||||||
|
assert_eq!(c.none.as_ref().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Combinator with none"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_with_one_of_condition() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: one-of-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: deploy.sh
|
||||||
|
when:
|
||||||
|
one_of:
|
||||||
|
- field: .inputs.mode
|
||||||
|
equals: fast
|
||||||
|
- field: .inputs.mode
|
||||||
|
equals: slow
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
YamlCondition::Combinator(c) => {
|
||||||
|
assert!(c.one_of.is_some());
|
||||||
|
assert_eq!(c.one_of.as_ref().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Combinator with one_of"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_comparison_with_each_operator() {
|
||||||
|
// Test that each operator variant deserializes correctly.
|
||||||
|
let operators = vec![
|
||||||
|
("equals: 42", "equals"),
|
||||||
|
("not_equals: foo", "not_equals"),
|
||||||
|
("gt: 10", "gt"),
|
||||||
|
("gte: 10", "gte"),
|
||||||
|
("lt: 100", "lt"),
|
||||||
|
("lte: 100", "lte"),
|
||||||
|
("contains: needle", "contains"),
|
||||||
|
("is_null: true", "is_null"),
|
||||||
|
("is_not_null: true", "is_not_null"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (op_yaml, op_name) in operators {
|
||||||
|
let yaml = format!(
|
||||||
|
r#"
|
||||||
|
workflow:
|
||||||
|
id: op-{op_name}
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hi
|
||||||
|
when:
|
||||||
|
field: .inputs.x
|
||||||
|
{op_yaml}
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(&yaml)
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to parse operator {op_name}: {e}"));
|
||||||
|
let step = &parsed.workflow.steps[0];
|
||||||
|
assert!(
|
||||||
|
step.when.is_some(),
|
||||||
|
"Step should have when condition for operator {op_name}"
|
||||||
|
);
|
||||||
|
match step.when.as_ref().unwrap() {
|
||||||
|
YamlCondition::Comparison(_) => {}
|
||||||
|
_ => panic!("Expected Comparison for operator {op_name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_step_without_when_has_none() {
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: no-when-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step1
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo hi
|
||||||
|
"#;
|
||||||
|
let parsed: YamlWorkflow = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(parsed.workflow.steps[0].when.is_none());
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ use wfe::{WorkflowHostBuilder, run_workflow_sync};
|
|||||||
use wfe_core::test_support::{
|
use wfe_core::test_support::{
|
||||||
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider,
|
||||||
};
|
};
|
||||||
use wfe_yaml::load_workflow_from_str;
|
use wfe_yaml::load_single_workflow_from_str;
|
||||||
|
|
||||||
async fn run_yaml_workflow_with_data(
|
async fn run_yaml_workflow_with_data(
|
||||||
yaml: &str,
|
yaml: &str,
|
||||||
data: serde_json::Value,
|
data: serde_json::Value,
|
||||||
) -> wfe::models::WorkflowInstance {
|
) -> wfe::models::WorkflowInstance {
|
||||||
let config = HashMap::new();
|
let config = HashMap::new();
|
||||||
let compiled = load_workflow_from_str(yaml, &config).unwrap();
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
|
|
||||||
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
||||||
let lock = Arc::new(InMemoryLockProvider::new());
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
@@ -53,6 +53,70 @@ async fn run_yaml_workflow(yaml: &str) -> wfe::models::WorkflowInstance {
|
|||||||
run_yaml_workflow_with_data(yaml, serde_json::json!({})).await
|
run_yaml_workflow_with_data(yaml, serde_json::json!({})).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A test LogSink that collects all chunks.
|
||||||
|
struct CollectingLogSink {
|
||||||
|
chunks: tokio::sync::Mutex<Vec<wfe_core::traits::LogChunk>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CollectingLogSink {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { chunks: tokio::sync::Mutex::new(Vec::new()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chunks(&self) -> Vec<wfe_core::traits::LogChunk> {
|
||||||
|
self.chunks.lock().await.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl wfe_core::traits::LogSink for CollectingLogSink {
|
||||||
|
async fn write_chunk(&self, chunk: wfe_core::traits::LogChunk) {
|
||||||
|
self.chunks.lock().await.push(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a workflow with a LogSink to verify log streaming works end-to-end.
|
||||||
|
async fn run_yaml_workflow_with_log_sink(
|
||||||
|
yaml: &str,
|
||||||
|
log_sink: Arc<CollectingLogSink>,
|
||||||
|
) -> wfe::models::WorkflowInstance {
|
||||||
|
let config = HashMap::new();
|
||||||
|
let compiled = load_single_workflow_from_str(yaml, &config).unwrap();
|
||||||
|
|
||||||
|
let persistence = Arc::new(InMemoryPersistenceProvider::new());
|
||||||
|
let lock = Arc::new(InMemoryLockProvider::new());
|
||||||
|
let queue = Arc::new(InMemoryQueueProvider::new());
|
||||||
|
|
||||||
|
let host = WorkflowHostBuilder::new()
|
||||||
|
.use_persistence(persistence as Arc<dyn wfe_core::traits::PersistenceProvider>)
|
||||||
|
.use_lock_provider(lock as Arc<dyn wfe_core::traits::DistributedLockProvider>)
|
||||||
|
.use_queue_provider(queue as Arc<dyn wfe_core::traits::QueueProvider>)
|
||||||
|
.use_log_sink(log_sink as Arc<dyn wfe_core::traits::LogSink>)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (key, factory) in compiled.step_factories {
|
||||||
|
host.register_step_factory(&key, factory).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
host.register_workflow_definition(compiled.definition.clone())
|
||||||
|
.await;
|
||||||
|
host.start().await.unwrap();
|
||||||
|
|
||||||
|
let instance = run_workflow_sync(
|
||||||
|
&host,
|
||||||
|
&compiled.definition.id,
|
||||||
|
compiled.definition.version,
|
||||||
|
serde_json::json!({}),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn simple_echo_captures_stdout() {
|
async fn simple_echo_captures_stdout() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -106,7 +170,7 @@ workflow:
|
|||||||
assert_eq!(greeting.as_str(), Some("hello"));
|
assert_eq!(greeting.as_str(), Some("hello"));
|
||||||
}
|
}
|
||||||
if let Some(count) = data.get("count") {
|
if let Some(count) = data.get("count") {
|
||||||
assert_eq!(count.as_str(), Some("42"));
|
assert_eq!(count.as_i64(), Some(42)); // auto-converted from string "42"
|
||||||
}
|
}
|
||||||
if let Some(path) = data.get("path") {
|
if let Some(path) = data.get("path") {
|
||||||
assert_eq!(path.as_str(), Some("/usr/local/bin"));
|
assert_eq!(path.as_str(), Some("/usr/local/bin"));
|
||||||
@@ -236,3 +300,176 @@ workflow:
|
|||||||
let instance = run_yaml_workflow(yaml).await;
|
let instance = run_yaml_workflow(yaml).await;
|
||||||
assert_eq!(instance.status, WorkflowStatus::Complete);
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LogSink regression tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn log_sink_receives_stdout_chunks() {
|
||||||
|
let log_sink = Arc::new(CollectingLogSink::new());
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: logsink-stdout-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: echo-step
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo "line one" && echo "line two"
|
||||||
|
"#;
|
||||||
|
let instance = run_yaml_workflow_with_log_sink(yaml, log_sink.clone()).await;
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
|
||||||
|
let chunks = log_sink.chunks().await;
|
||||||
|
assert!(chunks.len() >= 2, "expected at least 2 stdout chunks, got {}", chunks.len());
|
||||||
|
|
||||||
|
let stdout_chunks: Vec<_> = chunks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.stream == wfe_core::traits::LogStreamType::Stdout)
|
||||||
|
.collect();
|
||||||
|
assert!(stdout_chunks.len() >= 2, "expected at least 2 stdout chunks");
|
||||||
|
|
||||||
|
let all_data: String = stdout_chunks.iter()
|
||||||
|
.map(|c| String::from_utf8_lossy(&c.data).to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(all_data.contains("line one"), "stdout should contain 'line one', got: {all_data}");
|
||||||
|
assert!(all_data.contains("line two"), "stdout should contain 'line two', got: {all_data}");
|
||||||
|
|
||||||
|
// Verify chunk metadata.
|
||||||
|
for chunk in &stdout_chunks {
|
||||||
|
assert!(!chunk.workflow_id.is_empty());
|
||||||
|
assert_eq!(chunk.step_name, "echo-step");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn log_sink_receives_stderr_chunks() {
|
||||||
|
let log_sink = Arc::new(CollectingLogSink::new());
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: logsink-stderr-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: err-step
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo "stderr output" >&2
|
||||||
|
"#;
|
||||||
|
let instance = run_yaml_workflow_with_log_sink(yaml, log_sink.clone()).await;
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
|
||||||
|
let chunks = log_sink.chunks().await;
|
||||||
|
let stderr_chunks: Vec<_> = chunks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.stream == wfe_core::traits::LogStreamType::Stderr)
|
||||||
|
.collect();
|
||||||
|
assert!(!stderr_chunks.is_empty(), "expected stderr chunks");
|
||||||
|
|
||||||
|
let stderr_data: String = stderr_chunks.iter()
|
||||||
|
.map(|c| String::from_utf8_lossy(&c.data).to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(stderr_data.contains("stderr output"), "stderr should contain 'stderr output', got: {stderr_data}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn log_sink_captures_multi_step_workflow() {
|
||||||
|
let log_sink = Arc::new(CollectingLogSink::new());
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: logsink-multi-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: step-a
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo "from step a"
|
||||||
|
- name: step-b
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo "from step b"
|
||||||
|
"#;
|
||||||
|
let instance = run_yaml_workflow_with_log_sink(yaml, log_sink.clone()).await;
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
|
||||||
|
let chunks = log_sink.chunks().await;
|
||||||
|
let step_names: Vec<_> = chunks.iter().map(|c| c.step_name.as_str()).collect();
|
||||||
|
assert!(step_names.contains(&"step-a"), "should have chunks from step-a");
|
||||||
|
assert!(step_names.contains(&"step-b"), "should have chunks from step-b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn log_sink_not_configured_still_works() {
|
||||||
|
// Without a log_sink, the buffered path should still work.
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: no-logsink-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: echo-step
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo "no sink"
|
||||||
|
"#;
|
||||||
|
let instance = run_yaml_workflow(yaml).await;
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
let data = instance.data.as_object().unwrap();
|
||||||
|
assert!(data.get("echo-step.stdout").unwrap().as_str().unwrap().contains("no sink"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Security regression tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn security_blocked_env_vars_not_injected() {
|
||||||
|
// MEDIUM-22: Workflow data keys like "path" must NOT override PATH.
|
||||||
|
let yaml = r#"
|
||||||
|
workflow:
|
||||||
|
id: sec-env-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: check-path
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo "$PATH"
|
||||||
|
"#;
|
||||||
|
// Set a workflow data key "path" that would override PATH if not blocked.
|
||||||
|
let instance = run_yaml_workflow_with_data(
|
||||||
|
yaml,
|
||||||
|
serde_json::json!({"path": "/attacker/bin"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
|
||||||
|
let data = instance.data.as_object().unwrap();
|
||||||
|
let stdout = data.get("check-path.stdout").unwrap().as_str().unwrap();
|
||||||
|
// PATH should NOT contain /attacker/bin.
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("/attacker/bin"),
|
||||||
|
"PATH should not be overridden by workflow data, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn security_safe_env_vars_still_injected() {
|
||||||
|
// Verify non-blocked keys still work after the security fix.
|
||||||
|
let wfe_prefix = "##wfe";
|
||||||
|
let yaml = format!(
|
||||||
|
r#"
|
||||||
|
workflow:
|
||||||
|
id: sec-safe-env-wf
|
||||||
|
version: 1
|
||||||
|
steps:
|
||||||
|
- name: check-var
|
||||||
|
type: shell
|
||||||
|
config:
|
||||||
|
run: echo "{wfe_prefix}[output val=$MY_CUSTOM_VAR]"
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let instance = run_yaml_workflow_with_data(
|
||||||
|
&yaml,
|
||||||
|
serde_json::json!({"my_custom_var": "works"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(instance.status, WorkflowStatus::Complete);
|
||||||
|
|
||||||
|
let data = instance.data.as_object().unwrap();
|
||||||
|
assert_eq!(data.get("val").and_then(|v| v.as_str()), Some("works"));
|
||||||
|
}
|
||||||
|
|||||||
107
wfe-yaml/tests/types.rs
Normal file
107
wfe-yaml/tests/types.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use wfe_yaml::types::{parse_type_string, SchemaType};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_all_primitives() {
|
||||||
|
assert_eq!(parse_type_string("string").unwrap(), SchemaType::String);
|
||||||
|
assert_eq!(parse_type_string("number").unwrap(), SchemaType::Number);
|
||||||
|
assert_eq!(parse_type_string("integer").unwrap(), SchemaType::Integer);
|
||||||
|
assert_eq!(parse_type_string("bool").unwrap(), SchemaType::Bool);
|
||||||
|
assert_eq!(parse_type_string("any").unwrap(), SchemaType::Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_types() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("string?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("integer?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::Integer))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_list_types() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<number>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::Number))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_map_types() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<string>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::String))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<any>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::Any))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nested_generics() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<list<string>>").unwrap(),
|
||||||
|
SchemaType::List(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("map<list<integer>>").unwrap(),
|
||||||
|
SchemaType::Map(Box::new(SchemaType::List(Box::new(SchemaType::Integer))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_optional_generic() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_type_string("list<string>?").unwrap(),
|
||||||
|
SchemaType::Optional(Box::new(SchemaType::List(Box::new(SchemaType::String))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_type_returns_error() {
|
||||||
|
let err = parse_type_string("foobar").unwrap_err();
|
||||||
|
assert!(err.contains("Unknown type"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_generic_container_returns_error() {
|
||||||
|
let err = parse_type_string("set<string>").unwrap_err();
|
||||||
|
assert!(err.contains("Unknown generic type"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_returns_error() {
|
||||||
|
let err = parse_type_string("").unwrap_err();
|
||||||
|
assert!(err.contains("Empty"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_malformed_generic_returns_error() {
|
||||||
|
let err = parse_type_string("list<string").unwrap_err();
|
||||||
|
assert!(err.contains("Malformed"), "Got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_roundtrip() {
|
||||||
|
for s in &[
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"integer",
|
||||||
|
"bool",
|
||||||
|
"any",
|
||||||
|
"list<string>",
|
||||||
|
"map<number>",
|
||||||
|
"list<list<string>>",
|
||||||
|
] {
|
||||||
|
let parsed = parse_type_string(s).unwrap();
|
||||||
|
assert_eq!(parsed.to_string(), *s, "Roundtrip failed for {s}");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ name = "wfe"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "WFE workflow engine - umbrella crate"
|
description = "WFE workflow engine - umbrella crate"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
@@ -39,6 +41,7 @@ opentelemetry-otlp = { workspace = true, optional = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wfe-core = { workspace = true, features = ["test-support"] }
|
wfe-core = { workspace = true, features = ["test-support"] }
|
||||||
wfe-sqlite = { workspace = true }
|
wfe-sqlite = { workspace = true }
|
||||||
|
wfe-yaml = { workspace = true, features = ["deno"] }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
rstest = { workspace = true }
|
rstest = { workspace = true }
|
||||||
wiremock = { workspace = true }
|
wiremock = { workspace = true }
|
||||||
|
|||||||
165
wfe/examples/run_pipeline.rs
Normal file
165
wfe/examples/run_pipeline.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// WFE Self-Hosting CI Pipeline Runner
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Loads the multi-workflow CI pipeline from a YAML file and runs it to
|
||||||
|
// completion using the WFE engine with in-memory providers.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// cargo run --example run_pipeline -p wfe -- workflows.yaml
|
||||||
|
//
|
||||||
|
// With config:
|
||||||
|
// WFE_CONFIG='{"workspace_dir":"/path/to/wfe","registry":"sunbeam"}' \
|
||||||
|
// cargo run --example run_pipeline -p wfe -- workflows.yaml
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use wfe::models::WorkflowStatus;
|
||||||
|
use wfe::test_support::{InMemoryLockProvider, InMemoryQueueProvider, InMemoryPersistenceProvider};
|
||||||
|
use wfe::WorkflowHostBuilder;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Set up tracing.
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_target(false)
|
||||||
|
.with_timer(tracing_subscriber::fmt::time::uptime())
|
||||||
|
.with_env_filter(
|
||||||
|
std::env::var("RUST_LOG")
|
||||||
|
.unwrap_or_else(|_| "wfe_core=info,wfe=info,run_pipeline=info".into())
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Read YAML path from args.
|
||||||
|
let yaml_path = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("usage: run_pipeline <workflows.yaml>");
|
||||||
|
|
||||||
|
// Read config from WFE_CONFIG env var (JSON map), merged over sensible defaults.
|
||||||
|
let cwd = std::env::current_dir()?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Defaults for every ((var)) referenced in the YAML.
|
||||||
|
let mut config: HashMap<String, serde_json::Value> = HashMap::from([
|
||||||
|
("workspace_dir".into(), json!(cwd)),
|
||||||
|
("coverage_threshold".into(), json!(85)),
|
||||||
|
("registry".into(), json!("sunbeam")),
|
||||||
|
("git_remote".into(), json!("origin")),
|
||||||
|
("version".into(), json!("0.0.0")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Overlay user-provided config (WFE_CONFIG env var, JSON object).
|
||||||
|
if let Ok(user_json) = std::env::var("WFE_CONFIG") {
|
||||||
|
let user: HashMap<String, serde_json::Value> = serde_json::from_str(&user_json)?;
|
||||||
|
config.extend(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_json = serde_json::to_string(&config)?;
|
||||||
|
|
||||||
|
println!("Loading workflows from: {yaml_path}");
|
||||||
|
println!("Config: {config_json}");
|
||||||
|
|
||||||
|
// Load and compile all workflow definitions from the YAML file.
|
||||||
|
let yaml_content = std::fs::read_to_string(&yaml_path)?;
|
||||||
|
let workflows = wfe_yaml::load_workflow_from_str(&yaml_content, &config)?;
|
||||||
|
|
||||||
|
println!("Compiled {} workflow(s):", workflows.len());
|
||||||
|
for compiled in &workflows {
|
||||||
|
println!(
|
||||||
|
" - {} v{} ({} step factories)",
|
||||||
|
compiled.definition.id,
|
||||||
|
compiled.definition.version,
|
||||||
|
compiled.step_factories.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the host with in-memory providers.
|
||||||
|
let persistence = Arc::new(InMemoryPersistenceProvider::default());
|
||||||
|
let lock = Arc::new(InMemoryLockProvider::default());
|
||||||
|
let queue = Arc::new(InMemoryQueueProvider::default());
|
||||||
|
|
||||||
|
let host = WorkflowHostBuilder::new()
|
||||||
|
.use_persistence(persistence)
|
||||||
|
.use_lock_provider(lock)
|
||||||
|
.use_queue_provider(queue)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// Register all compiled workflows and their step factories.
|
||||||
|
// We must move the factories out of the compiled workflows since
|
||||||
|
// register_step_factory requires 'static closures.
|
||||||
|
for mut compiled in workflows {
|
||||||
|
let factories = std::mem::take(&mut compiled.step_factories);
|
||||||
|
for (key, factory) in factories {
|
||||||
|
host.register_step_factory(&key, move || factory()).await;
|
||||||
|
}
|
||||||
|
host.register_workflow_definition(compiled.definition).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the engine.
|
||||||
|
host.start().await?;
|
||||||
|
println!("\nEngine started. Launching 'ci' workflow...\n");
|
||||||
|
|
||||||
|
// Determine workspace_dir for initial data (use config value or cwd).
|
||||||
|
let workspace_dir = config
|
||||||
|
.get("workspace_dir")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&cwd)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let data = json!({
|
||||||
|
"workspace_dir": workspace_dir,
|
||||||
|
});
|
||||||
|
|
||||||
|
let workflow_id = host.start_workflow("ci", 1, data).await?;
|
||||||
|
println!("Workflow instance: {workflow_id}");
|
||||||
|
|
||||||
|
// Poll for completion with a 1-hour timeout.
|
||||||
|
let timeout = Duration::from_secs(3600);
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
let poll_interval = Duration::from_millis(500);
|
||||||
|
|
||||||
|
let final_instance = loop {
|
||||||
|
let instance = host.get_workflow(&workflow_id).await?;
|
||||||
|
match instance.status {
|
||||||
|
WorkflowStatus::Complete | WorkflowStatus::Terminated => break instance,
|
||||||
|
_ if tokio::time::Instant::now() > deadline => {
|
||||||
|
eprintln!("Timeout: workflow did not complete within {timeout:?}");
|
||||||
|
break instance;
|
||||||
|
}
|
||||||
|
_ => tokio::time::sleep(poll_interval).await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Print final status.
|
||||||
|
println!("\n========================================");
|
||||||
|
println!("Pipeline status: {:?}", final_instance.status);
|
||||||
|
println!(
|
||||||
|
"Execution pointers: {} total, {} complete",
|
||||||
|
final_instance.execution_pointers.len(),
|
||||||
|
final_instance
|
||||||
|
.execution_pointers
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.status == wfe::models::PointerStatus::Complete)
|
||||||
|
.count()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Print workflow data (contains outputs from all steps).
|
||||||
|
if let Some(obj) = final_instance.data.as_object() {
|
||||||
|
println!("\nKey outputs:");
|
||||||
|
for key in ["version", "all_tests_passed", "coverage", "published", "released"] {
|
||||||
|
if let Some(val) = obj.get(key) {
|
||||||
|
println!(" {key}: {val}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("========================================");
|
||||||
|
|
||||||
|
host.stop().await;
|
||||||
|
println!("\nEngine stopped.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
105
wfe/src/host.rs
105
wfe/src/host.rs
@@ -1,3 +1,5 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@@ -6,12 +8,12 @@ use tracing::{debug, error, info, warn};
|
|||||||
|
|
||||||
use wfe_core::executor::{StepRegistry, WorkflowExecutor};
|
use wfe_core::executor::{StepRegistry, WorkflowExecutor};
|
||||||
use wfe_core::models::{
|
use wfe_core::models::{
|
||||||
Event, ExecutionPointer, PointerStatus, QueueType, WorkflowDefinition, WorkflowInstance,
|
Event, ExecutionPointer, LifecycleEvent, LifecycleEventType, PointerStatus, QueueType,
|
||||||
WorkflowStatus,
|
WorkflowDefinition, WorkflowInstance, WorkflowStatus,
|
||||||
};
|
};
|
||||||
use wfe_core::traits::{
|
use wfe_core::traits::{
|
||||||
DistributedLockProvider, LifecyclePublisher, PersistenceProvider, QueueProvider, SearchIndex,
|
DistributedLockProvider, HostContext, LifecyclePublisher, PersistenceProvider, QueueProvider,
|
||||||
StepBody, WorkflowData,
|
SearchIndex, StepBody, WorkflowData,
|
||||||
};
|
};
|
||||||
use wfe_core::traits::registry::WorkflowRegistry;
|
use wfe_core::traits::registry::WorkflowRegistry;
|
||||||
use wfe_core::{Result, WfeError};
|
use wfe_core::{Result, WfeError};
|
||||||
@@ -19,6 +21,51 @@ use wfe_core::builder::WorkflowBuilder;
|
|||||||
|
|
||||||
use crate::registry::InMemoryWorkflowRegistry;
|
use crate::registry::InMemoryWorkflowRegistry;
|
||||||
|
|
||||||
|
/// A lightweight HostContext implementation that delegates to the WorkflowHost's
|
||||||
|
/// components. Used by the background consumer task which cannot hold a direct
|
||||||
|
/// reference to WorkflowHost (it runs in a spawned tokio task).
|
||||||
|
pub(crate) struct HostContextImpl {
|
||||||
|
persistence: Arc<dyn PersistenceProvider>,
|
||||||
|
registry: Arc<RwLock<InMemoryWorkflowRegistry>>,
|
||||||
|
queue_provider: Arc<dyn QueueProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostContext for HostContextImpl {
|
||||||
|
fn start_workflow(
|
||||||
|
&self,
|
||||||
|
definition_id: &str,
|
||||||
|
version: u32,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + '_>> {
|
||||||
|
let def_id = definition_id.to_string();
|
||||||
|
Box::pin(async move {
|
||||||
|
// Look up the definition.
|
||||||
|
let reg = self.registry.read().await;
|
||||||
|
let definition = reg
|
||||||
|
.get_definition(&def_id, Some(version))
|
||||||
|
.ok_or_else(|| WfeError::DefinitionNotFound {
|
||||||
|
id: def_id.clone(),
|
||||||
|
version,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Create the child workflow instance.
|
||||||
|
let mut instance = WorkflowInstance::new(&def_id, version, data);
|
||||||
|
if !definition.steps.is_empty() {
|
||||||
|
instance.execution_pointers.push(ExecutionPointer::new(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = self.persistence.create_new_workflow(&instance).await?;
|
||||||
|
|
||||||
|
// Queue for execution.
|
||||||
|
self.queue_provider
|
||||||
|
.queue_work(&id, QueueType::Workflow)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The main orchestrator that ties all workflow engine components together.
|
/// The main orchestrator that ties all workflow engine components together.
|
||||||
pub struct WorkflowHost {
|
pub struct WorkflowHost {
|
||||||
pub(crate) persistence: Arc<dyn PersistenceProvider>,
|
pub(crate) persistence: Arc<dyn PersistenceProvider>,
|
||||||
@@ -49,6 +96,7 @@ impl WorkflowHost {
|
|||||||
sr.register::<sequence::SequenceStep>();
|
sr.register::<sequence::SequenceStep>();
|
||||||
sr.register::<wait_for::WaitForStep>();
|
sr.register::<wait_for::WaitForStep>();
|
||||||
sr.register::<while_step::WhileStep>();
|
sr.register::<while_step::WhileStep>();
|
||||||
|
sr.register::<sub_workflow::SubWorkflowStep>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn background polling tasks for processing workflows and events.
|
/// Spawn background polling tasks for processing workflows and events.
|
||||||
@@ -66,6 +114,11 @@ impl WorkflowHost {
|
|||||||
let step_registry = Arc::clone(&self.step_registry);
|
let step_registry = Arc::clone(&self.step_registry);
|
||||||
let queue = Arc::clone(&self.queue_provider);
|
let queue = Arc::clone(&self.queue_provider);
|
||||||
let shutdown = self.shutdown.clone();
|
let shutdown = self.shutdown.clone();
|
||||||
|
let host_ctx = Arc::new(HostContextImpl {
|
||||||
|
persistence: Arc::clone(&self.persistence),
|
||||||
|
registry: Arc::clone(&self.registry),
|
||||||
|
queue_provider: Arc::clone(&self.queue_provider),
|
||||||
|
});
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -94,7 +147,7 @@ impl WorkflowHost {
|
|||||||
Some(def) => {
|
Some(def) => {
|
||||||
let def_clone = def.clone();
|
let def_clone = def.clone();
|
||||||
let sr = step_registry.read().await;
|
let sr = step_registry.read().await;
|
||||||
if let Err(e) = executor.execute(&workflow_id, &def_clone, &sr).await {
|
if let Err(e) = executor.execute(&workflow_id, &def_clone, &sr, Some(host_ctx.as_ref())).await {
|
||||||
error!(workflow_id = %workflow_id, error = %e, "Workflow execution failed");
|
error!(workflow_id = %workflow_id, error = %e, "Workflow execution failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,6 +308,18 @@ impl WorkflowHost {
|
|||||||
.queue_work(&id, QueueType::Workflow)
|
.queue_work(&id, QueueType::Workflow)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Publish lifecycle event.
|
||||||
|
if let Some(ref publisher) = self.lifecycle {
|
||||||
|
let _ = publisher
|
||||||
|
.publish(LifecycleEvent::new(
|
||||||
|
&id,
|
||||||
|
definition_id,
|
||||||
|
version,
|
||||||
|
LifecycleEventType::Started,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +357,16 @@ impl WorkflowHost {
|
|||||||
}
|
}
|
||||||
instance.status = WorkflowStatus::Suspended;
|
instance.status = WorkflowStatus::Suspended;
|
||||||
self.persistence.persist_workflow(&instance).await?;
|
self.persistence.persist_workflow(&instance).await?;
|
||||||
|
if let Some(ref publisher) = self.lifecycle {
|
||||||
|
let _ = publisher
|
||||||
|
.publish(LifecycleEvent::new(
|
||||||
|
id,
|
||||||
|
&instance.workflow_definition_id,
|
||||||
|
instance.version,
|
||||||
|
LifecycleEventType::Suspended,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +384,16 @@ impl WorkflowHost {
|
|||||||
.queue_work(id, QueueType::Workflow)
|
.queue_work(id, QueueType::Workflow)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if let Some(ref publisher) = self.lifecycle {
|
||||||
|
let _ = publisher
|
||||||
|
.publish(LifecycleEvent::new(
|
||||||
|
id,
|
||||||
|
&instance.workflow_definition_id,
|
||||||
|
instance.version,
|
||||||
|
LifecycleEventType::Resumed,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +408,16 @@ impl WorkflowHost {
|
|||||||
instance.status = WorkflowStatus::Terminated;
|
instance.status = WorkflowStatus::Terminated;
|
||||||
instance.complete_time = Some(chrono::Utc::now());
|
instance.complete_time = Some(chrono::Utc::now());
|
||||||
self.persistence.persist_workflow(&instance).await?;
|
self.persistence.persist_workflow(&instance).await?;
|
||||||
|
if let Some(ref publisher) = self.lifecycle {
|
||||||
|
let _ = publisher
|
||||||
|
.publish(LifecycleEvent::new(
|
||||||
|
id,
|
||||||
|
&instance.workflow_definition_id,
|
||||||
|
instance.version,
|
||||||
|
LifecycleEventType::Terminated,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct WorkflowHostBuilder {
|
|||||||
queue_provider: Option<Arc<dyn QueueProvider>>,
|
queue_provider: Option<Arc<dyn QueueProvider>>,
|
||||||
lifecycle: Option<Arc<dyn LifecyclePublisher>>,
|
lifecycle: Option<Arc<dyn LifecyclePublisher>>,
|
||||||
search: Option<Arc<dyn SearchIndex>>,
|
search: Option<Arc<dyn SearchIndex>>,
|
||||||
|
log_sink: Option<Arc<dyn wfe_core::traits::LogSink>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkflowHostBuilder {
|
impl WorkflowHostBuilder {
|
||||||
@@ -31,6 +32,7 @@ impl WorkflowHostBuilder {
|
|||||||
queue_provider: None,
|
queue_provider: None,
|
||||||
lifecycle: None,
|
lifecycle: None,
|
||||||
search: None,
|
search: None,
|
||||||
|
log_sink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,12 @@ impl WorkflowHostBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set an optional log sink for real-time step output streaming.
|
||||||
|
pub fn use_log_sink(mut self, sink: Arc<dyn wfe_core::traits::LogSink>) -> Self {
|
||||||
|
self.log_sink = Some(sink);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the `WorkflowHost`.
|
/// Build the `WorkflowHost`.
|
||||||
///
|
///
|
||||||
/// Returns an error if persistence, lock_provider, or queue_provider have not been set.
|
/// Returns an error if persistence, lock_provider, or queue_provider have not been set.
|
||||||
@@ -90,6 +98,9 @@ impl WorkflowHostBuilder {
|
|||||||
if let Some(ref search) = self.search {
|
if let Some(ref search) = self.search {
|
||||||
executor = executor.with_search(Arc::clone(search));
|
executor = executor.with_search(Arc::clone(search));
|
||||||
}
|
}
|
||||||
|
if let Some(ref log_sink) = self.log_sink {
|
||||||
|
executor = executor.with_log_sink(Arc::clone(log_sink));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(WorkflowHost {
|
Ok(WorkflowHost {
|
||||||
persistence,
|
persistence,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user