Skip to content

Commit

Permalink
rust: properly separate cli from library in vault init (#530)
Browse files Browse the repository at this point in the history
* rust: properly separate cli from library in vault init

* drop anim duration to 600ms
  • Loading branch information
Esgrove authored Oct 9, 2024
1 parent 03c34ca commit 443ddf6
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 265 deletions.
2 changes: 1 addition & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "nitor-vault"
version = "1.0.0"
version = "1.1.0"
edition = "2021"
description = "Encrypted AWS key-value storage utility."
license = "Apache-2.0"
Expand All @@ -14,13 +14,13 @@ authors = [
[dependencies]
aes-gcm = "0.10.3"
anyhow = "1.0.89"
aws-config = { version = "1.4.0", features = ["behavior-version-latest"] }
aws-sdk-cloudformation = "1.30.0"
aws-sdk-kms = "1.26.0"
aws-sdk-s3 = "1.29.0"
aws-config = { version = "1.5.8", features = ["behavior-version-latest"] }
aws-sdk-cloudformation = "1.50.0"
aws-sdk-kms = "1.46.0"
aws-sdk-s3 = "1.54.0"
aws-sdk-sts = { version = "1.45.0", features = ["behavior-version-latest"] }
base64 = "0.22.1"
clap = { version = "4.5.19", features = ["derive", "env"] }
clap = { version = "4.5.20", features = ["derive", "env"] }
colored = "2.1.0"
rand = "0.8.5"
serde = { version = "1.0.210", features = ["derive"] }
Expand Down
77 changes: 76 additions & 1 deletion rust/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,39 @@
use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use aws_sdk_cloudformation::types::StackStatus;
use colored::Colorize;
use tokio::time::Duration;

use nitor_vault::{Value, Vault};
use nitor_vault::{cloudformation, CreateStackResult, Value, Vault};

const INIT_WAIT_ANIMATION_DURATION: Duration = Duration::from_millis(600);

pub async fn init_vault_stack(
stack_name: Option<String>,
region: Option<String>,
bucket: Option<String>,
) -> Result<()> {
match Vault::init(stack_name, region, bucket).await? {
CreateStackResult::AlreadyInitialized { data } => {
println!("Vault stack already initialized");
println!("{data}");
}
CreateStackResult::Created {
stack_name,
stack_id,
region,
} => {
println!("Stack created with ID: {stack_id}");
let config = aws_config::from_env().region(region).load().await;
let client = aws_sdk_cloudformation::Client::new(&config);
wait_for_stack_creation_to_finish(&client, &stack_name).await?;
}
}

Ok(())
}

/// Store a key-value pair
pub async fn store(
Expand Down Expand Up @@ -111,6 +141,51 @@ pub async fn exists(vault: &Vault, key: &str) -> Result<()> {
})
}

/// Poll Cloudformation for stack status until it has been created or creation failed.
async fn wait_for_stack_creation_to_finish(
cf_client: &aws_sdk_cloudformation::Client,
stack_name: &str,
) -> Result<()> {
let mut last_status: Option<StackStatus> = None;
let clear_line = "\x1b[2K";
let dots = [".", "..", "...", ""];
loop {
let stack_data = cloudformation::get_stack_data(cf_client, stack_name).await?;

if let Some(ref status) = stack_data.status {
// Check if stack has reached a terminal state
match status {
StackStatus::CreateComplete => {
println!("{clear_line}{stack_data}");
println!("{}", "Stack creation completed successfully".green());
break;
}
StackStatus::CreateFailed
| StackStatus::RollbackFailed
| StackStatus::RollbackComplete => {
println!("{clear_line}{stack_data}");
anyhow::bail!("Stack creation failed");
}
_ => {
// Print status if it has changed
if last_status.as_ref() != Some(status) {
last_status = Some(status.clone());
println!("status: {status}");
}
// Continue waiting for stack creation to complete
for dot in &dots {
print!("\r{clear_line}{dot}");
std::io::stdout().flush()?;
tokio::time::sleep(INIT_WAIT_ANIMATION_DURATION).await;
}
}
}
}
}

Ok(())
}

/// Try to get the filename for the given filepath
fn get_filename_from_path(path: &str) -> Result<String> {
let path = Path::new(path);
Expand Down
165 changes: 165 additions & 0 deletions rust/src/cloudformation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
use std::fmt;

use aws_sdk_cloudformation::operation::describe_stacks::DescribeStacksOutput;
use aws_sdk_cloudformation::types::{Output, StackStatus};

use crate::errors::VaultError;

#[derive(Debug, Clone, Default)]
/// Parameter values for Cloudformation resources.
pub struct CloudFormationParams {
pub bucket_name: String,
pub key_arn: Option<String>,
pub stack_name: String,
}

#[derive(Debug, Default, Clone)]
/// Cloudformation stack status information.
pub struct CloudFormationStackData {
pub bucket_name: Option<String>,
pub key_arn: Option<String>,
pub version: Option<u32>,
pub status: Option<StackStatus>,
pub status_reason: Option<String>,
}

impl CloudFormationParams {
#[must_use]
/// Create `CloudFormationParams` from owned parameters.
pub const fn new(bucket_name: String, key_arn: Option<String>, stack_name: String) -> Self {
Self {
bucket_name,
key_arn,
stack_name,
}
}

#[must_use]
/// Create `CloudFormationParams` from references.
pub fn from(bucket_name: &str, key_arn: Option<&str>, stack_name: &str) -> Self {
Self {
bucket_name: bucket_name.to_owned(),
key_arn: key_arn.map(std::borrow::ToOwned::to_owned),
stack_name: stack_name.to_owned(),
}
}

/// Get `CloudFormationParams` from Cloudformation describe stack output.
pub async fn from_stack(
client: &aws_sdk_cloudformation::Client,
stack: String,
) -> Result<Self, VaultError> {
let describe_stack_output = client
.describe_stacks()
.stack_name(stack.clone())
.send()
.await?;

let stack_output = describe_stack_output
.stacks()
.first()
.map(aws_sdk_cloudformation::types::Stack::outputs)
.ok_or(VaultError::StackOutputsMissingError)?;

let bucket_name = Self::parse_output_value_from_key("vaultBucketName", stack_output)
.ok_or(VaultError::BucketNameMissingError)?;

let key_arn = Self::parse_output_value_from_key("kmsKeyArn", stack_output);

Ok(Self::new(bucket_name, key_arn, stack))
}

fn parse_output_value_from_key(key: &str, output: &[Output]) -> Option<String> {
output
.iter()
.find(|output| output.output_key() == Some(key))
.map(|output| output.output_value().unwrap_or_default().to_owned())
}
}

impl fmt::Display for CloudFormationParams {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"bucket: {}\nkey: {}\nstack: {}",
self.bucket_name,
self.key_arn.as_ref().map_or("None", |k| k),
self.stack_name
)
}
}

