├── mintlifydocs ├── images │ ├── tui.jpg │ ├── logo.png │ ├── tui.png │ ├── favicon.ico │ └── tui-tags.png ├── .gitignore ├── favicon.png ├── features │ ├── tui.mdx │ ├── Manage-AWS-Organizations.mdx │ ├── scps.mdx │ ├── localstack.mdx │ └── Assign-IaC-Blueprints-To-Accounts.mdx ├── commands │ ├── account-import.mdx │ ├── diff.mdx │ └── deploy.mdx ├── mint.json ├── introduction.mdx ├── quickstart.mdx └── config │ └── organization.mdx ├── examples ├── tf │ ├── buildkite │ │ ├── apply.sh │ │ ├── plan.sh │ │ ├── blueprint_terraform │ │ │ ├── provider.tf │ │ │ ├── main.tf │ │ │ └── vpc.tf │ │ ├── pipeline.yml │ │ └── organization.yml │ ├── awsconfig │ │ ├── provider.tf │ │ ├── variables.tf │ │ ├── baseconfig │ │ │ ├── variables.tf │ │ │ └── main.tf │ │ ├── bucket.tf │ │ ├── iam.tf │ │ └── main.tf │ ├── configaggregator │ │ ├── provider.tf │ │ └── main.tf │ ├── db-per-tenant │ │ ├── provider.tf │ │ └── tenant.tf │ └── SCPs │ │ ├── restrict_to_us │ │ └── main.tf │ │ └── restrict_to_eu │ │ └── main.tf ├── localstack │ ├── tf │ │ ├── ci_iam │ │ │ ├── provider.tf │ │ │ └── main.tf │ │ └── workspace │ │ │ └── main.tf │ ├── s3-remote-state │ │ ├── README.md │ │ ├── go.mod │ │ ├── app.go │ │ ├── cdk.json │ │ └── go.sum │ ├── organization.yml │ ├── setup.sh │ └── README.mdx ├── README.md ├── cdk │ └── tf-s3-backend │ │ ├── README.md │ │ ├── go.mod │ │ ├── app.go │ │ ├── cdk.json │ │ └── go.sum ├── cloudformation │ ├── iam │ │ └── role.yml │ └── dynamo │ │ └── table.yml └── organization-config-everywhere.yml ├── .dockerignore ├── tests ├── teardown.sh ├── tf │ ├── s3-test │ │ └── main.tf │ └── scp-test │ │ └── main.tf ├── cdk │ └── dynamo │ │ ├── README.md │ │ ├── dyanmo_test.go │ │ ├── go.mod │ │ ├── dynamo.go │ │ ├── cdk.json │ │ └── go.sum ├── setup.sh └── cloudformation │ └── table.yml ├── resource ├── interface.go ├── organization_unit.go ├── stack_test.go ├── stack.go └── account.go ├── lib ├── ymlparser │ ├── testdata │ │ ├── organization-child2.yml │ │ ├── organization-one-child.yml │ │ ├── organization-with-filepath.yml │ │ ├── organization-child.yml │ │ └── organization-basic.yml │ ├── organization_test.go │ └── organization.go ├── localstack │ └── localstack.go ├── cdk │ └── local.go ├── colors │ └── color.go ├── awssts │ └── awssts.go ├── awssess │ └── awssess.go ├── telophase │ └── account.go ├── metrics │ └── metrics.go ├── terraform │ └── local.go └── awsorgs │ └── awsorgsmock │ └── organizationmock.go ├── .gitignore ├── cmd ├── runner │ ├── interface.go │ ├── stdout.go │ └── tui.go ├── auth.go ├── iac.go ├── diff.go ├── deploy.go ├── provisionaccounts.go └── root.go ├── main.go ├── resourceoperation ├── interface.go ├── organization_unit_test.go ├── cdk.go ├── terraform.go └── scp.go ├── .github └── workflows │ ├── go.yml │ ├── release.yml │ └── dockerimage.yml ├── Dockerfile ├── go.mod ├── .goreleaser.yaml ├── setup.sh └── README.md /mintlifydocs/images/tui.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mintlifydocs/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | -------------------------------------------------------------------------------- /examples/tf/buildkite/apply.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | telophasecli account apply 4 | -------------------------------------------------------------------------------- /examples/tf/buildkite/plan.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | telophasecli account diff 4 | -------------------------------------------------------------------------------- /mintlifydocs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/telophasehq/telophasecli/HEAD/mintlifydocs/favicon.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # But not .go files 5 | !**/*.go 6 | !go.mod 7 | !go.sum 8 | -------------------------------------------------------------------------------- /mintlifydocs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/telophasehq/telophasecli/HEAD/mintlifydocs/images/logo.png -------------------------------------------------------------------------------- /mintlifydocs/images/tui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/telophasehq/telophasecli/HEAD/mintlifydocs/images/tui.png -------------------------------------------------------------------------------- /mintlifydocs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/telophasehq/telophasecli/HEAD/mintlifydocs/images/favicon.ico -------------------------------------------------------------------------------- /tests/teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | localstack stop 6 | rm organization.yml 7 | rm -rf telophasedirs/ -------------------------------------------------------------------------------- /mintlifydocs/images/tui-tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/telophasehq/telophasecli/HEAD/mintlifydocs/images/tui-tags.png -------------------------------------------------------------------------------- /resource/interface.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | type Resource interface { 4 | ID() string 5 | Name() string 6 | Type() string 7 | } 8 | -------------------------------------------------------------------------------- /lib/ymlparser/testdata/organization-child2.yml: -------------------------------------------------------------------------------- 1 | Name: ExampleOU2 2 | 3 | Accounts: 4 | - Email: test3@example.com 5 | AccountName: test3 6 | -------------------------------------------------------------------------------- /tests/tf/s3-test/main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "us-east-1" 3 | } 4 | 5 | resource "aws_s3_bucket" "test" { 6 | bucket = "test" 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | organization*.yml 2 | .DS_STORE 3 | 4 | telophasecli 5 | 6 | .terraform 7 | telophasedirs 8 | .terraform.lock.hcl 9 | 10 | # CDK Ignores 11 | cdk.out 12 | 13 | 14 | dist/ 15 | -------------------------------------------------------------------------------- /examples/localstack/tf/ci_iam/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "tfstate-${telophase.account_id}" 4 | key = "terraform.tfstate" 5 | region = "us-west-2" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/tf/awsconfig/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "terraform-state-${telophase.account_id}" 4 | key = "terraform.tfstate" 5 | region = "us-east-1" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/tf/buildkite/blueprint_terraform/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "tfstate-${telophase.account_id}" 4 | key = "terraform.tfstate" 5 | region = "us-west-2" 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /lib/ymlparser/testdata/organization-one-child.yml: -------------------------------------------------------------------------------- 1 | Organization: 2 | OrganizationUnits: 3 | - OUFilepath: "./testdata/organization-child.yml" 4 | 5 | - Name: ExampleOU2 6 | Accounts: 7 | - Email: test3@example.com 8 | AccountName: test3 9 | -------------------------------------------------------------------------------- /cmd/runner/interface.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/santiago-labs/telophasecli/resource" 7 | ) 8 | 9 | type ConsoleUI interface { 10 | Print(string, resource.Account) 11 | RunCmd(*exec.Cmd, resource.Account) error 12 | Start() 13 | } 14 | -------------------------------------------------------------------------------- /lib/ymlparser/testdata/organization-with-filepath.yml: -------------------------------------------------------------------------------- 1 | Organization: 2 | Name: root 3 | 4 | OrganizationUnits: 5 | # Path needs to be relative to where telophase is run 6 | - OUFilepath: "./testdata/organization-child.yml" 7 | - OUFilepath: "./testdata/organization-child2.yml" 8 | -------------------------------------------------------------------------------- /examples/tf/configaggregator/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "terraform-state-${telophase.account_id}" 4 | key = "configaggregator/terraform.tfstate" 5 | region = "us-west-2" 6 | } 7 | } 8 | 9 | 10 | provider "aws" { 11 | region = "us-east-1" 12 | } 13 | -------------------------------------------------------------------------------- /examples/tf/db-per-tenant/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | neon = { 4 | source = "terraform-community-providers/neon" 5 | } 6 | } 7 | backend "s3" { 8 | bucket = "tfstate-${telophase.account_id}" 9 | key = "terraform.tfstate" 10 | region = "us-west-2" 11 | } 12 | } 13 | 14 | provider "neon" {} 15 | -------------------------------------------------------------------------------- /examples/tf/awsconfig/variables.tf: -------------------------------------------------------------------------------- 1 | variable "bucket_name" { 2 | type = string 3 | description = "name of the S3 bucket" 4 | default = "telophase-awsconfig-bucket-${telophase.account_id}" 5 | } 6 | 7 | variable "tags" { 8 | default = {} 9 | description = "Tags to add to resources that support it" 10 | type = map(string) 11 | } 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/santiago-labs/telophasecli/cmd" 7 | ) 8 | 9 | func main() { 10 | cmdStr := "cdk" 11 | cdkArgs := []string{"--version"} 12 | 13 | cmdCdk := exec.Command(cmdStr, cdkArgs...) 14 | if err := cmdCdk.Run(); err != nil { 15 | panic("install cdk before running telophasecli. You can install by running `npm install -g aws-cdk`") 16 | } 17 | cmd.Execute() 18 | } 19 | -------------------------------------------------------------------------------- /mintlifydocs/features/tui.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Terminal UI' 3 | icon: 'computer-classic' 4 | --- 5 | 6 | Telophase comes with an interactive terminal UI (TUI). Enabled the TUI by passing in the `--tui` flag. For example, `telophasecli deploy --tui` 7 | 8 | 9 | 10 | The TUI is helpful to organize output across the multiple accounts that are being deployed in parallel. 11 | -------------------------------------------------------------------------------- /lib/localstack/localstack.go: -------------------------------------------------------------------------------- 1 | package localstack 2 | 3 | import "os" 4 | 5 | func UsingLocalStack() bool { 6 | if os.Getenv("LOCALSTACK") != "" { 7 | return true 8 | } 9 | return false 10 | } 11 | 12 | func CdkCmd() string { 13 | if UsingLocalStack() { 14 | return "cdklocal" 15 | } 16 | return "cdk" 17 | } 18 | 19 | func TfCmd() string { 20 | if UsingLocalStack() { 21 | return "tflocal" 22 | } 23 | return "terraform" 24 | } 25 | -------------------------------------------------------------------------------- /examples/tf/awsconfig/baseconfig/variables.tf: -------------------------------------------------------------------------------- 1 | variable "iam_role" { 2 | type = string 3 | description = "IAM role ARN for AWS Config" 4 | } 5 | 6 | variable "global_resource_collector_region" { 7 | type = string 8 | description = "value of the global resource collector region" 9 | default = "us-east-1" 10 | } 11 | 12 | variable "bucket_name" { 13 | type = string 14 | description = "name of the S3 bucket" 15 | } 16 | -------------------------------------------------------------------------------- /mintlifydocs/commands/account-import.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'telophasecli account import' 3 | --- 4 | 5 | ``` 6 | Usage: 7 | telophasecli account import [flags] 8 | 9 | Flags: 10 | -h, --help help for account 11 | --org string Path to the organization.yml file (default "organization.yml") 12 | ``` 13 | 14 | This command reads your AWS Organization and writes an `organization.yml` file locally. It must be run in your AWS Management Account. 15 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | The examples directory contains example Cloudformation, terraform and CDK code that can be referenced in an organization.yml 3 | 4 | ## organization-config-everywhere.yml 5 | `organization-config-everywhere.yml` stands up an example Org structure where: 6 | - Applies delegated admin to the Audit account. 7 | - Provisions an organization-wide config aggregator in the Audit account. 8 | - AWS Config is enabled in every region of every telophase managed Account. 9 | -------------------------------------------------------------------------------- /examples/tf/buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - label: "Terraform Plan" 3 | # This will output: 4 | # 1. Changes required to AWS Organization. 5 | # 2. Output of terraform plan. 6 | # This will not output the output of a terraform plan if the account has not 7 | # been provisioned. 8 | - command: "telophasecli diff" 9 | 10 | - wait: ~ 11 | - block: "unblock to apply" 12 | 13 | - label: "Apply new Accounts and Terraform" 14 | - command: "telophasecli deploy" 15 | -------------------------------------------------------------------------------- /tests/cdk/dynamo/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK Go project! 2 | 3 | This is a blank project for CDK development with Go. 4 | 5 | The `cdk.json` file tells the CDK toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `cdk deploy` deploy this stack to your default AWS account/region 10 | * `cdk diff` compare deployed stack with current state 11 | * `cdk synth` emits the synthesized CloudFormation template 12 | * `go test` run unit tests 13 | -------------------------------------------------------------------------------- /examples/cdk/tf-s3-backend/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK Go project! 2 | 3 | This is a blank project for CDK development with Go. 4 | 5 | The `cdk.json` file tells the CDK toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `cdk deploy` deploy this stack to your default AWS account/region 10 | * `cdk diff` compare deployed stack with current state 11 | * `cdk synth` emits the synthesized CloudFormation template 12 | * `go test` run unit tests 13 | -------------------------------------------------------------------------------- /tests/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | if ! nc -z localhost 4566; then 6 | localstack start -d 7 | sleep 5 8 | fi 9 | 10 | # Pre-create tf-test-state table to avoid concurrency bug in localstack. 11 | aws dynamodb create-table --table-name tf-test-state \ 12 | --attribute-definitions AttributeName=id,AttributeType=S \ 13 | --key-schema AttributeName=id,KeyType=HASH \ 14 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ 15 | --endpoint-url http://localhost:4566 16 | -------------------------------------------------------------------------------- /examples/localstack/s3-remote-state/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK Go project! 2 | 3 | This is a blank project for CDK development with Go. 4 | 5 | The `cdk.json` file tells the CDK toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `cdk deploy` deploy this stack to your default AWS account/region 10 | * `cdk diff` compare deployed stack with current state 11 | * `cdk synth` emits the synthesized CloudFormation template 12 | * `go test` run unit tests 13 | -------------------------------------------------------------------------------- /lib/ymlparser/testdata/organization-child.yml: -------------------------------------------------------------------------------- 1 | Name: ExampleOU 2 | Tags: 3 | - "ou=ExampleTenants" 4 | 5 | Stacks: 6 | - Type: "CDK" 7 | Path: "examples/localstack/s3-remote-state" 8 | Name: "example" 9 | 10 | Accounts: 11 | - Email: test1@example.com 12 | AccountName: test1 13 | Stacks: 14 | - Type: "CDK" 15 | Path: "examples/cdk/sqs" 16 | Name: "example" 17 | Region: "us-west-2,us-east-1" 18 | 19 | - Email: test2@example.com 20 | AccountName: test2 21 | -------------------------------------------------------------------------------- /lib/cdk/local.go: -------------------------------------------------------------------------------- 1 | package cdk 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "path" 8 | 9 | "github.com/santiago-labs/telophasecli/resource" 10 | ) 11 | 12 | func TmpPath(acct resource.Account, filePath string) string { 13 | hasher := sha256.New() 14 | hasher.Write([]byte(filePath)) 15 | hashBytes := hasher.Sum(nil) 16 | hashString := hex.EncodeToString(hashBytes) 17 | 18 | return path.Join("telophasedirs", fmt.Sprintf("cdk-tmp%s-%s", acct.AccountID, hashString)) 19 | } 20 | -------------------------------------------------------------------------------- /examples/tf/buildkite/blueprint_terraform/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "cross_account_role" { 2 | name = "CrossAccountRole" 3 | 4 | # This buildkite role can be assumed by ACCOUNT_iD 5 | assume_role_policy = jsonencode({ 6 | Version = "2012-10-17" 7 | Statement = [ 8 | { 9 | Action = "sts:AssumeRole" 10 | Effect = "Allow" 11 | Principal = { 12 | AWS = "arn:aws:iam::ACCOUNT_ID:role/BuildkiteRole" 13 | } 14 | }, 15 | ] 16 | }) 17 | } 18 | 19 | -------------------------------------------------------------------------------- /resourceoperation/interface.go: -------------------------------------------------------------------------------- 1 | package resourceoperation 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | const ( 8 | // Accounts 9 | UpdateParent = 1 10 | Create = 2 11 | Update = 3 12 | UpdateTags = 6 13 | Delete = 7 14 | DelegateAdmin = 8 15 | 16 | // IaC 17 | Diff = 4 18 | Deploy = 5 19 | ) 20 | 21 | type ResourceOperation interface { 22 | Call(context.Context) error 23 | ToString() string 24 | AddDependent(ResourceOperation) 25 | ListDependents() []ResourceOperation 26 | } 27 | -------------------------------------------------------------------------------- /examples/localstack/tf/ci_iam/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "test_role" { 2 | name = "ci_role" 3 | 4 | # Terraform's "jsonencode" function converts a 5 | # Terraform expression result to valid JSON syntax. 6 | assume_role_policy = jsonencode({ 7 | Version = "2012-10-17" 8 | Statement = [ 9 | { 10 | Action = "sts:AssumeRole" 11 | Effect = "Allow" 12 | Sid = "" 13 | Principal = { 14 | Service = "ec2.amazonaws.com" 15 | } 16 | }, 17 | ] 18 | }) 19 | 20 | tags = { 21 | ci = "true" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/colors/color.go: -------------------------------------------------------------------------------- 1 | package colors 2 | 3 | import ( 4 | "hash/fnv" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | func DeterministicColorFunc(id string) func(format string, a ...interface{}) string { 10 | options := []func(format string, a ...interface{}) string{ 11 | color.CyanString, 12 | color.GreenString, 13 | color.BlueString, 14 | color.MagentaString, 15 | color.HiBlueString, 16 | color.HiCyanString, 17 | color.HiGreenString, 18 | color.HiMagentaString, 19 | } 20 | 21 | h := fnv.New32a() 22 | h.Write([]byte(id)) 23 | hashedValue := h.Sum32() 24 | 25 | return options[hashedValue%uint32(len(options))] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version-file: go.mod 22 | 23 | - name: Run Setup 24 | run: ./setup.sh 25 | 26 | - name: Build 27 | run: go build -v ./... 28 | -------------------------------------------------------------------------------- /mintlifydocs/commands/diff.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'telophasecli diff' 3 | --- 4 | 5 | ``` 6 | Usage: 7 | telophasecli diff [flags] 8 | 9 | Flags: 10 | -h, --help help for diff 11 | --org string Path to the organization.yml file (default "organization.yml") 12 | --stacks string Filter stacks to diff 13 | --tag string Filter accounts and account groups to diff via a comma separated list. 14 | --tui use the TUI for diff 15 | ``` 16 | 17 | This command will read `organization.yml` and **output**: 18 | 1) Changes required to AWS Organization. 19 | 2) Output of `cdk diff`. 20 | 3) Output of `terraform plan`. -------------------------------------------------------------------------------- /examples/tf/db-per-tenant/tenant.tf: -------------------------------------------------------------------------------- 1 | resource "neon_project" "tenant" { 2 | name = telophase.account_name 3 | region_id = "aws-us-east-1" 4 | branch = { 5 | endpoint = { 6 | suspend_timeout = 300 7 | } 8 | } 9 | } 10 | 11 | resource "neon_role" "tenant" { 12 | name = "${telophase.account_name}_user" 13 | branch_id = neon_project.tenant.branch.id 14 | project_id = neon_project.tenant.id 15 | } 16 | 17 | resource "neon_database" "tenant" { 18 | name = "${telophase.account_name}db" 19 | owner_name = neon_role.tenant.name 20 | branch_id = neon_project.tenant.branch.id 21 | project_id = neon_project.tenant.id 22 | } 23 | 24 | -------------------------------------------------------------------------------- /examples/cdk/tf-s3-backend/go.mod: -------------------------------------------------------------------------------- 1 | module tf-s3-backend 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview 7 | github.com/aws/jsii-runtime-go v1.89.0 8 | ) 9 | 10 | require ( 11 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 12 | github.com/aws/constructs-go/constructs/v3 v3.4.232 // indirect 13 | github.com/fatih/color v1.15.0 // indirect 14 | github.com/mattn/go-colorable v0.1.13 // indirect 15 | github.com/mattn/go-isatty v0.0.19 // indirect 16 | github.com/yuin/goldmark v1.4.13 // indirect 17 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 18 | golang.org/x/mod v0.12.0 // indirect 19 | golang.org/x/sys v0.12.0 // indirect 20 | golang.org/x/tools v0.13.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /tests/cdk/dynamo/dyanmo_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // import ( 4 | // "testing" 5 | 6 | // "github.com/aws/aws-cdk-go/awscdk/v2" 7 | // "github.com/aws/aws-cdk-go/awscdk/v2/assertions" 8 | // "github.com/aws/jsii-runtime-go" 9 | // ) 10 | 11 | // example tests. To run these tests, uncomment this file along with the 12 | // example resource in dynamo_test.go 13 | // func TestSqsStack(t *testing.T) { 14 | // // GIVEN 15 | // app := awscdk.NewApp(nil) 16 | 17 | // // WHEN 18 | // stack := NewSqsStack(app, "MyStack", nil) 19 | 20 | // // THEN 21 | // template := assertions.Template_FromStack(stack, nil) 22 | 23 | // template.HasResourceProperties(jsii.String("AWS::SQS::Queue"), map[string]interface{}{ 24 | // "VisibilityTimeout": 300, 25 | // }) 26 | // } 27 | -------------------------------------------------------------------------------- /lib/ymlparser/testdata/organization-basic.yml: -------------------------------------------------------------------------------- 1 | Organization: 2 | OrganizationUnits: 3 | - Name: ExampleOU 4 | Tags: 5 | - "ou=ExampleTenants" 6 | 7 | Stacks: 8 | - Type: "CDK" 9 | Path: "examples/localstack/s3-remote-state" 10 | Name: "example" 11 | 12 | Accounts: 13 | - Email: test1@example.com 14 | AccountName: test1 15 | Stacks: 16 | - Type: "CDK" 17 | Path: "examples/cdk/sqs" 18 | Name: "example" 19 | Region: "us-west-2,us-east-1" 20 | 21 | - Email: test2@example.com 22 | AccountName: test2 23 | 24 | - Name: ExampleOU2 25 | Accounts: 26 | - Email: test3@example.com 27 | AccountName: test3 28 | -------------------------------------------------------------------------------- /examples/localstack/tf/workspace/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_dynamodb_table" "example" { 2 | name = "${terraform.workspace}-eu" 3 | 4 | hash_key = "TestTableHashKey" 5 | billing_mode = "PAY_PER_REQUEST" 6 | stream_enabled = true 7 | stream_view_type = "NEW_AND_OLD_IMAGES" 8 | 9 | attribute { 10 | name = "TestTableHashKey" 11 | type = "S" 12 | } 13 | } 14 | 15 | locals { 16 | region = split("_",terraform.workspace)[1] 17 | } 18 | 19 | provider "aws" { 20 | # Two options can use ${telophase.region} or look at local config 21 | region = "${telophase.region}" 22 | } 23 | 24 | terraform { 25 | backend "s3" { 26 | bucket = "tfstate-${telophase.account_id}" 27 | key = "workspace/terraform.tfstate" 28 | region = "us-west-2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/cloudformation/iam/role.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Administrator access service role for CloudFormation 3 | Resources: 4 | CFNAdminRole: 5 | Type: "AWS::IAM::Role" 6 | Properties: 7 | AssumeRolePolicyDocument: 8 | Version: "2012-10-17" 9 | Statement: 10 | - Effect: "Allow" 11 | Principal: 12 | Service: ["cloudformation.amazonaws.com"] 13 | Action: "sts:AssumeRole" 14 | Path: "/" 15 | ManagedPolicyArns: 16 | - 'arn:aws:iam::aws:policy/AdministratorAccess' 17 | Outputs: 18 | CFNAdminRole: 19 | Description: CloudFormation admin access service role. 20 | Value: !Ref CFNAdminRole 21 | CFNAdminRoleArn: 22 | Description: CloudFormation admin access service role ARN. 23 | Value: !GetAtt CFNAdminRole.Arn -------------------------------------------------------------------------------- /examples/tf/SCPs/restrict_to_us/main.tf: -------------------------------------------------------------------------------- 1 | data "aws_iam_policy_document" "restrict_regions" { 2 | statement { 3 | sid = "RegionRestriction" 4 | effect = "Deny" 5 | actions = ["*"] 6 | resources = ["*"] 7 | 8 | condition { 9 | test = "StringNotEquals" 10 | variable = "aws:RequestedRegion" 11 | 12 | values = [ 13 | "us-east-1" 14 | ] 15 | } 16 | } 17 | } 18 | 19 | resource "aws_organizations_policy" "restrict_regions" { 20 | name = "restrict_regions" 21 | description = "Deny all regions except US East 1." 22 | content = data.aws_iam_policy_document.restrict_regions.json 23 | } 24 | 25 | resource "aws_organizations_policy_attachment" "restrict_regions" { 26 | policy_id = aws_organizations_policy.restrict_regions.id 27 | target_id = telophase.organization_unit_id 28 | } 29 | -------------------------------------------------------------------------------- /examples/localstack/s3-remote-state/go.mod: -------------------------------------------------------------------------------- 1 | module tf-s3-backend 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview 7 | github.com/aws/jsii-runtime-go v1.89.0 8 | ) 9 | 10 | require ( 11 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 12 | github.com/aws/aws-sdk-go v1.48.1 // indirect 13 | github.com/aws/constructs-go/constructs/v3 v3.4.232 // indirect 14 | github.com/fatih/color v1.15.0 // indirect 15 | github.com/jmespath/go-jmespath v0.4.0 // indirect 16 | github.com/mattn/go-colorable v0.1.13 // indirect 17 | github.com/mattn/go-isatty v0.0.19 // indirect 18 | github.com/yuin/goldmark v1.4.13 // indirect 19 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 20 | golang.org/x/mod v0.12.0 // indirect 21 | golang.org/x/sys v0.12.0 // indirect 22 | golang.org/x/tools v0.13.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /examples/tf/SCPs/restrict_to_eu/main.tf: -------------------------------------------------------------------------------- 1 | data "aws_iam_policy_document" "restrict_regions" { 2 | statement { 3 | sid = "RegionRestriction" 4 | effect = "Deny" 5 | actions = ["*"] 6 | resources = ["*"] 7 | 8 | condition { 9 | test = "StringNotEquals" 10 | variable = "aws:RequestedRegion" 11 | 12 | values = [ 13 | "eu-west-1" 14 | ] 15 | } 16 | } 17 | } 18 | 19 | resource "aws_organizations_policy" "restrict_regions" { 20 | name = "restrict_regions_${telophase.organization_unit_id}" 21 | description = "Deny all regions except EU West 1." 22 | content = data.aws_iam_policy_document.restrict_regions.json 23 | } 24 | 25 | resource "aws_organizations_policy_attachment" "restrict_regions" { 26 | policy_id = aws_organizations_policy.restrict_regions.id 27 | target_id = telophase.organization_unit_id 28 | } 29 | -------------------------------------------------------------------------------- /examples/tf/awsconfig/baseconfig/main.tf: -------------------------------------------------------------------------------- 1 | data "aws_region" "this" {} 2 | 3 | data "aws_caller_identity" "this" {} 4 | 5 | data "aws_partition" "current" {} 6 | 7 | locals { 8 | is_global_recorder_region = var.global_resource_collector_region == data.aws_region.this.name 9 | partition = data.aws_partition.current.partition 10 | } 11 | 12 | resource "aws_config_configuration_recorder" "recorder" { 13 | name = "telophase-configuration-recorder" 14 | role_arn = var.iam_role 15 | 16 | recording_group { 17 | all_supported = true 18 | include_global_resource_types = local.is_global_recorder_region 19 | } 20 | } 21 | 22 | 23 | resource "aws_config_delivery_channel" "this" { 24 | name = "telophase-config-delivery-channel" 25 | s3_bucket_name = var.bucket_name 26 | 27 | depends_on = [aws_config_configuration_recorder.recorder] 28 | } 29 | -------------------------------------------------------------------------------- /tests/tf/scp-test/main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "us-east-1" 3 | } 4 | 5 | data "aws_iam_policy_document" "restrict_regions" { 6 | statement { 7 | sid = "RegionRestriction" 8 | effect = "Deny" 9 | actions = ["*"] 10 | resources = ["*"] 11 | 12 | condition { 13 | test = "StringNotEquals" 14 | variable = "aws:RequestedRegion" 15 | 16 | values = [ 17 | "us-east-1" 18 | ] 19 | } 20 | } 21 | } 22 | 23 | resource "aws_organizations_policy" "restrict_regions" { 24 | name = "restrict_regions" 25 | description = "Deny all regions except US East 1." 26 | content = data.aws_iam_policy_document.restrict_regions.json 27 | } 28 | 29 | resource "aws_organizations_policy_attachment" "restrict_regions" { 30 | policy_id = aws_organizations_policy.restrict_regions.id 31 | target_id = telophase.organization_unit_id 32 | } 33 | -------------------------------------------------------------------------------- /mintlifydocs/features/Manage-AWS-Organizations.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Manage AWS Organizations' 3 | icon: 'sitemap' 4 | --- 5 | 6 | `telophasecli` allows you to define your AWS Organization Unit structure via code. 7 | 8 | ## Example `organization.yml` 9 | ```yml 10 | Organization: 11 | OrganizationUnits: 12 | - Name: Production 13 | Accounts: 14 | - Email: safety+firmware@example.app 15 | AccountName: Safety Firmware 16 | - Email: safety+ingestion@example.app 17 | AccountName: Safety Ingestion Team 18 | - Name: Development 19 | Accounts: 20 | - Email: eng1@example.app 21 | AccountName: Engineer A 22 | 23 | ``` 24 | 25 | The configuration above will create 26 | 1) `Production` Organizational Unit 27 | 2) `Safety Firmware` and `Safety Ingestion Team` accounts in the `Production` OU 28 | 3) `Development` Organizational Unit 29 | 4) `Engineer A` account in the `Development` OU 30 | 31 | These accounts can be provisioned via `telophasecli account deploy` -------------------------------------------------------------------------------- /examples/tf/configaggregator/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_config_configuration_aggregator" "organization" { 2 | depends_on = [aws_iam_role_policy_attachment.organization] 3 | 4 | name = "telophase-config-aggregator" # Required 5 | 6 | organization_aggregation_source { 7 | all_regions = true 8 | role_arn = aws_iam_role.organization.arn 9 | } 10 | } 11 | 12 | data "aws_iam_policy_document" "assume_role" { 13 | statement { 14 | effect = "Allow" 15 | 16 | principals { 17 | type = "Service" 18 | identifiers = ["config.amazonaws.com"] 19 | } 20 | 21 | actions = ["sts:AssumeRole"] 22 | } 23 | } 24 | 25 | resource "aws_iam_role" "organization" { 26 | name = "telophase-config-aggregator-role" 27 | assume_role_policy = data.aws_iam_policy_document.assume_role.json 28 | } 29 | 30 | resource "aws_iam_role_policy_attachment" "organization" { 31 | role = aws_iam_role.organization.name 32 | policy_arn = "arn:aws:iam::aws:policy/service-role/AWSConfigRoleForOrganizations" 33 | } 34 | -------------------------------------------------------------------------------- /tests/cdk/dynamo/go.mod: -------------------------------------------------------------------------------- 1 | module dynamo 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview 7 | github.com/aws/aws-cdk-go/awscdk/v2 v2.135.0 8 | github.com/aws/constructs-go/constructs/v10 v10.3.0 9 | github.com/aws/jsii-runtime-go v1.96.0 10 | ) 11 | 12 | require ( 13 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 14 | github.com/aws/constructs-go/constructs/v3 v3.4.232 // indirect 15 | github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.202 // indirect 16 | github.com/cdklabs/awscdk-asset-kubectl-go/kubectlv20/v2 v2.1.2 // indirect 17 | github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv6/v2 v2.0.1 // indirect 18 | github.com/fatih/color v1.16.0 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/yuin/goldmark v1.4.13 // indirect 22 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 23 | golang.org/x/mod v0.16.0 // indirect 24 | golang.org/x/sys v0.18.0 // indirect 25 | golang.org/x/tools v0.19.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /mintlifydocs/features/scps.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Service Control Policies' 3 | icon: 'police-box' 4 | --- 5 | 6 | Service Control Policies defined in Terraform can be applied to Organization Units and Accounts in `organization.yml`. 7 | 8 | ## Example 9 | ```yml 10 | Organization: 11 | AccountGroups: 12 | - Name: Production 13 | ServiceControlPolicies: 14 | - Name: DisableEURegion # This SCP will be applied to the `Production` Organization Unit. 15 | Path: path/to/scp 16 | Type: Terraform 17 | Accounts: 18 | - Email: safety+firmware@example.app 19 | AccountName: Safety Firmware 20 | - Email: safety+ingestion@example.app 21 | AccountName: Safety Ingestion Team 22 | - Name: Development 23 | Accounts: 24 | - Email: eng1@example.app 25 | AccountName: Engineer A 26 | ServiceControlPolicies: 27 | - Name: DisableGPUInstances # This SCP will be applied to `Engineer A` account only. 28 | Path: path/to/scp 29 | Type: Terraform 30 | ``` 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24rc1 2 | 3 | WORKDIR /telophasecli 4 | 5 | COPY ./ /telophasecli 6 | 7 | RUN go mod download 8 | 9 | # run go install instead so that we can reuse the PATH to from the baseto from 10 | # the base image. 11 | RUN go install 12 | 13 | RUN apt-get update 14 | # We need npm to install the CDK and terraform 15 | RUN apt-get install -y npm 16 | RUN npm install -g aws-cdk 17 | 18 | # Install Terraform https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli 19 | RUN apt-get install -y gnupg software-properties-common 20 | RUN wget -O- https://apt.releases.hashicorp.com/gpg | \ 21 | gpg --dearmor | \ 22 | tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null 23 | 24 | RUN gpg --no-default-keyring \ 25 | --keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg \ 26 | --fingerprint 27 | 28 | RUN echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \ 29 | https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \ 30 | tee /etc/apt/sources.list.d/hashicorp.list 31 | 32 | RUN apt-get update 33 | RUN apt-get install terraform 34 | -------------------------------------------------------------------------------- /examples/cdk/tf-s3-backend/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aws/aws-cdk-go/awscdk" 7 | "github.com/aws/aws-cdk-go/awscdk/awss3" 8 | "github.com/aws/jsii-runtime-go" 9 | ) 10 | 11 | type TerraformStateBucketStackProps struct { 12 | awscdk.StackProps 13 | } 14 | 15 | func NewTerraformStateBucketStack(scope awscdk.Construct, id string, props *TerraformStateBucketStackProps) awscdk.Stack { 16 | var sprops awscdk.StackProps 17 | if props != nil { 18 | sprops = props.StackProps 19 | } 20 | stack := awscdk.NewStack(scope, &id, &sprops) 21 | 22 | accountId := stack.Node().TryGetContext(jsii.String("telophaseAccountId")).(string) 23 | awss3.NewBucket(stack, jsii.String("TerraformStateBucket"), &awss3.BucketProps{ 24 | Versioned: jsii.Bool(true), 25 | BucketName: jsii.String(fmt.Sprintf("tfstate-%s", accountId)), 26 | }) 27 | 28 | return stack 29 | } 30 | 31 | func main() { 32 | app := awscdk.NewApp(nil) 33 | 34 | NewTerraformStateBucketStack(app, "TerraformStateBucketStackExample", &TerraformStateBucketStackProps{ 35 | awscdk.StackProps{ 36 | Env: env(), 37 | }, 38 | }) 39 | 40 | app.Synth(nil) 41 | } 42 | 43 | func env() *awscdk.Environment { 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | # run only against tags 7 | tags: 8 | - "v*.*.*" 9 | 10 | permissions: 11 | contents: write 12 | # packages: write 13 | # issues: write 14 | 15 | jobs: 16 | goreleaser: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: stable 27 | # More assembly might be required: Docker logins, GPG, etc. 28 | # It all depends on your needs. 29 | - name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v5 31 | with: 32 | # either 'goreleaser' (default) or 'goreleaser-pro' 33 | distribution: goreleaser 34 | # 'latest', 'nightly', or a semver 35 | version: latest 36 | args: release --clean 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} 39 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 40 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} -------------------------------------------------------------------------------- /lib/awssts/awssts.go: -------------------------------------------------------------------------------- 1 | package awssts 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/aws/aws-sdk-go/service/sts" 7 | "github.com/santiago-labs/telophasecli/lib/localstack" 8 | ) 9 | 10 | func SetEnvironCreds(currEnv []string, 11 | creds *sts.Credentials, 12 | awsRegion *string) []string { 13 | var newEnv []string 14 | 15 | for _, e := range currEnv { 16 | if creds != nil { 17 | if strings.Contains(e, "AWS_ACCESS_KEY_ID=") || 18 | strings.Contains(e, "AWS_SECRET_ACCESS_KEY=") || 19 | strings.Contains(e, "AWS_SESSION_TOKEN=") { 20 | continue 21 | } 22 | } 23 | 24 | if awsRegion != nil && strings.Contains(e, "AWS_REGION=") { 25 | continue 26 | } 27 | 28 | newEnv = append(newEnv, e) 29 | } 30 | 31 | if creds != nil { 32 | newEnv = append(newEnv, 33 | "AWS_ACCESS_KEY_ID="+*creds.AccessKeyId, 34 | "AWS_SECRET_ACCESS_KEY="+*creds.SecretAccessKey, 35 | "AWS_SESSION_TOKEN="+*creds.SessionToken, 36 | ) 37 | } 38 | 39 | if awsRegion != nil { 40 | newEnv = append(newEnv, *awsRegion) 41 | } 42 | 43 | if localstack.UsingLocalStack() { 44 | // We need to set this to true for localstack so that tflocal will use 45 | // the AWS key for the proper account. 46 | newEnv = append(newEnv, "CUSTOMIZE_ACCESS_KEY=true") 47 | } 48 | 49 | return newEnv 50 | } 51 | -------------------------------------------------------------------------------- /examples/tf/buildkite/organization.yml: -------------------------------------------------------------------------------- 1 | Organization: 2 | Name: root 3 | OrganizationUnits: 4 | - Name: Security 5 | Accounts: 6 | - Email: aws+audit@example.com 7 | AccountName: Audit 8 | 9 | - Email: aws+logs@example.com 10 | AccountName: Log Archive 11 | 12 | - Name: ProductionTenants 13 | Accounts: 14 | - Email: ethan+example@example.com 15 | AccountName: example-app 16 | - Path: ./blueprint_terraform 17 | Type: Terraform 18 | Tags: 19 | - "production" 20 | 21 | - Email: ethan+derp@example.com 22 | AccountName: example-app-eu 23 | - Path: ./blueprint_terraform 24 | Type: Terraform 25 | Tags: 26 | - "production" 27 | 28 | - Name: Team SRE 29 | Accounts: 30 | - Email: aws+sre1@example.com 31 | AccountName: aws-SRE 32 | Tags: 33 | - "sre" 34 | AssumeRoleOverride: "TelophaseAdminAccess" 35 | 36 | - Email: aws+sre2@example.com 37 | AccountName: aws-SRE2 38 | Tags: 39 | - "sre" 40 | AssumeRoleOverride: "TelophaseAdminAccess" 41 | 42 | -------------------------------------------------------------------------------- /examples/organization-config-everywhere.yml: -------------------------------------------------------------------------------- 1 | Organization: 2 | # All OUs/accounts will inherit these stacks and apply to every account. 3 | Stacks: 4 | # This stack creates an S3 bucket and dynamo DB table in every account. 5 | - Type: "Cloudformation" 6 | Path: "cloudformation/s3/bucket.yml" 7 | Name: "s3-remote-state-terraform" 8 | CloudformationCapabilities: 9 | - "CAPABILITY_NAMED_IAM" 10 | 11 | # This stack enables AWS config in every region in every account. 12 | - Type: "Cloudformation" 13 | - Type: "Terraform" 14 | Path: "tf/awsconfig" 15 | Name: "AWS-Config-Every-Region" 16 | 17 | OrganizationUnits: 18 | - Name: Security 19 | Accounts: 20 | - Email: example+audit@example.com 21 | AccountName: Audit 22 | # This account will have config and config-multiaccountsetup delegated. 23 | DelegatedAdministratorServices: 24 | - "config.amazonaws.com" 25 | - "config-multiaccountsetup.amazonaws.com" 26 | Stacks: 27 | # This stack creates the aggregator for the organization in the delegated admin account. 28 | - Type: "Terraform" 29 | Path: "tf/configaggregator" 30 | Name: "aggregator" 31 | 32 | - Email: example+logarchive@example.com 33 | AccountName: Log Archive 34 | -------------------------------------------------------------------------------- /examples/tf/awsconfig/bucket.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "bucket" { 2 | bucket = var.bucket_name 3 | 4 | tags = merge(var.tags, { 5 | "Name" = var.bucket_name 6 | }) 7 | force_destroy = true 8 | } 9 | 10 | resource "aws_s3_bucket_server_side_encryption_configuration" "encryption" { 11 | bucket = aws_s3_bucket.bucket.id 12 | 13 | rule { 14 | apply_server_side_encryption_by_default { 15 | sse_algorithm = "aws:kms" 16 | } 17 | } 18 | } 19 | 20 | resource "aws_s3_bucket_public_access_block" "block" { 21 | bucket = aws_s3_bucket.bucket.id 22 | block_public_acls = true 23 | block_public_policy = true 24 | ignore_public_acls = true 25 | restrict_public_buckets = true 26 | } 27 | 28 | 29 | resource "aws_s3_bucket_versioning" "versioning_example" { 30 | bucket = aws_s3_bucket.bucket.id 31 | versioning_configuration { 32 | status = "Enabled" 33 | } 34 | } 35 | 36 | resource "aws_s3_bucket_lifecycle_configuration" "example" { 37 | bucket = aws_s3_bucket.bucket.id 38 | 39 | rule { 40 | id = "expire-old-logs" 41 | status = "Enabled" 42 | 43 | transition { 44 | days = 90 45 | storage_class = "GLACIER" 46 | } 47 | 48 | noncurrent_version_expiration { 49 | noncurrent_days = 120 50 | } 51 | 52 | expiration { 53 | # Default to 7 years 54 | days = 2555 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/santiago-labs/telophasecli 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.46.0 7 | github.com/fatih/color v1.15.0 8 | github.com/google/go-cmp v0.6.0 9 | github.com/google/uuid v1.4.0 10 | github.com/pkg/errors v0.9.1 11 | github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 12 | github.com/rivo/tview v0.0.0-20231031172508-2dfe06011790 13 | github.com/samsarahq/go/oops v0.0.0-20220211150445-4b291d6feac4 14 | github.com/spf13/cobra v1.7.0 15 | github.com/stretchr/testify v1.8.4 16 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 17 | gopkg.in/yaml.v3 v3.0.1 18 | ) 19 | 20 | require ( 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/gdamore/encoding v1.0.0 // indirect 23 | github.com/gdamore/tcell/v2 v2.6.0 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/jmespath/go-jmespath v0.4.0 // indirect 26 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 27 | github.com/mattn/go-colorable v0.1.13 // indirect 28 | github.com/mattn/go-isatty v0.0.19 // indirect 29 | github.com/mattn/go-runewidth v0.0.14 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | github.com/rivo/uniseg v0.4.3 // indirect 32 | github.com/spf13/pflag v1.0.5 // indirect 33 | golang.org/x/sys v0.15.0 // indirect 34 | golang.org/x/term v0.15.0 // indirect 35 | golang.org/x/text v0.14.0 // indirect 36 | gopkg.in/yaml.v2 v2.4.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /examples/tf/awsconfig/iam.tf: -------------------------------------------------------------------------------- 1 | data "aws_iam_policy_document" "iam" { 2 | statement { 3 | effect = "Allow" 4 | actions = ["s3:PutObject"] 5 | resources = [ 6 | "${aws_s3_bucket.bucket.arn}/*", 7 | ] 8 | condition { 9 | test = "StringEquals" 10 | variable = "s3:x-amz-acl" 11 | values = ["bucket-owner-full-control"] 12 | } 13 | } 14 | statement { 15 | effect = "Allow" 16 | actions = ["s3:GetBucketAcl"] 17 | resources = [ 18 | aws_s3_bucket.bucket.arn, 19 | ] 20 | } 21 | } 22 | 23 | resource "aws_iam_policy" "iam" { 24 | name_prefix = "telophase-config-role" 25 | policy = data.aws_iam_policy_document.iam.json 26 | } 27 | 28 | data "aws_iam_policy_document" "assume" { 29 | statement { 30 | effect = "Allow" 31 | actions = ["sts:AssumeRole"] 32 | 33 | principals { 34 | type = "Service" 35 | identifiers = ["config.amazonaws.com"] 36 | } 37 | } 38 | } 39 | 40 | resource "aws_iam_role" "iam" { 41 | name_prefix = "TelophaseConfigRole" 42 | assume_role_policy = data.aws_iam_policy_document.assume.json 43 | } 44 | 45 | resource "aws_iam_role_policy_attachment" "awsconfig_managed_policy" { 46 | role = aws_iam_role.iam.name 47 | policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AWS_ConfigRole" 48 | } 49 | 50 | 51 | resource "aws_iam_role_policy_attachment" "iam" { 52 | role = aws_iam_role.iam.name 53 | policy_arn = aws_iam_policy.iam.arn 54 | } 55 | -------------------------------------------------------------------------------- /cmd/auth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | "runtime" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | const TELOPHASE_URL = "https://app.telophase.dev" 13 | 14 | func init() { 15 | rootCmd.AddCommand(authCommand) 16 | } 17 | 18 | func isValidAuthArg(arg string) bool { 19 | switch arg { 20 | case "signup": 21 | return true 22 | default: 23 | return false 24 | } 25 | } 26 | 27 | var authCommand = &cobra.Command{ 28 | Use: "auth", 29 | Short: "auth - Signup for Telophase (Optional, but we would love your feedback!)", 30 | Args: func(cmd *cobra.Command, args []string) error { 31 | if len(args) < 1 { 32 | return errors.New("requires at least one arg") 33 | } 34 | if isValidAuthArg(args[0]) { 35 | return nil 36 | } 37 | return fmt.Errorf("invalid color specified: %s", args[0]) 38 | }, 39 | Run: func(cmd *cobra.Command, args []string) { 40 | if args[0] == "signup" { 41 | if err := openSignup(); err != nil { 42 | panic(fmt.Sprintf("error opening signup page: %s. Please visit https://app.telophase.dev", err)) 43 | } 44 | } 45 | }, 46 | } 47 | 48 | // https://gist.github.com/sevkin/9798d67b2cb9d07cb05f89f14ba682f8 49 | func openSignup() error { 50 | var cmd string 51 | var args []string 52 | 53 | switch runtime.GOOS { 54 | case "windows": 55 | cmd = "cmd" 56 | args = []string{"/c", "start"} 57 | case "darwin": 58 | cmd = "open" 59 | default: // "linux", "freebsd", "openbsd", "netbsd" 60 | cmd = "xdg-open" 61 | } 62 | args = append(args, TELOPHASE_URL) 63 | return exec.Command(cmd, args...).Start() 64 | } 65 | -------------------------------------------------------------------------------- /examples/localstack/organization.yml: -------------------------------------------------------------------------------- 1 | Organization: 2 | Name: root 3 | OrganizationUnits: 4 | - Name: ProductionTenants 5 | Stacks: 6 | # This stack provisions an S3 bucket to be used for teraform remote 7 | # state for every production tenant. 8 | - Type: "CDK" 9 | Path: "./s3-remote-state" 10 | - Type: "Terraform" 11 | Path: "./tf/ci_iam" 12 | Name: "Default IAM Roles for CI" 13 | - Type: "Terraform" 14 | Path: "./tf/workspace" 15 | Region: "eu-west-1" 16 | Workspace: "${telophase.account_id}_${telophase.region}" 17 | Tags: 18 | - "production" 19 | Accounts: 20 | - Email: production+us0@example.com 21 | AccountName: US0 22 | - Email: production+us1@example.com 23 | AccountName: US1 24 | - Email: production+us2@example.com 25 | AccountName: US2 26 | - Email: production+us3@example.com 27 | AccountName: US3 28 | - Name: Development 29 | Stacks: 30 | # This stack provisions an S3 bucket to be used for teraform remote 31 | # state for every production tenant. 32 | - Type: "CDK" 33 | Path: "./s3-remote-state" 34 | Tags: 35 | - "dev" 36 | Accounts: 37 | - Email: alice@example.com 38 | AccountName: Alice 39 | - Email: bob@example.com 40 | AccountName: Bob 41 | - Email: ethan@example.com 42 | AccountName: Ethan 43 | 44 | -------------------------------------------------------------------------------- /examples/localstack/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | print_red() { 6 | echo -e "\033[0;31m$1\033[0m" 7 | } 8 | 9 | print_green() { 10 | echo -e "\033[0;32m$1\033[0m" 11 | } 12 | 13 | if command -v brew &> /dev/null 14 | then 15 | echo "Brew is installed, proceeding with awscli and terraform installation." 16 | brew install awscli awscli-local terraform terraform-local localstack/tap/localstack-cli 17 | else 18 | print_red "Brew is not installed please install awscli and terraform manually" 19 | exit 1 20 | fi 21 | 22 | if command -v npm &> /dev/null 23 | then 24 | echo "Installing aws-cdk and aws-cdk-local." 25 | npm install -g aws-cdk aws-cdk-local 26 | else 27 | print_red "npm is not installed, please install it and then return" 28 | exit 1 29 | fi 30 | 31 | if command -v go &> /dev/null 32 | then 33 | echo "Installing telophasecli..." 34 | go install github.com/santiago-labs/telophasecli@latest 35 | else 36 | print_red "go is not installed. Please install the language then return." 37 | exit 1 38 | fi 39 | 40 | localstack start -d 41 | 42 | # Pre-create tf-test-state table to avoid concurrency bug in localstack. 43 | aws dynamodb create-table --table-name tf-test-state \ 44 | --attribute-definitions AttributeName=id,AttributeType=S \ 45 | --key-schema AttributeName=id,KeyType=HASH \ 46 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ 47 | --endpoint-url http://localhost:4566 48 | 49 | awslocal organizations create-organization --feature-set ALL 50 | 51 | print_green "Setup complete! You can now run telophasecli :)" 52 | -------------------------------------------------------------------------------- /lib/awssess/awssess.go: -------------------------------------------------------------------------------- 1 | package awssess 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/aws/session" 9 | "github.com/aws/aws-sdk-go/service/sts" 10 | "github.com/samsarahq/go/oops" 11 | ) 12 | 13 | func DefaultSession(cfgs ...*aws.Config) (*session.Session, error) { 14 | if os.Getenv("LOCALSTACK") != "" { 15 | cfg := aws.NewConfig() 16 | cfg.Endpoint = aws.String("http://localhost:4566") 17 | cfgs = append(cfgs, cfg) 18 | } 19 | 20 | sess, err := session.NewSession(cfgs...) 21 | if err != nil { 22 | return nil, oops.Wrapf(err, "new session") 23 | } 24 | return sess, nil 25 | } 26 | 27 | func AssumeRole(svc *sts.STS, input *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) { 28 | if os.Getenv("LOCALSTACK") != "" { 29 | // Localstack doesn't handle IAM checks so let everything through. 30 | return &sts.AssumeRoleOutput{ 31 | Credentials: &sts.Credentials{ 32 | // The accessKeyId needs to equal the target accountID for operations to happen on multiple accounts. 33 | 34 | AccessKeyId: aws.String(RoleARNToAccountID(*input.RoleArn)), 35 | SecretAccessKey: aws.String("fake"), 36 | SessionToken: aws.String("fake"), 37 | }, 38 | }, nil 39 | } 40 | 41 | result, err := svc.AssumeRole(input) 42 | if err != nil { 43 | return nil, oops.Wrapf(err, "assume role") 44 | } 45 | return result, nil 46 | } 47 | 48 | // RoleARN to accountID. 49 | func RoleARNToAccountID(roleARN string) string { 50 | parts := strings.Split(roleARN, ":") 51 | if len(parts) < 4 { 52 | return "" 53 | } 54 | 55 | return parts[4] 56 | } 57 | -------------------------------------------------------------------------------- /cmd/iac.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/samsarahq/go/oops" 9 | "github.com/santiago-labs/telophasecli/cmd/runner" 10 | "github.com/santiago-labs/telophasecli/resource" 11 | "github.com/santiago-labs/telophasecli/resourceoperation" 12 | ) 13 | 14 | func runIAC( 15 | ctx context.Context, 16 | consoleUI runner.ConsoleUI, 17 | cmd int, 18 | accts []resource.Account, 19 | ) error { 20 | var wg sync.WaitGroup 21 | 22 | var once sync.Once 23 | var retError error 24 | 25 | for i := range accts { 26 | wg.Add(1) 27 | go func(acct resource.Account) { 28 | defer wg.Done() 29 | if !acct.IsProvisioned() { 30 | consoleUI.Print(fmt.Sprintf("skipping account: %s because it hasn't been provisioned yet", acct.AccountName), acct) 31 | return 32 | } 33 | 34 | ops, err := resourceoperation.CollectAccountOps(ctx, consoleUI, cmd, &acct, stacks) 35 | if err != nil { 36 | panic(oops.Wrapf(err, "error collecting account ops for acct: %s", acct.AccountID)) 37 | } 38 | 39 | if len(ops) == 0 { 40 | consoleUI.Print("No stacks to deploy\n", acct) 41 | return 42 | } 43 | 44 | for _, op := range ops { 45 | if err := op.Call(ctx); err != nil { 46 | once.Do(func() { 47 | retError = err 48 | }) 49 | consoleUI.Print(fmt.Sprintf("%v", err), acct) 50 | return 51 | } 52 | } 53 | }(accts[i]) 54 | } 55 | 56 | wg.Wait() 57 | 58 | return retError 59 | } 60 | func contains(e string, s []string) bool { 61 | for _, a := range s { 62 | if a == e { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | -------------------------------------------------------------------------------- /resourceoperation/organization_unit_test.go: -------------------------------------------------------------------------------- 1 | package resourceoperation 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/santiago-labs/telophasecli/resource" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDiffTags(t *testing.T) { 12 | tests := []struct { 13 | description string 14 | input resource.OrganizationUnit 15 | 16 | wantAdded []string 17 | wantRemoved []string 18 | }{ 19 | { 20 | description: "adding basic tag", 21 | input: resource.OrganizationUnit{ 22 | Accounts: []*resource.Account{ 23 | { 24 | AccountName: "mgmt", 25 | Tags: []string{ 26 | "ou=mgmt", 27 | }, 28 | }, 29 | }, 30 | }, 31 | wantAdded: []string{"ou=mgmt"}, 32 | }, 33 | { 34 | description: "removing basic tag", 35 | input: resource.OrganizationUnit{ 36 | Accounts: []*resource.Account{ 37 | { 38 | AccountName: "mgmt", 39 | AWSTags: []string{ 40 | "ou=mgmt", 41 | }, 42 | }, 43 | }, 44 | }, 45 | wantRemoved: []string{"ou=mgmt"}, 46 | }, 47 | { 48 | description: "no tag diff", 49 | input: resource.OrganizationUnit{ 50 | Accounts: []*resource.Account{ 51 | { 52 | AccountName: "mgmt", 53 | Tags: []string{ 54 | "ou=mgmt", 55 | }, 56 | AWSTags: []string{ 57 | "ou=mgmt", 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | } 64 | 65 | for _, tc := range tests { 66 | added, removed := diffTags(tc.input.Accounts[0]) 67 | fmt.Println("added", added) 68 | fmt.Println("removed", removed) 69 | assert.Equal(t, tc.wantAdded, added, "added: "+tc.description) 70 | assert.Equal(t, tc.wantRemoved, removed, "removed: "+tc.description) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /examples/localstack/s3-remote-state/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/aws/aws-cdk-go/awscdk" 8 | "github.com/aws/aws-cdk-go/awscdk/awss3" 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/sts" 12 | "github.com/aws/jsii-runtime-go" 13 | ) 14 | 15 | type TerraformStateBucketStackProps struct { 16 | awscdk.StackProps 17 | } 18 | 19 | func fetchAccountID() string { 20 | cfg := aws.NewConfig() 21 | if os.Getenv("LOCALSTACK") != "" { 22 | cfg.Endpoint = aws.String("http://localhost:4566") 23 | } 24 | sess := session.Must(session.NewSession( 25 | cfg, 26 | )) 27 | svc := sts.New(sess) 28 | result, err := svc.GetCallerIdentity(&sts.GetCallerIdentityInput{}) 29 | if err != nil { 30 | panic(fmt.Sprintf("Failed to get caller identity: %s", err)) 31 | } 32 | 33 | return *result.Account 34 | } 35 | 36 | func NewTerraformStateBucketStack(scope awscdk.Construct, id string, props *TerraformStateBucketStackProps) awscdk.Stack { 37 | var sprops awscdk.StackProps 38 | if props != nil { 39 | sprops = props.StackProps 40 | } 41 | stack := awscdk.NewStack(scope, &id, &sprops) 42 | 43 | awss3.NewBucket(stack, jsii.String("TerraformStateBucket"), &awss3.BucketProps{ 44 | Versioned: jsii.Bool(true), 45 | BucketName: jsii.String(fmt.Sprintf("tfstate-%s", fetchAccountID())), 46 | }) 47 | 48 | return stack 49 | } 50 | 51 | func main() { 52 | app := awscdk.NewApp(nil) 53 | 54 | NewTerraformStateBucketStack(app, "TerraformStateBucketStackExample", &TerraformStateBucketStackProps{ 55 | awscdk.StackProps{ 56 | Env: env(), 57 | }, 58 | }) 59 | 60 | app.Synth(nil) 61 | } 62 | 63 | func env() *awscdk.Environment { 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: release-docker-image 2 | 3 | on: 4 | push: 5 | # run only against tags 6 | tags: 7 | - "v*.*.*" 8 | 9 | env: 10 | AWS_REGION: us-west-2 11 | 12 | jobs: 13 | buildx: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Configure AWS credentials 20 | uses: aws-actions/configure-aws-credentials@v1 21 | with: 22 | aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }} 23 | aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }} 24 | aws-region: ${{ env.AWS_REGION }} 25 | 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Login to Amazon ECR Public 33 | id: login-ecr-public 34 | uses: aws-actions/amazon-ecr-login@v2 35 | with: 36 | registry-type: public 37 | # ECR login has to be done in us-east-1 38 | env: 39 | AWS_REGION: us-east-1 40 | 41 | - name: Build, tag, and push docker image to Amazon ECR Public 42 | env: 43 | REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} 44 | REGISTRY_ALIAS: w0i7g3v8 45 | REPOSITORY: telophase 46 | # ref_name is the tag 47 | IMAGE_TAG: ${{ github.ref_name }} 48 | AWS_REGION: ${{ env.AWS_REGION }} 49 | run: | 50 | docker buildx build \ 51 | --platform linux/arm/v7,linux/arm64/v8,linux/amd64 \ 52 | --tag $REGISTRY/$REGISTRY_ALIAS/$REPOSITORY:$IMAGE_TAG \ 53 | --provenance=false \ 54 | --push . 55 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | before: 10 | hooks: 11 | # You may remove this if you don't use go modules. 12 | - go mod tidy 13 | # you may remove this if you don't need go generate 14 | - go generate ./... 15 | 16 | builds: 17 | - env: 18 | - CGO_ENABLED=0 19 | goos: 20 | - linux 21 | - windows 22 | - darwin 23 | 24 | archives: 25 | - format: tar.gz 26 | # this name template makes the OS and Arch compatible with the results of `uname`. 27 | name_template: >- 28 | {{ .ProjectName }}_ 29 | {{- title .Os }}_ 30 | {{- if eq .Arch "amd64" }}x86_64 31 | {{- else if eq .Arch "386" }}i386 32 | {{- else }}{{ .Arch }}{{ end }} 33 | {{- if .Arm }}v{{ .Arm }}{{ end }} 34 | # use zip for windows archives 35 | format_overrides: 36 | - goos: windows 37 | format: zip 38 | 39 | changelog: 40 | sort: asc 41 | filters: 42 | exclude: 43 | - "^docs:" 44 | - "^test:" 45 | 46 | brews: 47 | - name: telophasecli 48 | description: "Open-Source AWS Control Tower" 49 | homepage: "https://github.com/Santiago-Labs/telophasecli" 50 | license: "GPL-3.0" 51 | 52 | commit_author: 53 | name: dschofie 54 | email: danny@telophase.dev 55 | 56 | repository: 57 | # Repository owner. 58 | owner: Santiago-Labs 59 | # Repository name. 60 | name: homebrew-telophase 61 | branch: main 62 | -------------------------------------------------------------------------------- /cmd/diff.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/santiago-labs/telophasecli/cmd/runner" 10 | "github.com/santiago-labs/telophasecli/resourceoperation" 11 | "golang.org/x/sync/errgroup" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func init() { 17 | rootCmd.AddCommand(diffCmd) 18 | diffCmd.Flags().StringVar(&stacks, "stacks", "", "Filter stacks to deploy") 19 | diffCmd.Flags().StringVar(&tag, "tag", "", "Filter accounts and organization units to deploy with a comma separated list") 20 | diffCmd.Flags().StringVar(&targets, "targets", "", "Filter resource types to deploy. Options: organization, scp, stacks") 21 | diffCmd.Flags().StringVar(&orgFile, "org", "organization.yml", "Path to the organization.yml file") 22 | diffCmd.Flags().BoolVar(&useTUI, "tui", false, "use the TUI for diff") 23 | } 24 | 25 | var diffCmd = &cobra.Command{ 26 | Use: "diff", 27 | Short: "diff - Show accounts to create/update and CDK and/or TF changes for each account.", 28 | Run: func(cmd *cobra.Command, args []string) { 29 | 30 | if err := validateTargets(); err != nil { 31 | log.Fatal("error validating targets err:", err) 32 | } 33 | var consoleUI runner.ConsoleUI 34 | parsedTargets := filterEmptyStrings(strings.Split(targets, ",")) 35 | 36 | var g errgroup.Group 37 | 38 | if useTUI { 39 | consoleUI = runner.NewTUI() 40 | g.Go(func() error { 41 | return ProcessOrgEndToEnd(consoleUI, resourceoperation.Diff, parsedTargets) 42 | }) 43 | } else { 44 | consoleUI = runner.NewSTDOut() 45 | if err := ProcessOrgEndToEnd(consoleUI, resourceoperation.Diff, parsedTargets); err != nil { 46 | fmt.Println(err) 47 | os.Exit(1) 48 | } 49 | } 50 | 51 | consoleUI.Start() 52 | if err := g.Wait(); err != nil { 53 | os.Exit(1) 54 | } 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /mintlifydocs/mint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://mintlify.com/schema.json", 3 | "name": "Telophase", 4 | "logo": { 5 | "dark": "/logo/logo.png", 6 | "light": "/logo/light-telophase.svg" 7 | }, 8 | "favicon": "/favicon.png", 9 | "colors": { 10 | "primary": "#000000", 11 | "light": "#07C983", 12 | "dark": "#0D9373", 13 | "anchors": { 14 | "from": "#0D9373", 15 | "to": "#07C983" 16 | } 17 | }, 18 | "topbarLinks": [ 19 | { 20 | "name": "Contact Support", 21 | "url": "mailto:support@telophase.dev" 22 | } 23 | ], 24 | "topbarCtaButton": { 25 | "type": "github", 26 | "url": "https://github.com/Santiago-Labs/telophasecli" 27 | }, 28 | "navigation": [ 29 | { 30 | "group": "Get Started", 31 | "pages": [ 32 | "introduction", 33 | "quickstart" 34 | ] 35 | }, 36 | { 37 | "group": "Features", 38 | "pages": [ 39 | "features/Manage-AWS-Organizations", 40 | "features/Assign-IaC-Blueprints-To-Accounts", 41 | "features/scps", 42 | "features/tui", 43 | "features/localstack" 44 | ] 45 | }, 46 | { 47 | "group": "Configuration", 48 | "pages": [ 49 | "config/organization" 50 | ] 51 | }, 52 | { 53 | "group": "Commands", 54 | "pages": [ 55 | "commands/diff", 56 | "commands/deploy", 57 | "commands/account-import" 58 | ] 59 | } 60 | ], 61 | "footerSocials": { 62 | "twitter": "https://twitter.com/telophaseHQ", 63 | "github": "https://github.com/Santiago-Labs/telophasecli", 64 | "linkedin": "https://www.linkedin.com/company/95748888" 65 | }, 66 | "modeToggle": { 67 | "default": "light", 68 | "isHidden": true 69 | }, 70 | "analytics": { 71 | "posthog": { 72 | "apiKey": "phc_whf6xThJIHQw6Og5F9dsP3eb7dkrh3N4oZT7nzDhMR0" 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /examples/tf/buildkite/blueprint_terraform/vpc.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "us-east-1" 3 | } 4 | 5 | resource "aws_vpc" "example_vpc" { 6 | cidr_block = "10.0.0.0/16" 7 | enable_dns_support = true 8 | enable_dns_hostnames = true 9 | 10 | tags = { 11 | Name = "ExampleVPC" 12 | } 13 | } 14 | 15 | resource "aws_internet_gateway" "example_igw" { 16 | vpc_id = aws_vpc.example_vpc.id 17 | 18 | tags = { 19 | Name = "ExampleIGW" 20 | } 21 | } 22 | 23 | resource "aws_subnet" "example_public_subnet" { 24 | count = 3 25 | vpc_id = aws_vpc.example_vpc.id 26 | cidr_block = cidrsubnet(aws_vpc.example_vpc.cidr_block, 3, count.index) 27 | map_public_ip_on_launch = true 28 | availability_zone = element(split(",", data.aws_availability_zones.available.names), count.index) 29 | 30 | tags = { 31 | Name = "PublicSubnet-${count.index}" 32 | } 33 | } 34 | 35 | resource "aws_subnet" "example_private_subnet" { 36 | count = 3 37 | vpc_id = aws_vpc.example_vpc.id 38 | cidr_block = cidrsubnet(aws_vpc.example_vpc.cidr_block, 3, count.index + 3) 39 | availability_zone = element(split(",", data.aws_availability_zones.available.names), count.index) 40 | 41 | tags = { 42 | Name = "PrivateSubnet-${count.index}" 43 | } 44 | } 45 | 46 | resource "aws_route_table" "example_public_rt" { 47 | vpc_id = aws_vpc.example_vpc.id 48 | 49 | route { 50 | cidr_block = "0.0.0.0/0" 51 | gateway_id = aws_internet_gateway.example_igw.id 52 | } 53 | 54 | tags = { 55 | Name = "PublicRouteTable" 56 | } 57 | } 58 | 59 | resource "aws_route_table_association" "example_public_rta" { 60 | count = length(aws_subnet.example_public_subnet.*.id) 61 | subnet_id = aws_subnet.example_public_subnet.*.id[count.index] 62 | route_table_id = aws_route_table.example_public_rt.id 63 | } 64 | 65 | # Fetching availability zones 66 | data "aws_availability_zones" "available" {} 67 | 68 | -------------------------------------------------------------------------------- /examples/cloudformation/dynamo/table.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | 3 | Description: 'AWS CloudFormation Sample Template DynamoDB_Table: This template demonstrates the creation of a DynamoDB table. **WARNING** This template creates an Amazon DynamoDB table. You will be billed for the AWS resources used if you create a stack from this template.' 4 | 5 | Metadata: 6 | License: Apache-2.0 7 | 8 | Parameters: 9 | HashKeyElementName: 10 | Description: HashType PrimaryKey Name 11 | Type: String 12 | AllowedPattern: '[a-zA-Z0-9]*' 13 | MinLength: "1" 14 | MaxLength: "2048" 15 | ConstraintDescription: must contain only alphanumberic characters 16 | 17 | HashKeyElementType: 18 | Description: HashType PrimaryKey Type 19 | Type: String 20 | Default: S 21 | AllowedPattern: '[S|N]' 22 | MinLength: "1" 23 | MaxLength: "1" 24 | ConstraintDescription: must be either S or N 25 | 26 | ReadCapacityUnits: 27 | Description: Provisioned read throughput 28 | Type: Number 29 | Default: "5" 30 | MinValue: "5" 31 | MaxValue: "10000" 32 | ConstraintDescription: must be between 5 and 10000 33 | 34 | WriteCapacityUnits: 35 | Description: Provisioned write throughput 36 | Type: Number 37 | Default: "10" 38 | MinValue: "5" 39 | MaxValue: "10000" 40 | ConstraintDescription: must be between 5 and 10000 41 | 42 | Resources: 43 | myDynamoDBTable: 44 | Type: AWS::DynamoDB::Table 45 | Properties: 46 | AttributeDefinitions: 47 | - AttributeName: !Ref HashKeyElementName 48 | AttributeType: !Ref HashKeyElementType 49 | KeySchema: 50 | - AttributeName: !Ref HashKeyElementName 51 | KeyType: HASH 52 | ProvisionedThroughput: 53 | ReadCapacityUnits: !Ref ReadCapacityUnits 54 | WriteCapacityUnits: !Ref WriteCapacityUnits 55 | 56 | Outputs: 57 | TableName: 58 | Description: Table name of the newly created DynamoDB table 59 | Value: !Ref myDynamoDBTable 60 | 61 | -------------------------------------------------------------------------------- /lib/telophase/account.go: -------------------------------------------------------------------------------- 1 | package telophase 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | func ValidTelophaseToken(token string) bool { 13 | if token == "" || 14 | token == "ignore" { 15 | return false 16 | } 17 | 18 | return true 19 | } 20 | 21 | func UpsertAccount(accountID string, accountName string) { 22 | token := os.Getenv("TELOPHASE_TOKEN") 23 | if ValidTelophaseToken(token) { 24 | reqBody, _ := json.Marshal(map[string]string{ 25 | "account_id": accountID, 26 | "name": accountName, 27 | }) 28 | client := &http.Client{} 29 | req, _ := http.NewRequest("POST", "https://api.telophase.dev/cloudAccount", bytes.NewBuffer(reqBody)) 30 | req.Header.Set("Content-Type", "application/json") 31 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 32 | resp, err := client.Do(req) 33 | if err != nil { 34 | fmt.Printf("error creating account in telophase: %s\n", err) 35 | } 36 | if resp.StatusCode != 200 { 37 | defer resp.Body.Close() 38 | body, err := io.ReadAll(resp.Body) 39 | if err != nil { 40 | fmt.Println(err) 41 | } 42 | fmt.Printf("error creating account in telophase: %s\n", string(body)) 43 | } 44 | } 45 | } 46 | 47 | func RecordDeploy(accountID string, accountName string) { 48 | token := os.Getenv("TELOPHASE_TOKEN") 49 | if ValidTelophaseToken(token) { 50 | reqBody, _ := json.Marshal(map[string]string{}) 51 | client := &http.Client{} 52 | req, _ := http.NewRequest("PATCH", fmt.Sprintf("https://api.telophase.dev/cloudAccount/%s", accountID), bytes.NewBuffer(reqBody)) 53 | req.Header.Set("Content-Type", "application/json") 54 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 55 | resp, err := client.Do(req) 56 | if err != nil { 57 | fmt.Printf("error creating account in telophase: %s\n", err) 58 | } 59 | if resp.StatusCode != 200 { 60 | defer resp.Body.Close() 61 | body, err := io.ReadAll(resp.Body) 62 | if err != nil { 63 | fmt.Println(err) 64 | } 65 | fmt.Printf("error creating account in telophase: %s\n", string(body)) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /cmd/deploy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/santiago-labs/telophasecli/cmd/runner" 10 | "github.com/santiago-labs/telophasecli/resourceoperation" 11 | "golang.org/x/sync/errgroup" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | tag string 18 | targets string 19 | stacks string 20 | allowDeleteAccount bool 21 | 22 | // TUI 23 | useTUI bool 24 | ) 25 | 26 | func init() { 27 | rootCmd.AddCommand(deployCmd) 28 | deployCmd.Flags().StringVar(&stacks, "stacks", "", "Filter stacks to deploy") 29 | deployCmd.Flags().StringVar(&tag, "tag", "", "Filter accounts and organization units to deploy with a comma separated list") 30 | deployCmd.Flags().StringVar(&targets, "targets", "", "Filter resource types to deploy. Options: organization, scp, stacks") 31 | deployCmd.Flags().StringVar(&orgFile, "org", "organization.yml", "Path to the organization.yml file") 32 | deployCmd.Flags().BoolVar(&useTUI, "tui", false, "use the TUI for deploy") 33 | deployCmd.Flags().BoolVar(&allowDeleteAccount, "allow-account-delete", false, "Allow closing an AWS account") 34 | } 35 | 36 | var deployCmd = &cobra.Command{ 37 | Use: "deploy", 38 | Short: "deploy - Deploy a CDK and/or TF stacks to your AWS account(s). Accounts in organization.yml will be created if they do not exist.", 39 | Run: func(cmd *cobra.Command, args []string) { 40 | 41 | if err := validateTargets(); err != nil { 42 | log.Fatal("error validating targets err:", err) 43 | } 44 | var consoleUI runner.ConsoleUI 45 | parsedTargets := filterEmptyStrings(strings.Split(targets, ",")) 46 | var g errgroup.Group 47 | 48 | if useTUI { 49 | consoleUI = runner.NewTUI() 50 | g.Go(func() error { 51 | return ProcessOrgEndToEnd(consoleUI, resourceoperation.Deploy, parsedTargets) 52 | }) 53 | } else { 54 | consoleUI = runner.NewSTDOut() 55 | if err := ProcessOrgEndToEnd(consoleUI, resourceoperation.Deploy, parsedTargets); err != nil { 56 | fmt.Println(err) 57 | os.Exit(1) 58 | } 59 | } 60 | 61 | consoleUI.Start() 62 | if err := g.Wait(); err != nil { 63 | fmt.Println(err) 64 | os.Exit(1) 65 | } 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /tests/cloudformation/table.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | 3 | Description: 'AWS CloudFormation Sample Template DynamoDB_Table: This template demonstrates the creation of a DynamoDB table. **WARNING** This template creates an Amazon DynamoDB table. You will be billed for the AWS resources used if you create a stack from this template.' 4 | 5 | Metadata: 6 | License: Apache-2.0 7 | 8 | Parameters: 9 | HashKeyElementName: 10 | Description: HashType PrimaryKey Name 11 | Type: String 12 | AllowedPattern: '[a-zA-Z0-9]*' 13 | MinLength: "1" 14 | MaxLength: "2048" 15 | ConstraintDescription: must contain only alphanumberic characters 16 | 17 | TableName: 18 | Description: TableName 19 | Type: String 20 | AllowedPattern: '[a-zA-Z0-9]*' 21 | MinLength: "1" 22 | MaxLength: "2048" 23 | ConstraintDescription: must contain only alphanumberic characters 24 | 25 | HashKeyElementType: 26 | Description: HashType PrimaryKey Type 27 | Type: String 28 | Default: S 29 | AllowedPattern: '[S|N]' 30 | MinLength: "1" 31 | MaxLength: "1" 32 | ConstraintDescription: must be either S or N 33 | 34 | ReadCapacityUnits: 35 | Description: Provisioned read throughput 36 | Type: Number 37 | Default: "5" 38 | MinValue: "5" 39 | MaxValue: "10000" 40 | ConstraintDescription: must be between 5 and 10000 41 | 42 | WriteCapacityUnits: 43 | Description: Provisioned write throughput 44 | Type: Number 45 | Default: "10" 46 | MinValue: "5" 47 | MaxValue: "10000" 48 | ConstraintDescription: must be between 5 and 10000 49 | 50 | Resources: 51 | myDynamoDBTable: 52 | Type: AWS::DynamoDB::Table 53 | Properties: 54 | AttributeDefinitions: 55 | - AttributeName: !Ref HashKeyElementName 56 | AttributeType: !Ref HashKeyElementType 57 | KeySchema: 58 | - AttributeName: !Ref HashKeyElementName 59 | KeyType: HASH 60 | ProvisionedThroughput: 61 | ReadCapacityUnits: !Ref ReadCapacityUnits 62 | WriteCapacityUnits: !Ref WriteCapacityUnits 63 | TableName: !Ref TableName 64 | 65 | Outputs: 66 | TableName: 67 | Description: Table name of the newly created DynamoDB table 68 | Value: !Ref myDynamoDBTable 69 | 70 | -------------------------------------------------------------------------------- /examples/localstack/README.mdx: -------------------------------------------------------------------------------- 1 | # Example: Localstack 2 | This example will walk you through using localstack with Telophase to create AWS Accounts and OUs (see: [`organization.yml`](./organization.yml)), and deploy CDK and Terraform to them. 3 | 4 | # Getting Started with Localstack + Telophase 5 | 1. Install Dependencies 6 | ```bash 7 | ./setup.sh 8 | ``` 9 | 10 | 2. Start up Localstack in the background. Ensure your pro license is set. Learn more [here](https://docs.localstack.cloud/getting-started/auth-token/) 11 | ```bash 12 | localstack start -d 13 | ``` 14 | 15 | 3. Localstack Setup 16 | Create your root organization in Localstack 17 | ```bash 18 | awslocal organizations create-organization --feature-set ALL 19 | ``` 20 | 21 | 4. Setup an AWS_REGION if not set 22 | ```bash 23 | export AWS_REGION=us-east-1 24 | ``` 25 | 26 | 5. Deploy Infra in the Accounts 27 | 28 | Telophase will: 29 | 1. Create the accounts and OUs listed in `organization.yml` 30 | 2. A CDK stack will create an S3 bucket for a terraform state will be create in each account. Additionally, a Terraform stack will create a CI deploy role in each account in the `Production` OU. 31 | ```bash 32 | LOCALSTACK=true telophasecli deploy --tui 33 | ``` 34 | 35 | 6. Inspect the accounts using `awslocal` 36 | 37 | Learn how Localstack handles multi-account auth [here](https://docs.localstack.cloud/references/multi-account-setups/) 38 | ```bash 39 | # View the organizations you have created 40 | awslocal organizations list-accounts 41 | 42 | # Each account will have its own tfstate bucket 43 | AWS_ACCESS_KEY_ID=$AN_ORG_FROM_ABOVE awslocal s3 ls 44 | 45 | # New roles will be created in each account 46 | AWS_ACCESS_KEY_ID=$AN_ORG_FROM_ABOVE awslocal iam list-roles 47 | ``` 48 | 49 | ### Common Errors 50 | #### Cannot create preexisting table / Cannot do operations on a non-existent table 51 | These are triggered by a concurrency bug in localstack. You can resolve it by pre-creating the `tf-test-state` table and running Telophase again: 52 | ```bash 53 | aws dynamodb create-table --table-name tf-test-state \ 54 | --attribute-definitions AttributeName=id,AttributeType=S \ 55 | --key-schema AttributeName=id,KeyType=HASH \ 56 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ 57 | --endpoint-url http://localhost:4566 58 | 59 | LOCALSTACK=true telophasecli deploy 60 | ``` -------------------------------------------------------------------------------- /lib/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "github.com/google/uuid" 9 | "github.com/pkg/errors" 10 | "github.com/posthog/posthog-go" 11 | ) 12 | 13 | type client struct { 14 | posthog.Client 15 | } 16 | 17 | var c *client 18 | 19 | func Init() { 20 | if !isEnabled() { 21 | return 22 | } 23 | 24 | ph, err := posthog.NewWithConfig( 25 | "phc_whf6xThJIHQw6Og5F9dsP3eb7dkrh3N4oZT7nzDhMR0", 26 | posthog.Config{ 27 | Endpoint: "https://app.posthog.com", 28 | }, 29 | ) 30 | if err != nil { 31 | fmt.Fprintln(os.Stderr, errors.Wrap(err, "fail to init telemetry")) 32 | } 33 | 34 | c = &client{ph} 35 | } 36 | 37 | func isEnabled() bool { 38 | return false 39 | } 40 | 41 | func Close() { 42 | if c == nil || !isEnabled() { 43 | return 44 | } 45 | 46 | err := c.Close() 47 | if err != nil { 48 | fmt.Fprintln(os.Stderr, errors.Wrap(err, "fail to close telemetry")) 49 | } 50 | } 51 | 52 | type Event string 53 | 54 | const EventRunCommand Event = "telophasecli" 55 | 56 | func Push(event Event, properties posthog.Properties) { 57 | if c == nil || !isEnabled() { 58 | return 59 | } 60 | 61 | if err := c.Enqueue(posthog.Capture{ 62 | DistinctId: getDistinctId(), 63 | Event: string(event), 64 | Properties: properties, 65 | }); err != nil { 66 | fmt.Fprintln(os.Stderr, errors.Wrap(err, "fail to enqueue telemetry")) 67 | } 68 | } 69 | 70 | func RegisterCommand() { 71 | command := "" 72 | if len(os.Args) > 1 { 73 | command = os.Args[1] 74 | } 75 | 76 | Push( 77 | EventRunCommand, 78 | map[string]interface{}{ 79 | "$set": map[string]interface{}{ 80 | "command": command, 81 | }, 82 | }, 83 | ) 84 | } 85 | 86 | func getDistinctId() string { 87 | id := uuid.NewString() 88 | 89 | homedir, err := os.UserHomeDir() 90 | if err != nil { 91 | return id 92 | } 93 | 94 | telpohaseDir := path.Join(homedir, ".telophase") 95 | err = os.MkdirAll(telpohaseDir, os.ModePerm) 96 | if err != nil { 97 | return id 98 | } 99 | 100 | fpath := path.Join(telpohaseDir, "userid") 101 | 102 | bs, err := os.ReadFile(fpath) 103 | if err != nil { 104 | os.WriteFile(fpath, []byte(id), 0644) 105 | return id 106 | } 107 | 108 | prevId, err := uuid.ParseBytes(bs) 109 | if err != nil { 110 | os.WriteFile(fpath, []byte(id), 0644) 111 | return id 112 | } 113 | 114 | return prevId.String() 115 | } 116 | -------------------------------------------------------------------------------- /cmd/runner/stdout.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | "sync" 8 | 9 | "github.com/santiago-labs/telophasecli/lib/colors" 10 | "github.com/santiago-labs/telophasecli/resource" 11 | ) 12 | 13 | func NewSTDOut() ConsoleUI { 14 | return &stdOut{ 15 | coloredId: make(map[string]string), 16 | lock: sync.Mutex{}, 17 | } 18 | } 19 | 20 | type stdOut struct { 21 | coloredId map[string]string 22 | lock sync.Mutex 23 | } 24 | 25 | func (s *stdOut) ColoredId(acct resource.Account) string { 26 | s.lock.Lock() 27 | defer s.lock.Unlock() 28 | 29 | coloredId, ok := s.coloredId[acct.ID()] 30 | if !ok { 31 | colorFunc := colors.DeterministicColorFunc(acct.AccountID) 32 | if acct.AccountName != "" { 33 | coloredId = colorFunc(fmt.Sprintf("[Account: %s (%s)]", acct.ID(), acct.AccountName)) 34 | } else { 35 | coloredId = colorFunc("[Account: " + acct.ID() + "]") 36 | } 37 | s.coloredId[acct.ID()] = coloredId 38 | } 39 | return coloredId 40 | } 41 | 42 | // RunCmd takes the command and acct and runs it while prepending the 43 | // coloredAccountID from stderr and stdout and printing it. 44 | func (s *stdOut) RunCmd(cmd *exec.Cmd, acct resource.Account) error { 45 | stdoutPipe, err := cmd.StdoutPipe() 46 | if err != nil { 47 | return fmt.Errorf("[ERROR] %s %v", s.ColoredId(acct), err) 48 | } 49 | stdoutScanner := bufio.NewScanner(stdoutPipe) 50 | 51 | stderrPipe, err := cmd.StderrPipe() 52 | if err != nil { 53 | return fmt.Errorf("[ERROR] %s %v", s.ColoredId(acct), err) 54 | } 55 | stderrScanner := bufio.NewScanner(stderrPipe) 56 | 57 | if err := cmd.Start(); err != nil { 58 | return fmt.Errorf("[ERROR] %s %v", s.ColoredId(acct), err) 59 | } 60 | 61 | var scannerWg sync.WaitGroup 62 | scannerWg.Add(2) 63 | scanF := func(scanner *bufio.Scanner, _ string) { 64 | defer scannerWg.Done() 65 | for scanner.Scan() { 66 | fmt.Printf("%s %s\n", s.ColoredId(acct), scanner.Text()) 67 | } 68 | if err := scanner.Err(); err != nil { 69 | fmt.Printf("[ERROR] %s %v\n", s.ColoredId(acct), err) 70 | return 71 | } 72 | } 73 | 74 | go scanF(stdoutScanner, "stdout") 75 | go scanF(stderrScanner, "stderr") 76 | scannerWg.Wait() 77 | 78 | if err := cmd.Wait(); err != nil { 79 | return fmt.Errorf("[ERROR] %s %v", s.ColoredId(acct), err) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (s *stdOut) Print(msg string, acct resource.Account) { 86 | fmt.Printf("%s %v\n", s.ColoredId(acct), msg) 87 | } 88 | 89 | func (s *stdOut) Start() {} 90 | -------------------------------------------------------------------------------- /mintlifydocs/commands/deploy.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'telophasecli deploy' 3 | --- 4 | 5 | ``` 6 | Usage: 7 | telophasecli deploy [flags] 8 | 9 | Flags: 10 | -h, --help help for deploy 11 | --org string Path to the organization.yml file (default "organization.yml") 12 | --stacks string Filter stacks to deploy 13 | --tag string Filter accounts and account groups to deploy via a comma separated list 14 | --tui use the TUI for deploy 15 | ``` 16 | 17 | This command will read `organization.yml` and **perform**: 18 | 1) Changes required to AWS Organization, provisioning/deprovisioning new accounts. 19 | 2) Provision Resources within the accounts: 20 | - CDK deploy. Telophase runs `cdk bootstrap` and `cdk synth` on every deploy. 21 | - Terraform apply. Telophase automatically runs `terraform plan` if no plan exists. 22 | - `telophasecli diff` does _NOT_ need to be run before `telophasecli deploy`. 23 | 24 | # Examples 25 | For the following examples, we will use the following `organization.yml`. 26 | 27 | ```yml organization.yml 28 | Organization: 29 | Name: root 30 | OrganizationUnits: 31 | - Name: ProductionTenants 32 | Stacks: 33 | - Type: "CDK" 34 | Path: "./s3-remote-state" 35 | - Type: "Terraform" 36 | Path: "./tf/ci_iam" 37 | Name: "Default IAM Roles for CI" 38 | # Tags are specified here 39 | Tags: 40 | - "env=production" 41 | Accounts: 42 | - Email: production+us0@example.com 43 | AccountName: US0 44 | - Email: production+us1@example.com 45 | AccountName: US1 46 | - Email: production+us2@example.com 47 | AccountName: US2 48 | - Email: production+us3@example.com 49 | AccountName: US3 50 | 51 | - Name: Development 52 | Stacks: 53 | - Type: "CDK" 54 | Path: "./s3-remote-state" 55 | Tags: 56 | - "dev" 57 | Accounts: 58 | - Email: alice@example.com 59 | AccountName: Alice 60 | - Email: bob@example.com 61 | AccountName: Bob 62 | - Email: ethan@example.com 63 | AccountName: Ethan 64 | ``` 65 | ## Using Tags 66 | 67 | Running `telophasecli deploy --tag="env=production"` will only deploy terraform and CDK changes for the accounts named `US0`, `US1`, `US2`, `US3`. The resulting TUI looks like: 68 | 69 | 70 | -------------------------------------------------------------------------------- /mintlifydocs/features/localstack.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Testing' 3 | icon: 'vial' 4 | --- 5 | 6 | # Example: Localstack 7 | This example will walk you through using localstack with Telophase to create AWS Accounts and OUs (see: [`organization.yml`](https://github.com/Santiago-Labs/telophasecli/blob/main/examples/localstack/organization.yml)), and Stacks to them. 8 | 9 | # Getting Started with Localstack + Telophase 10 | 1. Install Dependencies 11 | ```bash 12 | ./setup.sh 13 | ``` 14 | 15 | 2. Start up Localstack in the background. Ensure your pro license is set. Learn more [here](https://docs.localstack.cloud/getting-started/auth-token/) 16 | ```bash 17 | localstack start -d 18 | ``` 19 | 20 | 3. Localstack Setup 21 | Create your root organization in Localstack 22 | ```bash 23 | awslocal organizations create-organization --feature-set ALL 24 | ``` 25 | 26 | 4. Setup an AWS_REGION if not set 27 | ```bash 28 | export AWS_REGION=us-east-1 29 | ``` 30 | 31 | 5. Deploy Infra in the Accounts 32 | 33 | Telophase will: 34 | 1. Create the accounts and OUs listed in [`organization.yml`](https://github.com/Santiago-Labs/telophasecli/blob/main/examples/localstack/organization.yml) 35 | 2. A CDK stack will create an S3 bucket for a terraform state will be create in each account. Additionally, a Terraform stack will create a CI deploy role in each account in the `Production` OU. 36 | ```bash 37 | LOCALSTACK=true telophasecli deploy --tui 38 | ``` 39 | 40 | 6. Inspect the accounts using `awslocal` 41 | 42 | Learn how Localstack handles multi-account auth [here](https://docs.localstack.cloud/references/multi-account-setups/) 43 | ```bash 44 | # View the organizations you have created 45 | awslocal organizations list-accounts 46 | 47 | # Each account will have its own tfstate bucket 48 | AWS_ACCESS_KEY_ID=$AN_ORG_FROM_ABOVE awslocal s3 ls 49 | 50 | # New roles will be created in each account 51 | AWS_ACCESS_KEY_ID=$AN_ORG_FROM_ABOVE awslocal iam list-roles 52 | ``` 53 | 54 | ### Common Errors 55 | #### Cannot create preexisting table / Cannot do operations on a non-existent table 56 | These are triggered by a concurrency bug in localstack. You can resolve it by pre-creating the `tf-test-state` table and running Telophase again: 57 | ```bash 58 | aws dynamodb create-table --table-name tf-test-state \ 59 | --attribute-definitions AttributeName=id,AttributeType=S \ 60 | --key-schema AttributeName=id,KeyType=HASH \ 61 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ 62 | --endpoint-url http://localhost:4566 63 | 64 | LOCALSTACK=true telophasecli deploy 65 | ``` 66 | 67 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | print_red() { 6 | echo -e "\033[0;31m$1\033[0m" 7 | } 8 | 9 | print_green() { 10 | echo -e "\033[0;32m$1\033[0m" 11 | } 12 | 13 | if command -v brew &> /dev/null; then 14 | echo "Brew is installed, proceeding with awscli and terraform installation." 15 | brew install awscli awscli-local terraform terraform-local localstack/tap/localstack-cli 16 | elif [ "$(uname)" = "Linux" ]; then 17 | if [ -f /etc/debian_version ]; then 18 | echo "Debian-based Linux detected. Installing packages with apt." 19 | sudo snap install aws-cli --classic 20 | pip3 install awscli-local 21 | pip3 install terraform-local 22 | sudo snap install terraform --classic 23 | curl -Lo localstack-cli-3.3.0-linux-amd64-onefile.tar.gz https://github.com/localstack/localstack-cli/releases/download/v3.3.0/localstack-cli-3.3.0-linux-amd64-onefile.tar.gz 24 | sudo tar xvzf localstack-cli-3.3.0-linux-*-onefile.tar.gz -C /usr/local/bin 25 | elif [ -f /etc/redhat-release ]; then 26 | echo "Red Hat-based Linux detected. Installing packages with dnf." 27 | sudo dnf install -y awscli 28 | print_red "Please manually install Terraform following the official instructions." 29 | fi 30 | else 31 | print_red "Brew is not installed, or you're not on a supported Linux distribution. Please install awscli and terraform manually." 32 | exit 1 33 | fi 34 | 35 | if command -v npm &> /dev/null 36 | then 37 | echo "Installing aws-cdk and aws-cdk-local." 38 | npm install -g aws-cdk aws-cdk-local 39 | else 40 | print_red "npm is not installed. Please install it and then return." 41 | exit 1 42 | fi 43 | 44 | if command -v go &> /dev/null 45 | then 46 | echo "Installing telophasecli..." 47 | go install github.com/santiago-labs/telophasecli@latest 48 | else 49 | if [ "$(uname)" = "Darwin" ]; then 50 | print_red "Go is not installed. On Mac, you can install Go using Homebrew with: brew install go. Then, return to run this script again." 51 | elif [ "$(uname)" = "Linux" ]; then 52 | print_red "Go is not installed. On Linux, you can generally install Go by running: sudo snap install go --classic (Debian/Ubuntu) or sudo dnf install golang (Fedora/RHEL). Then, return to run this script again." 53 | else 54 | print_red "Go is not installed. Please visit https://golang.org/doc/install for instructions on how to install Go on your system." 55 | fi 56 | exit 1 57 | fi 58 | 59 | print_green "Setup complete! You can now run telophasecli :)" -------------------------------------------------------------------------------- /tests/cdk/dynamo/dynamo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-cdk-go/awscdk/v2" 5 | "github.com/aws/aws-cdk-go/awscdk/v2/awsdynamodb" 6 | 7 | "github.com/aws/constructs-go/constructs/v10" 8 | "github.com/aws/jsii-runtime-go" 9 | ) 10 | 11 | type DynamoStackProps struct { 12 | awscdk.StackProps 13 | } 14 | 15 | func NewDynamoStack(scope constructs.Construct, id string, props *DynamoStackProps) awscdk.Stack { 16 | var sprops awscdk.StackProps 17 | if props != nil { 18 | sprops = props.StackProps 19 | } 20 | stack := awscdk.NewStack(scope, &id, &sprops) 21 | 22 | // The code that defines your stack goes here 23 | 24 | // example resource 25 | name := "cdktesttable" 26 | awsdynamodb.NewTable(stack, jsii.String("Table"), &awsdynamodb.TableProps{ 27 | TableName: &name, 28 | PartitionKey: &awsdynamodb.Attribute{ 29 | Name: jsii.String("pk"), 30 | Type: awsdynamodb.AttributeType_STRING, 31 | }, 32 | }) 33 | 34 | return stack 35 | } 36 | 37 | func main() { 38 | defer jsii.Close() 39 | 40 | app := awscdk.NewApp(nil) 41 | 42 | NewDynamoStack(app, "DynamoStack", &DynamoStackProps{ 43 | awscdk.StackProps{ 44 | Env: env(), 45 | }, 46 | }) 47 | 48 | app.Synth(nil) 49 | } 50 | 51 | // env determines the AWS environment (account+region) in which our stack is to 52 | // be deployed. For more information see: https://docs.aws.amazon.com/cdk/latest/guide/environments.html 53 | func env() *awscdk.Environment { 54 | // If unspecified, this stack will be "environment-agnostic". 55 | // Account/Region-dependent features and context lookups will not work, but a 56 | // single synthesized template can be deployed anywhere. 57 | //--------------------------------------------------------------------------- 58 | return nil 59 | 60 | // Uncomment if you know exactly what account and region you want to deploy 61 | // the stack to. This is the recommendation for production stacks. 62 | //--------------------------------------------------------------------------- 63 | // return &awscdk.Environment{ 64 | // Account: jsii.String("123456789012"), 65 | // Region: jsii.String("us-east-1"), 66 | // } 67 | 68 | // Uncomment to specialize this stack for the AWS Account and Region that are 69 | // implied by the current CLI configuration. This is recommended for dev 70 | // stacks. 71 | //--------------------------------------------------------------------------- 72 | // return &awscdk.Environment{ 73 | // Account: jsii.String(os.Getenv("CDK_DEFAULT_ACCOUNT")), 74 | // Region: jsii.String(os.Getenv("CDK_DEFAULT_REGION")), 75 | // } 76 | } 77 | -------------------------------------------------------------------------------- /examples/cdk/tf-s3-backend/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "go mod download && go run app.go", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "go.mod", 11 | "go.sum", 12 | "**/*test.go" 13 | ] 14 | }, 15 | "context": { 16 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 17 | "@aws-cdk/core:checkSecretUsage": true, 18 | "@aws-cdk/core:target-partitions": [ 19 | "aws", 20 | "aws-cn" 21 | ], 22 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 23 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 24 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 25 | "@aws-cdk/aws-iam:minimizePolicies": true, 26 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 27 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 28 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 29 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 30 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 31 | "@aws-cdk/core:enablePartitionLiterals": true, 32 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 33 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 34 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 35 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 36 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 37 | "@aws-cdk/aws-route53-patters:useCertificate": true, 38 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 39 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 40 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 41 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 42 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 43 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 44 | "@aws-cdk/aws-redshift:columnId": true, 45 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 46 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 47 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 48 | "@aws-cdk/aws-kms:aliasNameRef": true, 49 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 50 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 51 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 52 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 53 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 54 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 55 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 56 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 57 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 58 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true 59 | } 60 | } -------------------------------------------------------------------------------- /examples/localstack/s3-remote-state/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "go mod download && go run app.go", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "go.mod", 11 | "go.sum", 12 | "**/*test.go" 13 | ] 14 | }, 15 | "context": { 16 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 17 | "@aws-cdk/core:checkSecretUsage": true, 18 | "@aws-cdk/core:target-partitions": [ 19 | "aws", 20 | "aws-cn" 21 | ], 22 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 23 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 24 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 25 | "@aws-cdk/aws-iam:minimizePolicies": true, 26 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 27 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 28 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 29 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 30 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 31 | "@aws-cdk/core:enablePartitionLiterals": true, 32 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 33 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 34 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 35 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 36 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 37 | "@aws-cdk/aws-route53-patters:useCertificate": true, 38 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 39 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 40 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 41 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 42 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 43 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 44 | "@aws-cdk/aws-redshift:columnId": true, 45 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 46 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 47 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 48 | "@aws-cdk/aws-kms:aliasNameRef": true, 49 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 50 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 51 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 52 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 53 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 54 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 55 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 56 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 57 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 58 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true 59 | } 60 | } -------------------------------------------------------------------------------- /lib/ymlparser/organization_test.go: -------------------------------------------------------------------------------- 1 | package ymlparser 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "github.com/santiago-labs/telophasecli/lib/awsorgs" 12 | "github.com/santiago-labs/telophasecli/lib/awsorgs/awsorgsmock" 13 | "github.com/santiago-labs/telophasecli/resource" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func basicOU() resource.OrganizationUnit { 18 | return resource.OrganizationUnit{ 19 | OUName: "root", 20 | OUID: aws.String("r-0000"), 21 | ChildOUs: []*resource.OrganizationUnit{ 22 | { 23 | OUName: "ExampleOU", 24 | BaselineStacks: []resource.Stack{ 25 | { 26 | Type: "CDK", 27 | Path: "examples/localstack/s3-remote-state", 28 | Name: "example", 29 | }, 30 | }, 31 | Tags: []string{ 32 | "ou=ExampleTenants", 33 | }, 34 | Accounts: []*resource.Account{ 35 | { 36 | Email: "test1@example.com", 37 | AccountName: "test1", 38 | BaselineStacks: []resource.Stack{ 39 | { 40 | Type: "CDK", 41 | Path: "examples/cdk/sqs", 42 | Name: "example", 43 | Region: "us-west-2,us-east-1", 44 | }, 45 | }, 46 | AccountID: "10000000000", 47 | }, 48 | { 49 | Email: "test2@example.com", 50 | AccountName: "test2", 51 | AccountID: "20000000000", 52 | }, 53 | }, 54 | }, 55 | { 56 | OUName: "ExampleOU2", 57 | Accounts: []*resource.Account{ 58 | { 59 | Email: "test3@example.com", 60 | AccountName: "test3", 61 | AccountID: "30000000000", 62 | }, 63 | }, 64 | }, 65 | }, 66 | } 67 | } 68 | 69 | func TestParseOrganization(t *testing.T) { 70 | tests := []struct { 71 | name string 72 | orgPath string 73 | want resource.OrganizationUnit 74 | }{ 75 | { 76 | name: "basic OU", 77 | orgPath: "./testdata/organization-basic.yml", 78 | want: basicOU(), 79 | }, 80 | { 81 | name: "OU with child filepaths", 82 | orgPath: "./testdata/organization-with-filepath.yml", 83 | want: basicOU(), 84 | }, 85 | { 86 | name: "OU with one child inline and one filepath", 87 | orgPath: "./testdata/organization-one-child.yml", 88 | want: basicOU(), 89 | }, 90 | } 91 | for _, tc := range tests { 92 | mockClient := awsorgs.New(&awsorgs.Config{ 93 | OrganizationClient: awsorgsmock.New(), 94 | }) 95 | 96 | parser := NewParser(mockClient) 97 | 98 | actual, err := parser.ParseOrganization(context.Background(), tc.orgPath) 99 | require.NoError(t, err) 100 | ignoreFields := []cmp.Option{ 101 | cmpopts.IgnoreFields(resource.OrganizationUnit{}, "Parent"), 102 | cmpopts.IgnoreFields(resource.Account{}, "Parent"), 103 | } 104 | 105 | if diff := cmp.Diff(tc.want.ChildOUs, actual.ChildOUs, ignoreFields...); diff != "" { 106 | t.Errorf(fmt.Sprintf("expected no diff for %s got diff: %+v", tc.name, diff)) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /resource/organization_unit.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | type OrganizationUnit struct { 4 | OUID *string `yaml:"-"` 5 | OUName string `yaml:"Name,omitempty"` 6 | ChildGroups []*OrganizationUnit `yaml:"AccountGroups,omitempty"` // Deprecated. Use `OrganizationUnits` 7 | ChildOUs []*OrganizationUnit `yaml:"OrganizationUnits,omitempty"` 8 | Tags []string `yaml:"Tags,omitempty"` 9 | AWSTags []string `yaml:"-"` 10 | Accounts []*Account `yaml:"Accounts,omitempty"` 11 | BaselineStacks []Stack `yaml:"Stacks,omitempty"` 12 | ServiceControlPolicies []Stack `yaml:"ServiceControlPolicies,omitempty"` 13 | Parent *OrganizationUnit `yaml:"-"` 14 | 15 | OUFilepath *string `yaml:"OUFilepath,omitempty"` 16 | } 17 | 18 | func (grp OrganizationUnit) ID() string { 19 | if grp.OUID != nil { 20 | return *grp.OUID 21 | } 22 | return "" 23 | } 24 | 25 | func (grp OrganizationUnit) Name() string { 26 | return grp.OUName 27 | } 28 | 29 | func (grp OrganizationUnit) Type() string { 30 | return "Organization Unit" 31 | } 32 | 33 | func (grp OrganizationUnit) AllTags() []string { 34 | var tags []string 35 | tags = append(tags, grp.Tags...) 36 | if grp.Parent != nil { 37 | tags = append(tags, grp.Parent.AllTags()...) 38 | } 39 | return tags 40 | } 41 | 42 | func (grp OrganizationUnit) AllAWSTags() []string { 43 | var tags []string 44 | tags = append(tags, grp.AWSTags...) 45 | if grp.Parent != nil { 46 | tags = append(tags, grp.Parent.AllAWSTags()...) 47 | } 48 | return tags 49 | } 50 | 51 | func (grp OrganizationUnit) AllBaselineStacks() []Stack { 52 | var stacks []Stack 53 | if grp.Parent != nil { 54 | stacks = append(stacks, grp.Parent.AllBaselineStacks()...) 55 | } 56 | stacks = append(stacks, grp.BaselineStacks...) 57 | return stacks 58 | } 59 | 60 | func (grp OrganizationUnit) AllDescendentAccounts() []*Account { 61 | var accounts []*Account 62 | accounts = append(accounts, grp.Accounts...) 63 | 64 | for _, ou := range grp.ChildOUs { 65 | accounts = append(accounts, ou.AllDescendentAccounts()...) 66 | } 67 | 68 | return accounts 69 | } 70 | 71 | // This should only be called from the Root OU. 72 | func (grp OrganizationUnit) DelegatedAdministrator() *Account { 73 | for _, acct := range grp.AllDescendentAccounts() { 74 | if acct.DelegatedAdministrator { 75 | return acct 76 | } 77 | } 78 | 79 | return nil 80 | } 81 | 82 | // This should only be called from the Root OU. 83 | func (grp OrganizationUnit) ManagementAccount() *Account { 84 | for _, acct := range grp.AllDescendentAccounts() { 85 | if acct.ManagementAccount { 86 | return acct 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (grp OrganizationUnit) AllDescendentOUs() []*OrganizationUnit { 94 | var OUs []*OrganizationUnit 95 | OUs = append(OUs, grp.ChildOUs...) 96 | 97 | for _, childOU := range grp.ChildOUs { 98 | OUs = append(OUs, childOU.AllDescendentOUs()...) 99 | } 100 | 101 | return OUs 102 | 103 | } 104 | -------------------------------------------------------------------------------- /mintlifydocs/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: "Telophase is an open-source AWS Control Tower" 4 | --- 5 | 6 | # Why Telophase? 7 | Automation and Compliance are key concerns when adopting multi-account AWS. Telophase orchestrates the management of AWS Organizations alongside your infrastructure-as-code (IaC) provider, like Terraform or CDK. Using a single tool for these allows: 8 | 1. **Workflow Automation**: Automates account creation and decommissioning, integrating with existing automation workflows, like CI or ServiceNow. 9 | 2. **IaC & Account Binding**: Enables binding accounts to specific IaC blueprints for automatic provisioning of baseline resources. 10 | 3. **Easier Compliance Deployment**: Enables binding Service Control Policies (SCPs) to accounts as part of your Account provisioning workflow to make sure every Account is compliant. We make it easy to test SCPs before they are deployed. 11 | 12 | Currently, Telophase is a CLI tool only. In the future, we plan to offer a web UI. 13 | 14 | # Install 15 | If you'd like another method, please let us know by opening an issue! 16 | 17 | ## Go 18 | ```bash 19 | go install github.com/santiago-labs/telophasecli@latest 20 | ``` 21 | 22 | ## Homebrew 23 | ``` 24 | brew tap Santiago-Labs/telophasecli 25 | brew install telophasecli 26 | ``` 27 | 28 | # Features 29 | 30 | 35 | Manage your AWS organizations via an `organizations.yml` file. 36 | 37 | 42 | Assign Terraform, Cloudformation or CDK `Stacks` to Organization Units or Organizations to provision infrastructure in across your Organizations. 43 | 44 | 49 | Configuring Service Control Policies (SCPs). 50 | 51 | 56 | Learn how to use the Terminal UI with `telophasecli` 57 | 58 | 63 | Using localstack to test changes to your AWS Organization. 64 | 65 | 66 | 67 | # Commands 68 | 69 | 73 | Render a diff of your Organizations and Infrastructure from your code to what is currently deployed. 74 | 75 | 79 | Provision new organizations and infrastructure within them. 80 | 81 | 85 | Import your current AWS organizations into an `organization.yml` file. 86 | 87 | -------------------------------------------------------------------------------- /lib/terraform/local.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "io/fs" 8 | "os" 9 | "path" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/samsarahq/go/oops" 14 | "github.com/santiago-labs/telophasecli/resource" 15 | ) 16 | 17 | func TmpPath(acct resource.Account, filePath string) string { 18 | hasher := sha256.New() 19 | hasher.Write([]byte(filePath)) 20 | hashBytes := hasher.Sum(nil) 21 | hashString := hex.EncodeToString(hashBytes) 22 | 23 | return path.Join("telophasedirs", fmt.Sprintf("tf-tmp%s-%s", acct.ID(), hashString)) 24 | } 25 | 26 | func CopyDir(stack resource.Stack, dst string, resource resource.Resource) error { 27 | ignoreDir := "telophasedirs" 28 | 29 | abs, err := filepath.Abs(stack.Path) 30 | if err != nil { 31 | return oops.Wrapf(err, "could not get absolute file path for path: %s", stack.Path) 32 | } 33 | return filepath.Walk(abs, func(path string, info fs.FileInfo, err error) error { 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if strings.Contains(path, filepath.Join(abs, ignoreDir)) { 39 | return nil 40 | } 41 | 42 | relPath := strings.TrimPrefix(path, abs) 43 | targetPath := filepath.Join(dst, relPath) 44 | 45 | if info.IsDir() { 46 | return os.MkdirAll(targetPath, info.Mode()) 47 | } else { 48 | return replaceVariablesInFile(path, targetPath, resource, stack) 49 | } 50 | }) 51 | } 52 | 53 | func replaceVariablesInFile(srcFile, dstFile string, resource resource.Resource, stack resource.Stack) error { 54 | fileInfo, err := os.Stat(srcFile) 55 | if err != nil { 56 | return oops.Wrapf(err, "error accessing file %s", srcFile) 57 | } 58 | 59 | content, err := os.ReadFile(srcFile) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | resourceType := strings.Join(strings.Split(strings.ToLower(resource.Type()), " "), "_") 65 | updatedContent := strings.ReplaceAll(string(content), fmt.Sprintf("${telophase.%s_id}", resourceType), resource.ID()) 66 | updatedContent = strings.ReplaceAll(updatedContent, fmt.Sprintf("telophase.%s_id", resourceType), fmt.Sprintf("\"%s\"", resource.ID())) 67 | updatedContent = strings.ReplaceAll(updatedContent, fmt.Sprintf("${telophase.%s_name}", resourceType), resource.Name()) 68 | updatedContent = strings.ReplaceAll(updatedContent, fmt.Sprintf("telophase.%s_name", resourceType), fmt.Sprintf("\"%s\"", resource.Name())) 69 | 70 | updatedContent = strings.ReplaceAll(updatedContent, "${telophase.resource_id}", resource.ID()) 71 | updatedContent = strings.ReplaceAll(updatedContent, "telophase.resource_id", fmt.Sprintf("\"%s\"", resource.ID())) 72 | updatedContent = strings.ReplaceAll(updatedContent, "${telophase.resource_name}", resource.Name()) 73 | updatedContent = strings.ReplaceAll(updatedContent, "telophase.resource_name", fmt.Sprintf("\"%s\"", resource.Name())) 74 | 75 | // Update Region 76 | preRegionContent := updatedContent 77 | updatedContent = strings.ReplaceAll(updatedContent, "${telophase.region}", stack.Region) 78 | updatedContent = strings.ReplaceAll(updatedContent, "telophase.region", stack.Region) 79 | if updatedContent != preRegionContent && stack.Region == "" { 80 | return oops.Errorf("Region needs to be set on stack if performing substitution") 81 | } 82 | 83 | return os.WriteFile(dstFile, []byte(updatedContent), fileInfo.Mode()) 84 | } 85 | -------------------------------------------------------------------------------- /mintlifydocs/features/Assign-IaC-Blueprints-To-Accounts.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Assign IaC Blueprints to Accounts' 3 | icon: 'hand-pointer' 4 | --- 5 | 6 | Terraform, Cloudformation and CDK (AWS Only) can be assigned at any level in the hierarchy. All child accounts inherit the stack. 7 | 8 | ## Example 9 | ```yml 10 | Organization: 11 | OrganizationUnits: 12 | - Name: Production 13 | Stacks: 14 | # This stack will be applied to all accounts in the `Production` OU (`Safety Firmware` and `Safety Ingestion Team`). 15 | - Name: SCPDisableEURegion 16 | Path: go/src/cdk/scp 17 | Type: CDK 18 | Accounts: 19 | - Email: safety+firmware@example.app 20 | AccountName: Safety Firmware 21 | Stacks: 22 | # This stack will be applied to `Safety Firmware` account only. 23 | - Path: tf/safety/firmware_bucket 24 | Type: Terraform 25 | # You can set the region for where you want the resources to be created. 26 | Region: "us-west-2" 27 | # Cloudformation Path has to go directly to a cloudformation file. 28 | - Path: cloudformation/table.yml 29 | Type: CloudformationParameters 30 | # Set Cloudformation Parameters as Key=Value and can be passed in as a list. 31 | CloudformationParameters: 32 | - "HashKeyElementName=Painter" 33 | - "TableName=test" 34 | CloudformationCapabilities: 35 | - "CAPABILITY_IAM" 36 | - Email: safety+ingestion@example.app 37 | AccountName: Safety Ingestion Team 38 | - Name: Development 39 | Stacks: 40 | # This stack will be applied to all accounts in the `Development` OU (`Engineer A`). 41 | - Name: DevAccount 42 | Path: go/src/cdk/dev 43 | Type: CDK 44 | Accounts: 45 | - Email: eng1@example.app 46 | AccountName: Engineer A 47 | ``` 48 | 49 | # Stacks 50 | Stacks can be assigned to `Account`s and `OrganizationUnits`s. Stacks assigned 51 | to `OrganizationUnits` will be applied to all child `Account`s. A Stack is a 52 | collection of resources that you can manage as one block in YAML. 53 | 54 | ```yaml 55 | Stacks: 56 | - Path: # (Required) Path to CDK or Terraform project. This must be a directory. 57 | Type: # (Required) "CDK", "Terraform", or "Cloudformation". 58 | Name: # (Optional) Name of the Stack to filter on with --stacks. 59 | AssumeRoleName: # (Optional) Force the stack to use a specific role when applying a stack. The default role is the account's `AssumeRoleName` which is typically the `OrganizationAccountAccessRole`. 60 | Region: # (Optional) What region the stack's resources will be provisioned in. Region can be a comma separated list of regions or "all" to apply to all regions in an account. 61 | Workspace: # (Optional) Specify a Terraform workspace to use. 62 | CloudformationParameters: # (Optional) A list of parameters to pass into the cloudformation stack. 63 | CloudformationCapabilities: # (Optional) A list of capabilities to pass into the cloudformation stack the only valid values are (CAPABILITY_IAM | CAPABILITY_NAMED_IAM | CAPABILITY_AUTO_EXPAND). 64 | ``` 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 |

