A Deploy CLI
A deployment tool with a shared config file, per-environment overrides via env
substitution, an enum-backed target, and a deploy subcommand whose required
inputs are validated after parsing with
requirements extraction.
src/main.rs
rust
use facet:: Facet ;
use figue::{ self as args, builder, Driver , FigueBuiltins };
/// Deploy things to environments.
# [ derive ( Facet , Debug )]
struct App {
# [ facet ( args :: config , args :: env_prefix = "DEPLOY" )]
config : Config ,
# [ facet ( args :: subcommand )]
command : Command ,
# [ facet ( flatten )]
builtins : FigueBuiltins ,
}
# [ derive ( Facet , Debug )]
struct Config {
/// Registry host. `${REGISTRY:-ghcr.io}` lets CI override it.
# [ facet ( args :: env_subst , default = "${REGISTRY:-ghcr.io}" )]
registry : String ,
/// Image to deploy. Optional in general; required by `deploy`.
# [ facet ( default )]
image : Option < String >,
/// Where to deploy.
# [ facet ( default )]
target : Target ,
/// Concurrency for rollout.
# [ facet ( default = 3 )]
max_in_flight : u32 ,
}
# [ derive ( Facet , Debug , Default )]
# [ facet ( rename_all = "kebab-case" )]
# [ repr ( u8 )]
enum Target {
# [ default ]
Staging ,
Production {
/// Refuse unless explicitly confirmed.
# [ facet ( default )]
confirmed : bool ,
},
}
# [ derive ( Facet , Debug )]
# [ repr ( u8 )]
enum Command {
/// Print the resolved configuration and exit.
Plan ,
/// Actually deploy.
Deploy ,
}
/// What `deploy` requires, wherever it came from.
# [ derive ( Facet , Debug )]
struct DeployRequirements {
# [ facet ( args :: origin = "config.image" )]
image : String ,
# [ facet ( args :: origin = "config.registry" )]
registry : String ,
}
fn main () {
let config = builder ::< App >()
. unwrap ()
. cli ( |c| c. args ( std:: env:: args (). skip ( 1 )))
. env ( |e| e)
. file ( |f| f
. format ( args:: JsoncFormat )
. default_paths ([ "deploy.jsonc" , "deploy.json" ]))
. help ( |h| h
. program_name ( "deploy" )
. version ( env! ( "CARGO_PKG_VERSION" )))
. build ();
let out = Driver :: new ( config). run (). into_result (). unwrap_or_else ( |e| {
eprint! ( "{e}" );
std:: process:: exit ( e. exit_code ());
});
match out. value . command {
Command :: Plan => {
println! ( "registry = {}" , out. value . config . registry );
println! ( "image = {:?}" , out. value . config . image );
println! ( "target = {:?}" , out. value . config . target );
println! ( "max_in_flight = {}" , out. value . config . max_in_flight );
}
Command :: Deploy => {
let req = out. extract ::< DeployRequirements >(). unwrap_or_else ( |e| {
eprint! ( "{e}" );
std:: process:: exit ( 1 );
});
if let Target :: Production { confirmed : false } = out. value . config . target {
eprintln! ( "refusing: production deploy needs \
--config.target.production.confirmed" );
std:: process:: exit ( 1 );
}
println! ( "deploying {}/{} …" , req. registry , req. image );
}
}
} deploy.jsonc
jsonc
{
"config" : {
// ${REGISTRY} is filled from the environment at load time
"registry" : "${REGISTRY:-ghcr.io}" ,
"max_in_flight" : 5 ,
"target" : "staging"
}
}Sessions
bash
# Plan against staging using the file's defaults
deploy plan
# CI: registry comes from the environment via ${REGISTRY}
REGISTRY =registry.internal deploy plan
# Deploy — image is required only for THIS subcommand
deploy deploy
# Missing required fields for this operation:
# image <String> at config.image
# Set via: --config.image or $DEPLOY__CONFIG__IMAGE
deploy deploy --config.image app:1.4.2
deploy deploy --config.image app:1.4.2 \
--config.target.production.confirmed What this demonstrates
- Env substitution with a default:
registryuses${REGISTRY:-ghcr.io}, so it works locally and lets CI inject a value — without makingregistryan env-sourced field. - JSONC config: comments in
deploy.jsonc, enabled with one.format(JsoncFormat). - Enum target with a struct variant:
--config.target.production.confirmednavigates into the active variant from the CLI. - Per-operation requirements:
imageis optional globally (soplandoesn't need it) butdeployextractsDeployRequirementsand fails with a precise, actionable message if it's missing. - Manual outcome handling:
.into_result()so we can run requirements extraction and a domain check (production confirmation) before doing work.