From 3594dbbce95755170631caab06f961d8c3ad9cec Mon Sep 17 00:00:00 2001 From: Ferran Date: Sat, 8 Aug 2026 01:24:41 +0200 Subject: [PATCH] Move non-deterministic key generation out of catalog --- .gitignore | 3 +- Cargo.lock | 6 +- Cargo.toml | 1 - catalog/berachain/Cargo.toml | 1 - catalog/berachain/lib.rs | 50 ++--- catalog/ethereum/Cargo.toml | 1 - catalog/ethereum/lib.rs | 22 +- catalog/polygon/Cargo.toml | 2 - catalog/polygon/lib.rs | 98 +++++---- catalog/tempo/Cargo.toml | 1 - crates/bbuilder/Cargo.toml | 1 + crates/bbuilder/bin/main.rs | 4 + crates/bbuilder/src/generator.rs | 195 +++++++++++++++++ crates/bbuilder/src/lib.rs | 2 +- crates/runtime-docker-compose/src/runtime.rs | 211 ++++++++++++------- crates/spec/src/lib.rs | 202 +++++++++++++----- 16 files changed, 570 insertions(+), 230 deletions(-) create mode 100644 crates/bbuilder/src/generator.rs diff --git a/.gitignore b/.gitignore index 8b0d7ca..e077171 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -/bbuilder \ No newline at end of file +/bbuilder +/composer \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 769d025..49d8da2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -932,6 +932,7 @@ version = "0.1.0" dependencies = [ "catalog", "clap", + "cosmos-keys", "eyre", "runtime-docker-compose", "serde", @@ -1107,7 +1108,6 @@ name = "catalog-berachain" version = "0.1.0" dependencies = [ "eyre", - "getrandom 0.2.16", "serde", "serde_json", "spec", @@ -1120,7 +1120,6 @@ name = "catalog-ethereum" version = "0.1.0" dependencies = [ "eyre", - "getrandom 0.2.16", "serde", "serde_json", "spec", @@ -1132,9 +1131,7 @@ dependencies = [ name = "catalog-polygon" version = "0.1.0" dependencies = [ - "cosmos-keys", "eyre", - "getrandom 0.2.16", "serde", "serde_json", "spec", @@ -1147,7 +1144,6 @@ name = "catalog-tempo" version = "0.1.0" dependencies = [ "eyre", - "getrandom 0.2.16", "serde", "spec", ] diff --git a/Cargo.toml b/Cargo.toml index 08341d0..c7889aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,4 +46,3 @@ reqwest = { version = "0.12", default-features = false } jsonrpsee = { version = "0.24", features = ["server", "ws-client", "http-client", "macros"] } tracing = "0.1" tracing-subscriber = "0.3" -getrandom = { version = "0.2", features = ["js"] } diff --git a/catalog/berachain/Cargo.toml b/catalog/berachain/Cargo.toml index b6f6e07..839bde8 100644 --- a/catalog/berachain/Cargo.toml +++ b/catalog/berachain/Cargo.toml @@ -10,7 +10,6 @@ eyre.workspace = true tinytemplate.workspace = true serde_json.workspace = true template.workspace = true -getrandom.workspace = true [lib] path = "lib.rs" diff --git a/catalog/berachain/lib.rs b/catalog/berachain/lib.rs index 77ef5c4..b7ab1c7 100644 --- a/catalog/berachain/lib.rs +++ b/catalog/berachain/lib.rs @@ -117,26 +117,26 @@ impl ComputeResource for BeaconKit { port: "http".to_string(), }, )) - .artifact(Artifacts::File(spec::File { - name: "genesis".to_string(), - target_path: "/data/genesis.json".to_string(), - content: bera_chain_file(chain_id, "genesis.json"), - })) - .artifact(Artifacts::File(spec::File { - name: "kzg-trusted-setup".to_string(), - target_path: "/data/kzg-trusted-setup.json".to_string(), - content: bera_chain_file(chain_id, "kzg-trusted-setup.json"), - })) - .artifact(Artifacts::File(spec::File { - name: "config".to_string(), - target_path: "/data/config.toml".to_string(), - content: config_file.render().to_string(), - })) - .artifact(Artifacts::File(spec::File { - name: "app".to_string(), - target_path: "/data/app.toml".to_string(), - content: app_file.render().to_string(), - })); + .artifact(Artifacts::File(spec::File::remote( + "genesis", + "/data/genesis.json", + bera_chain_file(chain_id, "genesis.json"), + ))) + .artifact(Artifacts::File(spec::File::remote( + "kzg-trusted-setup", + "/data/kzg-trusted-setup.json", + bera_chain_file(chain_id, "kzg-trusted-setup.json"), + ))) + .artifact(Artifacts::File(spec::File::inline( + "config", + "/data/config.toml", + config_file.render(), + ))) + .artifact(Artifacts::File(spec::File::inline( + "app", + "/data/app.toml", + app_file.render(), + ))); Ok(Pod::default().with_spec("node", node)) } @@ -175,11 +175,11 @@ impl ComputeResource for BeraReth { port: "http".to_string(), }, )) - .artifact(Artifacts::File(spec::File { - name: "eth-genesis".to_string(), - target_path: "/data/eth-genesis.json".to_string(), - content: bera_chain_file(chain_id, "eth-genesis.json"), - })); + .artifact(Artifacts::File(spec::File::remote( + "eth-genesis", + "/data/eth-genesis.json", + bera_chain_file(chain_id, "eth-genesis.json"), + ))); Ok(Pod::default().with_spec("reth", node)) } diff --git a/catalog/ethereum/Cargo.toml b/catalog/ethereum/Cargo.toml index eb18711..a95fc55 100644 --- a/catalog/ethereum/Cargo.toml +++ b/catalog/ethereum/Cargo.toml @@ -10,7 +10,6 @@ eyre.workspace = true tinytemplate.workspace = true serde_json.workspace = true template.workspace = true -getrandom.workspace = true [lib] path = "lib.rs" diff --git a/catalog/ethereum/lib.rs b/catalog/ethereum/lib.rs index 870d6b5..e2b1cb8 100644 --- a/catalog/ethereum/lib.rs +++ b/catalog/ethereum/lib.rs @@ -1,7 +1,7 @@ use serde::Deserialize; use spec::{ - Arg, Artifacts, Babel, ComputeResource, DEFAULT_JWT_TOKEN, Deployment, DeploymentExtension, - Manifest, Pod, Port, Spec, Volume, + Arg, Artifacts, Babel, ComputeResource, Deployment, DeploymentExtension, Manifest, Pod, Port, + Spec, Volume, }; #[derive(Default, Clone)] @@ -104,11 +104,7 @@ impl ComputeResource for Reth { port: "http".to_string(), }, )) - .artifact(Artifacts::File(spec::File { - name: "jwt".to_string(), - target_path: "/data/jwt_secret".to_string(), - content: DEFAULT_JWT_TOKEN.to_string(), - })); + .artifact(Artifacts::File(spec::File::jwt("jwt", "/data/jwt_secret"))); Ok(Pod::default().with_spec("node", node)) } @@ -168,11 +164,7 @@ impl ComputeResource for Lighthouse { port: "http".to_string(), }, )) - .artifact(Artifacts::File(spec::File { - name: "jwt".to_string(), - target_path: "/data/jwt_secret".to_string(), - content: DEFAULT_JWT_TOKEN.to_string(), - })); + .artifact(Artifacts::File(spec::File::jwt("jwt", "/data/jwt_secret"))); Ok(Pod::default().with_spec("node", node)) } @@ -233,11 +225,7 @@ impl ComputeResource for Prysm { port: "http".to_string(), }, )) - .artifact(Artifacts::File(spec::File { - name: "jwt".to_string(), - target_path: "/data/jwt_secret".to_string(), - content: DEFAULT_JWT_TOKEN.to_string(), - })); + .artifact(Artifacts::File(spec::File::jwt("jwt", "/data/jwt_secret"))); Ok(Pod::default().with_spec("node", node)) } diff --git a/catalog/polygon/Cargo.toml b/catalog/polygon/Cargo.toml index 67c842a..0440121 100644 --- a/catalog/polygon/Cargo.toml +++ b/catalog/polygon/Cargo.toml @@ -10,8 +10,6 @@ eyre.workspace = true tinytemplate.workspace = true serde_json.workspace = true template.workspace = true -cosmos-keys.workspace = true -getrandom.workspace = true [lib] path = "lib.rs" diff --git a/catalog/polygon/lib.rs b/catalog/polygon/lib.rs index f66a4e4..0162ec4 100644 --- a/catalog/polygon/lib.rs +++ b/catalog/polygon/lib.rs @@ -1,8 +1,7 @@ -use cosmos_keys::{generate_cometbft_key, generate_tendermint_key}; use serde::{Deserialize, Serialize}; use spec::{ - Arg, Artifacts, Babel, ComputeResource, Deployment, DeploymentExtension, Manifest, Pod, Spec, - Volume, + Arg, Artifacts, Babel, ComputeResource, Deployment, DeploymentExtension, Generated, Manifest, + Pod, Spec, Volume, }; use template::Template; @@ -48,9 +47,6 @@ impl ComputeResource for Heimdall { chain: chain.cosmos_chain_id().to_string(), }; - let keys = generate_tendermint_key().serialize()?; - let val_keys = generate_cometbft_key().serialize()?; - let val_keys_state = "{ \"height\": \"0\", \"round\": 0, @@ -81,41 +77,41 @@ impl ComputeResource for Heimdall { port: "http".to_string(), }, )) - .artifact(Artifacts::File(spec::File{ - name: "genesis".to_string(), - target_path: "/data/heimdall/config/genesis.json".to_string(), - content: "https://storage.googleapis.com/amoy-heimdallv2-genesis/migrated_dump-genesis.json".to_string(), - })) - .artifact(Artifacts::File(spec::File{ - name: "client.toml".to_string(), - target_path: "/data/heimdall/config/client.toml".to_string(), - content: client_config.render().to_string(), - })) - .artifact(Artifacts::File(spec::File{ - name: "app.toml".to_string(), - target_path: "/data/heimdall/config/app.toml".to_string(), - content: app_config.to_string(), - })) - .artifact(Artifacts::File(spec::File{ - name: "config.toml".to_string(), - target_path: "/data/heimdall/config/config.toml".to_string(), - content: config_config.to_string(), - })) - .artifact(Artifacts::File(spec::File{ - name: "node_key.json".to_string(), - target_path: "/data/heimdall/config/node_key.json".to_string(), - content: keys, - })) - .artifact(Artifacts::File(spec::File{ - name: "priv_validator_key.json".to_string(), - target_path: "/data/heimdall/config/priv_validator_key.json".to_string(), - content: val_keys, - })) - .artifact(Artifacts::File(spec::File{ - name: "priv_validator_state.json".to_string(), - target_path: "/data/heimdall/data/priv_validator_state.json".to_string(), - content: val_keys_state.to_string(), - })); + .artifact(Artifacts::File(spec::File::remote( + "genesis", + "/data/heimdall/config/genesis.json", + "https://storage.googleapis.com/amoy-heimdallv2-genesis/migrated_dump-genesis.json", + ))) + .artifact(Artifacts::File(spec::File::inline( + "client.toml", + "/data/heimdall/config/client.toml", + client_config.render(), + ))) + .artifact(Artifacts::File(spec::File::inline( + "app.toml", + "/data/heimdall/config/app.toml", + app_config, + ))) + .artifact(Artifacts::File(spec::File::inline( + "config.toml", + "/data/heimdall/config/config.toml", + config_config, + ))) + .artifact(Artifacts::File(spec::File::generated( + "node_key.json", + "/data/heimdall/config/node_key.json", + Generated::Ed25519TendermintNodeKey, + ))) + .artifact(Artifacts::File(spec::File::generated( + "priv_validator_key.json", + "/data/heimdall/config/priv_validator_key.json", + Generated::Secp256k1CometBftValidatorKey, + ))) + .artifact(Artifacts::File(spec::File::inline( + "priv_validator_state.json", + "/data/heimdall/data/priv_validator_state.json", + val_keys_state, + ))); Ok(Pod::default().with_spec("node", node)) } @@ -161,16 +157,16 @@ impl ComputeResource for Bor { }) .arg("server") .arg2("--config", "/data/config.toml") - .artifact(Artifacts::File(spec::File { - name: "config".to_string(), - target_path: "/data/config.toml".to_string(), - content: config.render(), - })) - .artifact(Artifacts::File(spec::File { - name: "genesis.json".to_string(), - target_path: "/data/genesis.json".to_string(), - content: bor_genesis(chain), - })); + .artifact(Artifacts::File(spec::File::inline( + "config", + "/data/config.toml", + config.render(), + ))) + .artifact(Artifacts::File(spec::File::remote( + "genesis.json", + "/data/genesis.json", + bor_genesis(chain), + ))); Ok(Pod::default().with_spec("bor", node)) } diff --git a/catalog/tempo/Cargo.toml b/catalog/tempo/Cargo.toml index de1afe2..0f384da 100644 --- a/catalog/tempo/Cargo.toml +++ b/catalog/tempo/Cargo.toml @@ -7,7 +7,6 @@ edition = "2024" serde.workspace = true spec.workspace = true eyre.workspace = true -getrandom.workspace = true [lib] path = "lib.rs" diff --git a/crates/bbuilder/Cargo.toml b/crates/bbuilder/Cargo.toml index c9a19b2..b9dc6da 100644 --- a/crates/bbuilder/Cargo.toml +++ b/crates/bbuilder/Cargo.toml @@ -11,6 +11,7 @@ eyre.workspace = true serde_json.workspace = true serde.workspace = true catalog.workspace = true +cosmos-keys.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/bbuilder/bin/main.rs b/crates/bbuilder/bin/main.rs index 8915167..f75689a 100644 --- a/crates/bbuilder/bin/main.rs +++ b/crates/bbuilder/bin/main.rs @@ -73,6 +73,10 @@ async fn run_command( let manifest_path = manifest_dir.join("manifest.json"); fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?; + // Mint secrets and inline them, so runtimes only ever see content or a URL + let secrets_dir = std::path::Path::new(&config_folder).join("secrets"); + let manifest = bbuilder::generator::generate(manifest, &secrets_dir)?; + // Pass ./bbuilder/docker-runtime to DockerRuntime let docker_runtime_path = std::path::Path::new(&config_folder) .join("docker-runtime") diff --git a/crates/bbuilder/src/generator.rs b/crates/bbuilder/src/generator.rs new file mode 100644 index 0000000..dc65aa2 --- /dev/null +++ b/crates/bbuilder/src/generator.rs @@ -0,0 +1,195 @@ +use spec::{Artifacts, File, Generated, Manifest, Pod, ResolvedSource, Source, Spec}; +use std::collections::HashMap; +use std::path::Path; + +pub fn generate( + manifest: Manifest, + secrets_dir: &Path, +) -> eyre::Result> { + let deployment = manifest.name.clone(); + let mut pods = HashMap::new(); + + for (pod_name, pod) in manifest.pods { + let mut specs = HashMap::new(); + for (spec_name, spec) in pod.specs { + specs.insert(spec_name, generate_spec(spec, &deployment, secrets_dir)?); + } + pods.insert(pod_name, Pod { specs }); + } + + Ok(Manifest { + name: manifest.name, + pods, + }) +} + +fn generate_spec( + spec: Spec, + deployment: &str, + secrets_dir: &Path, +) -> eyre::Result> { + let mut artifacts = Vec::with_capacity(spec.artifacts.len()); + + for artifact in spec.artifacts { + let Artifacts::File(file) = artifact; + let source = generate_source(file.source, deployment, &file.name, secrets_dir)?; + artifacts.push(Artifacts::File(File { + name: file.name, + target_path: file.target_path, + source, + })); + } + + Ok(Spec { + image: spec.image, + tag: spec.tag, + args: spec.args, + entrypoint: spec.entrypoint, + labels: spec.labels, + env: spec.env, + artifacts, + ports: spec.ports, + volumes: spec.volumes, + platform: spec.platform, + extensions: spec.extensions, + }) +} + +fn generate_source( + source: Source, + deployment: &str, + name: &str, + secrets_dir: &Path, +) -> eyre::Result { + Ok(match source { + Source::Inline(content) => ResolvedSource::Inline(content), + Source::Remote { url, checksum } => ResolvedSource::Remote { url, checksum }, + Source::Jwt => ResolvedSource::Inline(spec::DEFAULT_JWT_TOKEN.to_string()), + Source::Generated(generated) => { + ResolvedSource::Inline(secret(generated, deployment, name, secrets_dir)?) + } + }) +} + +fn secret( + generated: Generated, + deployment: &str, + name: &str, + secrets_dir: &Path, +) -> eyre::Result { + let dir = secrets_dir.join(deployment); + std::fs::create_dir_all(&dir)?; + let path = dir.join(name); + + if path.exists() { + return Ok(std::fs::read_to_string(&path)?); + } + + let content = match generated { + Generated::Ed25519TendermintNodeKey => cosmos_keys::generate_tendermint_key().serialize()?, + Generated::Secp256k1CometBftValidatorKey => { + cosmos_keys::generate_cometbft_key().serialize()? + } + }; + + std::fs::write(&path, &content)?; + tracing::info!("generated secret {} for deployment {}", name, deployment); + + Ok(content) +} + +#[cfg(test)] +mod tests { + use super::*; + use spec::{Pod, Spec}; + + fn manifest_with(file: File) -> Manifest { + let mut manifest = Manifest::new("test".to_string()); + let spec = Spec::builder() + .image("test-image") + .artifact(Artifacts::File(file)) + .build(); + manifest.add_spec("pod".to_string(), Pod::default().with_spec("service", spec)); + manifest + } + + fn only_source(manifest: Manifest) -> ResolvedSource { + let spec = &manifest.pods["pod"].specs["service"]; + let Artifacts::File(file) = &spec.artifacts[0]; + file.source.clone() + } + + #[test] + fn jwt_resolves_to_the_shared_token() -> eyre::Result<()> { + let dir = tempdir()?; + let resolved = generate(manifest_with(File::jwt("jwt", "/data/jwt")), dir.path())?; + + match only_source(resolved) { + ResolvedSource::Inline(content) => assert_eq!(content, spec::DEFAULT_JWT_TOKEN), + other => panic!("expected inline, got {:?}", other), + } + Ok(()) + } + + #[test] + fn remote_passes_through_untouched() -> eyre::Result<()> { + let dir = tempdir()?; + let file = File::remote("genesis", "/data/genesis.json", "https://example.com/g.json"); + let resolved = generate(manifest_with(file), dir.path())?; + + match only_source(resolved) { + ResolvedSource::Remote { url, checksum } => { + assert_eq!(url, "https://example.com/g.json"); + assert_eq!(checksum, None); + } + other => panic!("expected remote, got {:?}", other), + } + Ok(()) + } + + #[test] + fn generated_secrets_are_stable_across_runs() -> eyre::Result<()> { + let dir = tempdir()?; + let file = || { + File::generated( + "node_key.json", + "/data/node_key.json", + Generated::Ed25519TendermintNodeKey, + ) + }; + + let first = only_source(generate(manifest_with(file()), dir.path())?); + let second = only_source(generate(manifest_with(file()), dir.path())?); + + match (first, second) { + (ResolvedSource::Inline(a), ResolvedSource::Inline(b)) => { + assert_eq!(a, b, "re-running must not rotate the key"); + assert!(dir.path().join("test").join("node_key.json").exists()); + } + other => panic!("expected inline, got {:?}", other), + } + Ok(()) + } + + struct TempDir(std::path::PathBuf); + + impl TempDir { + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn tempdir() -> eyre::Result { + let base = std::env::temp_dir().join(format!("bbuilder-generator-{}", std::process::id())); + let unique = base.join(format!("{:?}", std::thread::current().id())); + let _ = std::fs::remove_dir_all(&unique); + std::fs::create_dir_all(&unique)?; + Ok(TempDir(unique)) + } +} diff --git a/crates/bbuilder/src/lib.rs b/crates/bbuilder/src/lib.rs index 8b13789..2a22e3d 100644 --- a/crates/bbuilder/src/lib.rs +++ b/crates/bbuilder/src/lib.rs @@ -1 +1 @@ - +pub mod generator; diff --git a/crates/runtime-docker-compose/src/runtime.rs b/crates/runtime-docker-compose/src/runtime.rs index f2ac704..717e596 100644 --- a/crates/runtime-docker-compose/src/runtime.rs +++ b/crates/runtime-docker-compose/src/runtime.rs @@ -2,7 +2,7 @@ use bollard::Docker; use bollard::query_parameters::CreateImageOptions; use futures_util::future::join_all; use futures_util::stream::StreamExt; -use spec::{File, Manifest, Platform}; +use spec::{File, Manifest, Platform, ResolvedSource}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::net::TcpListener; use std::process::{Command, Stdio}; @@ -84,6 +84,34 @@ fn load_reserved_ports(dir_path: &str, reserved_ports: &ReservedPorts) -> eyre:: Ok(()) } +fn fetch_service( + url: String, + checksum: Option, + target_path: &str, + pod_name: &str, + volumes: &[spec::Volume], +) -> eyre::Result { + let matching_vol = volumes + .iter() + .find(|vol| target_path.starts_with(&vol.path)) + .ok_or_else(|| eyre::eyre!("No matching volume found for path: {}", target_path))?; + + let mut command = vec![url, target_path.to_string()]; + if let Some(checksum) = checksum { + command.push(checksum); + } + + Ok(DockerComposeService { + image: "ghcr.io/ferranbt/bbuilder/fetcher:latest".to_string(), + command, + volumes: vec![ServiceVolume { + host: format!("{}-{}", pod_name, matching_vol.name), + target: matching_vol.path.clone(), + }], + ..Default::default() + }) +} + pub struct DockerRuntime { dir_path: String, reserved_ports: ReservedPorts, @@ -210,7 +238,7 @@ impl DockerRuntime { fn convert_to_docker_compose_spec( &self, - manifest: Manifest, + manifest: Manifest, ) -> eyre::Result { let mut services = HashMap::new(); let mut volumes = HashMap::new(); @@ -267,10 +295,6 @@ impl DockerRuntime { spec::Arg::Value(value) => Some(value.clone()), spec::Arg::Dir { path, .. } => Some(path.clone()), spec::Arg::Port { preferred, .. } => Some(format!("{}", preferred)), - spec::Arg::File(file) => { - artifacts_to_process.push(spec::Artifacts::File(file.clone())); - None - } spec::Arg::Ref { name, port } => { let reference = manifest.resolve_ref(name.clone(), port.clone())?; Some(reference) @@ -290,65 +314,39 @@ impl DockerRuntime { // Process all artifacts after args have been hydrated for artifact in artifacts_to_process { - match artifact { - spec::Artifacts::File(File { - name, - target_path, - content, - }) => { - // Check if the file is a URL - if content.starts_with("https://") { - // For URLs, create an init container to download the file - let init_service_name = - format!("{}-{}-init-{}", pod_name, spec_name, name); - - // Figure out which volume is this artifact refering to - let matching_vol = spec - .volumes - .iter() - .find(|vol| target_path.starts_with(&vol.path)) - .ok_or_else(|| { - eyre::eyre!( - "No matching volume found for path: {}", - target_path - ) - })?; - - let matching_vol_name = - format!("{}-{}", pod_name, matching_vol.name.clone()); - - // Create init container service - let init_service = DockerComposeService { - image: "ghcr.io/ferranbt/bbuilder/fetcher:latest".to_string(), - command: vec![content, target_path], - volumes: vec![ServiceVolume { - host: matching_vol_name.clone(), - target: matching_vol.path.clone(), - }], - ..Default::default() - }; - - services.insert(init_service_name.clone(), init_service); - init_services.insert( - init_service_name, - DependsOn { - condition: Some( - DependsOnCondition::ServiceCompletedSuccessfully, - ), - }, - ); - } else { - let target_host_path = absolute_config_path.join(name); - if let Some(parent) = target_host_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&target_host_path, content)?; - - service_volumes.push(ServiceVolume { - host: target_host_path.display().to_string(), - target: target_path, - }); + let spec::Artifacts::File(File { + name, + target_path, + source, + }) = artifact; + + match source { + spec::ResolvedSource::Remote { url, checksum } => { + let init_service_name = + format!("{}-{}-init-{}", pod_name, spec_name, name); + + let init_service = + fetch_service(url, checksum, &target_path, pod_name, &spec.volumes)?; + + services.insert(init_service_name.clone(), init_service); + init_services.insert( + init_service_name, + DependsOn { + condition: Some(DependsOnCondition::ServiceCompletedSuccessfully), + }, + ); + } + spec::ResolvedSource::Inline(content) => { + let target_host_path = absolute_config_path.join(name); + if let Some(parent) = target_host_path.parent() { + std::fs::create_dir_all(parent)?; } + std::fs::write(&target_host_path, content)?; + + service_volumes.push(ServiceVolume { + host: target_host_path.display().to_string(), + target: target_path, + }); } } } @@ -397,7 +395,7 @@ impl DockerRuntime { }) } - pub async fn run(&self, manifest: Manifest, dry_run: bool) -> eyre::Result<()> { + pub async fn run(&self, manifest: Manifest, dry_run: bool) -> eyre::Result<()> { let name = manifest.name.clone(); // Create the parent folder path @@ -445,8 +443,17 @@ mod tests { use super::*; use spec::{Artifacts, File, Manifest, Pod, Spec}; - fn generate_docker_compose(manifest: Manifest) -> eyre::Result { - let temp_dir = std::env::temp_dir().join("test-runtime"); + fn generate_docker_compose( + manifest: Manifest, + ) -> eyre::Result { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + + let temp_dir = std::env::temp_dir().join(format!( + "test-runtime-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); let _ = std::fs::remove_dir_all(&temp_dir); std::fs::create_dir_all(&temp_dir).unwrap(); let runtime = DockerRuntime::new(temp_dir.to_str().unwrap().to_string()); @@ -464,7 +471,7 @@ mod tests { let file_artifact = File { name: "config.json".to_string(), target_path: "/app/config.json".to_string(), - content: r#"{"key": "value"}"#.to_string(), + source: ResolvedSource::Inline(r#"{"key": "value"}"#.to_string()), }; let spec = Spec::builder() @@ -473,7 +480,7 @@ mod tests { .build(); let pod = Pod::default().with_spec("test-service", spec); - manifest.add_spec("test-pod".to_string(), pod); + manifest.pods.insert("test-pod".to_string(), pod); let docker_compose = generate_docker_compose(manifest)?; let service = docker_compose @@ -494,11 +501,61 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_remote_source_creates_fetcher_init_service() -> eyre::Result<()> { + let mut manifest = Manifest::new("remote-test".to_string()); + + let spec: Spec = Spec::builder() + .image("test-image") + .volume(spec::Volume { + name: "data".to_string(), + path: "/data".to_string(), + }) + .artifact(Artifacts::File(File { + name: "genesis".to_string(), + target_path: "/data/genesis.json".to_string(), + source: ResolvedSource::Remote { + url: "https://example.com/genesis.json".to_string(), + checksum: None, + }, + })) + .build(); + + manifest + .pods + .insert("pod".to_string(), Pod::default().with_spec("node", spec)); + + let docker_compose = generate_docker_compose(manifest)?; + + let init = docker_compose + .services + .get("pod-node-init-genesis") + .expect("fetcher init service should exist"); + assert_eq!( + init.command, + ["https://example.com/genesis.json", "/data/genesis.json"] + ); + assert!( + init.volumes + .iter() + .any(|v| v.host == "pod-data" && v.target == "/data"), + "init service should mount the volume the artifact targets" + ); + + let service = docker_compose.services.get("pod-node").unwrap(); + assert!( + service.depends_on.contains_key("pod-node-init-genesis"), + "service should wait for the fetch to complete" + ); + + Ok(()) + } + #[tokio::test] async fn test_port_arg_uses_preferred_port() -> eyre::Result<()> { let mut manifest = Manifest::new("port-test".to_string()); - let spec = Spec::builder() + let spec: Spec = Spec::builder() .image("test-image") .arg(spec::Arg::Port { name: "rpc-port".to_string(), @@ -507,7 +564,7 @@ mod tests { .build(); let pod = Pod::default().with_spec("service", spec); - manifest.add_spec("pod".to_string(), pod); + manifest.pods.insert("pod".to_string(), pod); let docker_compose = generate_docker_compose(manifest)?; let service = docker_compose.services.get("pod-service").unwrap(); @@ -567,7 +624,7 @@ mod tests { async fn test_volumes_are_created_with_bbuilder_label() -> eyre::Result<()> { let mut manifest = Manifest::new("volume-test".to_string()); - let spec = Spec::builder() + let spec: Spec = Spec::builder() .image("test-image") .volume(spec::Volume { name: "data".to_string(), @@ -576,7 +633,7 @@ mod tests { .build(); let pod = Pod::default().with_spec("service", spec); - manifest.add_spec("pod".to_string(), pod); + manifest.pods.insert("pod".to_string(), pod); let docker_compose = generate_docker_compose(manifest)?; @@ -614,9 +671,9 @@ mod tests { #[tokio::test] async fn test_image_tag_defaults_to_latest() -> eyre::Result<()> { let mut manifest = Manifest::new("tag-test".to_string()); - let spec = Spec::builder().image("test-image").build(); + let spec: Spec = Spec::builder().image("test-image").build(); let pod = Pod::default().with_spec("service", spec); - manifest.add_spec("pod".to_string(), pod); + manifest.pods.insert("pod".to_string(), pod); let docker_compose = generate_docker_compose(manifest)?; let service = docker_compose.services.get("pod-service").unwrap(); @@ -628,13 +685,13 @@ mod tests { #[tokio::test] async fn test_service_includes_spec_labels_and_bbuilder_label() -> eyre::Result<()> { let mut manifest = Manifest::new("label-test".to_string()); - let spec = Spec::builder() + let spec: Spec = Spec::builder() .image("test-image") .label("app", "myapp") .label("env", "production") .build(); let pod = Pod::default().with_spec("service", spec); - manifest.add_spec("pod".to_string(), pod); + manifest.pods.insert("pod".to_string(), pod); let docker_compose = generate_docker_compose(manifest)?; let service = docker_compose.services.get("pod-service").unwrap(); diff --git a/crates/spec/src/lib.rs b/crates/spec/src/lib.rs index 0322897..dcc0568 100644 --- a/crates/spec/src/lib.rs +++ b/crates/spec/src/lib.rs @@ -55,12 +55,12 @@ pub struct ChainSpec { } #[derive(Clone, Serialize, Deserialize)] -pub struct Manifest { +pub struct Manifest { pub name: String, - pub pods: HashMap, + pub pods: HashMap>, } -impl Manifest { +impl Manifest { pub fn new(name: String) -> Self { Manifest { name, @@ -68,21 +68,6 @@ impl Manifest { } } - pub fn add_spec(&mut self, name: String, mut pod: Pod) { - let mut new_specs = Vec::new(); - for (spec_name, spec) in &pod.specs { - if let Some(babel) = spec.get_babel() { - let babel_spec = babel.spec(); - let babel_name = format!("{}-babel", spec_name); - new_specs.push((babel_name, babel_spec)); - } - } - for (babel_name, babel_spec) in new_specs { - pod.specs.insert(babel_name, babel_spec); - } - self.pods.insert(name, pod); - } - pub fn resolve_ref(&self, name: String, port: String) -> eyre::Result { for (pod_name, pod) in &self.pods { for (spec_name, spec) in &pod.specs { @@ -108,9 +93,26 @@ impl Manifest { } } +impl Manifest { + pub fn add_spec(&mut self, name: String, mut pod: Pod) { + let mut new_specs = Vec::new(); + for (spec_name, spec) in &pod.specs { + if let Some(babel) = spec.get_babel() { + let babel_spec = babel.spec(); + let babel_name = format!("{}-babel", spec_name); + new_specs.push((babel_name, babel_spec)); + } + } + for (babel_name, babel_spec) in new_specs { + pod.specs.insert(babel_name, babel_spec); + } + self.pods.insert(name, pod); + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] -pub enum Artifacts { - File(File), +pub enum Artifacts { + File(File), } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -118,7 +120,6 @@ pub enum Arg { Port { name: String, preferred: u16 }, Dir { name: String, path: String }, Ref { name: String, port: String }, - File(File), Value(String), } @@ -129,10 +130,92 @@ pub struct Dir { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct File { +pub struct File { pub name: String, pub target_path: String, - pub content: String, + pub source: S, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Source { + Inline(String), + Remote { + url: String, + checksum: Option, + }, + Generated(Generated), + Jwt, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ResolvedSource { + Inline(String), + Remote { + url: String, + checksum: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Generated { + Ed25519TendermintNodeKey, + Secp256k1CometBftValidatorKey, +} + +impl File { + pub fn inline( + name: impl Into, + target_path: impl Into, + content: impl Into, + ) -> Self { + Self { + name: name.into(), + target_path: target_path.into(), + source: Source::Inline(content.into()), + } + } + + pub fn remote( + name: impl Into, + target_path: impl Into, + url: impl Into, + ) -> Self { + Self { + name: name.into(), + target_path: target_path.into(), + source: Source::Remote { + url: url.into(), + checksum: None, + }, + } + } + + pub fn generated( + name: impl Into, + target_path: impl Into, + generated: Generated, + ) -> Self { + Self { + name: name.into(), + target_path: target_path.into(), + source: Source::Generated(generated), + } + } + + pub fn jwt(name: impl Into, target_path: impl Into) -> Self { + Self { + name: name.into(), + target_path: target_path.into(), + source: Source::Jwt, + } + } + + pub fn with_checksum(mut self, value: impl Into) -> Self { + if let Source::Remote { checksum, .. } = &mut self.source { + *checksum = Some(value.into()); + } + self + } } #[macro_export] @@ -193,13 +276,21 @@ impl From<&PathBuf> for Arg { } } -#[derive(Default, Debug, Clone, Deserialize, Serialize)] -pub struct Pod { - pub specs: HashMap, +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Pod { + pub specs: HashMap>, } -impl Pod { - pub fn with_spec(mut self, name: &str, spec: impl Into) -> Self { +impl Default for Pod { + fn default() -> Self { + Self { + specs: HashMap::new(), + } + } +} + +impl Pod { + pub fn with_spec(mut self, name: &str, spec: impl Into>) -> Self { self.specs.insert(name.to_string(), spec.into()); self } @@ -212,37 +303,54 @@ pub struct Port { } #[derive(Default, Debug, Clone, Deserialize, Serialize)] -pub struct Spec { +pub struct Spec { pub image: String, pub tag: Option, pub args: Vec, pub entrypoint: Vec, pub labels: HashMap, pub env: HashMap, - pub artifacts: Vec, + pub artifacts: Vec>, pub ports: Vec, pub volumes: Vec, pub platform: Option, pub extensions: HashMap, } -#[derive(Default)] -pub struct SpecBuilder { +pub struct SpecBuilder { image: Option, tag: Option, args: Vec, env: HashMap, entrypoint: Vec, labels: HashMap, - artifacts: Vec, + artifacts: Vec>, ports: Vec, volumes: Vec, extensions: HashMap, platform: Option, } -impl Spec { - pub fn builder() -> SpecBuilder { +impl Default for SpecBuilder { + fn default() -> Self { + Self { + image: None, + tag: None, + args: Vec::new(), + env: HashMap::new(), + entrypoint: Vec::new(), + labels: HashMap::new(), + artifacts: Vec::new(), + ports: Vec::new(), + volumes: Vec::new(), + extensions: HashMap::new(), + platform: None, + } + } +} + +impl Spec { + pub fn builder() -> SpecBuilder { SpecBuilder::default() } } @@ -252,13 +360,13 @@ pub enum Platform { LinuxAmd64, } -impl SpecBuilder { - pub fn image>(mut self, image: S) -> Self { +impl SpecBuilder { + pub fn image>(mut self, image: T) -> Self { self.image = Some(image.into()); self } - pub fn tag>(mut self, tag: S) -> Self { + pub fn tag>(mut self, tag: T) -> Self { self.tag = Some(tag.into()); self } @@ -310,7 +418,7 @@ impl SpecBuilder { self } - pub fn artifact(mut self, artifact: Artifacts) -> Self { + pub fn artifact(mut self, artifact: Artifacts) -> Self { self.artifacts.push(artifact); self } @@ -349,7 +457,7 @@ impl SpecBuilder { } } - pub fn build(self) -> Spec { + pub fn build(self) -> Spec { let mut ports = self.ports; for arg in &self.args { @@ -377,8 +485,8 @@ impl SpecBuilder { } } -impl Into for SpecBuilder { - fn into(self) -> Spec { +impl Into> for SpecBuilder { + fn into(self) -> Spec { self.build() } } @@ -418,7 +526,7 @@ pub trait DeploymentExtension { fn get_babel(&self) -> Option; } -impl DeploymentExtension for SpecBuilder { +impl DeploymentExtension for SpecBuilder { fn min_version(self, version: String) -> Self { self.extension("min_version", serde_json::Value::String(version)) } @@ -432,7 +540,7 @@ impl DeploymentExtension for SpecBuilder { } } -impl DeploymentExtension for Spec { +impl DeploymentExtension for Spec { fn min_version(self, _version: String) -> Self { self } @@ -446,7 +554,7 @@ impl DeploymentExtension for Spec { } } -impl Spec { +impl Spec { pub fn get_extension( &self, name: String, @@ -464,7 +572,7 @@ mod tests { #[test] fn test_arg_port_populates_ports() { - let spec = Spec::builder() + let spec: Spec = Spec::builder() .image("test-image") .arg(Arg::Port { name: "http".to_string(), @@ -485,7 +593,7 @@ mod tests { #[test] fn test_arg_port_with_existing_ports() { - let spec = Spec::builder() + let spec: Spec = Spec::builder() .image("test-image") .port(Port { port: 3000,