use std::fmt::Display;
use crate::prompts;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use turnkey_client::generated::immutable::common::v1::TvcHealthCheckType;
const PULL_SECRET_PLACEHOLDER: &str = "<REMOVE_ME_IF_PIVOT_CONTAINER_URL_IS_PUBLIC>";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeployConfig {
pub app_id: String,
pub qos_version: String,
pub pivot_container_image_url: String,
pub pivot_path: String,
#[serde(default)]
pub pivot_args: Vec<String>,
pub expected_pivot_digest: String,
#[serde(default)]
pub dangerous_deploy_debug_mode: bool,
#[serde(default)]
pub pivot_container_encrypted_pull_secret: Option<String>,
pub health_check_type: TvcHealthCheckType,
pub health_check_port: u16,
pub public_ingress_port: u16,
}
impl DeployConfig {
pub fn template(app_id: Option<&str>) -> Self {
Self {
app_id: app_id.unwrap_or("<FILL_IN_APP_ID>").to_string(),
qos_version: "<FILL_IN_QOS_VERSION>".to_string(),
pivot_container_image_url: "<FILL_IN_PIVOT_CONTAINER_IMAGE_URL>".to_string(),
pivot_path: "<FILL_IN_PIVOT_PATH>".to_string(),
pivot_args: vec![],
expected_pivot_digest: "<FILL_IN_EXPECTED_PIVOT_DIGEST>".to_string(),
dangerous_deploy_debug_mode: false,
pivot_container_encrypted_pull_secret: Some(PULL_SECRET_PLACEHOLDER.to_string()),
health_check_type: TvcHealthCheckType::Http,
health_check_port: 3000,
public_ingress_port: 3000,
}
}
pub fn fill_interactively(&mut self, saved_app_id: Option<&str>) -> Result<()> {
if self.app_id.starts_with("<FILL_IN") {
self.app_id = prompts::required_text("App ID", saved_app_id)?;
}
if self.qos_version.starts_with("<FILL_IN") {
self.qos_version = prompts::required_text("QOS version", None)?;
}
if self.pivot_container_image_url.starts_with("<FILL_IN") {
self.pivot_container_image_url =
prompts::required_text("Pivot container image URL", None)?;
}
if self.pivot_path.starts_with("<FILL_IN") {
self.pivot_path = prompts::required_text("Pivot path (inside container)", None)?;
}
if self.expected_pivot_digest.starts_with("<FILL_IN") {
self.expected_pivot_digest =
prompts::required_text("Expected pivot digest (sha256:...)", None)?;
}
if self.pivot_container_encrypted_pull_secret.as_deref() == Some(PULL_SECRET_PLACEHOLDER) {
let is_public = prompts::confirm("Is the container image in a public registry?", true)?;
self.pivot_container_encrypted_pull_secret = None;
if !is_public {
println!(
"Note: pass `--pivot-pull-secret <PATH>` when running \
`tvc deploy create` to encrypt and attach the pull secret."
);
}
}
Ok(())
}
pub fn has_placeholders(&self) -> bool {
self.app_id.starts_with("<FILL_IN")
|| self.qos_version.starts_with("<FILL_IN")
|| self.pivot_container_image_url.starts_with("<FILL_IN")
|| self.pivot_path.starts_with("<FILL_IN")
|| self.expected_pivot_digest.starts_with("<FILL_IN")
|| self.pivot_container_encrypted_pull_secret.as_deref()
== Some(PULL_SECRET_PLACEHOLDER)
}
pub fn missing_required_fields(&self) -> Vec<&'static str> {
let mut missing = Vec::new();
if self.app_id.starts_with("<FILL_IN") {
missing.push("--app-id");
}
if self.qos_version.starts_with("<FILL_IN") {
missing.push("--qos-version");
}
if self.pivot_container_image_url.starts_with("<FILL_IN") {
missing.push("--pivot-image-url");
}
if self.pivot_path.starts_with("<FILL_IN") {
missing.push("--pivot-path");
}
if self.expected_pivot_digest.starts_with("<FILL_IN") {
missing.push("--expected-pivot-digest");
}
missing
}
pub fn pull_secret_is_placeholder(&self) -> bool {
self.pivot_container_encrypted_pull_secret.as_deref() == Some(PULL_SECRET_PLACEHOLDER)
}
pub fn validate(&self) -> Result<(), DeployConfigValidationErrors> {
let mut errors = Vec::new();
if self.app_id.starts_with("<FILL_IN") {
errors.push(DeployConfigValidationError::Placeholder {
field: "app_id",
placeholder: self.app_id.clone(),
});
}
if self.qos_version.starts_with("<FILL_IN") {
errors.push(DeployConfigValidationError::Placeholder {
field: "qos_version",
placeholder: self.qos_version.clone(),
});
}
if self.pivot_container_image_url.starts_with("<FILL_IN") {
errors.push(DeployConfigValidationError::Placeholder {
field: "pivot_container_image_url",
placeholder: self.pivot_container_image_url.clone(),
});
}
if self.pivot_path.starts_with("<FILL_IN") {
errors.push(DeployConfigValidationError::Placeholder {
field: "pivot_path",
placeholder: self.pivot_path.clone(),
});
}
if self.expected_pivot_digest.starts_with("<FILL_IN") {
errors.push(DeployConfigValidationError::Placeholder {
field: "expected_pivot_digest",
placeholder: self.expected_pivot_digest.clone(),
});
}
if self.pull_secret_is_placeholder() {
errors.push(DeployConfigValidationError::PullSecretPlaceholder {
placeholder: PULL_SECRET_PLACEHOLDER.to_string(),
});
}
DeployConfigValidationErrors::ok_or_errors(errors)
}
}
#[derive(Debug, Clone, Error)]
pub enum DeployConfigValidationError {
#[error("{field} contains placeholder value {placeholder}")]
Placeholder {
field: &'static str,
placeholder: String,
},
#[error(
"pivotContainerEncryptedPullSecret contains placeholder value {placeholder}; pass \
--pivot-pull-secret <PATH> or remove pivotContainerEncryptedPullSecret for public images"
)]
PullSecretPlaceholder { placeholder: String },
}
impl DeployConfigValidationError {
pub fn is_placeholder(&self) -> bool {
matches!(
self,
Self::Placeholder { .. } | Self::PullSecretPlaceholder { .. }
)
}
}
#[derive(Debug)]
pub struct DeployConfigValidationErrors(Vec<DeployConfigValidationError>);
impl DeployConfigValidationErrors {
fn ok_or_errors(errors: Vec<DeployConfigValidationError>) -> Result<(), Self> {
if errors.is_empty() {
return Ok(());
}
Err(Self(errors))
}
pub fn has_non_placeholder_error(&self) -> bool {
self.0.iter().any(|e| !e.is_placeholder())
}
}
impl Display for DeployConfigValidationErrors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = self
.0
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
Display::fmt(&s, f)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_template_is_all_placeholders() {
let config = DeployConfig::template(None);
assert!(config.has_placeholders());
}
#[test]
fn filled_config_with_pull_secret_still_placeholder_is_detected() {
let mut config = DeployConfig::template(None);
config.app_id = "app_123".into();
config.qos_version = "0.6.1".into();
config.pivot_container_image_url = "ghcr.io/x/y:v1".into();
config.pivot_path = "/bin/pivot".into();
config.expected_pivot_digest = "sha256:abc".into();
assert!(
config.has_placeholders(),
"pull-secret sentinel must count as a placeholder"
);
}
#[test]
fn fill_interactively_is_noop_when_config_has_no_placeholders() {
let mut config = DeployConfig::template(None);
config.app_id = "app_xyz".into();
config.qos_version = "0.6.1".into();
config.pivot_container_image_url = "ghcr.io/x/y:v1".into();
config.pivot_path = "/bin/pivot".into();
config.expected_pivot_digest = "sha256:abc".into();
config.pivot_container_encrypted_pull_secret = None;
config.fill_interactively(None).unwrap();
assert_eq!(config.app_id, "app_xyz");
assert_eq!(config.qos_version, "0.6.1");
assert_eq!(config.pivot_container_image_url, "ghcr.io/x/y:v1");
assert_eq!(config.pivot_path, "/bin/pivot");
assert_eq!(config.expected_pivot_digest, "sha256:abc");
assert_eq!(config.pivot_container_encrypted_pull_secret, None);
}
#[test]
fn fully_filled_config_has_no_placeholders() {
let mut config = DeployConfig::template(None);
config.app_id = "app_123".into();
config.qos_version = "0.6.1".into();
config.pivot_container_image_url = "ghcr.io/x/y:v1".into();
config.pivot_path = "/bin/pivot".into();
config.expected_pivot_digest = "sha256:abc".into();
config.pivot_container_encrypted_pull_secret = None;
assert!(!config.has_placeholders());
}
}