mod github;
mod gitops;
mod names;
mod service_crate;
pub(crate) use github::parse_github_repo;
use std::collections::BTreeSet;
use names::{
default_command_name, message_handlers_with_modules, model_scaffolds, MessageHandler,
ModelScaffold, ScaffoldNames,
};
use crate::{
BusTarget, GeneratedFile, GeneratedProject, GithubRepo, GitopsPromoteTarget, MetricsTarget,
PostCreateAction, ScaffoldError, ServiceScaffoldSpec, ServiceTransport, StoreTarget,
};
pub fn generate_service_scaffold(
spec: ServiceScaffoldSpec,
) -> Result<GeneratedProject, ScaffoldError> {
Ok(Scaffold::from_spec(spec)?.generate())
}
pub fn package_name(name: &str) -> Result<String, ScaffoldError> {
Ok(ScaffoldNames::new(name)?.package_name)
}
pub(crate) struct Scaffold {
pub(crate) names: ScaffoldNames,
pub(crate) distributed_dependency_path: String,
pub(crate) transport: ServiceTransport,
pub(crate) store: StoreTarget,
pub(crate) bus: Option<BusTarget>,
pub(crate) metrics: Option<MetricsTarget>,
pub(crate) include_read_models: bool,
pub(crate) tracing: bool,
pub(crate) gitops: bool,
pub(crate) gitops_promote: Option<GitopsPromoteTarget>,
pub(crate) github: Option<GithubRepo>,
pub(crate) github_preview: Option<GithubRepo>,
pub(crate) github_promote: Option<GithubRepo>,
pub(crate) models: Vec<ModelScaffold>,
pub(crate) read_models: Vec<ModelScaffold>,
pub(crate) commands: Vec<MessageHandler>,
pub(crate) events: Vec<MessageHandler>,
}
impl Scaffold {
fn from_spec(spec: ServiceScaffoldSpec) -> Result<Self, ScaffoldError> {
let names = ScaffoldNames::new(&spec.name)?;
let models = model_scaffolds(&spec.models)?;
let read_models = if spec.read_models {
if models.is_empty() {
vec![ModelScaffold::new(&names.package_name)?]
} else {
models.clone()
}
} else {
Vec::new()
};
let mut module_idents = BTreeSet::new();
let commands = message_handlers_with_modules(
if spec.commands.is_empty() {
vec![default_command_name(&names, &models)]
} else {
spec.commands.clone()
},
"command",
&mut module_idents,
)?;
let events =
message_handlers_with_modules(spec.events.clone(), "event", &mut module_idents)?;
Ok(Self {
names,
distributed_dependency_path: spec.distributed_dependency_path,
transport: spec.transport,
store: spec.store,
bus: spec.bus,
metrics: spec.metrics,
include_read_models: spec.read_models,
tracing: spec.tracing,
gitops: spec.gitops,
gitops_promote: spec.gitops_promote,
github: spec.github,
github_preview: spec.github_preview,
github_promote: spec.github_promote,
models,
read_models,
commands,
events,
})
}
fn generate(self) -> GeneratedProject {
let mut files = Vec::new();
let mut post_create_actions = Vec::new();
files.push(file("Cargo.toml", self.cargo_toml()));
files.push(file("src/lib.rs", self.lib_rs()));
files.push(file("src/main.rs", self.main_rs()));
files.push(file("src/manifest.rs", self.manifest_rs()));
files.push(file("src/service.rs", self.service_rs()));
if !self.models.is_empty() {
files.push(file("src/models/mod.rs", self.models_mod_rs()));
for model in &self.models {
files.push(file(
&format!("src/models/{}.rs", model.module_ident),
self.model_rs(model),
));
}
}
files.push(file("src/handlers/mod.rs", self.handlers_mod_rs()));
for command in &self.commands {
files.push(file(
&format!("src/handlers/{}.rs", command.module_ident),
self.command_handler_rs(command),
));
}
for event in &self.events {
files.push(file(
&format!("src/handlers/{}.rs", event.module_ident),
self.event_handler_rs(event),
));
}
if self.include_read_models {
files.push(file("src/read_models/mod.rs", self.read_models_mod_rs()));
}
files.extend(self.gitops_files());
files.extend(self.github_files());
if let Some(repo) = &self.github {
post_create_actions
.push(PostCreateAction::EnsureGithubRepository { repo: repo.clone() });
}
GeneratedProject {
files,
warnings: Vec::new(),
post_create_actions,
}
}
}
fn file(path: &str, contents: String) -> GeneratedFile {
GeneratedFile {
path: path.to_string(),
contents,
mode: None,
}
}
#[cfg(test)]
mod tests {
use crate::{
generate_service_scaffold, GeneratedProject, GithubRepo, MetricsTarget, PostCreateAction,
ServiceScaffoldSpec, ServiceTransport, StoreTarget,
};
fn spec(name: &str) -> ServiceScaffoldSpec {
ServiceScaffoldSpec {
name: name.to_string(),
transport: ServiceTransport::Http,
store: StoreTarget::Postgres,
bus: None,
metrics: None,
models: Vec::new(),
read_models: false,
tracing: false,
commands: Vec::new(),
events: Vec::new(),
distributed_dependency_path: "../distributed".to_string(),
gitops: false,
gitops_promote: None,
github: None,
github_preview: None,
github_promote: None,
}
}
fn paths(project: &GeneratedProject) -> Vec<&str> {
project.files.iter().map(|f| f.path.as_str()).collect()
}
fn contents<'a>(project: &'a GeneratedProject, path: &str) -> &'a str {
project
.files
.iter()
.find(|f| f.path == path)
.map(|f| f.contents.as_str())
.unwrap_or_else(|| panic!("missing file {path}"))
}
#[test]
fn generates_the_core_service_crate() {
let project = generate_service_scaffold(spec("orders")).unwrap();
let paths = paths(&project);
for expected in [
"Cargo.toml",
"src/lib.rs",
"src/main.rs",
"src/manifest.rs",
"src/service.rs",
"src/handlers/mod.rs",
] {
assert!(paths.contains(&expected), "missing {expected} in {paths:?}");
}
assert!(paths
.iter()
.any(|p| p.starts_with("src/handlers/") && *p != "src/handlers/mod.rs"));
}
#[test]
fn service_uses_the_route_bundle_api() {
let project = generate_service_scaffold(spec("orders")).unwrap();
let service = contents(&project, "src/service.rs");
assert!(
service.contains("distributed::routes!("),
"service.rs should register handlers with routes!:\n{service}"
);
assert!(
service.contains("Routes::new().with_dependencies(repo)"),
"service.rs should build a typed route bundle:\n{service}"
);
assert!(!service.contains("Service<"));
assert!(!service.contains("register_handlers!"));
}
#[test]
fn cargo_features_track_transport_and_store() {
let mut s = spec("orders");
s.store = StoreTarget::Sqlite;
let project = generate_service_scaffold(s).unwrap();
let cargo = contents(&project, "Cargo.toml");
assert!(cargo.contains("\"http\""));
assert!(cargo.contains("\"sqlite\""));
}
#[test]
fn read_models_and_models_emit_modules() {
let mut s = spec("orders");
s.models = vec!["order".to_string()];
s.read_models = true;
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
assert!(paths.contains(&"src/models/mod.rs"));
assert!(paths.contains(&"src/models/order.rs"));
assert!(paths.contains(&"src/read_models/mod.rs"));
}
#[test]
fn invalid_rust_identifier_names_are_rejected() {
assert!(generate_service_scaffold(spec("3d")).is_err());
let mut keyword = spec("orders");
keyword.models = vec!["enum".to_string()];
assert!(generate_service_scaffold(keyword).is_err());
let mut digit_model = spec("orders");
digit_model.models = vec!["3d".to_string()];
assert!(generate_service_scaffold(digit_model).is_err());
}
#[test]
fn deploy_templates_consume_helm_image_values() {
let mut s = spec("orders");
s.gitops = true;
let project = generate_service_scaffold(s).unwrap();
let deployment = contents(&project, ".gitops/deploy/templates/deployment.yaml");
assert!(
deployment.contains("{{ .Values.image.repository }}:{{ .Values.image.tag }}"),
"deployment.yaml should template the image from values:\n{deployment}"
);
assert!(!deployment.contains(":latest"));
}
#[test]
fn knative_triggers_with_colliding_names_are_deduplicated() {
let mut s = spec("orders");
s.transport = ServiceTransport::Knative;
s.gitops = true;
s.commands = vec!["orders.created".to_string(), "orders.Created".to_string()];
let project = generate_service_scaffold(s).unwrap();
let triggers = contents(&project, ".gitops/deploy/templates/knative-triggers.yaml");
assert!(triggers.contains("name: orders-created-command"));
assert!(
triggers.contains("name: orders-created-command-2"),
"the colliding trigger should be suffixed:\n{triggers}"
);
}
#[test]
fn github_generates_workflows_and_a_post_create_action() {
let mut s = spec("orders");
s.github = Some(GithubRepo::parse("hops-ops/orders").unwrap());
s.github_preview = Some(GithubRepo::parse("hops-ops/preview").unwrap());
s.github_promote = Some(GithubRepo::parse("hops-ops/prod").unwrap());
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
for expected in [
".github/workflows/version.yaml",
".github/workflows/release.yaml",
".github/workflows/preview.yaml",
".github/workflows/promote.yaml",
".gitops/preview/helm/Chart.yaml",
".gitops/promote/helm/templates/application.yaml",
".gitops/deploy/Chart.yaml",
] {
assert!(paths.contains(&expected), "missing {expected} in {paths:?}");
}
let preview = contents(&project, ".github/workflows/preview.yaml");
assert!(preview.contains("ghcr.io/hops-ops/orders"));
assert_eq!(
project.post_create_actions,
vec![PostCreateAction::EnsureGithubRepository {
repo: GithubRepo {
owner: "hops-ops".to_string(),
repo: "orders".to_string(),
},
}]
);
assert!(project.warnings.is_empty());
}
#[test]
fn github_preview_is_independent_of_the_service_repo() {
let mut s = spec("orders");
s.github_preview = Some(GithubRepo::parse("hops-ops/preview").unwrap());
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
assert!(paths.contains(&".github/workflows/preview.yaml"));
assert!(paths.contains(&".gitops/preview/helm/Chart.yaml"));
assert!(paths.contains(&".gitops/deploy/Chart.yaml"));
assert!(!paths.contains(&".github/workflows/version.yaml"));
assert!(!paths.contains(&".github/workflows/release.yaml"));
assert!(project.post_create_actions.is_empty());
let preview = contents(&project, ".github/workflows/preview.yaml");
assert!(preview.contains("ghcr.io/hops-ops/orders"));
}
#[test]
fn gitops_http_deploy_chart() {
let mut s = spec("orders");
s.gitops = true;
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
assert!(paths.contains(&".gitops/deploy/Chart.yaml"));
assert!(paths.contains(&".gitops/deploy/templates/deployment.yaml"));
assert!(paths.contains(&".gitops/deploy/templates/service.yaml"));
assert!(!paths.contains(&".gitops/deploy/templates/servicemonitor.yaml"));
assert!(!paths.contains(&".gitops/deploy/templates/prometheusrule.yaml"));
assert!(!paths.iter().any(|p| p.contains("knative")));
}
#[test]
fn gitops_http_metrics_emits_prometheus_operator_resources() {
let mut s = spec("orders");
s.gitops = true;
s.metrics = Some(MetricsTarget::Prometheus);
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
assert!(paths.contains(&".gitops/deploy/templates/servicemonitor.yaml"));
assert!(paths.contains(&".gitops/deploy/templates/prometheusrule.yaml"));
let cargo = contents(&project, "Cargo.toml");
assert!(cargo.contains("\"metrics\""));
let values = contents(&project, ".gitops/deploy/values.yaml");
assert!(values.contains("metrics:"));
assert!(values.contains("serviceMonitor:"));
assert!(values.contains("serviceMonitor:\n enabled: false"));
assert!(values.contains("prometheusRule:"));
assert!(values.contains("prometheusRule:\n enabled: false"));
let monitor = contents(&project, ".gitops/deploy/templates/servicemonitor.yaml");
assert!(monitor.contains("apiVersion: monitoring.coreos.com/v1"));
assert!(monitor.contains("kind: ServiceMonitor"));
assert!(monitor.contains("path: {{ .Values.metrics.path }}"));
}
#[test]
fn knative_metrics_does_not_emit_service_monitor() {
let mut s = spec("orders");
s.transport = ServiceTransport::Knative;
s.gitops = true;
s.metrics = Some(MetricsTarget::Prometheus);
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
assert!(!paths.contains(&".gitops/deploy/templates/servicemonitor.yaml"));
assert!(!paths.contains(&".gitops/deploy/templates/prometheusrule.yaml"));
let values = contents(&project, ".gitops/deploy/values.yaml");
assert!(!values.contains("metrics:"), "values.yaml: {values}");
assert!(!values.contains("serviceMonitor:"), "values.yaml: {values}");
assert!(!values.contains("prometheusRule:"), "values.yaml: {values}");
}
#[test]
fn tracing_scaffold_enables_otel_feature_and_gitops_env_values() {
let mut s = spec("orders");
s.tracing = true;
s.gitops = true;
let project = generate_service_scaffold(s).unwrap();
let cargo = contents(&project, "Cargo.toml");
assert!(cargo.contains("\"otel\""));
assert!(cargo.contains("opentelemetry-otlp"));
assert!(cargo.contains("\"grpc-tonic\""));
assert!(cargo.contains("tracing-subscriber"));
let main = contents(&project, "src/main.rs");
assert!(main.contains("let tracer_provider = init_tracing()?;"));
assert!(main.contains("opentelemetry_otlp::SpanExporter::builder().build()?"));
assert!(main.contains("tracing_opentelemetry::layer().with_tracer(tracer)"));
assert!(main.contains("tracer_provider.shutdown()"));
let service = contents(&project, "src/service.rs");
assert!(service.contains(".tracing(TracingManifest::otlp())"));
let values = contents(&project, ".gitops/deploy/values.yaml");
assert!(values.contains("enabled: true"));
assert!(values.contains("otlpEndpoint: \"\""));
assert!(!values.contains("otlpProtocol"));
let deployment = contents(&project, ".gitops/deploy/templates/deployment.yaml");
assert!(deployment.contains("OTEL_SERVICE_NAME"));
assert!(deployment.contains("value: \"orders\""));
assert!(!deployment.contains(".Chart.Name"));
assert!(deployment.contains("OTEL_EXPORTER_OTLP_PROTOCOL\n value: \"grpc\""));
assert!(!deployment.contains(".Values.observability.tracing.otlpProtocol"));
assert!(deployment.contains("OTEL_EXPORTER_OTLP_ENDPOINT"));
assert!(deployment.contains(".Values.observability.tracing.otlpEndpoint"));
assert!(deployment.contains(
"value: 0.0.0.0:3000\n {{ if .Values.observability.tracing.enabled }}"
));
assert!(!deployment.contains("{{- if .Values.observability.tracing.enabled }}"));
}
#[test]
fn knative_deploy_emits_brokers_and_triggers() {
let mut s = spec("orders");
s.transport = ServiceTransport::Knative;
s.gitops = true;
s.events = vec!["orders.shipped".to_string()];
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
assert!(paths.contains(&".gitops/deploy/templates/knative-service.yaml"));
assert!(paths.contains(&".gitops/deploy/templates/knative-brokers.yaml"));
let triggers = contents(&project, ".gitops/deploy/templates/knative-triggers.yaml");
assert!(triggers.contains("type: orders.shipped"));
}
#[test]
fn gitops_promote_flux() {
let mut s = spec("orders");
s.gitops_promote = Some(crate::GitopsPromoteTarget::Flux);
let project = generate_service_scaffold(s).unwrap();
let paths = paths(&project);
assert!(paths.contains(&".gitops/promote/Chart.yaml"));
assert!(paths.contains(&".gitops/promote/templates/helmrelease.yaml"));
}
#[test]
fn invalid_name_is_an_error() {
assert!(generate_service_scaffold(spec(" ")).is_err());
}
#[test]
fn github_repo_parse_rejects_bad_input() {
assert!(GithubRepo::parse("no-slash").is_err());
assert!(GithubRepo::parse("a/b/c").is_err());
assert_eq!(GithubRepo::parse("o/r").unwrap().slug(), "o/r");
}
}