Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions client/src/generated/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3682,6 +3682,16 @@ impl<S: Stamp> TurnkeyClient<S> {
app_proofs: activity.app_proofs,
})
}
/// Validate Container Image for TVC
///
/// Validate a container image URL and pull secret for TVC deployment
pub async fn validate_tvc_image(
&self,
request: coordinator::ValidateTvcImageRequest,
) -> Result<coordinator::ValidateTvcImageResponse, TurnkeyClientError> {
self.process_request(&request, "/public/v1/query/validate_tvc_image".to_string())
.await
}
/// List TVC Deployments
///
/// List all deployments for a given TVC App
Expand Down
4 changes: 4 additions & 0 deletions client/src/generated/external.data.v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,8 @@ pub struct TvcApp {
pub created_at: ::core::option::Option<Timestamp>,
#[serde(default)]
pub updated_at: ::core::option::Option<Timestamp>,
#[serde(default)]
pub live_deployment_id: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Debug)]
#[derive(::serde::Serialize, ::serde::Deserialize)]
Expand All @@ -553,6 +555,8 @@ pub struct TvcDeployment {
pub created_at: ::core::option::Option<Timestamp>,
#[serde(default)]
pub updated_at: ::core::option::Option<Timestamp>,
#[serde(default)]
pub delete: bool,
}
#[derive(Debug)]
#[derive(::serde::Serialize, ::serde::Deserialize)]
Expand Down
19 changes: 19 additions & 0 deletions client/src/generated/services.coordinator.public.v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,25 @@ pub struct GetTvcDeploymentResponse {
#[derive(::serde::Serialize, ::serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Clone, PartialEq)]
pub struct ValidateTvcImageRequest {
pub organization_id: ::prost::alloc::string::String,
pub pivot_container_image_url: ::prost::alloc::string::String,
#[serde(default)]
pub pivot_container_encrypted_pull_secret: ::core::option::Option<
::prost::alloc::string::String,
>,
}
#[derive(Debug)]
#[derive(::serde::Serialize, ::serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Clone, PartialEq)]
pub struct ValidateTvcImageResponse {
pub resolved_image_digest: ::prost::alloc::string::String,
}
#[derive(Debug)]
#[derive(::serde::Serialize, ::serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Clone, PartialEq)]
pub struct GetAppStatusRequest {
pub organization_id: ::prost::alloc::string::String,
pub app_id: ::prost::alloc::string::String,
Expand Down
5 changes: 5 additions & 0 deletions proto/external/data/v1/tvc.proto
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ message TvcApp {
];
Timestamp created_at = 8 [(google.api.field_behavior) = REQUIRED];
Timestamp updated_at = 9 [(google.api.field_behavior) = REQUIRED];
optional string live_deployment_id = 10 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "The deployment currently designated to receive traffic. Null if no deployment for this app is deployed."}];
}

message TvcDeployment {
Expand Down Expand Up @@ -85,6 +86,10 @@ message TvcDeployment {
];
Timestamp created_at = 12 [(google.api.field_behavior) = REQUIRED];
Timestamp updated_at = 13 [(google.api.field_behavior) = REQUIRED];
bool delete = 14 [
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Whether or not the user wants this deployment deleted from the cluster."}
];
}

message TvcContainerSpec {
Expand Down
1 change: 1 addition & 0 deletions proto/external/errors/v1/errors.proto
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ enum TurnkeyErrorCode {
EMAIL_SENDING_DISABLED = 32;
ORGANIZATION_MISMATCH = 33;
MAX_OTP_INITIATED = 34;
TVC_IMAGE_VALIDATION_FAILED = 35;
}
2 changes: 1 addition & 1 deletion proto/immutable/activity/v1/activity.proto
Original file line number Diff line number Diff line change
Expand Up @@ -4200,7 +4200,7 @@ message CreateTvcDeploymentIntent {
];
immutable.common.v1.TvcHealthCheckType health_check_type = 14 [
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Heath check type (TVC_HEALTH_CHECK_TYPE_HTTP or TVC_HEALTH_CHECK_TYPE_GRPC). HTTP health checks are made with a GET request on /health, and gRPC health checks follow the standard gRPC health checking protocol."}
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Health check type (TVC_HEALTH_CHECK_TYPE_HTTP or TVC_HEALTH_CHECK_TYPE_GRPC). HTTP health checks are made with a GET request on /health, and gRPC health checks follow the standard gRPC health checking protocol."}
];
uint32 health_check_port = 15 [
(google.api.field_behavior) = REQUIRED,
Expand Down
35 changes: 34 additions & 1 deletion proto/services/coordinator/public/v1/public_api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1755,6 +1755,20 @@ service PublicApiService {
};
}

rpc ValidateTvcImage(ValidateTvcImageRequest) returns (ValidateTvcImageResponse) {
// TODO: remove me when it's time to unleash TVC
option (google.api.method_visibility).restriction = "INTERNAL";
option (google.api.http) = {
post: "/public/v1/query/validate_tvc_image"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
description: "Validate a container image URL and pull secret for TVC deployment"
summary: "Validate Container Image for TVC"
tags: "TVC"
};
}

rpc GetTvcAppDeployments(GetTvcAppDeploymentsRequest) returns (GetTvcAppDeploymentsResponse) {
// TODO: remove me when it's time to unleash TVC
option (google.api.method_visibility).restriction = "INTERNAL";
Expand Down Expand Up @@ -2683,6 +2697,25 @@ message GetTvcDeploymentResponse {
];
}

message ValidateTvcImageRequest {
string organization_id = 1 [
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Unique identifier for a given Organization."}
];
string pivot_container_image_url = 2 [
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "URL of the container image."}
];
optional string pivot_container_encrypted_pull_secret = 3 [
(google.api.field_behavior) = OPTIONAL,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "HPKE-encrypted pull secret for private images."}
];
}

