Skip to content
Draft
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
370 changes: 320 additions & 50 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,26 @@ rust-version = "1.92"

[workspace.dependencies]
async-stream = "0.3"
aws-config = { version = "1", default-features = false }
base64 = "0.22"
aws-sdk-cloudformation = { version = "1", default-features = false }
aws-sdk-cloudwatchlogs = { version = "1", default-features = false }
aws-sdk-s3 = { version = "1", default-features = false }
aws-sdk-secretsmanager = { version = "1", default-features = false }
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
cmd-proc = { version = "0.1.0", path = "cmd-proc" }
dirs = "6"
env_logger = "0.11"
git-proc = { version = "0.0.1", path = "git-proc" }
hex = "0.4.3"
hmac = "0.12"
http = "1.3"
indoc = "2"
itertools = "0.14"
log = "0.4"
mhttp = { path = "mhttp" }
mlambda = { path = "mlambda" }
mmigration = { path = "mmigration" }
nom = "8"
nom-language = "0.1"
Expand All @@ -49,13 +57,15 @@ prettyplease = "0.2"
proc-macro2 = "1"
quote = "1"
rand = "0.9"
rcgen = "0.13"
regex-lite = "0.1.6"
reqwest = { version = "0.13", features = ["json", "query", "rustls"], default-features = false }
semver = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["arbitrary_precision", "indexmap"] }
serde_path_to_error = "0.1"
sha2 = "0.10"
stack-deploy = { version = "0.0.1", path = "./stack-deploy" }
sqlx = { version = "0.9.0-alpha.1", git = "https://github.com/mbj/sqlx", rev = "f795fe994a6973ebe872c8c619706a4244c65d54", features = ["postgres", "runtime-tokio", "tls-rustls"] }
stratosphere = { version = "0.0.4", path = "./stratosphere" }
stratosphere-core = { version = "0.0.4", path = "./stratosphere-core" }
Expand Down
14 changes: 14 additions & 0 deletions greenhell/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,35 @@ workspace = true

[dependencies]
async-stream.workspace = true
aws-config = { workspace = true, features = ["behavior-version-latest", "rustls", "rt-tokio", "sso"] }
base64.workspace = true
aws-sdk-cloudformation = { workspace = true, features = ["behavior-version-latest", "rustls"] }
aws-sdk-cloudwatchlogs = { workspace = true, features = ["behavior-version-latest", "rustls"] }
aws-sdk-s3 = { workspace = true, features = ["behavior-version-latest", "rustls"] }
aws-sdk-secretsmanager = { workspace = true, features = ["behavior-version-latest", "rustls"] }
chrono.workspace = true
clap.workspace = true
cmd-proc.workspace = true
env_logger.workspace = true
git-proc.workspace = true
futures-util.workspace = true
hex.workspace = true
hmac.workspace = true
http.workspace = true
itertools.workspace = true
log.workspace = true
mhttp.workspace = true
mlambda.workspace = true
nom.workspace = true
nom-language.workspace = true
rcgen.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
stack-deploy.workspace = true
stratosphere = { workspace = true, features = ["aws_iam", "aws_lambda", "aws_logs", "aws_s3", "aws_secretsmanager"] }
strum.workspace = true
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
Expand Down
19 changes: 19 additions & 0 deletions greenhell/src/aws.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
pub struct Clients {
pub cloudformation: aws_sdk_cloudformation::Client,
pub cloudwatchlogs: aws_sdk_cloudwatchlogs::Client,
pub s3: aws_sdk_s3::Client,
pub secretsmanager: aws_sdk_secretsmanager::Client,
}

impl Clients {
pub async fn load() -> Self {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;

Self {
cloudformation: aws_sdk_cloudformation::Client::new(&config),
cloudwatchlogs: aws_sdk_cloudwatchlogs::Client::new(&config),
s3: aws_sdk_s3::Client::new(&config),
secretsmanager: aws_sdk_secretsmanager::Client::new(&config),
}
}
}
4 changes: 4 additions & 0 deletions greenhell/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
pub mod aws;
pub mod cli;
pub mod cli_token;
pub mod evaluate;
pub mod events;
pub mod github;
pub mod parse;
pub mod secrets;
pub mod stack;
pub mod watch;
pub mod webhook;
146 changes: 138 additions & 8 deletions greenhell/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ enum Command {
#[clap(long)]
dry_run: bool,
},
/// Manage secrets
Secrets {
#[clap(subcommand)]
command: stack_deploy::secrets::cli::Command<greenhell::secrets::Secret>,
},
/// Manage CloudFormation stack
Stack {
#[clap(subcommand)]
command: stack_deploy::cli::Command,
},
/// Lambda deployment
Lambda {
#[clap(subcommand)]
command: stack_deploy::lambda::deploy::cli::Command,
},
/// Stream Lambda logs
Logs {
/// Filter pattern for log events
#[arg(long)]
filter: Option<String>,
},
}