Telophase

5 |
6 | 7 | ## Documentation 8 | Full documentation here: https://docs.telophase.dev 9 | 10 | ## Why Telophase? 11 | Automation and Compliance are key concerns when adopting a multi-account AWS setup. Telophase manages your AWS Organization as IaC, and deeply integrates with IaC providers, like Terraform or CDK. This integration allows: 12 | 1. **Workflow Automation**: Automates account creation and decommissioning, integrating with existing automation workflows, like CI or ServiceNow. 13 | 2. **IaC <> Account Binding**: Enables binding accounts to IaC blueprints for automatic provisioning of resources in a newly created account. 14 | 3. **Easier Compliance Deployment**: Enables binding Service Control Policies (SCPs) to accounts as part of your Account provisioning workflow to make sure every Account is compliant. We make it easy to test SCPs before they are deployed. 15 | 16 | Currently, Telophase is a CLI tool only. In the future, we plan to offer a web UI. 17 | 18 | ## Install 19 | If you'd like another method, please let us know by opening an issue! 20 | 21 | ### Go 22 | ``` 23 | go install github.com/santiago-labs/telophasecli@latest 24 | ``` 25 | 26 | ### Homebrew 27 | ``` 28 | brew tap Santiago-Labs/telophasecli 29 | brew install telophasecli 30 | ``` 31 | 32 | ## Quick links 33 | 34 | - Intro 35 | - [Quickstart](https://docs.telophase.dev/quickstart) 36 | - Features 37 | - [Manage AWS Organization as IaC](https://docs.telophase.dev/features/Manage-AWS-Organizations) 38 | - [Manage Service Control Policies](https://docs.telophase.dev/features/scps) 39 | - [Assign IaC Blueprints to Accounts](https://docs.telophase.dev/features/Assign-IaC-Blueprints-To-Accounts) 40 | - [Testing](https://docs.telophase.dev/features/localstack) 41 | - [Terminal UI](https://docs.telophase.dev/features/tui) 42 | - CLI 43 | - [`telophase diff`](https://docs.telophase.dev/commands/diff) 44 | - [`telophase deploy`](https://docs.telophase.dev/commands/deploy) 45 | - [`telophase account import`](https://docs.telophase.dev/commands/account-import) 46 | - Organization.yml Reference 47 | - [Reference](https://docs.telophase.dev/config/organization) 48 | 49 | 50 | ### Community 51 | Join our Slack community: [![slack](https://img.shields.io/badge/slack-chat-yellow)](https://join.slack.com/t/telophasecommunity/shared_invite/zt-2gpmj94b4-3sexoHWp3~ee~MZpjJcRgQ) 52 | 53 | ### Future Development 54 | - [ ] Support for multi-cloud organizations with a unified account factory. 55 | - [ ] Azure 56 | - [ ] GCP 57 | - [ ] Drift detection/prevention 58 | - [ ] Guardrails around account resources 59 | - [ ] Guardrails around new Accounts, similar to Control Tower rules. 60 | 61 | ### Comparisons 62 | #### Telophase vs Control Tower 63 | Manage Accounts via code not a UI. Telophase leaves the controls up to you and your IaC. 64 | 65 | #### Telophase vs CDK with multiple environments 66 | Telophase wraps your usage of CDK so that you can apply the cdk to multiple 67 | accounts in parallel. Telophase lets you focus on your actual infrastructure and 68 | not worrying about setting up the right IAM roles for multi account management. 69 | -------------------------------------------------------------------------------- /tests/cdk/dynamo/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "go mod download && go run dynamo.go", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "go.mod", 11 | "go.sum", 12 | "**/*test.go" 13 | ] 14 | }, 15 | "context": { 16 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 17 | "@aws-cdk/core:checkSecretUsage": true, 18 | "@aws-cdk/core:target-partitions": [ 19 | "aws", 20 | "aws-cn" 21 | ], 22 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 23 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 24 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 25 | "@aws-cdk/aws-iam:minimizePolicies": true, 26 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 27 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 28 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 29 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 30 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 31 | "@aws-cdk/core:enablePartitionLiterals": true, 32 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 33 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 34 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 35 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 36 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 37 | "@aws-cdk/aws-route53-patters:useCertificate": true, 38 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 39 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 40 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 41 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 42 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 43 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 44 | "@aws-cdk/aws-redshift:columnId": true, 45 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 46 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 47 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 48 | "@aws-cdk/aws-kms:aliasNameRef": true, 49 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 50 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 51 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 52 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 53 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 54 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 55 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 56 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 57 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 58 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, 59 | "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, 60 | "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, 61 | "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, 62 | "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true 63 | } 64 | } -------------------------------------------------------------------------------- /mintlifydocs/quickstart.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Quickstart' 3 | description: 'Start using telophasecli' 4 | --- 5 | ### Requirements 6 | 1. You must have AWS Organizations enabled. 7 | - Follow directions from [here](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_tutorials_basic.html) to set up your AWS Organization. 8 | 2. AWS CLI must be configured. See [Authentication](#authentication) below 9 | 10 | ### Installation 11 | #### Go 12 | ```bash 13 | go install github.com/santiago-labs/telophasecli@latest 14 | ``` 15 | 16 | #### Homebrew 17 | ``` 18 | brew tap Santiago-Labs/telophasecli 19 | brew install telophasecli 20 | ``` 21 | 22 | ### Authentication 23 | Note: Set `AWS_SDK_LOAD_CONFIG=1` when passing env variables directly e.g. `AWS_PROFILE= AWS_SDK_LOAD_CONFIG=1 telophasecli account import` 24 | 25 | #### Option 1: IAM Identity Center/AWS SSO (Recommended) 26 | 1. Navigate to Identity Center in the *Management Account* 27 | 2. Create a group and add the users who will manage accounts and apply IaC changes 28 | 3. Navigate to the `AWS accounts` tab in Identity Center 29 | 4. Assign the group to all accounts you want telophase to manage (note: you must include your management account) 30 | 5. Assign these permission sets to the group: 31 | - `AWSOrganizationsFullAccess` - This policy allows the creation of organizations and linked roles. 32 | - `sts:*` - This policy allows the AWS CLI to assume roles in sub-accounts to update infrastructure. 33 | 6. Configure AWS CLI using `aws configure sso`. Make sure to choose the region where IAM Identity Center is configured! 34 | 35 | For more details, visit the [Identity Center CLI Guide](https://docs.aws.amazon.com/cli/latest/userguide/sso-configure-profile-token.html) 36 | 37 | #### Option 2: IAM 38 | 1. Navigate to IAM in the *Management Account* 39 | 2. Create a role and attach the following policies: 40 | - `AWSOrganizationsFullAccess` - This policy allows the creation of organizations and linked roles. 41 | - `sts:*` - This policy allows the AWS CLI to assume roles in sub-accounts to update infrastructure. 42 | 3. Configure AWS CLI to use the role you just created. 43 | - Follow the instructions [here](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html) to configure the CLI with role-based access. 44 | 45 | ### Configure `organization.yml` 46 | Telophase uses a file called `organization.yml` to manage your AWS Organization and IaC. See [organization.yml](#organization.yml) for configuration options. 47 | 48 | #### Option 1: Import Existing AWS Organization 49 | Telophase can import your AWS Organization (including OU structure): 50 | ```sh 51 | telophasecli account import 52 | ``` 53 | 54 | This command will output an `organization.yml` file containing all the accounts in your AWS Organization. You can remove any accounts you don't want Telophase to manage from this file. 55 | 56 | #### Option 2: Start From Scratch 57 | If you prefer to start fresh and not have Telophase manage any of your existing accounts, create the organization.yml file with the following content: 58 | 59 | ```yaml 60 | Organization: 61 | Name: root 62 | ``` 63 | 64 | ### You're ready! 65 | Here's a few examples of what you can do. Visit [Features](./features) for a more detailed guide. 66 | 67 | #### Example: Create account 68 | Create an account by adding a new entry to `organization.yml`: 69 | ```yaml 70 | Organization: 71 | Name: root 72 | Accounts: 73 | - Email: ethan+ci@telophase.dev 74 | AccountName: CI 75 | ``` 76 | Then run `telophasecli account deploy` 77 | 78 | #### Example: Apply Terraform 79 | You can apply IaC by assigning a stack to the account in `organization.yml`: 80 | ```yaml 81 | Organization: 82 | Name: root 83 | Accounts: 84 | - Email: ethan+ci@telophase.dev 85 | AccountName: CI 86 | Stacks: 87 | - Path: tf/ci_blueprint 88 | Type: Terraform 89 | ``` 90 | Then run `telophasecli deploy` 91 | -------------------------------------------------------------------------------- /resource/stack_test.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // TestNewForRegion ensures NewForRegion has an exact replica of the Stack type 11 | // when creating a new Stack. This makes sure that we don't add a field to the 12 | // Stack struct that doesn't get copied over. 13 | func TestNewForRegion(t *testing.T) { 14 | newStack := Stack{} 15 | setFields(&newStack) 16 | newStack.Region = "us-west-2" 17 | 18 | valS := reflect.ValueOf(&newStack).Elem() 19 | valNewS := reflect.ValueOf(&newStack).Elem() 20 | 21 | for i := 0; i < valS.NumField(); i++ { 22 | valNewS.Field(i).Set(valS.Field(i)) 23 | } 24 | 25 | newStackSameRegion := newStack.NewForRegion("us-west-2") 26 | 27 | assert.True(t, reflect.DeepEqual(newStack, newStackSameRegion), "you likely added something to the Stack struct wihtout adding it to the stack in NewForRegion") 28 | } 29 | 30 | func setFields(s *Stack) { 31 | v := reflect.ValueOf(s).Elem() 32 | for i := 0; i < v.NumField(); i++ { 33 | field := v.Field(i) 34 | switch field.Kind() { 35 | case reflect.String: 36 | // all strings set as an example value 37 | field.SetString("example value for " + v.Type().Field(i).Name) 38 | case reflect.Int: 39 | // all ints are set to 8 40 | field.SetInt(8) 41 | } 42 | } 43 | } 44 | 45 | func TestCloudformationStackName(t *testing.T) { 46 | tests := []struct { 47 | input Stack 48 | 49 | want string 50 | }{ 51 | { 52 | input: Stack{ 53 | Name: "name", 54 | Path: "path", 55 | Region: "us-west-2", 56 | }, 57 | want: "name-path-us-west-2", 58 | }, 59 | { 60 | input: Stack{ 61 | Path: "./path", 62 | Region: "us-west-2", 63 | }, 64 | want: "path-us-west-2", 65 | }, 66 | { 67 | input: Stack{ 68 | Name: "@danny", 69 | Path: "./path", 70 | Region: "us-west-2", 71 | }, 72 | want: "danny---path-us-west-2", 73 | }, 74 | } 75 | 76 | for _, tc := range tests { 77 | assert.Equal(t, tc.want, *tc.input.CloudformationStackName()) 78 | } 79 | 80 | } 81 | 82 | func TestValidate(t *testing.T) { 83 | tests := []struct { 84 | input Stack 85 | description string 86 | 87 | wantErr bool 88 | }{ 89 | { 90 | input: Stack{ 91 | Type: "Cloudformation", 92 | Name: "name", 93 | Path: "path", 94 | Region: "us-west-2", 95 | }, 96 | wantErr: false, 97 | }, 98 | { 99 | input: Stack{ 100 | Type: "Terraform", 101 | Path: "./path", 102 | Region: "us-west-2", 103 | }, 104 | wantErr: false, 105 | }, 106 | { 107 | input: Stack{ 108 | Type: "CDK", 109 | Name: "@danny", 110 | Path: "./path", 111 | Region: "us-west-2", 112 | }, 113 | wantErr: false, 114 | }, 115 | { 116 | input: Stack{ 117 | Type: "Cloudformation", 118 | Name: "name", 119 | Path: "path", 120 | Region: "us-west-2", 121 | CloudformationCapabilities: []string{ 122 | "IAM_CAPABILITY", 123 | }, 124 | }, 125 | description: "misspelled cloudformation capabilities", 126 | wantErr: true, 127 | }, 128 | { 129 | input: Stack{ 130 | Type: "Cloudformation", 131 | Name: "name", 132 | Path: "path", 133 | Region: "us-west-2", 134 | CloudformationCapabilities: []string{ 135 | "IAM_CAPABILITY", 136 | }, 137 | }, 138 | description: "misspelled cloudformation capabilities", 139 | wantErr: true, 140 | }, 141 | { 142 | input: Stack{ 143 | Type: "Cloudformation", 144 | Name: "name", 145 | Path: "path", 146 | Region: "us-west-2", 147 | CloudformationCapabilities: []string{ 148 | "CAPABILITY_IAM", 149 | "CAPABILITY_NAMED_IAM", 150 | "CAPABILITY_AUTO_EXPAND", 151 | }, 152 | }, 153 | description: "all valid capabilities", 154 | wantErr: false, 155 | }, 156 | { 157 | input: Stack{ 158 | Type: "Cloudformation", 159 | Name: "name", 160 | Path: "path", 161 | Region: "us-west-2", 162 | CloudformationCapabilities: []string{ 163 | "CAPABILITY_IAM", 164 | "CAPABILITY_NAMED_IAM", 165 | // Misspelled below 166 | "CAPABILITY_AUTOO_EXPAND", 167 | }, 168 | }, 169 | description: "one invalid capability", 170 | wantErr: true, 171 | }, 172 | } 173 | 174 | for _, tc := range tests { 175 | if tc.wantErr { 176 | assert.Error(t, tc.input.Validate()) 177 | } else { 178 | assert.NoError(t, tc.input.Validate()) 179 | } 180 | } 181 | 182 | } 183 | -------------------------------------------------------------------------------- /cmd/runner/tui.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/rivo/tview" 13 | "github.com/santiago-labs/telophasecli/resource" 14 | ) 15 | 16 | var tuiIndex atomic.Int64 17 | var tuiLock sync.Mutex 18 | 19 | type tui struct { 20 | tails map[string]*func() string 21 | index map[string]int 22 | list *tview.List 23 | main *tview.TextView 24 | app *tview.Application 25 | tv *tview.TextView 26 | files map[string]io.Writer 27 | } 28 | 29 | func NewTUI() ConsoleUI { 30 | app := tview.NewApplication() 31 | tv := tview.NewTextView().SetDynamicColors(true) 32 | 33 | main := tv. 34 | SetTextAlign(tview.AlignLeft).SetScrollable(true). 35 | SetChangedFunc(func() { 36 | tv.ScrollToEnd() 37 | app.Draw() 38 | }).SetText("Starting Telophase...") 39 | 40 | return &tui{ 41 | list: tview.NewList(), 42 | app: app, 43 | main: main, 44 | tv: tv, 45 | index: make(map[string]int), 46 | tails: make(map[string]*func() string), 47 | files: make(map[string]io.Writer), 48 | } 49 | } 50 | 51 | func (t *tui) accountID(acct resource.Account) string { 52 | var acctId = acct.ID() 53 | if acctId == "" { 54 | acctId = fmt.Sprintf("Not yet provisioned (email: %s)", acct.Email) 55 | } 56 | 57 | return acctId 58 | } 59 | 60 | func (t *tui) createIfNotExists(acct resource.Account) { 61 | acctId := t.accountID(acct) 62 | 63 | tuiLock.Lock() 64 | defer tuiLock.Unlock() 65 | if _, ok := t.tails[acctId]; ok { 66 | return 67 | } 68 | 69 | idx := len(t.index) 70 | t.index[acctId] = idx 71 | 72 | t.list.AddItem(acctId, acct.AccountName, runeIndex(idx), func() { 73 | currText := *t.tails[acctId] 74 | // And we want to call this on repeat 75 | tuiIndex.Swap(int64(idx)) 76 | tuiLock.Lock() 77 | defer tuiLock.Unlock() 78 | t.main.SetText(tview.TranslateANSI(currText())) 79 | }) 80 | 81 | file, err := os.CreateTemp("/tmp", acctId) 82 | if err != nil { 83 | panic(err) 84 | } 85 | 86 | setter := func() string { 87 | bytes, err := os.ReadFile(file.Name()) 88 | if err != nil { 89 | fmt.Printf("ERR: %s \n", err) 90 | return "" 91 | } 92 | 93 | return string(bytes) 94 | } 95 | 96 | t.tails[acctId] = &setter 97 | t.files[acctId] = file 98 | 99 | t.app.Draw() 100 | } 101 | 102 | func (t *tui) RunCmd(cmd *exec.Cmd, acct resource.Account) error { 103 | t.createIfNotExists(acct) 104 | 105 | acctId := t.accountID(acct) 106 | cmd.Stderr = t.files[acctId] 107 | cmd.Stdout = t.files[acctId] 108 | 109 | if err := cmd.Start(); err != nil { 110 | return err 111 | } 112 | 113 | if err := cmd.Wait(); err != nil { 114 | return err 115 | } 116 | return nil 117 | } 118 | 119 | func (t *tui) Start() { 120 | t.list.AddItem("Quit", "Press to exit", 'q', func() { 121 | t.app.Stop() 122 | }) 123 | 124 | startScreen := func() string { return "Starting Telophase..." } 125 | t.tails["quit"] = &startScreen 126 | t.index["quit"] = 0 127 | 128 | tuiIndex.Swap(0) 129 | 130 | go t.liveTextSetter() 131 | 132 | grid := tview.NewGrid(). 133 | SetColumns(-1, -3). 134 | SetRows(-1). 135 | SetBorders(true) 136 | 137 | // Layout for screens wider than 100 cells. 138 | grid.AddItem(t.list, 0, 0, 1, 1, 0, 100, false). 139 | AddItem(t.main, 0, 1, 1, 1, 0, 100, false) 140 | 141 | err := t.app.SetRoot(grid, true).SetFocus(t.list).Run() 142 | if err != nil { 143 | panic(err) 144 | } 145 | } 146 | 147 | func (t tui) Print(msg string, acct resource.Account) { 148 | t.createIfNotExists(acct) 149 | acctId := t.accountID(acct) 150 | 151 | fmt.Fprintf(t.files[acctId], "%s\n", msg) 152 | } 153 | 154 | func runeIndex(i int) rune { 155 | j := 0 156 | for r := 'a'; r <= 'p'; r++ { 157 | if j == i { 158 | return r 159 | } 160 | j++ 161 | } 162 | 163 | return 'z' 164 | } 165 | 166 | // liveTextSetter updates the current tui view with the current tail's text. 167 | func (t *tui) liveTextSetter() { 168 | defer func() { 169 | if r := recover(); r != nil { 170 | t.app.Stop() 171 | } 172 | }() 173 | for { 174 | func() { 175 | time.Sleep(200 * time.Millisecond) 176 | tuiLock.Lock() 177 | defer tuiLock.Unlock() 178 | var tailfunc func() string 179 | for key, val := range t.index { 180 | if int64(val) == tuiIndex.Load() { 181 | tailfunc = *t.tails[key] 182 | } 183 | } 184 | 185 | curr := t.tv.GetText(true) 186 | newText := tailfunc() 187 | if newText != curr && newText != "" { 188 | t.tv.SetText(tview.TranslateANSI(tailfunc())) 189 | } 190 | }() 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /examples/cdk/tf-s3-backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 2 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 3 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview h1:t3TI4mtmRQlUF5OceL3zhhTlz1RtFMMbsWQ1oMrAsWg= 4 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview/go.mod h1:ZAyiU+hVHfDS6Vvxf1ljmwawmA5Ri6FUay6M04+93pk= 5 | github.com/aws/aws-sdk-go v1.48.1 h1:OXPUVL4cLdsDsqkVIuhwY+D389tjI7e1xu0lsDYyeMk= 6 | github.com/aws/aws-sdk-go v1.48.1/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= 7 | github.com/aws/constructs-go/constructs/v3 v3.4.232 h1:YLw1wSu8/qqmacsZWPXI+qFNu4ml2YZwtRZHL2gaB8M= 8 | github.com/aws/constructs-go/constructs/v3 v3.4.232/go.mod h1:ejR5Hd2llgfqo68taM7H/hnri4hrHObbsqbT6YySLMI= 9 | github.com/aws/jsii-runtime-go v1.89.0 h1:1HKw9LyE8lOM9iMiSzVOUAVeUInTNhOyoxQrVVRbSFk= 10 | github.com/aws/jsii-runtime-go v1.89.0/go.mod h1:Jkx2jjw8wKQdQYzwh+JDDGy3MRPwKqDCeSvW6WWubi0= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 14 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 15 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 16 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 17 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 18 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 19 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 20 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 21 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 22 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 27 | github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 28 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 31 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 32 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 33 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 34 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 35 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 36 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 37 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 38 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 40 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 45 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 47 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 48 | golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= 49 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 50 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 53 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 54 | -------------------------------------------------------------------------------- /examples/localstack/s3-remote-state/go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 2 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 3 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview h1:t3TI4mtmRQlUF5OceL3zhhTlz1RtFMMbsWQ1oMrAsWg= 4 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview/go.mod h1:ZAyiU+hVHfDS6Vvxf1ljmwawmA5Ri6FUay6M04+93pk= 5 | github.com/aws/aws-sdk-go v1.48.1 h1:OXPUVL4cLdsDsqkVIuhwY+D389tjI7e1xu0lsDYyeMk= 6 | github.com/aws/aws-sdk-go v1.48.1/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= 7 | github.com/aws/constructs-go/constructs/v3 v3.4.232 h1:YLw1wSu8/qqmacsZWPXI+qFNu4ml2YZwtRZHL2gaB8M= 8 | github.com/aws/constructs-go/constructs/v3 v3.4.232/go.mod h1:ejR5Hd2llgfqo68taM7H/hnri4hrHObbsqbT6YySLMI= 9 | github.com/aws/jsii-runtime-go v1.89.0 h1:1HKw9LyE8lOM9iMiSzVOUAVeUInTNhOyoxQrVVRbSFk= 10 | github.com/aws/jsii-runtime-go v1.89.0/go.mod h1:Jkx2jjw8wKQdQYzwh+JDDGy3MRPwKqDCeSvW6WWubi0= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 14 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 15 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 16 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 17 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 18 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 19 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 20 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 21 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 22 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 27 | github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 28 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 31 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 32 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 33 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 34 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 35 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 36 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 37 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 38 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 40 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 45 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 47 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 48 | golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= 49 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 50 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 53 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 54 | -------------------------------------------------------------------------------- /cmd/provisionaccounts.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/santiago-labs/telophasecli/cmd/runner" 11 | "github.com/santiago-labs/telophasecli/lib/awsorgs" 12 | "github.com/santiago-labs/telophasecli/lib/ymlparser" 13 | "github.com/santiago-labs/telophasecli/resource" 14 | "github.com/santiago-labs/telophasecli/resourceoperation" 15 | ) 16 | 17 | var orgFile string 18 | 19 | func init() { 20 | rootCmd.AddCommand(accountProvision) 21 | accountProvision.Flags().StringVar(&orgFile, "org", "organization.yml", "Path to the organization.yml file") 22 | accountProvision.Flags().BoolVar(&useTUI, "tui", false, "use the TUI for diff") 23 | accountProvision.Flags().BoolVar(&allowDeleteAccount, "allow-account-delete", false, "Allow closing an AWS account") 24 | } 25 | 26 | func isValidAccountArg(arg string) bool { 27 | switch arg { 28 | case "import": 29 | return true 30 | case "diff": 31 | return true 32 | case "deploy": 33 | return true 34 | default: 35 | return false 36 | } 37 | } 38 | 39 | var accountProvision = &cobra.Command{ 40 | Use: "account", 41 | Short: "account - Provision and import AWS accounts", 42 | Args: func(cmd *cobra.Command, args []string) error { 43 | if len(args) < 1 { 44 | return errors.New("requires at least one arg") 45 | } 46 | if isValidAccountArg(args[0]) { 47 | return nil 48 | } 49 | return fmt.Errorf("invalid color specified: %s", args[0]) 50 | }, 51 | Run: func(cmd *cobra.Command, args []string) { 52 | 53 | var consoleUI runner.ConsoleUI 54 | if useTUI { 55 | consoleUI = runner.NewTUI() 56 | go processOrg(consoleUI, args[0]) 57 | } else { 58 | consoleUI = runner.NewSTDOut() 59 | processOrg(consoleUI, args[0]) 60 | } 61 | consoleUI.Start() 62 | 63 | }, 64 | } 65 | 66 | func processOrg(consoleUI runner.ConsoleUI, cmd string) { 67 | orgClient := awsorgs.New(nil) 68 | ctx := context.Background() 69 | 70 | if cmd == "import" { 71 | mgmtAcct, err := orgClient.FetchManagementAccount(ctx) 72 | if err != nil { 73 | consoleUI.Print(fmt.Sprintf("Error: %v", err), *mgmtAcct) 74 | return 75 | } 76 | consoleUI.Print("Importing AWS Organization", *mgmtAcct) 77 | if err := importOrg(ctx, consoleUI, orgClient, mgmtAcct); err != nil { 78 | consoleUI.Print(fmt.Sprintf("error importing organization: %s", err), *mgmtAcct) 79 | } 80 | } 81 | 82 | rootAWSOU, err := ymlparser.NewParser(orgClient).ParseOrganization(ctx, orgFile) 83 | if err != nil { 84 | consoleUI.Print(fmt.Sprintf("error parsing organization: %s", err), resource.Account{AccountID: "error", AccountName: "error"}) 85 | } 86 | 87 | mgmtAcct, err := resolveMgmtAcct(ctx, orgClient, rootAWSOU) 88 | if err != nil { 89 | consoleUI.Print(fmt.Sprintf("Could not fetch AWS Management Account: %s", err), resource.Account{AccountID: "error", AccountName: "error"}) 90 | return 91 | } 92 | 93 | if cmd == "diff" { 94 | consoleUI.Print("Diffing AWS Organization", *mgmtAcct) 95 | orgOps := resourceoperation.CollectOrganizationUnitOps( 96 | ctx, consoleUI, orgClient, mgmtAcct, rootAWSOU, resourceoperation.Diff, allowDeleteAccount, 97 | ) 98 | for _, op := range resourceoperation.FlattenOperations(orgOps) { 99 | consoleUI.Print(op.ToString(), *mgmtAcct) 100 | } 101 | if len(orgOps) == 0 { 102 | consoleUI.Print("\033[32m No changes to AWS Organization. \033[0m", *mgmtAcct) 103 | } 104 | } 105 | 106 | if cmd == "deploy" { 107 | consoleUI.Print("Diffing AWS Organization", *mgmtAcct) 108 | orgOps := resourceoperation.CollectOrganizationUnitOps( 109 | ctx, consoleUI, orgClient, mgmtAcct, rootAWSOU, resourceoperation.Deploy, allowDeleteAccount, 110 | ) 111 | 112 | for _, op := range resourceoperation.FlattenOperations(orgOps) { 113 | consoleUI.Print(op.ToString(), *mgmtAcct) 114 | } 115 | if len(orgOps) == 0 { 116 | consoleUI.Print("\033[32m No changes to AWS Organization. \033[0m", *mgmtAcct) 117 | } 118 | for _, op := range orgOps { 119 | err := op.Call(ctx) 120 | if err != nil { 121 | consoleUI.Print(fmt.Sprintf("Error: %v", err), *mgmtAcct) 122 | return 123 | } 124 | } 125 | } 126 | 127 | consoleUI.Print("Done.\n", *mgmtAcct) 128 | } 129 | 130 | func importOrg(ctx context.Context, consoleUI runner.ConsoleUI, orgClient awsorgs.Client, mgmtAcct *resource.Account) error { 131 | 132 | rootId, err := orgClient.GetRootId() 133 | if err != nil { 134 | return err 135 | } 136 | if rootId == "" { 137 | return fmt.Errorf("no root ID found") 138 | } 139 | 140 | rootOU, err := orgClient.FetchOUAndDescendents(ctx, rootId, mgmtAcct.AccountID) 141 | if err != nil { 142 | return err 143 | } 144 | org := resource.OrganizationUnit{ 145 | OUName: rootOU.OUName, 146 | ChildOUs: rootOU.ChildOUs, 147 | Accounts: rootOU.Accounts, 148 | } 149 | 150 | if err := ymlparser.WriteOrgFile(orgFile, &org); err != nil { 151 | return err 152 | } 153 | 154 | consoleUI.Print(fmt.Sprintf("Successfully wrote file to: %s", orgFile), *mgmtAcct) 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /tests/cdk/dynamo/go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 2 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 3 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview h1:t3TI4mtmRQlUF5OceL3zhhTlz1RtFMMbsWQ1oMrAsWg= 4 | github.com/aws/aws-cdk-go/awscdk v1.204.0-devpreview/go.mod h1:ZAyiU+hVHfDS6Vvxf1ljmwawmA5Ri6FUay6M04+93pk= 5 | github.com/aws/aws-cdk-go/awscdk/v2 v2.135.0 h1:Ld+XZJTzKyW0SwML+9F9Cdqyg+30Rt6d4A5YMgCp1BE= 6 | github.com/aws/aws-cdk-go/awscdk/v2 v2.135.0/go.mod h1:Uk4vqgQ2uInmANDjyYBQEjtSXJvCOfFdHftZrnH963A= 7 | github.com/aws/constructs-go/constructs/v10 v10.3.0 h1:LsjBIMiaDX/vqrXWhzTquBJ9pPdi02/H+z1DCwg0PEM= 8 | github.com/aws/constructs-go/constructs/v10 v10.3.0/go.mod h1:GgzwIwoRJ2UYsr3SU+JhAl+gq5j39bEMYf8ev3J+s9s= 9 | github.com/aws/constructs-go/constructs/v3 v3.4.232 h1:YLw1wSu8/qqmacsZWPXI+qFNu4ml2YZwtRZHL2gaB8M= 10 | github.com/aws/constructs-go/constructs/v3 v3.4.232/go.mod h1:ejR5Hd2llgfqo68taM7H/hnri4hrHObbsqbT6YySLMI= 11 | github.com/aws/jsii-runtime-go v1.96.0 h1:ma4ee4CgkNT/OnEsnXVQQPUCwvx+3p4SYaJ9qmnZpWg= 12 | github.com/aws/jsii-runtime-go v1.96.0/go.mod h1:ISVfzRvqqxXAYyPKhucPCFeUjIjnqtj9QzE/77yk9pQ= 13 | github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.202 h1:VixXB9DnHN8oP7pXipq8GVFPjWCOdeNxIaS/ZyUwTkI= 14 | github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.202/go.mod h1:iPUti/SWjA3XAS3CpnLciFjS8TN9Y+8mdZgDfSgcyus= 15 | github.com/cdklabs/awscdk-asset-kubectl-go/kubectlv20/v2 v2.1.2 h1:k+WD+6cERd59Mao84v0QtRrcdZuuSMfzlEmuIypKnVs= 16 | github.com/cdklabs/awscdk-asset-kubectl-go/kubectlv20/v2 v2.1.2/go.mod h1:CvFHBo0qcg8LUkJqIxQtP1rD/sNGv9bX3L2vHT2FUAo= 17 | github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv6/v2 v2.0.1 h1:MBBQNKKPJ5GArbctgwpiCy7KmwGjHDjUUH5wEzwIq8w= 18 | github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv6/v2 v2.0.1/go.mod h1:/2WiXEft9s8ViJjD01CJqDuyJ8HXBjhBLtK5OvJfdSc= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 21 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 22 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 23 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 24 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 25 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 26 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 29 | github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 30 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 31 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 32 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 33 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 34 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 35 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 36 | golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 37 | golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 38 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 39 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 40 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 42 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 43 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 47 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 48 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 49 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 50 | golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= 51 | golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= 52 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 53 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 54 | -------------------------------------------------------------------------------- /lib/awsorgs/awsorgsmock/organizationmock.go: -------------------------------------------------------------------------------- 1 | // package awsorgsmock provides a basic mocked implementation of the 2 | // organizations interface for telophase. 3 | // 4 | // The structure fo the mock org is that account 0 is the root account. 5 | // Account 1 - Child account 6 | // Account 2 - Another Child account 7 | // Account 3 - Orphan account within the organization. 8 | // Other methods are mocked, but don't perform any functions to avoid nil pointer exceptions. 9 | package awsorgsmock 10 | 11 | import ( 12 | "fmt" 13 | 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/request" 16 | "github.com/aws/aws-sdk-go/service/organizations" 17 | "github.com/aws/aws-sdk-go/service/organizations/organizationsiface" 18 | ) 19 | 20 | func New() organizationsiface.OrganizationsAPI { 21 | return &mockedOrganizations{} 22 | } 23 | 24 | type mockedOrganizations struct { 25 | organizationsiface.OrganizationsAPI 26 | } 27 | 28 | // ListAccountsPagesWithContext mocks the ListAccountsPagesWithContext method 29 | func (m *mockedOrganizations) ListAccountsPagesWithContext(ctx aws.Context, input *organizations.ListAccountsInput, fn func(*organizations.ListAccountsOutput, bool) bool, opts ...request.Option) error { 30 | return m.ListAccountsPagesWithContextFunc(ctx, input, fn, opts...) 31 | } 32 | 33 | func mockAccount(index int) *organizations.Account { 34 | return &organizations.Account{ 35 | Id: aws.String(fmt.Sprintf("%d0000000000", index)), 36 | Name: aws.String(fmt.Sprintf("test%d", index)), 37 | Email: aws.String(fmt.Sprintf("test%d@example.com", index)), 38 | } 39 | } 40 | 41 | func (m *mockedOrganizations) ListAccountsPagesWithContextFunc(ctx aws.Context, input *organizations.ListAccountsInput, fn func(*organizations.ListAccountsOutput, bool) bool, opts ...request.Option) error { 42 | fn(&organizations.ListAccountsOutput{ 43 | Accounts: []*organizations.Account{ 44 | mockAccount(0), 45 | mockAccount(1), 46 | mockAccount(2), 47 | mockAccount(3), 48 | }, 49 | }, true) 50 | 51 | return nil 52 | } 53 | 54 | func (m mockedOrganizations) DescribeOrganization(org *organizations.DescribeOrganizationInput) (*organizations.DescribeOrganizationOutput, error) { 55 | return &organizations.DescribeOrganizationOutput{ 56 | Organization: &organizations.Organization{ 57 | MasterAccountId: mockAccount(0).Id, 58 | }, 59 | }, nil 60 | } 61 | 62 | func (m mockedOrganizations) DescribeOrganizationWithContext(ctx aws.Context, org *organizations.DescribeOrganizationInput, opts ...request.Option) (*organizations.DescribeOrganizationOutput, error) { 63 | return &organizations.DescribeOrganizationOutput{ 64 | Organization: &organizations.Organization{ 65 | MasterAccountId: mockAccount(0).Id, 66 | }, 67 | }, nil 68 | } 69 | 70 | func (m mockedOrganizations) ListRoots(org *organizations.ListRootsInput) (*organizations.ListRootsOutput, error) { 71 | return &organizations.ListRootsOutput{ 72 | Roots: []*organizations.Root{ 73 | { 74 | Id: aws.String("r-0000"), 75 | Name: aws.String("root"), 76 | }, 77 | }, 78 | }, nil 79 | } 80 | 81 | func (m *mockedOrganizations) ListAccountsForParentPagesWithContext(ctx aws.Context, input *organizations.ListAccountsForParentInput, fn func(*organizations.ListAccountsForParentOutput, bool) bool, opts ...request.Option) error { 82 | return m.ListAccountsForParentPagesWithContextFunc(ctx, input, fn, opts...) 83 | } 84 | 85 | func (m *mockedOrganizations) ListAccountsForParentPagesWithContextFunc(ctx aws.Context, input *organizations.ListAccountsForParentInput, fn func(*organizations.ListAccountsForParentOutput, bool) bool, opts ...request.Option) error { 86 | if aws.StringValue(input.ParentId) == "1ou" { 87 | fn(&organizations.ListAccountsForParentOutput{ 88 | Accounts: []*organizations.Account{ 89 | mockAccount(1), 90 | mockAccount(2), 91 | }, 92 | }, true) 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (m *mockedOrganizations) ListOrganizationalUnitsForParentPagesWithContext(ctx aws.Context, input *organizations.ListOrganizationalUnitsForParentInput, fn func(*organizations.ListOrganizationalUnitsForParentOutput, bool) bool, opts ...request.Option) error { 99 | return m.ListOrganizationalUnitsForParentPagesWithContextFunc(ctx, input, fn, opts...) 100 | } 101 | 102 | func (m *mockedOrganizations) ListOrganizationalUnitsForParentPagesWithContextFunc(ctx aws.Context, input *organizations.ListOrganizationalUnitsForParentInput, fn func(*organizations.ListOrganizationalUnitsForParentOutput, bool) bool, opts ...request.Option) error { 103 | fn(&organizations.ListOrganizationalUnitsForParentOutput{ 104 | OrganizationalUnits: []*organizations.OrganizationalUnit{}, 105 | }, true) 106 | 107 | return nil 108 | } 109 | 110 | func (m *mockedOrganizations) ListTagsForResourcePagesWithContext(ctx aws.Context, input *organizations.ListTagsForResourceInput, fn func(*organizations.ListTagsForResourceOutput, bool) bool, opts ...request.Option) error { 111 | return m.ListTagsForResourcePagesWithContextFunc(ctx, input, fn, opts...) 112 | } 113 | 114 | func (m *mockedOrganizations) ListTagsForResourcePagesWithContextFunc(ctx aws.Context, input *organizations.ListTagsForResourceInput, fn func(*organizations.ListTagsForResourceOutput, bool) bool, opts ...request.Option) error { 115 | fn(&organizations.ListTagsForResourceOutput{}, true) 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /resourceoperation/cdk.go: -------------------------------------------------------------------------------- 1 | package resourceoperation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/sts" 12 | "github.com/samsarahq/go/oops" 13 | "github.com/santiago-labs/telophasecli/cmd/runner" 14 | "github.com/santiago-labs/telophasecli/lib/awssess" 15 | "github.com/santiago-labs/telophasecli/lib/awssts" 16 | "github.com/santiago-labs/telophasecli/lib/cdk" 17 | "github.com/santiago-labs/telophasecli/lib/localstack" 18 | "github.com/santiago-labs/telophasecli/resource" 19 | ) 20 | 21 | type cdkOperation struct { 22 | Account *resource.Account 23 | Operation int 24 | Stack resource.Stack 25 | OutputUI runner.ConsoleUI 26 | DependentOperations []ResourceOperation 27 | } 28 | 29 | func NewCDKOperation(consoleUI runner.ConsoleUI, acct *resource.Account, stack resource.Stack, op int) ResourceOperation { 30 | return &cdkOperation{ 31 | Account: acct, 32 | Operation: op, 33 | Stack: stack, 34 | OutputUI: consoleUI, 35 | } 36 | } 37 | 38 | func (co *cdkOperation) AddDependent(op ResourceOperation) { 39 | co.DependentOperations = append(co.DependentOperations, op) 40 | } 41 | 42 | func (co *cdkOperation) ListDependents() []ResourceOperation { 43 | return co.DependentOperations 44 | } 45 | 46 | func (co *cdkOperation) Call(ctx context.Context) error { 47 | co.OutputUI.Print(fmt.Sprintf("Executing CDK stack in %s", co.Stack.Path), *co.Account) 48 | 49 | creds, region, err := authAWS(*co.Account, *co.Stack.RoleARN(*co.Account), co.OutputUI) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if co.Stack.Region != "" { 55 | region = co.Stack.Region 56 | } 57 | 58 | // We must bootstrap cdk with the account role. 59 | bootstrapCDK := bootstrapCDK(creds, region, *co.Account, co.Stack) 60 | if err := co.OutputUI.RunCmd(bootstrapCDK, *co.Account); err != nil { 61 | return err 62 | } 63 | 64 | synthCDK := synthCDK(creds, *co.Account, co.Stack) 65 | if err := co.OutputUI.RunCmd(synthCDK, *co.Account); err != nil { 66 | return err 67 | } 68 | 69 | var cdkArgs []string 70 | if co.Operation == Diff { 71 | cdkArgs = []string{ 72 | "diff", 73 | } 74 | } else if co.Operation == Deploy { 75 | cdkArgs = []string{"deploy", "--require-approval", "never"} 76 | } 77 | 78 | cdkArgs = append(cdkArgs, cdkDefaultArgs(*co.Account, co.Stack)...) 79 | // Deploy all CDK stacks every time. 80 | cdkArgs = append(cdkArgs, "--all") 81 | 82 | cmd := exec.Command(localstack.CdkCmd(), cdkArgs...) 83 | cmd.Dir = co.Stack.Path 84 | cmd.Env = awssts.SetEnvironCreds(os.Environ(), 85 | creds, 86 | co.Stack.AWSRegionEnv(), 87 | ) 88 | if err := co.OutputUI.RunCmd(cmd, *co.Account); err != nil { 89 | return err 90 | } 91 | 92 | for _, op := range co.DependentOperations { 93 | if err := op.Call(ctx); err != nil { 94 | return err 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (co *cdkOperation) ToString() string { 102 | return "" 103 | } 104 | 105 | func bootstrapCDK(creds *sts.Credentials, region string, acct resource.Account, stack resource.Stack) *exec.Cmd { 106 | cdkArgs := append([]string{ 107 | "bootstrap", 108 | fmt.Sprintf("aws://%s/%s", acct.AccountID, region), 109 | }, 110 | cdkDefaultArgs(acct, stack)..., 111 | ) 112 | 113 | cmd := exec.Command(localstack.CdkCmd(), cdkArgs...) 114 | cmd.Dir = stack.Path 115 | cmd.Env = awssts.SetEnvironCreds(os.Environ(), 116 | creds, 117 | stack.AWSRegionEnv(), 118 | ) 119 | 120 | return cmd 121 | } 122 | 123 | func synthCDK(creds *sts.Credentials, acct resource.Account, stack resource.Stack) *exec.Cmd { 124 | cdkArgs := append( 125 | []string{"synth"}, 126 | cdkDefaultArgs(acct, stack)..., 127 | ) 128 | 129 | cmd := exec.Command(localstack.CdkCmd(), cdkArgs...) 130 | cmd.Dir = stack.Path 131 | cmd.Env = awssts.SetEnvironCreds(os.Environ(), 132 | creds, 133 | stack.AWSRegionEnv(), 134 | ) 135 | 136 | return cmd 137 | } 138 | 139 | func authAWS(acct resource.Account, arn string, consoleUI runner.ConsoleUI) (*sts.Credentials, string, error) { 140 | if os.Getenv("TELOPHASE_BYPASS_ASSUME_ROLE") != "" { 141 | return nil, "us-east-1", nil 142 | } 143 | 144 | var svc *sts.STS 145 | sess := session.Must(awssess.DefaultSession()) 146 | svc = sts.New(sess) 147 | 148 | consoleUI.Print(fmt.Sprintf("Assuming role: %s", arn), acct) 149 | input := &sts.AssumeRoleInput{ 150 | RoleArn: aws.String(arn), 151 | RoleSessionName: aws.String("telophase-org"), 152 | } 153 | 154 | role, err := awssess.AssumeRole(svc, input) 155 | if err != nil { 156 | // If we are in the management account the OrganizationAccountAccessRole 157 | // does not exist so fallback to the current credentials. 158 | if acct.ManagementAccount { 159 | // I tried to use GetSessionToken to satisfy a type and avoid 160 | // passing around a nil. However, GetSessionToken's output keys are 161 | // not allowed to make any IAM changes. 162 | // 163 | // Return nil because we don't need to assume a role. 164 | return nil, "us-east-1", nil 165 | } 166 | 167 | return nil, "", oops.Wrapf(err, "AssumeRole") 168 | } 169 | return role.Credentials, *sess.Config.Region, nil 170 | } 171 | 172 | func cdkDefaultArgs(acct resource.Account, stack resource.Stack) []string { 173 | return []string{ 174 | "--context", fmt.Sprintf("telophaseAccountName=%s", acct.AccountName), 175 | "--context", fmt.Sprintf("telophaseAccountId=%s", acct.AccountID), 176 | "--output", cdk.TmpPath(acct, stack.Path), 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /resource/stack.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/cloudformation" 11 | "github.com/samsarahq/go/oops" 12 | ) 13 | 14 | type Stack struct { 15 | // When adding a new type to the struct, make sure you add it to the `NewForRegion` method. 16 | Name string `yaml:"Name"` 17 | Type string `yaml:"Type"` 18 | Path string `yaml:"Path"` 19 | Region string `yaml:"Region,omitempty"` 20 | RoleOverrideARNDeprecated string `yaml:"RoleOverrideARN,omitempty"` // Deprecated 21 | AssumeRoleName string `yaml:"AssumeRoleName,omitempty"` 22 | Workspace string `yaml:"Workspace,omitempty"` 23 | 24 | CloudformationParameters []string `yaml:"CloudformationParameters,omitempty"` 25 | CloudformationCapabilities []string `yaml:"CloudformationCapabilities,omitempty"` 26 | } 27 | 28 | func (s Stack) NewForRegion(region string) Stack { 29 | return Stack{ 30 | Name: s.Name, 31 | Type: s.Type, 32 | Path: s.Path, 33 | Region: region, 34 | RoleOverrideARNDeprecated: s.RoleOverrideARNDeprecated, 35 | AssumeRoleName: s.AssumeRoleName, 36 | Workspace: s.Workspace, 37 | 38 | CloudformationParameters: s.CloudformationParameters, 39 | CloudformationCapabilities: s.CloudformationCapabilities, 40 | } 41 | } 42 | 43 | func (s Stack) RoleARN(acct Account) *string { 44 | if s.AssumeRoleName != "" { 45 | result := fmt.Sprintf("arn:aws:iam::%s:role/%s", acct.AccountID, s.AssumeRoleName) 46 | return &result 47 | } 48 | 49 | acctRole := acct.AssumeRoleARN() 50 | return &acctRole 51 | } 52 | 53 | func (s Stack) AWSRegionEnv() *string { 54 | if s.Region != "" { 55 | v := "AWS_REGION=" + s.Region 56 | return &v 57 | } 58 | return nil 59 | } 60 | 61 | func (s Stack) WorkspaceEnabled() bool { 62 | return s.Workspace != "" 63 | } 64 | 65 | func (s Stack) Validate() error { 66 | switch os := s.Type; os { 67 | case "Terraform": 68 | return nil 69 | 70 | case "CDK": 71 | if s.Workspace != "" { 72 | return oops.Errorf("Workspace: (%s) should not be set for CDK stack", s.Workspace) 73 | } 74 | return nil 75 | 76 | case "Cloudformation": 77 | if _, err := s.CloudformationParametersType(); err != nil { 78 | return oops.Wrapf(err, "") 79 | } 80 | 81 | if valid := s.ValidCloudformationCapabilities(); !valid { 82 | return fmt.Errorf("cloudformation capabilities set: %v is not one of the valid capabilities (CAPABILITY_IAM | CAPABILITY_NAMED_IAM | CAPABILITY_AUTO_EXPAND)", s.CloudformationCapabilities) 83 | } 84 | if s.Workspace != "" { 85 | return oops.Errorf("Workspace: (%s) should not be set for Cloudformation stack", s.Workspace) 86 | } 87 | return nil 88 | 89 | case "": 90 | return oops.Errorf("stack type needs to be set for stack: %+v", s) 91 | 92 | default: 93 | return oops.Errorf("only support stack types of `Cloudformation`, `Terraform` and `CDK` not: %s", s.Type) 94 | } 95 | } 96 | 97 | func (s Stack) CloudformationParametersType() ([]*cloudformation.Parameter, error) { 98 | var params []*cloudformation.Parameter 99 | for _, param := range s.CloudformationParameters { 100 | parts := strings.Split(param, "=") 101 | if len(parts) != 2 { 102 | return nil, oops.Errorf("cloudformation parameter (%s) should be = delimited and have 2 parts it has %d parts", param, len(parts)) 103 | } 104 | 105 | params = append(params, &cloudformation.Parameter{ 106 | ParameterKey: aws.String(parts[0]), 107 | ParameterValue: aws.String(parts[1]), 108 | }) 109 | } 110 | 111 | return params, nil 112 | } 113 | 114 | func (s Stack) CloudformationCapabilitiesArg() []*string { 115 | var result []*string 116 | for _, cap := range s.CloudformationCapabilities { 117 | mirror := cap 118 | result = append(result, &mirror) 119 | } 120 | 121 | return result 122 | } 123 | 124 | func (s Stack) ValidCloudformationCapabilities() bool { 125 | validCapabilities := map[string]struct{}{ 126 | "CAPABILITY_IAM": {}, 127 | "CAPABILITY_NAMED_IAM": {}, 128 | "CAPABILITY_AUTO_EXPAND": {}, 129 | } 130 | 131 | for _, cap := range s.CloudformationCapabilities { 132 | if _, ok := validCapabilities[cap]; !ok { 133 | return false 134 | } 135 | } 136 | 137 | return true 138 | } 139 | 140 | // CloudformationStackName returns the corresponding stack name to create in cloudformation. 141 | // 142 | // The name needs to match [a-zA-Z][-a-zA-Z0-9]*|arn:[-a-zA-Z0-9:/._+]* 143 | func (s Stack) CloudformationStackName() *string { 144 | // Replace: 145 | // - "/" with "-", "/" appears in the path 146 | // - "." with "-", "." shows up with "".yml" or ".json" 147 | name := strings.ReplaceAll(strings.ReplaceAll(s.Path, "/", "-"), ".", "-") 148 | if s.Name != "" { 149 | name = strings.ReplaceAll(s.Name, " ", "-") + "-" + name 150 | } 151 | if s.Region != "" { 152 | name = name + "-" + s.Region 153 | } 154 | 155 | // Stack name needs to start with [a-zA-Z] 156 | // Remove leading characters that are not alphabetic 157 | firstAlphaRegex := regexp.MustCompile(`^[^a-zA-Z]*`) 158 | name = firstAlphaRegex.ReplaceAllString(name, "") 159 | 160 | // Ensure the first character is alphabetic (already guaranteed by the previous step) 161 | // Remove or replace all characters that do not match [-a-zA-Z0-9] 162 | validCharsRegex := regexp.MustCompile(`[^-a-zA-Z0-9]+`) 163 | name = validCharsRegex.ReplaceAllString(name, "-") 164 | 165 | return &name 166 | } 167 | 168 | func (s Stack) ChangeSetName() *string { 169 | changeSetName := fmt.Sprintf("telophase-%s-%d", *s.CloudformationStackName(), time.Now().Unix()) 170 | return &changeSetName 171 | } 172 | -------------------------------------------------------------------------------- /resourceoperation/terraform.go: -------------------------------------------------------------------------------- 1 | package resourceoperation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/aws/aws-sdk-go/service/sts" 12 | "github.com/samsarahq/go/oops" 13 | "github.com/santiago-labs/telophasecli/cmd/runner" 14 | "github.com/santiago-labs/telophasecli/lib/awssts" 15 | "github.com/santiago-labs/telophasecli/lib/localstack" 16 | "github.com/santiago-labs/telophasecli/lib/terraform" 17 | "github.com/santiago-labs/telophasecli/resource" 18 | ) 19 | 20 | type tfOperation struct { 21 | Account *resource.Account 22 | Operation int 23 | Stack resource.Stack 24 | OutputUI runner.ConsoleUI 25 | DependentOperations []ResourceOperation 26 | } 27 | 28 | func NewTFOperation(consoleUI runner.ConsoleUI, acct *resource.Account, stack resource.Stack, op int) ResourceOperation { 29 | return &tfOperation{ 30 | Account: acct, 31 | Operation: op, 32 | Stack: stack, 33 | OutputUI: consoleUI, 34 | } 35 | } 36 | 37 | func (to *tfOperation) AddDependent(op ResourceOperation) { 38 | to.DependentOperations = append(to.DependentOperations, op) 39 | } 40 | 41 | func (to *tfOperation) ListDependents() []ResourceOperation { 42 | return to.DependentOperations 43 | } 44 | 45 | func (to *tfOperation) Call(ctx context.Context) error { 46 | to.OutputUI.Print(fmt.Sprintf("Executing Terraform stack in %s", to.Stack.Path), *to.Account) 47 | 48 | var creds *sts.Credentials 49 | var assumeRoleErr error 50 | if to.Account.AccountID != "" { 51 | if roleArn := to.Stack.RoleARN(*to.Account); roleArn != nil { 52 | creds, _, assumeRoleErr = authAWS(*to.Account, *roleArn, to.OutputUI) 53 | } else { 54 | creds, _, assumeRoleErr = authAWS(*to.Account, to.Account.AssumeRoleARN(), to.OutputUI) 55 | } 56 | 57 | if assumeRoleErr != nil { 58 | return assumeRoleErr 59 | } 60 | } 61 | 62 | initTFCmd := to.initTf(creds) 63 | if initTFCmd != nil { 64 | if err := to.OutputUI.RunCmd(initTFCmd, *to.Account); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | // Set workspace if we are using it. 70 | setWorkspace, err := to.setWorkspace(creds) 71 | if err != nil { 72 | return err 73 | } 74 | if setWorkspace != nil { 75 | if err := to.OutputUI.RunCmd(setWorkspace, *to.Account); err != nil { 76 | return err 77 | } 78 | } 79 | 80 | var args []string 81 | if to.Operation == Diff { 82 | args = []string{ 83 | "plan", 84 | } 85 | } else if to.Operation == Deploy { 86 | args = []string{ 87 | "apply", "-auto-approve", 88 | } 89 | } 90 | 91 | workingPath := terraform.TmpPath(*to.Account, to.Stack.Path) 92 | cmd := exec.Command(localstack.TfCmd(), args...) 93 | cmd.Dir = workingPath 94 | 95 | cmd.Env = awssts.SetEnvironCreds(os.Environ(), 96 | creds, 97 | to.Stack.AWSRegionEnv(), 98 | ) 99 | 100 | if err := to.OutputUI.RunCmd(cmd, *to.Account); err != nil { 101 | return err 102 | } 103 | 104 | for _, op := range to.DependentOperations { 105 | if err := op.Call(ctx); err != nil { 106 | return err 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func (to *tfOperation) initTf(creds *sts.Credentials) *exec.Cmd { 114 | workingPath := terraform.TmpPath(*to.Account, to.Stack.Path) 115 | terraformDir := filepath.Join(workingPath, ".terraform") 116 | if terraformDir == "" || !strings.Contains(terraformDir, "telophasedirs") { 117 | to.OutputUI.Print("expected terraform dir to be set", *to.Account) 118 | return nil 119 | } 120 | // Clean the directory 121 | if err := os.RemoveAll(terraformDir); err != nil { 122 | to.OutputUI.Print(fmt.Sprintf("Error: failed to remove directory %s: %v", terraformDir, err), *to.Account) 123 | return nil 124 | } 125 | 126 | if _, err := os.Stat(terraformDir); os.IsNotExist(err) { 127 | if err := os.MkdirAll(workingPath, 0755); err != nil { 128 | to.OutputUI.Print(fmt.Sprintf("Error: failed to create directory %s: %v", terraformDir, err), *to.Account) 129 | return nil 130 | } 131 | 132 | if err := terraform.CopyDir(to.Stack, workingPath, *to.Account); err != nil { 133 | to.OutputUI.Print(fmt.Sprintf("Error: failed to copy files from %s to %s: %v", to.Stack.Path, workingPath, err), *to.Account) 134 | return nil 135 | } 136 | 137 | cmd := exec.Command(localstack.TfCmd(), "init") 138 | cmd.Dir = workingPath 139 | 140 | cmd.Env = awssts.SetEnvironCreds(os.Environ(), 141 | creds, 142 | to.Stack.AWSRegionEnv(), 143 | ) 144 | 145 | return cmd 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func replaceVals(workspace, AccountID, Region string) (string, error) { 152 | currentContent := workspace 153 | // Bracketed needs to be checked before non-bracketed otherwise {telophase.account_id} will replaced with {11111}. 154 | currentContent = strings.ReplaceAll(currentContent, "${telophase.account_id}", AccountID) 155 | currentContent = strings.ReplaceAll(currentContent, "telophase.account_id", AccountID) 156 | 157 | preRegionContent := currentContent 158 | currentContent = strings.ReplaceAll(currentContent, "${telophase.region}", Region) 159 | currentContent = strings.ReplaceAll(currentContent, "telophase.region", Region) 160 | if currentContent != preRegionContent && Region == "" { 161 | return "", oops.Errorf("Region needs to be set on stack if performing substitution") 162 | } 163 | 164 | return currentContent, nil 165 | } 166 | 167 | func (to *tfOperation) setWorkspace(creds *sts.Credentials) (*exec.Cmd, error) { 168 | if !to.Stack.WorkspaceEnabled() { 169 | return nil, nil 170 | } 171 | 172 | workingPath := terraform.TmpPath(*to.Account, to.Stack.Path) 173 | 174 | rewrittenWorkspace, err := replaceVals(to.Stack.Workspace, to.Account.AccountID, to.Stack.Region) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | cmd := exec.Command(localstack.TfCmd(), "workspace", "select", "-or-create", rewrittenWorkspace) 180 | cmd.Dir = workingPath 181 | 182 | cmd.Env = awssts.SetEnvironCreds(os.Environ(), 183 | creds, 184 | to.Stack.AWSRegionEnv(), 185 | ) 186 | 187 | return cmd, nil 188 | } 189 | 190 | func (to *tfOperation) ToString() string { 191 | return "" 192 | } 193 | -------------------------------------------------------------------------------- /resourceoperation/scp.go: -------------------------------------------------------------------------------- 1 | package resourceoperation 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/aws/aws-sdk-go/service/sts" 15 | "github.com/santiago-labs/telophasecli/cmd/runner" 16 | "github.com/santiago-labs/telophasecli/lib/awsorgs" 17 | "github.com/santiago-labs/telophasecli/lib/awssts" 18 | "github.com/santiago-labs/telophasecli/lib/localstack" 19 | "github.com/santiago-labs/telophasecli/lib/terraform" 20 | "github.com/santiago-labs/telophasecli/resource" 21 | ) 22 | 23 | func CollectSCPOps( 24 | ctx context.Context, 25 | orgClient awsorgs.Client, 26 | consoleUI runner.ConsoleUI, 27 | operation int, 28 | rootOU *resource.OrganizationUnit, 29 | mgmtAcct *resource.Account, 30 | ) []ResourceOperation { 31 | 32 | var ops []ResourceOperation 33 | for _, ou := range rootOU.AllDescendentOUs() { 34 | if ou.OUID == nil { 35 | consoleUI.Print(fmt.Sprintf("Skipping OU because it is not yet created: %s", ou.OUName), *mgmtAcct) 36 | continue 37 | } 38 | for _, scp := range ou.ServiceControlPolicies { 39 | ops = append(ops, NewSCPOperation( 40 | consoleUI, 41 | nil, 42 | mgmtAcct, 43 | ou, 44 | scp, 45 | operation, 46 | )) 47 | } 48 | } 49 | 50 | for _, acct := range rootOU.AllDescendentAccounts() { 51 | if acct.AccountID == "" { 52 | consoleUI.Print(fmt.Sprintf("Skipping Account because it is not yet created: %s", acct.AccountName), *mgmtAcct) 53 | continue 54 | } 55 | for _, scp := range acct.ServiceControlPolicies { 56 | ops = append(ops, NewSCPOperation( 57 | consoleUI, 58 | acct, 59 | mgmtAcct, 60 | nil, 61 | scp, 62 | operation, 63 | )) 64 | } 65 | } 66 | 67 | return ops 68 | } 69 | 70 | type scpOperation struct { 71 | TargetAcct *resource.Account 72 | TargetOU *resource.OrganizationUnit 73 | MgmtAcct *resource.Account 74 | Operation int 75 | Stack resource.Stack 76 | OutputUI runner.ConsoleUI 77 | DependentOperations []ResourceOperation 78 | } 79 | 80 | func NewSCPOperation( 81 | consoleUI runner.ConsoleUI, 82 | targetAcct, mgmtAcct *resource.Account, 83 | targetOU *resource.OrganizationUnit, 84 | stack resource.Stack, 85 | op int, 86 | ) ResourceOperation { 87 | return &scpOperation{ 88 | TargetAcct: targetAcct, 89 | TargetOU: targetOU, 90 | MgmtAcct: mgmtAcct, 91 | Operation: op, 92 | Stack: stack, 93 | OutputUI: consoleUI, 94 | } 95 | } 96 | 97 | func (so *scpOperation) AddDependent(op ResourceOperation) { 98 | so.DependentOperations = append(so.DependentOperations, op) 99 | } 100 | 101 | func (so *scpOperation) ListDependents() []ResourceOperation { 102 | return so.DependentOperations 103 | } 104 | 105 | func (so *scpOperation) Call(ctx context.Context) error { 106 | so.OutputUI.Print(fmt.Sprintf("Executing SCP Terraform stack in %s", so.Stack.Path), *so.MgmtAcct) 107 | 108 | var creds *sts.Credentials 109 | if so.MgmtAcct.AssumeRoleName != "" { 110 | var err error 111 | creds, _, err = authAWS(*so.MgmtAcct, so.MgmtAcct.AssumeRoleARN(), so.OutputUI) 112 | if err != nil { 113 | return err 114 | } 115 | } 116 | 117 | initTFCmd, err := so.initTf() 118 | if err != nil { 119 | so.OutputUI.Print(fmt.Sprintf("Error initializing terraform: %s", err), *so.MgmtAcct) 120 | return err 121 | } 122 | 123 | if initTFCmd != nil { 124 | initTFCmd.Env = awssts.SetEnvironCreds(os.Environ(), 125 | creds, 126 | // SCPs can't have regions 127 | nil, 128 | ) 129 | if err := so.OutputUI.RunCmd(initTFCmd, *so.MgmtAcct); err != nil { 130 | return err 131 | } 132 | } 133 | 134 | var args []string 135 | if so.Operation == Diff { 136 | args = []string{ 137 | "plan", 138 | } 139 | } else if so.Operation == Deploy { 140 | args = []string{ 141 | "apply", "-auto-approve", 142 | } 143 | } 144 | 145 | workingPath := so.tmpPath() 146 | cmd := exec.Command(localstack.TfCmd(), args...) 147 | cmd.Dir = workingPath 148 | cmd.Env = awssts.SetEnvironCreds(os.Environ(), 149 | creds, 150 | // SCPs don't have regions 151 | nil, 152 | ) 153 | 154 | if err := so.OutputUI.RunCmd(cmd, *so.MgmtAcct); err != nil { 155 | return err 156 | } 157 | 158 | for _, op := range so.DependentOperations { 159 | if err := op.Call(ctx); err != nil { 160 | return err 161 | } 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func (so *scpOperation) initTf() (*exec.Cmd, error) { 168 | workingPath := so.tmpPath() 169 | terraformDir := filepath.Join(workingPath, ".terraform") 170 | if terraformDir == "" || !strings.Contains(terraformDir, "telophasedirs") { 171 | return nil, fmt.Errorf("expected terraform dir to be set") 172 | } 173 | 174 | if err := os.RemoveAll(terraformDir); err != nil { 175 | return nil, fmt.Errorf("failed to remove directory %s: %v", terraformDir, err) 176 | } 177 | 178 | _, err := os.Stat(terraformDir) 179 | if os.IsNotExist(err) { 180 | if err := os.MkdirAll(workingPath, 0755); err != nil { 181 | return nil, fmt.Errorf("failed to create directory %s: %v", terraformDir, err) 182 | } 183 | 184 | if err := terraform.CopyDir(so.Stack, workingPath, so.targetResource()); err != nil { 185 | return nil, fmt.Errorf("failed to copy files from %s to %s: %v", so.Stack.Path, workingPath, err) 186 | } 187 | 188 | cmd := exec.Command(localstack.TfCmd(), "init") 189 | cmd.Dir = workingPath 190 | 191 | return cmd, nil 192 | } 193 | 194 | return nil, err 195 | } 196 | 197 | func (so *scpOperation) targetResource() resource.Resource { 198 | if so.TargetAcct != nil { 199 | return so.TargetAcct 200 | } 201 | return so.TargetOU 202 | } 203 | 204 | func (so *scpOperation) tmpPath() string { 205 | hasher := sha256.New() 206 | hasher.Write([]byte(so.Stack.Path)) 207 | hashBytes := hasher.Sum(nil) 208 | hashString := hex.EncodeToString(hashBytes) 209 | 210 | return path.Join("telophasedirs", fmt.Sprintf("tf-tmp-%s-%s-%s", so.MgmtAcct.ID(), so.targetResource().ID(), hashString)) 211 | } 212 | 213 | func (so *scpOperation) ToString() string { 214 | return "" 215 | } 216 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/samsarahq/go/oops" 10 | "github.com/santiago-labs/telophasecli/cmd/runner" 11 | "github.com/santiago-labs/telophasecli/lib/awsorgs" 12 | "github.com/santiago-labs/telophasecli/lib/metrics" 13 | "github.com/santiago-labs/telophasecli/lib/ymlparser" 14 | "github.com/santiago-labs/telophasecli/resource" 15 | "github.com/santiago-labs/telophasecli/resourceoperation" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var rootCmd = &cobra.Command{ 20 | Use: "telophasecli", 21 | Short: "telophasecli - Command line interface for Telophase", 22 | Run: func(cmd *cobra.Command, args []string) { 23 | fmt.Fprintf(os.Stderr, "Please pass in a command. See more with -h\n") 24 | os.Exit(1) 25 | }, 26 | } 27 | 28 | func Execute() { 29 | metrics.Init() 30 | metrics.RegisterCommand() 31 | defer metrics.Close() 32 | 33 | if err := rootCmd.Execute(); err != nil { 34 | fmt.Fprintf(os.Stderr, "Whoops. There was an error while executing your CLI '%s'", err) 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | func setOpsError() error { 40 | return fmt.Errorf("error running operations") 41 | } 42 | 43 | func ProcessOrgEndToEnd(consoleUI runner.ConsoleUI, cmd int, targets []string) error { 44 | ctx := context.Background() 45 | orgClient := awsorgs.New(nil) 46 | rootAWSOU, err := ymlparser.NewParser(orgClient).ParseOrganization(ctx, orgFile) 47 | if err != nil { 48 | consoleUI.Print(fmt.Sprintf("error: %s", err), resource.Account{AccountID: "error", AccountName: "error"}) 49 | return oops.Wrapf(err, "ParseOrg") 50 | } 51 | 52 | if rootAWSOU == nil { 53 | consoleUI.Print("Could not parse AWS Organization", resource.Account{AccountID: "error", AccountName: "error"}) 54 | return oops.Errorf("No root AWS OU") 55 | } 56 | 57 | mgmtAcct, err := resolveMgmtAcct(ctx, orgClient, rootAWSOU) 58 | if err != nil { 59 | consoleUI.Print(fmt.Sprintf("Could not fetch AWS Management Account: %s", err), resource.Account{AccountID: "error", AccountName: "error"}) 60 | return oops.Wrapf(err, "resolveMgmtAcct") 61 | } 62 | 63 | var deployStacks bool 64 | var deploySCP bool 65 | var deployOrganization bool 66 | 67 | for _, target := range targets { 68 | if strings.ReplaceAll(target, " ", "") == "stacks" { 69 | deployStacks = true 70 | } 71 | if strings.ReplaceAll(target, " ", "") == "scp" { 72 | deploySCP = true 73 | } 74 | if strings.ReplaceAll(target, " ", "") == "organization" { 75 | deployOrganization = true 76 | } 77 | } 78 | 79 | // opsError is the error we return eventually. We want to allow partially 80 | // applied operations across organizations, IaC, and SCPs so we only return 81 | // this error in the end. 82 | var opsError error 83 | 84 | if len(targets) == 0 || deployOrganization { 85 | orgOps := resourceoperation.CollectOrganizationUnitOps( 86 | ctx, consoleUI, orgClient, mgmtAcct, rootAWSOU, cmd, allowDeleteAccount, 87 | ) 88 | for _, op := range resourceoperation.FlattenOperations(orgOps) { 89 | consoleUI.Print(op.ToString(), *mgmtAcct) 90 | } 91 | if len(orgOps) == 0 { 92 | consoleUI.Print("\033[32m No changes to AWS Organization. \033[0m", *mgmtAcct) 93 | } 94 | 95 | if cmd == resourceoperation.Deploy { 96 | for _, op := range orgOps { 97 | err := op.Call(ctx) 98 | if err != nil { 99 | consoleUI.Print(fmt.Sprintf("Error on AWS Organization Operation: %v", err), *mgmtAcct) 100 | opsError = setOpsError() 101 | } 102 | } 103 | } 104 | } 105 | 106 | if len(targets) == 0 || deployStacks { 107 | totalTags := strings.Split(tag, ",") 108 | var accountsToApply []resource.Account 109 | for _, acct := range rootAWSOU.AllDescendentAccounts() { 110 | for _, tag := range totalTags { 111 | if contains(tag, acct.AllTags()) || tag == "" { 112 | accountsToApply = append(accountsToApply, *acct) 113 | } 114 | } 115 | } 116 | 117 | if len(accountsToApply) == 0 { 118 | consoleUI.Print("No accounts to deploy.", *mgmtAcct) 119 | } 120 | 121 | err := runIAC(ctx, consoleUI, cmd, accountsToApply) 122 | if err != nil { 123 | consoleUI.Print("No accounts to deploy.", *mgmtAcct) 124 | opsError = setOpsError() 125 | } 126 | } 127 | 128 | if len(targets) == 0 || deploySCP { 129 | // Telophasecli can be run from either the management account or 130 | // the delegated administrator account. 131 | var scpAdmin *resource.Account 132 | delegatedAdmin := rootAWSOU.DelegatedAdministrator() 133 | if delegatedAdmin != nil { 134 | scpAdmin = delegatedAdmin 135 | } else { 136 | scpAdmin = mgmtAcct 137 | } 138 | 139 | scpOps := resourceoperation.CollectSCPOps(ctx, orgClient, consoleUI, cmd, rootAWSOU, scpAdmin) 140 | for _, op := range scpOps { 141 | err := op.Call(ctx) 142 | if err != nil { 143 | consoleUI.Print(fmt.Sprintf("Error on SCP Operation: %v", err), *scpAdmin) 144 | opsError = setOpsError() 145 | } 146 | } 147 | 148 | if len(scpOps) == 0 { 149 | consoleUI.Print("No Service Control Policies to deploy.", *scpAdmin) 150 | } 151 | } 152 | 153 | consoleUI.Print("Done.\n", *mgmtAcct) 154 | return opsError 155 | } 156 | 157 | func validateTargets() error { 158 | if targets == "" { 159 | return nil 160 | } 161 | 162 | for _, target := range strings.Split(targets, ",") { 163 | strippedTarget := strings.ReplaceAll(target, " ", "") 164 | if strippedTarget != "stacks" && strippedTarget != "scp" && strippedTarget != "organization" { 165 | return fmt.Errorf("invalid targets: %s", targets) 166 | } 167 | } 168 | 169 | return nil 170 | } 171 | 172 | func resolveMgmtAcct( 173 | ctx context.Context, 174 | orgClient awsorgs.Client, 175 | rootAWSOU *resource.OrganizationUnit, 176 | ) (*resource.Account, error) { 177 | // We must have access to the management account so that we can create Accounts and OUs, 178 | // but the management account does not need to be managed by Telophase. 179 | parsedMgmtAcct := rootAWSOU.ManagementAccount() 180 | if parsedMgmtAcct != nil { 181 | return parsedMgmtAcct, nil 182 | } 183 | 184 | fetchedMgmtAcct, err := orgClient.FetchManagementAccount(ctx) 185 | if err != nil { 186 | return nil, oops.Wrapf(err, "FetchManagementAccount") 187 | } 188 | return fetchedMgmtAcct, nil 189 | } 190 | 191 | func filterEmptyStrings(slice []string) []string { 192 | var result []string 193 | for _, str := range slice { 194 | if str != "" { 195 | result = append(result, str) 196 | } 197 | } 198 | return result 199 | } 200 | -------------------------------------------------------------------------------- /mintlifydocs/config/organization.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'organization.yml' 3 | description: 'Configure your `organization.yml`' 4 | --- 5 | 6 | This file, `organization.yml` represents your AWS Organization. Any changes to `Accounts` or `OrganizationUnits` will be reflected in your AWS Organization. You can create new accounts or organization units, move accounts to different organization units, and assign `Terraform`, `Cloudformation`, `CDK`stacks at **any** level in the hierarchy. 7 | 8 | # Structure 9 | Telophase allows you to structure accounts in any way the cloud provider allows. You can nest `Organization Units` within each other. 10 | 11 | ```yaml 12 | Organization: 13 | Name: root ## AWS Organization Root 14 | OrganizationUnits: ## Organization Units 15 | - Name: ProductionTenants 16 | Stacks: ## Terraform, Cloudformation and CDK stacks to apply to all accounts in this Organization Unit 17 | - Path: go/src/cdk 18 | Type: CDK 19 | Name: telophase-owner 20 | Accounts: ## Accounts at the top level of this Organization Unit 21 | - Email: ethan+clitest4@telophase.dev 22 | AccountName: cli-test4 23 | Tags: 24 | - "production" 25 | Accounts: 26 | - AccountName: Management 27 | Email: mgmt@telophase.dev 28 | ``` 29 | 30 | # AWS Organization Root 31 | `Organization:` instructs the Telophase parser that an AWS Organization follows. It has no corresponding representation in AWS. 32 | `Name: root` represents the root organization unit in your AWS Organization. It is the parent for all accounts and organization units. It cannot be renamed. 33 | 34 | ```yaml 35 | Organization: 36 | Name: root # (Required) This must be set to "Name: root". 37 | Accounts: # (Optional) Child accounts of root Organization Unit. 38 | OrganizationUnits: # (Optional) Child Organization Units of the root Organization Unit. 39 | ``` 40 | 41 | # Account 42 | `Accounts` represents a list of AWS `Account`s. 43 | 44 | ```yaml 45 | Accounts: 46 | - Email: # (Required) Email used to create the account. This will be the root user for this account. 47 | AccountName: # (Required) Name of the account. 48 | Delete: # (Optional) Set to true if you want telophase to close the account, after closing an account it can be removed from organizations.yml. 49 | # If deleting an account you need to pass in --allow-account-delete to telophasecli as a confirmation of the deletion. 50 | Tags: # (Optional) Telophase label for this account. Tags translate to AWS tags with a `=` as the key value delimiter. For example, `telophase:env=prod` 51 | Stacks: # (Optional) Terraform, Cloudformation and CDK stacks to apply to all accounts in this Organization Unit. 52 | DelegatedAdministratorServices: # (Optional) List of delegated service principals for the current account (e.g. config.amazonaws.com) 53 | ``` 54 | 55 | ## Example 56 | ```yaml 57 | Accounts: 58 | - Email: us-prod@telophase.dev 59 | AccountName: us-prod 60 | - Email: eu-prod@telophase.dev 61 | AccountName: eu-prod 62 | ``` 63 | 64 | This will create two Accounts: 65 | 1. `us-prod` with root user `us-prod@telophase.dev` 66 | 2. `eu-prod` with root user `eu-prod@telophase.dev` 67 | 68 | # OrganizationUnits 69 | `OrganizationUnits` represents a list of AWS `Organization Unit`s. 70 | 71 | ```yaml 72 | OrganizationUnits: 73 | - Name: # (Required) Name of the Organization Unit. 74 | Accounts: # (Optional) Child accounts of this Organization Unit. 75 | Stacks: # (Optional) Terraform, Cloudformation, and CDK stacks to apply to all accounts in this Organization Unit. 76 | OrganizationUnits: # (Optional) Child Organization Units of this Organization Unit. 77 | - OUFilepath: # (Oprtional) provide a filepath to load a separate OU into telophase. 78 | ``` 79 | 80 | ### Example 81 | ```yaml 82 | OrganizationUnits: 83 | - Name: Production 84 | Accounts: 85 | - Email: us-prod@telophase.dev 86 | AccountName: us-prod 87 | - Email: eu-prod@telophase.dev 88 | AccountName: eu-prod 89 | - Name: Dev Accounts 90 | Accounts: 91 | - Email: developer1@telophase.dev 92 | AccountName: developer1 93 | - Email: developer2@telophase.dev 94 | AccountName: developer2 95 | ``` 96 | 97 | This will create two OUs: 98 | 1. `Production` with child accounts `us-prod` and `eu-prod` 99 | 2. `Dev Accounts` with child accounts `developer1` and `developer2` 100 | 101 | # Stacks 102 | Terraform, Cloudformation and CDK stacks can be assigned to `Account`s and `OrganizationUnits`s. Stacks assigned to `OrganizationUnits` will be applied to all child `Account`s. 103 | 104 | ```yaml 105 | Stacks: 106 | - Path: # (Required) Path to CDK or Terraform project. This must be a directory. 107 | Type: # (Required) "Terraform", "CDK", "Cloudformation . 108 | Name: # (Optional) Name of the Stack to filter on with --stacks. 109 | AssumeRoleName: # (Optional) Force the stack to use a specific role when applying a stack. The default role is the account's `AssumeRoleName` which is typically the `OrganizationAccountAccessRole`. 110 | Region: # (Optional) What region the stack's resources will be provisioned in. Region can be a comma separated list of regions or "all" to apply to all regions in an account. 111 | Workspace: # (Optional) Specify a Terraform workspace to use. 112 | CloudformationParameters: # (Optional) A list of parameters to pass into the cloudformation stack. 113 | CloudformationCapabilities: # (Optional) A list of capabilities to pass into the cloudformation stack the only valid values are (CAPABILITY_IAM | CAPABILITY_NAMED_IAM | CAPABILITY_AUTO_EXPAND). 114 | ``` 115 | 116 | ### Example 117 | ```yaml 118 | Accounts: 119 | - Email: us-prod@telophase.dev 120 | AccountName: us-prod 121 | Stacks: 122 | - Path: go/src/cdk 123 | Type: CDK 124 | Name: s3-remote-state 125 | - Path: tf/default-vpc 126 | Type: Terraform 127 | ``` 128 | 129 | This will run two separate applies in the `us-prod` account: 130 | 1. `s3-remote-state` CDK stack in `go/src/cdk` that stands up an s3 bucket for a terraform remote state. 131 | 2. `tf/default-vpc` Terraform stack. 132 | 133 | # Tags 134 | Tags can be used to perform operations on groups of accounts. `Account`s and `OrganizationUnits`s can be tagged. Tags represent AWS `Tag`s. 135 | Telophase Tags map to AWS tags with a key, value pair delimited by an `=`. For example, `env=dev` will translate to an AWS tag on an Account or OU with the key `env` and value `dev`. 136 | 137 | 138 | Telophase commands optionally take tags as inputs, allowing you to limit the scope of the operation. 139 | 140 | ### Example 141 | ```yaml 142 | Accounts: 143 | - Email: newdev+1@telophase.dev 144 | AccountName: newdev1 145 | Tags: 146 | - "env=dev" 147 | - Email: newdev+2@telophase.dev 148 | AccountName: newdev2 149 | 150 | - Email: production@telophase.dev 151 | AccountName: production 152 | ``` 153 | 154 | `telophasecli diff --tag "env=dev"` will show a `diff` for only the `newdev1` account. 155 | -------------------------------------------------------------------------------- /resource/account.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/service/account" 9 | "github.com/samsarahq/go/oops" 10 | "github.com/santiago-labs/telophasecli/lib/awssess" 11 | ) 12 | 13 | type Account struct { 14 | Email string `yaml:"Email"` 15 | AccountName string `yaml:"AccountName"` 16 | AccountID string `yaml:"-"` 17 | 18 | AssumeRoleName string `yaml:"AssumeRoleName,omitempty"` 19 | Tags []string `yaml:"Tags,omitempty"` 20 | AWSTags []string `yaml:"-"` 21 | BaselineStacks []Stack `yaml:"Stacks,omitempty"` 22 | NoStackInheritance bool `yaml:"NoStackInheritance,omitempty"` 23 | ServiceControlPolicies []Stack `yaml:"ServiceControlPolicies,omitempty"` 24 | ManagementAccount bool `yaml:"-"` 25 | 26 | Delete bool `yaml:"Delete"` 27 | DelegatedAdministrator bool `yaml:"DelegatedAdministrator,omitempty"` 28 | DelegatedAdministratorServices []string `yaml:"DelegatedAdministratorServices,omitempty"` 29 | Parent *OrganizationUnit `yaml:"-"` 30 | 31 | Status string `yaml:"-,omitempty"` 32 | } 33 | 34 | func (a Account) AssumeRoleARN() string { 35 | assumeRoleName := "OrganizationAccountAccessRole" 36 | if a.AssumeRoleName != "" { 37 | assumeRoleName = a.AssumeRoleName 38 | } 39 | 40 | return fmt.Sprintf("arn:aws:iam::%s:role/%s", a.AccountID, assumeRoleName) 41 | } 42 | 43 | func (a Account) ID() string { 44 | if a.IsAWS() { 45 | return a.AccountID 46 | } 47 | 48 | return "" 49 | } 50 | 51 | func (a Account) Name() string { 52 | return a.AccountName 53 | } 54 | 55 | func (a Account) Type() string { 56 | return "Account" 57 | } 58 | 59 | func (a Account) IsAWS() bool { 60 | return a.AccountID != "" 61 | } 62 | 63 | func (a Account) IsProvisioned() bool { 64 | return a.IsAWS() 65 | } 66 | 67 | func (a Account) AllTags() []string { 68 | tags := []string{"AccountName=" + a.AccountName} 69 | tags = append(tags, a.Tags...) 70 | if a.Parent != nil { 71 | tags = append(tags, a.Parent.AllTags()...) 72 | } 73 | return tags 74 | } 75 | 76 | func (a Account) AllAWSTags() []string { 77 | var tags []string 78 | tags = append(tags, a.AWSTags...) 79 | if a.Parent != nil { 80 | tags = append(tags, a.Parent.AllAWSTags()...) 81 | } 82 | return tags 83 | } 84 | 85 | func (a Account) CurrentTags() []string { 86 | // Default tags for every account 87 | var tags []string 88 | currTags := make(map[string]struct{}) 89 | for _, tag := range a.Tags { 90 | key := strings.Split(tag, "=")[0] 91 | if _, exists := currTags[key]; exists { 92 | panic(fmt.Sprintf("duplicate tag key: %s on account with email: %s", key, a.Email)) 93 | } 94 | } 95 | 96 | tags = append(tags, a.Tags...) 97 | if a.Parent != nil { 98 | for _, tag := range a.Parent.AllTags() { 99 | key := strings.Split(tag, "=")[0] 100 | if _, exists := currTags[key]; exists { 101 | panic(fmt.Sprintf("duplicate tag key: %s on account with email : %s inherited from parent tree", key, a.Email)) 102 | } 103 | 104 | tags = append(tags, tag) 105 | } 106 | } 107 | 108 | return tags 109 | } 110 | 111 | func (a Account) AllBaselineStacks() ([]Stack, error) { 112 | var stacks []Stack 113 | if a.Parent != nil && !a.NoStackInheritance { 114 | stacks = append(stacks, a.Parent.AllBaselineStacks()...) 115 | } 116 | 117 | stacks = append(stacks, a.BaselineStacks...) 118 | 119 | // We need to rerun through the stacks after we have collected them for an 120 | // account because we check what regions are enabled for the specific 121 | // account. 122 | var returnStacks []Stack 123 | for i := range stacks { 124 | currStack := stacks[i] 125 | 126 | if currStack.Region == "all" { 127 | generatedStacks, err := a.GenerateStacks(currStack) 128 | if err != nil { 129 | return nil, err 130 | } 131 | returnStacks = append(returnStacks, generatedStacks...) 132 | continue 133 | } 134 | 135 | // Regions can be comma separated to target just a few 136 | splitRegionStack := strings.Split(currStack.Region, ",") 137 | if len(splitRegionStack) > 1 { 138 | for _, region := range splitRegionStack { 139 | returnStacks = append(returnStacks, currStack.NewForRegion(region)) 140 | } 141 | continue 142 | } 143 | 144 | returnStacks = append(returnStacks, currStack) 145 | } 146 | 147 | cloudformationStackNames := map[string]struct{}{} 148 | for _, stack := range returnStacks { 149 | if err := stack.Validate(); err != nil { 150 | return nil, err 151 | } 152 | 153 | if stack.Type == "Cloudformation" { 154 | if _, ok := cloudformationStackNames[*stack.CloudformationStackName()]; ok { 155 | return nil, oops.Errorf("Multiple Cloudformation stacks have the same Name: (%s) and Path (%s). Please set a distinct Name", stack.Name, stack.Path) 156 | } 157 | cloudformationStackNames[*stack.CloudformationStackName()] = struct{}{} 158 | } 159 | } 160 | 161 | return returnStacks, nil 162 | } 163 | 164 | func (a Account) GenerateStacks(stack Stack) ([]Stack, error) { 165 | // We only generate multiple stacks if the region is "all" 166 | if stack.Region != "all" { 167 | return []Stack{stack}, nil 168 | } 169 | 170 | sess, err := awssess.DefaultSession() 171 | if err != nil { 172 | return nil, oops.Wrapf(err, "error starting sess") 173 | } 174 | acctClient := account.New(sess) 175 | output, err := acctClient.ListRegions(&account.ListRegionsInput{ 176 | AccountId: &a.AccountID, 177 | MaxResults: aws.Int64(50), 178 | }) 179 | if err != nil { 180 | return nil, oops.Wrapf(err, "listing regions for account: (%s)", a.AccountID) 181 | } 182 | 183 | var stacks []Stack 184 | for _, region := range output.Regions { 185 | if *region.RegionOptStatus == account.RegionOptStatusEnabled || 186 | *region.RegionOptStatus == account.RegionOptStatusEnabling || 187 | *region.RegionOptStatus == account.RegionOptStatusEnabledByDefault { 188 | 189 | stacks = append(stacks, 190 | stack.NewForRegion(*region.RegionName), 191 | ) 192 | } 193 | } 194 | 195 | return stacks, nil 196 | } 197 | 198 | func (a Account) FilterBaselineStacks(stackNames string) ([]Stack, error) { 199 | var matchingStacks []Stack 200 | targetStackNames := strings.Split(stackNames, ",") 201 | baselineStacks, err := a.AllBaselineStacks() 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | for i, stack := range baselineStacks { 207 | acctStackNames := strings.Split(stack.Name, ",") 208 | for _, name := range acctStackNames { 209 | for _, targetName := range targetStackNames { 210 | if strings.TrimSpace(name) == strings.TrimSpace(targetName) { 211 | matchingStacks = append(matchingStacks, baselineStacks[i]) 212 | break 213 | } 214 | } 215 | } 216 | } 217 | return matchingStacks, nil 218 | } 219 | 220 | func (a Account) FilterServiceControlPolicies(stackNames string) []Stack { 221 | var matchingStacks []Stack 222 | targetStackNames := strings.Split(stackNames, ",") 223 | for i, stack := range a.ServiceControlPolicies { 224 | acctStackNames := strings.Split(stack.Name, ",") 225 | for _, name := range acctStackNames { 226 | for _, targetName := range targetStackNames { 227 | if strings.TrimSpace(name) == strings.TrimSpace(targetName) { 228 | matchingStacks = append(matchingStacks, a.ServiceControlPolicies[i]) 229 | break 230 | } 231 | } 232 | } 233 | } 234 | 235 | return matchingStacks 236 | } 237 | -------------------------------------------------------------------------------- /examples/tf/awsconfig/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 5.0" 6 | } 7 | } 8 | } 9 | 10 | data "aws_partition" "current" {} 11 | 12 | locals { 13 | enabled_regions = [ 14 | "ap-northeast-1", 15 | "ap-northeast-2", 16 | "ap-northeast-3", 17 | "ap-south-1", 18 | "ap-southeast-1", 19 | "ap-southeast-2", 20 | "ca-central-1", 21 | "eu-central-1", 22 | "eu-north-1", 23 | "eu-west-1", 24 | "eu-west-2", 25 | "eu-west-3", 26 | "sa-east-1", 27 | "us-east-1", 28 | "us-east-2", 29 | "us-west-1", 30 | "us-west-2", 31 | ] 32 | } 33 | 34 | # Default Provider 35 | provider "aws" { 36 | region = "us-east-1" 37 | } 38 | 39 | module "us_east_1" { 40 | source = "./baseconfig" 41 | 42 | count = contains(local.enabled_regions, "us-east-1") ? 1 : 0 43 | 44 | iam_role = aws_iam_role.iam.arn 45 | bucket_name = aws_s3_bucket.bucket.id 46 | 47 | # No need to specify provider for this because it is using the default provider 48 | # providers = { 49 | # aws = aws.us-east-1 50 | # } 51 | } 52 | 53 | provider "aws" { 54 | alias = "us-east-2" 55 | region = "us-east-2" 56 | } 57 | 58 | module "us_east_2" { 59 | source = "./baseconfig" 60 | 61 | count = contains(local.enabled_regions, "us-east-2") ? 1 : 0 62 | 63 | iam_role = aws_iam_role.iam.arn 64 | bucket_name = aws_s3_bucket.bucket.id 65 | 66 | providers = { 67 | aws = aws.us-east-2 68 | } 69 | } 70 | 71 | provider "aws" { 72 | alias = "us-west-1" 73 | region = "us-west-1" 74 | } 75 | 76 | module "us_west_1" { 77 | source = "./baseconfig" 78 | 79 | count = contains(local.enabled_regions, "us-west-1") ? 1 : 0 80 | 81 | iam_role = aws_iam_role.iam.arn 82 | bucket_name = aws_s3_bucket.bucket.id 83 | providers = { 84 | aws = aws.us-west-1 85 | } 86 | } 87 | 88 | provider "aws" { 89 | alias = "us-west-2" 90 | region = "us-west-2" 91 | } 92 | 93 | module "us_west_2" { 94 | source = "./baseconfig" 95 | 96 | count = contains(local.enabled_regions, "us-west-2") ? 1 : 0 97 | 98 | iam_role = aws_iam_role.iam.arn 99 | bucket_name = aws_s3_bucket.bucket.id 100 | 101 | providers = { 102 | aws = aws.us-west-2 103 | } 104 | } 105 | 106 | provider "aws" { 107 | alias = "ca-central-1" 108 | region = "ca-central-1" 109 | } 110 | 111 | module "ca_central_1" { 112 | source = "./baseconfig" 113 | 114 | count = contains(local.enabled_regions, "ca-central-1") ? 1 : 0 115 | 116 | iam_role = aws_iam_role.iam.arn 117 | bucket_name = aws_s3_bucket.bucket.id 118 | providers = { 119 | aws = aws.ca-central-1 120 | } 121 | } 122 | 123 | provider "aws" { 124 | alias = "eu-central-1" 125 | region = "eu-central-1" 126 | } 127 | 128 | module "eu_central_1" { 129 | source = "./baseconfig" 130 | 131 | count = contains(local.enabled_regions, "eu-central-1") ? 1 : 0 132 | 133 | iam_role = aws_iam_role.iam.arn 134 | bucket_name = aws_s3_bucket.bucket.id 135 | providers = { 136 | aws = aws.eu-central-1 137 | } 138 | } 139 | 140 | provider "aws" { 141 | alias = "eu-west-1" 142 | region = "eu-west-1" 143 | } 144 | 145 | module "eu_west_1" { 146 | source = "./baseconfig" 147 | 148 | count = contains(local.enabled_regions, "eu-west-1") ? 1 : 0 149 | 150 | iam_role = aws_iam_role.iam.arn 151 | bucket_name = aws_s3_bucket.bucket.id 152 | providers = { 153 | aws = aws.eu-west-1 154 | } 155 | } 156 | 157 | provider "aws" { 158 | alias = "eu-west-2" 159 | region = "eu-west-2" 160 | } 161 | 162 | module "eu_west_2" { 163 | source = "./baseconfig" 164 | 165 | count = contains(local.enabled_regions, "eu-west-2") ? 1 : 0 166 | 167 | iam_role = aws_iam_role.iam.arn 168 | bucket_name = aws_s3_bucket.bucket.id 169 | providers = { 170 | aws = aws.eu-west-2 171 | } 172 | } 173 | 174 | provider "aws" { 175 | alias = "eu-west-3" 176 | region = "eu-west-3" 177 | } 178 | 179 | module "eu_west_3" { 180 | source = "./baseconfig" 181 | 182 | count = contains(local.enabled_regions, "eu-west-3") ? 1 : 0 183 | 184 | iam_role = aws_iam_role.iam.arn 185 | bucket_name = aws_s3_bucket.bucket.id 186 | providers = { 187 | aws = aws.eu-west-3 188 | } 189 | } 190 | 191 | provider "aws" { 192 | alias = "eu-north-1" 193 | region = "eu-north-1" 194 | } 195 | 196 | module "eu_north_1" { 197 | source = "./baseconfig" 198 | 199 | count = contains(local.enabled_regions, "eu-north-1") ? 1 : 0 200 | 201 | iam_role = aws_iam_role.iam.arn 202 | bucket_name = aws_s3_bucket.bucket.id 203 | providers = { 204 | aws = aws.eu-north-1 205 | } 206 | } 207 | 208 | provider "aws" { 209 | alias = "ap-northeast-1" 210 | region = "ap-northeast-1" 211 | } 212 | 213 | module "ap_northeast_1" { 214 | source = "./baseconfig" 215 | 216 | count = contains(local.enabled_regions, "ap-northeast-1") ? 1 : 0 217 | 218 | iam_role = aws_iam_role.iam.arn 219 | bucket_name = aws_s3_bucket.bucket.id 220 | providers = { 221 | aws = aws.ap-northeast-1 222 | } 223 | } 224 | 225 | provider "aws" { 226 | alias = "ap-northeast-2" 227 | region = "ap-northeast-2" 228 | } 229 | 230 | module "ap_northeast_2" { 231 | source = "./baseconfig" 232 | 233 | count = contains(local.enabled_regions, "ap-northeast-2") ? 1 : 0 234 | 235 | iam_role = aws_iam_role.iam.arn 236 | bucket_name = aws_s3_bucket.bucket.id 237 | providers = { 238 | aws = aws.ap-northeast-2 239 | } 240 | } 241 | 242 | provider "aws" { 243 | alias = "ap-northeast-3" 244 | region = "ap-northeast-3" 245 | } 246 | 247 | module "ap_northeast_3" { 248 | source = "./baseconfig" 249 | 250 | count = contains(local.enabled_regions, "ap-northeast-3") ? 1 : 0 251 | 252 | iam_role = aws_iam_role.iam.arn 253 | bucket_name = aws_s3_bucket.bucket.id 254 | 255 | providers = { 256 | aws = aws.ap-northeast-3 257 | } 258 | } 259 | 260 | provider "aws" { 261 | region = "ap-southeast-1" 262 | alias = "ap-southeast-1" 263 | } 264 | 265 | module "ap_southeast_1" { 266 | source = "./baseconfig" 267 | 268 | count = contains(local.enabled_regions, "ap-southeast-1") ? 1 : 0 269 | 270 | iam_role = aws_iam_role.iam.arn 271 | bucket_name = aws_s3_bucket.bucket.id 272 | 273 | providers = { 274 | aws = aws.ap-southeast-1 275 | } 276 | } 277 | 278 | provider "aws" { 279 | alias = "ap-southeast-2" 280 | region = "ap-southeast-2" 281 | } 282 | 283 | module "ap_southeast_2" { 284 | source = "./baseconfig" 285 | 286 | count = contains(local.enabled_regions, "ap-southeast-2") ? 1 : 0 287 | 288 | iam_role = aws_iam_role.iam.arn 289 | bucket_name = aws_s3_bucket.bucket.id 290 | 291 | providers = { 292 | aws = aws.ap-southeast-2 293 | } 294 | } 295 | 296 | provider "aws" { 297 | region = "ap-south-1" 298 | alias = "ap-south-1" 299 | } 300 | 301 | module "ap_south_1" { 302 | source = "./baseconfig" 303 | 304 | count = contains(local.enabled_regions, "ap-south-1") ? 1 : 0 305 | 306 | iam_role = aws_iam_role.iam.arn 307 | bucket_name = aws_s3_bucket.bucket.id 308 | 309 | providers = { 310 | aws = aws.ap-south-1 311 | } 312 | } 313 | 314 | provider "aws" { 315 | alias = "sa-east-1" 316 | region = "sa-east-1" 317 | } 318 | 319 | module "sa_east_1" { 320 | source = "./baseconfig" 321 | 322 | count = contains(local.enabled_regions, "sa-east-1") ? 1 : 0 323 | 324 | iam_role = aws_iam_role.iam.arn 325 | bucket_name = aws_s3_bucket.bucket.id 326 | 327 | providers = { 328 | aws = aws.sa-east-1 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /lib/ymlparser/organization.go: -------------------------------------------------------------------------------- 1 | package ymlparser 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/organizations" 11 | "github.com/samsarahq/go/oops" 12 | "github.com/santiago-labs/telophasecli/lib/awsorgs" 13 | "github.com/santiago-labs/telophasecli/resource" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | type orgDatav2 struct { 18 | Organization resource.OrganizationUnit `yaml:"Organization"` 19 | } 20 | 21 | type Parser struct { 22 | orgClient awsorgs.Client 23 | } 24 | 25 | func NewParser(orgClient awsorgs.Client) Parser { 26 | return Parser{ 27 | orgClient: orgClient, 28 | } 29 | } 30 | 31 | func (o Parser) ParseOrganization(ctx context.Context, filepath string) (*resource.OrganizationUnit, error) { 32 | if filepath == "" { 33 | return nil, errors.New("filepath is empty") 34 | } 35 | 36 | data, err := os.ReadFile(filepath) 37 | if err != nil { 38 | return nil, fmt.Errorf("err: %s reading file %s", err.Error(), filepath) 39 | } 40 | 41 | var org orgDatav2 42 | 43 | if err := yaml.Unmarshal(data, &org); err != nil { 44 | return nil, err 45 | } 46 | 47 | // We hydrate the OU filepaths before validating the organization because we 48 | // need every org from all the file branches to be populated so we can get 49 | // their corresponding accountIDs. 50 | if err := o.hydrateOUFilepaths(ctx, &org.Organization); err != nil { 51 | return nil, err 52 | } 53 | 54 | if err := validOrganization(org.Organization); err != nil { 55 | return nil, err 56 | } 57 | 58 | if err = o.HydrateParsedOrg(ctx, &org.Organization); err != nil { 59 | return nil, err 60 | } 61 | 62 | return &org.Organization, nil 63 | } 64 | 65 | func (p Parser) hydrateOUFilepaths(ctx context.Context, ou *resource.OrganizationUnit) error { 66 | if ou.OUFilepath != nil { 67 | data, err := os.ReadFile(*ou.OUFilepath) 68 | if err != nil { 69 | return fmt.Errorf("err: %s reading file for OUFilepath %s", err.Error(), *ou.OUFilepath) 70 | } 71 | 72 | var childOU resource.OrganizationUnit 73 | if err := yaml.Unmarshal(data, &childOU); err != nil { 74 | return oops.Wrapf(err, "UnmarshalChildOU") 75 | } 76 | // Now we replace where the ou is pointing to with this parsed OU. 77 | *ou = childOU 78 | } 79 | 80 | for _, childOU := range ou.ChildOUs { 81 | err := p.hydrateOUFilepaths(ctx, childOU) 82 | if err != nil { 83 | return oops.Wrapf(err, "HydrateFilepaths") 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func (p Parser) HydrateParsedOrg(ctx context.Context, parsedOrg *resource.OrganizationUnit) error { 91 | rootId, err := p.orgClient.GetRootId() 92 | if err != nil { 93 | return err 94 | } 95 | rootName := "root" 96 | rootOU := &organizations.OrganizationalUnit{ 97 | Id: &rootId, 98 | Name: &rootName, 99 | } 100 | parsedOrg.OUName = "root" 101 | p.hydrateOUID(parsedOrg, rootOU) 102 | hydrateOUParent(parsedOrg) 103 | hydrateAccountParent(parsedOrg) 104 | 105 | mgmtAcct, err := p.orgClient.FetchManagementAccount(ctx) 106 | if err != nil { 107 | return oops.Wrapf(err, "error fetching management account") 108 | } 109 | 110 | // Hydrate Group, then fetch all accounts (pointers) and populate ID. 111 | providerAccts, err := p.orgClient.CurrentAccounts(ctx) 112 | if err != nil { 113 | return oops.Wrapf(err, "CurrentAccounts") 114 | } 115 | for _, providerAcct := range providerAccts { 116 | for _, parsedAcct := range parsedOrg.AllDescendentAccounts() { 117 | if parsedAcct.Email == *providerAcct.Email { 118 | parsedAcct.AccountID = *providerAcct.Id 119 | parsedAcct.Status = aws.StringValue(providerAcct.Status) 120 | } 121 | if parsedAcct.Email == mgmtAcct.Email { 122 | parsedAcct.ManagementAccount = true 123 | } 124 | } 125 | } 126 | 127 | // We have to hydrate tags after account ID to get the right tags. 128 | p.hydrateTags(ctx, parsedOrg) 129 | 130 | return nil 131 | } 132 | 133 | func hydrateAccountParent(ou *resource.OrganizationUnit) { 134 | for idx := range ou.Accounts { 135 | ou.Accounts[idx].Parent = ou 136 | } 137 | 138 | for _, childOU := range ou.ChildOUs { 139 | hydrateAccountParent(childOU) 140 | } 141 | } 142 | 143 | func (p Parser) hydrateOUID(parsedOU *resource.OrganizationUnit, providerOU *organizations.OrganizationalUnit) error { 144 | if providerOU != nil { 145 | parsedOU.OUID = providerOU.Id 146 | providerChildren, err := p.orgClient.GetOrganizationUnitChildren(context.TODO(), *parsedOU.OUID) 147 | if err != nil { 148 | return oops.Wrapf(err, "GetOrganizationUnitChildren for OUID: %s", *parsedOU.OUID) 149 | } 150 | 151 | for _, parsedChild := range parsedOU.ChildOUs { 152 | var found bool 153 | for _, providerChild := range providerChildren { 154 | if parsedChild.OUName == *providerChild.Name { 155 | found = true 156 | err = p.hydrateOUID(parsedChild, providerChild) 157 | if err != nil { 158 | return err 159 | } 160 | } 161 | } 162 | 163 | if !found { 164 | err := p.hydrateOUID(parsedChild, nil) 165 | if err != nil { 166 | return err 167 | } 168 | } 169 | } 170 | } else { 171 | for _, parsedChild := range parsedOU.ChildOUs { 172 | err := p.hydrateOUID(parsedChild, nil) 173 | if err != nil { 174 | return err 175 | } 176 | } 177 | } 178 | 179 | return nil 180 | } 181 | 182 | func (p Parser) hydrateTags(ctx context.Context, ou *resource.OrganizationUnit) error { 183 | tags, err := p.orgClient.GetTags(ctx, ou.ID()) 184 | if err != nil { 185 | return oops.Wrapf(err, "GetTags OUID: %s", ou.ID()) 186 | } 187 | ou.AWSTags = tags 188 | 189 | for _, parsedChild := range ou.AllDescendentOUs() { 190 | p.hydrateTags(ctx, parsedChild) 191 | } 192 | 193 | for _, acct := range ou.AllDescendentAccounts() { 194 | tags, err := p.orgClient.GetTags(ctx, acct.ID()) 195 | if err != nil { 196 | return oops.Wrapf(err, "GetTags Account ID: %s", acct.ID()) 197 | } 198 | acct.AWSTags = tags 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func hydrateOUParent(parsedOU *resource.OrganizationUnit) { 205 | for _, parsedChild := range parsedOU.ChildOUs { 206 | parsedChild.Parent = parsedOU 207 | hydrateOUParent(parsedChild) 208 | } 209 | } 210 | 211 | func WriteOrgFile(filepath string, org *resource.OrganizationUnit) error { 212 | orgData := orgDatav2{ 213 | Organization: *org, 214 | } 215 | result, err := yaml.Marshal(orgData) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | if fileExists(filepath) { 221 | return fmt.Errorf("file %s already exists we will not overwrite it", filepath) 222 | } 223 | 224 | if err := os.WriteFile(filepath, result, 0644); err != nil { 225 | return err 226 | } 227 | 228 | return nil 229 | } 230 | 231 | func validOrganization(data resource.OrganizationUnit) error { 232 | accountEmails := map[string]struct{}{} 233 | 234 | for _, account := range data.AllDescendentAccounts() { 235 | if _, ok := accountEmails[account.Email]; ok { 236 | return fmt.Errorf("duplicate account email %s", account.Email) 237 | } else { 238 | accountEmails[account.Email] = struct{}{} 239 | } 240 | 241 | } 242 | 243 | for _, ou := range data.AllDescendentOUs() { 244 | if len(ou.ChildGroups) > 0 { 245 | if len(ou.ChildOUs) > 0 { 246 | return fmt.Errorf("cannot set both AccountGroups and OrganizationUnits fields on Organization Unit: %s", ou.OUName) 247 | } 248 | 249 | // Remove this after deleting ChildGroups field. 250 | ou.ChildOUs = append(ou.ChildOUs, ou.ChildGroups...) 251 | } 252 | } 253 | 254 | return nil 255 | } 256 | 257 | func fileExists(filename string) bool { 258 | _, err := os.Stat(filename) 259 | if os.IsNotExist(err) { 260 | return false 261 | } 262 | return err == nil 263 | } 264 | --------------------------------------------------------------------------------