use clap::{Args, Subcommand, ValueEnum};
use std::error::Error;
use std::ffi::OsString;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use crate::manifest_harness::{run_manifest_harness, HarnessMode, HarnessOptions};
use crate::{
generate_service_scaffold, package_name, render_atlas_schema, AtlasDatabaseUrl,
AtlasSchemaSpec, BusTarget, FileMode, GeneratedFile, GithubRepo, GitopsPromoteTarget,
PostCreateAction, ServiceScaffoldSpec, ServiceTransport, StoreTarget,
};
const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u64 = 1;
#[derive(Args, Debug)]
pub struct ServiceArgs {
#[command(subcommand)]
pub command: ServiceCommands,
}
#[derive(Subcommand, Debug)]
pub enum ServiceCommands {
#[command(alias = "create")]
Scaffold(ScaffoldArgs),
Describe(DescribeArgs),
Schema(SchemaArgs),
}
#[derive(Args, Debug)]
pub struct ScaffoldArgs {
pub name: String,
#[arg(long)]
pub path: Option<PathBuf>,
#[arg(long, value_enum, default_value = "distributed")]
pub framework: Framework,
#[arg(long)]
pub kind: Option<String>,
#[arg(long, value_enum, default_value = "http")]
pub transport: Transport,
#[arg(long)]
pub http: bool,
#[arg(long)]
pub knative: bool,
#[arg(long)]
pub model: Vec<String>,
#[arg(long)]
pub read_models: bool,
#[arg(long)]
pub command: Vec<String>,
#[arg(long)]
pub event: Vec<String>,
#[arg(long, value_enum)]
pub bus: Option<Bus>,
#[arg(long)]
pub gitops: bool,
#[arg(long, value_enum)]
pub gitops_promote: Option<GitopsPromote>,
#[arg(long, value_name = "OWNER/REPO")]
pub github: Option<String>,
#[arg(long, value_name = "OWNER/REPO")]
pub github_preview: Option<String>,
#[arg(long, value_name = "OWNER/REPO")]
pub github_promote: Option<String>,
#[arg(
long,
alias = "storage",
visible_alias = "storage",
visible_alias = "read-model",
value_enum,
default_value = "postgres"
)]
pub store: Store,
#[arg(long)]
pub distributed_path: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub struct DescribeArgs {
#[arg(long, default_value = ".")]
pub path: PathBuf,
#[arg(long)]
pub manifest_path: Option<PathBuf>,
#[arg(long)]
pub package: Option<String>,
#[arg(long, value_delimiter = ',')]
pub features: Vec<String>,
#[arg(long)]
pub no_default_features: bool,
#[arg(long)]
pub entrypoint: Option<String>,
#[arg(long, value_enum, default_value = "json")]
pub format: ManifestFormat,
#[arg(long)]
pub distributed_path: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct SchemaArgs {
#[arg(long, default_value = ".")]
pub path: PathBuf,
#[arg(long)]
pub manifest_path: Option<PathBuf>,
#[arg(long)]
pub package: Option<String>,
#[arg(long, value_delimiter = ',')]
pub features: Vec<String>,
#[arg(long)]
pub no_default_features: bool,
#[arg(long)]
pub entrypoint: Option<String>,
#[arg(long, value_enum, default_value = "postgres")]
pub dialect: SchemaDialect,
#[arg(long, value_enum, default_value = "sql")]
pub format: SchemaFormat,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub namespace: Option<String>,
#[arg(long, value_name = "SECRET")]
pub db_secret: Option<String>,
#[arg(long, default_value = "url")]
pub db_secret_key: String,
#[arg(long)]
pub db_url: Option<String>,
#[arg(long)]
pub dev_url: Option<String>,
#[arg(long, alias = "output", visible_alias = "output")]
pub out: Option<PathBuf>,
#[arg(long)]
pub distributed_path: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Framework {
Distributed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Transport {
Http,
Knative,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum GitopsPromote {
Argo,
Flux,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Bus {
Rabbitmq,
Kafka,
Psql,
Nats,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Store {
Postgres,
Sqlite,
InMemory,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum ManifestFormat {
Json,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum SchemaDialect {
Postgres,
Sqlite,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum SchemaFormat {
Sql,
Atlas,
}
impl From<Transport> for ServiceTransport {
fn from(transport: Transport) -> Self {
match transport {
Transport::Http => ServiceTransport::Http,
Transport::Knative => ServiceTransport::Knative,
}
}
}
impl From<Store> for StoreTarget {
fn from(store: Store) -> Self {
match store {
Store::Postgres => StoreTarget::Postgres,
Store::Sqlite => StoreTarget::Sqlite,
Store::InMemory => StoreTarget::InMemory,
}
}
}
impl From<Bus> for BusTarget {
fn from(bus: Bus) -> Self {
match bus {
Bus::Rabbitmq => BusTarget::Rabbitmq,
Bus::Kafka => BusTarget::Kafka,
Bus::Psql => BusTarget::Psql,
Bus::Nats => BusTarget::Nats,
}
}
}
impl From<GitopsPromote> for GitopsPromoteTarget {
fn from(promote: GitopsPromote) -> Self {
match promote {
GitopsPromote::Argo => GitopsPromoteTarget::Argo,
GitopsPromote::Flux => GitopsPromoteTarget::Flux,
}
}
}
pub fn run(args: &ServiceArgs) -> Result<(), Box<dyn Error>> {
match &args.command {
ServiceCommands::Scaffold(scaffold) => run_scaffold(scaffold),
ServiceCommands::Describe(describe) => run_describe(describe),
ServiceCommands::Schema(schema) => run_schema(schema),
}
}
fn run_scaffold(args: &ScaffoldArgs) -> Result<(), Box<dyn Error>> {
validate_scaffold_kind(args.framework, args.kind.as_deref())?;
let transport = if args.http && args.knative {
return Err("--http and --knative cannot be used together".into());
} else if args.http {
Transport::Http
} else if args.knative {
Transport::Knative
} else {
args.transport
};
let github = parse_optional_github_repo(args.github.as_deref(), "--github")?;
let github_preview =
parse_optional_github_repo(args.github_preview.as_deref(), "--github-preview")?;
let github_promote =
parse_optional_github_repo(args.github_promote.as_deref(), "--github-promote")?;
let package_name = package_name(&args.name)?;
let output_dir = args
.path
.clone()
.unwrap_or_else(|| PathBuf::from(&package_name));
let output_dir = absolute_path(&output_dir)?;
ensure_output_dir(&output_dir, args.force)?;
let distributed_path = resolve_distributed_path(args.distributed_path.as_deref(), &output_dir)?;
let distributed_dependency_path = path_for_toml(&relative_path(&output_dir, &distributed_path));
let spec = ServiceScaffoldSpec {
name: args.name.clone(),
transport: transport.into(),
store: args.store.into(),
bus: args.bus.map(Into::into),
models: args.model.clone(),
read_models: args.read_models,
commands: args.command.clone(),
events: args.event.clone(),
distributed_dependency_path,
gitops: args.gitops,
gitops_promote: args.gitops_promote.map(Into::into),
github,
github_preview,
github_promote,
};
let project = generate_service_scaffold(spec)?;
for file in &project.files {
write_generated_file(&output_dir, file)?;
}
for warning in &project.warnings {
eprintln!("warning: {warning}");
}
for action in &project.post_create_actions {
match action {
PostCreateAction::EnsureGithubRepository { repo } => ensure_github_repo(repo)?,
}
}
println!("Scaffolded Distributed service at {}", output_dir.display());
Ok(())
}
fn run_describe(args: &DescribeArgs) -> Result<(), Box<dyn Error>> {
match args.format {
ManifestFormat::Json => {
let json = run_manifest_harness(
&HarnessOptions {
path: args.path.clone(),
manifest_path: args.manifest_path.clone(),
package: args.package.clone(),
features: args.features.clone(),
no_default_features: args.no_default_features,
entrypoint: args.entrypoint.clone(),
distributed_path: args.distributed_path.clone(),
},
HarnessMode::DescribeJson,
)?;
let envelope: serde_json::Value = serde_json::from_str(&json)?;
validate_manifest_json(&envelope)?;
println!("{}", serde_json::to_string_pretty(&envelope)?);
Ok(())
}
}
}
fn run_schema(args: &SchemaArgs) -> Result<(), Box<dyn Error>> {
let sql = run_manifest_harness(
&HarnessOptions {
path: args.path.clone(),
manifest_path: args.manifest_path.clone(),
package: args.package.clone(),
features: args.features.clone(),
no_default_features: args.no_default_features,
entrypoint: args.entrypoint.clone(),
distributed_path: args.distributed_path.clone(),
},
HarnessMode::SchemaSql(args.dialect),
)?;
let content = match args.format {
SchemaFormat::Sql => sql,
SchemaFormat::Atlas => render_atlas_schema(&atlas_spec_from_flags(args, sql)?)?,
};
if let Some(out) = &args.out {
if let Some(parent) = out.parent().filter(|parent| !parent.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
fs::write(out, content)?;
} else {
print!("{content}");
}
Ok(())
}
fn atlas_spec_from_flags(
args: &SchemaArgs,
sql: String,
) -> Result<AtlasSchemaSpec, Box<dyn Error>> {
let name = args
.name
.clone()
.ok_or("--name is required for --format atlas")?;
let database = match (&args.db_url, &args.db_secret) {
(Some(_), Some(_)) => {
return Err("pass either --db-url or --db-secret, not both".into());
}
(Some(url), None) => AtlasDatabaseUrl::Inline(url.clone()),
(None, Some(secret)) => AtlasDatabaseUrl::SecretKeyRef {
name: secret.clone(),
key: args.db_secret_key.clone(),
},
(None, None) => {
return Err(
"--format atlas needs a database: pass --db-secret <name> (GitOps) or --db-url <url> (dev)"
.into(),
);
}
};
Ok(AtlasSchemaSpec {
name,
namespace: args.namespace.clone(),
database,
dev_url: args.dev_url.clone(),
sql,
})
}
fn validate_scaffold_kind(framework: Framework, kind: Option<&str>) -> Result<(), Box<dyn Error>> {
if framework != Framework::Distributed {
return Err("only --framework distributed is supported".into());
}
if let Some(kind) = kind {
match kind {
"distributed-microsvc" | "distributed" => {}
_ => {
return Err(format!(
"unsupported service kind `{kind}`; expected distributed-microsvc"
)
.into());
}
}
}
Ok(())
}
fn ensure_output_dir(path: &Path, force: bool) -> Result<(), Box<dyn Error>> {
if path.exists() {
if !path.is_dir() {
return Err(format!("{} exists and is not a directory", path.display()).into());
}
if !force && fs::read_dir(path)?.next().is_some() {
return Err(format!(
"{} already exists and is not empty; pass --force to overwrite generated files",
path.display()
)
.into());
}
}
fs::create_dir_all(path)?;
Ok(())
}
fn write_generated_file(output_dir: &Path, file: &GeneratedFile) -> Result<(), Box<dyn Error>> {
let path = output_dir.join(&file.path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, &file.contents)?;
if file.mode == Some(FileMode::Executable) {
set_executable(&path)?;
}
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<(), Box<dyn Error>> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<(), Box<dyn Error>> {
Ok(())
}
fn parse_optional_github_repo(
raw: Option<&str>,
flag: &str,
) -> Result<Option<GithubRepo>, Box<dyn Error>> {
raw.map(|value| {
GithubRepo::parse(value)
.map_err(|err| -> Box<dyn Error> { format!("{flag}: {err}").into() })
})
.transpose()
}
fn ensure_github_repo(repo: &GithubRepo) -> Result<(), Box<dyn Error>> {
let slug = repo.slug();
let view_output = Command::new("gh")
.args(["repo", "view", &slug, "--json", "nameWithOwner"])
.output();
match view_output {
Ok(output) if output.status.success() => {
println!("GitHub repository {slug} already exists");
return Ok(());
}
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(
"GitHub CLI (`gh`) is not installed or not in PATH. Install it before using --github."
.into(),
);
}
Err(err) => return Err(Box::new(err)),
}
let output = Command::new("gh")
.args(github_repo_create_args(&slug))
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("gh repo create failed: {stderr}").into());
}
println!("Created GitHub repository {slug}");
Ok(())
}
fn github_repo_create_args(slug: &str) -> Vec<&str> {
vec!["repo", "create", slug, "--private"]
}
fn validate_manifest_json(envelope: &serde_json::Value) -> Result<(), Box<dyn Error>> {
let Some(schema_version) = envelope
.get("schema_version")
.and_then(serde_json::Value::as_u64)
else {
return Err("manifest JSON is missing numeric schema_version".into());
};
if schema_version != DISTRIBUTED_MANIFEST_SCHEMA_VERSION {
return Err(format!(
"unsupported Distributed manifest schema version {schema_version}; expected {DISTRIBUTED_MANIFEST_SCHEMA_VERSION}"
)
.into());
}
if envelope.get("project").is_none() {
return Err("manifest JSON is missing project".into());
}
Ok(())
}
pub(crate) fn resolve_distributed_path(
provided: Option<&Path>,
anchor: &Path,
) -> Result<PathBuf, Box<dyn Error>> {
if let Some(path) = provided {
return validate_distributed_path(path);
}
if let Ok(path) = std::env::var("DISTRIBUTED_PATH") {
return validate_distributed_path(Path::new(&path));
}
let mut roots = Vec::new();
roots.extend(anchor.ancestors().map(Path::to_path_buf));
roots.extend(std::env::current_dir()?.ancestors().map(Path::to_path_buf));
for root in roots {
for candidate in [root.clone(), root.join("distributed")] {
if candidate.join("Cargo.toml").exists()
&& cargo_toml_package_name(&candidate.join("Cargo.toml")).as_deref()
== Some("distributed")
{
return Ok(candidate.canonicalize()?);
}
}
}
Err("unable to find local Distributed crate; pass --distributed-path".into())
}
fn validate_distributed_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
let path = path.canonicalize()?;
let manifest = path.join("Cargo.toml");
if !manifest.exists() {
return Err(format!("{} does not contain Cargo.toml", path.display()).into());
}
if cargo_toml_package_name(&manifest).as_deref() != Some("distributed") {
return Err(format!("{} is not the Distributed crate", path.display()).into());
}
Ok(path)
}
fn cargo_toml_package_name(path: &Path) -> Option<String> {
let contents = fs::read_to_string(path).ok()?;
let mut in_package = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed == "[package]" {
in_package = true;
continue;
}
if trimmed.starts_with('[') {
in_package = false;
}
if in_package {
if let Some(value) = trimmed.strip_prefix("name") {
let value = value.trim_start();
if let Some(value) = value.strip_prefix('=') {
return value.trim().trim_matches('"').to_string().into();
}
}
}
}
None
}
fn absolute_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()?.join(path))
}
}
fn relative_path(from_dir: &Path, to: &Path) -> PathBuf {
let from = path_components(from_dir);
let to = path_components(to);
let common = from
.iter()
.zip(to.iter())
.take_while(|(left, right)| left == right)
.count();
let mut relative = PathBuf::new();
for _ in common..from.len() {
relative.push("..");
}
for component in &to[common..] {
relative.push(component);
}
if relative.as_os_str().is_empty() {
PathBuf::from(".")
} else {
relative
}
}
fn path_components(path: &Path) -> Vec<OsString> {
path.components()
.filter_map(|component| match component {
Component::Normal(value) => Some(value.to_os_string()),
_ => None,
})
.collect()
}
pub(crate) fn path_for_toml(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
fn schema_args() -> SchemaArgs {
SchemaArgs {
path: PathBuf::from("."),
manifest_path: None,
package: None,
features: Vec::new(),
no_default_features: false,
entrypoint: None,
dialect: SchemaDialect::Postgres,
format: SchemaFormat::Atlas,
name: Some("orders".to_string()),
namespace: None,
db_secret: Some("orders-db".to_string()),
db_secret_key: "url".to_string(),
db_url: None,
dev_url: None,
out: None,
distributed_path: None,
}
}
#[test]
fn github_repo_create_args_are_private() {
assert_eq!(
github_repo_create_args("hops-ops/test-domain"),
vec!["repo", "create", "hops-ops/test-domain", "--private"]
);
}
#[test]
fn optional_github_repo_reports_the_flag_on_error() {
let err = parse_optional_github_repo(Some("missing-repo"), "--github")
.expect_err("invalid repo should error");
assert!(err.to_string().contains("--github"));
assert!(parse_optional_github_repo(None, "--github")
.unwrap()
.is_none());
let ok = parse_optional_github_repo(Some("hops-ops/test-domain"), "--github")
.unwrap()
.unwrap();
assert_eq!(ok.slug(), "hops-ops/test-domain");
}
#[test]
fn atlas_spec_uses_secret_ref_by_default() {
let spec = atlas_spec_from_flags(&schema_args(), "CREATE TABLE orders (id text);".into())
.expect("secret ref spec");
assert_eq!(
spec.database,
AtlasDatabaseUrl::SecretKeyRef {
name: "orders-db".to_string(),
key: "url".to_string(),
}
);
assert_eq!(spec.name, "orders");
}
#[test]
fn atlas_inline_url_when_db_url_given() {
let mut args = schema_args();
args.db_secret = None;
args.db_url = Some("postgres://localhost/orders".to_string());
let spec = atlas_spec_from_flags(&args, "CREATE TABLE orders (id text);".into()).unwrap();
assert_eq!(
spec.database,
AtlasDatabaseUrl::Inline("postgres://localhost/orders".to_string())
);
}
#[test]
fn atlas_requires_name_and_a_database() {
let mut no_name = schema_args();
no_name.name = None;
assert!(atlas_spec_from_flags(&no_name, "sql".into()).is_err());
let mut no_db = schema_args();
no_db.db_secret = None;
assert!(atlas_spec_from_flags(&no_db, "sql".into()).is_err());
let mut both = schema_args();
both.db_url = Some("postgres://localhost/orders".to_string());
assert!(atlas_spec_from_flags(&both, "sql".into()).is_err());
}
}