From 4571314b71e3f05f72ac8ebc2d94e73b58eed77b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 12:52:34 +0000 Subject: [PATCH] feat(deploy): add Codra Deploy foundation --- Cargo.lock | 2 +- README.md | 10 ++ crates/codra-cli/Cargo.toml | 1 + crates/codra-cli/src/deploy.rs | 5 + crates/codra-cli/src/lib.rs | 1 + crates/codra-cli/src/main.rs | 4 + crates/codra-cli/src/terminal.rs | 2 + .../codra-cli/tests/deploy_command_tests.rs | 69 +++++++++ crates/codra-deploy/Cargo.toml | 6 +- crates/codra-deploy/src/cli.rs | 114 ++++++++++++++ crates/codra-deploy/src/config.rs | 91 +++++++++++ crates/codra-deploy/src/lib.rs | 38 ++++- crates/codra-deploy/src/plan.rs | 126 +++++++++++++++ crates/codra-deploy/src/validator.rs | 90 +++++++++++ .../codra-deploy/tests/deploy_core_tests.rs | 146 ++++++++++++++++++ docs/codra-deploy/README.md | 57 +++++++ .../codra-deploy/nextjs/codra.deploy.json | 23 +++ .../codra-deploy/static/codra.deploy.json | 13 ++ .../codra-deploy/worker/codra.deploy.json | 15 ++ 19 files changed, 807 insertions(+), 6 deletions(-) create mode 100644 crates/codra-cli/src/deploy.rs create mode 100644 crates/codra-cli/tests/deploy_command_tests.rs create mode 100644 crates/codra-deploy/src/cli.rs create mode 100644 crates/codra-deploy/src/config.rs create mode 100644 crates/codra-deploy/src/plan.rs create mode 100644 crates/codra-deploy/src/validator.rs create mode 100644 crates/codra-deploy/tests/deploy_core_tests.rs create mode 100644 docs/codra-deploy/README.md create mode 100644 examples/codra-deploy/nextjs/codra.deploy.json create mode 100644 examples/codra-deploy/static/codra.deploy.json create mode 100644 examples/codra-deploy/worker/codra.deploy.json diff --git a/Cargo.lock b/Cargo.lock index b867da9..46d1456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,7 @@ version = "0.1.0" dependencies = [ "chrono", "codra-core", + "codra-deploy", "codra-protocol", "codra-runtime", "codra-tools", @@ -606,7 +607,6 @@ dependencies = [ "serde", "serde_json", "thiserror 1.0.69", - "tokio", ] [[package]] diff --git a/README.md b/README.md index b688e76..86d7a13 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,16 @@ cargo check cargo test ``` +## Codra Ecosystem + +- Codra CLI: coding agent +- Codra Action: GitHub automation layer +- Codra Deploy: deployment and runtime layer + +Codra Deploy status: Experimental foundation. + +Example: `codra deploy plan --config codra.deploy.json` + ## Codra CLI (event protocol + GitHub context) ```bash diff --git a/crates/codra-cli/Cargo.toml b/crates/codra-cli/Cargo.toml index ee81e80..878df9b 100644 --- a/crates/codra-cli/Cargo.toml +++ b/crates/codra-cli/Cargo.toml @@ -18,6 +18,7 @@ codra-core = { path = "../codra-core" } codra-protocol = { path = "../codra-protocol" } codra-runtime = { path = "../codra-runtime" } codra-tools = { path = "../codra-tools" } +codra-deploy = { path = "../codra-deploy" } chrono = { workspace = true } reqwest = { workspace = true, features = ["blocking"] } tokio = { workspace = true } diff --git a/crates/codra-cli/src/deploy.rs b/crates/codra-cli/src/deploy.rs new file mode 100644 index 0000000..7d5c709 --- /dev/null +++ b/crates/codra-cli/src/deploy.rs @@ -0,0 +1,5 @@ +use codra_deploy::execute_deploy; + +pub fn execute_deploy_command(args: &[String]) -> Result<(), String> { + execute_deploy(args) +} diff --git a/crates/codra-cli/src/lib.rs b/crates/codra-cli/src/lib.rs index a4b279d..4ad960e 100644 --- a/crates/codra-cli/src/lib.rs +++ b/crates/codra-cli/src/lib.rs @@ -1,4 +1,5 @@ pub mod context; +pub mod deploy; pub mod doctor; pub mod events; pub mod init; diff --git a/crates/codra-cli/src/main.rs b/crates/codra-cli/src/main.rs index 656eb5c..ba1d7a3 100644 --- a/crates/codra-cli/src/main.rs +++ b/crates/codra-cli/src/main.rs @@ -46,6 +46,10 @@ fn main() { .cloned() .unwrap_or_else(|| "Inspect workspace and report readiness.".to_string()), ), + "deploy" => { + args.remove(0); + codra_cli::deploy::execute_deploy_command(&args) + } "mcp-server" => mcp_server(), "run" => { args.remove(0); diff --git a/crates/codra-cli/src/terminal.rs b/crates/codra-cli/src/terminal.rs index f3ffae9..76ab7e0 100644 --- a/crates/codra-cli/src/terminal.rs +++ b/crates/codra-cli/src/terminal.rs @@ -24,6 +24,7 @@ pub fn welcome() -> Result<(), String> { println!("Next:"); println!(" Run \"codra init\" to create CODRA.md for this repo."); println!(" Run \"codra doctor\" to check your environment."); + println!(" Run \"codra deploy plan --config codra.deploy.json\" to preview deployments."); Ok(()) } @@ -31,6 +32,7 @@ pub fn help() -> Result<(), String> { println!("codra "); println!(" init [--force] [--dry-run] Create CODRA.md and .codra project skeleton"); println!(" doctor [--json] Check local Codra environment readiness"); + println!(" deploy plan [--json] Validate and render a safe deployment plan"); println!(" run --task [--jsonl] Run a task with optional JSONL event stream"); println!(" Tasks: review-pr, explain-issue, summarize-context"); println!( diff --git a/crates/codra-cli/tests/deploy_command_tests.rs b/crates/codra-cli/tests/deploy_command_tests.rs new file mode 100644 index 0000000..0579710 --- /dev/null +++ b/crates/codra-cli/tests/deploy_command_tests.rs @@ -0,0 +1,69 @@ +use codra_cli::deploy::execute_deploy_command; +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_dir() -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("codra-deploy-test-{nanos}")); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn cli_plan_succeeds_for_valid_config() { + let dir = temp_dir(); + let config = dir.join("codra.deploy.json"); + fs::write( + &config, + r#"{ + "version": 1, + "project": "teraai", + "services": [ + { + "name": "web", + "type": "web", + "buildCommand": "npm run build", + "startCommand": "npm start", + "ports": [{ "internal": 3000, "public": true }] + } + ] + }"#, + ) + .unwrap(); + + let result = execute_deploy_command(&[ + "plan".to_string(), + "--config".to_string(), + config.display().to_string(), + ]); + + assert!(result.is_ok()); +} + +#[test] +fn cli_plan_fails_for_invalid_config() { + let dir = temp_dir(); + let config = dir.join("codra.deploy.json"); + fs::write( + &config, + r#"{ + "version": 1, + "project": "", + "services": [ + { "name": "web", "type": "web" } + ] + }"#, + ) + .unwrap(); + + let result = execute_deploy_command(&[ + "plan".to_string(), + "--config".to_string(), + config.display().to_string(), + ]); + + assert!(result.is_err()); +} diff --git a/crates/codra-deploy/Cargo.toml b/crates/codra-deploy/Cargo.toml index 9790493..c633adc 100644 --- a/crates/codra-deploy/Cargo.toml +++ b/crates/codra-deploy/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] -serde.workspace = true +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true -tokio.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/codra-deploy/src/cli.rs b/crates/codra-deploy/src/cli.rs new file mode 100644 index 0000000..a8e02e8 --- /dev/null +++ b/crates/codra-deploy/src/cli.rs @@ -0,0 +1,114 @@ +use crate::plan::{generate_plan, DeployPlan}; +use crate::{load_config_from_path, validate_config}; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeployOutputFormat { + Human, + Json, +} + +#[derive(Debug, Serialize)] +struct DeployPlanOutput { + plan: DeployPlan, +} + +pub fn execute_deploy(args: &[String]) -> Result<(), String> { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + println!("codra deploy plan [--config codra.deploy.json] [--json]"); + println!(" Validate and render a safe deployment plan."); + return Ok(()); + } + + let (format, config_path) = parse_args(args)?; + let config = load_config_from_path(&config_path).map_err(|err| err.to_string())?; + let validation = validate_config(&config); + if !validation.valid { + return Err(render_validation_errors(&validation.errors)); + } + let plan = generate_plan(&config); + + match format { + DeployOutputFormat::Human => print_human(&plan), + DeployOutputFormat::Json => { + let body = serde_json::to_string_pretty(&DeployPlanOutput { plan }) + .map_err(|err| err.to_string())?; + println!("{body}"); + } + } + + Ok(()) +} + +fn parse_args(args: &[String]) -> Result<(DeployOutputFormat, PathBuf), String> { + let mut format = DeployOutputFormat::Human; + let mut config_path = PathBuf::from("codra.deploy.json"); + let mut iter = args.iter().peekable(); + + while let Some(arg) = iter.next() { + match arg.as_str() { + "plan" => {} + "--json" => format = DeployOutputFormat::Json, + "--config" => { + let value = iter + .next() + .ok_or_else(|| "missing value for --config".to_string())?; + config_path = PathBuf::from(value); + } + flag if flag.starts_with("--") => return Err(format!("unknown flag: {flag}")), + other => return Err(format!("unexpected argument: {other}")), + } + } + + Ok((format, config_path)) +} + +fn render_validation_errors(errors: &[crate::validator::ValidationError]) -> String { + let mut out = String::from("invalid codra.deploy.json:\n"); + for error in errors { + out.push_str(&format!("- {}: {}\n", error.path, error.message)); + } + out.trim_end().to_string() +} + +fn print_human(plan: &DeployPlan) { + println!("Codra Deploy Plan"); + println!(); + println!("Project: {}", plan.project); + println!("Services: {}", plan.services.len()); + println!(); + for service in &plan.services { + println!("{}", service.name); + println!("Type: {:?}", service.service_type); + println!("Root: {}", service.root); + println!( + "Build: {}", + service.build_command.as_deref().unwrap_or("not set") + ); + println!( + "Start: {}", + service.start_command.as_deref().unwrap_or("not set") + ); + if let Some(port) = service.expected_port { + println!("Port: {}", port); + } + println!( + "Health check: {}", + service.health_check_path.as_deref().unwrap_or("not set") + ); + if !service.env_keys.is_empty() { + println!("Env keys: {}", service.env_keys.join(", ")); + } + if !service.redacted_env.is_empty() { + println!("Env values: redacted"); + } + println!(); + } + if !plan.warnings.is_empty() { + println!("Warnings:"); + for warning in &plan.warnings { + println!("- {}", warning.message); + } + } +} diff --git a/crates/codra-deploy/src/config.rs b/crates/codra-deploy/src/config.rs new file mode 100644 index 0000000..193cee2 --- /dev/null +++ b/crates/codra-deploy/src/config.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; +use std::path::PathBuf; + +pub type DeployVersion = u32; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DeployServiceType { + Web, + Worker, + Cron, + Static, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeployPort { + pub internal: u16, + #[serde(default)] + pub public: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeployServiceConfig { + pub name: String, + #[serde(rename = "type")] + pub service_type: DeployServiceType, + #[serde(default = "default_root")] + pub root: String, + #[serde(rename = "buildCommand", default)] + pub build_command: Option, + #[serde(rename = "startCommand", default)] + pub start_command: Option, + #[serde(rename = "publishDir", default)] + pub publish_dir: Option, + #[serde(default)] + pub schedule: Option, + #[serde(default)] + pub command: Option, + #[serde(rename = "healthCheckPath", default)] + pub health_check_path: Option, + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub ports: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeployConfig { + pub version: DeployVersion, + pub project: String, + pub services: Vec, +} + +#[derive(Debug)] +pub enum DeployConfigError { + Io { + path: PathBuf, + source: std::io::Error, + }, + Parse { + path: PathBuf, + source: serde_json::Error, + }, +} + +impl fmt::Display for DeployConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DeployConfigError::Io { path, source } => { + write!(f, "failed to read {}: {}", path.display(), source) + } + DeployConfigError::Parse { path, source } => { + write!(f, "failed to parse {}: {}", path.display(), source) + } + } + } +} + +impl std::error::Error for DeployConfigError {} + +impl DeployConfig { + pub fn from_json_str(source: &str) -> Result { + serde_json::from_str(source) + } +} + +fn default_root() -> String { + ".".to_string() +} diff --git a/crates/codra-deploy/src/lib.rs b/crates/codra-deploy/src/lib.rs index ac87642..116058b 100644 --- a/crates/codra-deploy/src/lib.rs +++ b/crates/codra-deploy/src/lib.rs @@ -1,4 +1,36 @@ -pub trait Deployer { - fn plan_deployment(&self) -> Result; - fn execute(&self) -> Result<(), String>; +mod cli; +mod config; +mod plan; +mod validator; + +pub use cli::{execute_deploy, DeployOutputFormat}; +pub use config::{ + DeployConfig, DeployConfigError, DeployPort, DeployServiceConfig, DeployServiceType, + DeployVersion, +}; +pub use plan::{ + generate_plan, DeployPlan, DeployPlanService, DeployPlanUnsupportedFeature, DeployPlanWarning, +}; +pub use validator::{validate_config, ValidationError, ValidationResult}; + +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn load_config_from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = fs::read_to_string(path).map_err(|source| DeployConfigError::Io { + path: path.to_path_buf(), + source, + })?; + DeployConfig::from_json_str(&contents).map_err(|source| DeployConfigError::Parse { + path: path.to_path_buf(), + source, + }) +} + +pub fn load_config_from_cwd(filename: &str) -> Result { + let path = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(filename); + load_config_from_path(path) } diff --git a/crates/codra-deploy/src/plan.rs b/crates/codra-deploy/src/plan.rs new file mode 100644 index 0000000..46e4761 --- /dev/null +++ b/crates/codra-deploy/src/plan.rs @@ -0,0 +1,126 @@ +use crate::config::{DeployConfig, DeployServiceConfig, DeployServiceType}; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeployPlan { + pub project: String, + pub services: Vec, + pub warnings: Vec, + pub unsupported_features: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeployPlanService { + pub name: String, + #[serde(rename = "type")] + pub service_type: DeployServiceType, + pub root: String, + pub build_command: Option, + pub start_command: Option, + pub expected_port: Option, + pub health_check_path: Option, + pub required_env_keys: Vec, + pub env_keys: Vec, + pub redacted_env: Vec, + pub schedule: Option, + pub command: Option, + pub publish_dir: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeployPlanWarning { + pub service: Option, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeployPlanUnsupportedFeature { + pub service: Option, + pub feature: String, +} + +pub fn generate_plan(config: &DeployConfig) -> DeployPlan { + let mut warnings = Vec::new(); + let mut unsupported_features = Vec::new(); + let services = config + .services + .iter() + .map(|service| { + let expected_port = expected_port(service); + if service.health_check_path.is_none() { + warnings.push(DeployPlanWarning { + service: Some(service.name.clone()), + message: "No health check path configured.".to_string(), + }); + } + if matches!(service.service_type, DeployServiceType::Web) && service.ports.is_empty() { + warnings.push(DeployPlanWarning { + service: Some(service.name.clone()), + message: "No public port configured.".to_string(), + }); + } + if matches!(service.service_type, DeployServiceType::Cron) && service.schedule.is_none() + { + unsupported_features.push(DeployPlanUnsupportedFeature { + service: Some(service.name.clone()), + feature: "cron schedule missing".to_string(), + }); + } + DeployPlanService { + name: service.name.clone(), + service_type: service.service_type.clone(), + root: service.root.clone(), + build_command: service.build_command.clone(), + start_command: service.start_command.clone(), + expected_port, + health_check_path: service.health_check_path.clone(), + required_env_keys: required_env_keys(service), + env_keys: service.env.keys().cloned().collect(), + redacted_env: service + .env + .keys() + .map(|key| format!("{key}=")) + .collect(), + schedule: service.schedule.clone(), + command: service.command.clone(), + publish_dir: service.publish_dir.clone(), + } + }) + .collect(); + + if config + .services + .iter() + .all(|service| service.health_check_path.is_none()) + { + warnings.push(DeployPlanWarning { + service: None, + message: "No domain configured.".to_string(), + }); + } + + DeployPlan { + project: config.project.clone(), + services, + warnings, + unsupported_features, + } +} + +fn required_env_keys(service: &DeployServiceConfig) -> Vec { + let mut keys = BTreeSet::new(); + if matches!(service.service_type, DeployServiceType::Web) { + keys.insert("PORT".to_string()); + } + keys.into_iter().collect() +} + +fn expected_port(service: &DeployServiceConfig) -> Option { + service + .ports + .iter() + .find(|port| port.public) + .map(|port| port.internal) + .or_else(|| service.ports.first().map(|port| port.internal)) +} diff --git a/crates/codra-deploy/src/validator.rs b/crates/codra-deploy/src/validator.rs new file mode 100644 index 0000000..4fb6bfd --- /dev/null +++ b/crates/codra-deploy/src/validator.rs @@ -0,0 +1,90 @@ +use crate::config::{DeployConfig, DeployServiceType}; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ValidationResult { + pub valid: bool, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ValidationError { + pub path: String, + pub message: String, +} + +pub fn validate_config(config: &DeployConfig) -> ValidationResult { + let mut errors = Vec::new(); + + if config.project.trim().is_empty() { + errors.push(ValidationError { + path: "project".to_string(), + message: "project name is required".to_string(), + }); + } + + if config.services.is_empty() { + errors.push(ValidationError { + path: "services".to_string(), + message: "at least one service is required".to_string(), + }); + } + + let mut names = BTreeSet::new(); + for (index, service) in config.services.iter().enumerate() { + let base = format!("services[{index}]"); + if service.name.trim().is_empty() { + errors.push(ValidationError { + path: format!("{base}.name"), + message: "service name is required".to_string(), + }); + } else if !names.insert(service.name.clone()) { + errors.push(ValidationError { + path: format!("{base}.name"), + message: format!("duplicate service name: {}", service.name), + }); + } + + if matches!(service.service_type, DeployServiceType::Web) && service.start_command.is_none() + { + errors.push(ValidationError { + path: format!("{base}.startCommand"), + message: "web services require startCommand".to_string(), + }); + } + + if matches!(service.service_type, DeployServiceType::Static) + && service.build_command.is_none() + && service.publish_dir.is_none() + { + errors.push(ValidationError { + path: format!("{base}.buildCommand"), + message: "static services require buildCommand or publishDir".to_string(), + }); + } + + if matches!(service.service_type, DeployServiceType::Cron) + && service.schedule.as_deref().unwrap_or("").trim().is_empty() + { + errors.push(ValidationError { + path: format!("{base}.schedule"), + message: "cron services require schedule".to_string(), + }); + } + + if matches!(service.service_type, DeployServiceType::Cron) + && service.command.as_deref().unwrap_or("").trim().is_empty() + { + errors.push(ValidationError { + path: format!("{base}.command"), + message: "cron services require command".to_string(), + }); + } + } + + ValidationResult { + valid: errors.is_empty(), + errors, + } +} diff --git a/crates/codra-deploy/tests/deploy_core_tests.rs b/crates/codra-deploy/tests/deploy_core_tests.rs new file mode 100644 index 0000000..285ba29 --- /dev/null +++ b/crates/codra-deploy/tests/deploy_core_tests.rs @@ -0,0 +1,146 @@ +use codra_deploy::{generate_plan, validate_config, DeployConfig}; + +fn valid_web_config() -> DeployConfig { + serde_json::from_str( + r#"{ + "version": 1, + "project": "web-app", + "services": [ + { + "name": "web", + "type": "web", + "root": ".", + "buildCommand": "npm run build", + "startCommand": "npm start", + "healthCheckPath": "/", + "env": { "NODE_ENV": "production" }, + "ports": [{ "internal": 3000, "public": true }] + } + ] + }"#, + ) + .unwrap() +} + +fn valid_static_config() -> DeployConfig { + serde_json::from_str( + r#"{ + "version": 1, + "project": "static-app", + "services": [ + { + "name": "site", + "type": "static", + "root": ".", + "buildCommand": "npm run build", + "publishDir": "dist" + } + ] + }"#, + ) + .unwrap() +} + +fn valid_worker_config() -> DeployConfig { + serde_json::from_str( + r#"{ + "version": 1, + "project": "worker-app", + "services": [ + { + "name": "worker", + "type": "worker", + "root": ".", + "startCommand": "node worker.js", + "env": { "QUEUE_NAME": "jobs" } + } + ] + }"#, + ) + .unwrap() +} + +#[test] +fn valid_web_service_config() { + let result = validate_config(&valid_web_config()); + assert!(result.valid); + assert!(result.errors.is_empty()); +} + +#[test] +fn valid_static_service_config() { + let result = validate_config(&valid_static_config()); + assert!(result.valid); +} + +#[test] +fn valid_worker_service_config() { + let result = validate_config(&valid_worker_config()); + assert!(result.valid); +} + +#[test] +fn invalid_missing_project() { + let mut config = valid_web_config(); + config.project = String::new(); + let result = validate_config(&config); + assert!(!result.valid); + assert!(result.errors.iter().any(|error| error.path == "project")); +} + +#[test] +fn invalid_duplicate_service_names() { + let config: DeployConfig = serde_json::from_str( + r#"{ + "version": 1, + "project": "dup-app", + "services": [ + { "name": "web", "type": "web", "startCommand": "npm start" }, + { "name": "web", "type": "worker", "startCommand": "node worker.js" } + ] + }"#, + ) + .unwrap(); + let result = validate_config(&config); + assert!(!result.valid); + assert!(result + .errors + .iter() + .any(|error| error.message.contains("duplicate service name"))); +} + +#[test] +fn invalid_unsupported_service_type_is_rejected_by_parser() { + let parsed = serde_json::from_str::( + r#"{ + "version": 1, + "project": "bad", + "services": [ + { "name": "x", "type": "database" } + ] + }"#, + ); + assert!(parsed.is_err()); +} + +#[test] +fn env_redaction_keeps_keys_but_not_values() { + let plan = generate_plan(&valid_worker_config()); + let service = &plan.services[0]; + assert_eq!(service.env_keys, vec!["QUEUE_NAME".to_string()]); + assert_eq!( + service.redacted_env, + vec!["QUEUE_NAME=".to_string()] + ); +} + +#[test] +fn deploy_plan_generation_includes_expected_fields() { + let plan = generate_plan(&valid_web_config()); + let service = &plan.services[0]; + assert_eq!(plan.project, "web-app"); + assert_eq!(service.name, "web"); + assert_eq!(service.expected_port, Some(3000)); + assert_eq!(service.health_check_path.as_deref(), Some("/")); + assert_eq!(service.start_command.as_deref(), Some("npm start")); +} diff --git a/docs/codra-deploy/README.md b/docs/codra-deploy/README.md new file mode 100644 index 0000000..a381308 --- /dev/null +++ b/docs/codra-deploy/README.md @@ -0,0 +1,57 @@ +# Codra Deploy + +Codra Deploy is the deployment and runtime layer of the Codra ecosystem. + +## Vision + +Codra Deploy is an open-source Render-style deployment platform for developers who want simple app deployments, logs, domains, workers, and databases on their own infrastructure. + +## Positioning + +Codra Deploy is for developers who want a small, predictable deployment surface they can run on their own infrastructure without adopting a hosted control plane. + +## Ecosystem Relationship + +- Codra CLI writes, reviews, and fixes code locally. +- Codra Action automates GitHub pull request and issue workflows. +- Codra Deploy runs apps, workers, jobs, logs, domains, and runtime services on user-owned infrastructure. +- Later, Codra can read failed deploy logs and propose fixes. + +## MVP Scope + +- Project +- Service +- Deploy +- Environment variables +- Build command +- Start command +- Logs +- Domains +- Health checks + +## Render-Style Service Types + +- Web service +- Background worker +- Cron job +- Static site +- Database, later + +## Future Roadmap + +- Service lifecycle orchestration +- Log streaming +- Domain routing +- Runtime health reporting +- Database support +- Safe runner abstractions for local execution + +## Non-Goals For This PR + +- No Kubernetes +- No billing +- No hosted cloud +- No team management +- No destructive Docker operations +- No real domain provisioning yet +- No database add-ons yet diff --git a/examples/codra-deploy/nextjs/codra.deploy.json b/examples/codra-deploy/nextjs/codra.deploy.json new file mode 100644 index 0000000..3f8ed4a --- /dev/null +++ b/examples/codra-deploy/nextjs/codra.deploy.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "project": "nextjs-app", + "services": [ + { + "name": "web", + "type": "web", + "root": ".", + "buildCommand": "npm run build", + "startCommand": "npm start", + "healthCheckPath": "/", + "env": { + "NODE_ENV": "production" + }, + "ports": [ + { + "internal": 3000, + "public": true + } + ] + } + ] +} diff --git a/examples/codra-deploy/static/codra.deploy.json b/examples/codra-deploy/static/codra.deploy.json new file mode 100644 index 0000000..2d8faa3 --- /dev/null +++ b/examples/codra-deploy/static/codra.deploy.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "project": "static-site", + "services": [ + { + "name": "site", + "type": "static", + "root": ".", + "buildCommand": "npm run build", + "publishDir": "dist" + } + ] +} diff --git a/examples/codra-deploy/worker/codra.deploy.json b/examples/codra-deploy/worker/codra.deploy.json new file mode 100644 index 0000000..1bb7fd5 --- /dev/null +++ b/examples/codra-deploy/worker/codra.deploy.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "project": "worker-app", + "services": [ + { + "name": "worker", + "type": "worker", + "root": ".", + "startCommand": "node worker.js", + "env": { + "QUEUE_NAME": "jobs" + } + } + ] +}