/// Target for evaluation: either a branch or a pull request number.
Expand Down Expand Up @@ -360,22 +381,131 @@ impl App {
}
}
}
Command::Secrets { command } => {
let aws = greenhell::aws::Clients::load().await;
command.run(&aws.cloudformation, &aws.secretsmanager).await;
}
Command::Stack { command } => {
let aws = greenhell::aws::Clients::load().await;
let registry = greenhell::stack::registry();

let config = stack_deploy::cli::Config {
cloudformation: &aws.cloudformation,
cloudwatchlogs: &aws.cloudwatchlogs,
registry: &registry,
template_uploader: None,
};

command.run(&config).await;
}
Command::Lambda { command } => {
let target = stack_deploy::lambda::deploy::Target {
binary_name: stack_deploy::lambda::deploy::BinaryName("greenhell".into()),
build_target: stack_deploy::lambda::deploy::BuildTarget(
"x86_64-unknown-linux-musl".into(),
),
build_type: stack_deploy::lambda::deploy::BuildType::Release,
extra_files: std::collections::BTreeMap::new(),
};

match command {
stack_deploy::lambda::deploy::cli::Command::Build => {
target.build();
}
_ => {
let aws = greenhell::aws::Clients::load().await;
let registry = greenhell::stack::registry();

let config = stack_deploy::lambda::deploy::cli::Config {
cloudformation: &aws.cloudformation,
parameter_key: stack_deploy::types::ParameterKey::from("LambdaS3Key"),
registry,
s3: &aws.s3,
s3_bucket_source:
stack_deploy::lambda::deploy::S3BucketSource::StackOutput {
stack_name: stack_deploy::types::StackName::from(
greenhell::stack::artifacts::STACK_NAME,
),
output_key: stack_deploy::types::OutputKey::from(
"LambdaBucketName",
),
},
target,
template_uploader: None,
};

command.run(&config).await;
}
}
}
Command::Logs { filter } => {
let aws = greenhell::aws::Clients::load().await;

let log_group_arn = stack_deploy::stack::read_stack_output(
&aws.cloudformation,
&stack_deploy::types::StackName::from(greenhell::stack::webhook::STACK_NAME),
&stack_deploy::types::OutputKey::from(
greenhell::stack::webhook::LOG_GROUP_ARN_OUTPUT,
),
)
.await;

let config = stack_deploy::logs::tail::Config {
client: &aws.cloudwatchlogs,
log_group_arn: &log_group_arn,
log_stream_names: vec![],
filter_pattern: filter.clone(),
};

if let Err(error) = stack_deploy::logs::tail::run(&config).await {
log::error!("{error}");
}
}
}
Ok(())
}
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();

let app = <App as clap::Parser>::parse();
if let Err(error) = app.run().await {
log::error!("{error}");
std::process::exit(1);
fn main() {
let binary_name = std::env::args()
.next()
.and_then(|path| path.rsplit('/').next().map(String::from))
.unwrap();

let mut builder =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"));

if binary_name == "bootstrap" {
builder.format_timestamp(None).init();
run_webhook();
} else {
builder.init();
run_cli();
}
}

fn run_webhook() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(greenhell::webhook::run());
}

fn run_cli() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let app = <App as clap::Parser>::parse();
if let Err(error) = app.run().await {
log::error!("{error}");
std::process::exit(1);
}
});
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
87 changes: 87 additions & 0 deletions greenhell/src/secrets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#[derive(Clone, Debug, Eq, PartialEq, strum::Display, strum::EnumIter, strum::EnumString)]
pub enum Secret {
GitHubApp,
}

impl stack_deploy::secrets::SecretType for Secret {
fn to_arn_output_key(&self) -> stack_deploy::types::OutputKey {
match self {
Self::GitHubApp => "GitHubAppSecretArn".into(),
}
}

fn to_env_variable_name(&self) -> &str {
match self {
Self::GitHubApp => "GITHUB_APP_SECRET_ARN",
}
}

fn validate(&self, input: &str) -> Result<(), String> {
match self {
Self::GitHubApp => {
serde_json::from_str::<GitHubApp>(input).map_err(|error| error.to_string())?;
Ok(())
}
}
}
}

use std::num::NonZeroU64;

#[derive(Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct GitHubApp {
pub app_id: NonZeroU64,
pub private_key: PrivateKey,
pub webhook_secret: WebhookSecret,
}

#[derive(Debug, Eq, PartialEq)]
pub struct PrivateKey(String);

impl PrivateKey {
pub fn as_str(&self) -> &str {
&self.0
}
}

impl<'de> serde::Deserialize<'de> for PrivateKey {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
rcgen::KeyPair::from_pem(&value).map_err(|error| {
serde::de::Error::custom(format!("invalid private key PEM: {error}"))
})?;
Ok(Self(value))
}
}

impl serde::Serialize for PrivateKey {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}

#[derive(Debug, Eq, PartialEq)]
pub struct WebhookSecret(String);

impl WebhookSecret {
pub fn as_str(&self) -> &str {
&self.0
}
}

impl<'de> serde::Deserialize<'de> for WebhookSecret {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
if value.is_empty() {
return Err(serde::de::Error::custom("webhook_secret must be non-empty"));
}
Ok(Self(value))
}
}

impl serde::Serialize for WebhookSecret {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
Loading
Loading