use crate::client::build_client;
use crate::config::deploy::DeployConfig;
use crate::pull_secret::encrypt_pivot_pull_secret;
use anyhow::{bail, Context, Result};
use clap::Args as ClapArgs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use turnkey_client::generated::{CreateTvcDeploymentIntent, ValidateTvcImageRequest};
pub(crate) const LONG_ABOUT: &str = "\
Create a new TVC deployment.
Use --config-file, flags, env vars, or a mix of them. Command-line flags
override env vars; env vars override config file values. If --config-file is
omitted, all required deployment fields must be provided by flags or env vars.
Required deployment fields:
--app-id / TVC_APP_ID
--qos-version / TVC_QOS_VERSION
--pivot-image-url / TVC_PIVOT_IMAGE_URL
--pivot-path / TVC_PIVOT_PATH
--expected-pivot-digest / TVC_EXPECTED_PIVOT_DIGEST
Special rules:
--pivot-args replaces the config file's list entirely (does not append).
--debug-mode can enable debug mode but cannot disable a true config value.
--pivot-pull-secret reads an unencrypted pull secret file, encrypts it for the
active org's API environment, and overrides the encrypted secret in the config.
Examples:
tvc deploy create --config-file deploy.json
# OR
TVC_ORG_ID=... \\
TVC_API_KEY_PUBLIC=... \\
TVC_API_KEY_PRIVATE=... \\
TVC_APP_ID=... \\
TVC_QOS_VERSION=... \\
TVC_PIVOT_PATH=... \\
TVC_PIVOT_IMAGE_URL=... \\
TVC_EXPECTED_PIVOT_DIGEST=... \\
tvc deploy create";
#[derive(Debug, ClapArgs)]
#[command(about, long_about = None)]
pub struct Args {
#[arg(short = 'c', long, value_name = "PATH", env = "TVC_DEPLOY_CONFIG")]
pub config_file: Option<PathBuf>,
#[arg(long, env = "TVC_APP_ID")]
pub app_id: Option<String>,
#[arg(long, env = "TVC_QOS_VERSION")]
pub qos_version: Option<String>,
#[arg(long, env = "TVC_PIVOT_IMAGE_URL")]
pub pivot_image_url: Option<String>,
#[arg(long, env = "TVC_EXPECTED_PIVOT_DIGEST")]
pub expected_pivot_digest: Option<String>,
#[arg(long, env = "TVC_PIVOT_PATH")]
pub pivot_path: Option<String>,
#[arg(
long,
value_name = "ARG",
value_delimiter = ',',
env = "TVC_PIVOT_ARGS"
)]
pub pivot_args: Vec<String>,
#[arg(long, env = "TVC_DEBUG_MODE")]
pub debug_mode: bool,
#[arg(long, env = "TVC_HEALTH_CHECK_PORT")]
pub health_check_port: Option<u16>,
#[arg(long, env = "TVC_PUBLIC_INGRESS_PORT")]
pub public_ingress_port: Option<u16>,
#[arg(long, value_name = "PATH", env = "TVC_PIVOT_PULL_SECRET")]
pub pivot_pull_secret: Option<PathBuf>,
}
fn build_validate_image_request(
organization_id: &str,
image_url: &str,
pivot_container_encrypted_pull_secret: Option<String>,
) -> ValidateTvcImageRequest {
ValidateTvcImageRequest {
organization_id: organization_id.to_string(),
pivot_container_image_url: image_url.to_string(),
pivot_container_encrypted_pull_secret,
}
}
fn build_create_intent(
deploy_config: &DeployConfig,
pivot_container_image_url: String,
pivot_container_encrypted_pull_secret: Option<String>,
) -> CreateTvcDeploymentIntent {
CreateTvcDeploymentIntent {
app_id: deploy_config.app_id.clone(),
qos_version: deploy_config.qos_version.clone(),
pivot_container_image_url,
pivot_path: deploy_config.pivot_path.clone(),
pivot_args: deploy_config.pivot_args.clone(),
expected_pivot_digest: deploy_config.expected_pivot_digest.clone(),
pivot_container_encrypted_pull_secret,
debug_mode: deploy_config.debug_mode,
nonce: None,
health_check_type: deploy_config.health_check_type,
health_check_port: deploy_config.health_check_port as u32,
public_ingress_port: deploy_config.public_ingress_port as u32,
}
}
fn pin_image_url(image_url: &str, resolved_digest: &str) -> String {
if image_url.contains("@") {
image_url.to_string()
} else {
format!("{image_url}@{resolved_digest}") }
}
fn apply_overrides(config: &mut DeployConfig, args: &Args) {
if let Some(v) = &args.app_id {
config.app_id = v.clone();
}
if let Some(v) = &args.qos_version {
config.qos_version = v.clone();
}
if let Some(v) = &args.pivot_image_url {
config.pivot_container_image_url = v.clone();
}
if let Some(v) = &args.expected_pivot_digest {
config.expected_pivot_digest = v.clone();
}
if let Some(v) = &args.pivot_path {
config.pivot_path = v.clone();
}
if !args.pivot_args.is_empty() {
config.pivot_args = args.pivot_args.clone();
}
if args.debug_mode {
config.debug_mode = Some(true);
}
if let Some(v) = args.health_check_port {
config.health_check_port = v;
}
if let Some(v) = args.public_ingress_port {
config.public_ingress_port = v;
}
}
fn resolve_deploy_config(args: &Args) -> Result<DeployConfig> {
let mut config = match &args.config_file {
Some(path) => {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse config file: {}", path.display()))?
}
None => {
let mut t = DeployConfig::template(None);
t.pivot_container_encrypted_pull_secret = None;
t
}
};
apply_overrides(&mut config, args);
let missing = config.missing_required_fields();
if !missing.is_empty() {
let suggestion = if args.config_file.is_some() {
"Edit the config file or override via flag/TVC_* env."
} else {
"Provide via flag, TVC_* env, or --config-file."
};
bail!(
"missing required values: {}. {suggestion}",
missing.join(", ")
);
}
Ok(config)
}
pub async fn run(args: Args) -> Result<()> {
let deploy_config = resolve_deploy_config(&args)?;
println!("Creating deployment for app '{}'...", deploy_config.app_id);
let auth = build_client().await?;
let pivot_container_encrypted_pull_secret = match args.pivot_pull_secret.as_ref() {
Some(path) => {
let pull_secret = std::fs::read_to_string(path).with_context(|| {
format!("failed to read pivot pull secret file: {}", path.display())
})?;
if pull_secret.trim().is_empty() {
bail!(
"pivot pull secret file is empty after trimming whitespace: {}",
path.display()
);
}
Some(encrypt_pivot_pull_secret(&pull_secret, &auth.api_base_url)?)
}
None => deploy_config.pivot_container_encrypted_pull_secret.clone(),
};
let validate_image_request = build_validate_image_request(
&auth.org_id,
&deploy_config.pivot_container_image_url,
pivot_container_encrypted_pull_secret.clone(),
);
let validate_image_response = auth
.client
.validate_tvc_image(validate_image_request)
.await
.context("failed to validate TVC image")?;
let pinned_image_url = pin_image_url(
&deploy_config.pivot_container_image_url,
&validate_image_response.resolved_image_digest,
);
if pinned_image_url != deploy_config.pivot_container_image_url {
println!("Using pinned image reference for deployment request: {pinned_image_url}");
}
let intent = build_create_intent(
&deploy_config,
pinned_image_url,
pivot_container_encrypted_pull_secret,
);
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system time before unix epoch")?
.as_millis();
let result = auth
.client
.create_tvc_deployment(auth.org_id, timestamp_ms, intent)
.await
.context("failed to create TVC deployment")?;
println!();
println!("Deployment created successfully!");
println!();
println!("Deployment ID: {}", result.result.deployment_id);
println!("App ID: {}", deploy_config.app_id);
if let Some(path) = &args.config_file {
println!("Config: {}", path.display());
}
println!();
println!("Next steps:");
println!(
" - Run `WIP: tvc deploy status {}` to check deployment status",
result.result.deployment_id
);
println!(
" - Run `tvc deploy approve --deploy-id {}` to approve the manifest",
result.result.deployment_id
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn pin_image_url_appends_digest_to_tagged_reference() {
let pinned = pin_image_url("ghcr.io/team/app:latest", "sha256:abc123");
assert_eq!(pinned, "ghcr.io/team/app:latest@sha256:abc123");
}
#[test]
fn pin_image_url_appends_digest_to_untagged_reference() {
let pinned = pin_image_url("ghcr.io/team/app", "sha256:abc123");
assert_eq!(pinned, "ghcr.io/team/app@sha256:abc123");
}
fn empty_args() -> Args {
Args {
config_file: None,
app_id: None,
qos_version: None,
pivot_image_url: None,
expected_pivot_digest: None,
pivot_path: None,
pivot_args: vec![],
debug_mode: false,
health_check_port: None,
public_ingress_port: None,
pivot_pull_secret: None,
}
}
fn all_required_flags() -> Args {
Args {
app_id: Some("flag-app-id".into()),
qos_version: Some("flag-qos".into()),
pivot_image_url: Some("flag-image".into()),
expected_pivot_digest: Some("flag-digest".into()),
pivot_path: Some("flag-path".into()),
..empty_args()
}
}
fn file_config() -> DeployConfig {
let mut c = DeployConfig::template(None);
c.app_id = "file-app-id".into();
c.qos_version = "file-qos".into();
c.pivot_container_image_url = "file-image".into();
c.pivot_path = "file-path".into();
c.pivot_args = vec!["a".into(), "b".into()];
c.expected_pivot_digest = "file-digest".into();
c.debug_mode = Some(false);
c.pivot_container_encrypted_pull_secret = None;
c.health_check_port = 4000;
c.public_ingress_port = 5000;
c
}
fn write_config(config: &DeployConfig) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(serde_json::to_string(config).unwrap().as_bytes())
.unwrap();
f
}
#[test]
fn flag_overrides_file_value() {
let file = write_config(&file_config());
let args = Args {
config_file: Some(file.path().to_path_buf()),
app_id: Some("flag-app-id".into()),
..empty_args()
};
let resolved = resolve_deploy_config(&args).unwrap();
assert_eq!(resolved.app_id, "flag-app-id");
assert_eq!(resolved.qos_version, "file-qos");
assert_eq!(resolved.health_check_port, 4000);
}
#[test]
fn file_value_used_when_flag_absent() {
let file = write_config(&file_config());
let args = Args {
config_file: Some(file.path().to_path_buf()),
..empty_args()
};
let resolved = resolve_deploy_config(&args).unwrap();
assert_eq!(resolved.app_id, "file-app-id");
assert_eq!(resolved.qos_version, "file-qos");
assert_eq!(resolved.health_check_port, 4000);
}
#[test]
fn no_file_uses_flag_only_with_template_defaults() {
let resolved = resolve_deploy_config(&all_required_flags()).unwrap();
assert_eq!(resolved.app_id, "flag-app-id");
assert_eq!(resolved.qos_version, "flag-qos");
assert_eq!(resolved.pivot_container_image_url, "flag-image");
assert_eq!(resolved.pivot_path, "flag-path");
assert_eq!(resolved.expected_pivot_digest, "flag-digest");
assert_eq!(resolved.health_check_port, 3000);
assert_eq!(resolved.public_ingress_port, 3000);
assert_eq!(resolved.debug_mode, Some(false));
assert!(resolved.pivot_args.is_empty());
assert_eq!(resolved.pivot_container_encrypted_pull_secret, None);
}
#[test]
fn no_file_no_required_flags_bails_naming_each_flag() {
let err = resolve_deploy_config(&empty_args()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("--app-id"), "{msg}");
assert!(msg.contains("--qos-version"), "{msg}");
assert!(msg.contains("--pivot-image-url"), "{msg}");
assert!(msg.contains("--pivot-path"), "{msg}");
assert!(msg.contains("--expected-pivot-digest"), "{msg}");
}
#[test]
fn pivot_args_flag_replaces_file_list() {
let file = write_config(&file_config()); let args = Args {
config_file: Some(file.path().to_path_buf()),
pivot_args: vec!["c".into()],
..empty_args()
};
let resolved = resolve_deploy_config(&args).unwrap();
assert_eq!(resolved.pivot_args, vec!["c"]);
}
}