impl fmt::Display for CloudFormationStackData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"status: {}\nbucket: {}\nkey ARN: {}\nversion: {}{}",
self.status
.as_ref()
.map_or("None".to_string(), std::string::ToString::to_string),
self.bucket_name.as_deref().unwrap_or("None"),
self.key_arn.as_deref().unwrap_or("None"),
self.version.map_or("None".to_string(), |v| v.to_string()),
self.status_reason
.as_ref()
.map_or_else(String::new, |reason| format!("\nreason: {reason}"))
)
}
}

/// Extract relevant information from Cloudformation stack outputs
pub async fn get_stack_data(
cf_client: &aws_sdk_cloudformation::Client,
stack_name: &str,
) -> Result<CloudFormationStackData, VaultError> {
let stack_response = describe_stack(cf_client, stack_name).await?;

let mut data = CloudFormationStackData::default();
if let Some(stacks) = stack_response.stacks {
if let Some(stack) = stacks.first() {
data.status.clone_from(&stack.stack_status);
data.status_reason.clone_from(&stack.stack_status_reason);
if let Some(outputs) = &stack.outputs {
for output in outputs {
if let Some(output_key) = output.output_key() {
match output_key {
"vaultBucketName" => {
if let Some(output_value) = output.output_value() {
data.bucket_name = Some(output_value.to_string());
}
}
"kmsKeyArn" => {
if let Some(output_value) = output.output_value() {
data.key_arn = Some(output_value.to_string());
}
}
"vaultStackVersion" => {
if let Some(output_value) = output.output_value() {
if let Ok(version) = output_value.parse::<u32>() {
data.version = Some(version);
}
}
}
_ => {}
}
}
}
}
}
}

Ok(data)
}

// Get Cloudformation describe stack output.
pub async fn describe_stack(
cf_client: &aws_sdk_cloudformation::Client,
stack_name: &str,
) -> Result<DescribeStacksOutput, VaultError> {
cf_client
.describe_stacks()
.stack_name(stack_name)
.send()
.await
.map_err(VaultError::from)
}
5 changes: 3 additions & 2 deletions rust/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use aws_sdk_s3::operation::head_object::HeadObjectError;
use aws_sdk_s3::operation::list_objects_v2::ListObjectsV2Error;
use aws_sdk_s3::operation::put_object::PutObjectError;
use aws_sdk_sts::operation::get_caller_identity::GetCallerIdentityError;

use thiserror::Error;

#[derive(Debug, Error)]
Expand Down Expand Up @@ -86,6 +87,6 @@ pub enum VaultError {
CallerIdError(#[from] SdkError<GetCallerIdentityError>),
#[error("Failed to create stack: {0}")]
CreateStackError(#[from] SdkError<CreateStackError>),
#[error("{0}")]
Error(String),
#[error("Failed to get stack ID for new vault stack")]
MissingStackIdError,
}
Loading

0 comments on commit 443ddf6

Please sign in to comment.