message ValidateTvcImageResponse {
string resolved_image_digest = 1 [(google.api.field_behavior) = OPTIONAL];
}

message GetAppStatusRequest {
string organization_id = 1 [
(google.api.field_behavior) = REQUIRED,
Expand Down Expand Up @@ -2712,7 +2745,7 @@ message GetWalletAddressBalancesRequest {
];
string address = 2 [
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Address corresponding to a wallet account."}
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {description: "Address corresponding to a wallet account. Private key addresses are not supported."}
];
string caip2 = 3 [
(google.api.field_behavior) = REQUIRED,
Expand Down
2 changes: 1 addition & 1 deletion tvc/src/commands/app/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! App commands.

pub mod create;
pub mod status;
pub mod init;
pub mod list;
pub mod status;
9 changes: 3 additions & 6 deletions tvc/src/commands/app/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,12 @@ pub async fn run(args: Args) -> anyhow::Result<()> {

let app_status = crate::commands::app_status::sanitize_app_status(
response
.app_status
.ok_or_else(|| anyhow!("no status returned for app: {}", args.app_id))?,
.app_status
.ok_or_else(|| anyhow!("no status returned for app: {}", args.app_id))?,
);

println!("App ID: {}", app_status.app_id);
println!(
"Targeted Deployment: {}",
app_status.targeted_deployment_id
);
println!("Targeted Deployment: {}", app_status.targeted_deployment_id);

if app_status.deployments.is_empty() {
println!();
Expand Down
103 changes: 88 additions & 15 deletions tvc/src/commands/deploy/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use anyhow::{Context, Result};
use clap::Args as ClapArgs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use turnkey_client::generated::CreateTvcDeploymentIntent;
use turnkey_client::generated::{CreateTvcDeploymentIntent, ValidateTvcImageRequest};

/// Create a new TVC deployment from a config file.
#[derive(Debug, ClapArgs)]
Expand All @@ -24,6 +24,47 @@ pub struct Args {
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}") // works for docker pull even if image_url included a :tag
}
}

/// Run the deploy create command.
pub async fn run(args: Args) -> Result<()> {
// Read and parse config file
Expand Down Expand Up @@ -69,21 +110,32 @@ pub async fn run(args: Args) -> Result<()> {
None => deploy_config.pivot_container_encrypted_pull_secret.clone(),
};

// Convert config to API intent
let intent = CreateTvcDeploymentIntent {
app_id: deploy_config.app_id.clone(),
qos_version: deploy_config.qos_version.clone(),
pivot_container_image_url: deploy_config.pivot_container_image_url.clone(),
pivot_path: deploy_config.pivot_path.clone(),
pivot_args: deploy_config.pivot_args.clone(),
expected_pivot_digest: deploy_config.expected_pivot_digest.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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the purpose of doing this extra work to ensure the image URL is hash pinned? Is it to help ensure the pivot hash resolves correctly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TVC requires hashpinned image URLs (or deploys fail), so we're soft-enforcing it in the frontend and here

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,
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,
};
);

// Get timestamp
let timestamp_ms = SystemTime::now()
Expand Down Expand Up @@ -117,3 +169,24 @@ pub async fn run(args: Args) -> Result<()> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::pin_image_url;

#[test]
fn pin_image_url_appends_digest_to_tagged_reference() {
let image_url = "ghcr.io/team/app:latest";
let pinned = pin_image_url(image_url, "sha256:abc123");

assert_eq!(pinned, "ghcr.io/team/app:latest@sha256:abc123");
}

#[test]
fn pin_image_url_appends_digest_to_untagged_reference() {
let image_url = "ghcr.io/team/app";
let pinned = pin_image_url(image_url, "sha256:abc123");

assert_eq!(pinned, "ghcr.io/team/app@sha256:abc123");
}
}
5 changes: 2 additions & 3 deletions tvc/src/commands/deploy/get_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ pub async fn run(args: Args) -> anyhow::Result<()> {

let app_status = crate::commands::app_status::sanitize_app_status(
app_response
.app_status
.ok_or_else(|| anyhow!("no status returned for app: {}", deployment.app_id))?,
.app_status
.ok_or_else(|| anyhow!("no status returned for app: {}", deployment.app_id))?,
);

println!("Deployment: {}", deployment.id);
Expand Down Expand Up @@ -110,5 +110,4 @@ mod tests {

assert!(deployment_status.is_some());
}

}
Loading