From 1a2f2f48d8892fdcc5e968500038c6b710472bf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 12:12:53 +0000 Subject: [PATCH] Add cloud resource + secret modeling with DAG upsert patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces typed cloud resource modeling (GCP, AWS) and secret reference system for idempotent resource provisioning via DAG upserts. Transport layer (core/ir): - cloud/mod.rs: CloudProvider, CloudResource, CloudResourceState - cloud/gcp.rs: GcpProject, GcpResource (bucket, SA, secret, pubsub, artifact registry), GcpWifConfig for Workload Identity Federation - cloud/aws.rs: AwsAccount, AwsResource (S3, IAM role, secrets manager, ECR), AwsOidcConfig for GitHub Actions OIDC federation - secret.rs: SecretRef, SecretSource, SecretFederation, SecretRequirements with federation-first design (no static keys in DAG) GitHub Actions integration: - AWS OIDC integration (aws-actions/configure-aws-credentials@v4) - GitHubSecret type for declaring required repository secrets - Standard secret catalog (GCP WIF, AWS OIDC role ARN) - WorkflowConfig.required_secrets for secret dependency tracking Resource model: - ResourceId::cloud() and ResourceId::secret() constructors - ResourceId::from_cloud_resource() for typed conversion gunbc-dag workspace: - CloudOp enum in WorkspaceOp for cloud upsert phases - cloud_upsert SubDag using UpsertBuilder (Check → Create → Resolve) - Integrated into workspace DAG composition https://claude.ai/code/session_01BueK16sY3G3hQKvgfNWXPW --- core/ir/src/resource.rs | 35 ++ core/ir/src/transport/cloud/aws.rs | 519 +++++++++++++++++++++++ core/ir/src/transport/cloud/gcp.rs | 516 ++++++++++++++++++++++ core/ir/src/transport/cloud/mod.rs | 198 +++++++++ core/ir/src/transport/github_actions.rs | 198 +++++++++ core/ir/src/transport/mod.rs | 13 +- core/ir/src/transport/secret.rs | 444 +++++++++++++++++++ gunbc-dag/src/workspace/mod.rs | 3 +- gunbc-dag/src/workspace/ops.rs | 37 ++ gunbc-dag/src/workspace/subdags/cloud.rs | 103 +++++ gunbc-dag/src/workspace/subdags/mod.rs | 8 +- 11 files changed, 2071 insertions(+), 3 deletions(-) create mode 100644 core/ir/src/transport/cloud/aws.rs create mode 100644 core/ir/src/transport/cloud/gcp.rs create mode 100644 core/ir/src/transport/cloud/mod.rs create mode 100644 core/ir/src/transport/secret.rs create mode 100644 gunbc-dag/src/workspace/subdags/cloud.rs diff --git a/core/ir/src/resource.rs b/core/ir/src/resource.rs index 0fafc16da7e..496ddd381bf 100644 --- a/core/ir/src/resource.rs +++ b/core/ir/src/resource.rs @@ -70,6 +70,29 @@ impl ResourceId { pub fn tool(name: impl Into) -> Self { Self(format!("tool:{}", name.into())) } + + /// Create a cloud resource ID. + /// + /// Format: `cloud:{provider}:{scope}:{kind}/{name}` + /// + /// Used for tracking access to cloud resources (buckets, IAM roles, etc.). + pub fn cloud(provider: &str, scope: &str, kind: &str, name: &str) -> Self { + Self(format!("cloud:{provider}:{scope}:{kind}/{name}")) + } + + /// Create a cloud resource ID from a [`CloudResource`](crate::transport::cloud::CloudResource). + pub fn from_cloud_resource(resource: &crate::transport::cloud::CloudResource) -> Self { + Self(resource.resource_id()) + } + + /// Create a secret resource ID. + /// + /// Format: `secret:{name}` + /// + /// Used for tracking access to secrets (read/write). + pub fn secret(name: impl Into) -> Self { + Self(format!("secret:{}", name.into())) + } } impl From<&str> for ResourceId { @@ -362,6 +385,18 @@ mod tests { assert!(conn.0.starts_with("conn:")); } + #[test] + fn test_cloud_resource_id() { + let cloud = ResourceId::cloud("gcp", "my-project", "storage_bucket", "my-bucket"); + assert_eq!(cloud.0, "cloud:gcp:my-project:storage_bucket/my-bucket"); + } + + #[test] + fn test_secret_resource_id() { + let secret = ResourceId::secret("db-password"); + assert_eq!(secret.0, "secret:db-password"); + } + #[test] fn test_access_mode_conflicts() { assert!(!AccessMode::Read.conflicts_with(&AccessMode::Read)); diff --git a/core/ir/src/transport/cloud/aws.rs b/core/ir/src/transport/cloud/aws.rs new file mode 100644 index 00000000000..60df90bfeda --- /dev/null +++ b/core/ir/src/transport/cloud/aws.rs @@ -0,0 +1,519 @@ +//! AWS (Amazon Web Services) resource modeling. +//! +//! Models AWS resources that can be upserted: checked for existence, +//! created if missing, and resolved to a handle. +//! +//! # Resources Modeled +//! +//! - **S3 Buckets**: Object storage +//! - **IAM Roles**: Identity for workloads and services +//! - **Secrets Manager Secrets**: Secret storage +//! - **ECR Repositories**: Container image registry +//! +//! # Authentication +//! +//! AWS authentication from GitHub Actions uses OIDC federation via +//! `aws-actions/configure-aws-credentials`. This is modeled in +//! [`AwsOidcConfig`]. +//! +//! # Example +//! +//! ```ignore +//! use gunbc_ir::transport::cloud::aws::*; +//! +//! let account = AwsAccount::new("123456789012", "us-east-1"); +//! let bucket = AwsResource::S3Bucket { +//! name: "my-data-bucket".into(), +//! region: None, // inherit from account +//! }; +//! +//! let check = bucket.check_command(&account); +//! let create = bucket.create_command(&account); +//! ``` + +use serde::{Deserialize, Serialize}; + +/// AWS account and region scope. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AwsAccount { + /// AWS account ID (12-digit number). + pub id: String, + /// Default region (e.g., "us-east-1"). + pub region: String, +} + +impl AwsAccount { + /// Create a new AWS account reference. + pub fn new(id: impl Into, region: impl Into) -> Self { + Self { + id: id.into(), + region: region.into(), + } + } +} + +impl std::fmt::Display for AwsAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.id, self.region) + } +} + +/// AWS resource types that can be upserted. +/// +/// Each variant carries the configuration needed to check, create, +/// and resolve the resource. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AwsResource { + /// S3 bucket. + S3Bucket { + /// Bucket name (globally unique). + name: String, + /// Override region (uses account default if None). + region: Option, + }, + + /// IAM Role. + IamRole { + /// Role name. + name: String, + /// Trust policy document (JSON string). + /// Required for creation; describes who can assume this role. + assume_role_policy: String, + }, + + /// Secrets Manager secret. + SecretsManagerSecret { + /// Secret name. + name: String, + }, + + /// ECR (Elastic Container Registry) repository. + EcrRepository { + /// Repository name. + name: String, + }, +} + +impl AwsResource { + /// Get the resource kind identifier. + pub fn kind(&self) -> &'static str { + match self { + Self::S3Bucket { .. } => "s3_bucket", + Self::IamRole { .. } => "iam_role", + Self::SecretsManagerSecret { .. } => "secretsmanager_secret", + Self::EcrRepository { .. } => "ecr_repository", + } + } + + /// Get the resource name. + pub fn name(&self) -> &str { + match self { + Self::S3Bucket { name, .. } => name, + Self::IamRole { name, .. } => name, + Self::SecretsManagerSecret { name } => name, + Self::EcrRepository { name } => name, + } + } + + /// Resolve the effective region for this resource. + fn effective_region<'a>(&'a self, account: &'a AwsAccount) -> &'a str { + match self { + Self::S3Bucket { region: Some(r), .. } => r.as_str(), + _ => account.region.as_str(), + } + } + + /// Generate the AWS CLI check command for this resource. + /// + /// Returns `(command, args)` that can be used in a `ShellRequest`. + pub fn check_command(&self, account: &AwsAccount) -> (&'static str, Vec) { + match self { + Self::S3Bucket { name, .. } => ( + "aws", + vec![ + "s3api".into(), + "head-bucket".into(), + "--bucket".into(), + name.clone(), + "--region".into(), + self.effective_region(account).to_string(), + ], + ), + Self::IamRole { name, .. } => ( + "aws", + vec![ + "iam".into(), + "get-role".into(), + "--role-name".into(), + name.clone(), + ], + ), + Self::SecretsManagerSecret { name } => ( + "aws", + vec![ + "secretsmanager".into(), + "describe-secret".into(), + "--secret-id".into(), + name.clone(), + "--region".into(), + self.effective_region(account).to_string(), + ], + ), + Self::EcrRepository { name } => ( + "aws", + vec![ + "ecr".into(), + "describe-repositories".into(), + "--repository-names".into(), + name.clone(), + "--region".into(), + self.effective_region(account).to_string(), + ], + ), + } + } + + /// Generate the AWS CLI create command for this resource. + /// + /// Returns `(command, args)` that can be used in a `ShellRequest`. + pub fn create_command(&self, account: &AwsAccount) -> (&'static str, Vec) { + match self { + Self::S3Bucket { name, .. } => { + let region = self.effective_region(account); + let mut args = vec![ + "s3api".into(), + "create-bucket".into(), + "--bucket".into(), + name.clone(), + "--region".into(), + region.to_string(), + ]; + // LocationConstraint is required for non-us-east-1 regions + if region != "us-east-1" { + args.push("--create-bucket-configuration".into()); + args.push(format!("LocationConstraint={region}")); + } + ("aws", args) + } + Self::IamRole { + name, + assume_role_policy, + } => ( + "aws", + vec![ + "iam".into(), + "create-role".into(), + "--role-name".into(), + name.clone(), + "--assume-role-policy-document".into(), + assume_role_policy.clone(), + ], + ), + Self::SecretsManagerSecret { name } => ( + "aws", + vec![ + "secretsmanager".into(), + "create-secret".into(), + "--name".into(), + name.clone(), + "--region".into(), + self.effective_region(account).to_string(), + ], + ), + Self::EcrRepository { name } => ( + "aws", + vec![ + "ecr".into(), + "create-repository".into(), + "--repository-name".into(), + name.clone(), + "--region".into(), + self.effective_region(account).to_string(), + ], + ), + } + } + + /// Get the IAM role ARN for an IamRole resource. + /// + /// Returns `None` for non-IamRole resources. + pub fn role_arn(&self, account: &AwsAccount) -> Option { + match self { + Self::IamRole { name, .. } => { + Some(format!("arn:aws:iam::{}:role/{}", account.id, name)) + } + _ => None, + } + } +} + +/// AWS OIDC federation configuration for GitHub Actions. +/// +/// Enables keyless authentication from GitHub Actions to AWS using OIDC. +/// The GitHub Actions runner requests an OIDC token, which AWS validates +/// against the IAM OIDC provider to grant temporary credentials via STS. +/// +/// # Setup Requirements +/// +/// 1. An IAM OIDC Provider for `token.actions.githubusercontent.com` +/// 2. An IAM Role with a trust policy allowing the OIDC provider +/// 3. The role must have permissions for the resources you want to access +/// +/// # Example +/// +/// ```ignore +/// let oidc = AwsOidcConfig::new( +/// AwsAccount::new("123456789012", "us-east-1"), +/// "github-actions-role", +/// "myorg/myrepo", +/// ); +/// +/// let role_arn = oidc.role_arn(); +/// // "arn:aws:iam::123456789012:role/github-actions-role" +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AwsOidcConfig { + /// AWS account for the OIDC role. + pub account: AwsAccount, + /// IAM role name to assume via OIDC. + pub role_name: String, + /// GitHub repository filter (e.g., "myorg/myrepo"). + /// Used in the trust policy to restrict which repos can assume the role. + pub github_repo: String, +} + +impl AwsOidcConfig { + /// Create a new AWS OIDC configuration. + pub fn new( + account: AwsAccount, + role_name: impl Into, + github_repo: impl Into, + ) -> Self { + Self { + account, + role_name: role_name.into(), + github_repo: github_repo.into(), + } + } + + /// Get the IAM role ARN for the OIDC role. + pub fn role_arn(&self) -> String { + format!( + "arn:aws:iam::{}:role/{}", + self.account.id, self.role_name, + ) + } + + /// Generate the trust policy document for this OIDC configuration. + /// + /// This policy allows GitHub Actions from the specified repository + /// to assume this role via OIDC federation. + pub fn trust_policy(&self) -> String { + serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Federated": format!( + "arn:aws:iam::{}:oidc-provider/token.actions.githubusercontent.com", + self.account.id, + ) + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": format!( + "repo:{}:*", + self.github_repo, + ) + } + } + }] + }) + .to_string() + } + + /// List the AWS resources that must exist for OIDC to work. + /// + /// These should be upserted before any workflow that uses OIDC auth. + pub fn required_resources(&self) -> Vec { + vec![AwsResource::IamRole { + name: self.role_name.clone(), + assume_role_policy: self.trust_policy(), + }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_account() -> AwsAccount { + AwsAccount::new("123456789012", "us-east-1") + } + + #[test] + fn test_aws_account_display() { + let account = test_account(); + assert_eq!(account.to_string(), "123456789012:us-east-1"); + } + + #[test] + fn test_aws_resource_kind() { + let bucket = AwsResource::S3Bucket { + name: "b".into(), + region: None, + }; + assert_eq!(bucket.kind(), "s3_bucket"); + + let role = AwsResource::IamRole { + name: "r".into(), + assume_role_policy: "{}".into(), + }; + assert_eq!(role.kind(), "iam_role"); + + let secret = AwsResource::SecretsManagerSecret { + name: "s".into(), + }; + assert_eq!(secret.kind(), "secretsmanager_secret"); + + let ecr = AwsResource::EcrRepository { + name: "e".into(), + }; + assert_eq!(ecr.kind(), "ecr_repository"); + } + + #[test] + fn test_s3_bucket_check_command() { + let account = test_account(); + let bucket = AwsResource::S3Bucket { + name: "my-bucket".into(), + region: None, + }; + let (cmd, args) = bucket.check_command(&account); + assert_eq!(cmd, "aws"); + assert!(args.contains(&"head-bucket".to_string())); + assert!(args.contains(&"my-bucket".to_string())); + } + + #[test] + fn test_s3_bucket_create_with_location_constraint() { + let account = AwsAccount::new("123456789012", "eu-west-1"); + let bucket = AwsResource::S3Bucket { + name: "my-bucket".into(), + region: None, + }; + let (_, args) = bucket.create_command(&account); + assert!(args.contains(&"--create-bucket-configuration".to_string())); + assert!(args.contains(&"LocationConstraint=eu-west-1".to_string())); + } + + #[test] + fn test_s3_bucket_create_us_east_1_no_constraint() { + let account = test_account(); // us-east-1 + let bucket = AwsResource::S3Bucket { + name: "my-bucket".into(), + region: None, + }; + let (_, args) = bucket.create_command(&account); + assert!(!args.contains(&"--create-bucket-configuration".to_string())); + } + + #[test] + fn test_s3_bucket_region_override() { + let account = test_account(); + let bucket = AwsResource::S3Bucket { + name: "my-bucket".into(), + region: Some("ap-southeast-1".into()), + }; + let (_, args) = bucket.check_command(&account); + assert!(args.contains(&"ap-southeast-1".to_string())); + } + + #[test] + fn test_iam_role_arn() { + let account = test_account(); + let role = AwsResource::IamRole { + name: "my-role".into(), + assume_role_policy: "{}".into(), + }; + assert_eq!( + role.role_arn(&account), + Some("arn:aws:iam::123456789012:role/my-role".to_string()), + ); + } + + #[test] + fn test_non_role_has_no_arn() { + let account = test_account(); + let bucket = AwsResource::S3Bucket { + name: "b".into(), + region: None, + }; + assert_eq!(bucket.role_arn(&account), None); + } + + #[test] + fn test_aws_oidc_config() { + let oidc = AwsOidcConfig::new( + test_account(), + "github-actions-role", + "myorg/myrepo", + ); + + assert_eq!( + oidc.role_arn(), + "arn:aws:iam::123456789012:role/github-actions-role", + ); + } + + #[test] + fn test_aws_oidc_trust_policy() { + let oidc = AwsOidcConfig::new( + test_account(), + "github-actions-role", + "myorg/myrepo", + ); + + let policy = oidc.trust_policy(); + let parsed: serde_json::Value = serde_json::from_str(&policy).unwrap(); + + assert_eq!(parsed["Version"], "2012-10-17"); + let statement = &parsed["Statement"][0]; + assert_eq!(statement["Effect"], "Allow"); + assert_eq!(statement["Action"], "sts:AssumeRoleWithWebIdentity"); + } + + #[test] + fn test_aws_oidc_required_resources() { + let oidc = AwsOidcConfig::new( + test_account(), + "github-actions-role", + "myorg/myrepo", + ); + let resources = oidc.required_resources(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].kind(), "iam_role"); + assert_eq!(resources[0].name(), "github-actions-role"); + } + + #[test] + fn test_cloud_resource_from_aws() { + use super::super::CloudResource; + + let account = test_account(); + let bucket = AwsResource::S3Bucket { + name: "my-bucket".into(), + region: None, + }; + + let cloud_resource = CloudResource::aws(&account, &bucket); + assert_eq!( + cloud_resource.resource_id(), + "cloud:aws:123456789012:s3_bucket/my-bucket", + ); + } +} diff --git a/core/ir/src/transport/cloud/gcp.rs b/core/ir/src/transport/cloud/gcp.rs new file mode 100644 index 00000000000..0d7531ca675 --- /dev/null +++ b/core/ir/src/transport/cloud/gcp.rs @@ -0,0 +1,516 @@ +//! GCP (Google Cloud Platform) resource modeling. +//! +//! Models GCP resources that can be upserted: checked for existence, +//! created if missing, and resolved to a handle. +//! +//! # Resources Modeled +//! +//! - **Storage Buckets** (GCS): Object storage +//! - **Service Accounts**: IAM identity for workloads +//! - **Secrets**: Secret Manager secrets +//! - **Pub/Sub Topics**: Messaging topics +//! - **Artifact Registry Repos**: Container/package registry +//! +//! # Authentication +//! +//! GCP authentication uses Workload Identity Federation (WIF) for keyless +//! access from GitHub Actions. This is modeled in [`GcpWifConfig`] and +//! integrates with the [`super::super::secret::SecretRef`] system. +//! +//! # Example +//! +//! ```ignore +//! use gunbc_ir::transport::cloud::gcp::*; +//! +//! let project = GcpProject::new("my-project-123"); +//! let bucket = GcpResource::StorageBucket { +//! name: "my-data-bucket".into(), +//! location: "us-central1".into(), +//! }; +//! +//! // Check command: gsutil ls gs://my-data-bucket +//! let check = bucket.check_command(&project); +//! // Create command: gsutil mb -l us-central1 gs://my-data-bucket +//! let create = bucket.create_command(&project); +//! ``` + +use serde::{Deserialize, Serialize}; + +/// GCP project identifier. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GcpProject { + /// Project ID (e.g., "my-project-123"). + pub id: String, +} + +impl GcpProject { + /// Create a new GCP project reference. + pub fn new(id: impl Into) -> Self { + Self { id: id.into() } + } +} + +impl std::fmt::Display for GcpProject { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.id) + } +} + +/// GCP resource types that can be upserted. +/// +/// Each variant carries the configuration needed to check, create, +/// and resolve the resource. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum GcpResource { + /// Google Cloud Storage bucket. + StorageBucket { + /// Bucket name (globally unique). + name: String, + /// Bucket location (e.g., "us-central1", "US"). + location: String, + }, + + /// IAM Service Account. + ServiceAccount { + /// Service account ID (e.g., "my-sa"). + /// Full email will be `{id}@{project}.iam.gserviceaccount.com`. + id: String, + /// Display name. + display_name: String, + }, + + /// Secret Manager secret. + Secret { + /// Secret ID. + id: String, + }, + + /// Pub/Sub topic. + PubSubTopic { + /// Topic name. + name: String, + }, + + /// Artifact Registry repository. + ArtifactRegistryRepo { + /// Repository name. + name: String, + /// Repository location (e.g., "us-central1"). + location: String, + /// Repository format (e.g., "docker", "npm", "python"). + format: String, + }, +} + +impl GcpResource { + /// Get the resource kind identifier. + pub fn kind(&self) -> &'static str { + match self { + Self::StorageBucket { .. } => "storage_bucket", + Self::ServiceAccount { .. } => "service_account", + Self::Secret { .. } => "secret", + Self::PubSubTopic { .. } => "pubsub_topic", + Self::ArtifactRegistryRepo { .. } => "artifact_registry_repo", + } + } + + /// Get the resource name. + pub fn name(&self) -> &str { + match self { + Self::StorageBucket { name, .. } => name, + Self::ServiceAccount { id, .. } => id, + Self::Secret { id } => id, + Self::PubSubTopic { name } => name, + Self::ArtifactRegistryRepo { name, .. } => name, + } + } + + /// Generate the gcloud/gsutil check command for this resource. + /// + /// Returns `(command, args)` that can be used in a `ShellRequest`. + pub fn check_command(&self, project: &GcpProject) -> (&'static str, Vec) { + match self { + Self::StorageBucket { name, .. } => ( + "gcloud", + vec![ + "storage".into(), + "buckets".into(), + "describe".into(), + format!("gs://{name}"), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::ServiceAccount { id, .. } => ( + "gcloud", + vec![ + "iam".into(), + "service-accounts".into(), + "describe".into(), + format!("{id}@{}.iam.gserviceaccount.com", project.id), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::Secret { id } => ( + "gcloud", + vec![ + "secrets".into(), + "describe".into(), + id.clone(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::PubSubTopic { name } => ( + "gcloud", + vec![ + "pubsub".into(), + "topics".into(), + "describe".into(), + name.clone(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::ArtifactRegistryRepo { name, location, .. } => ( + "gcloud", + vec![ + "artifacts".into(), + "repositories".into(), + "describe".into(), + name.clone(), + "--location".into(), + location.clone(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + } + } + + /// Generate the gcloud/gsutil create command for this resource. + /// + /// Returns `(command, args)` that can be used in a `ShellRequest`. + pub fn create_command(&self, project: &GcpProject) -> (&'static str, Vec) { + match self { + Self::StorageBucket { name, location } => ( + "gcloud", + vec![ + "storage".into(), + "buckets".into(), + "create".into(), + format!("gs://{name}"), + "--location".into(), + location.clone(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::ServiceAccount { id, display_name } => ( + "gcloud", + vec![ + "iam".into(), + "service-accounts".into(), + "create".into(), + id.clone(), + "--display-name".into(), + display_name.clone(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::Secret { id } => ( + "gcloud", + vec![ + "secrets".into(), + "create".into(), + id.clone(), + "--replication-policy=automatic".into(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::PubSubTopic { name } => ( + "gcloud", + vec![ + "pubsub".into(), + "topics".into(), + "create".into(), + name.clone(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + Self::ArtifactRegistryRepo { + name, + location, + format, + } => ( + "gcloud", + vec![ + "artifacts".into(), + "repositories".into(), + "create".into(), + name.clone(), + "--location".into(), + location.clone(), + "--repository-format".into(), + format.clone(), + "--project".into(), + project.id.clone(), + "--format=json".into(), + ], + ), + } + } + + /// Get the service account email for a ServiceAccount resource. + /// + /// Returns `None` for non-ServiceAccount resources. + pub fn service_account_email(&self, project: &GcpProject) -> Option { + match self { + Self::ServiceAccount { id, .. } => { + Some(format!("{id}@{}.iam.gserviceaccount.com", project.id)) + } + _ => None, + } + } +} + +/// GCP Workload Identity Federation configuration. +/// +/// Enables keyless authentication from GitHub Actions to GCP using OIDC. +/// The GitHub Actions runner requests an OIDC token, which GCP validates +/// against the workload identity pool to grant temporary credentials. +/// +/// # Setup Requirements +/// +/// 1. A Workload Identity Pool in the GCP project +/// 2. A Workload Identity Provider configured for GitHub OIDC +/// 3. A Service Account with IAM bindings to the pool +/// +/// # Example +/// +/// ```ignore +/// let wif = GcpWifConfig::new( +/// GcpProject::new("my-project"), +/// "my-pool", +/// "github-provider", +/// "ci-sa", +/// ); +/// +/// // Generates the auth action config for GitHub Actions +/// let provider_name = wif.provider_full_name(); +/// // "projects/my-project/locations/global/workloadIdentityPools/my-pool/providers/github-provider" +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GcpWifConfig { + /// GCP project containing the workload identity pool. + pub project: GcpProject, + /// Workload Identity Pool ID. + pub pool_id: String, + /// Workload Identity Provider ID (configured for GitHub OIDC). + pub provider_id: String, + /// Service account ID to impersonate. + pub service_account_id: String, +} + +impl GcpWifConfig { + /// Create a new WIF configuration. + pub fn new( + project: GcpProject, + pool_id: impl Into, + provider_id: impl Into, + service_account_id: impl Into, + ) -> Self { + Self { + project, + pool_id: pool_id.into(), + provider_id: provider_id.into(), + service_account_id: service_account_id.into(), + } + } + + /// Get the full Workload Identity Provider resource name. + /// + /// This is used in the `google-github-actions/auth@v2` action's + /// `workload_identity_provider` input. + pub fn provider_full_name(&self) -> String { + format!( + "projects/{}/locations/global/workloadIdentityPools/{}/providers/{}", + self.project.id, self.pool_id, self.provider_id, + ) + } + + /// Get the service account email for impersonation. + pub fn service_account_email(&self) -> String { + format!( + "{}@{}.iam.gserviceaccount.com", + self.service_account_id, self.project.id, + ) + } + + /// List the GCP resources that must exist for WIF to work. + /// + /// These should be upserted before any workflow that uses WIF. + pub fn required_resources(&self) -> Vec { + vec![ + GcpResource::ServiceAccount { + id: self.service_account_id.clone(), + display_name: format!("WIF SA for {}", self.pool_id), + }, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_project() -> GcpProject { + GcpProject::new("test-project-123") + } + + #[test] + fn test_gcp_project_display() { + let project = test_project(); + assert_eq!(project.to_string(), "test-project-123"); + } + + #[test] + fn test_gcp_resource_kind() { + let bucket = GcpResource::StorageBucket { + name: "b".into(), + location: "us".into(), + }; + assert_eq!(bucket.kind(), "storage_bucket"); + + let sa = GcpResource::ServiceAccount { + id: "sa".into(), + display_name: "SA".into(), + }; + assert_eq!(sa.kind(), "service_account"); + + let secret = GcpResource::Secret { id: "s".into() }; + assert_eq!(secret.kind(), "secret"); + } + + #[test] + fn test_gcp_resource_name() { + let bucket = GcpResource::StorageBucket { + name: "my-bucket".into(), + location: "us-central1".into(), + }; + assert_eq!(bucket.name(), "my-bucket"); + } + + #[test] + fn test_bucket_check_command() { + let project = test_project(); + let bucket = GcpResource::StorageBucket { + name: "my-bucket".into(), + location: "us-central1".into(), + }; + let (cmd, args) = bucket.check_command(&project); + assert_eq!(cmd, "gcloud"); + assert!(args.contains(&"describe".to_string())); + assert!(args.contains(&"gs://my-bucket".to_string())); + } + + #[test] + fn test_bucket_create_command() { + let project = test_project(); + let bucket = GcpResource::StorageBucket { + name: "my-bucket".into(), + location: "us-central1".into(), + }; + let (cmd, args) = bucket.create_command(&project); + assert_eq!(cmd, "gcloud"); + assert!(args.contains(&"create".to_string())); + assert!(args.contains(&"gs://my-bucket".to_string())); + assert!(args.contains(&"us-central1".to_string())); + } + + #[test] + fn test_service_account_email() { + let project = test_project(); + let sa = GcpResource::ServiceAccount { + id: "my-sa".into(), + display_name: "My SA".into(), + }; + assert_eq!( + sa.service_account_email(&project), + Some("my-sa@test-project-123.iam.gserviceaccount.com".to_string()), + ); + } + + #[test] + fn test_non_sa_has_no_email() { + let project = test_project(); + let bucket = GcpResource::StorageBucket { + name: "b".into(), + location: "us".into(), + }; + assert_eq!(bucket.service_account_email(&project), None); + } + + #[test] + fn test_wif_config() { + let wif = GcpWifConfig::new( + test_project(), + "my-pool", + "github-provider", + "ci-sa", + ); + + assert_eq!( + wif.provider_full_name(), + "projects/test-project-123/locations/global/workloadIdentityPools/my-pool/providers/github-provider", + ); + assert_eq!( + wif.service_account_email(), + "ci-sa@test-project-123.iam.gserviceaccount.com", + ); + } + + #[test] + fn test_wif_required_resources() { + let wif = GcpWifConfig::new( + test_project(), + "my-pool", + "github-provider", + "ci-sa", + ); + let resources = wif.required_resources(); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].kind(), "service_account"); + assert_eq!(resources[0].name(), "ci-sa"); + } + + #[test] + fn test_cloud_resource_from_gcp() { + use super::super::CloudResource; + + let project = test_project(); + let bucket = GcpResource::StorageBucket { + name: "my-bucket".into(), + location: "us-central1".into(), + }; + + let cloud_resource = CloudResource::gcp(&project, &bucket); + assert_eq!( + cloud_resource.resource_id(), + "cloud:gcp:test-project-123:storage_bucket/my-bucket", + ); + } +} diff --git a/core/ir/src/transport/cloud/mod.rs b/core/ir/src/transport/cloud/mod.rs new file mode 100644 index 00000000000..56711581def --- /dev/null +++ b/core/ir/src/transport/cloud/mod.rs @@ -0,0 +1,198 @@ +//! Cloud provider abstraction for resource upserts. +//! +//! This module models cloud resources (GCP, AWS) as typed identifiers that +//! can be upserted using the standard Check → Create → Resolve pattern. +//! +//! # Design +//! +//! Cloud resources are modeled as data — provider, project/account, resource +//! kind, and name. Authentication is handled separately through [`SecretRef`] +//! and federation patterns (e.g., GCP Workload Identity Federation). +//! +//! The key abstraction is [`CloudResource`]: a fully-qualified identifier for +//! a cloud resource that can be checked, created, and resolved. +//! +//! # Example +//! +//! ```ignore +//! use gunbc_ir::transport::cloud::*; +//! use gunbc_ir::transport::cloud::gcp::*; +//! +//! let bucket = CloudResource::gcp( +//! GcpProject::new("my-project"), +//! GcpResource::StorageBucket { name: "my-bucket".into(), location: "us-central1".into() }, +//! ); +//! +//! // Use with UpsertBuilder for idempotent provisioning +//! ``` + +pub mod aws; +pub mod gcp; + +use serde::{Deserialize, Serialize}; + +/// Cloud provider identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CloudProvider { + /// Google Cloud Platform + Gcp, + /// Amazon Web Services + Aws, +} + +impl CloudProvider { + /// Get the display name. + pub fn name(&self) -> &'static str { + match self { + Self::Gcp => "GCP", + Self::Aws => "AWS", + } + } + + /// Get the CLI tool name for this provider. + pub fn cli_tool(&self) -> &'static str { + match self { + Self::Gcp => "gcloud", + Self::Aws => "aws", + } + } +} + +impl std::fmt::Display for CloudProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +/// A fully-qualified cloud resource identifier. +/// +/// Combines provider, project/account scope, and resource-specific identity. +/// This is the unit of upsert — one `CloudResource` maps to one +/// Check → Create → Resolve cycle. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CloudResource { + /// Which cloud provider owns this resource. + pub provider: CloudProvider, + /// Provider-specific project or account scope. + pub scope: String, + /// Resource kind (e.g., "storage_bucket", "iam_role"). + pub kind: String, + /// Resource name within its scope. + pub name: String, +} + +impl CloudResource { + /// Create a GCP cloud resource. + pub fn gcp(project: &gcp::GcpProject, resource: &gcp::GcpResource) -> Self { + Self { + provider: CloudProvider::Gcp, + scope: project.id.clone(), + kind: resource.kind().to_string(), + name: resource.name().to_string(), + } + } + + /// Create an AWS cloud resource. + pub fn aws(account: &aws::AwsAccount, resource: &aws::AwsResource) -> Self { + Self { + provider: CloudProvider::Aws, + scope: account.id.clone(), + kind: resource.kind().to_string(), + name: resource.name().to_string(), + } + } + + /// Get the canonical resource ID string. + /// + /// Format: `cloud:{provider}:{scope}:{kind}/{name}` + pub fn resource_id(&self) -> String { + format!( + "cloud:{}:{}:{}/{}", + self.provider.name().to_lowercase(), + self.scope, + self.kind, + self.name, + ) + } +} + +impl std::fmt::Display for CloudResource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.resource_id()) + } +} + +/// State of a cloud resource after a check operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum CloudResourceState { + /// Resource exists and is ready. + Exists, + /// Resource does not exist (can be created). + NotFound, + /// Resource exists but is in a degraded/error state. + Degraded { reason: String }, +} + +impl CloudResourceState { + /// Whether the resource exists (regardless of state). + pub fn exists(&self) -> bool { + matches!(self, Self::Exists | Self::Degraded { .. }) + } + + /// Whether the resource needs to be created. + pub fn needs_create(&self) -> bool { + matches!(self, Self::NotFound) + } +} + +/// Result of a cloud resource upsert operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CloudUpsertResult { + /// The resource that was upserted. + pub resource: CloudResource, + /// Whether the resource was created (true) or already existed (false). + pub was_created: bool, + /// Final state after upsert. + pub state: CloudResourceState, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cloud_provider_display() { + assert_eq!(CloudProvider::Gcp.name(), "GCP"); + assert_eq!(CloudProvider::Aws.name(), "AWS"); + } + + #[test] + fn test_cloud_provider_cli_tool() { + assert_eq!(CloudProvider::Gcp.cli_tool(), "gcloud"); + assert_eq!(CloudProvider::Aws.cli_tool(), "aws"); + } + + #[test] + fn test_cloud_resource_id_format() { + let resource = CloudResource { + provider: CloudProvider::Gcp, + scope: "my-project".to_string(), + kind: "storage_bucket".to_string(), + name: "my-bucket".to_string(), + }; + assert_eq!( + resource.resource_id(), + "cloud:gcp:my-project:storage_bucket/my-bucket" + ); + } + + #[test] + fn test_cloud_resource_state() { + assert!(CloudResourceState::Exists.exists()); + assert!(!CloudResourceState::NotFound.exists()); + assert!(CloudResourceState::Degraded { reason: "err".into() }.exists()); + + assert!(!CloudResourceState::Exists.needs_create()); + assert!(CloudResourceState::NotFound.needs_create()); + } +} diff --git a/core/ir/src/transport/github_actions.rs b/core/ir/src/transport/github_actions.rs index 7fc386482fd..bfa554afe58 100644 --- a/core/ir/src/transport/github_actions.rs +++ b/core/ir/src/transport/github_actions.rs @@ -309,6 +309,22 @@ pub fn upload_artifact() -> Integration { }) } +/// AWS OIDC federation - authenticate to AWS via OIDC. +/// +/// Uses: aws-actions/configure-aws-credentials@v4 +/// Requires: id-token:write +pub fn aws_oidc_credentials() -> Integration { + Integration::new( + "aws-oidc", + "aws-actions/configure-aws-credentials@v4", + "Authenticate to AWS via OIDC federation", + ) + .with_permissions(permissions! { + PermissionScope::IdToken => PermissionLevel::Write, + }) + .with_provides_tools(vec!["aws"]) +} + /// All known integrations. pub fn all_integrations() -> Vec { vec![ @@ -317,10 +333,101 @@ pub fn all_integrations() -> Vec { rust_toolchain(), ghcr_push(), gcp_workload_identity(), + aws_oidc_credentials(), upload_artifact(), ] } +// ============================================================================ +// GitHub Secret Declaration +// ============================================================================ + +/// A GitHub Actions secret required by a workflow. +/// +/// This represents a secret that must be configured in the GitHub repository +/// settings (Settings → Secrets → Actions) for the workflow to function. +/// +/// Secrets are referenced in workflow YAML as `${{ secrets.NAME }}`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GitHubSecret { + /// Secret name as it appears in GitHub settings. + pub name: String, + /// Human-readable description of what this secret contains. + pub description: String, + /// Whether this secret is required (vs. optional with fallback). + pub required: bool, +} + +impl GitHubSecret { + /// Create a required GitHub Actions secret. + pub fn required(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + required: true, + } + } + + /// Create an optional GitHub Actions secret. + pub fn optional(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + required: false, + } + } + + /// Get the workflow expression for this secret. + /// + /// Returns `${{ secrets.NAME }}`. + pub fn expression(&self) -> String { + format!("${{{{ secrets.{} }}}}", self.name) + } +} + +/// Standard GitHub secrets for common cloud integrations. +pub mod github_secrets { + use super::GitHubSecret; + + /// GCP Workload Identity Provider resource name. + /// + /// Value: `projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER` + pub fn gcp_wif_provider() -> GitHubSecret { + GitHubSecret::required( + "GCP_WIF_PROVIDER", + "GCP Workload Identity Provider resource name", + ) + } + + /// GCP Service Account email for WIF impersonation. + /// + /// Value: `sa-name@project-id.iam.gserviceaccount.com` + pub fn gcp_wif_service_account() -> GitHubSecret { + GitHubSecret::required( + "GCP_WIF_SERVICE_ACCOUNT", + "GCP Service Account email for WIF", + ) + } + + /// AWS IAM Role ARN for OIDC federation. + /// + /// Value: `arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME` + pub fn aws_oidc_role_arn() -> GitHubSecret { + GitHubSecret::required( + "AWS_OIDC_ROLE_ARN", + "AWS IAM Role ARN for OIDC federation", + ) + } + + /// AWS region for OIDC authentication. + pub fn aws_region() -> GitHubSecret { + GitHubSecret::optional( + "AWS_REGION", + "AWS region (defaults to us-east-1)", + ) + } +} + // ============================================================================ // Runner Image Model // ============================================================================ @@ -507,6 +614,8 @@ pub struct WorkflowConfig { pub permissions: Permissions, /// Command to run in the CI step pub run_command: String, + /// GitHub Actions secrets required by this workflow. + pub required_secrets: Vec, } impl WorkflowConfig { @@ -527,6 +636,7 @@ impl WorkflowConfig { integrations, permissions, run_command: String::new(), + required_secrets: Vec::new(), } } @@ -536,6 +646,12 @@ impl WorkflowConfig { self } + /// Set the required GitHub Actions secrets. + pub fn with_secrets(mut self, secrets: Vec) -> Self { + self.required_secrets = secrets; + self + } + /// Check if this workflow has any special permissions. pub fn has_permissions(&self) -> bool { !self.permissions.is_empty() @@ -829,4 +945,86 @@ mod tests { assert!(result.is_err()); assert_eq!(result.unwrap_err(), vec!["nonexistent"]); } + + // ======================================================================== + // AWS OIDC Integration Tests + // ======================================================================== + + #[test] + fn test_aws_oidc_integration() { + let aws = aws_oidc_credentials(); + assert_eq!(aws.id, "aws-oidc"); + assert_eq!(aws.uses, "aws-actions/configure-aws-credentials@v4"); + + let perms = aws.required_permissions(); + assert_eq!( + perms.get(&PermissionScope::IdToken), + Some(&PermissionLevel::Write), + ); + + // Should provide aws CLI tool + assert!(aws.provides_tools().contains(&"aws")); + } + + #[test] + fn test_all_integrations_includes_aws_oidc() { + let integrations = all_integrations(); + assert!(integrations.iter().any(|i| i.id == "aws-oidc")); + } + + // ======================================================================== + // GitHub Secret Tests + // ======================================================================== + + #[test] + fn test_github_secret_required() { + let secret = GitHubSecret::required("MY_TOKEN", "A test token"); + assert_eq!(secret.name, "MY_TOKEN"); + assert!(secret.required); + assert_eq!(secret.expression(), "${{ secrets.MY_TOKEN }}"); + } + + #[test] + fn test_github_secret_optional() { + let secret = GitHubSecret::optional("OPTIONAL_KEY", "An optional key"); + assert!(!secret.required); + } + + #[test] + fn test_standard_gcp_secrets() { + let provider = github_secrets::gcp_wif_provider(); + assert_eq!(provider.name, "GCP_WIF_PROVIDER"); + assert!(provider.required); + + let sa = github_secrets::gcp_wif_service_account(); + assert_eq!(sa.name, "GCP_WIF_SERVICE_ACCOUNT"); + assert!(sa.required); + } + + #[test] + fn test_standard_aws_secrets() { + let role = github_secrets::aws_oidc_role_arn(); + assert_eq!(role.name, "AWS_OIDC_ROLE_ARN"); + assert!(role.required); + + let region = github_secrets::aws_region(); + assert_eq!(region.name, "AWS_REGION"); + assert!(!region.required); + } + + #[test] + fn test_workflow_config_with_secrets() { + let config = WorkflowConfig::new( + "Deploy", + ubuntu_latest(), + vec![checkout(), gcp_workload_identity()], + ) + .with_secrets(vec![ + github_secrets::gcp_wif_provider(), + github_secrets::gcp_wif_service_account(), + ]); + + assert_eq!(config.required_secrets.len(), 2); + assert!(config.required_secrets.iter().any(|s| s.name == "GCP_WIF_PROVIDER")); + } } diff --git a/core/ir/src/transport/mod.rs b/core/ir/src/transport/mod.rs index 73f4fe7d1ea..de35fcdb5fc 100644 --- a/core/ir/src/transport/mod.rs +++ b/core/ir/src/transport/mod.rs @@ -8,6 +8,8 @@ //! - GitHub platform (API + CLI) //! - GitHub Actions for CI/CD integration //! - CI provider abstraction for workflow commands +//! - Cloud resource management (GCP, AWS) with upsert patterns +//! - Secret references and federation (OIDC-based keyless auth) //! //! The key insight is that all world I/O can be modeled as request/response pairs, //! allowing business logic to remain pure while transport execution happens at @@ -33,12 +35,14 @@ pub mod ci; pub mod cli; +pub mod cloud; pub mod file; pub mod gist; pub mod github; pub mod github_actions; pub mod http; pub mod rest; +pub mod secret; pub mod tcp; pub mod tool; @@ -50,7 +54,8 @@ pub use github::{ GitHubAuth, GITHUB_API_VERSION, GITHUB_CONTRACT_VERSION, GH_CLI_MIN_VERSION, }; pub use github_actions::{ - merge_permissions, Integration, PermissionLevel, PermissionScope, Permissions, RunnerImage, + merge_permissions, aws_oidc_credentials, github_secrets, GitHubSecret, + Integration, PermissionLevel, PermissionScope, Permissions, RunnerImage, WorkflowConfig, }; pub use http::{HttpMethod, HttpRequest, HttpResponse}; @@ -77,6 +82,12 @@ pub use ci::{ detect_provider, is_ci, AnnotationLevel, CiProvider, FileLocation, GitHubActionsProvider, GitLabCiProvider, GitLabRunner, PlainTextProvider, Runner, WorkflowCommand, }; +pub use cloud::{ + CloudProvider, CloudResource, CloudResourceState, CloudUpsertResult, + gcp::{GcpProject, GcpResource, GcpWifConfig}, + aws::{AwsAccount, AwsOidcConfig, AwsResource}, +}; +pub use secret::{SecretFederation, SecretRef, SecretRequirements, SecretSource}; use serde::{Deserialize, Serialize}; diff --git a/core/ir/src/transport/secret.rs b/core/ir/src/transport/secret.rs new file mode 100644 index 00000000000..d3ccf693261 --- /dev/null +++ b/core/ir/src/transport/secret.rs @@ -0,0 +1,444 @@ +//! Secret reference modeling. +//! +//! Secrets are never stored as values in the DAG — only references to where +//! secrets live. This module provides [`SecretRef`] for referencing secrets +//! from different sources, and [`SecretFederation`] for describing how to +//! obtain temporary credentials without static keys. +//! +//! # Design Principles +//! +//! 1. **No secret values in the DAG.** Only references (`SecretRef`) flow +//! through nodes. Actual secret resolution happens at the transport boundary. +//! +//! 2. **Federation over static keys.** Prefer OIDC-based federation +//! (`SecretFederation`) over long-lived credentials wherever possible. +//! +//! 3. **Source tracking.** Every secret reference knows where it comes from +//! (`SecretSource`), enabling auditing and rotation planning. +//! +//! # Example +//! +//! ```ignore +//! use gunbc_ir::transport::secret::*; +//! +//! // Reference a GitHub Actions secret +//! let token = SecretRef::github_actions("GCP_WIF_PROVIDER"); +//! +//! // Reference an environment variable +//! let api_key = SecretRef::env("API_KEY"); +//! +//! // Describe GCP federation +//! let fed = SecretFederation::gcp_wif( +//! "projects/123/locations/global/workloadIdentityPools/pool/providers/gh", +//! "sa@project.iam.gserviceaccount.com", +//! ); +//! ``` + +use serde::{Deserialize, Serialize}; + +/// A reference to a secret (never the value itself). +/// +/// `SecretRef` is the unit of secret management in the DAG. It carries +/// enough information to resolve the actual value at the transport boundary, +/// but never contains the secret itself. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SecretRef { + /// Human-readable name for this secret reference. + pub name: String, + /// Where the secret comes from. + pub source: SecretSource, +} + +impl SecretRef { + /// Create a secret reference from a GitHub Actions secret. + /// + /// In workflows, this resolves to `${{ secrets.NAME }}`. + pub fn github_actions(name: impl Into) -> Self { + let name = name.into(); + Self { + name: name.clone(), + source: SecretSource::GitHubActionsSecret { name }, + } + } + + /// Create a secret reference from an environment variable. + /// + /// Resolved at execution time from the process environment. + pub fn env(var_name: impl Into) -> Self { + let var_name = var_name.into(); + Self { + name: var_name.clone(), + source: SecretSource::EnvVar { var_name }, + } + } + + /// Create a secret reference from GCP Secret Manager. + pub fn gcp_secret_manager( + project: impl Into, + secret_id: impl Into, + ) -> Self { + let secret_id = secret_id.into(); + Self { + name: secret_id.clone(), + source: SecretSource::GcpSecretManager { + project: project.into(), + secret_id, + version: "latest".to_string(), + }, + } + } + + /// Create a secret reference from AWS Secrets Manager. + pub fn aws_secrets_manager( + secret_name: impl Into, + region: impl Into, + ) -> Self { + let secret_name = secret_name.into(); + Self { + name: secret_name.clone(), + source: SecretSource::AwsSecretsManager { + secret_name, + region: region.into(), + }, + } + } + + /// Create a secret reference from OIDC federation (no static secret). + /// + /// The "secret" here is a temporary credential obtained via OIDC token + /// exchange. There is no stored secret — just a federation configuration. + pub fn federated(name: impl Into, federation: SecretFederation) -> Self { + Self { + name: name.into(), + source: SecretSource::Federation(federation), + } + } + + /// Whether this secret requires a static value (vs. federation). + pub fn is_static(&self) -> bool { + !matches!(self.source, SecretSource::Federation(_)) + } + + /// Whether this secret uses federation (OIDC-based, no static key). + pub fn is_federated(&self) -> bool { + matches!(self.source, SecretSource::Federation(_)) + } + + /// Get the GitHub Actions expression for this secret, if applicable. + /// + /// Returns `Some("${{ secrets.NAME }}")` for GitHub Actions secrets, + /// or `None` for other sources. + pub fn github_actions_expression(&self) -> Option { + match &self.source { + SecretSource::GitHubActionsSecret { name } => { + Some(format!("${{{{ secrets.{name} }}}}")) + } + _ => None, + } + } +} + +impl std::fmt::Display for SecretRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "secret:{}", self.name) + } +} + +/// Where a secret comes from. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SecretSource { + /// Environment variable (resolved at execution time). + EnvVar { + /// Variable name (e.g., "GITHUB_TOKEN"). + var_name: String, + }, + + /// GitHub Actions repository/environment secret. + GitHubActionsSecret { + /// Secret name as configured in GitHub settings. + name: String, + }, + + /// GCP Secret Manager. + GcpSecretManager { + /// GCP project ID. + project: String, + /// Secret ID within the project. + secret_id: String, + /// Secret version (typically "latest"). + version: String, + }, + + /// AWS Secrets Manager. + AwsSecretsManager { + /// Secret name or ARN. + secret_name: String, + /// AWS region. + region: String, + }, + + /// Federation-based credential (OIDC token exchange). + /// No static secret — credentials obtained dynamically via identity federation. + Federation(SecretFederation), +} + +impl SecretSource { + /// Get a short identifier for the source type. + pub fn source_kind(&self) -> &'static str { + match self { + Self::EnvVar { .. } => "env", + Self::GitHubActionsSecret { .. } => "github_actions", + Self::GcpSecretManager { .. } => "gcp_secret_manager", + Self::AwsSecretsManager { .. } => "aws_secrets_manager", + Self::Federation(_) => "federation", + } + } +} + +/// Federation configuration for obtaining temporary credentials. +/// +/// Federation eliminates the need for static credentials by using identity +/// tokens (e.g., GitHub Actions OIDC) to obtain temporary cloud credentials. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SecretFederation { + /// GCP Workload Identity Federation. + /// + /// Uses GitHub Actions OIDC token → GCP STS → temporary GCP credentials. + /// Requires: `id-token: write` permission in GitHub Actions. + GcpWorkloadIdentity { + /// Full provider resource name. + /// e.g., "projects/P/locations/global/workloadIdentityPools/POOL/providers/PROV" + workload_identity_provider: String, + /// Service account to impersonate. + /// e.g., "sa@project.iam.gserviceaccount.com" + service_account: String, + }, + + /// AWS OIDC federation. + /// + /// Uses GitHub Actions OIDC token → AWS STS → temporary AWS credentials. + /// Requires: `id-token: write` permission in GitHub Actions. + AwsOidc { + /// IAM role ARN to assume. + /// e.g., "arn:aws:iam::123456789012:role/github-actions" + role_arn: String, + /// AWS region for STS endpoint. + region: String, + }, +} + +impl SecretFederation { + /// Create a GCP Workload Identity Federation configuration. + pub fn gcp_wif( + workload_identity_provider: impl Into, + service_account: impl Into, + ) -> Self { + Self::GcpWorkloadIdentity { + workload_identity_provider: workload_identity_provider.into(), + service_account: service_account.into(), + } + } + + /// Create an AWS OIDC federation configuration. + pub fn aws_oidc(role_arn: impl Into, region: impl Into) -> Self { + Self::AwsOidc { + role_arn: role_arn.into(), + region: region.into(), + } + } + + /// Whether this federation requires `id-token: write` in GitHub Actions. + pub fn requires_id_token(&self) -> bool { + // Both GCP WIF and AWS OIDC require OIDC token generation + true + } + + /// Get the cloud provider for this federation. + pub fn provider(&self) -> super::cloud::CloudProvider { + match self { + Self::GcpWorkloadIdentity { .. } => super::cloud::CloudProvider::Gcp, + Self::AwsOidc { .. } => super::cloud::CloudProvider::Aws, + } + } +} + +/// A set of secrets required by a workflow or operation. +/// +/// Tracks all secret references needed, enabling: +/// - Pre-flight validation (are all secrets available?) +/// - GitHub Actions secret configuration +/// - Audit of secret dependencies +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SecretRequirements { + /// All required secret references. + pub secrets: Vec, +} + +impl SecretRequirements { + /// Create an empty set of requirements. + pub fn new() -> Self { + Self::default() + } + + /// Add a secret requirement. + pub fn require(&mut self, secret: SecretRef) { + if !self.secrets.iter().any(|s| s.name == secret.name) { + self.secrets.push(secret); + } + } + + /// Get all GitHub Actions secrets needed. + pub fn github_actions_secrets(&self) -> Vec<&SecretRef> { + self.secrets + .iter() + .filter(|s| matches!(s.source, SecretSource::GitHubActionsSecret { .. })) + .collect() + } + + /// Get all federated credentials needed. + pub fn federated_secrets(&self) -> Vec<&SecretRef> { + self.secrets + .iter() + .filter(|s| s.is_federated()) + .collect() + } + + /// Get all static secrets (non-federated). + pub fn static_secrets(&self) -> Vec<&SecretRef> { + self.secrets.iter().filter(|s| s.is_static()).collect() + } + + /// Check if any federation requires `id-token: write` permission. + pub fn requires_id_token_permission(&self) -> bool { + self.secrets.iter().any(|s| { + matches!( + &s.source, + SecretSource::Federation(fed) if fed.requires_id_token() + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_ref_github_actions() { + let secret = SecretRef::github_actions("MY_TOKEN"); + assert_eq!(secret.name, "MY_TOKEN"); + assert!(secret.is_static()); + assert!(!secret.is_federated()); + assert_eq!( + secret.github_actions_expression(), + Some("${{ secrets.MY_TOKEN }}".to_string()), + ); + } + + #[test] + fn test_secret_ref_env() { + let secret = SecretRef::env("API_KEY"); + assert_eq!(secret.name, "API_KEY"); + assert!(secret.is_static()); + assert_eq!(secret.github_actions_expression(), None); + } + + #[test] + fn test_secret_ref_gcp_secret_manager() { + let secret = SecretRef::gcp_secret_manager("my-project", "db-password"); + assert_eq!(secret.name, "db-password"); + assert!(secret.is_static()); + match &secret.source { + SecretSource::GcpSecretManager { + project, + secret_id, + version, + } => { + assert_eq!(project, "my-project"); + assert_eq!(secret_id, "db-password"); + assert_eq!(version, "latest"); + } + _ => panic!("Expected GcpSecretManager"), + } + } + + #[test] + fn test_secret_ref_aws_secrets_manager() { + let secret = SecretRef::aws_secrets_manager("db-password", "us-east-1"); + assert_eq!(secret.name, "db-password"); + assert!(secret.is_static()); + } + + #[test] + fn test_secret_ref_federated() { + let fed = SecretFederation::gcp_wif( + "projects/p/locations/global/workloadIdentityPools/pool/providers/prov", + "sa@p.iam.gserviceaccount.com", + ); + let secret = SecretRef::federated("gcp-access", fed); + assert!(!secret.is_static()); + assert!(secret.is_federated()); + assert_eq!(secret.github_actions_expression(), None); + } + + #[test] + fn test_secret_source_kind() { + assert_eq!( + SecretSource::EnvVar { var_name: "X".into() }.source_kind(), + "env", + ); + assert_eq!( + SecretSource::GitHubActionsSecret { name: "X".into() }.source_kind(), + "github_actions", + ); + } + + #[test] + fn test_secret_federation_provider() { + let gcp = SecretFederation::gcp_wif("p", "sa"); + assert_eq!(gcp.provider(), super::super::cloud::CloudProvider::Gcp); + + let aws = SecretFederation::aws_oidc("arn", "us-east-1"); + assert_eq!(aws.provider(), super::super::cloud::CloudProvider::Aws); + } + + #[test] + fn test_secret_federation_requires_id_token() { + let gcp = SecretFederation::gcp_wif("p", "sa"); + assert!(gcp.requires_id_token()); + + let aws = SecretFederation::aws_oidc("arn", "us-east-1"); + assert!(aws.requires_id_token()); + } + + #[test] + fn test_secret_requirements() { + let mut reqs = SecretRequirements::new(); + reqs.require(SecretRef::github_actions("TOKEN_A")); + reqs.require(SecretRef::env("LOCAL_KEY")); + reqs.require(SecretRef::federated( + "gcp", + SecretFederation::gcp_wif("p", "sa"), + )); + + assert_eq!(reqs.secrets.len(), 3); + assert_eq!(reqs.github_actions_secrets().len(), 1); + assert_eq!(reqs.federated_secrets().len(), 1); + assert_eq!(reqs.static_secrets().len(), 2); + assert!(reqs.requires_id_token_permission()); + } + + #[test] + fn test_secret_requirements_dedup() { + let mut reqs = SecretRequirements::new(); + reqs.require(SecretRef::github_actions("TOKEN_A")); + reqs.require(SecretRef::github_actions("TOKEN_A")); // duplicate + assert_eq!(reqs.secrets.len(), 1); + } + + #[test] + fn test_secret_ref_display() { + let secret = SecretRef::github_actions("MY_TOKEN"); + assert_eq!(secret.to_string(), "secret:MY_TOKEN"); + } +} diff --git a/gunbc-dag/src/workspace/mod.rs b/gunbc-dag/src/workspace/mod.rs index e29c827f60b..1e70f42d394 100644 --- a/gunbc-dag/src/workspace/mod.rs +++ b/gunbc-dag/src/workspace/mod.rs @@ -6,12 +6,13 @@ pub mod deprecated; pub mod ops; pub mod subdags; -pub use ops::WorkspaceOp; +pub use ops::{CloudOp, WorkspaceOp}; pub use subdags::build_workspace_dag; pub use subdags::bootstrap::build_bootstrap_subdag; pub use subdags::buck2::build_buck2_subdag; pub use subdags::ci::build_ci_subdag; pub use subdags::clippy::{build_clippy_lint_all_subdag, build_clippy_subdag}; +pub use subdags::cloud::build_cloud_upsert_subdag; pub use subdags::deps::{build_deps_generate_subdag, build_deps_install_subdag}; pub use subdags::gist::{build_gist_rust_subdag, build_gist_subdag}; pub use subdags::languages::build_languages_subdag; diff --git a/gunbc-dag/src/workspace/ops.rs b/gunbc-dag/src/workspace/ops.rs index 1d15ae5cf4d..1e65d0ee41b 100644 --- a/gunbc-dag/src/workspace/ops.rs +++ b/gunbc-dag/src/workspace/ops.rs @@ -23,6 +23,28 @@ use gunbc_ir::LanguageOp; use gunbc_lib_transport::TransportOps; use gunbc_primitives::PrimitiveOp; +/// Cloud resource operations. +/// +/// Pure operations for cloud resource management (GCP, AWS). +/// Actual I/O happens at TransportOps::Execute boundaries. +#[derive(Debug, Clone)] +pub enum CloudOp { + /// Prepare a shell command to check if a cloud resource exists. + PrepareCheckResource, + /// Parse the result of a resource existence check. + ParseCheckResult, + /// Prepare a shell command to create a cloud resource. + PrepareCreateResource, + /// Parse the result of a resource creation. + ParseCreateResult, + /// Prepare a shell command to verify a cloud resource exists (resolve). + PrepareResolveResource, + /// Parse the resolve result and return the resource handle. + ParseResolveResult, + /// Load cloud resource configuration. + LoadCloudConfig, +} + /// Unified operation enum for the workspace DAG. /// /// All tool, language, primitive, and transport operations are wrapped @@ -31,6 +53,7 @@ use gunbc_primitives::PrimitiveOp; /// # Categories /// /// - **Domain ops**: Tool-specific pure operations (Ci, Deps, Makegen, etc.) +/// - **Cloud ops**: Cloud resource upsert operations (GCP, AWS) /// - **Language ops**: Language/format characteristics (from Languages DAG) /// - **Primitive ops**: Reusable pure operations (parsing, file prep, etc.) /// - **Transport ops**: I/O boundary operations @@ -53,6 +76,8 @@ pub enum WorkspaceOp { Buck2(Buck2Op), /// Clippy/CLI tool operations Clippy(CliToolOp), + /// Cloud resource upsert operations (GCP, AWS) + Cloud(CloudOp), // ======================================================================== // Language Ops (from Languages DAG) @@ -90,6 +115,12 @@ impl Executable for WorkspaceOp { WorkspaceOp::Clippy(op) => op .execute() .map_err(|e| ExecError::new(format!("CliToolOp error: {}", e))), + // Cloud ops are pure data transformations + WorkspaceOp::Cloud(_op) => { + // CloudOp nodes build/parse shell commands for cloud CLIs. + // Execution is delegated to TransportOps::Execute nodes. + Ok(inputs) + } // Language ops WorkspaceOp::Language(_op) => { // LanguageOp nodes are mostly config nodes - return empty for now @@ -149,6 +180,12 @@ impl From for WorkspaceOp { } } +impl From for WorkspaceOp { + fn from(op: CloudOp) -> Self { + WorkspaceOp::Cloud(op) + } +} + impl From for WorkspaceOp { fn from(op: LanguageOp) -> Self { WorkspaceOp::Language(op) diff --git a/gunbc-dag/src/workspace/subdags/cloud.rs b/gunbc-dag/src/workspace/subdags/cloud.rs new file mode 100644 index 00000000000..df3de4c4faf --- /dev/null +++ b/gunbc-dag/src/workspace/subdags/cloud.rs @@ -0,0 +1,103 @@ +//! Cloud resource SubDag builders. +//! +//! Provides SubDag nodes for cloud resource upserts (GCP, AWS) and +//! secret federation setup. Uses the UpsertBuilder pattern for +//! idempotent provisioning (Check → Create → Resolve). +//! +//! # Architecture +//! +//! The cloud upsert SubDag uses the UpsertBuilder pattern internally, +//! which creates a guarded SubDag: +//! +//! ```text +//! Cloud Upsert SubDag +//! ├── check (check if cloud resource exists via CLI) +//! ├── create (create resource if missing, guarded by check) +//! └── resolve (verify resource exists and return handle) +//! ``` +//! +//! Each phase (check/create/resolve) is an opaque CloudOp node. +//! The actual CLI execution is delegated to the transport boundary +//! at execution time. + +use crate::workspace::ops::CloudOp; +use crate::workspace::WorkspaceOp; +use gunbc_ir::patterns::UpsertBuilder; +use gunbc_ir::Node; + +/// Build a cloud resource upsert SubDag node. +/// +/// This creates a SubDag that performs the Check → Create → Resolve +/// pattern for a single cloud resource, using the UpsertBuilder to +/// handle guard logic internally. +/// +/// # I/O Interface +/// +/// Inputs: +/// - `resource_spec`: String (JSON cloud resource description) +/// +/// Outputs: +/// - `handle`: String (resource identifier/handle after upsert) +/// - `was_created`: Bool (true if resource was newly created) +pub fn build_cloud_upsert_subdag() -> Node { + UpsertBuilder::new("cloud_upsert") + .with_check(WorkspaceOp::Cloud(CloudOp::PrepareCheckResource)) + .with_create(WorkspaceOp::Cloud(CloudOp::PrepareCreateResource)) + .with_resolve(WorkspaceOp::Cloud(CloudOp::PrepareResolveResource)) + .with_input_port("resource_spec", "String") + .with_output_port("handle", "String") + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use gunbc_ir::NodeBody; + + #[test] + fn test_cloud_upsert_subdag_is_subdag() { + let node = build_cloud_upsert_subdag(); + assert!(node.is_subdag()); + assert_eq!(node.id.0, "cloud_upsert"); + } + + #[test] + fn test_cloud_upsert_subdag_io() { + let node = build_cloud_upsert_subdag(); + + // One input: resource_spec + assert_eq!(node.inputs.len(), 1); + assert_eq!(node.inputs[0].name.0, "resource_spec"); + + // Two outputs: handle, was_created (from UpsertBuilder) + assert_eq!(node.outputs.len(), 2); + let output_names: Vec<_> = node.outputs.iter().map(|p| p.name.0.as_str()).collect(); + assert!(output_names.contains(&"handle")); + assert!(output_names.contains(&"was_created")); + } + + #[test] + fn test_cloud_upsert_subdag_structure() { + let node = build_cloud_upsert_subdag(); + + match &node.body { + NodeBody::SubDag(dag) => { + // UpsertBuilder creates 3 internal nodes: check, create, resolve + assert_eq!(dag.nodes.len(), 3); + + // Verify node names + let names: Vec<_> = dag.nodes.iter().map(|n| n.id.0.as_str()).collect(); + assert!(names.contains(&"check")); + assert!(names.contains(&"create")); + assert!(names.contains(&"resolve")); + + // Verify the edge from check → create (for guard) + assert_eq!(dag.edges.len(), 1); + assert_eq!(dag.edges[0].from_node.0, "check"); + assert_eq!(dag.edges[0].from_port.0, "exists"); + assert_eq!(dag.edges[0].to_node.0, "create"); + } + _ => panic!("Expected SubDag"), + } + } +} diff --git a/gunbc-dag/src/workspace/subdags/mod.rs b/gunbc-dag/src/workspace/subdags/mod.rs index dd5246f7f0a..2c9959edd2a 100644 --- a/gunbc-dag/src/workspace/subdags/mod.rs +++ b/gunbc-dag/src/workspace/subdags/mod.rs @@ -7,6 +7,7 @@ pub mod bootstrap; pub mod buck2; pub mod ci; pub mod clippy; +pub mod cloud; pub mod deps; pub mod gist; pub mod languages; @@ -25,6 +26,7 @@ use gunbc_ir::Dag; /// ```text /// Workspace DAG /// ├── makegen SubDag (Makefile generation) +/// ├── cloud_upsert SubDag (cloud resource provisioning) /// ├── languages SubDag /// │ ├── rust /// │ ├── makefile @@ -45,6 +47,9 @@ pub fn build_workspace_dag() -> Dag { dag.add_node(ci::build_ci_subdag()); dag.add_node(gist::build_gist_rust_subdag()); + // Cloud resource upsert SubDag + dag.add_node(cloud::build_cloud_upsert_subdag()); + // Language SubDag (already fractal) dag.add_node(languages::build_languages_subdag()); @@ -59,7 +64,7 @@ mod tests { fn test_workspace_dag_structure() { let dag = build_workspace_dag(); - // Should have all tool subdags plus languages + // Should have all tool subdags plus languages and cloud let node_ids: Vec<_> = dag.nodes.iter().map(|n| n.id.0.as_str()).collect(); assert!(node_ids.contains(&"makegen")); assert!(node_ids.contains(&"clippy")); @@ -69,6 +74,7 @@ mod tests { assert!(node_ids.contains(&"buck2")); assert!(node_ids.contains(&"ci")); assert!(node_ids.contains(&"gist")); + assert!(node_ids.contains(&"cloud_upsert")); assert!(node_ids.contains(&"languages")); }