Skip to content
Open
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
806 changes: 647 additions & 159 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ qos_p256 = { version = "0.5.0", default-features = false }
# Encoding and serialization
base64 = { version = "0.22.0", default-features = false, features = ["std"] }
bs58 = { version = "0.5.0", features = ["std", "check"], default-features = false }
flate2 = { version = "1.1.9", default-features = false, features = ["rust_backend"] }
hex = { version = "0.4.3", default-features = false, features = ["std"] }
serde = { version = "1.0.219", default-features = false, features = ["std", "derive"] }
serde_json = { version = "1.0.140", default-features = false, features = ["std"] }
serde_bytes = { version = "0.11", default-features = false }
serde_cbor = { version = "0.11", default-features = false }
serde_with = { version = "3.14.0", default-features = false, features = ["macros", "base64"] }
tar = { version = "0.4.45", default-features = false }

# Cryptography
hpke = { version = "0.10", features = ["alloc", "p256", "serde_impls"], default-features = false }
Expand All @@ -56,8 +58,8 @@ prost-types = { version = "0.12", default-features = false }
prost-build = { version = "0.12.6", default-features = false }

# AWS Nitro enclaves
attestation-doc-validation = { version = "0.8.0", default-features = false }
aws-nitro-enclaves-nsm-api = { version = "0.3", features = ["nix"], default-features = false }
attestation-doc-validation = { version = "0.9.0", default-features = false }
aws-nitro-enclaves-nsm-api = { version = "0.4", features = ["nix"], default-features = false }
aws-nitro-enclaves-cose = { version = "0.5", default-features = false }

# CBOR and COSE
Expand Down Expand Up @@ -88,6 +90,9 @@ walkdir = { version = "2.5", default-features = false }
dotenvy = { version = "0.15.0", default-features = false }
clap = { version = "4.5", features = ["std", "derive", "help", "usage", "error-context"], default-features = false }

# Container operations
oci-client = { version = "0.16.1", default-features = false, features = ["rustls-tls"] }

# Development dependencies
assert_cmd = { version = "2", default-features = false }
predicates = { version = "3", default-features = false }
Expand Down
7 changes: 6 additions & 1 deletion tvc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ turnkey_client = { workspace = true }
turnkey_enclave_encrypt = { workspace = true }

anyhow = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["env"] }
flate2 = { workspace = true }
hex = { workspace = true }
p256 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tar = { workspace = true }
tempfile = { workspace = true }
toml = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
oci-client = { workspace = true }

[dev-dependencies]
assert_cmd = { workspace = true }
hpke = { workspace = true }
predicates = { workspace = true }
tempfile = { workspace = true }
39 changes: 35 additions & 4 deletions tvc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,48 @@ tvc deploy init --output my-deploy.json

# Edit my-deploy.json to fill in required values (appId, container images, etc.)

# Create the deployment
tvc deploy create my-deploy.json
# Optional: validate the digest of the file at pivotPath inside the image locally
tvc deploy validate-pivot-digest \
--image-url ghcr.io/tkhq/helloworld:latest \
--pivot-path /helloworld \
--expected-digest <EXPECTED_PIVOT_DIGEST>

# Recommended: uses GetTvcDeployment to fetch manifest and manifest_id automatically
# Create the deployment and validate the pivot digest locally first
tvc deploy create my-deploy.json --validate-pivot-digest

# Recommended: uses GetTvcDeployment to fetch the manifest automatically and
# validates the pivot digest against the deployment manifest before approval
tvc deploy approve \
--deploy-id <DEPLOYMENT_UUID> \
--validate-pivot-digest \
--operator-id <OPERATOR_UUID> # Turnkey's ID for your operator (from app create response)

# Alternative: provide manifest file and IDs manually
tvc deploy approve \
--manifest manifest.json \
--manifest-id <MANIFEST_UUID> \ # Turnkey's ID for the manifest (from deploy create response)
--operator-id <OPERATOR_UUID>
```
```

## Pivot Digest Validation

`tvc deploy validate-pivot-digest` computes the SHA-256 digest of the file at
`pivotPath` inside a Linux container image. The command resolves the image with
the CLI's native OCI client and does not require Docker.

For private images, pass `--pull-secret` with an unencrypted Docker-style
`config.json` containing credentials for the image registry.

```bash
tvc deploy validate-pivot-digest \
--image-url ghcr.io/tkhq/helloworld@sha256:f8132a6236609e4c67d9d29e5694989f18e528240844638e850897ee6319676d \
--pivot-path /helloworld \
--expected-digest cbe01169428f144086bfaef348bbf3db70f9217628996cafd2ecb85d5f2b47a1
```

Notes:

