Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/codra-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
5 changes: 5 additions & 0 deletions crates/codra-cli/src/deploy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
use codra_deploy::execute_deploy;

pub fn execute_deploy_command(args: &[String]) -> Result<(), String> {
execute_deploy(args)
}
1 change: 1 addition & 0 deletions crates/codra-cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod context;
pub mod deploy;
pub mod doctor;
pub mod events;
pub mod init;
Expand Down
4 changes: 4 additions & 0 deletions crates/codra-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions crates/codra-cli/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ 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(())
}

pub fn help() -> Result<(), String> {
println!("codra <command>");
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 <task> [--jsonl] Run a task with optional JSONL event stream");
println!(" Tasks: review-pr, explain-issue, summarize-context");
println!(
Expand Down
69 changes: 69 additions & 0 deletions crates/codra-cli/tests/deploy_command_tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}
6 changes: 4 additions & 2 deletions crates/codra-deploy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
114 changes: 114 additions & 0 deletions crates/codra-deploy/src/cli.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
91 changes: 91 additions & 0 deletions crates/codra-deploy/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(rename = "startCommand", default)]
pub start_command: Option<String>,
#[serde(rename = "publishDir", default)]
pub publish_dir: Option<String>,
#[serde(default)]
pub schedule: Option<String>,
#[serde(default)]
pub command: Option<String>,
#[serde(rename = "healthCheckPath", default)]
pub health_check_path: Option<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub ports: Vec<DeployPort>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployConfig {
pub version: DeployVersion,
pub project: String,
pub services: Vec<DeployServiceConfig>,
}

#[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<Self, serde_json::Error> {
serde_json::from_str(source)
}
}

fn default_root() -> String {
".".to_string()
}
Loading
Loading