diff --git a/Cargo.lock b/Cargo.lock index 3380f929..49913e22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -708,6 +708,19 @@ dependencies = [ "walkdir", ] +[[package]] +name = "edgezero-adapter-celld" +version = "0.1.0" +dependencies = [ + "ctor", + "edgezero-adapter", + "edgezero-adapter-cloudflare", + "edgezero-core", + "serde_json", + "tempfile", + "worker", +] + [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" @@ -802,6 +815,7 @@ dependencies = [ "clap", "edgezero-adapter", "edgezero-adapter-axum", + "edgezero-adapter-celld", "edgezero-adapter-cloudflare", "edgezero-adapter-fastly", "edgezero-adapter-spin", diff --git a/Cargo.toml b/Cargo.toml index 0837012a..5ab8d917 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/edgezero-adapter-axum", "crates/edgezero-adapter-cloudflare", + "crates/edgezero-adapter-celld", "crates/edgezero-adapter-fastly", "crates/edgezero-adapter-spin", "crates/edgezero-adapter", @@ -51,6 +52,7 @@ ctor = "1.0" edgezero-adapter = { path = "crates/edgezero-adapter" } edgezero-adapter-axum = { path = "crates/edgezero-adapter-axum", default-features = false } edgezero-adapter-cloudflare = { path = "crates/edgezero-adapter-cloudflare", default-features = false } +edgezero-adapter-celld = { path = "crates/edgezero-adapter-celld", default-features = false } edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-features = false } edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } diff --git a/crates/edgezero-adapter-celld/Cargo.toml b/crates/edgezero-adapter-celld/Cargo.toml new file mode 100644 index 00000000..bea65fad --- /dev/null +++ b/crates/edgezero-adapter-celld/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "edgezero-adapter-celld" +description = "Experimental celld local Workers runtime adapter for EdgeZero" +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[features] +default = [] +celld = ["dep:edgezero-adapter-cloudflare", "dep:worker"] +cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:serde_json"] + +[dependencies] +edgezero-core = { workspace = true } +edgezero-adapter-cloudflare = { workspace = true, default-features = false, features = ["cloudflare"], optional = true } +edgezero-adapter = { workspace = true, optional = true, features = ["cli"] } +ctor = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +worker = { workspace = true, optional = true } + +[dev-dependencies] +edgezero-adapter-cloudflare = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/edgezero-adapter-celld/src/cli.rs b/crates/edgezero-adapter-celld/src/cli.rs new file mode 100644 index 00000000..4e49b969 --- /dev/null +++ b/crates/edgezero-adapter-celld/src/cli.rs @@ -0,0 +1,565 @@ +//! Host-side CLI registration for local celld build and serve. + +use std::env; +use std::ffi::OsStr; +use std::io; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use ctor::ctor; +use edgezero_adapter::cli_support::find_manifest_upwards; +use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterDeployContext, AdapterExecutionTarget, DeployOwnership, + ProvisionStores, register_adapter, +}; +use edgezero_adapter::scaffold::{ + AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, + ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, +}; +use edgezero_core::{Capability, CapabilitySupport, PlatformMetadata}; + +use crate::{CELLD_ADAPTER, CELLD_PLATFORM}; + +static CELLD_ADAPTER_IMPL: CelldCliAdapter = CelldCliAdapter; + +static CELLD_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "celld", + display_name: "celld", + crate_suffix: "adapter-celld", + dependency_crate: "edgezero-adapter-celld", + dependency_repo_path: "crates/edgezero-adapter-celld", + template_registrations: CELLD_TEMPLATE_REGISTRATIONS, + files: CELLD_FILE_SPECS, + extra_dirs: &["src", ".cargo"], + dependencies: CELLD_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "wrangler.json", + build_target: "wasm32-unknown-unknown", + build_profile: "release", + build_features: &["celld"], + }, + // Empty command overrides keep this adapter on registered execute_target, + // where build-before-serve and loopback argument validation live. + commands: CommandTemplates { + build: "", + deploy: "", + serve: "", + }, + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: None, + }, + readme: ReadmeInfo { + description: "Experimental celld local Worker entrypoint.", + dev_heading: "celld (experimental local runtime)", + dev_steps: &["`edgezero serve --adapter celld` (loopback only)"], + }, + run_module: "edgezero_adapter_celld", +}; + +static CELLD_DEPENDENCIES: &[DependencySpec] = &[DependencySpec { + key: "dep_edgezero_adapter_celld", + repo_crate: "crates/edgezero-adapter-celld", + fallback: "edgezero-adapter-celld = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-celld\", default-features = false, features = [\"celld\"] }", + features: &["celld"], +}]; + +static CELLD_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "celld_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "celld_src_lib_rs", + output: "src/lib.rs", + }, + AdapterFileSpec { + template: "celld_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "celld_cargo_config_toml", + output: ".cargo/config.toml", + }, + AdapterFileSpec { + template: "celld_wrangler_json", + output: "wrangler.json", + }, +]; + +static CELLD_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "celld_Cargo_toml", + contents: include_str!("templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "celld_src_lib_rs", + contents: include_str!("templates/src/lib.rs.hbs"), + }, + TemplateRegistration { + name: "celld_src_main_rs", + contents: include_str!("templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "celld_cargo_config_toml", + contents: include_str!("templates/.cargo/config.toml.hbs"), + }, + TemplateRegistration { + name: "celld_wrangler_json", + contents: include_str!("templates/wrangler.json.hbs"), + }, +]; + +struct CelldCliAdapter; + +#[expect( + clippy::missing_trait_methods, + reason = "celld supports only local build/serve and deliberately has no deploy, provisioning, or config publication implementation" +)] +impl Adapter for CelldCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::IngressAdmission => CapabilitySupport::Native, + Capability::ConfigReadDeadlines + | Capability::InboundReadDeadlines + | Capability::IngressAdmissionAbort + | Capability::LazyStreamedResponsePassthrough + | Capability::OutboundAuthorityOverride + | Capability::OutboundBatchCancellation + | Capability::OutboundBatchCompletionOrder + | Capability::OutboundBatchSlotIsolation + | Capability::OutboundCacheBypass + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::OutboundHeaderFidelity + | Capability::OutboundHttp + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | Capability::StreamedUploadDeadlines => CapabilitySupport::BestEffort, + // Default stream reads bound Rust-owned copies, but the runtime may + // have allocated an entire KV value as one JS chunk before delivery. + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | _ => CapabilitySupport::Unsupported, + } + } + + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + match action { + AdapterAction::Build => { + let manifest = + find_celld_manifest(&env::current_dir().map_err(|err| err.to_string())?)?; + build_from_manifest(&manifest, args).map(|_| ()) + } + AdapterAction::Serve => { + let manifest = + find_celld_manifest(&env::current_dir().map_err(|err| err.to_string())?)?; + serve_from_manifest(&manifest, args) + } + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::Deploy + | AdapterAction::DeployStaging + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(unsupported_action(action)), + _ => Err("celld adapter does not implement this action".to_owned()), + } + } + + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + let manifest = target.platform_manifest().ok_or_else(|| { + "celld target is missing [adapters.celld.adapter].manifest".to_owned() + })?; + match action { + AdapterAction::Build => build_from_manifest(manifest, args).map(|_| ()), + AdapterAction::Serve => serve_from_manifest(manifest, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::Deploy + | AdapterAction::DeployStaging + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(unsupported_action(action)), + _ => Err("celld adapter does not implement this action".to_owned()), + } + } + + fn merged_id_kinds(&self) -> &'static [&'static str] { + &["kv", "config"] + } + + fn name(&self) -> &'static str { + CELLD_ADAPTER + } + + fn platform_metadata(&self) -> PlatformMetadata { + CELLD_PLATFORM + } + + fn preflight_deploy( + &self, + _context: &AdapterDeployContext, + _args: &[String], + ) -> Result { + Err("celld adapter supports local build and serve only; deploy is unsupported".to_owned()) + } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _dry_run: bool, + ) -> Result, String> { + Err("celld adapter does not provision platform resources".to_owned()) + } +} + +fn unsupported_action(action: AdapterAction) -> String { + format!("celld adapter supports local build and serve only; {action:?} is unsupported") +} + +fn find_celld_manifest(start: &Path) -> Result { + find_manifest_upwards(start, "wrangler.json") + .or_else(|| find_manifest_upwards(start, "wrangler.jsonc")) + .ok_or_else(|| "could not locate wrangler.json or wrangler.jsonc for celld".to_owned()) +} + +fn build_from_manifest(manifest: &Path, extra_args: &[String]) -> Result { + build_with_program(manifest, extra_args, OsStr::new("worker-build")) +} + +fn build_with_program( + manifest: &Path, + extra_args: &[String], + worker_build: &OsStr, +) -> Result { + let manifest_dir = manifest + .parent() + .ok_or_else(|| "celld manifest has no parent directory".to_owned())?; + let status = Command::new(worker_build) + .arg("--release") + .arg(manifest_dir) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + "`worker-build` is not on PATH; install workers-rs worker-build 0.8.5".to_owned() + } else { + format!("failed to run `worker-build`: {error}") + } + })?; + if !status.success() { + return Err(format!( + "`worker-build --release` failed with status {status}" + )); + } + let shim = manifest_dir.join("build/worker/shim.mjs"); + if !shim.is_file() { + return Err(format!( + "`worker-build` succeeded but did not emit {}", + shim.display() + )); + } + Ok(shim) +} + +fn serve_from_manifest(manifest: &Path, args: &[String]) -> Result<(), String> { + serve_with_programs( + manifest, + args, + OsStr::new("worker-build"), + OsStr::new("celld"), + ) +} + +fn serve_with_programs( + manifest: &Path, + args: &[String], + worker_build: &OsStr, + celld_program: &OsStr, +) -> Result<(), String> { + let safe_args = validate_serve_args(args)?; + let _shim = build_with_program(manifest, &[], worker_build)?; + let canonical_manifest = manifest + .canonicalize() + .map_err(|error| format!("failed to resolve celld manifest: {error}"))?; + let status = Command::new(celld_program) + .arg("dev") + .arg(&canonical_manifest) + .args(safe_args) + .arg("--host") + .arg("127.0.0.1") + .current_dir(canonical_manifest.parent().unwrap_or(Path::new("."))) + .status() + .map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + "`celld` is not on PATH; install celld v0.6.2".to_owned() + } else { + format!("failed to run `celld dev`: {error}") + } + })?; + if !status.success() { + return Err(format!("`celld dev` failed with status {status}")); + } + Ok(()) +} + +fn validate_serve_args(args: &[String]) -> Result, String> { + let mut safe = Vec::with_capacity(args.len()); + let mut arguments = args.iter(); + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--host" => { + let value = arguments + .next() + .ok_or_else(|| "celld serve --host requires a loopback IP".to_owned())?; + validate_loopback_host(value)?; + } + value => { + if let Some(host) = value.strip_prefix("--host=") { + validate_loopback_host(host)?; + } else if matches!(value, "--port" | "--watch-ignore") { + let argument_value = arguments + .next() + .ok_or_else(|| format!("celld serve {value} requires a value"))?; + if value == "--port" { + validate_port(argument_value)?; + } + safe.push(value.to_owned()); + safe.push(argument_value.clone()); + } else if matches!(value, "--clean" | "--logs" | "--no-watch") { + safe.push(value.to_owned()); + } else { + return Err(format!( + "celld serve does not accept forwarded argument {value:?}" + )); + } + } + } + } + Ok(safe) +} + +fn validate_loopback_host(value: &str) -> Result<(), String> { + match value.parse::() { + Ok(ip) if ip.is_loopback() => Ok(()), + _ => Err("celld serve only permits loopback --host values (127.0.0.1 or ::1)".to_owned()), + } +} + +fn validate_port(value: &str) -> Result<(), String> { + match value.parse::() { + Ok(port) if port != 0 => Ok(()), + _ => Err(format!("invalid loopback port {value:?}")), + } +} + +/// Register celld CLI and scaffold support. +#[inline] +pub fn register() { + register_adapter(&CELLD_ADAPTER_IMPL); + register_adapter_blueprint(&CELLD_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capability_map_does_not_claim_raw_ingress_or_resource_accounting() { + assert_eq!( + CELLD_ADAPTER_IMPL.capability(Capability::IngressAdmission), + CapabilitySupport::Native + ); + assert_eq!( + CELLD_ADAPTER_IMPL.capability(Capability::RawIngressFramingValidation), + CapabilitySupport::Unsupported + ); + assert_eq!( + CELLD_ADAPTER_IMPL.capability(Capability::ConfigReadAllocationBounds), + CapabilitySupport::Unsupported + ); + assert_eq!( + CELLD_ADAPTER_IMPL.capability(Capability::OutboundCompleteResourceAccounting), + CapabilitySupport::Unsupported + ); + } + + #[test] + fn loopback_host_is_required_and_forwarded_public_hosts_are_rejected() { + validate_loopback_host("127.0.0.1").expect("IPv4 loopback"); + validate_loopback_host("::1").expect("IPv6 loopback"); + validate_loopback_host("0.0.0.0").expect_err("wildcard host rejected"); + validate_loopback_host("192.0.2.1").expect_err("public host rejected"); + validate_serve_args(&["--host".to_owned(), "0.0.0.0".to_owned()]) + .expect_err("public forwarded host rejected"); + } + + #[test] + fn only_local_celld_dev_arguments_are_forwarded() { + assert_eq!( + validate_serve_args(&["--port".to_owned(), "9877".to_owned(), "--logs".to_owned()]) + .expect("safe local args"), + vec!["--port", "9877", "--logs"] + ); + validate_serve_args(&["--remote".to_owned()]).expect_err("remote option rejected"); + validate_serve_args(&["--port".to_owned(), "0".to_owned()]) + .expect_err("zero port rejected"); + } + + #[test] + fn unsupported_actions_never_fall_through_to_cloud_or_fleet_commands() { + assert!( + CELLD_ADAPTER_IMPL + .execute(AdapterAction::AuthLogin, &[]) + .expect_err("auth unsupported") + .contains("unsupported") + ); + assert!( + CELLD_ADAPTER_IMPL + .execute(AdapterAction::Deploy, &[]) + .expect_err("deploy unsupported") + .contains("unsupported") + ); + assert!( + CELLD_ADAPTER_IMPL + .execute(AdapterAction::DeployStaging, &[]) + .expect_err("release action unsupported") + .contains("unsupported") + ); + assert!( + CELLD_ADAPTER_IMPL + .preflight_deploy(&AdapterDeployContext::default(), &[]) + .expect_err("deploy preflight fails closed") + .contains("unsupported") + ); + } + + #[test] + fn provisioning_fails_instead_of_succeeding_as_a_noop() { + let empty = ProvisionStores { + kv: &[], + config: &[], + secrets: &[], + }; + assert!( + CELLD_ADAPTER_IMPL + .provision(Path::new("."), None, None, &empty, false) + .expect_err("provision unsupported") + .contains("does not provision") + ); + } + + #[cfg(unix)] + #[test] + fn serve_builds_first_then_starts_celld_with_loopback_only() { + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::tempdir().expect("temp dir"); + let bin = temp.path().join("bin"); + let app = temp.path().join("app"); + fs::create_dir_all(&bin).expect("bin directory"); + fs::create_dir_all(&app).expect("app directory"); + let manifest = app.join("wrangler.json"); + fs::write( + &manifest, + r#"{"name":"test-celld","main":"build/worker/shim.mjs"}"#, + ) + .expect("manifest"); + let worker_build = bin.join("worker-build"); + fs::write( + &worker_build, + "#!/bin/sh\nprintf build > build-step\nmkdir -p build/worker\nprintf shim > build/worker/shim.mjs\n", + ) + .expect("fake worker-build"); + let celld = bin.join("celld"); + fs::write( + &celld, + "#!/bin/sh\ntest -f build-step || exit 3\nprintf '%s\\n' \"$*\" > celld-args\n", + ) + .expect("fake celld"); + for executable in [&worker_build, &celld] { + let mut permissions = fs::metadata(executable) + .expect("executable metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(executable, permissions).expect("make executable"); + } + + serve_with_programs( + &manifest, + &["--port".to_owned(), "9877".to_owned()], + worker_build.as_os_str(), + celld.as_os_str(), + ) + .expect("serve through fake local tools"); + + assert_eq!( + fs::read_to_string(app.join("build-step")).expect("build marker"), + "build" + ); + let celld_args = fs::read_to_string(app.join("celld-args")).expect("celld args"); + assert!(celld_args.starts_with("dev ")); + assert!(celld_args.contains("--port 9877 --host 127.0.0.1")); + assert!(app.join("build/worker/shim.mjs").is_file()); + } + + #[cfg(unix)] + #[test] + fn public_host_rejection_happens_before_build_or_serve() { + use std::fs; + + let temp = tempfile::tempdir().expect("temp dir"); + let manifest = temp.path().join("wrangler.json"); + fs::write(&manifest, "{}\n").expect("manifest"); + let error = serve_from_manifest(&manifest, &["--host".to_owned(), "0.0.0.0".to_owned()]) + .expect_err("public host rejected"); + assert!(error.contains("only permits loopback")); + assert!(!temp.path().join("build/worker/shim.mjs").exists()); + } + + #[test] + fn blueprint_uses_worker_build_and_default_bundling_manifest() { + assert_eq!(CELLD_BLUEPRINT.manifest.manifest_filename, "wrangler.json"); + let cargo = include_str!("templates/Cargo.toml.hbs") + .replace("{{proj_celld}}", "test-celld") + .replace("{{proj_core}}", "test-core") + .replace( + "{{{dep_edgezero_adapter_celld}}}", + "edgezero-adapter-celld = { path = \"../../crates/edgezero-adapter-celld\" }", + ); + assert!(cargo.contains("test-core = { path = \"../test-core\" }")); + assert!(CELLD_BLUEPRINT.commands.build.is_empty()); + assert!(CELLD_BLUEPRINT.commands.deploy.is_empty()); + assert!(CELLD_BLUEPRINT.commands.serve.is_empty()); + let rendered = + include_str!("templates/wrangler.json.hbs").replace("{{proj_celld}}", "test-celld"); + let manifest: serde_json::Value = + serde_json::from_str(&rendered).expect("valid rendered JSON manifest"); + assert_eq!(manifest["main"], "build/worker/shim.mjs"); + assert_eq!(manifest["no_bundle"], false); + } +} diff --git a/crates/edgezero-adapter-celld/src/lib.rs b/crates/edgezero-adapter-celld/src/lib.rs new file mode 100644 index 00000000..5a00015c --- /dev/null +++ b/crates/edgezero-adapter-celld/src/lib.rs @@ -0,0 +1,120 @@ +//! Experimental local runtime adapter for celld. +//! +//! The HTTP implementation reuses the Worker transport. This crate supplies +//! the celld identity and rejects unsupported secret-store declarations. + +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] +pub mod cli; + +use edgezero_core::{PlatformFact, PlatformMetadata, PlatformUnknownReason}; + +/// celld's published runtime limits do not establish `EdgeZero` platform facts. +pub const CELLD_PLATFORM: PlatformMetadata = PlatformMetadata::new( + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), +); + +/// Adapter identity used to resolve celld-specific manifest settings. +pub const CELLD_ADAPTER: &str = "celld"; + +/// Runtime identity and policy used by [`run_app`]. +#[cfg(feature = "celld")] +pub const CELLD_RUNTIME_OPTIONS: edgezero_adapter_cloudflare::WorkerRuntimeOptions = + edgezero_adapter_cloudflare::WorkerRuntimeOptions { + adapter_key: CELLD_ADAPTER, + config_read_mode: edgezero_adapter_cloudflare::ConfigReadMode::DefaultStream, + platform: CELLD_PLATFORM, + secret_policy: edgezero_adapter_cloudflare::SecretPolicy::RejectDeclared, + }; + +#[cfg(all(feature = "celld", target_arch = "wasm32"))] +/// Worker request context alias for consumers that need the Worker-compatible context. +pub type CelldRequestContext = edgezero_adapter_cloudflare::context::CloudflareRequestContext; + +/// Handle one Worker-compatible request under celld's EdgeZero identity. +/// +/// The generated application must declare its celld-specific environment and +/// stores in `edgezero.toml`. Typed config and ordinary KV use celld's local +/// Worker KV binding. Declared secret stores are rejected. +/// +/// # Errors +/// Returns a Worker error when application configuration, secret policy, or +/// request dispatch fails. +#[cfg(all(feature = "celld", target_arch = "wasm32"))] +#[inline] +pub async fn run_app( + req: worker::Request, + env: worker::Env, + ctx: worker::Context, +) -> worker::Result { + edgezero_adapter_cloudflare::run_app_with_options::(req, env, ctx, CELLD_RUNTIME_OPTIONS) + .await +} + +#[cfg(test)] +mod tests { + use std::io::Error; + + use edgezero_core::app::{App, Hooks}; + use edgezero_core::error::EdgeError; + use edgezero_core::router::RouterService; + + struct PlatformHooks; + + #[expect( + clippy::missing_trait_methods, + reason = "the test checks only platform metadata before configure" + )] + impl Hooks for PlatformHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + if app.platform() == super::CELLD_PLATFORM { + Ok(()) + } else { + Err(EdgeError::internal(Error::other("celld metadata missing"))) + } + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn celld_metadata_is_supplied_before_application_configuration() { + let app = App::build::(super::CELLD_PLATFORM).expect("celld app"); + assert_eq!(app.platform(), super::CELLD_PLATFORM); + } + + #[test] + fn celld_metadata_does_not_claim_provider_facts() { + assert!(super::CELLD_PLATFORM.memory_ceiling().value().is_none()); + assert!( + super::CELLD_PLATFORM + .inbound_request_population_bound() + .value() + .is_none() + ); + assert!( + super::CELLD_PLATFORM + .host_ingress_memory_accounting() + .value() + .is_none() + ); + } + + #[cfg(feature = "celld")] + #[test] + fn celld_runtime_options_never_enable_worker_plaintext_secrets() { + assert_eq!( + super::CELLD_RUNTIME_OPTIONS.secret_policy, + edgezero_adapter_cloudflare::SecretPolicy::RejectDeclared + ); + assert_eq!(super::CELLD_RUNTIME_OPTIONS.adapter_key, "celld"); + assert_eq!(super::CELLD_RUNTIME_OPTIONS.platform, super::CELLD_PLATFORM); + assert_eq!( + super::CELLD_RUNTIME_OPTIONS.config_read_mode, + edgezero_adapter_cloudflare::ConfigReadMode::DefaultStream + ); + } +} diff --git a/crates/edgezero-adapter-celld/src/templates/.cargo/config.toml.hbs b/crates/edgezero-adapter-celld/src/templates/.cargo/config.toml.hbs new file mode 100644 index 00000000..f4e8c002 --- /dev/null +++ b/crates/edgezero-adapter-celld/src/templates/.cargo/config.toml.hbs @@ -0,0 +1,2 @@ +[build] +target = "wasm32-unknown-unknown" diff --git a/crates/edgezero-adapter-celld/src/templates/Cargo.toml.hbs b/crates/edgezero-adapter-celld/src/templates/Cargo.toml.hbs new file mode 100644 index 00000000..9c049f89 --- /dev/null +++ b/crates/edgezero-adapter-celld/src/templates/Cargo.toml.hbs @@ -0,0 +1,26 @@ +[package] +name = "{{proj_celld}}" +description = "Experimental celld Worker entrypoint for {{name}}" +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[features] +default = ["celld"] +celld = ["edgezero-adapter-celld/celld"] + +[dependencies] +{{proj_core}} = { path = "../{{proj_core}}" } +{{{dep_edgezero_adapter_celld}}} + +[target.'cfg(target_arch = "wasm32")'.dependencies] +worker = { workspace = true } diff --git a/crates/edgezero-adapter-celld/src/templates/src/lib.rs.hbs b/crates/edgezero-adapter-celld/src/templates/src/lib.rs.hbs new file mode 100644 index 00000000..cee2f6bc --- /dev/null +++ b/crates/edgezero-adapter-celld/src/templates/src/lib.rs.hbs @@ -0,0 +1,15 @@ +#![cfg_attr(target_arch = "wasm32", no_main)] + +#[cfg(target_arch = "wasm32")] +use worker::{Context, Env, Request, Response, Result, event}; + +/// Entrypoint invoked by the Worker-compatible runtime. +/// +/// # Errors +/// Returns an error if EdgeZero application dispatch fails. +#[cfg(target_arch = "wasm32")] +#[event(fetch)] +#[inline] +pub async fn main(req: Request, env: Env, ctx: Context) -> Result { + edgezero_adapter_celld::run_app::<{{proj_core_mod}}::App>(req, env, ctx).await +} diff --git a/crates/edgezero-adapter-celld/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-celld/src/templates/src/main.rs.hbs new file mode 100644 index 00000000..a073736d --- /dev/null +++ b/crates/edgezero-adapter-celld/src/templates/src/main.rs.hbs @@ -0,0 +1,3 @@ +fn main() { + eprintln!("Use `worker-build --release` and `celld dev` to run this adapter."); +} diff --git a/crates/edgezero-adapter-celld/src/templates/wrangler.json.hbs b/crates/edgezero-adapter-celld/src/templates/wrangler.json.hbs new file mode 100644 index 00000000..12d70fc1 --- /dev/null +++ b/crates/edgezero-adapter-celld/src/templates/wrangler.json.hbs @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/cloudflare/workers-sdk/main/packages/wrangler/config-schema.json", + "name": "{{proj_celld}}", + "main": "build/worker/shim.mjs", + "compatibility_date": "2023-05-01", + "compatibility_flags": ["enable_request_signal", "enable_weak_ref"], + "no_bundle": false, + "vars": {}, + "kv_namespaces": [] +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index cf985f1c..60412dfc 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -156,6 +156,8 @@ impl Adapter for CloudflareCliAdapter { | Capability::OutboundCacheBypass | Capability::OutboundFlexiblePhaseBudget | Capability::OutboundHttp => CapabilitySupport::Native, + // Workers host chunks may be allocated before the bounded Rust drain + // sees them; byte caps do not establish provider allocation bounds. Capability::ConfigReadAllocationBounds | Capability::OutboundCompleteResourceAccounting | Capability::RawIngressFramingValidation diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index a389be19..b039da31 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -33,6 +33,8 @@ use std::future::Future; use std::future::{pending, ready}; use std::time::Duration; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use crate::ConfigReadMode; use async_trait::async_trait; use edgezero_core::config_store::{ BoundedStoreRead, ConfigStore, ConfigStoreError, finish_bounded_config_read, @@ -50,6 +52,8 @@ use worker::{Delay, Env}; /// async KV lookups against that namespace. pub struct CloudflareConfigStore { inner: CloudflareConfigBackend, + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + read_mode: ConfigReadMode, } enum CloudflareConfigBackend { @@ -64,6 +68,8 @@ impl CloudflareConfigStore { fn from_entries(entries: impl IntoIterator) -> Self { Self { inner: CloudflareConfigBackend::InMemory(entries.into_iter().collect()), + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + read_mode: ConfigReadMode::Byob, } } @@ -75,9 +81,25 @@ impl CloudflareConfigStore { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] pub fn from_env(env: &Env, binding_name: &str) -> Result { + Self::from_env_with_read_mode(env, binding_name, ConfigReadMode::Byob) + } + + /// Open the KV namespace with an explicitly selected response-stream reader. + /// + /// # Errors + /// Returns [`ConfigStoreError::Unavailable`] when the binding is missing + /// or cannot be opened. + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + #[inline] + pub fn from_env_with_read_mode( + env: &Env, + binding_name: &str, + read_mode: ConfigReadMode, + ) -> Result { let store = kv::KvBinding::from_this(env.as_ref(), binding_name)?; Ok(Self { inner: CloudflareConfigBackend::Kv(store), + read_mode, }) } @@ -125,6 +147,7 @@ impl ConfigStore for CloudflareConfigStore { max_backend_bytes.min(max_value_bytes), clock, deadline, + self.read_mode, ), clock, deadline, @@ -237,10 +260,18 @@ mod tests { use worker::wasm_bindgen_futures::JsFuture; fn streaming_store(length: usize, stalled: bool) -> (CloudflareConfigStore, JsValue) { - // An actual KV binding double: text reads fail and the byte source only - // fills caller-owned BYOB views, without materializing a complete value. + streaming_store_with_mode(length, stalled, ConfigReadMode::Byob) + } + + fn streaming_store_with_mode( + length: usize, + stalled: bool, + read_mode: ConfigReadMode, + ) -> (CloudflareConfigStore, JsValue) { + // An actual KV binding double: text reads fail and the byte source can + // exercise either caller-sized BYOB views or ordinary JS chunks. let factory = Function::new_with_args( - "length, stalled", + "length, stalled, defaultReader", " // worker::Delay expects browser numeric handles, not Node objects. if (!globalThis.edgezeroNumericTimers) { @@ -259,7 +290,7 @@ mod tests { timers.delete(id); }; } - const stats = { cancels: 0, gets: 0, pulls: 0, maxView: 0 }; + const stats = { cancels: 0, gets: 0, pulls: 0, maxView: 0, maxChunk: 0 }; const kv = { get: async (key, options) => { stats.gets++; @@ -276,6 +307,21 @@ mod tests { pull(controller) { stats.pulls++; if (key === 'source-error') throw new Error('TOKEN-DO-NOT-LEAK'); + if (defaultReader || !controller.byobRequest) { + if (stalled) return new Promise(() => {}); + const count = Math.min(0x4000, length - position); + if (count === 0) { + controller.close(); + } else { + const chunk = new Uint8Array(count); + chunk.fill(key === 'invalid-utf8' ? 255 : 97); + stats.maxChunk = Math.max(stats.maxChunk, count); + position += count; + controller.enqueue(chunk); + if (position === length) controller.close(); + } + return; + } const view = controller.byobRequest.view; stats.maxView = Math.max(stats.maxView, view.byteLength); if (stalled) return new Promise(() => {}); @@ -304,10 +350,11 @@ mod tests { ); let js_length = u32::try_from(length).expect("fixture length fits JS integer"); let binding = factory - .call2( + .call3( &JsValue::NULL, &JsValue::from(js_length), &JsValue::from(stalled), + &JsValue::from(read_mode == ConfigReadMode::DefaultStream), ) .expect("KV fixture"); let stats = Reflect::get(&binding, &JsValue::from("stats")).expect("stats"); @@ -315,6 +362,7 @@ mod tests { ( CloudflareConfigStore { inner: CloudflareConfigBackend::Kv(kv), + read_mode, }, stats, ) @@ -330,6 +378,40 @@ mod tests { .expect("integer counter") } + #[wasm_bindgen_test] + async fn bounded_default_reader_rejects_an_oversized_host_chunk_before_copy() { + let (oversized_store, oversized_stats) = + streaming_store_with_mode(151, false, ConfigReadMode::DefaultStream); + let error = oversized_store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + 10, + 10, + ) + .await + .expect_err("oversized host chunk rejected"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + assert_eq!(counter(&oversized_stats, "maxChunk"), 151); + + let (exact_store, exact_stats) = + streaming_store_with_mode(10, false, ConfigReadMode::DefaultStream); + let result = exact_store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + 10, + 10, + ) + .await + .expect("chunk exactly at both caps is accepted"); + assert_eq!(result.backend_bytes, 10); + assert_eq!(result.value.expect("config value").as_str(), "aaaaaaaaaa"); + assert_eq!(counter(&exact_stats, "maxChunk"), 10); + } + #[wasm_bindgen_test] async fn bounded_kv_requires_gc_cleanup_support_before_provider_dispatch() { let (store, stats) = streaming_store(1, false); diff --git a/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs b/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs index edf76a48..a43932f3 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs @@ -6,7 +6,7 @@ use std::time::Duration; use edgezero_core::config_store::ConfigStoreError; use edgezero_core::time::{Deadline, MonotonicClock}; -use wasm_streams::readable::ReadableStreamBYOBReader; +use wasm_streams::readable::{ReadableStreamBYOBReader, ReadableStreamDefaultReader}; use worker::Delay; use worker::js_sys::{Function, Object, Promise, Reflect, Uint8Array, global}; use worker::kv::KvStore as WorkerKvStore; @@ -15,6 +15,8 @@ use worker::wasm_bindgen::{JsCast as _, JsValue}; use worker::wasm_bindgen_futures::JsFuture; use worker::web_sys::ReadableStream; +use crate::ConfigReadMode; + const CONFIG_READ_CHUNK_BYTES: usize = 0x4000; struct Acquisition(Rc>); @@ -31,6 +33,11 @@ struct ConfigReader<'stream> { reader: ReadableStreamBYOBReader<'stream>, } +struct DefaultConfigReader<'stream> { + completed: bool, + reader: ReadableStreamDefaultReader<'stream>, +} + pub(super) struct KvBinding { get: Function, namespace: JsValue, @@ -58,6 +65,14 @@ impl Drop for ConfigReader<'_> { } } +impl Drop for DefaultConfigReader<'_> { + fn drop(&mut self) { + if !self.completed { + consume_cancellation(&self.reader.as_raw().cancel()); + } + } +} + impl KvBinding { pub(super) fn from_this(env: &JsValue, name: &str) -> Result { let store = WorkerKvStore::from_this(env, name).map_err(|_error| unavailable())?; @@ -90,6 +105,7 @@ impl KvBinding { max_bytes: u64, clock: &MonotonicClock, deadline: Deadline, + read_mode: ConfigReadMode, ) -> Result, ConfigStoreError> { // Transferred callbacks require wasm-bindgen's JS-GC cleanup support. // Older/explicitly disabled Workers profiles must fail before dispatch. @@ -142,7 +158,9 @@ impl KvBinding { .stream .take() .ok_or_else(unavailable)?; - drain(raw, max_bytes, clock, deadline).await.map(Some) + drain(raw, max_bytes, clock, deadline, read_mode) + .await + .map(Some) } } @@ -159,6 +177,19 @@ async fn drain( max_bytes: u64, clock: &MonotonicClock, deadline: Deadline, + read_mode: ConfigReadMode, +) -> Result { + match read_mode { + ConfigReadMode::Byob => drain_byob(raw, max_bytes, clock, deadline).await, + ConfigReadMode::DefaultStream => drain_default(raw, max_bytes, clock, deadline).await, + } +} + +async fn drain_byob( + raw: ReadableStream, + max_bytes: u64, + clock: &MonotonicClock, + deadline: Deadline, ) -> Result { let mut stream = wasm_streams::ReadableStream::from_raw(raw.clone().unchecked_into()); let reader = match stream.try_get_byob_reader() { @@ -221,6 +252,74 @@ async fn drain( String::from_utf8(bytes).map_err(|_error| unavailable()) } +async fn drain_default( + raw: ReadableStream, + max_bytes: u64, + clock: &MonotonicClock, + deadline: Deadline, +) -> Result { + let mut stream = wasm_streams::ReadableStream::from_raw(raw.clone().unchecked_into()); + let reader = match stream.try_get_reader() { + Ok(reader) => reader, + Err(_error) => { + consume_cancellation(&raw.cancel()); + return Err(unavailable()); + } + }; + let mut guard = DefaultConfigReader { + completed: false, + reader, + }; + let limit = usize::try_from(max_bytes).unwrap_or(usize::MAX); + let mut bytes = Vec::new(); + let mut pulls = 0_u32; + loop { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let next_chunk = guard.reader.read().await.map_err(|_error| unavailable())?; + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let Some(chunk_value) = next_chunk else { + guard.completed = true; + break; + }; + let byte_chunk = chunk_value + .dyn_into::() + .map_err(|_error| unavailable())?; + let chunk_len = usize::try_from(byte_chunk.length()).map_err(|_error| unavailable())?; + let remaining = limit.saturating_sub(bytes.len()); + if chunk_len > remaining { + return Err(ConfigStoreError::ValueTooLarge); + } + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let next_len = bytes + .len() + .checked_add(chunk_len) + .ok_or(ConfigStoreError::ValueTooLarge)?; + bytes + .try_reserve_exact(chunk_len) + .map_err(|_error| ConfigStoreError::ValueTooLarge)?; + let chunk_offset = bytes.len(); + bytes.resize(next_len, 0); + byte_chunk.copy_to( + bytes + .get_mut(chunk_offset..next_len) + .ok_or_else(unavailable)?, + ); + pulls = pulls.saturating_add(1); + if pulls == 16 { + // Yield so timers can make progress even when Workers freezes its clock in-task. + Delay::from(Duration::ZERO).await; + pulls = 0; + } + } + String::from_utf8(bytes).map_err(|_error| unavailable()) +} + fn unavailable() -> ConfigStoreError { ConfigStoreError::unavailable("config KV byte stream is unavailable") } diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 562824c6..2381b4c1 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -27,7 +27,7 @@ pub mod secret_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use edgezero_core::app::StoresMetadata; #[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] -use edgezero_core::app::{App, Hooks}; +use edgezero_core::app::{App, Hooks, StoreMetadata}; #[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] use edgezero_core::context::RuntimeVariables; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] @@ -37,6 +37,8 @@ use edgezero_core::error::EdgeError; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use edgezero_core::manifest::BakedManifest; #[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::manifest::Manifest; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] use edgezero_core::manifest::ResolvedEnvironmentBinding; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::{Context, Env, Error as WorkerError, Request, Response}; @@ -62,17 +64,71 @@ pub const CLOUDFLARE_PLATFORM: edgezero_core::PlatformMetadata = ), ); +/// Policy for attaching a Worker `Env`-backed secret store. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretPolicy { + /// Attach `CloudflareSecretStore`, which reads Worker secret bindings. + AllowWorkerSecrets, + /// Reject applications that declare secret stores. + RejectDeclared, +} + +/// Config KV stream consumption strategy selected for the Worker runtime. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigReadMode { + /// Use a BYOB reader to fill caller-sized Rust buffers. + Byob, + /// Use the stream's default reader, checking each host-allocated chunk + /// against the caller's byte limits before copying it into Rust memory. + /// This bounds Rust-owned value storage but not the host chunk allocation. + DefaultStream, +} + +/// Identity and policy for the shared Worker runtime. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WorkerRuntimeOptions { + /// Canonical adapter id used for manifest environment and logging selection. + pub adapter_key: &'static str, + /// Selects how config KV response streams are read. + pub config_read_mode: ConfigReadMode, + /// Platform metadata supplied before application configuration hooks run. + pub platform: edgezero_core::PlatformMetadata, + /// Controls whether the Worker secret store can be attached. + pub secret_policy: SecretPolicy, +} + +impl WorkerRuntimeOptions { + /// Cloudflare's existing runtime behavior. + pub const CLOUDFLARE: Self = Self { + adapter_key: "cloudflare", + config_read_mode: ConfigReadMode::Byob, + platform: CLOUDFLARE_PLATFORM, + secret_policy: SecretPolicy::AllowWorkerSecrets, + }; +} + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -fn build_app_for_dispatch() -> Result { - build_target_app::().map_err(|error| { +fn build_app_for_dispatch(options: WorkerRuntimeOptions) -> Result { + build_target_app::(options).map_err(|error| { log::error!(target: edgezero_core::BOOT_LOG_TARGET, "application configuration failed: {}", error.kind()); WorkerError::RustError("application configuration failed".to_owned()) }) } #[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] -fn build_target_app() -> Result { - App::build::(CLOUDFLARE_PLATFORM) +fn build_target_app(options: WorkerRuntimeOptions) -> Result { + App::build::(options.platform) +} + +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +fn validate_secret_policy( + secrets: Option, + policy: SecretPolicy, +) -> Result<(), &'static str> { + if secrets.is_some() && policy == SecretPolicy::RejectDeclared { + return Err("celld does not support declared secret stores"); + } + Ok(()) } /// Test seam for the production application-assembly error mapping. @@ -80,7 +136,7 @@ fn build_target_app() -> Result { #[doc(hidden)] #[inline] pub fn build_app_for_test() -> Result { - build_app_for_dispatch::() + build_app_for_dispatch::(WorkerRuntimeOptions::CLOUDFLARE) } /// # Errors @@ -151,6 +207,16 @@ fn collect_worker_variables( })) } +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +fn manifest_runtime_variables( + manifest: &Manifest, + adapter_key: &str, + lookup: impl FnMut(&str) -> Option, +) -> RuntimeVariables { + let resolved = manifest.environment_for(adapter_key); + collect_worker_variables(&resolved.variables, lookup) +} + /// Entry point for a Cloudflare Workers application. /// /// Portable store config is baked into `A` by the `app!` macro; adapter-specific @@ -167,11 +233,30 @@ pub async fn run_app( env: Env, ctx: Context, ) -> Result { + run_app_with_options::(req, env, ctx, WorkerRuntimeOptions::CLOUDFLARE).await +} + +/// Entry point for an application using the shared Worker transport under another adapter id. +/// +/// # Errors +/// Returns [`worker::Error`] if application configuration, secret policy, or dispatch fails. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[inline] +pub async fn run_app_with_options( + req: Request, + env: Env, + ctx: Context, + options: WorkerRuntimeOptions, +) -> Result { + validate_secret_policy(A::stores().secrets, options.secret_policy) + .map_err(|message| WorkerError::RustError(message.to_owned()))?; + // Worker logging remains the existing no-op initializer. The manifest's + // adapter-specific Hooks::logging_for contract has no Worker logger backend. if !A::owns_logging() { drop(init_logger()); } - let app = build_app_for_dispatch::()?; - dispatch_app::(&app, req, env, ctx).await + let app = build_app_for_dispatch::(options)?; + dispatch_app_with_options::(&app, req, env, ctx, options).await } /// Dispatch a caller-owned app through canonical admission and response egress. @@ -189,14 +274,33 @@ pub async fn dispatch_app( req: Request, env: Env, ctx: Context, +) -> Result { + dispatch_app_with_options::(app, req, env, ctx, WorkerRuntimeOptions::CLOUDFLARE).await +} + +/// Dispatch with an explicit adapter identity, platform metadata, secret policy, and config +/// stream reader. +/// +/// # Errors +/// Returns a Worker error if bindings cannot be opened, the manifest is invalid, or dispatch +/// fails. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[inline] +pub async fn dispatch_app_with_options( + app: &App, + req: Request, + env: Env, + ctx: Context, + options: WorkerRuntimeOptions, ) -> Result { let stores = A::stores(); + validate_secret_policy(stores.secrets, options.secret_policy) + .map_err(|message| WorkerError::RustError(message.to_owned()))?; let env_config = env_config_from_worker(&env, stores); let runtime_variables = match A::manifest() { BakedManifest::Absent => RuntimeVariables::default(), BakedManifest::Present(manifest) => { - let resolved = manifest.environment_for("cloudflare"); - collect_worker_variables(&resolved.variables, |key| { + manifest_runtime_variables(manifest, options.adapter_key, |key| { env.var(key).ok().map(|value| value.to_string()) }) } @@ -213,10 +317,11 @@ pub async fn dispatch_app( ctx, request::RegistryInputs { config_meta: stores.config, - kv_meta: stores.kv, - secret_meta: stores.secrets, + config_read_mode: options.config_read_mode, env_config: &env_config, + kv_meta: stores.kv, runtime_variables, + secret_meta: stores.secrets, }, ) .await @@ -224,8 +329,9 @@ pub async fn dispatch_app( #[cfg(test)] mod platform_tests { - use edgezero_core::app::{App, Hooks}; + use edgezero_core::app::{App, Hooks, StoreMetadata}; use edgezero_core::error::EdgeError; + use edgezero_core::manifest::ManifestLoader; use edgezero_core::manifest::ResolvedEnvironmentBinding; use edgezero_core::router::RouterService; @@ -249,14 +355,52 @@ mod platform_tests { } } + #[test] + fn cloudflare_runtime_keeps_byob_config_read_mode() { + assert_eq!( + super::WorkerRuntimeOptions::CLOUDFLARE.config_read_mode, + super::ConfigReadMode::Byob + ); + } + #[test] fn application_configuration_receives_cloudflare_platform_metadata() { - let app = super::build_target_app::() - .expect("configured application"); + let app = super::build_target_app::( + super::WorkerRuntimeOptions::CLOUDFLARE, + ) + .expect("configured application"); assert_eq!(app.platform(), crate::CLOUDFLARE_PLATFORM); } + #[test] + fn cloudflare_secret_policy_preserves_worker_secret_support() { + let metadata = StoreMetadata { + default: "secrets", + ids: &["secrets"], + }; + assert_eq!( + super::validate_secret_policy(Some(metadata), super::SecretPolicy::AllowWorkerSecrets,), + Ok(()) + ); + } + + #[test] + fn celld_secret_policy_rejects_declared_secret_stores() { + let metadata = StoreMetadata { + default: "secrets", + ids: &["secrets"], + }; + assert_eq!( + super::validate_secret_policy(Some(metadata), super::SecretPolicy::RejectDeclared,), + Err("celld does not support declared secret stores") + ); + assert_eq!( + super::validate_secret_policy(None, super::SecretPolicy::RejectDeclared), + Ok(()) + ); + } + #[test] fn declared_worker_variables_prefer_binding_then_manifest_default() { let bindings = [ @@ -285,4 +429,27 @@ mod platform_tests { Some("https://default.example") ); } + + #[test] + fn manifest_environment_selection_uses_the_selected_adapter_key() { + let loader = ManifestLoader::load_from_str( + r#" +[[environment.variables]] +name = "API_BASE_URL" +value = "https://cf.example" +adapters = ["cloudflare"] + +[[environment.variables]] +name = "API_BASE_URL" +value = "https://celld.example" +adapters = ["celld"] +"#, + ); + let manifest = loader.manifest(); + + let cloudflare = super::manifest_runtime_variables(manifest, "cloudflare", |_| None); + let celld = super::manifest_runtime_variables(manifest, "celld", |_| None); + assert_eq!(cloudflare.get("API_BASE_URL"), Some("https://cf.example")); + assert_eq!(celld.get("API_BASE_URL"), Some("https://celld.example")); + } } diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index bb58e0ed..eb82b424 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -43,6 +43,7 @@ use worker::{ Context, Delay, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; +use crate::ConfigReadMode; use crate::config_store::CloudflareConfigStore; use crate::context::CloudflareRequestContext; use crate::key_value_store::CloudflareKvStore; @@ -134,7 +135,9 @@ impl<'app> CloudflareService<'app> { ) -> Result { let request_start = self.app.monotonic_now(); let config_store = match self.config { - ConfigSource::Binding(binding) => Some(open_config_handle(&env, &binding)), + ConfigSource::Binding(binding) => { + Some(open_config_handle(&env, &binding, ConfigReadMode::Byob)) + } ConfigSource::Handle(handle) => Some(handle), ConfigSource::None => None, }; @@ -258,6 +261,7 @@ impl<'app> CloudflareService<'app> { /// stays under the `too_many_arguments` ceiling. pub(crate) struct RegistryInputs<'env> { pub config_meta: Option, + pub config_read_mode: ConfigReadMode, pub env_config: &'env EnvConfig, pub kv_meta: Option, pub runtime_variables: RuntimeVariables, @@ -667,7 +671,12 @@ pub(crate) async fn dispatch_with_registries( ) -> Result { let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(&env, inputs.kv_meta, inputs.env_config)?; - let config_registry = build_config_registry(&env, inputs.config_meta, inputs.env_config); + let config_registry = build_config_registry( + &env, + inputs.config_meta, + inputs.env_config, + inputs.config_read_mode, + ); let secret_registry = build_secret_registry(&env, inputs.secret_meta, inputs.env_config); dispatch_with_handles( app, @@ -729,6 +738,7 @@ fn build_config_registry( env: &Env, config_meta: Option, env_config: &EnvConfig, + config_read_mode: ConfigReadMode, ) -> Option { let meta = config_meta?; let mut by_id: BTreeMap = BTreeMap::new(); @@ -737,7 +747,7 @@ fn build_config_registry( by_id.insert( (*id).to_owned(), ConfigStoreBinding { - handle: open_config_handle(env, &binding_name), + handle: open_config_handle(env, &binding_name, config_read_mode), default_key: env_config.store_key("config", id), }, ); @@ -842,8 +852,12 @@ fn into_core_method(method: &Method) -> CoreMethod { }) } -fn open_config_handle(env: &Env, binding_name: &str) -> ConfigStoreHandle { - match CloudflareConfigStore::from_env(env, binding_name) { +fn open_config_handle( + env: &Env, + binding_name: &str, + config_read_mode: ConfigReadMode, +) -> ConfigStoreHandle { + match CloudflareConfigStore::from_env_with_read_mode(env, binding_name, config_read_mode) { Ok(store) => ConfigStoreHandle::new(Arc::new(store)), Err(err) => { warn_missing_config_binding_once(binding_name, &err.to_string()); diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 4fe54f60..dcccefea 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -24,6 +24,7 @@ edgezero-core = { workspace = true } edgezero-adapter = { path = "../edgezero-adapter" } edgezero-adapter-axum = { workspace = true, features = ["cli", "axum"], optional = true } edgezero-adapter-cloudflare = { workspace = true, features = ["cli"], optional = true } +edgezero-adapter-celld = { workspace = true, features = ["cli"], optional = true } edgezero-adapter-fastly = { workspace = true, features = ["cli"], optional = true } edgezero-adapter-spin = { workspace = true, features = ["cli"], optional = true } app-demo-core = { path = "../../examples/app-demo/crates/app-demo-core", package = "app-demo-core", optional = true } @@ -56,6 +57,7 @@ default = [ "edgezero-adapter-axum", "edgezero-adapter-fastly", "edgezero-adapter-cloudflare", + "edgezero-adapter-celld", "edgezero-adapter-spin", ] cli = ["dep:clap", "dep:handlebars"] diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 222eb7a3..e1bb44e7 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -498,11 +498,22 @@ fn render_manifest_section( writeln!(out, "features = [{joined}]")?; } out.push('\n'); - writeln!( - out, - "[adapters.{}.commands]\nbuild = \"{}\"\ndeploy = \"{}\"\nserve = \"{}\"\n", - blueprint.id, build_cmd, deploy_cmd, serve_cmd, - )?; + let commands = [ + ("build", build_cmd), + ("deploy", deploy_cmd), + ("serve", serve_cmd), + ]; + let configured_commands: Vec<_> = commands + .into_iter() + .filter(|(_, command)| !command.is_empty()) + .collect(); + if !configured_commands.is_empty() { + writeln!(out, "[adapters.{}.commands]", blueprint.id)?; + for (action, command) in configured_commands { + writeln!(out, "{action} = \"{command}\"")?; + } + out.push('\n'); + } out.push('\n'); writeln!(out, "[adapters.{}.logging]", blueprint.id)?; @@ -1490,6 +1501,44 @@ mod tests { } } + fn assert_generated_celld_entrypoint(project_dir: &Path) { + let crate_dir = project_dir.join("crates/demo-app-adapter-celld"); + let cargo = fs::read_to_string(crate_dir.join("Cargo.toml")) + .expect("read generated celld Cargo.toml"); + assert!( + cargo.contains("demo-app-core = { path = \"../demo-app-core\" }"), + "generated celld adapter must depend on its app core crate", + ); + let entrypoint = + fs::read_to_string(crate_dir.join("src/lib.rs")).expect("read celld lib.rs"); + assert!( + entrypoint.contains( + "edgezero_adapter_celld::run_app::(req, env, ctx).await" + ), + "generated celld entrypoint must call the shared runtime API", + ); + let raw = + fs::read_to_string(crate_dir.join("wrangler.json")).expect("read celld Wrangler JSON"); + let manifest: serde_json::Value = serde_json::from_str(&raw) + .expect("generated celld Wrangler manifest must be valid JSON"); + assert_eq!(manifest["main"], "build/worker/shim.mjs"); + assert_eq!(manifest["no_bundle"], false); + let app_manifest = fs::read_to_string(project_dir.join("edgezero.toml")) + .expect("read generated app manifest"); + assert!(app_manifest.contains("[adapters.celld.adapter]")); + assert!( + !app_manifest.contains("[adapters.celld.commands]"), + "celld must use registered build/serve dispatch", + ); + let ignore = fs::read_to_string(project_dir.join(".gitignore")).expect("read gitignore"); + for pattern in ["build/", ".celld/", ".dev.vars", ".dev.vars.*"] { + assert!( + ignore.contains(pattern), + "generated gitignore must include {pattern}" + ); + } + } + fn assert_generated_fastly_entrypoint(project_dir: &Path) { let entrypoint = fs::read_to_string(project_dir.join("crates/demo-app-adapter-fastly/src/main.rs")) @@ -1690,6 +1739,7 @@ mod tests { assert_generated_route_and_provider_manifests(project_dir); assert_generated_axum_entrypoint(project_dir); assert_generated_cloudflare_entrypoint(project_dir); + assert_generated_celld_entrypoint(project_dir); assert_generated_fastly_entrypoint(project_dir); assert_generated_spin_entrypoint(project_dir); } diff --git a/crates/edgezero-cli/src/manifest_source.rs b/crates/edgezero-cli/src/manifest_source.rs index cd4f4fb1..959083af 100644 --- a/crates/edgezero-cli/src/manifest_source.rs +++ b/crates/edgezero-cli/src/manifest_source.rs @@ -420,7 +420,7 @@ pub(crate) fn resolve_runtime_from( #[cfg(test)] mod tests { - use std::ffi::OsString; + use std::ffi::{OsStr, OsString}; use tempfile::TempDir; @@ -467,6 +467,29 @@ mod tests { assert!(matches!(runtime.target(), ResolvedAdapterTarget::Shell(_))); } + #[test] + fn celld_manifest_without_command_overrides_uses_registered_dispatch() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path().join("celld-app"); + write_manifest(&root, "celld", "wrangler.json", None); + + let runtime = resolve_runtime_from( + "celld", + Action::Serve, + &root, + Some(OsString::from("edgezero.toml")), + ) + .expect("resolve celld runtime"); + + let ResolvedAdapterTarget::Registered(target) = runtime.target() else { + panic!("celld should use registered dispatch, not a shell override"); + }; + assert_eq!( + target.platform_manifest().and_then(Path::file_name), + Some(OsStr::new("wrangler.json")) + ); + } + #[test] fn runtime_resolver_rejects_cross_app_pair() { let temp = TempDir::new().expect("temp dir"); diff --git a/crates/edgezero-cli/src/templates/root/gitignore.hbs b/crates/edgezero-cli/src/templates/root/gitignore.hbs index 55704710..b07f594b 100644 --- a/crates/edgezero-cli/src/templates/root/gitignore.hbs +++ b/crates/edgezero-cli/src/templates/root/gitignore.hbs @@ -2,17 +2,23 @@ bin/ pkg/ target/ +build/ # local emulator / runtime state -- each adapter's local KV / config # store lives under one of these directories: # - Cloudflare local KV -> .wrangler/state/v3/kv/*/db.sqlite # - Spin local KV -> .spin/sqlite_key_value.db # - Axum local config -> .edgezero/local-config-.json -# All three are populated by `config push --local` (or by the -# runtime itself on first read) and should not be committed. +# - celld local state -> .celld/dev +# All local runtime / config state should stay out of generated projects. .wrangler/ .spin/ .edgezero/ +.celld/ + +# celld / Wrangler local variables may contain plaintext demo values. +.dev.vars +.dev.vars.* # Fastly local config-push lock and staging temp files (sidecars next to the # manifest; the lock serialises concurrent `config push --local` / `provision`, diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 3fe47e5e..da3cb94a 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -398,9 +398,13 @@ pub trait ConfigStore: Send + Sync { /// Retrieves one value under an absolute deadline and independent backend/value caps. /// - /// Implementations must define their deadline and allocation behavior explicitly. There is - /// no compatibility default because awaiting an unbounded provider call can hide a - /// never-ready future. + /// Implementations must define where each byte cap and deadline is enforced. In particular, + /// the byte caps constrain backend bytes and the value returned to Rust; they do not by + /// themselves guarantee that a provider or host did not allocate an oversized chunk before + /// the adapter received it. Implementations must reject an oversized chunk before copying it + /// into Rust-owned storage, and adapters without a provider-side allocation guarantee must + /// leave allocation-bound capabilities unsupported. There is no compatibility default because + /// awaiting an unbounded provider call can hide a never-ready future. async fn get_bounded( &self, key: &str, diff --git a/docs/superpowers/plans/2026-10-13-celld-adapter.md b/docs/superpowers/plans/2026-10-13-celld-adapter.md new file mode 100644 index 00000000..48dc0b08 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-celld-adapter.md @@ -0,0 +1,218 @@ +--- +created: 2026-10-13 13:34 +status: done +--- + +# Celld adapter implementation plan + +## Delivery order + +First prove a generated Worker WASM can enter EdgeZero through celld. Then wire Mocktioneer's real routes and typed config. Do not spend time on deployment or secret publication while the local application path is unproven. + +This plan implements [[Celld Adapter Specification]]. It uses the two isolated worktrees created for this request. The EdgeZero checkout starts at current draft PR275 head `28038cce`; Mocktioneer starts at `cb6f238`. Original worktrees are not implementation targets. + +```mermaid +flowchart TD + Prep[Inspect APIs and prepare isolated worktrees] --> Spec[Write specification and implementation plan] + Spec --> Runtime[Add shared Worker runner options and celld wrapper] + Spec --> Consumer[Prepare Mocktioneer consumer and local fixture tooling] + Runtime --> CLI[Register celld scaffold and build/serve CLI] + CLI --> Fixture[Generate and run a real EdgeZero consumer] + Runtime --> ConsumerBuild[Build Mocktioneer WASM] + Consumer --> ConsumerBuild + Fixture --> Smoke[Run localhost HTTP acceptance] + ConsumerBuild --> Smoke + Smoke --> Audit[Focused regressions and handoff evidence] +``` + +## Ownership + +The coordinator owns specification, plan, tool preparation, cross-repository consistency, runtime acceptance, review of the combined result, and the final run record. + +The EdgeZero implementation worker owns only `/home/pav/worktrees/feat-celld-adapter` code, tests, templates, and CLI integration. It does not edit the vault or Mocktioneer. + +The Mocktioneer implementation worker owns only `/home/pav/worktrees/feat-celld-demo` code, tests, manifests, demo fixture generator/wrapper, and local run documentation. It does not edit EdgeZero or the vault. + +Workers report touched files, commands, failures, and unverified behavior. They do not commit, push, install global tooling, start cloud work, or rewrite other worktrees. The coordinator retains responsibility for accepting the implementation. + +## 1. Worktree and tool preparation + +Completed preparation: + +- Create `feat/celld-adapter` through `gwa --no-switch`, based on the fetched #275 branch. +- Create `feat/celld-demo` through the same helper in Mocktioneer. +- Read local repository instructions and record original dirty/untracked state. +- Inspect existing Worker runtime, manifest/route selection, scaffolds, BlobEnvelope, and actual Mocktioneer config-consuming handlers. +- Read-only workers produced reports in `/tmp`; no application behavior has been proved by that research. +- Verify ports 9876 and 9877 are free before first launch. Recheck immediately before launch. + +Tool setup is scoped to `~/.cache/edgezero-celld-demo`. Download celld v0.6.2's Linux release, check SHA-256 `ce0ee3552cc8d55dde3998dc3b1b0c8f547bbcb4bc803d2a94842368b047646c`, and verify GitHub provenance if available. Install esbuild 0.25.12 and worker-build 0.8.5 in that cache, using the configured Rust toolchain. Keep its `bin` on PATH only in demo/build commands. Capture tool versions and installation output. + +Do not run a fleet bootstrap, bucket operation, account login, or deployment. A missing prerequisite should produce a documented local setup error. + +## 2. Shared Worker runner and celld identity + +Files expected: + +- `crates/edgezero-adapter-cloudflare/src/lib.rs` +- `crates/edgezero-adapter-celld/Cargo.toml` +- `crates/edgezero-adapter-celld/src/lib.rs` +- Root workspace/dependency entries +- Narrow core adapter-name declaration if useful + +Steps: + +1. Add a runner/options seam carrying adapter key, platform metadata, secret policy, and explicit config reader mode. +2. Preserve existing Cloudflare runner APIs as wrappers with unchanged defaults. +3. Thread adapter selection through app build, environment lookup, stores, and dispatch. Preserve the existing no-op Worker logger; an application-owned logging backend is outside this demo. Existing route adapter lists remain metadata; the current macro registers all handlers. Do not introduce a broad route-filtering change. +4. Add the celld wrapper and its own metadata. +5. Block secret-store activation under celld. Plain variables are never a secret backend. +6. Keep Worker request/context ownership, response egress, ingress admission, and cancellation paths in the existing transport implementation. +7. Add tests for celld metadata, runtime key selection, and secret policy. Retain Cloudflare regression tests. + +Exact seam naming is left to the implementer. A small options value is enough; do not add a dynamic plugin system or copy transport files. + +Exit condition: the celld crate passes focused host checks and a thin fetch event consumer compiles for wasm. Any inability to load a generated workers-rs shim in celld stops broader CLI work until resolved. + +## 3. Celld CLI and scaffold + +Files expected: + +- `crates/edgezero-adapter-celld/src/cli.rs` and templates +- `crates/edgezero-cli/Cargo.toml` registration/features +- Scaffold integration tests and any manifest default mapping actually needed + +Steps: + +1. Register ID `celld`, unique template names, runtime feature, and scaffold crate. +2. Emit JSON `wrangler.json`, generated shim path, Worker event entrypoint, and config/KV binding definitions from existing manifest contracts. +3. Implement only build and serve using the normal execution-target contract. +4. Invoke worker-build in the correct adapter crate. Preserve child-tool failures and artifact path. +5. Start `celld dev` against the chosen manifest. Force or validate loopback binding and reject public host arguments. +6. Return unsupported errors for auth, deploy, staging/release, provision, secret publication, and remote config operations. +7. Document prerequisites and beta/local-only scope in generated docs. +8. Test valid JSON, dependency/features, manifest key selection, correct command construction, public-bind rejection, and unsupported actions without cloud processes. + +Exit condition: `edgezero new` or the repository's corresponding generator creates a celld consumer that builds and answers over localhost. The coordinator should inspect emitted source and manifests, not only a string-render test. + +Prefer a temporary generated consumer outside either original checkout. Ignore all build output and local state. A fresh generated project has no seeded config; choose a config-free route for the first HTTP proof. + +## 4. Mocktioneer consumer and dependency migration + +Files expected: + +- Root `Cargo.toml` and lockfile +- `crates/mocktioneer-adapter-celld/Cargo.toml`, `src/lib.rs`, and JSON manifests +- `edgezero.toml` celld adapter and route allowlists +- A small local-only fixture generator and JS wrapper +- Local run notes/script and ignore rules + +Steps: + +1. Patch every relevant EdgeZero git package to the local EdgeZero worktree. Keep exactly one core crate identity. +2. Add the sibling adapter crate with a fetch entrypoint calling `edgezero_adapter_celld::run_app::`. +3. Add celld to existing route allowlists and config-store metadata without changing route implementations. +4. Repair only actual #275 API incompatibilities discovered by compiler/tests. The first build found removed request access, app build, and legacy proxy APIs. Migrate the real JWKS fetch while preserving status and 64 KiB body bounds; unsigned smoke tests will not certify signed verification. +5. Emit a normal no-seed manifest pointing directly at the generated shim. +6. Add a separately selected local-demo manifest wrapping the same shim for explicit fixture initialization. +7. Generate a real envelope from the existing Mocktioneer type and loader with demo CPM 0.37. Write ignored plaintext dev setup containing public config only. +8. Use the real Worker KV binding to write the configured key before dispatch. No public setup endpoint, no database editing, no app fallback. +9. Test the wrapper disabled path, real key/value write, concurrent first requests, and seed failure behavior. +10. Explain that this is development setup, not a celld config-push implementation or secret delivery system. + +Use normal Worker/WASM entrypoint code. Do not start the Axum binary. The wrapper must import the worker-build output, not replace it with handwritten route output. + +Exit condition: the crate compiles to WASM against the single local EdgeZero revision; host core tests retain deterministic bidding and fail-loud config semantics. + +## 5. Local runtime acceptance + +The coordinator starts processes in visible, clearly named panes, or another recorded local process group. Check ports first. Use separate local state directories if negative and positive tests need independent config state. Do not delete an unrelated `.celld` directory. + +Suggested order: + +1. Generated EdgeZero fixture on port 9877. GET its config-free endpoint, assert status/body, then stop it after evidence capture. +2. Mocktioneer normal no-seed manifest on port 9876 with dedicated fresh local state. GET a config-free route and introspection. POST a valid OpenRTB request and assert the expected missing-config failure. +3. Stop that process and switch explicitly to local-demo manifest or a distinct state directory, without relying on stale values. +4. Start the seeded demo on 9876. Assert the same route returns a bid at 0.37 and preserves auction/impression IDs and deterministic seat/bid fields. +5. Check malformed input returns the existing application error. Exercise APS if it is straightforward and record the actual result. +6. If a development KV fixture is included, verify write/read/list/delete through celld, not an in-memory host mock. +7. Keep the final seeded Mocktioneer demo running for the user, with URL and a stop command/process-pane reference. + +A fixture generator or Node unit test is not the HTTP proof. Capture actual curl/Python HTTP results, status/body assertions, and celld logs. No performance claims are allowed. + +## 6. Focused verification and review + +Run commands from each new worktree. Set PATH to the demo tools explicitly where needed. + +EdgeZero initial checks: + +```bash +cargo test -p edgezero-adapter-cloudflare +cargo test -p edgezero-adapter-celld --features cli +cargo test -p edgezero-cli +cargo fmt --all -- --check +cargo clippy -p edgezero-adapter-celld --features cli --all-targets -- -D warnings +``` + +Use exact feature selections the workspace requires; record changes to these commands. Test the shared runtime on wasm as well as host CLI because host-only cfg can hide Worker compile failures. Preserve existing Cloudflare behavior with its relevant tests. A workspace-wide check is useful if affordable, but should not repeatedly replace focused checks during iteration. + +Mocktioneer initial checks: + +```bash +cargo test -p mocktioneer-core +cargo fmt --all -- --check +cargo clippy -p mocktioneer-core -p mocktioneer-adapter-celld --all-targets -- -D warnings +``` + +Also run the actual worker-build command for the celld application and tests for the demo wrapper. Inspect `cargo tree -d` or equivalent metadata for duplicate EdgeZero core sources. If workspace tests/clippy fail because of an existing incompatible adapter API, distinguish that failure from the celld demo and decide whether the minimal migration belongs in scope. + +Review the combined diff for accidental Cloudflare selection, plaintext secrets, unconditional config fallback, unsupported remote actions, invalid JSON templates, public listener options, and generated/state artifacts accidentally tracked. + +Check both original checkouts after implementation and compare their status with the recorded initial state. Do not clean them. + +## 7. Handoff and unresolved work + +Create a dated work log under Trusted Server with: + +- Worktree/branch paths and prerequisite base +- Files and major behavior changed +- Installed local tools and integrity/provenance outcomes +- Exact build/run/setup commands +- Checks and outcomes, including failed checks +- Missing-config versus seeded-auction results and CPM evidence +- Running demo URL, pane/PID, state location, and stop/restart instructions +- Unsupported secrets, remote deployment/config publication, geographic metadata, stronger identity stores, and unverified HTTP/fleet acceptance + +Update this plan with actual completed steps and deviations after evidence exists. Do not mark it done solely because source was written. The proposal stays a broader draft; a local demo does not approve production adoption. + +No PR or package publishing is part of this handoff. + +## Completed local delivery + +All seven local implementation stages completed on 2026-10-13. The proposal remains draft; production adoption is not approved. + +- The shared `WorkerRuntimeOptions` selects celld identity, unknown provider facts, rejected secret declarations, and an explicit default-stream reader. Cloudflare keeps BYOB. +- The registered CLI builds and serves a fresh generated project. Empty command overrides are omitted, JSON uses default bundling, and generated Cargo includes the app-core dependency. Build/state/dev-variable outputs are ignored. +- Mocktioneer uses one local EdgeZero core source. Required #275 migrations preserve fallible app assembly, request method/URI access, and the JWKS response's 64 KiB bound. +- Final real WASM builds and artifact staging passed. The separate development wrapper awaits real KV publication of a verified 151-byte envelope before invoking the generated Worker class. It adds no public seed endpoint. +- Fresh generated-consumer acceptance passed five HTTP checks, including celld variable selection, KV CRUD/list, bounded config and expired-deadline rejection, ingress admission, and streaming. +- The final clean normal Mocktioneer run passed five HTTP checks. The final seeded run passed nine, including CPM 0.37 OpenRTB/APS responses, config introspection, malformed JSON 400, and validation 422. +- Focused host tests, the added Node-executed WASM cap regression, final CLI suite, formatting, diff checks, Clippy, adapter test compilation, and WASM checks passed. The dated work log records exact commands and inherited ignored coverage. + +### Implementation deviations + +The first seeded auction failed because celld's BYOB KV reader returned zero bytes for valid stored data. The fix adds an explicit `ConfigReadMode::DefaultStream`, not a text-materialization fallback. Host chunk allocation remains unsupported even though copies into Rust obey byte limits. + +Celld rejected an out-of-project main path. Preparation now copies the genuine build tree beneath each normal/demo project. The generated Worker export is a class, so the wrapper constructs it with context/environment instead of calling `worker.fetch` as an object method. + +An actual generated build found a missing app-core dependency. The source template now includes it, a generated-Cargo regression checks it, and acceptance used a newly generated project after the fix. + +The generic EdgeZero CLI has no port-forwarding option for serve. Acceptance therefore used port 9876 serially rather than 9877 for the generated fixture. All temporary listeners stopped before the final Mocktioneer restart. Final ownership is terminal 28, celld child PID 3801536, URL `http://127.0.0.1:9876`. + +See [[2026-10-13 - Celld Adapter Local Demo]] for replay, stop/restart instructions, acceptance artifacts, and unverified production work. + +## Related + +- Spec: [[Celld Adapter Specification]] +- Proposal: [[Celld Store Provider Evaluation]] +- Project: [[Trusted Server]] diff --git a/docs/superpowers/specs/2026-10-13-celld-adapter.md b/docs/superpowers/specs/2026-10-13-celld-adapter.md new file mode 100644 index 00000000..c1a620a5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-13-celld-adapter.md @@ -0,0 +1,212 @@ +--- +created: 2026-10-13 13:34 +status: done +--- + +# Celld adapter specification + +## Decision and delivery target + +Add `edgezero-adapter-celld` as an experimental sibling runtime adapter. It runs EdgeZero applications as Rust WASM plus the workers-rs JavaScript shim inside celld. Axum is not in this request path. The first deliverable is a working localhost demo, including Mocktioneer's real OpenRTB route and typed application config. + +The user authorized separate worktrees, a detailed specification and plan, implementation, and a local Mocktioneer run. This does not authorize a fleet, bucket provisioning, deployment, public listener, cloud credentials, GitHub issue creation, or publishing packages. + +The larger adapter proposal remains the research record. This specification deliberately limits implementation to local build and serve. Missing secrets and remote operational features are acceptable if they are explicit and fail instead of pretending to work. + +## Worktrees and prerequisites + +| Repository | Worktree | Branch | Starting commit | +|---|---|---|---| +| EdgeZero | `/home/pav/worktrees/feat-celld-adapter` | `feat/celld-adapter` | `28038cce26a499499fd4e050387d4d02e35fa7c7` | +| Mocktioneer | `/home/pav/worktrees/feat-celld-demo` | `feat/celld-demo` | `cb6f2389d1366756f0aa14a936861ef77381613b` | +| celld reference | `/tmp/edgezero-celld-research` | pinned research checkout | `90b43017241f81189453d326d05948f388b34652` | + +The EdgeZero branch starts at the current draft #275 HTTP-contract branch, not the older main HTTP API. Adoption depends on that prerequisite or an equivalent merged contract. This work does not resolve #275 or any Axum production-readiness issue. + +Original checkouts and their existing untracked native-store documents remain untouched. No commits or pushes are required for the demo. The new EdgeZero branch has no upstream so it cannot accidentally update the prerequisite branch. + +## Success criteria + +A completed local delivery has all of these: + +1. A separately named celld adapter with celld platform metadata and manifest selection. +2. Existing Cloudflare runtime APIs retain their behavior. +3. Rust WASM compiles through `worker-build`; celld loads the generated shim and wasm asset. +4. EdgeZero's CLI registers celld, scaffolds a runnable consumer, and supports local build and serve. +5. A generated consumer answers through celld on loopback. +6. Mocktioneer runs through `edgezero_adapter_celld::run_app::`, not an Axum proxy or an imitation of its handlers. +7. A real OpenRTB request returns the fixture CPM and stable input-related bid fields from a valid config-store BlobEnvelope. Generated bid IDs remain UUIDs. +8. Missing config fails through the existing typed-config error path. +9. Secrets and unsupported CLI operations fail clearly. No plaintext variable is represented as a secret store. +10. A run record lists checks, observed outputs, remaining gaps, replay commands, and process ownership. + +Do not infer production support, fleet durability, parity with Cloudflare, or performance from these checks. + +## Architecture and boundaries + +```mermaid +flowchart TD + Client[Local HTTP client] --> Celld[celld dev on loopback] + Celld --> Shim[workers-rs generated JavaScript shim] + Shim --> Entrypoint[Application celld WASM entrypoint] + Entrypoint --> Adapter[edgezero-adapter-celld run_app] + Adapter --> Shared[Shared Worker transport and dispatch] + Shared --> Core[EdgeZero app lifecycle and router] + Core --> App[Portable application handlers] + Shared --> KV[celld local KV binding] + KV --> Config[BlobEnvelope config read] +``` + +Reuse the existing Cloudflare Workers transport behind a narrow runner API. Do not copy its ingress, response, outbound, compression, or store modules into a new adapter. The first implementation may depend on the Cloudflare adapter crate's Worker runtime feature. This is code reuse, not Cloudflare registration or use of Wrangler services. + +The shared runner needs explicit inputs for the manifest adapter key, `PlatformMetadata`, secret policy, and config reader mode. A small options type or corresponding functions are acceptable. Preserve the existing Cloudflare `run_app` and dispatch APIs as wrappers with the original defaults. + +The celld wrapper must select `celld` for environment and store bindings. The existing Worker adapter installs no Rust `log` backend; preserve that behavior. Manifest logging declarations remain available to an application-owned backend, but adapter-level logging is not implemented by this experiment. The current app macro registers all declared route handlers; trigger adapter lists are metadata, not runtime isolation. Preserve that existing behavior and add celld to the intended declarations. Runtime-enforced route filtering is outside this demo. App assembly owns platform identity before configure hooks execute. Do not build an app in the consumer, replace its platform after construction, or bypass the accepted ingress/response lifecycle. + +Do not add a generic plugin framework or extract a broad shared runtime crate unless compilation proves the small seam insufficient. + +## Runtime identity and capabilities + +The adapter ID is `celld`. The public consumer entrypoint is: + +```rust +edgezero_adapter_celld::run_app::(req, env, ctx).await +``` + +The exact Cargo feature may be `celld`, matching other adapter runtime features. Native CLI support must remain independently feature-gated so host tooling does not compile Worker-only runtime code. + +Celld metadata must use its own platform name and vendor facts. Do not copy Cloudflare's 128 MB memory claim or assign geography, colo, client TLS metadata, a managed secret service, or autoscaling guarantees. Unknown facts stay unknown. The celld request context may expose a documented Worker-compatible context alias for this first release, but that must not change core platform identity. + +A conservative capability declaration is better than an inherited Cloudflare declaration. Advertise only facts exercised or justified by the Worker runtime contract. Raw framing, header-byte admission, strict allocation/resource accounting, and platform guarantees not available at the Worker API remain unsupported. Deadline/cancellation/egress claims remain best-effort where that is what the underlying implementation promises. If initial runtime evidence does not justify a capability, leave it unsupported or document the limitation. + +Outbound Fetch transport may be reused. Mocktioneer's signature verification can fetch Trusted Server JWKS through its older proxy API, so the consumer must migrate that real code to the new `HttpClient` API. The earlier source-only investigation missed this call by searching newer outbound names. The unsigned auction demo does not exercise JWKS retrieval or prove signed verification. Do not add invented application traffic. A local fixture can exercise a narrow outbound path separately if time permits; that is not broad transport acceptance. + +## Stores + +### Ordinary KV + +Use celld's public Worker KV binding through the existing workers-rs API and EdgeZero `KvHandle`. Binding names and logical IDs must follow the manifest's existing store-resolution contract. + +No namespace is an implied sharded database. A namespace remains one cell/writer in the research snapshot. The demo establishes only tested local operations. Celld local SQLite storage does not establish object-bucket durability or fleet semantics. Values beyond local celld's documented size support remain an explicit limitation, not an adapter success claim. + +EdgeZero's existing 60-second minimum relative TTL applies. Do not modify expiry interpretation based on the celld documentation discrepancy. Absolute-expiration findings in the research report came from a helper test, not fleet acceptance. + +### Typed config + +Reuse the existing KV-backed config read path. Typed application config remains a serialized `BlobEnvelope` containing `data`, `generated_at`, `sha256`, and `version`. The normal extractor verifies the envelope, enforces bounded reads, deserializes the application type, and validates it. + +A missing binding, absent key, bad digest, unsupported envelope version, or invalid application config must retain the existing explicit error behavior. Do not replace the normal extractor with embedded defaults or environment JSON. + +Local publication is a separate demo setup concern. Celld v0.6.2 has no established local KV seed CLI in the inspected documentation. Remote `celld kv bulk put`, Wrangler local state, and Axum local config files are not substitutes. + +The allowed demo seeder is an explicitly selected local-development JavaScript wrapper around the generated worker shim. It writes a validated fixture into the real celld KV binding before forwarding to the actual WASM entrypoint. It is not part of the adapter or portable handlers. Requirements: + +- Keep the normal `wrangler.json` pointing at the generated worker shim without seeding. +- Put any wrapper in a separately named local-demo manifest and source file. +- Require an explicit local seed variable or mode. No silently embedded fallback. +- Generate a correct envelope through EdgeZero's `BlobEnvelope::new` and Mocktioneer's typed config loader, or another tested equivalent using the same hash rules. +- Store the intended config key through `env..put`; never edit celld's internal database or namespace files. +- Await seeding before the first handler uses config. Share a pending seed operation to avoid concurrent first-request races. +- A failed seed fails the request and remains retryable or reports a persistent setup error; do not suppress it. +- No public seed endpoint or handler is added. +- The demo envelope contains only public CPM data. `.dev.vars` is plaintext and ignored. +- Without the explicit fixture/mode, forward without creating config so the missing-config test exercises the real error path. +- Tests cover disabled setup, correct key/value, concurrent initialization, and failed setup as applicable. + +Whether to retain this wrapper after a real local publication backend exists is deferred. It must not be described as `config push --adapter celld`. + +### Secrets and stronger identity contracts + +Managed secret delivery is out of scope and unsupported. The shared runner must have a celld secret policy that prevents `CloudflareSecretStore` from interpreting ordinary variables as secrets. Conservatively rejecting a manifest that declares secret stores is acceptable. A typed unavailable store is also acceptable if errors retain the right category and do not expose values. + +Do not send secret material to `vars`, `.dev.vars`, fixture files, logs, or generated manifests. There is no secret publication, rotation, versioning, or auditing feature in this deliverable. + +Trusted Server's conditional Add, generations, CAS, strong listing/existence, and synchronous identity backend are not implemented. A working Mocktioneer config binding does not prove that stronger backend. + +### Config reader compatibility + +Local celld v0.6.2 returned a valid 99-byte KV value through text and ordinary stream reads, but its BYOB reader immediately reported end-of-stream with zero bytes. The first seeded WASM auction consequently received an empty config string and failed envelope parsing. This was an observed host compatibility failure, not a bad envelope. + +The shared runner now carries an explicit `ConfigReadMode`. `CELLD_RUNTIME_OPTIONS` selects `DefaultStream` through `RegistryInputs` and the config-store factory. Cloudflare defaults remain `Byob`. There is no retry into an unbounded text read and no silent reader fallback. + +The default reader inspects each JavaScript `Uint8Array` length before copying into Rust. It enforces the smaller backend/value cap, checks the absolute deadline around reads, yields periodically for timers, and requests cancellation when an incomplete reader drops. The outer timer covers acquisition, draining, and conversion. This does not bound a host allocation that happened before chunk delivery or prove finite host teardown. `ConfigReadAllocationBounds` and complete resource accounting remain unsupported. Raw config-store `get` remains explicitly unbounded; these limits apply to `get_bounded` and the typed extractor. + +A Node-executed WASM regression rejected a 151-byte host chunk at 10-byte caps and accepted exactly 10 bytes. Real celld HTTP acceptance separately read 20 bytes, rejected independent backend and value caps of 3 bytes, and rejected an expired deadline. + +## Build, CLI, and generated files + +Register a celld CLI adapter and scaffold blueprint. Use unique template names, crate suffix `adapter-celld`, and dependency `edgezero-adapter-celld`. + +Celld accepts Wrangler-compatible JSON/JSONC, not the existing EdgeZero Wrangler TOML template. Emit `wrangler.json` with the generated shim path and KV binding declarations. Do not add account IDs, Cloudflare-specific migration/auth settings, or remote bucket credentials. Avoid Handlebars escaping that produces invalid JSON. + +Build invokes `worker-build --release` in the selected application adapter crate. Serve builds if needed, then invokes `celld dev` with the selected manifest on loopback. The generated shim and its relative wasm asset remain together. `no_bundle` or bundling options must match actual celld behavior and the generated module layout. + +Use the existing CLI execution-target contract so manifest selection, working directory, forwarded arguments, and errors remain predictable. Do not infer paths from the user's original checkout. Preserve exit failures from child tools. + +Only build and local serve are supported. Auth, deploy, staging, healthcheck/rollback release operations, fleet provisioning, remote config reads/writes, and secret publication must return unsupported errors. Never route them to Wrangler or a fleet command. Do not advertise generic config push success when no celld publication backend exists. + +Forwarded arguments must not accidentally turn local serve into a public listener. Set or validate loopback `--host`; reject public addresses for this experimental local adapter. The local demo uses port 9876 for Mocktioneer, 9877 for the generated fixture if available. Check availability before starting. Do not kill an unrelated process to claim a port. + +Tool prerequisites are explicit. Celld v0.6.2, Worker SDK/build 0.8.5, esbuild, the configured Rust toolchain, and `wasm32-unknown-unknown` are the initial target. User-local tooling is installed in `~/.cache/edgezero-celld-demo/bin`; no system-wide runtime installation is needed. Record actual versions and downloads. Verify celld's release digest and available GitHub artifact attestation. + +Do not run fleet bootstrap or any cloud command. + +## Mocktioneer integration + +Add `mocktioneer-adapter-celld` with a thin Worker fetch event function. It delegates only to the new EdgeZero adapter. Preserve the existing portable routes, auction implementation, typed config, middleware, and other adapter crates. + +The worktree currently pins EdgeZero v0.0.7. Patch all relevant EdgeZero git packages to the single local checkout, or use consistent local path dependencies. Do not mix git and path instances of core/macro/adapter types. A local-only patch section with an explicit sibling-worktree path is acceptable for this development branch. Record that packaging limitation. + +Add celld to intended route adapter lists, adapter environment, config binding metadata, logging, and manifest introspection. Do not claim those lists enforce route isolation. Existing sync handlers use `std::env` for some authorization/domain settings, which is unavailable on this WASM target; this experiment does not certify those routes for production. Any source migration required by #275 should be limited to actual API incompatibilities and keep the same handler behavior. + +The demo should use a CPM visibly different from the historical 0.20 fallback, for example 0.37, to prove typed config drove the bid. Generate the envelope from that value using the normal application config type. Keep this a demo fixture, not a change to the shipped example default. + +## Validation and evidence + +Use focused tests while implementation changes. Reuse passing results when their relevant inputs remain unchanged. Before handoff run the relevant host tests, wasm builds, formatting, and Clippy checks with warnings denied. Report unrelated failures separately rather than repairing other work. + +The runtime smoke test must observe the actual HTTP status and body through celld. At minimum: + +- Generated application GET succeeds through the new adapter. +- Mocktioneer config-free route succeeds. +- Manifest introspection includes the celld adapter declarations. A distinct manifest variable in a generated fixture or focused selection test separately proves the runtime key is celld. +- Missing config returns the expected error, not a successful bid. +- Seeded unsigned OpenRTB request produces the fixture CPM, auction/impression IDs, and expected seat/bid fields. Bid IDs remain UUIDs; deterministic assertions concern prices and input-related fields, not identical full response bytes. +- Malformed auction input takes the application's existing error path. +- Unsupported adapter actions fail without invoking cloud tools. +- Secret-store declaration cannot activate the plaintext Worker secret adapter. + +Check KV write/read/list/delete through the real local binding when a suitable development fixture is included. If not tested, mark ordinary KV runtime behavior unverified beyond the config read/write path. + +The smoke test is not a benchmark. Tail latency, cancellation under load, streaming fidelity, recovery/restart, namespace contention, upgrades, backup/restore, operator isolation, and distributed durable acknowledgements remain unverified. + +## Follow-up gates + +Remote support needs a separately approved design for secure bindings and deployments, real config publication, capability acceptance, recovery and upgrade tests, restore, and representative workload benchmarks. Keep Axum preparedness independent. No issue is closed by this experiment. + +## Evidence used for this specification + +- A pure workers-rs 0.8.5 probe compiled and answered HTTP 200 through celld dev on 2026-10-13. `no_bundle: true` failed because the generated shim re-exports `../index.js`; default bundling succeeded. This proves the ABI/tool path only, not EdgeZero dispatch. worker-build also downloaded its own wasm-bindgen CLI, wasm-opt, and esbuild 0.28.1 under `~/.cache/worker-build`; those helper artifacts were not independently attestation-verified. +- Source inspection of `edgezero-macros/src/app.rs::build_route_tokens` on 2026-10-13 confirmed that trigger adapter lists do not filter runtime registration. This corrected the initial design wording before implementation acceptance. +- EdgeZero PR275 head, especially `edgezero-adapter-cloudflare/src/lib.rs`, `request.rs`, `context.rs`, `cli.rs`, `edgezero-core/src/blob_envelope.rs`, and scaffold/registry interfaces. +- Mocktioneer `Cargo.toml`, `edgezero.toml`, `mocktioneer-core/src/routes.rs`, `config.rs`, and Cloudflare entrypoint. +- celld v0.6.2 `docs/wasm.md`, `docs/cloudflare-compat.md`, `docs/services/kv.md`, `docs/limitations.md`, and `dev.rs`. +- Source-only investigations `/tmp/celld-adapter-investigation.md` and `/tmp/mocktioneer-celld-investigation.md`. These are recommendations, not passing tests. +- `celld dev --help` confirmed loopback default, persistent `.celld/dev`, JSON/project selection, and local variable loading on 2026-10-13. + +## Local delivery outcome + +The local scope passed acceptance on 2026-10-13. The generated consumer used the registered celld CLI build/serve path and returned the celld-scoped variable. KV put/get/exists/list/delete, bounded config caps, an expired deadline, ingress grant consumption, and a three-chunk response passed through celld. + +The final Mocktioneer WASM returned OpenRTB and APS bids at CPM 0.37 using the real KV-backed `BlobEnvelope`. The clean normal manifest returned 503 `config_out_of_date` and config introspection 404. The seeded manifest returned config 0.37, malformed JSON 400, and semantic validation 422. UUID bid IDs were not treated as deterministic output. + +Generated manifests omit all-empty command overrides so registered adapter validation is not bypassed. A real build exposed a missing generated app-core dependency; the template and regression assertion were fixed, and a fresh project built and served successfully. Mocktioneer stages the genuine shim/WASM inside each manifest's project directory and invokes the workers-rs default class with `new worker(ctx, env).fetch(request)`. + +The generated fixture and Mocktioneer ran serially on port 9876 because the generic CLI serve command does not expose port forwarding. Only the final seeded Mocktioneer process remains running. Replay commands, process ownership, test results, and remaining gaps are in [[2026-10-13 - Celld Adapter Local Demo]]. + +## Related + +- Proposal: [[Celld Store Provider Evaluation]] +- Plan: [[Celld Adapter Implementation Plan]] +- Context: [[Axum Self-Hosting Dependency Map]] +- Project: [[Trusted Server]]