- Validation is Linux-only and resolves the image as `linux/amd64`.
- `tvc deploy approve --validate-pivot-digest` only works with `--deploy-id`.
- `--pull-secret` expects an unencrypted Docker-style JSON file, not the
encrypted pull secret stored in deployment config.
5 changes: 5 additions & 0 deletions tvc/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ impl Cli {
DeployCommands::Status(args) => commands::deploy::status::run(args).await,
DeployCommands::Create(args) => commands::deploy::create::run(args).await,
DeployCommands::Init(args) => commands::deploy::init::run(args).await,
DeployCommands::ValidatePivotDigest(args) => {
commands::deploy::validate_pivot_digest::run(args).await
}
},
Commands::App { command } => match command {
AppCommands::Status(args) => commands::app::status::run(args).await,
Expand Down Expand Up @@ -63,6 +66,8 @@ enum DeployCommands {
Create(commands::deploy::create::Args),
/// Generate a template deployment configuration file.
Init(commands::deploy::init::Args),
/// Compute or validate the pivot digest for a container image locally.
ValidatePivotDigest(commands::deploy::validate_pivot_digest::Args),
}

#[derive(Debug, Subcommand)]
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
80 changes: 70 additions & 10 deletions tvc/src/commands/deploy/approve.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
//! Approve deploy command - cryptographically approve a QOS manifest.

use super::validate_pivot_digest::print_result;
use crate::config::app::KNOWN_SHARE_SET_KEYS;
use crate::config::turnkey::{Config, StoredQosOperatorKey};
use crate::pair::LocalPair;
use crate::pivot_digest::{compute_pivot_digest, validate_expected_digest, PivotDigestSource};
use crate::util::{read_file_to_string, write_file};
use anyhow::{anyhow, bail, Context};
use clap::{ArgGroup, Args as ClapArgs};
Expand All @@ -19,6 +21,13 @@ use turnkey_client::generated::{
CreateTvcManifestApprovalsIntent, GetTvcDeploymentRequest, TvcManifestApproval,
};

struct DeployApprovalSource {
manifest: Manifest,
manifest_id: String,
pivot_image_url: Option<String>,
pivot_path: Option<String>,
}

/// Cryptographically approve a QOS manifest for a deployment with your operator's manifest set key.
#[derive(Debug, ClapArgs)]
#[command(about, long_about = None)]
Expand Down Expand Up @@ -57,6 +66,14 @@ pub struct Args {
#[arg(long, help_heading = "Operator signing key", value_name = "PATH")]
pub operator_seed: Option<PathBuf>,

/// Locally validate the pivot digest before approval. Only supported with `--deploy-id`.
#[arg(long)]
pub validate_pivot_digest: bool,

/// Path to an unencrypted Docker-style pull secret JSON file.
#[arg(long, value_name = "PATH")]
pub pull_secret: Option<PathBuf>,

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.

Do people normally store the pull secret as a file? either way i think they could also do --pull-secret <(echo "contents")


/// Walk through manifest approval prompts but do not generate an approval.
#[arg(long)]
pub dry_run: bool,
Expand All @@ -76,15 +93,48 @@ pub struct Args {

/// Run the approve deploy command.
pub async fn run(args: Args) -> anyhow::Result<()> {
if args.validate_pivot_digest && args.manifest.is_some() {
bail!(
"--validate-pivot-digest only works with --deploy-id. \
Use `tvc deploy validate-pivot-digest` to validate a manifest file source locally."
);
}

// Fetch manifest - track manifest_id if fetched from API
let (manifest, fetched_manifest_id) = match (&args.manifest, &args.deploy_id) {
(Some(path), _) => (read_manifest_from_path(path).await?, None),
(_, Some(deploy_id)) => {
let (manifest, manifest_id) = fetch_manifest_from_deploy(deploy_id).await?;
(manifest, Some(manifest_id))
}
(None, None) => bail!("a manifest source is required"),
};
let (manifest, fetched_manifest_id, pivot_image_url, pivot_path) =
match (&args.manifest, &args.deploy_id) {
(Some(path), _) => (read_manifest_from_path(path).await?, None, None, None),
(_, Some(deploy_id)) => {
let source = fetch_manifest_from_deploy(deploy_id).await?;
(
source.manifest,
Some(source.manifest_id),
source.pivot_image_url,
source.pivot_path,
)
}
(None, None) => bail!("a manifest source is required"),
};

if args.validate_pivot_digest {
let pivot_image_url = pivot_image_url
.ok_or_else(|| anyhow!("deployment is missing a pivot container image URL"))?;
let pivot_path =
pivot_path.ok_or_else(|| anyhow!("deployment is missing a pivot container path"))?;

let result = compute_pivot_digest(
&PivotDigestSource {
image_url: pivot_image_url,
pivot_path,
},
args.pull_secret.as_deref(),
)
.await?;
validate_expected_digest(&result.digest, &hex::encode(manifest.pivot.hash))?;
print_result(&result);

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.

nit: i would prefer using path so it reads validate_pivot_digest::print_result bc otherwise print_result sounds vague

println!("Pivot digest validated successfully.");
println!();
}

if !args.dangerous_skip_interactive {
interactive_approve(&manifest)?;
Expand Down Expand Up @@ -369,7 +419,7 @@ async fn read_manifest_from_path(path: &Path) -> anyhow::Result<Manifest> {

/// Fetch manifest from Turnkey using GetTvcDeployment API.
/// Returns the manifest and its Turnkey manifest_id.
async fn fetch_manifest_from_deploy(deploy_id: &str) -> anyhow::Result<(Manifest, String)> {
async fn fetch_manifest_from_deploy(deploy_id: &str) -> anyhow::Result<DeployApprovalSource> {
println!("Fetching deployment {deploy_id}...");

let auth = crate::client::build_client().await?;
Expand Down Expand Up @@ -399,5 +449,15 @@ async fn fetch_manifest_from_deploy(deploy_id: &str) -> anyhow::Result<(Manifest

println!("✓ Manifest loaded (manifest_id: {})", tvc_manifest.id);

Ok((manifest, tvc_manifest.id))
let (pivot_image_url, pivot_path) = deployment
.pivot_container
.map(|container| (Some(container.container_url), Some(container.path)))
.unwrap_or((None, None));

Ok(DeployApprovalSource {
manifest,
manifest_id: tvc_manifest.id,
pivot_image_url,
pivot_path,
})
}
Loading
Loading