From ec0154f5d3fb6bb8b9930842034f1d8e587324a2 Mon Sep 17 00:00:00 2001 From: "Matthew C. Morgan" Date: Thu, 26 Feb 2026 15:03:13 -0500 Subject: [PATCH] complete refactor, add s3 --- README.md | 149 ++++++++-- examples/ec2/linux/census-rhel-instance.tf | 68 ++--- examples/ec2/linux/main.tf | 14 +- examples/ec2/linux/outputs.tf | 7 - examples/ec2/linux/terraform.tfvars.example | 39 ++- examples/ec2/linux/variables.tf | 19 ++ examples/ec2/linux/versions.tf | 9 + examples/s3/outputs.tf | 26 ++ examples/s3/providers.tf | 3 + examples/s3/simple-s3.tf | 91 +++--- examples/s3/terraform.tfvars.example | 65 +++++ examples/s3/variables.tf | 133 +++++++++ examples/s3/versions.tf | 9 + locals.tf.initial | 9 - modules/ec2/README.md | 244 ++++++++++++++++ modules/ec2/data.tf | 37 +-- modules/ec2/locals.tf | 62 ++-- modules/ec2/main.tf | 67 ++--- modules/ec2/module_name.tf | 2 +- modules/ec2/outputs.tf | 35 +-- modules/ec2/prefixes.tf | 28 -- modules/ec2/settings.tf | 15 - modules/ec2/variables.common.tf | 46 ++- modules/ec2/variables.product.tf | 74 ++--- modules/ec2/variables.servicecatalog.tf | 6 + modules/ec2/versions.tf | 2 +- modules/product/README.md | 83 ++++++ modules/product/data.tf | 29 ++ modules/product/locals.tf | 56 ++++ modules/product/main.tf | 48 ++++ modules/product/module_name.tf | 4 + modules/product/outputs.tf | 49 ++++ modules/product/variables.common.tf | 14 + modules/product/variables.product.tf | 50 ++++ modules/product/variables.servicecatalog.tf | 65 +++++ modules/product/variables.tags.tf | 5 + modules/product/versions.tf | 9 + modules/s3/README.md | 298 ++++++++++++++++++++ modules/s3/data.tf | 69 ----- modules/s3/locals.tf | 75 +---- modules/s3/main.tf | 67 ++--- modules/s3/module_name.tf | 3 +- modules/s3/outputs.tf | 51 +--- modules/s3/prefixes.tf | 28 -- modules/s3/variables.common.tf | 135 +-------- modules/s3/variables.product.tf | 49 ++++ modules/s3/variables.safeguards.tf | 24 -- modules/s3/variables.servicecatalog.tf | 65 +++++ modules/s3/variables.tags.tf | 11 - modules/s3/versions.tf | 2 +- 50 files changed, 1745 insertions(+), 803 deletions(-) create mode 100644 examples/ec2/linux/versions.tf create mode 100644 examples/s3/outputs.tf create mode 100644 examples/s3/providers.tf create mode 100644 examples/s3/terraform.tfvars.example create mode 100644 examples/s3/variables.tf create mode 100644 examples/s3/versions.tf delete mode 100644 locals.tf.initial create mode 100644 modules/ec2/README.md delete mode 100644 modules/ec2/prefixes.tf delete mode 100644 modules/ec2/settings.tf create mode 100644 modules/product/README.md create mode 100644 modules/product/data.tf create mode 100644 modules/product/locals.tf create mode 100644 modules/product/main.tf create mode 100644 modules/product/module_name.tf create mode 100644 modules/product/outputs.tf create mode 100644 modules/product/variables.common.tf create mode 100644 modules/product/variables.product.tf create mode 100644 modules/product/variables.servicecatalog.tf create mode 100644 modules/product/variables.tags.tf create mode 100644 modules/product/versions.tf create mode 100644 modules/s3/README.md delete mode 100644 modules/s3/data.tf delete mode 100644 modules/s3/prefixes.tf create mode 100644 modules/s3/variables.product.tf delete mode 100644 modules/s3/variables.safeguards.tf create mode 100644 modules/s3/variables.servicecatalog.tf diff --git a/README.md b/README.md index 916f2c0..7fb7576 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,90 @@ # AWS Service Catalog Terraform Module -This module provides a standardized way to provision and manage AWS Service Catalog products using Terraform. +This module provides simplified Terraform wrappers for provisioning AWS Service Catalog products with strongly-typed variables, validation, and consistent patterns. + +## Overview + +The module uses a layered architecture: +- **product module** - Base module handling Service Catalog provisioning (internal use) +- **ec2 module** - EC2-specific wrapper with instance parameters and VPC networking +- **s3 module** - S3-specific wrapper with bucket parameters and data classification + +Each wrapper module provides: +- Product-specific variables with validation +- Parameter mapping to CloudFormation templates +- Consistent tagging strategy +- Comprehensive documentation and examples ## Features -- **Product Provisioning**: Provision EC2, RDS, ECS, and S3 products from existing Service Catalog portfolios -- **Lifecycle Management**: Update and terminate provisioned products -- **Service Actions**: Execute service actions (start, stop, reboot, etc.) on provisioned products -- **Automatic Discovery**: Lookup portfolios and products by naming patterns -- **Stack Outputs**: Access CloudFormation stack outputs from provisioned products +- ✅ **EC2 Provisioning** - Provision EC2 instances with automated VPC/subnet discovery +- ✅ **S3 Provisioning** - Provision S3 buckets with title data classification +- ✅ **Lifecycle Management** - Create, update, and destroy provisioned products +- ✅ **Stack Outputs** - Access CloudFormation outputs from provisioned products +- ✅ **Variable Validation** - Built-in validation for parameters (instance types, bucket names, etc.) +- ✅ **Comprehensive Examples** - Working examples with terraform.tfvars.example files ## Module Structure -- `ec2/` - Provision EC2 instances from Service Catalog -- `ec2-actions/` - Execute service actions on EC2 provisioned products -- `rds/` - Provision RDS databases from Service Catalog -- `rds-actions/` - Execute service actions on RDS provisioned products -- `ecs/` - Provision ECS services from Service Catalog -- `ecs-actions/` - Execute service actions on ECS provisioned products -- `s3/` - Provision S3 buckets from Service Catalog -- `s3-actions/` - Execute service actions on S3 provisioned products -- `common/` - Shared components and utilities +``` +aws-servicecatalog/ +├── modules/ +│ ├── product/ # Base Service Catalog provisioning module (internal) +│ ├── ec2/ # EC2 instance provisioning wrapper +│ └── s3/ # S3 bucket provisioning wrapper +└── examples/ + ├── ec2/linux/ # EC2 Linux instance example + └── s3/ # S3 bucket example +``` + +### Currently Implemented + +- ✅ **modules/ec2/** - Provision EC2 instances from Service Catalog +- ✅ **modules/s3/** - Provision S3 buckets from Service Catalog +- ✅ **modules/product/** - Base module for Service Catalog provisioning (internal use only) + +### Planned/Future Enhancements + +- ⏳ **RDS module** - Provision RDS databases +- ⏳ **ECS module** - Provision ECS services +- ⏳ **Service actions** - Execute service actions (start, stop, reboot) ## Usage -See individual submodule README files for detailed usage examples. +See individual module README files for detailed documentation: +- [EC2 Module Documentation](modules/ec2/README.md) +- [S3 Module Documentation](modules/s3/README.md) +- [Product Module Documentation](modules/product/README.md) -### Basic EC2 Example +### EC2 Example ```hcl module "ec2_instance" { - source = "path/to/aws-servicecatalog/ec2" + source = "path/to/aws-servicecatalog/modules/ec2" + # Product identity provisioned_product_name = "my-web-server" - portfolio_name_pattern = "edl-portfolio" - product_name_pattern = "linux-product" + # EC2-specific parameters + instance_type = "t3.small" + os_name = "RHEL9" + requires_backup = "yes" + power_schedule = "Always_On" + + # Networking (auto-discovers VPC/subnets by name tag) + vpc_name = "my-vpc" + subnets_name = "*-apps-*" + + # Project/creator information + project_name = "myorg_myapp_prod-123456789012" + creator = "jsmith123" + contact_email = "jsmith@example.com" + inc_poc_email = "team@example.com" + + # Additional parameters parameters = { - InstanceType = "t3.medium" - KeyName = "my-key" - SubnetId = "subnet-12345" + NameTag = "web-server-01" + SecurityGroupNames = "it-linux-base,web-servers" } tags = { @@ -49,7 +94,63 @@ module "ec2_instance" { } ``` +### S3 Example + +```hcl +module "s3_bucket" { + source = "path/to/aws-servicecatalog/modules/s3" + + # Product identity + provisioned_product_name = "my-data-bucket" + + # S3-specific parameters + bucket_name = "my-data-lake" + enable_bucket_versioning = false + title_data = ["no_title"] + project_role = "data-engineering" + + # Project/creator information + project_name = "myorg_myapp_prod-123456789012" + creator = "jsmith123" + contact_email = "jsmith@example.com" + inc_poc_email = "team@example.com" + + tags = { + Environment = "production" + DataClass = "internal" + } +} +``` + ## Requirements - Terraform >= 1.0 -- AWS Provider >= 5.0 \ No newline at end of file +- AWS Provider >= 6.0 + +## Examples + +Complete working examples with terraform.tfvars.example files are available in: +- [examples/ec2/linux/](examples/ec2/linux/) - EC2 Linux instance provisioning +- [examples/s3/](examples/s3/) - S3 bucket provisioning + +## Architecture + +This module follows a base + specialized wrapper pattern: + +1. **product module** provides generic Service Catalog provisioning +2. **ec2/s3 modules** add product-specific parameters and data sources +3. Each wrapper handles parameter mapping to CloudFormation format +4. Networking data sources are only in modules that need them (EC2, not S3) + +## Contributing + +When adding new product modules: +1. Create a new wrapper module under `modules/` +2. Call the `product` module with product-specific parameters +3. Add product-specific data sources if needed (e.g., VPC lookups for EC2) +4. Create comprehensive README with examples +5. Add terraform.tfvars.example to examples/ + +## License + +Internal use only. \ No newline at end of file diff --git a/examples/ec2/linux/census-rhel-instance.tf b/examples/ec2/linux/census-rhel-instance.tf index b88a972..c0edefa 100644 --- a/examples/ec2/linux/census-rhel-instance.tf +++ b/examples/ec2/linux/census-rhel-instance.tf @@ -4,56 +4,46 @@ # Aligns with the 2-0-2.yaml CloudFormation product template. module "ec2_instance" { - source = "../../../ec2" + source = "../../../modules/ec2" + # === Product Identity === provisioned_product_name = var.provisioned_product_name - portfolio_name_pattern = var.portfolio_name_pattern - product_name_pattern = var.product_name_pattern - # CloudFormation parameters passed to the Service Catalog product template + # === Required EC2-Specific Parameters === + instance_type = var.instance_type + os_name = var.os_name + requires_backup = var.requires_backup + power_schedule = var.power_schedule + + # === Required Project/Creator Parameters === + project_name = var.project_name + creator = var.creator_jbid + contact_email = var.contact_email + inc_poc_email = var.incident_poc_email + + # === Network Configuration (looks up VPC/subnets by name tag) === + vpc_name = var.vpc_name + subnets_name = var.subnets_name + availability_zones = var.availability_zones + + # === Additional CloudFormation Parameters (optional) === + # For parameters not exposed as module variables, pass them here parameters = { - # === Required Census Project Parameters === - ProjectName = var.project_name - VpcId = var.vpc_id - AZName = var.availability_zone - NameTag = var.instance_hostname - InstanceType = var.instance_type - OSName = var.os_name - Creator = var.creator_jbid - ContactEmail = var.contact_email - IncPocEmail = var.incident_poc_email - - # === Optional EC2 Configuration === - pDescription = var.instance_description - VolumeAppsSize = tostring(var.volume_apps_size) - SecurityGroupNames = join(",", var.security_group_names) - - # === Extra Volumes (optional) === - # Uncomment below and populate var.extra_volumes to add additional volumes: - # Volume1Mount = try(var.extra_volumes[0].mount, "") - # Volume1Size = try(tostring(var.extra_volumes[0].size), "0") - # Volume2Mount = try(var.extra_volumes[1].mount, "") - # Volume2Size = try(tostring(var.extra_volumes[1].size), "0") - # Volume3Mount = try(var.extra_volumes[2].mount, "") - # Volume3Size = try(tostring(var.extra_volumes[2].size), "0") - - # === Compliance and Operational Tags === - FISMAID = var.fisma_id - TitleData = join(",", var.title_data_types) - RequiresBackup = var.requires_backup - PowerSchedule = var.power_schedule - ProjectRole = var.project_role - MapMigrated = var.map_migrated + NameTag = var.instance_hostname + pDescription = var.instance_description + VolumeAppsSize = tostring(var.volume_apps_size) + SecurityGroupNames = join(",", var.security_group_names) + # ProjectRole and FISMAID will be passed from module automatically + TitleData = join(",", var.title_data_types) + MapMigrated = var.map_migrated } - enable_deletion_protection = var.enable_deletion_protection - + # === Tags === tags = merge( { Environment = var.environment ManagedBy = "Terraform" ServiceCatalog = "true" - Creator = var.creator_jbid }, var.additional_tags ) diff --git a/examples/ec2/linux/main.tf b/examples/ec2/linux/main.tf index a50c01b..d41aae3 100644 --- a/examples/ec2/linux/main.tf +++ b/examples/ec2/linux/main.tf @@ -1,13 +1 @@ -# terraform { -# required_version = ">= 1.0" -# required_providers { -# aws = { -# source = "hashicorp/aws" -# version = ">= 5.0" -# } -# } -# } - -# provider "aws" { -# region = var.region -# } +# Module is defined in census-rhel-instance.tf diff --git a/examples/ec2/linux/outputs.tf b/examples/ec2/linux/outputs.tf index 2608418..fdaa648 100644 --- a/examples/ec2/linux/outputs.tf +++ b/examples/ec2/linux/outputs.tf @@ -13,12 +13,6 @@ output "provisioned_product_status" { value = module.ec2_instance.provisioned_product_status } -output "stack_outputs" { - description = "CloudFormation stack outputs from the provisioned product (instance details, IPs, etc.)" - value = module.ec2_instance.stack_outputs - sensitive = true -} - output "instance_configuration_summary" { description = "Summary of the EC2 instance configuration" value = { @@ -31,6 +25,5 @@ output "instance_configuration_summary" { security_groups = var.security_group_names backup_enabled = var.requires_backup == "yes" power_schedule = var.power_schedule - deletion_protected = var.enable_deletion_protection } } diff --git a/examples/ec2/linux/terraform.tfvars.example b/examples/ec2/linux/terraform.tfvars.example index efa479b..e2cc121 100644 --- a/examples/ec2/linux/terraform.tfvars.example +++ b/examples/ec2/linux/terraform.tfvars.example @@ -8,20 +8,20 @@ region = "us-east-1" environment = "Production" -# Service Catalog Configuration (typically don't need to change these) +# === SERVICE CATALOG CONFIGURATION === + +# Name for the provisioned product (unique identifier in your account) provisioned_product_name = "census-rhel-instance" -portfolio_name_pattern = "csvd-portfolio" -product_name_pattern = "linux-product" # === EC2 INSTANCE CONFIGURATION === # Instance Type - Options: t3.nano, t3.micro, t3.small (default), t3.medium, c5.large, m5.large, etc. instance_type = "t3a.small" -# Operating System - Options: RHEL9 (default), RHEL8 +# Operating System - Options: RHEL8, RHEL9 (default) os_name = "RHEL9" -# Instance Hostname - Must be 3-16 characters, lowercase alphanumeric with hyphens/underscores +# Instance Hostname (local name, no domain) - Must be 3-16 characters, lowercase alphanumeric with hyphens/underscores instance_hostname = "app-mcm-01" # Instance Description @@ -29,14 +29,22 @@ instance_description = "Census RHEL instance provisioned via Terraform" # === NETWORK CONFIGURATION === -# VPC ID (REQUIRED - must be in the same account as the project) -vpc_id = "vpc-1234567890abcdef0" +# VPC Configuration - Choose ONE approach: +# Approach 1: Use vpc_name to auto-discover by name tag (recommended) +vpc_name = "csvd-vpc" + +# Approach 2 (ALTERNATIVE): Use vpc_id directly +# vpc_id = "vpc-1234567890abcdef0" + +# Subnet Configuration - Name pattern for subnet auto-discovery +# Default pattern "*-apps-*" matches subnets tagged with names like "csvd-apps-primary" +subnets_name = "*-apps-*" -# Availability Zone (REQUIRED) -availability_zone = "us-east-1a" +# Availability Zones - Leave empty to use all available, or specify list for filtering +# Examples: ["us-east-1a"], ["us-east-1a", "us-east-1b"] +availability_zones = [] -# Security Groups (it-linux-base is required and included by default) -# Add additional security groups as needed +# Security Groups - List of security group names to attach (it-linux-base is automatically included) security_group_names = ["it-linux-base", "ois-scanner"] # === PROJECT CONFIGURATION === @@ -82,7 +90,8 @@ incident_poc_email = "matthew.c.morgan@census.gov" fisma_id = "OCIO_CSVD" # Title Data Types - Data classification for this instance -# Options: no_title, title_2, title_5, title_13, title_14, title_21, title_25, title_26, etc. +# Options: no_title, title_2, title_5, title_13, title_14, title_21, title_25, title_26, +# title_26_pending, title_health_data, title_pii, title_pub_1075 title_data_types = ["no_title"] # Backup Requirement - Whether to add instance to enterprise backup @@ -98,6 +107,12 @@ requires_backup = "no" # - Weekday_Business_Hours_9-5 # - Weekday_Core_Hours_7-7 # - Weekday_Hours_0600-1900 +power_schedule = "Always_On" + +# MAP Migrated - MAP program migration tag for cost credits (5-25% discount) +# Example: "migMSDNVLIJDZ: On-Prem Server, Storage, Networking" +map_migrated = "" + power_schedule = "Weekday_Core_Hours_7-7" # MAP Migration Tag - For AWS Migration Accelerator Program credits diff --git a/examples/ec2/linux/variables.tf b/examples/ec2/linux/variables.tf index b84c0ea..7cffb74 100644 --- a/examples/ec2/linux/variables.tf +++ b/examples/ec2/linux/variables.tf @@ -73,9 +73,28 @@ variable "vpc_id" { type = string } +variable "vpc_name" { + description = "Name tag of the VPC for lookup (alternative to vpc_id). Used by the module to automatically discover VPC" + type = string + default = "" +} + variable "availability_zone" { description = "Availability zone for the EC2 instance deployment" type = string + default = "" +} + +variable "availability_zones" { + description = "List of availability zones to consider for subnet selection. Leave empty for all available AZs" + type = list(string) + default = [] +} + +variable "subnets_name" { + description = "Name tag pattern for subnets to deploy into (e.g., '*-apps-*')" + type = string + default = "*-apps-*" } variable "security_group_names" { diff --git a/examples/ec2/linux/versions.tf b/examples/ec2/linux/versions.tf new file mode 100644 index 0000000..773514a --- /dev/null +++ b/examples/ec2/linux/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + } +} diff --git a/examples/s3/outputs.tf b/examples/s3/outputs.tf new file mode 100644 index 0000000..d64a803 --- /dev/null +++ b/examples/s3/outputs.tf @@ -0,0 +1,26 @@ +# === Provisioned Product Outputs === + +output "provisioned_product_id" { + description = "The unique identifier of the provisioned S3 product" + value = module.s3_bucket.provisioned_product_id +} + +output "provisioned_product_arn" { + description = "The ARN of the provisioned S3 product" + value = module.s3_bucket.provisioned_product_arn +} + +# === S3 Bucket Outputs === + +output "bucket_name" { + description = "The name of the provisioned S3 bucket (with account number appended)" + value = module.s3_bucket.bucket_name +} + +# === CloudFormation Stack Outputs === + +output "stack_outputs" { + description = "All outputs from the CloudFormation stack that provisioned the S3 bucket" + value = module.s3_bucket.stack_outputs + sensitive = true +} diff --git a/examples/s3/providers.tf b/examples/s3/providers.tf new file mode 100644 index 0000000..dc58d9a --- /dev/null +++ b/examples/s3/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = var.region +} diff --git a/examples/s3/simple-s3.tf b/examples/s3/simple-s3.tf index 57c0626..7fd6347 100644 --- a/examples/s3/simple-s3.tf +++ b/examples/s3/simple-s3.tf @@ -1,64 +1,39 @@ -terraform { - required_version = ">= 1.0" - required_providers { - aws = { - source = "hashicorp/aws" - version = ">= 5.0" - } - } -} - -provider "aws" { - region = var.region -} - -variable "region" { - description = "AWS region" - type = string - default = "us-east-1" -} - -variable "provisioned_product_name" { - description = "Name for the S3 provisioned product" - type = string - default = "example-s3-bucket" -} - -variable "bucket_name" { - description = "S3 bucket name" - type = string -} +# === S3 Service Catalog Product Example === +# This example demonstrates how to provision an S3 bucket +# via AWS Service Catalog using the aws-servicecatalog s3 module. module "s3_bucket" { - source = "../../s3" + source = "../../../modules/s3" + # === Product Identity === provisioned_product_name = var.provisioned_product_name - portfolio_name_pattern = "edl-portfolio" - product_name_pattern = "s3-product" - - parameters = { - BucketName = var.bucket_name - Versioning = "Enabled" - EncryptionEnabled = "true" - } - - tags = { - Environment = "example" - ManagedBy = "Terraform" - } -} - -output "provisioned_product_id" { - description = "The ID of the provisioned product" - value = module.s3_bucket.provisioned_product_id -} - -output "provisioned_product_arn" { - description = "The ARN of the provisioned product" - value = module.s3_bucket.provisioned_product_arn -} -output "stack_outputs" { - description = "CloudFormation stack outputs" - value = module.s3_bucket.stack_outputs + # === Required S3-Specific Parameters === + bucket_name = var.bucket_name + enable_bucket_versioning = var.enable_bucket_versioning + title_data = var.title_data + project_role = var.project_role + + # === Required Project/Creator Parameters === + project_name = var.project_name + creator = var.creator + contact_email = var.contact_email + inc_poc_email = var.incident_poc_email + + # === Additional CloudFormation Parameters (optional) === + # For parameters not exposed as module variables, pass them here + parameters = merge( + {}, + var.additional_parameters + ) + + # === Tags === + tags = merge( + { + Environment = var.environment + ManagedBy = "Terraform" + ServiceCatalog = "true" + }, + var.additional_tags + ) } diff --git a/examples/s3/terraform.tfvars.example b/examples/s3/terraform.tfvars.example new file mode 100644 index 0000000..ef5cb41 --- /dev/null +++ b/examples/s3/terraform.tfvars.example @@ -0,0 +1,65 @@ +# === Census S3 Bucket Example === +# This terraform.tfvars.example file shows how to provision an S3 bucket +# via AWS Service Catalog. Copy this file to terraform.tfvars and customize the values. + +# AWS Region +region = "us-east-1" +environment = "Production" + +# === SERVICE CATALOG CONFIGURATION === + +# Name for the provisioned product (unique identifier in your account) +provisioned_product_name = "example-data-lake-bucket" + +# === S3 BUCKET CONFIGURATION === + +# Bucket Name - Will have account number appended (e.g., my-bucket-123456789012) +# Must be 3-50 characters, lowercase alphanumeric with hyphens only +bucket_name = "my-data-lake" + +# Enable Bucket Versioning - Once enabled, cannot be disabled (only suspended) +# WARNING: Versioning incurs additional storage charges +enable_bucket_versioning = false + +# Title Data Classification - Data types stored in this bucket +# Can select multiple classifications for mixed-data buckets +# Options: no_title, title_2, title_5, title_13, title_14, title_21, +# title_25, title_26, title_26_pending, title_health_data, title_pii, title_pub_1075 +title_data = ["no_title"] + +# FinOps Project Role (Optional) - For cost allocation within the project +project_role = "" + +# === PROJECT CONFIGURATION === + +# Census Project Identifier (REQUIRED) +# Format: organization_project_environment_type-accountid +# Example: edl_adsd_s3_prod-260954754267 +# Allowed values are defined in the Service Catalog product template. +project_name = "edl_adsd_s3_prod-260954754267" + +# Creator JBID (Census User ID) - Optional +creator = "morga471" + +# Provisioning User's Email (REQUIRED) +contact_email = "matthew.c.morgan@census.gov" + +# Team Distribution List Email (REQUIRED) +incident_poc_email = "matthew.c.morgan@census.gov" + +# === OPTIONAL ADDITIONAL PARAMETERS === + +# Pass additional CloudFormation parameters not exposed as module variables +# additional_parameters = { +# param_name = "param_value" +# } +additional_parameters = {} + +# === CUSTOM TAGS === + +# Additional tags to apply to the bucket beyond standard ones +# additional_tags = { +# "CostCenter" = "12345" +# "Owner" = "team-name" +# } +additional_tags = {} diff --git a/examples/s3/variables.tf b/examples/s3/variables.tf new file mode 100644 index 0000000..d730530 --- /dev/null +++ b/examples/s3/variables.tf @@ -0,0 +1,133 @@ +# === Provider and Global Configuration === + +variable "region" { + description = "AWS region where resources will be deployed" + type = string + default = "us-east-1" +} + +variable "environment" { + description = "Environment name for tagging (e.g., Production, QA, Dev)" + type = string + default = "Production" +} + +# === Service Catalog Configuration === + +variable "provisioned_product_name" { + description = "Name of the provisioned S3 product in Service Catalog" + type = string + default = "example-s3-bucket" +} + +variable "portfolio_id" { + description = "Portfolio ID for the S3 product (if not provided, uses default)" + type = string + default = "" +} + +variable "product_id" { + description = "Product ID for the S3 product (if not provided, uses default)" + type = string + default = "" +} + +# === S3 Bucket Configuration === + +variable "bucket_name" { + description = "Name of the S3 bucket (the account number will be appended). Must be 3-50 characters, lowercase alphanumeric with hyphens" + type = string + + validation { + condition = can(regex("^[a-z0-9]{1}[a-z0-9\\-]{1,48}[a-z0-9]{1}$", var.bucket_name)) + error_message = "Bucket name must start with lowercase letter/number, contain only lowercase letters/numbers/hyphens, be 3-50 characters total" + } +} + +variable "enable_bucket_versioning" { + description = "Enable S3 bucket versioning - once enabled cannot be disabled (only suspended). Incurs additional charges" + type = bool + default = false +} + +variable "title_data" { + description = "List of title data classifications to tag this bucket with" + type = list(string) + default = ["no_title"] + + validation { + condition = alltrue([ + for item in var.title_data : contains([ + "no_title", + "title_2", + "title_5", + "title_13", + "title_14", + "title_21", + "title_25", + "title_26", + "title_26_pending", + "title_health_data", + "title_pii", + "title_pub_1075" + ], item) + ]) + error_message = "Invalid title_data value(s). Allowed: no_title, title_2, title_5, title_13, title_14, title_21, title_25, title_26, title_26_pending, title_health_data, title_pii, title_pub_1075" + } +} + +variable "project_role" { + description = "FinOps project role for cost allocation and tracking (optional)" + type = string + default = "" +} + +# === Project Configuration === + +variable "project_name" { + description = "Census project identifier (format: org_project_env_type-accountid). Must be one of allowed values in Service Catalog product template" + type = string + # Example: "edl_adsd_s3_prod-260954754267" +} + +variable "creator" { + description = "Creator's JBID (Census system user ID)" + type = string + default = "" +} + +variable "contact_email" { + description = "Provisioning user's email address for notifications" + type = string + + validation { + condition = can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", var.contact_email)) + error_message = "Must be a valid email address" + } +} + +variable "incident_poc_email" { + description = "Team distribution list email for incident notifications" + type = string + + validation { + condition = can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", var.incident_poc_email)) + error_message = "Must be a valid email address" + } +} + +# === Optional CloudFormation Parameters === + +variable "additional_parameters" { + description = "Additional CloudFormation parameters for the S3 product" + type = map(string) + default = {} +} + +# === Custom Tags === + +variable "additional_tags" { + description = "Additional custom tags to apply to the bucket" + type = map(string) + default = {} +} diff --git a/examples/s3/versions.tf b/examples/s3/versions.tf new file mode 100644 index 0000000..773514a --- /dev/null +++ b/examples/s3/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + } +} diff --git a/locals.tf.initial b/locals.tf.initial deleted file mode 100644 index 2bd4d7f..0000000 --- a/locals.tf.initial +++ /dev/null @@ -1,9 +0,0 @@ -locals { - account_id = var.account_id != "" ? var.account_id : data.aws_caller_identity.current.account_id - account_environment = data.aws_arn.current.partition == "aws-us-gov" ? "gov" : "ew" - - base_tags = { - "boc:tf_module_version" = local._module_version - "boc:created_by" = "terraform" - } -} diff --git a/modules/ec2/README.md b/modules/ec2/README.md new file mode 100644 index 0000000..d80ac57 --- /dev/null +++ b/modules/ec2/README.md @@ -0,0 +1,244 @@ +# Service Catalog EC2 Module + +## Overview + +This module provisions EC2 instances via AWS Service Catalog. It provides a simplified Terraform interface to the Service Catalog EC2 product, handling all the complexity of parameter mapping, networking lookups, and provisioning. + +This is a product-specific wrapper around the base [`product`](../product/) module, adding EC2-specific parameters and defaults. + +## Features + +- **Simplified Interface**: Clean Terraform variables instead of raw Service Catalog parameters +- **Automatic VPC/Subnet Resolution**: Looks up VPC and subnet information by name tags +- **Latest Version by Default**: Automatically uses the latest active provisioning artifact +- **Flexible Parameter Override**: Pass additional parameters via the `parameters` variable +- **Standard Tagging**: Consistent tagging across all resources +- **EC2-Specific Validations**: Built-in validation for EC2 parameters + +## Usage + +### Basic Example + +```hcl +module "ec2_instance" { + source = "git::https://your-repo/terraform-modules/aws-servicecatalog//modules/ec2?ref=v1.0.0" + + # Service Catalog Configuration + portfolio_id = "port-pgj3zvoqca7ya" + product_id = "prod-43foqxjcq5isw" + + # Instance Identity + provisioned_product_name = "app-web-01" + project_name = "csvd_myproject_dev-229685449397" + + # Networking + vpc_name = "vpc-dev-us-east-1" + subnets_name = "*-apps-*" + + # EC2 Configuration + instance_type = "t3a.medium" + os_name = "RHEL9" + power_schedule = "Weekday_Core_Hours_7-7" + requires_backup = "yes" + + # Contact Information + creator = "jsmith" + contact_email = "jsmith@example.com" + inc_poc_email = "team@example.com" + fisma_id = "OCIO_CSVD (CEN16.09)" + + tags = { + Environment = "dev" + Application = "web-server" + } +} +``` + +### Advanced Example with Custom Parameters + +```hcl +module "ec2_instance" { + source = "git::https://your-repo/terraform-modules/aws-servicecatalog//modules/ec2?ref=v1.0.0" + + portfolio_id = "port-pgj3zvoqca7ya" + product_id = "prod-43foqxjcq5isw" + provisioned_product_name = "app-db-01" + project_name = "csvd_database_prod-229685449397" + + vpc_name = "vpc-prod-us-east-1" + subnets_name = "subnet-data-*" + + instance_type = "r5.xlarge" + os_name = "RHEL9" + power_schedule = "24x7" + requires_backup = "yes" + + creator = "dbadmin" + contact_email = "dbadmin@example.com" + inc_poc_email = "dba-team@example.com" + fisma_id = "OCIO_DATA (DAT01.01)" + + # Custom parameters not covered by named variables + parameters = { + VolumeAppsSize = "200" + SecurityGroupNames = "sg-database,sg-monitoring" + pDescription = "Production database server" + Volume1Mount = "/data01" + Volume1Size = "500" + } + + # Service Catalog Options + timeout = "30m" + notification_arns = ["arn:aws:sns:us-east-1:123456789012:sc-notifications"] + retain_physical_resources = true + + tags = { + Environment = "prod" + Application = "database" + CostCenter = "engineering" + } +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.0 | +| aws | >= 6.0 | + +## Providers + +No providers are used directly - all AWS resources are provisioned by the underlying `product` module. + +## Variables + +### Required Variables + +| Name | Description | Type | +|------|-------------|------| +| `provisioned_product_name` | Name of the provisioned product (1-128 characters) | `string` | + +### Service Catalog Configuration + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `portfolio_id` | Portfolio ID containing the EC2 product | `string` | `"port-pgj3zvoqca7ya"` | +| `product_id` | Product ID for the EC2 product | `string` | `"prod-43foqxjcq5isw"` | +| `path_id` | Path identifier (version). Uses latest if not provided | `string` | `null` | +| `timeout` | Timeout for provisioning operations | `string` | `"15m"` | +| `accept_language` | Language code for API calls | `string` | `"en"` | +| `ignore_errors` | Ignore errors during provisioning | `bool` | `false` | +| `notification_arns` | SNS topic ARNs for notifications | `list(string)` | `[]` | +| `retain_physical_resources` | Retain resources when deleted | `bool` | `false` | +| `stack_set_provisioning_preferences` | StackSet preferences | `object` | `null` | + +### EC2 Configuration + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `instance_type` | EC2 instance type | `string` | `"t3.small"` | +| `os_name` | Operating system version | `string` | `"RHEL9"` | +| `requires_backup` | Backup requirement (yes/no) | `string` | `"no"` | +| `power_schedule` | Power schedule name | `string` | `""` | + +### Common Parameters + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `project_name` | Project name (must end with account ID) | `string` | `""` | +| `creator` | Creator's JBID | `string` | `""` | +| `contact_email` | Provisioning user's email | `string` | `""` | +| `inc_poc_email` | Incident POC email | `string` | `""` | +| `fisma_id` | FISMA ID | `string` | `""` | + +### Networking + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `vpc_name` | Name tag of the VPC | `string` | `""` | +| `subnets_name` | Name tag pattern for subnets | `string` | `"*-apps-*"` | + +### Other + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `parameters` | Additional Service Catalog parameters | `map(string)` | `{}` | +| `tags` | Tags to apply to the provisioned product | `map(string)` | `{}` | + +## Outputs + +| Name | Description | +|------|-------------| +| `provisioned_product_id` | The ID of the provisioned product | +| `provisioned_product_name` | The name of the provisioned product | +| `provisioned_product_arn` | The ARN of the provisioned product | +| `provisioned_product_type` | The type of the provisioned product | +| `provisioned_product_status` | The status of the provisioned product | +| `provisioned_product_status_message` | The status message | +| `launch_role_arn` | The ARN of the launch role | +| `portfolio_id` | The ID of the portfolio used | +| `product_id` | The ID of the product used | +| `provisioning_artifact_id` | The ID of the provisioning artifact used | +| `vpc_id` | The VPC ID where the instance is provisioned | +| `availability_zone` | The availability zone used | +| `availability_zone_names` | List of all availability zone names | +| `availability_zone_ids` | List of all availability zone IDs | +| `availability_zone_suffixes` | List of availability zone suffixes | + +## Module Architecture + +This module is built on top of the generic [`product`](../product/) module: + +``` +ec2 module (this) + ↓ +product module (base Service Catalog logic) + ↓ +AWS Service Catalog + ↓ +CloudFormation Stack (EC2 instance) +``` + +The `ec2` module: +1. Accepts EC2-specific variables +2. Maps them to Service Catalog parameters +3. Passes them to the `product` module via `product_parameters` +4. Exposes outputs from the `product` module + +## Parameter Mapping + +The module automatically maps Terraform variables to Service Catalog parameters: + +| Terraform Variable | Service Catalog Parameter | +|-------------------|---------------------------| +| `provisioned_product_name` | `NameTag` | +| `project_name` | `ProjectName` | +| `creator` | `Creator` | +| `contact_email` | `ContactEmail` | +| `inc_poc_email` | `IncPocEmail` | +| `fisma_id` | `FISMAID` | +| `instance_type` | `InstanceType` | +| `os_name` | `OSName` | +| `requires_backup` | `RequiresBackup` | +| `power_schedule` | `PowerSchedule` | +| `vpc_name` | Resolved to `VpcId` | +| `subnets_name` | Resolved to `AZName` | + +Additional parameters can be passed via the `parameters` variable, which will override any defaults. + +## Notes + +- The `project_name` must end with the AWS account ID (last 12 digits) +- VPC and subnet lookups are performed automatically based on name tags +- The module uses the latest active provisioning artifact by default unless `path_id` is specified +- All resources created by Service Catalog are managed by CloudFormation +- Deleting the Terraform resource will delete the CloudFormation stack unless `retain_physical_resources = true` + +## Examples + +See the [examples/ec2/](../../examples/ec2/) directory for complete working examples. + +## Support + +For issues or questions, please refer to the main [aws-servicecatalog module documentation](../../README.md). diff --git a/modules/ec2/data.tf b/modules/ec2/data.tf index c8f6dd6..c3717ad 100644 --- a/modules/ec2/data.tf +++ b/modules/ec2/data.tf @@ -1,12 +1,5 @@ -data "aws_caller_identity" "current" {} -data "aws_partition" "current" {} -data "aws_arn" "current" { - arn = data.aws_caller_identity.current.arn -} - -data "aws_region" "current" {} - data "aws_vpc" "vpc" { + count = var.vpc_name != "" ? 1 : 0 filter { name = "tag:Name" values = [var.vpc_name] @@ -15,43 +8,23 @@ data "aws_vpc" "vpc" { # Get all subnets matching name and VPC ID data "aws_subnets" "subnets" { + count = var.vpc_name != "" && var.subnets_name != "" ? 1 : 0 filter { name = "tag:Name" values = [var.subnets_name] } filter { name = "vpc-id" - values = [data.aws_vpc.vpc.id] + values = [data.aws_vpc.vpc[0].id] } } # get ids for each subnet for use in provisioning data "aws_subnet" "subnets" { - for_each = toset(data.aws_subnets.subnets.ids) + for_each = var.vpc_name != "" && var.subnets_name != "" ? toset(data.aws_subnets.subnets[0].ids) : [] id = each.key } -# Get portfolio details if we resolved an ID -data "aws_servicecatalog_portfolio" "by_id" { - count = local.portfolio_id != null ? 1 : 0 - id = local.portfolio_id - accept_language = var.accept_language -} - -# Get product details by ID (requires product_id) -data "aws_servicecatalog_product" "by_id" { - count = local.product_id != null ? 1 : 0 - id = local.product_id - accept_language = var.accept_language -} - -# Get the latest provisioning artifact (product version) -data "aws_servicecatalog_provisioning_artifacts" "this" { - count = local.product_id != null ? 1 : 0 - accept_language = var.accept_language - product_id = local.product_id -} - data "aws_availability_zones" "zones" { state = "available" } @@ -60,4 +33,4 @@ data "aws_availability_zone" "zone" { for_each = toset(data.aws_availability_zones.zones.names) state = "available" name = each.key -} +} \ No newline at end of file diff --git a/modules/ec2/locals.tf b/modules/ec2/locals.tf index b18cf83..16368f7 100644 --- a/modules/ec2/locals.tf +++ b/modules/ec2/locals.tf @@ -1,52 +1,30 @@ locals { - account_id = data.aws_caller_identity.current.account_id - az_name = data.aws_subnet.subnets[sort(data.aws_subnets.subnets.ids)[0]].availability_zone - partition = data.aws_partition.current.partition - region = data.aws_region.current.id - vpc_id = data.aws_vpc.vpc.id - - # Get the latest provisioning artifact ID - latest_artifact_id = local.product_id == null ? null : try( - [for artifact in data.aws_servicecatalog_provisioning_artifacts.this[0].provisioning_artifact_details : - artifact.id if artifact.active - ][0], - null + # EC2-specific parameters to pass to the product module + ec2_parameters = merge( + { + InstanceType = var.instance_type + OSName = var.os_name + RequiresBackup = var.requires_backup + PowerSchedule = var.power_schedule + }, + var.parameters # Allow user to override any parameter ) - # Use provided path_id or default to latest - provisioning_artifact_id = var.path_id != null ? var.path_id : local.latest_artifact_id + # VPC and networking - only resolve if vpc_name is provided + vpc_id = var.vpc_name != "" ? data.aws_vpc.vpc[0].id : null + az_name = var.vpc_name != "" && var.subnets_name != "" && length(data.aws_subnets.subnets[0].ids) > 0 ? data.aws_subnet.subnets[sort(data.aws_subnets.subnets[0].ids)[0]].availability_zone : null - # Build default parameters from module variables - default_parameters = { - ProjectName = var.project_name - VpcId = local.vpc_id - AZName = local.az_name - InstanceType = var.instance_type - NameTag = var.provisioned_product_name - OSName = var.os_name - Creator = var.creator - ContactEmail = var.contact_email - IncPocEmail = var.inc_poc_email - RequiresBackup = var.requires_backup - PowerSchedule = var.power_schedule - FISMAID = var.fisma_id - } + # Add networking parameters if available + network_parameters = local.vpc_id != null ? { + VpcId = local.vpc_id + AZName = local.az_name + } : {} - # Merge defaults with user-provided parameters (user params override defaults) parameters = merge( - local.default_parameters, - var.parameters + local.network_parameters, + local.ec2_parameters ) - # Convert parameters map to the format expected by aws_servicecatalog_provisioned_product - provisioning_parameters = [ - for key, value in local.parameters : { - key = key - value = tostring(value) - } - if value != "" # Only include non-empty values - ] - base_tags = { "boc:tf_module_name" = local.module_name "boc:tf_module_version" = local.module_version @@ -57,4 +35,4 @@ locals { local.base_tags, var.tags ) -} \ No newline at end of file +} diff --git a/modules/ec2/main.tf b/modules/ec2/main.tf index b4f97fe..7313160 100644 --- a/modules/ec2/main.tf +++ b/modules/ec2/main.tf @@ -1,48 +1,33 @@ -# Product Submodule +# EC2 Product Module # -# Provisions a Service Catalog product -# using a pre-configured portfolio and product +# Provisions an EC2 instance via Service Catalog +# This is a thin wrapper around the product module with EC2-specific parameters -resource "aws_servicecatalog_provisioned_product" "this" { - name = var.provisioned_product_name - product_id = local.product_id - provisioning_artifact_id = local.provisioning_artifact_id - region = local.region - path_id = var.path_id - accept_language = var.accept_language - ignore_errors = var.ignore_errors - notification_arns = var.notification_arns - retain_physical_resources = var.retain_physical_resources +module "ec2" { + source = "../product" - dynamic "provisioning_parameters" { - for_each = local.provisioning_parameters - content { - key = provisioning_parameters.value.key - value = provisioning_parameters.value.value - } - } + # Service Catalog configuration + portfolio_id = var.portfolio_id + product_id = var.product_id + path_id = var.path_id + accept_language = var.accept_language + timeout = var.timeout + ignore_errors = var.ignore_errors + notification_arns = var.notification_arns + retain_physical_resources = var.retain_physical_resources + stack_set_provisioning_preferences = var.stack_set_provisioning_preferences - dynamic "stack_set_provisioning_preferences" { - for_each = var.stack_set_provisioning_preferences != null ? [var.stack_set_provisioning_preferences] : [] - content { - accounts = try(stack_set_provisioning_preferences.value.accounts, null) - failure_tolerance_count = try(stack_set_provisioning_preferences.value.failure_tolerance_count, null) - failure_tolerance_percentage = try(stack_set_provisioning_preferences.value.failure_tolerance_percentage, null) - max_concurrency_count = try(stack_set_provisioning_preferences.value.max_concurrency_count, null) - max_concurrency_percentage = try(stack_set_provisioning_preferences.value.max_concurrency_percentage, null) - regions = try(stack_set_provisioning_preferences.value.regions, null) - } - } + # Common parameters + provisioned_product_name = var.provisioned_product_name + project_name = var.project_name + creator = var.creator + contact_email = var.contact_email + inc_poc_email = var.inc_poc_email + fisma_id = var.fisma_id - tags = local.tags - - timeouts { - create = var.timeout - update = var.timeout - delete = var.timeout - } + # EC2-specific + networking parameters (merged in locals.tf) + product_parameters = local.parameters - depends_on = [ - data.aws_servicecatalog_provisioning_artifacts.this - ] + # Tags + tags = local.tags } diff --git a/modules/ec2/module_name.tf b/modules/ec2/module_name.tf index 0132a12..d20f85f 100644 --- a/modules/ec2/module_name.tf +++ b/modules/ec2/module_name.tf @@ -1,4 +1,4 @@ locals { - module_name = "aws-servicecatalog/ec2" + module_name = "aws-servicecatalog/ec2" module_version = "0.0.0" } diff --git a/modules/ec2/outputs.tf b/modules/ec2/outputs.tf index 5ab1436..6286f2d 100644 --- a/modules/ec2/outputs.tf +++ b/modules/ec2/outputs.tf @@ -1,79 +1,74 @@ output "provisioned_product_id" { description = "The ID of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.id + value = module.product.provisioned_product_id } output "provisioned_product_name" { description = "The name of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.name + value = module.product.provisioned_product_name } output "provisioned_product_arn" { description = "The ARN of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.arn + value = module.product.provisioned_product_arn } output "provisioned_product_type" { description = "The type of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.type + value = module.product.provisioned_product_type } output "provisioned_product_status" { description = "The status of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.status + value = module.product.provisioned_product_status } output "provisioned_product_status_message" { description = "The status message for the provisioned product" - value = aws_servicecatalog_provisioned_product.this.status_message + value = module.product.provisioned_product_status_message } output "launch_role_arn" { description = "The ARN of the launch role" - value = aws_servicecatalog_provisioned_product.this.launch_role_arn + value = module.product.launch_role_arn } output "portfolio_id" { description = "The ID of the portfolio used" - value = local.portfolio_id + value = module.product.portfolio_id } output "product_id" { description = "The ID of the product used" - value = local.product_id + value = module.product.product_id } output "provisioning_artifact_id" { description = "The ID of the provisioning artifact used" - value = local.provisioning_artifact_id + value = module.product.provisioning_artifact_id } output "vpc_id" { description = "The VPC ID where the instance will be provisioned" - value = data.aws_vpc.vpc.id -} - -output "subnet_ids" { - description = "The subnet IDs where the instance can be provisioned" - value = data.aws_subnets.subnets.ids + value = module.product.vpc_id } output "availability_zone" { description = "The availability zone of the first selected subnet" - value = data.aws_subnet.subnets[sort(data.aws_subnets.subnets.ids)[0]].availability_zone + value = module.product.availability_zone } output "availability_zone_names" { description = "VPC Availability zone name list" - value = data.aws_availability_zones.zones.names + value = module.product.availability_zone_names } output "availability_zone_ids" { description = "VPC Availability zone id list" - value = data.aws_availability_zones.zones.zone_ids + value = module.product.availability_zone_ids } output "availability_zone_suffixes" { description = "VPC Availability zone suffix list" - value = [for k, v in data.aws_availability_zone.zone : v.name_suffix] + value = module.product.availability_zone_suffixes } \ No newline at end of file diff --git a/modules/ec2/prefixes.tf b/modules/ec2/prefixes.tf deleted file mode 100644 index d2ee1fe..0000000 --- a/modules/ec2/prefixes.tf +++ /dev/null @@ -1,28 +0,0 @@ -locals { - _prefixes = { - "efs" = "v-efs-" - "s3" = "v-s3-" - "ebs" = "v-ebs-" - "kms" = "k-kms-" - "role" = "r-" - "policy" = "p-" - "group" = "g-" - "security-group" = "" # "sg-" - # VPC - "vpc" = "" - "dhcp-options" = "" - "vpc-peer" = "vpcp-" - "route-table" = "route-" - "subnet" = "" - "vpc-endpoint" = "vpce-" - "elastic-ip" = "eip-" - "nat-gateway" = "nat-" - "internet-gateway" = "igw-" - "network-acl" = "nacl-" - "customer-gateway" = "cgw-" - "vpn-gateway" = "vpcg-" - "vpn-connection" = "vpn_" - "log-group" = "lg-" - "log-stream" = "lgs-" - } -} diff --git a/modules/ec2/settings.tf b/modules/ec2/settings.tf deleted file mode 100644 index 89e24f0..0000000 --- a/modules/ec2/settings.tf +++ /dev/null @@ -1,15 +0,0 @@ -locals { - provisioned_product_name = "app-mcm-01" - project_name = "csvd_morpheus_dev_qa_dev-229685449397" - creator = "morga471" - contact_email = "morga471@example.com" - inc_poc_email = "morga471@example.com" - fisma_id = "OCIO_CSVD (CEN16.09)" - power_schedule = "Weekday_Core_Hours_7-7" - instance_type = "t3a.small" - os_name = "RHEL9" - requires_backup = "no" - - portfolio_id = "port-pgj3zvoqca7ya" - product_id = "prod-43foqxjcq5isw" -} \ No newline at end of file diff --git a/modules/ec2/variables.common.tf b/modules/ec2/variables.common.tf index 2a554c5..fc183d9 100644 --- a/modules/ec2/variables.common.tf +++ b/modules/ec2/variables.common.tf @@ -13,26 +13,48 @@ variable "account_alias" { default = "" } -variable "availability_zones" { - description = "AWS Availability Zones to use (by default will use all available)" - type = list(string) - default = [] -} - variable "parameters" { - description = "Parameters to pass to the Service Catalog product. Map of parameter names to values" + description = "Additional parameters to pass to the Service Catalog product. Map of parameter names to values." type = map(string) default = {} } -variable "vpc_name" { - description = "Name tag of the VPC to deploy into" +variable "provisioned_product_name" { + description = "Name of the provisioned product" + type = string + + validation { + condition = length(var.provisioned_product_name) > 0 && length(var.provisioned_product_name) <= 128 + error_message = "provisioned_product_name must be between 1 and 128 characters" + } +} + +variable "project_name" { + description = "Project name (ProjectName parameter). Make sure to select the project designated for your account. The build will fail if an incorrect project is selected. The list of values can be found in the ProjectName parameter of the product's provisioning artifact." + type = string + default = "" +} + +variable "creator" { + description = "Creator's JBID (Creator parameter)" type = string default = "" } -variable "subnets_name" { - description = "Name tag of the subnets to deploy into" +variable "contact_email" { + description = "Provisioning user's email (ContactEmail parameter)" type = string - default = "*-apps-*" + default = "" +} + +variable "inc_poc_email" { + description = "Incident POC email (IncPocEmail parameter)" + type = string + default = "" +} + +variable "fisma_id" { + description = "FISMA ID" + type = string + default = "" } diff --git a/modules/ec2/variables.product.tf b/modules/ec2/variables.product.tf index a6eb533..ba7d465 100644 --- a/modules/ec2/variables.product.tf +++ b/modules/ec2/variables.product.tf @@ -1,53 +1,21 @@ -variable "path_id" { - description = "Path identifier of the product. If not provided, will use the latest active artifact" - type = string - default = null -} - -variable "provisioned_product_name" { - description = "Name of the provisioned product" - type = string - - validation { - condition = length(var.provisioned_product_name) > 0 && length(var.provisioned_product_name) <= 128 - error_message = "provisioned_product_name must be between 1 and 128 characters" - } -} - -variable "project_name" { - description = "Project name (ProjectName parameter) Make sure to select the project designated for your account. The build will fail if an incorrect project is selected. The list of values can be found in the ProjectName parameter of the product's provisioning artifact." - type = string - default = "" - - validation { - condition = var.project_name == "" || (length(var.project_name) >= 12 && substr(var.project_name, length(var.project_name) - 12, 12) == data.aws_caller_identity.current.account_id) - error_message = "The last 12 digits of project_name must match the AWS account ID" - } +# EC2-specific product parameters -} - -variable "creator" { - description = "Creator's JBID (Creator parameter)" - type = string - default = "" -} - -variable "contact_email" { - description = "Provisioning user's email (ContactEmail parameter)" +variable "instance_type" { + description = "EC2 instance type" type = string - default = "" + default = "t3.small" } -variable "inc_poc_email" { - description = "Incident POC email (IncPocEmail parameter)" +variable "os_name" { + description = "Operating system version" type = string - default = "" + default = "RHEL9" } -variable "fisma_id" { - description = "FISMA ID" +variable "requires_backup" { + description = "Backup requirement" type = string - default = "" + default = "no" } variable "power_schedule" { @@ -56,20 +24,20 @@ variable "power_schedule" { default = "" } -variable "instance_type" { - description = "EC2 instance type" - type = string - default = "t3.small" +variable "availability_zones" { + description = "AWS Availability Zones to use (by default will use all available)" + type = list(string) + default = [] } -variable "os_name" { - description = "Operating system version" +variable "vpc_name" { + description = "Name tag of the VPC to deploy into" type = string - default = "RHEL9" + default = "" } -variable "requires_backup" { - description = "Backup requirement" +variable "subnets_name" { + description = "Name tag of the subnets to deploy into" type = string - default = "no" -} \ No newline at end of file + default = "*-apps-*" +} diff --git a/modules/ec2/variables.servicecatalog.tf b/modules/ec2/variables.servicecatalog.tf index cca6a3f..ce4789e 100644 --- a/modules/ec2/variables.servicecatalog.tf +++ b/modules/ec2/variables.servicecatalog.tf @@ -10,6 +10,12 @@ variable "product_id" { default = "prod-43foqxjcq5isw" } +variable "path_id" { + description = "Path identifier of the product. If not provided, will use the latest active artifact" + type = string + default = null +} + variable "timeout" { description = "Timeout for provisioned product operations (create/update/delete)" type = string diff --git a/modules/ec2/versions.tf b/modules/ec2/versions.tf index dd0ebb9..773514a 100644 --- a/modules/ec2/versions.tf +++ b/modules/ec2/versions.tf @@ -3,7 +3,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 5.0" + version = ">= 6.0" } } } diff --git a/modules/product/README.md b/modules/product/README.md new file mode 100644 index 0000000..d3b96bc --- /dev/null +++ b/modules/product/README.md @@ -0,0 +1,83 @@ +# Service Catalog Product Module + +## Overview + +This is the base module for provisioning AWS Service Catalog products. It provides common functionality and defaults that apply to ALL Service Catalog products. + +Product-specific modules (like `ec2`, `s3`, `rds`, etc.) should use this module as their foundation, passing in product-specific parameters via the `product_parameters` variable. + +## Purpose + +This module abstracts away the common Service Catalog provisioning logic: + +- Portfolio and product resolution +- VPC and networking lookups +- Provisioning artifact selection (latest version by default) +- Common tagging strategy +- Standard timeout and error handling configuration +- Parameter mapping and conversion + +## Module Structure + +Product-specific modules should: +1. Define their own product-specific variables +2. Call this `product` module +3. Pass EC2/S3/RDS-specific parameters via `product_parameters` +4. Pass through outputs from this module + +## Usage + +This module is designed to be called by other modules, not directly by end users. See the `ec2` module for an example of how to use this as a base. + +```hcl +module "product" { + source = "../product" + + # Service Catalog configuration + portfolio_id = var.portfolio_id + product_id = var.product_id + path_id = var.path_id + + # Common parameters + provisioned_product_name = var.provisioned_product_name + project_name = var.project_name + creator = var.creator + contact_email = var.contact_email + inc_poc_email = var.inc_poc_email + fisma_id = var.fisma_id + + # Networking + vpc_name = var.vpc_name + subnets_name = var.subnets_name + + # Product-specific parameters + product_parameters = { + InstanceType = "t3.small" + OSName = "RHEL9" + RequiresBackup = "no" + } + + # Tags + tags = var.tags +} +``` + +## Features + +- **Automatic VPC/Subnet Resolution**: Automatically looks up VPC and subnet information if `vpc_name` is provided +- **Latest Version Selection**: Uses the latest active provisioning artifact by default +- **Flexible Parameter Passing**: Accepts product-specific parameters that are merged with common parameters +- **Standard Tagging**: Applies consistent base tags across all Service Catalog products +- **Comprehensive Outputs**: Exposes all relevant Service Catalog and networking outputs + +## Variables + +See individual `variables.*.tf` files for details: +- `variables.common.tf` - Account and networking configuration +- `variables.servicecatalog.tf` - Service Catalog-specific settings +- `variables.product.tf` - Product parameters and naming +- `variables.tags.tf` - Tagging configuration + +## Outputs + +All standard Service Catalog outputs plus networking information. See `outputs.tf` for complete list. diff --git a/modules/product/data.tf b/modules/product/data.tf new file mode 100644 index 0000000..01efb52 --- /dev/null +++ b/modules/product/data.tf @@ -0,0 +1,29 @@ +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} +data "aws_arn" "current" { + arn = data.aws_caller_identity.current.arn +} + +data "aws_region" "current" {} + +# Get portfolio details if we resolved an ID +data "aws_servicecatalog_portfolio" "by_id" { + count = local.portfolio_id != null ? 1 : 0 + id = local.portfolio_id + accept_language = var.accept_language +} + +# Get product details by ID (requires product_id) +data "aws_servicecatalog_product" "by_id" { + count = local.product_id != null ? 1 : 0 + id = local.product_id + accept_language = var.accept_language +} + +# Get the latest provisioning artifact (product version) +data "aws_servicecatalog_provisioning_artifacts" "this" { + count = local.product_id != null ? 1 : 0 + accept_language = var.accept_language + product_id = local.product_id +} + diff --git a/modules/product/locals.tf b/modules/product/locals.tf new file mode 100644 index 0000000..436f4a6 --- /dev/null +++ b/modules/product/locals.tf @@ -0,0 +1,56 @@ +locals { + account_id = data.aws_caller_identity.current.account_id + partition = data.aws_partition.current.partition + region = data.aws_region.current.id + + # Use provided IDs + portfolio_id = var.portfolio_id + product_id = var.product_id + + # Get the latest provisioning artifact ID + latest_artifact_id = local.product_id == null ? null : try( + [for artifact in data.aws_servicecatalog_provisioning_artifacts.this[0].provisioning_artifact_details : + artifact.id if artifact.active + ][0], + null + ) + + # Use provided path_id or default to latest + provisioning_artifact_id = var.path_id != null ? var.path_id : local.latest_artifact_id + + # Build common default parameters + base_parameters = { + ProjectName = var.project_name + NameTag = var.provisioned_product_name + Creator = var.creator + ContactEmail = var.contact_email + IncPocEmail = var.inc_poc_email + FISMAID = var.fisma_id + } + + # Merge base, network, and product-specific parameters + parameters = merge( + local.base_parameters, + var.product_parameters + ) + + # Convert parameters map to the format expected by aws_servicecatalog_provisioned_product + provisioning_parameters = [ + for key, value in local.parameters : { + key = key + value = tostring(value) + } + if value != null && value != "" # Only include non-empty values + ] + + base_tags = { + "boc:tf_module_name" = local.module_name + "boc:tf_module_version" = local.module_version + "boc:created_by" = "terraform" + } + + tags = merge( + local.base_tags, + var.tags + ) +} diff --git a/modules/product/main.tf b/modules/product/main.tf new file mode 100644 index 0000000..b4f97fe --- /dev/null +++ b/modules/product/main.tf @@ -0,0 +1,48 @@ +# Product Submodule +# +# Provisions a Service Catalog product +# using a pre-configured portfolio and product + +resource "aws_servicecatalog_provisioned_product" "this" { + name = var.provisioned_product_name + product_id = local.product_id + provisioning_artifact_id = local.provisioning_artifact_id + region = local.region + path_id = var.path_id + accept_language = var.accept_language + ignore_errors = var.ignore_errors + notification_arns = var.notification_arns + retain_physical_resources = var.retain_physical_resources + + dynamic "provisioning_parameters" { + for_each = local.provisioning_parameters + content { + key = provisioning_parameters.value.key + value = provisioning_parameters.value.value + } + } + + dynamic "stack_set_provisioning_preferences" { + for_each = var.stack_set_provisioning_preferences != null ? [var.stack_set_provisioning_preferences] : [] + content { + accounts = try(stack_set_provisioning_preferences.value.accounts, null) + failure_tolerance_count = try(stack_set_provisioning_preferences.value.failure_tolerance_count, null) + failure_tolerance_percentage = try(stack_set_provisioning_preferences.value.failure_tolerance_percentage, null) + max_concurrency_count = try(stack_set_provisioning_preferences.value.max_concurrency_count, null) + max_concurrency_percentage = try(stack_set_provisioning_preferences.value.max_concurrency_percentage, null) + regions = try(stack_set_provisioning_preferences.value.regions, null) + } + } + + tags = local.tags + + timeouts { + create = var.timeout + update = var.timeout + delete = var.timeout + } + + depends_on = [ + data.aws_servicecatalog_provisioning_artifacts.this + ] +} diff --git a/modules/product/module_name.tf b/modules/product/module_name.tf new file mode 100644 index 0000000..6e45f8e --- /dev/null +++ b/modules/product/module_name.tf @@ -0,0 +1,4 @@ +locals { + module_name = "aws-servicecatalog/product" + module_version = "0.0.0" +} diff --git a/modules/product/outputs.tf b/modules/product/outputs.tf new file mode 100644 index 0000000..23dfbe5 --- /dev/null +++ b/modules/product/outputs.tf @@ -0,0 +1,49 @@ +output "provisioned_product_id" { + description = "The ID of the provisioned product" + value = aws_servicecatalog_provisioned_product.this.id +} + +output "provisioned_product_name" { + description = "The name of the provisioned product" + value = aws_servicecatalog_provisioned_product.this.name +} + +output "provisioned_product_arn" { + description = "The ARN of the provisioned product" + value = aws_servicecatalog_provisioned_product.this.arn +} + +output "provisioned_product_type" { + description = "The type of the provisioned product" + value = aws_servicecatalog_provisioned_product.this.type +} + +output "provisioned_product_status" { + description = "The status of the provisioned product" + value = aws_servicecatalog_provisioned_product.this.status +} + +output "provisioned_product_status_message" { + description = "The status message for the provisioned product" + value = aws_servicecatalog_provisioned_product.this.status_message +} + +output "launch_role_arn" { + description = "The ARN of the launch role" + value = aws_servicecatalog_provisioned_product.this.launch_role_arn +} + +output "portfolio_id" { + description = "The ID of the portfolio used" + value = local.portfolio_id +} + +output "product_id" { + description = "The ID of the product used" + value = local.product_id +} + +output "provisioning_artifact_id" { + description = "The ID of the provisioning artifact used" + value = local.provisioning_artifact_id +} diff --git a/modules/product/variables.common.tf b/modules/product/variables.common.tf new file mode 100644 index 0000000..db455af --- /dev/null +++ b/modules/product/variables.common.tf @@ -0,0 +1,14 @@ +#--- +# account info +#--- +variable "account_id" { + description = "AWS Account ID (default will pull from current user)" + type = string + default = "" +} + +variable "account_alias" { + description = "AWS Account Alias" + type = string + default = "" +} diff --git a/modules/product/variables.product.tf b/modules/product/variables.product.tf new file mode 100644 index 0000000..0f5c08e --- /dev/null +++ b/modules/product/variables.product.tf @@ -0,0 +1,50 @@ +variable "provisioned_product_name" { + description = "Name of the provisioned product" + type = string + + validation { + condition = length(var.provisioned_product_name) > 0 && length(var.provisioned_product_name) <= 128 + error_message = "provisioned_product_name must be between 1 and 128 characters" + } +} + +variable "project_name" { + description = "Project name (ProjectName parameter). Make sure to select the project designated for your account. The build will fail if an incorrect project is selected. The list of values can be found in the ProjectName parameter of the product's provisioning artifact." + type = string + default = "" + + validation { + condition = var.project_name == "" || (length(var.project_name) >= 12 && substr(var.project_name, length(var.project_name) - 12, 12) == data.aws_caller_identity.current.account_id) + error_message = "The last 12 digits of project_name must match the AWS account ID" + } +} + +variable "creator" { + description = "Creator's JBID (Creator parameter)" + type = string + default = "" +} + +variable "contact_email" { + description = "Provisioning user's email (ContactEmail parameter)" + type = string + default = "" +} + +variable "inc_poc_email" { + description = "Incident POC email (IncPocEmail parameter)" + type = string + default = "" +} + +variable "fisma_id" { + description = "FISMA ID" + type = string + default = "" +} + +variable "product_parameters" { + description = "Product-specific parameters to pass to the Service Catalog product. These will be merged with common parameters." + type = map(string) + default = {} +} diff --git a/modules/product/variables.servicecatalog.tf b/modules/product/variables.servicecatalog.tf new file mode 100644 index 0000000..204c553 --- /dev/null +++ b/modules/product/variables.servicecatalog.tf @@ -0,0 +1,65 @@ +variable "portfolio_id" { + description = "Portfolio ID. If not provided, will lookup by portfolio_name_pattern" + type = string + default = null +} + +variable "product_id" { + description = "Product ID. If not provided, will lookup by product_name_pattern" + type = string + default = null +} + +variable "timeout" { + description = "Timeout for provisioned product operations (create/update/delete)" + type = string + default = "15m" +} + +variable "accept_language" { + description = "Language code for Service Catalog API calls" + type = string + default = "en" + + validation { + condition = contains(["en", "jp", "zh"], var.accept_language) + error_message = "accept_language must be one of: en, jp, zh" + } +} + +variable "ignore_errors" { + description = "Whether to ignore errors during provisioning" + type = bool + default = false +} + +variable "notification_arns" { + description = "List of SNS topic ARNs to send provisioning notifications to" + type = list(string) + default = [] +} + +variable "retain_physical_resources" { + description = "Whether to retain physical resources when deleting the provisioned product" + type = bool + default = false +} + +variable "stack_set_provisioning_preferences" { + description = "StackSet provisioning preferences to use when provisioning the product" + type = object({ + accounts = optional(list(string)) + failure_tolerance_count = optional(number) + failure_tolerance_percentage = optional(number) + max_concurrency_count = optional(number) + max_concurrency_percentage = optional(number) + regions = optional(list(string)) + }) + default = null +} + +variable "path_id" { + description = "Path identifier of the product. If not provided, will use the latest active artifact" + type = string + default = null +} diff --git a/modules/product/variables.tags.tf b/modules/product/variables.tags.tf new file mode 100644 index 0000000..a1bde85 --- /dev/null +++ b/modules/product/variables.tags.tf @@ -0,0 +1,5 @@ +variable "tags" { + description = "AWS Tags to apply to appropriate resources." + type = map(string) + default = {} +} diff --git a/modules/product/versions.tf b/modules/product/versions.tf new file mode 100644 index 0000000..773514a --- /dev/null +++ b/modules/product/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + } +} diff --git a/modules/s3/README.md b/modules/s3/README.md new file mode 100644 index 0000000..97dd961 --- /dev/null +++ b/modules/s3/README.md @@ -0,0 +1,298 @@ +# Service Catalog S3 Module + +## Overview + +This module provisions S3 buckets via AWS Service Catalog. It provides a simplified Terraform interface to the Service Catalog S3 bucket product, handling all the complexity of parameter mapping, encryption configuration, and compliance tagging. + +This is a product-specific wrapper around the base [`product`](../product/) module, adding S3-specific parameters and defaults. + +## Features + +- **Simplified Interface**: Clean Terraform variables instead of raw Service Catalog parameters +- **Automatic Encryption**: All buckets use KMS encryption by default +- **Compliance Tagging**: Built-in support for FISMA and Title Data classification tags +- **Versioning Control**: Easy toggle for bucket versioning +- **Latest Version by Default**: Automatically uses the latest active provisioning artifact +- **Flexible Parameter Override**: Pass additional parameters via the `parameters` variable +- **Standard Tagging**: Consistent tagging across all resources +- **S3-Specific Validations**: Built-in validation for bucket naming and title data types + +## Usage + +### Basic Example + +```hcl +module "s3_bucket" { + source = "git::https://your-repo/terraform-modules/aws-servicecatalog//modules/s3?ref=v1.0.0" + + # Service Catalog Configuration + portfolio_id = "port-pgj3zvoqca7ya" + product_id = "prod-s3bucket123xyz" + + # Bucket Configuration + provisioned_product_name = "my-data-bucket" + bucket_name = "my-data-bucket" + project_name = "csvd_myproject_dev-229685449397" + + # Versioning + enable_bucket_versioning = false + + # Security & Compliance + title_data = ["no_title"] + fisma_id = "OCIO_CSVD (CEN16.09)" + + # Contact Information + creator = "jsmith" + contact_email = "jsmith@example.com" + inc_poc_email = "team@example.com" + + tags = { + Environment = "dev" + Application = "data-storage" + } +} +``` + +### Example with Title 26 Data + +```hcl +module "s3_bucket_title26" { + source = "git::https://your-repo/terraform-modules/aws-servicecatalog//modules/s3?ref=v1.0.0" + + portfolio_id = "port-pgj3zvoqca7ya" + product_id = "prod-s3bucket123xyz" + provisioned_product_name = "tax-data-bucket" + bucket_name = "tax-data-bucket" + project_name = "csvd_taxsys_prod-229685449397" + + # Enable versioning for compliance + enable_bucket_versioning = true + + # Title data classification - multiple can be specified + title_data = ["title_26", "title_pii"] + fisma_id = "OCIO_CSVD (CEN16.09)" + + creator = "taxadmin" + contact_email = "taxadmin@example.com" + inc_poc_email = "tax-team@example.com" + + # Optional FinOps tag for cost tracking + project_role = "data-storage" + + tags = { + Environment = "prod" + Application = "tax-system" + DataSensitivity = "high" + } +} +``` + +### Example with Custom Parameters + +```hcl +module "s3_bucket_advanced" { + source = "git::https://your-repo/terraform-modules/aws-servicecatalog//modules/s3?ref=v1.0.0" + + portfolio_id = "port-pgj3zvoqca7ya" + product_id = "prod-s3bucket123xyz" + provisioned_product_name = "archive-bucket" + bucket_name = "archive-bucket" + project_name = "csvd_archive_prod-229685449397" + + enable_bucket_versioning = true + title_data = ["no_title"] + fisma_id = "OCIO_CSVD (CEN16.09)" + + creator = "archiveadmin" + contact_email = "archiveadmin@example.com" + inc_poc_email = "archive-team@example.com" + project_role = "long-term-storage" + + # Custom parameters not covered by named variables + # parameters = { + # # Add any custom CloudFormation template parameters here + # } + + # Service Catalog Options + timeout = "20m" + notification_arns = ["arn:aws:sns:us-gov-east-1:123456789012:sc-notifications"] + retain_physical_resources = true + + tags = { + Environment = "prod" + Application = "archive" + CostCenter = "operations" + RetentionPolicy = "7-years" + } +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.0 | +| aws | >= 6.0 | + +## Providers + +No providers are used directly - all AWS resources are provisioned by the underlying `product` module. + +## Variables + +### Required Variables + +| Name | Description | Type | +|------|-------------|------| +| `provisioned_product_name` | Name of the provisioned product (1-128 characters) | `string` | +| `bucket_name` | Name of the S3 bucket (3-50 characters, lowercase alphanumeric and hyphens only) | `string` | + +### Service Catalog Configuration + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `portfolio_id` | Portfolio ID containing the S3 product | `string` | `"port-pgj3zvoqca7ya"` | +| `product_id` | Product ID for the S3 product | `string` | `"prod-s3bucket123xyz"` | +| `path_id` | Path identifier (version). Uses latest if not provided | `string` | `null` | +| `timeout` | Timeout for provisioning operations | `string` | `"15m"` | +| `accept_language` | Language code for API calls | `string` | `"en"` | +| `ignore_errors` | Ignore errors during provisioning | `bool` | `false` | +| `notification_arns` | SNS topic ARNs for notifications | `list(string)` | `[]` | +| `retain_physical_resources` | Retain resources when deleted | `bool` | `false` | +| `stack_set_provisioning_preferences` | StackSet preferences | `object` | `null` | + +### S3 Configuration + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `bucket_name` | Bucket name (account ID will be appended) | `string` | **required** | +| `enable_bucket_versioning` | Enable bucket versioning | `bool` | `false` | +| `title_data` | List of title data types for compliance | `list(string)` | `["no_title"]` | +| `project_role` | Optional FinOps tag for cost tracking | `string` | `""` | + +#### Valid Title Data Values + +- `no_title` - No title data +- `title_13` - Title 13 Census data +- `title_14` - Title 14 data +- `title_2` - Title 2 data +- `title_21` - Title 21 data +- `title_26` - Title 26 tax data +- `title_25` - Title 25 data +- `title_26_pending` - Pending Title 26 classification +- `title_5` - Title 5 data +- `title_health_data` - Health data +- `title_pii` - Personally Identifiable Information +- `title_pub_1075` - IRS Publication 1075 data + +### Common Parameters + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `project_name` | Project name (must end with account ID) | `string` | `""` | +| `creator` | Creator's JBID | `string` | `""` | +| `contact_email` | Provisioning user's email | `string` | `""` | +| `inc_poc_email` | Incident POC email | `string` | `""` | +| `fisma_id` | FISMA ID | `string` | `""` | + +### Other + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `parameters` | Additional Service Catalog parameters | `map(string)` | `{}` | +| `tags` | Tags to apply to the provisioned product | `map(string)` | `{}` | + +## Outputs + +| Name | Description | +|------|-------------| +| `provisioned_product_id` | The ID of the provisioned product | +| `provisioned_product_name` | The name of the provisioned product | +| `provisioned_product_arn` | The ARN of the provisioned product | +| `provisioned_product_type` | The type of the provisioned product | +| `provisioned_product_status` | The status of the provisioned product | +| `provisioned_product_status_message` | The status message | +| `launch_role_arn` | The ARN of the launch role | +| `portfolio_id` | The ID of the portfolio used | +| `product_id` | The ID of the product used | +| `provisioning_artifact_id` | The ID of the provisioning artifact used | +| `bucket_name` | The actual bucket name (with account ID suffix) | + +## Module Architecture + +This module is built on top of the generic [`product`](../product/) module: + +``` +s3 module (this) + ↓ +product module (base Service Catalog logic) + ↓ +AWS Service Catalog + ↓ +CloudFormation Stack (S3 bucket with KMS encryption) +``` + +The `s3` module: +1. Accepts S3-specific variables +2. Maps them to Service Catalog parameters +3. Passes them to the `product` module via `product_parameters` +4. Exposes outputs from the `product` module + +## Parameter Mapping + +The module automatically maps Terraform variables to Service Catalog parameters: + +| Terraform Variable | Service Catalog Parameter | +|-------------------|---------------------------| +| `provisioned_product_name` | Provisioned product name | +| `bucket_name` | `pBucketName` | +| `enable_bucket_versioning` | `pEnableBucketVersioning` | +| `title_data` | `TitleData` (comma-separated list) | +| `project_role` | `ProjectRole` | +| `project_name` | `ProjectName` | +| `contact_email` | `ContactEmail` | +| `inc_poc_email` | `IncPocEmail` | +| `fisma_id` | `FISMAID` | + +Additional parameters can be passed via the `parameters` variable, which will override any defaults. + +## Important Notes + +### Bucket Naming +- The bucket name must be 3-50 characters long +- Can only contain lowercase letters, numbers, and hyphens +- Must start and end with a letter or number +- The AWS account ID is automatically appended to ensure uniqueness + +### Versioning +- Once enabled, bucket versioning **cannot be disabled**, only suspended +- Versioning will incur additional storage costs based on version retention +- Consider your data retention policies before enabling versioning + +### Encryption +- All buckets are encrypted with AWS KMS by default +- Encryption is managed by the CloudFormation template and cannot be disabled + +### Deletion Policy +- Buckets have a `DeletionPolicy: Retain` in CloudFormation +- Deleting the Terraform resource will **not** delete the actual S3 bucket +- Buckets must be manually deleted from the AWS console if needed +- Set `retain_physical_resources = false` if you want Terraform to attempt deletion + +### Title Data Compliance +- `title_data` is a **required field** for compliance tracking +- Default value is `["no_title"]` if no title data is stored +- Multiple values can be specified for buckets containing mixed data types +- This tag is monitored for security and compliance auditing + +### Project Name Validation +- The `project_name` must end with the AWS account ID (last 12 digits) +- This ensures resources are charged to the correct project + +## Examples + +See the [examples/s3/](../../examples/s3/) directory for complete working examples. + +## Support + +For issues or questions, please refer to the main [aws-servicecatalog module documentation](../../README.md). diff --git a/modules/s3/data.tf b/modules/s3/data.tf deleted file mode 100644 index bf678aa..0000000 --- a/modules/s3/data.tf +++ /dev/null @@ -1,69 +0,0 @@ -data "aws_caller_identity" "current" {} -data "aws_partition" "current" {} -data "aws_arn" "current" { - arn = data.aws_caller_identity.current.arn -} - -data "aws_region" "current" {} - -data "aws_vpc" "vpc" { - filter { - name = "tag:Name" - values = [var.vpc_name] - } -} - -data "aws_subnets" "subnets" { - filter { - name = "tag:Name" - values = [var.subnets_name] - } - filter { - name = "vpc-id" - values = [data.aws_vpc.vpc.id] - } -} - -data "aws_subnet" "subnets" { - for_each = toset(data.aws_subnets.subnets.ids) - id = each.key -} - -// Get portfolio details if we resolved an ID -data "aws_servicecatalog_portfolio" "by_id" { - count = local.portfolio_id != null ? 1 : 0 - id = local.portfolio_id - accept_language = var.accept_language -} - -// Get product details by ID (requires product_id) -data "aws_servicecatalog_product" "by_id" { - count = local.product_id != null ? 1 : 0 - id = local.product_id - accept_language = var.accept_language -} - -// Get the latest provisioning artifact (product version) -data "aws_servicecatalog_provisioning_artifacts" "this" { - count = local.product_id != null ? 1 : 0 - accept_language = var.accept_language - product_id = local.product_id -} - -data "aws_availability_zones" "zones" { - state = "available" -} - -data "aws_availability_zone" "zone" { - for_each = toset(data.aws_availability_zones.zones.names) - state = "available" - name = each.key -} - -data "external" "portfolio" { - program = ["bash", "-c", "tf-aws servicecatalog list-portfolios --region ${local.region} --query \"PortfolioDetails[?contains(DisplayName, 'Service Portfolio for')]|[0]|{id: Id}\" --output json"] -} - -data "external" "product" { - program = ["bash", "-c", "tf-aws servicecatalog search-products --region ${local.region} --query \"ProductViewSummaries[?contains(Name, 'RHEL')]|[0]|{id: ProductId}\" --output json"] -} diff --git a/modules/s3/locals.tf b/modules/s3/locals.tf index 26bea18..701d23f 100644 --- a/modules/s3/locals.tf +++ b/modules/s3/locals.tf @@ -1,70 +1,23 @@ locals { - account_id = data.aws_caller_identity.current.account_id - az_name = data.aws_subnet.subnets[sort(data.aws_subnets.subnets.ids)[0]].availability_zone - partition = data.aws_partition.current.partition - region = data.aws_region.current.id - vpc_id = data.aws_vpc.vpc.id - - # Use provided portfolio_id or fall back to external data source lookup - portfolio_id = var.portfolio_id != null ? var.portfolio_id : try(data.external.portfolio.result.id, null) - - # Use provided product_id or fall back to external data source lookup - product_id = var.product_id != null ? var.product_id : try(data.external.product.result.id, null) - - # Get the latest provisioning artifact ID - latest_artifact_id = local.product_id == null ? null : try( - [for artifact in data.aws_servicecatalog_provisioning_artifacts.this[0].provisioning_artifact_details : - artifact.id if artifact.active - ][0], - null + # S3-specific parameters to pass to the product module + s3_parameters = merge( + { + pBucketName = var.bucket_name + pEnableBucketVersioning = tostring(var.enable_bucket_versioning) + TitleData = join(",", var.title_data) + ProjectRole = var.project_role + }, + var.parameters # Allow user to override any parameter ) - # Use provided path_id or default to latest - provisioning_artifact_id = var.path_id != null ? var.path_id : local.latest_artifact_id - - # Build default parameters from module variables - default_parameters = { - ProjectName = var.project_name - VpcId = local.vpc_id - AZName = local.az_name - InstanceType = var.instance_type - NameTag = var.provisioned_product_name - OSName = var.os_name - Creator = var.creator - ContactEmail = var.contact_email - IncPocEmail = var.inc_poc_email - RequiresBackup = var.requires_backup - PowerSchedule = var.power_schedule - FISMAID = var.fisma_id + base_tags = { + "boc:tf_module_name" = local.module_name + "boc:tf_module_version" = local.module_version + "boc:created_by" = "terraform" } - # Merge defaults with user-provided parameters (user params override defaults) - parameters = merge( - local.default_parameters, - var.parameters - ) - - # Convert parameters map to the format expected by aws_servicecatalog_provisioned_product - provisioning_parameters = [ - for key, value in local.parameters : { - key = key - value = tostring(value) - } - if value != "" # Only include non-empty values - ] - - standard_tags = { - ManagedBy = "Terraform" - Module = local.module_name - } - - enforced_tags = merge( - local.standard_tags, - var.enforced_tags - ) - tags = merge( - local.enforced_tags, + local.base_tags, var.tags ) } \ No newline at end of file diff --git a/modules/s3/main.tf b/modules/s3/main.tf index b4f97fe..7116e06 100644 --- a/modules/s3/main.tf +++ b/modules/s3/main.tf @@ -1,48 +1,33 @@ -# Product Submodule +# S3 Product Module # -# Provisions a Service Catalog product -# using a pre-configured portfolio and product +# Provisions an S3 bucket via Service Catalog +# This is a thin wrapper around the product module with S3-specific parameters -resource "aws_servicecatalog_provisioned_product" "this" { - name = var.provisioned_product_name - product_id = local.product_id - provisioning_artifact_id = local.provisioning_artifact_id - region = local.region - path_id = var.path_id - accept_language = var.accept_language - ignore_errors = var.ignore_errors - notification_arns = var.notification_arns - retain_physical_resources = var.retain_physical_resources +module "s3" { + source = "../product" - dynamic "provisioning_parameters" { - for_each = local.provisioning_parameters - content { - key = provisioning_parameters.value.key - value = provisioning_parameters.value.value - } - } + # Service Catalog configuration + portfolio_id = var.portfolio_id + product_id = var.product_id + path_id = var.path_id + accept_language = var.accept_language + timeout = var.timeout + ignore_errors = var.ignore_errors + notification_arns = var.notification_arns + retain_physical_resources = var.retain_physical_resources + stack_set_provisioning_preferences = var.stack_set_provisioning_preferences - dynamic "stack_set_provisioning_preferences" { - for_each = var.stack_set_provisioning_preferences != null ? [var.stack_set_provisioning_preferences] : [] - content { - accounts = try(stack_set_provisioning_preferences.value.accounts, null) - failure_tolerance_count = try(stack_set_provisioning_preferences.value.failure_tolerance_count, null) - failure_tolerance_percentage = try(stack_set_provisioning_preferences.value.failure_tolerance_percentage, null) - max_concurrency_count = try(stack_set_provisioning_preferences.value.max_concurrency_count, null) - max_concurrency_percentage = try(stack_set_provisioning_preferences.value.max_concurrency_percentage, null) - regions = try(stack_set_provisioning_preferences.value.regions, null) - } - } + # Common parameters + provisioned_product_name = var.provisioned_product_name + project_name = var.project_name + creator = var.creator + contact_email = var.contact_email + inc_poc_email = var.inc_poc_email + fisma_id = var.fisma_id - tags = local.tags - - timeouts { - create = var.timeout - update = var.timeout - delete = var.timeout - } + # S3-specific parameters + product_parameters = local.s3_parameters - depends_on = [ - data.aws_servicecatalog_provisioning_artifacts.this - ] + # Tags + tags = local.tags } diff --git a/modules/s3/module_name.tf b/modules/s3/module_name.tf index 87a6a5c..5e2c32c 100644 --- a/modules/s3/module_name.tf +++ b/modules/s3/module_name.tf @@ -1,3 +1,4 @@ locals { - module_name = "aws-servicecatalog/ec2" + module_name = "aws-servicecatalog/s3" + module_version = "0.0.0" } diff --git a/modules/s3/outputs.tf b/modules/s3/outputs.tf index 5ab1436..a894b3b 100644 --- a/modules/s3/outputs.tf +++ b/modules/s3/outputs.tf @@ -1,79 +1,54 @@ output "provisioned_product_id" { description = "The ID of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.id + value = module.product.provisioned_product_id } output "provisioned_product_name" { description = "The name of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.name + value = module.product.provisioned_product_name } output "provisioned_product_arn" { description = "The ARN of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.arn + value = module.product.provisioned_product_arn } output "provisioned_product_type" { description = "The type of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.type + value = module.product.provisioned_product_type } output "provisioned_product_status" { description = "The status of the provisioned product" - value = aws_servicecatalog_provisioned_product.this.status + value = module.product.provisioned_product_status } output "provisioned_product_status_message" { description = "The status message for the provisioned product" - value = aws_servicecatalog_provisioned_product.this.status_message + value = module.product.provisioned_product_status_message } output "launch_role_arn" { description = "The ARN of the launch role" - value = aws_servicecatalog_provisioned_product.this.launch_role_arn + value = module.product.launch_role_arn } output "portfolio_id" { description = "The ID of the portfolio used" - value = local.portfolio_id + value = module.product.portfolio_id } output "product_id" { description = "The ID of the product used" - value = local.product_id + value = module.product.product_id } output "provisioning_artifact_id" { description = "The ID of the provisioning artifact used" - value = local.provisioning_artifact_id + value = module.product.provisioning_artifact_id } -output "vpc_id" { - description = "The VPC ID where the instance will be provisioned" - value = data.aws_vpc.vpc.id -} - -output "subnet_ids" { - description = "The subnet IDs where the instance can be provisioned" - value = data.aws_subnets.subnets.ids -} - -output "availability_zone" { - description = "The availability zone of the first selected subnet" - value = data.aws_subnet.subnets[sort(data.aws_subnets.subnets.ids)[0]].availability_zone -} - -output "availability_zone_names" { - description = "VPC Availability zone name list" - value = data.aws_availability_zones.zones.names -} - -output "availability_zone_ids" { - description = "VPC Availability zone id list" - value = data.aws_availability_zones.zones.zone_ids -} - -output "availability_zone_suffixes" { - description = "VPC Availability zone suffix list" - value = [for k, v in data.aws_availability_zone.zone : v.name_suffix] +output "bucket_name" { + description = "The actual bucket name (with account ID suffix)" + value = "${var.bucket_name}" } \ No newline at end of file diff --git a/modules/s3/prefixes.tf b/modules/s3/prefixes.tf deleted file mode 100644 index d2ee1fe..0000000 --- a/modules/s3/prefixes.tf +++ /dev/null @@ -1,28 +0,0 @@ -locals { - _prefixes = { - "efs" = "v-efs-" - "s3" = "v-s3-" - "ebs" = "v-ebs-" - "kms" = "k-kms-" - "role" = "r-" - "policy" = "p-" - "group" = "g-" - "security-group" = "" # "sg-" - # VPC - "vpc" = "" - "dhcp-options" = "" - "vpc-peer" = "vpcp-" - "route-table" = "route-" - "subnet" = "" - "vpc-endpoint" = "vpce-" - "elastic-ip" = "eip-" - "nat-gateway" = "nat-" - "internet-gateway" = "igw-" - "network-acl" = "nacl-" - "customer-gateway" = "cgw-" - "vpn-gateway" = "vpcg-" - "vpn-connection" = "vpn_" - "log-group" = "lg-" - "log-stream" = "lgs-" - } -} diff --git a/modules/s3/variables.common.tf b/modules/s3/variables.common.tf index 195849d..f6ea035 100644 --- a/modules/s3/variables.common.tf +++ b/modules/s3/variables.common.tf @@ -1,5 +1,5 @@ #--- -# account info +# Common variables #--- variable "account_id" { description = "AWS Account ID (default will pull from current user)" @@ -13,12 +13,6 @@ variable "account_alias" { default = "" } -variable "override_prefixes" { - description = "Override built-in prefixes by component. This should be used primarily for common infrastructure things" - type = map(string) - default = {} -} - variable "availability_zones" { description = "AWS Availability Zones to use (by default will use all available)" type = list(string) @@ -26,13 +20,23 @@ variable "availability_zones" { } variable "parameters" { - description = "Parameters to pass to the Service Catalog product. Map of parameter names to values" + description = "Additional parameters to pass to the Service Catalog product. Map of parameter names to values." type = map(string) default = {} } +variable "provisioned_product_name" { + description = "Name of the provisioned product" + type = string + + validation { + condition = length(var.provisioned_product_name) > 0 && length(var.provisioned_product_name) <= 128 + error_message = "provisioned_product_name must be between 1 and 128 characters" + } +} + variable "project_name" { - description = "Project name (ProjectName parameter)" + description = "Project name (ProjectName parameter). Make sure to select the project designated for your account. The build will fail if an incorrect project is selected. The list of values can be found in the ProjectName parameter of the product's provisioning artifact." type = string default = "" } @@ -55,121 +59,8 @@ variable "inc_poc_email" { default = "" } -variable "instance_type" { - description = "EC2 instance type" - type = string - default = "t3.small" -} - -variable "os_name" { - description = "Operating system version" - type = string - default = "RHEL9" -} - -variable "requires_backup" { - description = "Backup requirement" - type = string - default = "no" -} - -variable "power_schedule" { - description = "Power schedule" - type = string - default = "" -} - variable "fisma_id" { description = "FISMA ID" type = string default = "" } - -variable "provisioned_product_name" { - description = "Name of the provisioned product" - type = string - - validation { - condition = length(var.provisioned_product_name) > 0 && length(var.provisioned_product_name) <= 128 - error_message = "provisioned_product_name must be between 1 and 128 characters" - } -} - -variable "portfolio_id" { - description = "Portfolio ID. If not provided, will lookup by portfolio_name_pattern" - type = string - default = "port-pgj3zvoqca7ya" -} - -variable "product_id" { - description = "Product ID. If not provided, will lookup by product_name_pattern" - type = string - default = "prod-43foqxjcq5isw" -} - -variable "product_name_pattern" { - description = "Pattern to search for product by name" - type = string - default = "linux-product" -} - -variable "path_id" { - description = "Path identifier of the product. If not provided, will use the latest active artifact" - type = string - default = null -} - -variable "ignore_errors" { - description = "Only applies to deleting. If true, errors from the underlying service are ignored" - type = bool - default = false -} - -variable "notification_arns" { - description = "SNS topic ARNs to notify when the provisioned product changes" - type = list(string) - default = [] -} - -variable "retain_physical_resources" { - description = "Whether to retain the physical resources when the provisioned product is terminated" - type = bool - default = false -} - -variable "stack_set_provisioning_preferences" { - description = "Configuration for StackSet provisioning" - type = object({ - accounts = optional(list(string)) - failure_tolerance_count = optional(number) - failure_tolerance_percentage = optional(number) - max_concurrency_count = optional(number) - max_concurrency_percentage = optional(number) - regions = optional(list(string)) - }) - default = null -} - -variable "retrieve_stack_outputs" { - description = "Whether to retrieve CloudFormation stack outputs" - type = bool - default = true -} - -variable "timeout" { - description = "Timeout for provisioned product operations (create/update/delete)" - type = string - default = "15m" -} - -variable "vpc_name" { - description = "Name tag of the VPC to deploy into" - type = string - default = "" -} - -variable "subnets_name" { - description = "Name tag of the subnets to deploy into" - type = string - default = "*-apps-*" -} \ No newline at end of file diff --git a/modules/s3/variables.product.tf b/modules/s3/variables.product.tf new file mode 100644 index 0000000..124eac8 --- /dev/null +++ b/modules/s3/variables.product.tf @@ -0,0 +1,49 @@ +# S3-specific product parameters + +variable "bucket_name" { + description = "Name of the S3 bucket (the account number will be appended to the end). Must be unique and 3-50 characters." + type = string + + validation { + condition = can(regex("^[a-z0-9]{1}[a-z0-9\\-]{1,48}[a-z0-9]{1}$", var.bucket_name)) + error_message = "Bucket name must start with a lowercase letter (a-z) or number (0-9) and can consist only of lowercase letters, numbers, and hyphens (-). Must be 3-50 characters long." + } +} + +variable "enable_bucket_versioning" { + description = "Enable bucket versioning. Once enabled, versioning cannot be disabled, only suspended. Versioning will incur additional charges." + type = bool + default = false +} + +variable "title_data" { + description = "List of title data types that will be stored in this bucket. Multiple can be selected. This is a required tag." + type = list(string) + default = ["no_title"] + + validation { + condition = alltrue([ + for item in var.title_data : contains([ + "no_title", + "title_13", + "title_14", + "title_2", + "title_21", + "title_26", + "title_25", + "title_26_pending", + "title_5", + "title_health_data", + "title_pii", + "title_pub_1075" + ], item) + ]) + error_message = "Each title_data item must be one of the allowed title data types." + } +} + +variable "project_role" { + description = "Project role is an optional FinOps tag that can be used to further track sub-component cost within the same project." + type = string + default = "" +} diff --git a/modules/s3/variables.safeguards.tf b/modules/s3/variables.safeguards.tf deleted file mode 100644 index 91c21b1..0000000 --- a/modules/s3/variables.safeguards.tf +++ /dev/null @@ -1,24 +0,0 @@ -# This file contains safeguard variables to prevent accidental destruction -# Pattern follows aws-s3 module conventions - -variable "enable_deletion_protection" { - description = "Enable deletion protection to prevent accidental termination" - type = bool - default = false -} - -locals { - deletion_protection_error = "Deletion protection is enabled. Set enable_deletion_protection = false to allow termination." -} - -resource "null_resource" "deletion_protection" { - count = var.enable_deletion_protection ? 1 : 0 - - lifecycle { - prevent_destroy = true - } - - triggers = { - provisioned_product_id = aws_servicecatalog_provisioned_product.this.id - } -} diff --git a/modules/s3/variables.servicecatalog.tf b/modules/s3/variables.servicecatalog.tf new file mode 100644 index 0000000..d5efc97 --- /dev/null +++ b/modules/s3/variables.servicecatalog.tf @@ -0,0 +1,65 @@ +variable "portfolio_id" { + description = "Portfolio ID for the S3 product" + type = string + default = "port-pgj3zvoqca7ya" +} + +variable "product_id" { + description = "Product ID for the S3 product" + type = string + default = "prod-s3bucket123xyz" # Update with actual S3 product ID +} + +variable "path_id" { + description = "Path identifier of the product. If not provided, will use the latest active artifact" + type = string + default = null +} + +variable "timeout" { + description = "Timeout for provisioned product operations (create/update/delete)" + type = string + default = "15m" +} + +variable "accept_language" { + description = "Language code for Service Catalog API calls" + type = string + default = "en" + + validation { + condition = contains(["en", "jp", "zh"], var.accept_language) + error_message = "accept_language must be one of: en, jp, zh" + } +} + +variable "ignore_errors" { + description = "Whether to ignore errors during provisioning" + type = bool + default = false +} + +variable "notification_arns" { + description = "List of SNS topic ARNs to send provisioning notifications to" + type = list(string) + default = [] +} + +variable "retain_physical_resources" { + description = "Whether to retain physical resources when deleting the provisioned product" + type = bool + default = false +} + +variable "stack_set_provisioning_preferences" { + description = "StackSet provisioning preferences to use when provisioning the product" + type = object({ + accounts = optional(list(string)) + failure_tolerance_count = optional(number) + failure_tolerance_percentage = optional(number) + max_concurrency_count = optional(number) + max_concurrency_percentage = optional(number) + regions = optional(list(string)) + }) + default = null +} diff --git a/modules/s3/variables.tags.tf b/modules/s3/variables.tags.tf index 18c6001..da93538 100644 --- a/modules/s3/variables.tags.tf +++ b/modules/s3/variables.tags.tf @@ -9,14 +9,3 @@ variable "enforced_tags" { type = map(string) default = {} } - -variable "accept_language" { - description = "Language code for Service Catalog API calls" - type = string - default = "en" - - validation { - condition = contains(["en", "jp", "zh"], var.accept_language) - error_message = "accept_language must be one of: en, jp, zh" - } -} diff --git a/modules/s3/versions.tf b/modules/s3/versions.tf index dd0ebb9..773514a 100644 --- a/modules/s3/versions.tf +++ b/modules/s3/versions.tf @@ -3,7 +3,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 5.0" + version = ">= 6.0" } } }