├── .github ├── CODEOWNERS ├── pull_request_template.md ├── workflows │ ├── bucket.yml │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── cd-dev.yml │ └── cd-prod.yml └── actions │ ├── init │ └── action.yml │ ├── version │ └── action.yml │ └── merge-from-to │ └── action.yml ├── terraform ├── cloudzero-payer │ ├── terraform.tf │ ├── varriables.tf │ └── main.tf ├── cloudzero-resource │ ├── terraform.tf │ ├── varriables.tf │ └── main.tf └── README.md ├── docs ├── releases │ ├── 1.0.88.md │ ├── 1.0.85.md │ ├── 1.0.84.md │ ├── 1.0.87.md │ ├── 1.0.86.md │ ├── 1.0.83.md │ ├── 1.0.90.md │ └── 1.0.89.md └── RELEASE_PROCESS.md ├── services ├── discovery │ ├── src │ │ ├── __init__.py │ │ ├── cfnresponse.py │ │ └── app.py │ ├── tests │ │ ├── __init__.py │ │ └── unit │ │ │ └── __init__.py │ ├── requirements.txt │ ├── event.json │ ├── setup.cfg │ ├── template.yaml │ ├── Makefile │ └── .gitignore ├── notification │ ├── src │ │ ├── __init__.py │ │ ├── cfnresponse.py │ │ └── app.py │ ├── tests │ │ ├── __init__.py │ │ └── unit │ │ │ ├── __init__.py │ │ │ └── test_app.py │ ├── requirements.txt │ ├── setup.cfg │ ├── event.json │ ├── template.yaml │ ├── Makefile │ └── .gitignore ├── account_type │ ├── audit.yaml │ ├── cloudtrail_owner.yaml │ ├── resource_owner.yaml │ └── master_payer.yaml ├── connected_account.yaml └── connected_account_dev.yaml ├── requirements-dev.txt ├── policies ├── managed.json ├── resource_owner.json └── master_payer.json ├── .gitignore ├── MakefileConstants.mk ├── template.yaml ├── LICENSE ├── manifests ├── aws-logs-proxy.yaml └── README.md ├── azure ├── README.md └── cz_azure_billing_permissions_setup.ps1 ├── project.sh ├── README.md ├── scripts └── snowflake │ └── unload_billing_data.sql └── Makefile /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cloudzero/open-source-maintainers 2 | -------------------------------------------------------------------------------- /terraform/cloudzero-payer/terraform.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "us-east-1" 3 | } -------------------------------------------------------------------------------- /terraform/cloudzero-resource/terraform.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "us-east-1" 3 | } -------------------------------------------------------------------------------- /docs/releases/1.0.88.md: -------------------------------------------------------------------------------- 1 | # [1.0.88](https://github.com/Cloudzero/provision-account/compare/1.0.87...1.0.88) (2025-07-09) 2 | 3 | > Updating template paths in `terraform/README.md` -------------------------------------------------------------------------------- /docs/releases/1.0.85.md: -------------------------------------------------------------------------------- 1 | # [1.0.85](https://github.com/Cloudzero/provision-account/compare/1.0.84...1.0.85) (2025-03-08) 2 | 3 | > Fixing CODEOWNERS 4 | 5 | ### Other Changes 6 | * Corrected CODEOWNERS file 7 | -------------------------------------------------------------------------------- /services/discovery/src/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | -------------------------------------------------------------------------------- /services/discovery/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | -------------------------------------------------------------------------------- /services/notification/src/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | -------------------------------------------------------------------------------- /services/discovery/tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | -------------------------------------------------------------------------------- /services/notification/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | -------------------------------------------------------------------------------- /services/notification/tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | -------------------------------------------------------------------------------- /terraform/cloudzero-resource/varriables.tf: -------------------------------------------------------------------------------- 1 | variable "cloudzero_external_id" { 2 | type = string 3 | description = "The CloudZero provided External ID for your cross account access role (Your ID can be found on the CloudZero manual account connection page)" 4 | } 5 | -------------------------------------------------------------------------------- /docs/releases/1.0.84.md: -------------------------------------------------------------------------------- 1 | # [1.0.84](https://github.com/Cloudzero/provision-account/compare/1.0.83...1.0.84) (2024-11-20) 2 | 3 | > Fixing the CI workflow to not run on push. 4 | 5 | ## Bug Fixes 6 | 7 | * **CI:** Fixing the CI workflow to not run on push. This was leftover from testing on 1.0.83 8 | -------------------------------------------------------------------------------- /services/discovery/requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | boto3>=1.34.91 5 | urllib3>=2.2.1 6 | voluptuous>=0.14.2 7 | toolz>=0.12.1 8 | -------------------------------------------------------------------------------- /services/notification/requirements.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | boto3>=1.34.91 5 | urllib3>=2.2.1 6 | voluptuous>=0.14.2 7 | toolz>=0.12.1 8 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | cfn-lint>=0.86.4 5 | flake8>=7.0.0 6 | pytest-cov>=5.0.0 7 | pytest-mock>=3.14.0 8 | pytest>=8.1.1 9 | setuptools>=65.5.1 10 | -------------------------------------------------------------------------------- /services/discovery/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "LogicalResourceId": "some cfn logical resource id", 3 | "PhysicalResourceId": "physical_id", 4 | "RequestId": "Some Request Id", 5 | "RequestType": "Create", 6 | "ResourceProperties": { 7 | "AccountId": "123456789012" 8 | }, 9 | "ResponseURL": "https://examplecallback", 10 | "StackId": "some stack id" 11 | } 12 | 13 | -------------------------------------------------------------------------------- /docs/releases/1.0.87.md: -------------------------------------------------------------------------------- 1 | # [1.0.87](https://github.com/Cloudzero/provision-account/compare/1.0.86...1.0.87) (2025-03-27) 2 | 3 | > Adding Azure setup script README to the provision-account project. 4 | 5 | ## New Features 6 | 7 | * **Azure Connection Script README:** Added a README for the [Azure EA/MCA connection setup script](https://github.com/Cloudzero/provision-account/blob/develop/azure/cz_azure_billing_permissions_setup.ps1). -------------------------------------------------------------------------------- /policies/managed.json: -------------------------------------------------------------------------------- 1 | { 2 | "policies": [ 3 | { 4 | "arn": "arn:aws:iam::aws:policy/ComputeOptimizerReadOnlyAccess", 5 | "name": "ComputeOptimizerReadOnlyAccess" 6 | }, 7 | { 8 | "arn": "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", 9 | "name": "ViewOnlyAccess" 10 | }, 11 | { 12 | "arn": "arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess", 13 | "name": "AWSBillingReadOnlyAccess" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /terraform/cloudzero-payer/varriables.tf: -------------------------------------------------------------------------------- 1 | variable "cloudzero_external_id" { 2 | type = string 3 | description = "The CloudZero provided External ID for your cross account access role (Your ID can be found on the CloudZero manual account connection page)" 4 | } 5 | 6 | variable "AWS_CUR_bucket" { 7 | type = string 8 | description = "The S3 bucket where your AWS CUR is stored. Please ensure you have configured your CUR according to these instructions https://docs.cloudzero.com/docs/validate-your-cost-and-usage-report" 9 | } 10 | -------------------------------------------------------------------------------- /docs/releases/1.0.86.md: -------------------------------------------------------------------------------- 1 | # [1.0.86](https://github.com/Cloudzero/provision-account/compare/1.0.85...1.0.86) (2025-03-10) 2 | 3 | > Adding Azure connection permissions setup script to the provision-account project. 4 | 5 | ## New Features 6 | 7 | * **Azure Connection Setup Script:** Added a PowerShell script to make it easier to connect an [Azure EA](https://docs.cloudzero.com/docs/connections-azure-ea) or [MCA](https://docs.cloudzero.com/docs/connections-azure-mca) account to CloudZero. The script grants the CloudZero service principal the necessary permissions to access Azure's Cost Management & Billing APIs. -------------------------------------------------------------------------------- /docs/releases/1.0.83.md: -------------------------------------------------------------------------------- 1 | # [1.0.83](https://github.com/Cloudzero/provision-account/compare/fd8f2dd4b591d33b7d293b380be8fc7a9e624ec5...1.0.83) (2024-11-19) 2 | 3 | > Adding versioning and release history to the provision-account project. 4 | 5 | ## New Features 6 | 7 | * **Release Notes Validation:** Added a GitHub Action to validate that a release notes file exists for the given version. 8 | * **Versioning:** Added a GitHub Action to calculate the current version based on the number of commits on HEAD and the SEMVER_MAJ_MIN value in the MakefileConstants.mk file. Repository is tagged with the current version and a release is created for the current commit. 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description of the change 2 | 3 | > Did something awesome w/out a breaking change. 4 | 5 | ## Type of change 6 | - [ ] Bug fix 7 | - [ ] New feature 8 | 9 | ## Checklists 10 | 11 | ### Development 12 | - [ ] All changed code has 80% unit test coverage 13 | - [ ] All changed code has been automatically (smoke test or otherwise) or manually verified in `alfa` (or with a cross namespace setup, e.g. developer namespace for this feature, pointing at shared `alfa` resources) 14 | 15 | ### Code review 16 | - [ ] This pull request has a title that includes the ticket # and a short useful summary, e.g. `CP-4051: Create TEMPLATE Feature Repo`. 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | # Mac OS garbage 5 | .DS_Store 6 | *.out 7 | 8 | # Python tests 9 | .coverage 10 | .pytest_cache 11 | .tox 12 | .cache 13 | coverage-reports 14 | coverage.xml 15 | test-results 16 | 17 | # SAM/Lambda artifacts 18 | packaged-template.yaml 19 | 20 | # Python artifacts 21 | __pycache__ 22 | *.pyc 23 | .venv 24 | 25 | # PyCharm 26 | .idea 27 | 28 | # Makefile temp stuff 29 | .cz_*_verified 30 | 31 | # Log files 32 | *.log 33 | coverage-report 34 | packaged.template.yaml 35 | .cz_py_dependencies_installed 36 | .aws-sam 37 | app.zip 38 | packaged.yaml 39 | /.vscode/ 40 | /services/discovery/.vscode/ 41 | /services/notification/.vscode/ 42 | temp -------------------------------------------------------------------------------- /docs/releases/1.0.90.md: -------------------------------------------------------------------------------- 1 | # [1.0.90](https://github.com/Cloudzero/provision-account/compare/1.0.89...1.0.90) (2025-12-10) 2 | 3 | > Require hourly Cost and Usage Reports and update documentation 4 | 5 | ### Upgrade Steps 6 | * [ACTION REQUIRED] If you are using DAILY Cost and Usage Reports, you must configure your AWS account to generate HOURLY reports instead. CloudZero now only accepts HOURLY Cost and Usage Reports for accurate cost analysis. 7 | 8 | ### Breaking Changes 9 | * Cost and Usage Report validation now requires HOURLY TimeUnit. DAILY reports are no longer supported. 10 | 11 | ### Bug Fixes 12 | * **CUR Validation:** Enforce HOURLY Cost and Usage Reports to ensure data accuracy and consistency in cost analysis. 13 | 14 | ### Improvements 15 | * **Documentation:** Added clear documentation in README.md stating that CloudZero requires HOURLY Cost and Usage Reports. 16 | -------------------------------------------------------------------------------- /.github/workflows/bucket.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | name: Deploy Bucket 5 | 6 | on: 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | 12 | jobs: 13 | ci: 14 | runs-on: ubuntu-latest 15 | environment: prod 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Setup AWS SAM 20 | uses: aws-actions/setup-sam@v2 21 | with: 22 | use-installer: true 23 | - name: Configure AWS Credentials 24 | uses: aws-actions/configure-aws-credentials@v3 25 | with: 26 | role-to-assume: ${{ secrets.SAM_DEPLOY_ROLE_ARN }} 27 | aws-region: us-east-1 28 | - name: Deploy 29 | shell: bash 30 | run: make deploy-bucket 31 | -------------------------------------------------------------------------------- /MakefileConstants.mk: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | # Main parameters 6 | OWNER := bill.buckley@cloudzero.com 7 | FEATURE_NAME ?= provision-account 8 | TEAM_NAME ?= cloudzero 9 | BUCKET := cz-$(FEATURE_NAME) 10 | SEMVER_MAJ_MIN := 1.0 11 | VIRTUAL_ENV ?= .venv 12 | 13 | # Util constants 14 | ERROR_COLOR := \033[1;31m 15 | INFO_COLOR := \033[1;32m 16 | WARN_COLOR := \033[1;33m 17 | NO_COLOR := \033[0m 18 | 19 | # Prerequisite verification 20 | REQUIREMENTS_FILES := $(shell find . -name "requirements*.txt") 21 | PYTHON_DEPENDENCY_FILE := .cz_py_dependencies_installed 22 | 23 | # Testing 24 | CFN_LINT_OUTPUT := cfn-lint.output 25 | 26 | # CFN Constants 27 | COVERAGE_XML := coverage.xml 28 | LINT_RESULTS := flake8.out 29 | TEMPLATE_FILE := template.yaml 30 | PACKAGED_TEMPLATE_FILE := packaged-template.yaml 31 | APP_ZIP := app.zip 32 | -------------------------------------------------------------------------------- /services/discovery/setup.cfg: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | [tool:pytest] 5 | addopts = 6 | --cov src 7 | --cov-report xml 8 | --cov-report html:coverage-reports/html 9 | --cov-report term 10 | --cov-branch 11 | --cov-fail-under=80 12 | --ignore=setup.py 13 | --doctest-modules 14 | --showlocals 15 | -vvv 16 | markers = 17 | slow: marks tests as slow (deselect with '-m "not slow"') 18 | unit 19 | performance 20 | python_files = test_*.py !check_*.py !legacy_*.py 21 | testpaths = test 22 | norecursedirs=.git .tox .cache .py3* .aws-sam 23 | # ^ NO TRAILING SLASHES ON DIRECTORIES!! 24 | 25 | [flake8] 26 | ignore = E265,E266,E402,E501,W504 27 | select = E,W,F,R,D,H,C 28 | max_line_length = 120 29 | exclude = .git,.tox,.cache,.py3*,.aws-sam 30 | tee = True 31 | statistics = True 32 | copyright-check = False -------------------------------------------------------------------------------- /services/notification/setup.cfg: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | [tool:pytest] 5 | addopts = 6 | --cov src 7 | --cov-report xml 8 | --cov-report html:coverage-reports/html 9 | --cov-report term 10 | --cov-branch 11 | --cov-fail-under=80 12 | --ignore=setup.py 13 | --doctest-modules 14 | --showlocals 15 | -rX 16 | -vvv 17 | markers = 18 | slow: marks tests as slow (deselect with '-m "not slow"') 19 | unit 20 | performance 21 | python_files = test_*.py !check_*.py !legacy_*.py 22 | testpaths = test 23 | norecursedirs=.git .tox .cache .py3* .aws-sam 24 | # ^ NO TRAILING SLASHES ON DIRECTORIES!! 25 | 26 | [flake8] 27 | ignore = E265,E266,E402,E501,W504 28 | select = E,W,F,R,D,H,C 29 | max_line_length = 120 30 | exclude = .git,.tox,.cache,.py3*,.aws-sam 31 | tee = True 32 | statistics = True 33 | copyright-check = False -------------------------------------------------------------------------------- /.github/actions/init/action.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | # This action installs dependencies for a deployable SAM Python Application, caching them for downstream jobs. 5 | 6 | name: Init 7 | description: Initialize Python environment 8 | 9 | inputs: 10 | python-version: 11 | description: "Python version" 12 | required: true 13 | 14 | runs: 15 | using: composite 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Python ${{ inputs.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ inputs.python-version }} 23 | cache: "pip" 24 | cache-dependency-path: | 25 | requirements-dev.txt 26 | src/lib/requirements.txt 27 | 28 | - name: Install dependencies 29 | shell: bash 30 | run: | 31 | touch .cz_py_virtualenv_verified 32 | make init 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | name: CI 5 | 6 | on: 7 | workflow_dispatch: 8 | pull_request: 9 | branches: 10 | - develop 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop'}} 15 | 16 | env: 17 | AWS_DEFAULT_REGION: us-east-1 18 | 19 | jobs: 20 | ci: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | - uses: ./.github/actions/version 26 | id: version 27 | - name: Validate Release Notes 28 | uses: Cloudzero/cloudzero-changelog-release-notes@v1 29 | id: changelog 30 | with: 31 | version: ${{ steps.version.outputs.nextVersion }} 32 | - name: Init 33 | uses: ./.github/actions/init 34 | with: 35 | python-version: 3.11.9 36 | - name: Lint 37 | shell: bash 38 | run: make lint 39 | - name: Test 40 | shell: bash 41 | run: make test 42 | -------------------------------------------------------------------------------- /services/notification/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "LogicalResourceId": "some cfn logical resource id", 3 | "PhysicalResourceId": "physical_id", 4 | "RequestId": "Some Request Id", 5 | "RequestType": "Create", 6 | "ResourceProperties": { 7 | "ReactorCallbackUrl": "str", 8 | "ExternalId": "str", 9 | "ReactorCallbackUrl": "str", 10 | "AccountName": "str", 11 | "ReactorId": "str", 12 | "Stacks": { 13 | "AuditAccount": "arn:aws:cloudformation:us-east-1:123456789012:stack/cz-stack-AuditAccount-1WZKAX5PDTWMK/c6ccc180-aa3f-11e9-a766-0ad7f2019cc4", 14 | "CloudTrailOwnerAccount": "arn:aws:cloudformation:us-east-1:123456789012:stack/cz-stack-CloudTrailOwnerAccount-C7KK4AWKCV35/c6d1f1a0-aa3f-11e9-8652-0eb49284c068", 15 | "Discovery": "arn:aws:cloudformation:us-east-1:123456789012:stack/cz-stack-Discovery-10PMSYWJM2PX5/b071f270-aa3f-11e9-8a19-0e7698e0f2e8", 16 | "MasterPayerAccount": "arn:aws:cloudformation:us-east-1:123456789012:stack/cz-stack-MasterPayerAccount-141S73RBTLDVR/c6964830-aa3f-11e9-8d0f-0eb08bdb4cec", 17 | "ResourceOwnerAccount": "arn:aws:cloudformation:us-east-1:123456789012:stack/cz-stack-ResourceOwnerAccount-1M5D2VSVAEGAB/c6c372b0-aa3f-11e9-bc13-0aaa9d7b8aca" 18 | } 19 | }, 20 | "ResponseURL": "https://examplecallback", 21 | "StackId": "some stack id" 22 | } 23 | -------------------------------------------------------------------------------- /.github/actions/version/action.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | name: Get Version 5 | description: Calculate version based on commit count and SEMVER_MAJ_MIN 6 | 7 | outputs: 8 | version: 9 | description: "Current version (based on commit count on origin/master)" 10 | value: ${{ steps.calculate_version.outputs.version }} 11 | nextVersion: 12 | description: "Next version number (after merge to main). This is the version that will be used for the release. Helpful for validating release notes during CI." 13 | value: ${{ steps.calculate_version.outputs.nextVersion }} 14 | 15 | runs: 16 | using: composite 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Calculate Version 23 | id: calculate_version 24 | shell: bash 25 | run: | 26 | PATCH=$(git rev-list --count origin/master) 27 | NEXT_PATCH=$((PATCH + 1)) 28 | SEMVER_MAJ_MIN=$(grep "SEMVER_MAJ_MIN" MakefileConstants.mk | awk -F':=' '{print $2}' | xargs) 29 | echo "version=${SEMVER_MAJ_MIN}.${PATCH}" >> $GITHUB_OUTPUT 30 | echo "nextVersion=${SEMVER_MAJ_MIN}.${NEXT_PATCH}" >> $GITHUB_OUTPUT 31 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | AWSTemplateFormatVersion: '2010-09-09' 6 | Description: CloudZero Provision Account CFN Templates 7 | 8 | Parameters: 9 | BucketName: 10 | Type: String 11 | 12 | Resources: 13 | Bucket: 14 | Type: AWS::S3::Bucket 15 | Properties: 16 | BucketName: !Ref BucketName 17 | CorsConfiguration: 18 | CorsRules: 19 | - AllowedHeaders: 20 | - '*' 21 | AllowedMethods: 22 | - GET 23 | AllowedOrigins: 24 | - '*' 25 | ExposedHeaders: 26 | - Content-Type 27 | MaxAge: 3000 28 | Policy: 29 | Type: AWS::S3::BucketPolicy 30 | Properties: 31 | Bucket: !Ref Bucket 32 | PolicyDocument: 33 | Statement: 34 | - Action: 35 | - "s3:GetObject" 36 | Effect: "Allow" 37 | Resource: 38 | - !Sub ${Bucket.Arn} 39 | - !Sub '${Bucket.Arn}/*' 40 | Principal: '*' 41 | 42 | Outputs: 43 | BucketName: 44 | Value: !Ref Bucket 45 | Description: CloudZero Provision Account CFN Templates Bucket Name 46 | BucketArn: 47 | Value: !GetAtt Bucket.Arn 48 | Description: CloudZero Provision Account CFN Templates Bucket Arn 49 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | name: "CodeQL" 5 | 6 | on: 7 | push: 8 | branches: 9 | - develop 10 | - master 11 | pull_request: 12 | branches: 13 | - develop 14 | schedule: 15 | - cron: '0 15 * * *' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze (${{ matrix.language }}) 20 | runs-on: 'ubuntu-latest' 21 | permissions: 22 | security-events: write 23 | packages: read 24 | actions: read 25 | contents: read 26 | 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | include: 31 | - language: python 32 | build-mode: none 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v4 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v3 38 | with: 39 | languages: ${{ matrix.language }} 40 | build-mode: ${{ matrix.build-mode }} 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@v3 43 | with: 44 | category: "/language:${{matrix.language}}" 45 | 46 | workflow-keepalive: 47 | if: github.event_name == 'schedule' 48 | runs-on: ubuntu-latest 49 | permissions: 50 | actions: write 51 | steps: 52 | - uses: liskin/gh-workflow-keepalive@v1 53 | -------------------------------------------------------------------------------- /services/notification/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | CloudZero Notification Stack 5 | Send results of Connected Account Provisioning to a CloudZero Reactor. 6 | 7 | SAM Template for notification 8 | 9 | Parameters: 10 | ReactorCallbackUrl: 11 | Type: String 12 | Default: 'null' 13 | Description: | 14 | CloudZero Reactor Queue Url; receives callbacks from this template 15 | Version: 16 | Type: String 17 | Default: 'latest' 18 | Description: | 19 | Version to target when deploying the stack. `latest` should be used by default. 20 | 21 | Conditions: 22 | ValidReactorCallbackUrl: !Not 23 | - !Equals [!Ref ReactorCallbackUrl, 'null'] 24 | 25 | Globals: 26 | Function: 27 | Runtime: python3.12 28 | MemorySize: 512 29 | Timeout: 30 30 | 31 | Resources: 32 | NotificationFunction: 33 | Type: AWS::Serverless::Function 34 | Condition: ValidReactorCallbackUrl 35 | Properties: 36 | CodeUri: 37 | Bucket: !Sub 'cz-provision-account-${AWS::Region}' 38 | Key: !Sub ${Version}/services/notification.zip 39 | Handler: src.app.handler 40 | Policies: 41 | - AWSCloudFormationReadOnlyAccess 42 | Environment: 43 | Variables: 44 | VERSION: '1' 45 | 46 | Outputs: 47 | Arn: 48 | Condition: ValidReactorCallbackUrl 49 | Value: !GetAtt NotificationFunction.Arn 50 | Description: Notification Function ARN 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016-present, CloudZero, Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /manifests/aws-logs-proxy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: aws-logs-proxy-service 5 | spec: 6 | selector: 7 | app: aws-logs-proxy 8 | ports: 9 | - protocol: "TCP" 10 | port: 6000 11 | targetPort: 5000 12 | 13 | --- 14 | apiVersion: v1 15 | data: 16 | proxy.conf: | 17 | server { 18 | listen 5000; 19 | 20 | location ~ ^/([^/]+) { 21 | proxy_buffering off; 22 | 23 | proxy_set_header Host logs.$1.amazonaws.com; 24 | proxy_set_header X-Amzn-Logs-Format ""; 25 | 26 | set $upstream http://127.0.0.1:5001; 27 | proxy_pass $upstream; 28 | } 29 | } 30 | kind: ConfigMap 31 | metadata: 32 | name: aws-logs-proxy-nginx-config 33 | 34 | --- 35 | apiVersion: apps/v1 36 | kind: Deployment 37 | metadata: 38 | name: aws-logs-proxy 39 | spec: 40 | selector: 41 | matchLabels: 42 | app: aws-logs-proxy 43 | replicas: 1 44 | template: 45 | metadata: 46 | labels: 47 | app: aws-logs-proxy 48 | spec: 49 | volumes: 50 | - configMap: 51 | name: aws-logs-proxy-nginx-config 52 | name: aws-logs-proxy-nginx-config 53 | containers: 54 | - name: nginx 55 | image: nginx 56 | ports: 57 | - containerPort: 5000 58 | volumeMounts: 59 | - mountPath: /etc/nginx/conf.d 60 | name: aws-logs-proxy-nginx-config 61 | - name: aws-sigv4-proxy 62 | image: cloudzero/aws-sigv4-proxy:latest 63 | ports: 64 | - containerPort: 5001 65 | args: ["--port", ":5001"] 66 | dnsPolicy: ClusterFirst 67 | -------------------------------------------------------------------------------- /.github/workflows/cd-dev.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | name: CD Dev 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - develop 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | 15 | permissions: 16 | id-token: write 17 | contents: read 18 | 19 | jobs: 20 | cd-dev: 21 | runs-on: ubuntu-latest 22 | environment: dev 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | - name: Setup AWS SAM 27 | uses: aws-actions/setup-sam@v2 28 | with: 29 | use-installer: true 30 | - name: Configure AWS Credentials 31 | uses: aws-actions/configure-aws-credentials@v3 32 | with: 33 | role-to-assume: ${{ secrets.SAM_DEPLOY_ROLE_ARN }} 34 | aws-region: us-east-1 35 | - name: Upload S3 Artifacts 36 | shell: bash 37 | run: make deploy version='dev' regions='us-east-1 us-east-2 us-west-1' 38 | 39 | merge-to-master: 40 | runs-on: ubuntu-latest 41 | needs: cd-dev 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v3 45 | - name: Merge from develop to master 46 | uses: ./.github/actions/merge-from-to 47 | with: 48 | from-branch: develop 49 | from-sha: ${{ github.sha }} 50 | to: master 51 | app_id: ${{ secrets.PUBLIC_GITHUB_ACTIONS_APP_ID }} 52 | private_key: ${{ secrets.PUBLIC_GITHUB_ACTIONS_APP_PRIVATE_KEY }} 53 | -------------------------------------------------------------------------------- /.github/workflows/cd-prod.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | name: CD Prod 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - master 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | 15 | permissions: 16 | id-token: write 17 | contents: write 18 | 19 | jobs: 20 | cd-prod: 21 | runs-on: ubuntu-latest 22 | environment: prod 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | - name: Setup AWS SAM 29 | uses: aws-actions/setup-sam@v2 30 | with: 31 | use-installer: true 32 | - name: Configure AWS Credentials 33 | uses: aws-actions/configure-aws-credentials@v3 34 | with: 35 | role-to-assume: ${{ secrets.SAM_DEPLOY_ROLE_ARN }} 36 | aws-region: us-east-1 37 | - name: Upload S3 Artifacts 38 | shell: bash 39 | run: make deploy 40 | - name: Get Version 41 | uses: ./.github/actions/version 42 | id: version 43 | - name: Validate Release Notes 44 | uses: Cloudzero/cloudzero-changelog-release-notes@v1 45 | id: changelog 46 | with: 47 | version: ${{ steps.version.outputs.version }} 48 | - name: Create Release 49 | uses: softprops/action-gh-release@v2 50 | with: 51 | name: ${{ steps.version.outputs.version }} 52 | tag_name: ${{ steps.version.outputs.version }} 53 | make_latest: true 54 | target_commitish: ${{ github.sha }} 55 | body_path: ${{ steps.changelog.outputs.releaseNotesFile }} 56 | -------------------------------------------------------------------------------- /.github/actions/merge-from-to/action.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 2 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 3 | 4 | # This action will do a merge from one branch to another 5 | 6 | name: Merge From To 7 | description: Merge from one branch to another 8 | 9 | inputs: 10 | from-sha: 11 | description: "Commit sha to merge from" 12 | required: true 13 | from-branch: 14 | description: "Branch to merge from" 15 | required: true 16 | default: "develop" 17 | to: 18 | description: "Branch to merge to" 19 | required: true 20 | default: "main" 21 | app_id: 22 | description: "GitHub App ID" 23 | required: true 24 | private_key: 25 | description: "GitHub App Private Key" 26 | required: true 27 | 28 | runs: 29 | using: composite 30 | steps: 31 | - name: Generate a token for the GitHub App so that push triggers a workflow 32 | id: generate_token 33 | uses: tibdex/github-app-token@v1 34 | with: 35 | app_id: ${{ inputs.app_id }} 36 | private_key: ${{ inputs.private_key }} 37 | 38 | - uses: actions/checkout@v3 39 | # replace token with the token generated by the previous action, so all git commands use it 40 | with: 41 | ref: ${{ inputs.from-branch }} 42 | token: ${{ steps.generate_token.outputs.token }} 43 | 44 | - name: git config, merge, and push 45 | env: 46 | GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} 47 | shell: bash 48 | run: | 49 | git config --local user.email "ops@cloudzero.com" 50 | git config --local user.name "Automated CZ Release" 51 | git fetch --unshallow 52 | git checkout ${{ inputs.to}} 53 | git pull 54 | git merge --no-edit --ff-only ${{ inputs.from-sha }} 55 | git push origin ${{ inputs.to }} 56 | -------------------------------------------------------------------------------- /services/discovery/src/cfnresponse.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | # This file is licensed to you under the AWS Customer Agreement (the "License"). 3 | # You may not use this file except in compliance with the License. 4 | # A copy of the License is located at http://aws.amazon.com/agreement/ . 5 | # This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. 6 | # See the License for the specific language governing permissions and limitations under the License. 7 | 8 | import urllib3 9 | import json 10 | http = urllib3.PoolManager() 11 | SUCCESS = "SUCCESS" 12 | FAILED = "FAILED" 13 | 14 | 15 | def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False): 16 | responseUrl = event['ResponseURL'] 17 | 18 | print(responseUrl) 19 | 20 | responseBody = {} 21 | responseBody['Status'] = responseStatus 22 | responseBody['Reason'] = 'See the details in CloudWatch Log Stream: ' + context.log_stream_name 23 | responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name 24 | responseBody['StackId'] = event['StackId'] 25 | responseBody['RequestId'] = event['RequestId'] 26 | responseBody['LogicalResourceId'] = event['LogicalResourceId'] 27 | responseBody['NoEcho'] = noEcho 28 | responseBody['Data'] = responseData 29 | 30 | json_responseBody = json.dumps(responseBody) 31 | 32 | print("Response body:\n" + json_responseBody) 33 | 34 | headers = { 35 | 'content-type': '', 36 | 'content-length': str(len(json_responseBody)) 37 | } 38 | 39 | try: 40 | 41 | response = http.request('PUT', responseUrl, body=json_responseBody.encode('utf-8'), headers=headers) 42 | print("Status code: " + response.reason) 43 | except Exception as e: 44 | print("send(..) failed executing requests.put(..): " + str(e)) 45 | -------------------------------------------------------------------------------- /services/notification/src/cfnresponse.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | # This file is licensed to you under the AWS Customer Agreement (the "License"). 3 | # You may not use this file except in compliance with the License. 4 | # A copy of the License is located at http://aws.amazon.com/agreement/ . 5 | # This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. 6 | # See the License for the specific language governing permissions and limitations under the License. 7 | 8 | import urllib3 9 | import json 10 | http = urllib3.PoolManager() 11 | SUCCESS = "SUCCESS" 12 | FAILED = "FAILED" 13 | 14 | 15 | def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False): 16 | responseUrl = event['ResponseURL'] 17 | 18 | print(responseUrl) 19 | 20 | responseBody = {} 21 | responseBody['Status'] = responseStatus 22 | responseBody['Reason'] = 'See the details in CloudWatch Log Stream: ' + context.log_stream_name 23 | responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name 24 | responseBody['StackId'] = event['StackId'] 25 | responseBody['RequestId'] = event['RequestId'] 26 | responseBody['LogicalResourceId'] = event['LogicalResourceId'] 27 | responseBody['NoEcho'] = noEcho 28 | responseBody['Data'] = responseData 29 | 30 | json_responseBody = json.dumps(responseBody) 31 | 32 | print("Response body:\n" + json_responseBody) 33 | 34 | headers = { 35 | 'content-type': '', 36 | 'content-length': str(len(json_responseBody)) 37 | } 38 | 39 | try: 40 | 41 | response = http.request('PUT', responseUrl, body=json_responseBody.encode('utf-8'), headers=headers) 42 | print("Status code: " + response.reason) 43 | except Exception as e: 44 | print("send(..) failed executing requests.put(..): " + str(e)) 45 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | # CloudZero Account Provisioning Terraform Templates for AWS 2 | 3 | ## About 4 | These templates provide CloudZero with the necessary access permissions to analyze your cloud 5 | environment and guide you to optimize your cloud spend. 6 | 7 | To learn more about CloudZero, visit [CloudZero.com](cloudzero.com) or start by creating an account 8 | at https://app.cloudzero.com and activating a free 30 day trial. Our online documentation is 9 | available at [docs.cloudzero.com](https://docs.cloudzero.com/docs/getting-started) 10 | 11 | ## Usage 12 | These templates are intended for advanced users who have existing familiarity with Terraform. 13 | 14 | There are two templates available, use the correct one for the type of account you are connecting. 15 | At a minimum you must connect your AWS Management account for CloudZero to function. In addition, to 16 | provide additional insight into AWS Infrastructure, EKS or Kubernetes spend beyond the data is only 17 | present in the AWS cost and usage data, you must connect the accounts you wish to monitor as resource 18 | accounts. 19 | * For AWS Management (also called payer) accounts, use `terraform/cloudzero-payer/main.tf` 20 | * For AWS Resource (or child) accounts, use `terraform/cloudzero-resource/main.tf` 21 | 22 | ### Pre-requisites for Connecting your AWS Accounts 23 | * Before connection ensure you have made note of the CloudZero provided External ID for your account (available from 24 | the [CloudZero Manual Account Connection Page](https://app.cloudzero.com/organization/onboard-accounts/connect?cztabs.connect-accounts=manual)) 25 | 26 | * To connect your AWS management account please first configure an [AWS Cost and Usage Report](https://docs.aws.amazon.com/cur/latest/userguide/cur-create.html) 27 | and make note of the S3 bucket where the AWS CUR is being stored. 28 | 29 | ## Getting Support 30 | If you are unsure how to run these templates, or would like to simply provision your accounts as quickly as possible, 31 | consider running our automated CloudFormation based connection process via the CloudZero Web Application 32 | 33 | If you have questions or want to report an issue with this template, feel free to open an issue or write to us at support@cloudzero.com 34 | -------------------------------------------------------------------------------- /services/account_type/audit.yaml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | AWSTemplateFormatVersion: '2010-09-09' 6 | Description: CloudZero Audit Account Template 7 | 8 | 9 | Parameters: 10 | IsAuditAccount: 11 | Description: Flag to indicate if this is the master payer account 12 | Type: String 13 | AllowedValues: 14 | - 'true' 15 | - 'false' 16 | AuditCloudTrailBucketName: 17 | Description: The name of the S3 bucket responsible for CloudTrail event data 18 | Type: String 19 | ReactorAccountId: 20 | Description: The CloudZero reactor AWS account ID 21 | Type: String 22 | Default: '061190967865' 23 | ExternalId: 24 | Description: | 25 | Unique ExternalId for Customer Organization; for cross-account Role Access and 26 | associating this template with a Customer Organization 27 | Type: String 28 | 29 | 30 | Conditions: 31 | CreateResources: !And 32 | - !Not 33 | - !Equals [ !Ref AuditCloudTrailBucketName, 'null' ] 34 | - !Equals [ !Ref IsAuditAccount, 'true' ] 35 | 36 | 37 | Resources: 38 | 39 | Role: 40 | Condition: CreateResources 41 | Type: 'AWS::IAM::Role' 42 | Properties: 43 | Path: '/cloudzero/' 44 | AssumeRolePolicyDocument: 45 | Version: '2012-10-17' 46 | Statement: 47 | - Effect: Allow 48 | Principal: 49 | AWS: !Sub 'arn:aws:iam::${ReactorAccountId}:root' 50 | Action: 51 | - 'sts:AssumeRole' 52 | Condition: 53 | StringEquals: 54 | 'sts:ExternalId': !Ref ExternalId 55 | 56 | RolePolicy: 57 | Condition: CreateResources 58 | Type: 'AWS::IAM::Policy' 59 | Properties: 60 | PolicyName: !Sub 'cloudzero-audit-policy-${ReactorAccountId}' 61 | Roles: 62 | - !Ref Role 63 | PolicyDocument: 64 | Version: '2012-10-17' 65 | Statement: 66 | - Sid: AccessAuditCloudTrailBucket 67 | Effect: Allow 68 | Action: 69 | - 's3:Get*' 70 | - 's3:List*' 71 | Resource: 72 | - !Sub 'arn:aws:s3:::${AuditCloudTrailBucketName}' 73 | - !Sub 'arn:aws:s3:::${AuditCloudTrailBucketName}/*' 74 | 75 | Outputs: 76 | RoleArn: 77 | Description: The cloudzero cross account role ARN 78 | Value: !If [ CreateResources, !GetAtt Role.Arn, 'null' ] 79 | -------------------------------------------------------------------------------- /services/account_type/cloudtrail_owner.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | 3 | 4 | Description: CloudZero CloudTrail Owner Template 5 | 6 | 7 | Parameters: 8 | IsCloudTrailOwnerAccount: 9 | Description: Flag that indicates if this account owns the cloudtrail 10 | Type: String 11 | AllowedValues: 12 | - 'true' 13 | - 'false' 14 | CloudTrailSNSTopicArn: 15 | Description: 'The CloudZero SNS Topic ARN responsible for receiving notifications from the cloudtrail' 16 | Type: String 17 | ReactorAccountId: 18 | Description: The CloudZero reactor AWS account ID 19 | Type: String 20 | Default: '061190967865' 21 | 22 | 23 | Conditions: 24 | CreateResources: !And [ !Not [ !Equals [ !Ref CloudTrailSNSTopicArn, 'null' ] ], !Equals [ !Ref IsCloudTrailOwnerAccount, 'true' ] ] 25 | 26 | 27 | Resources: 28 | 29 | SqsQueue: 30 | Type: 'AWS::SQS::Queue' 31 | Condition: CreateResources 32 | Properties: 33 | Tags: 34 | - Key: cloudzero-stack 35 | Value: !Ref AWS::StackName 36 | - Key: cloudzero-reactor-account-id 37 | Value: !Ref ReactorAccountId 38 | 39 | SnsSubscription: 40 | Type: AWS::SNS::Subscription 41 | Condition: CreateResources 42 | Properties: 43 | Protocol: sqs 44 | Endpoint: !GetAtt SqsQueue.Arn 45 | Region: !Select 46 | - 3 47 | - !Split [ ':', !Ref CloudTrailSNSTopicArn ] 48 | TopicArn: !Ref CloudTrailSNSTopicArn 49 | 50 | SqsPolicy: 51 | Type: 'AWS::SQS::QueuePolicy' 52 | Condition: CreateResources 53 | Properties: 54 | Queues: 55 | - !Ref SqsQueue 56 | PolicyDocument: 57 | Id: 'CloudZeroReactorQueuePolicy20190816' 58 | Version: '2012-10-17' 59 | Statement: 60 | - Sid: 'SNSTopicAccess20190816' 61 | Effect: Allow 62 | Action: 63 | - 'sqs:SendMessage' 64 | Principal: 65 | AWS: "*" 66 | Resource: "*" 67 | Condition: 68 | ArnEquals: 69 | 'aws:SourceArn': !Ref CloudTrailSNSTopicArn 70 | - Sid: 'ReactorAccess20190816' 71 | Effect: Allow 72 | Action: 73 | - 'sqs:*' 74 | Principal: 75 | AWS: !Sub 'arn:aws:iam::${ReactorAccountId}:root' 76 | Resource: !GetAtt SqsQueue.Arn 77 | 78 | 79 | Outputs: 80 | SQSQueueArn: 81 | Description: The CloudZero SQS queue 82 | Value: !If [ CreateResources, !GetAtt SqsQueue.Arn, 'null' ] 83 | SQSQueuePolicyName: 84 | Description: The CloudZero SQS queue policy 85 | Value: !If [ CreateResources, !Ref SqsPolicy, 'null' ] 86 | -------------------------------------------------------------------------------- /manifests/README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes Manifests 2 | ## AWS Log Proxy 3 | Proxies connections to the CloudWatch Logs API and removes the `X-Amzn-Logs-Format` header to prevent generation of EMF metrics 4 | ### Installation 5 | ``` 6 | kubectl apply -f https://raw.githubusercontent.com/Cloudzero/provision-account/master/manifests/aws-logs-proxy.yaml 7 | ``` 8 | 9 | ### CloudWatch Agent Configuration 10 | While this may be used for any connection to CloudWatch Logs, the typical use case is to proxy connections from the 11 | CloudWatch Agent to prevent cost Container Insights metrics, but keep the `performance` log stream. 12 | 13 | #### Install the Agent 14 | If you haven't already done so, follow the AWS instructions to [install the CloudWatch Agent for Container Insights.](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Container-Insights-setup-metrics.html) 15 | 16 | #### Find the AWS Log Proxy Service Endpoint 17 | ``` 18 | $ kubectl get service aws-logs-proxy-service 19 | NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE 20 | aws-logs-proxy-service ClusterIP 10.100.151.128 6000/TCP 23h 21 | ``` 22 | Copy the `CLUSTER-IP` from the output, it'll be used for the `` below. 23 | 24 | #### Edit the CloudWatch Agent ConfigMap 25 | Edit the `cwagent-configmap.yaml` that was downloaded in Step 3 of the [agent install instructions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Container-Insights-setup-metrics.html). Add an `endpoint_override` parameter: 26 | ``` 27 | "endpoint_override": "http://:6000/" 28 | ``` 29 | The complete agent ConfigMap should now look something like this: 30 | ``` 31 | { 32 | "agent": { 33 | "region": "us-east-1" 34 | }, 35 | "logs": { 36 | "metrics_collected": { 37 | "kubernetes": { 38 | "cluster_name": "MyCluster", 39 | "metrics_collection_interval": 60 40 | } 41 | }, 42 | "force_flush_interval": 5, 43 | "endpoint_override": "http://10.100.151.128:6000/us-east-1" 44 | } 45 | } 46 | ``` 47 | 48 | #### Restart the CloudWatch Agent 49 | Editing an existing ConfigMap may require the CloudWatch Agent to be restarted. Either delete and reapply the DaemonSet 50 | manifest or follow the [instructions for updating the agent.](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/ContainerInsights-update-image.html) 51 | 52 | ### Additional Notes 53 | #### AWS SigV4 Proxy 54 | This deployment is dependent on the [AWS SigV4 Proxy](https://github.com/awslabs/aws-sigv4-proxy) which has been prebuilt and uploaded to the [CloudZero DockerHub](https://hub.docker.com/repository/docker/cloudzero/aws-sigv4-proxy) 55 | 56 | -------------------------------------------------------------------------------- /services/discovery/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | CZ Discovery Stack: 5 | Determine the Types CZ can apply to the current account. 6 | 7 | SAM Template for discovery 8 | 9 | Parameters: 10 | Version: 11 | Type: String 12 | Default: 'latest' 13 | Description: | 14 | Version to target when deploying the stack. `latest` should be used by default. 15 | 16 | Globals: 17 | Function: 18 | Runtime: python3.12 19 | MemorySize: 512 20 | Timeout: 30 21 | 22 | Resources: 23 | DiscoveryFunction: 24 | Type: AWS::Serverless::Function 25 | Properties: 26 | CodeUri: 27 | Bucket: !Sub 'cz-provision-account-${AWS::Region}' 28 | Key: !Sub ${Version}/services/discovery.zip 29 | Handler: src.app.handler 30 | Policies: 31 | - Version: '2012-10-17' 32 | Statement: 33 | - Sid: CZDiscovery20190912 34 | Effect: Allow 35 | Action: 36 | - cloudtrail:DescribeTrails 37 | - s3:ListAllMyBuckets 38 | - cur:DescribeReportDefinitions 39 | - organizations:DescribeOrganization 40 | Resource: '*' 41 | Environment: 42 | Variables: 43 | VERSION: '20230523' 44 | 45 | DiscoveryResource: 46 | Type: Custom::Discovery 47 | Properties: 48 | ServiceToken: !GetAtt DiscoveryFunction.Arn 49 | AccountId: !Sub ${AWS::AccountId} 50 | Version: '20230523' 51 | 52 | Outputs: 53 | AuditCloudTrailBucketName: 54 | Value: !GetAtt DiscoveryResource.AuditCloudTrailBucketName 55 | AuditCloudTrailBucketPrefix: 56 | Value: !GetAtt DiscoveryResource.AuditCloudTrailBucketPrefix 57 | CloudTrailSNSTopicArn: 58 | Value: !GetAtt DiscoveryResource.CloudTrailSNSTopicArn 59 | CloudTrailTrailArn: 60 | Value: !GetAtt DiscoveryResource.CloudTrailTrailArn 61 | IsAuditAccount: 62 | Value: !GetAtt DiscoveryResource.IsAuditAccount 63 | IsMasterPayerAccount: 64 | Value: !GetAtt DiscoveryResource.IsMasterPayerAccount 65 | IsCloudTrailOwnerAccount: 66 | Value: !GetAtt DiscoveryResource.IsCloudTrailOwnerAccount 67 | IsResourceOwnerAccount: 68 | Value: !GetAtt DiscoveryResource.IsResourceOwnerAccount 69 | MasterPayerBillingBucketName: 70 | Value: !GetAtt DiscoveryResource.MasterPayerBillingBucketName 71 | MasterPayerBillingBucketPath: 72 | Value: !GetAtt DiscoveryResource.MasterPayerBillingBucketPath 73 | RemoteCloudTrailBucket: 74 | Value: !GetAtt DiscoveryResource.RemoteCloudTrailBucket 75 | VisibleCloudTrailArns: 76 | Value: !GetAtt DiscoveryResource.VisibleCloudTrailArns 77 | IsOrganizationTrail: 78 | Value: !GetAtt DiscoveryResource.IsOrganizationTrail 79 | IsOrganizationMasterAccount: 80 | Value: !GetAtt DiscoveryResource.IsOrganizationMasterAccount 81 | IsAccountOutsideOrganization: 82 | Value: !GetAtt DiscoveryResource.IsAccountOutsideOrganization 83 | -------------------------------------------------------------------------------- /azure/README.md: -------------------------------------------------------------------------------- 1 | # CloudZero Azure Billing API Access Script 2 | 3 | The [cz_azure_billing_permissions_setup.ps1](./cz_azure_billing_permissions_setup.ps1) PowerShell script grants CloudZero the necessary **read-only** permissions to connect to your Azure Enterprise Agreement (EA) or Microsoft Customer Agreement (MCA) account. 4 | 5 | It automates "Step 4: Grant Access to the Azure Billing API" in the CloudZero [EA](https://docs.cloudzero.com/docs/connections-azure-ea#step-4-grant-access-to-the-azure-billing-api) and [MCA](https://docs.cloudzero.com/docs/connections-azure-mca#step-4-grant-access-to-the-azure-billing-api) documentation by: 6 | 7 | 1. Validating your billing account ID. 8 | 2. Determining your Azure agreement type (EA or MCA). 9 | 3. Setting up read-only [EnrollmentReader](https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/assign-roles-azure-service-principals#permissions-that-can-be-assigned-to-the-service-principal) permissions for the CloudZero service principal. 10 | 11 | ## Prerequisites 12 | 13 | - [Azure PowerShell](https://learn.microsoft.com/en-us/powershell/azure/what-is-azure-powershell) installed: 14 | - Locally on any platform 15 | - Via [Azure Cloud Shell](https://shell.azure.com/) 16 | - Using a [Docker container](https://learn.microsoft.com/en-us/powershell/azure/azureps-in-docker) 17 | - Azure user with permissions to assign the `EnrollmentReader` role at the billing account scope 18 | - Your Azure [billing account ID (8-digit number)](#finding-your-billing-account-id) 19 | - Completed Steps 1-3 of the CloudZero connection process: 20 | - [EA accounts documentation](https://docs.cloudzero.com/docs/connections-azure-ea) 21 | - [MCA accounts documentation](https://docs.cloudzero.com/docs/connections-azure-mca) 22 | 23 | ### Finding Your Billing Account ID 24 | 25 | 1. Go to [Cost Management + Billing](https://portal.azure.com/#view/Microsoft_Azure_GTM/ModernBillingMenuBlade/~/BillingAccounts) in the Azure Portal. 26 | 2. Select your billing account. 27 | 3. Navigate to **Settings > Properties**. 28 | 4. Copy the 8-digit **Billing Account ID**. 29 | 30 | ## Usage Instructions 31 | 32 | 1. Log in to Azure PowerShell: 33 | 34 | ```powershell 35 | Connect-AzAccount 36 | ``` 37 | 38 | 2. Navigate to the directory containing the script. 39 | 40 | 3. Run the script with your billing account ID: 41 | 42 | ```powershell 43 | ./cz_azure_billing_permissions_setup.ps1 -billingaccountId 44 | ``` 45 | 46 | > **Important**: Execute the script as a whole file. Do not copy and paste individual lines. 47 | 48 | ## Verification 49 | 50 | Check your new Azure connection in CloudZero's [Billing Connections table](https://app.cloudzero.com/organization/connections). The connection status will change from **Pending Data** to **Healthy** after the first data ingest (may take several hours). 51 | 52 | ## Documentation Links 53 | 54 | - [EA Connection Documentation](https://docs.cloudzero.com/docs/connections-azure-ea) 55 | - [MCA Connection Documentation](https://docs.cloudzero.com/docs/connections-azure-mca) 56 | - [Azure Service Principal Permissions](https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/assign-roles-azure-service-principals#permissions-that-can-be-assigned-to-the-service-principal) 57 | -------------------------------------------------------------------------------- /project.sh: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 4 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 5 | 6 | #!/usr/bin/env bash 7 | 8 | # Useful macro functions for controlling AWS profiles/sessions and other useful environment vars. 9 | # These functions can be sourced and used with make, but they can also be used with any other console 10 | # tool that uses the standard AWS environment variables. Requires the AWS CLI to be installed and on the path. 11 | 12 | 13 | get_role_session_name(){ 14 | echo $(hostname | cut -f1 -d.)-cz-project-profile 15 | } 16 | 17 | 18 | # Assume a role that's named by a profile in ~/.aws/credentials. 19 | # Persist this session using environment variables. 20 | # Common pattern for developers, but can also be used with CI if the credentials file is set up ahead of time. 21 | cz_use_profile() { 22 | local target_profile=${1?} ; shift 23 | local mfa_code=${1} 24 | 25 | cz_clear_profile 26 | 27 | # Load all the details we need from the source profile, the one with the actual user and keys 28 | user_profile=$(aws configure get ${target_profile}.source_profile) 29 | : ${user_profile:?} 30 | export AWS_ACCESS_KEY_ID=$(aws configure get ${user_profile}.aws_access_key_id) 31 | export AWS_SECRET_ACCESS_KEY=$(aws configure get ${user_profile}.aws_secret_access_key) 32 | export AWS_PROFILE=${target_profile} 33 | 34 | # Load details about the role we need to assume in the target profile 35 | role_arn=$(aws configure get ${target_profile}.role_arn) 36 | mfa_serial=$(aws configure get ${target_profile}.mfa_serial) 37 | role_session_name=$(get_role_session_name) 38 | 39 | # Assume role with or without an MFA device 40 | eval $(aws sts assume-role \ 41 | ${mfa_serial:+ --serial-number $mfa_serial} \ 42 | ${mfa_code:+ --token-code ${mfa_code}} \ 43 | --role-arn ${role_arn} \ 44 | --role-session-name "${role_session_name}" \ 45 | --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' | jq '"export AWS_ACCESS_KEY_ID=" + .[0] + "; export AWS_SECRET_ACCESS_KEY=" + .[1] + "; export AWS_SESSION_TOKEN=" + .[2] + ";"' --raw-output) 46 | if [ -n "${AWS_SESSION_TOKEN}" ] ; then 47 | echo Successfully assumed role \"${role_arn}\" as session \"${role_session_name}\" using profile \"${target_profile}\" ${mfa_serial:+ with MFA device \"${mfa_serial}\"} 48 | fi 49 | } 50 | 51 | cz_assert_profile(){ 52 | local role_session_name=$(get_role_session_name) 53 | echo ${role_session_name} 54 | aws sts get-caller-identity | 55 | jq -r -e '.Arn' | 56 | grep -e "^arn:aws:sts::\([0-9]\{12\}\):assumed-role/\([A-Za-z\-]\+\)/${role_session_name}$" || 57 | { echo "Not in a cz_use_profile assumed role!" ; return 1 ; } 58 | } 59 | 60 | # Clear environment variables from a previous call to use-profile 61 | cz_clear_profile() { 62 | unset AWS_SESSION_TOKEN 63 | unset AWS_SECRET_ACCESS_KEY 64 | unset AWS_ACCESS_KEY_ID 65 | unset AWS_SECURITY_TOKEN 66 | unset AWS_PROFILE 67 | } 68 | 69 | # Shows the currently applied AWS profile details. 70 | # NOTE: Credentials shown here might be expired; if they are, run use_profile again. 71 | cz_show_profile() { 72 | printenv | grep AWS_ 73 | } 74 | -------------------------------------------------------------------------------- /policies/resource_owner.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "CZCostMonitoring20240422", 6 | "Effect": "Allow", 7 | "Action": [ 8 | "account:GetAccountInformation", 9 | "billing:Get*", 10 | "budgets:Describe*", 11 | "budgets:View*", 12 | "ce:Describe*", 13 | "ce:Get*", 14 | "ce:List*", 15 | "consolidatedbilling:Get*", 16 | "consolidatedbilling:List*", 17 | "cur:Describe*", 18 | "cur:Get*", 19 | "cur:Validate*", 20 | "cur:List*", 21 | "freetier:Get*", 22 | "invoicing:Get*", 23 | "invoicing:List*", 24 | "organizations:Describe*", 25 | "organizations:List*", 26 | "payments:Get*", 27 | "payments:List*", 28 | "pricing:*", 29 | "tax:Get*", 30 | "tax:List*" 31 | ], 32 | "Resource": "*" 33 | }, 34 | { 35 | "Sid": "CZActivityMonitoring20210423", 36 | "Effect": "Allow", 37 | "Action": [ 38 | "cloudtrail:Get*", 39 | "cloudtrail:List*", 40 | "cloudtrail:Describe*", 41 | "health:Describe*", 42 | "support:DescribeTrustedAdvisor*", 43 | "servicequotas:Get*", 44 | "servicequotas:List*", 45 | "resource-groups:Get*", 46 | "resource-groups:List*", 47 | "resource-groups:Search*", 48 | "tag:Get*", 49 | "tag:Describe*", 50 | "resource-explorer:List*", 51 | "account:ListRegions" 52 | ], 53 | "Resource": "*" 54 | }, 55 | { 56 | "Sid": "CZReservedCapacity20190912", 57 | "Effect": "Allow", 58 | "Action": [ 59 | "dynamodb:DescribeReserved*", 60 | "ec2:DescribeReserved*", 61 | "elasticache:DescribeReserved*", 62 | "es:DescribeReserved*", 63 | "rds:DescribeReserved*", 64 | "redshift:DescribeReserved*" 65 | ], 66 | "Resource": "*" 67 | }, 68 | { 69 | "Sid": "CloudZeroContainerInsightsAccess20210423", 70 | "Effect": "Allow", 71 | "Action": [ 72 | "logs:List*", 73 | "logs:Describe*", 74 | "logs:StartQuery", 75 | "logs:StopQuery", 76 | "logs:Filter*", 77 | "logs:Get*" 78 | ], 79 | "Resource": "arn:aws:logs:*:*:log-group:/aws/containerinsights/*" 80 | }, 81 | { 82 | "Sid": "CloudZeroCloudWatchContainerLogStreamAccess20210906", 83 | "Effect": "Allow", 84 | "Action": [ 85 | "logs:GetQueryResults", 86 | "logs:DescribeLogGroups" 87 | ], 88 | "Resource": "arn:aws:logs:*:*:log-group::log-stream:*" 89 | }, 90 | { 91 | "Sid": "CloudZeroCloudWatchMetricsAccess20210423", 92 | "Effect": "Allow", 93 | "Action": [ 94 | "autoscaling:Describe*", 95 | "cloudwatch:Describe*", 96 | "cloudwatch:Get*", 97 | "cloudwatch:List*" 98 | ], 99 | "Resource": "*" 100 | }, 101 | { 102 | "Sid": "ReadOnlyOptimizationHub20251103", 103 | "Effect": "Allow", 104 | "Action": [ 105 | "cost-optimization-hub:GetRecommendation", 106 | "cost-optimization-hub:ListRecommendations" 107 | ], 108 | "Resource": "*" 109 | }, 110 | { 111 | "Sid": "CloudFormationAccess20251103", 112 | "Effect": "Allow", 113 | "Action": [ 114 | "cloudformation:Describe*", 115 | "cloudformation:Get*", 116 | "cloudformation:List*" 117 | ], 118 | "Resource": "*" 119 | } 120 | ] 121 | } -------------------------------------------------------------------------------- /policies/master_payer.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "AccessMasterPayerBillingBucket", 6 | "Effect": "Allow", 7 | "Action": [ 8 | "s3:Get*", 9 | "s3:List*" 10 | ], 11 | "Resource": [ 12 | "arn:aws:s3:::{{BillingBucketName}}", 13 | "arn:aws:s3:::{{BillingBucketName}}/*" 14 | ] 15 | }, 16 | { 17 | "Sid": "CZCostMonitoring20240422", 18 | "Effect": "Allow", 19 | "Action": [ 20 | "account:GetAccountInformation", 21 | "billing:Get*", 22 | "budgets:Describe*", 23 | "budgets:View*", 24 | "ce:Describe*", 25 | "ce:Get*", 26 | "ce:List*", 27 | "consolidatedbilling:Get*", 28 | "consolidatedbilling:List*", 29 | "cur:Describe*", 30 | "cur:Get*", 31 | "cur:Validate*", 32 | "cur:List*", 33 | "freetier:Get*", 34 | "invoicing:Get*", 35 | "invoicing:List*", 36 | "organizations:Describe*", 37 | "organizations:List*", 38 | "payments:Get*", 39 | "payments:List*", 40 | "pricing:*", 41 | "tax:Get*", 42 | "tax:List*" 43 | ], 44 | "Resource": "*" 45 | }, 46 | { 47 | "Sid": "CZActivityMonitoring20210423", 48 | "Effect": "Allow", 49 | "Action": [ 50 | "cloudtrail:Get*", 51 | "cloudtrail:List*", 52 | "cloudtrail:Describe*", 53 | "health:Describe*", 54 | "support:DescribeTrustedAdvisor*", 55 | "servicequotas:Get*", 56 | "servicequotas:List*", 57 | "resource-groups:Get*", 58 | "resource-groups:List*", 59 | "resource-groups:Search*", 60 | "tag:Get*", 61 | "tag:Describe*", 62 | "resource-explorer:List*", 63 | "account:ListRegions" 64 | ], 65 | "Resource": "*" 66 | }, 67 | { 68 | "Sid": "CZReservedCapacity20190912", 69 | "Effect": "Allow", 70 | "Action": [ 71 | "dynamodb:DescribeReserved*", 72 | "ec2:DescribeReserved*", 73 | "elasticache:DescribeReserved*", 74 | "es:DescribeReserved*", 75 | "rds:DescribeReserved*", 76 | "redshift:DescribeReserved*" 77 | ], 78 | "Resource": "*" 79 | }, 80 | { 81 | "Sid": "CloudZeroContainerInsightsAccess20210423", 82 | "Effect": "Allow", 83 | "Action": [ 84 | "logs:List*", 85 | "logs:Describe*", 86 | "logs:StartQuery", 87 | "logs:StopQuery", 88 | "logs:Filter*", 89 | "logs:Get*" 90 | ], 91 | "Resource": "arn:aws:logs:*:*:log-group:/aws/containerinsights/*" 92 | }, 93 | { 94 | "Sid": "CloudZeroCloudWatchContainerLogStreamAccess20210906", 95 | "Effect": "Allow", 96 | "Action": [ 97 | "logs:GetQueryResults", 98 | "logs:DescribeLogGroups" 99 | ], 100 | "Resource": "arn:aws:logs:*:*:log-group::log-stream:*" 101 | }, 102 | { 103 | "Sid": "CloudZeroCloudWatchMetricsAccess20210423", 104 | "Effect": "Allow", 105 | "Action": [ 106 | "autoscaling:Describe*", 107 | "cloudwatch:Describe*", 108 | "cloudwatch:Get*", 109 | "cloudwatch:List*" 110 | ], 111 | "Resource": "*" 112 | }, 113 | { 114 | "Sid": "ReadOnlyOptimizationHub20251103", 115 | "Effect": "Allow", 116 | "Action": [ 117 | "cost-optimization-hub:GetRecommendation", 118 | "cost-optimization-hub:ListRecommendations" 119 | ], 120 | "Resource": "*" 121 | }, 122 | { 123 | "Sid": "CloudFormationAccess20251103", 124 | "Effect": "Allow", 125 | "Action": [ 126 | "cloudformation:Describe*", 127 | "cloudformation:Get*", 128 | "cloudformation:List*" 129 | ], 130 | "Resource": "*" 131 | } 132 | ] 133 | } 134 | -------------------------------------------------------------------------------- /services/notification/Makefile: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | # Include Makefile Constants 6 | include ../../MakefileConstants.mk 7 | 8 | #################### 9 | # 10 | # Project Constants 11 | # 12 | #################### 13 | APP_NAME = $(FEATURE_NAME)-notification 14 | SRC_FILES = $(shell find src -name "*.py") 15 | TEST_FILES = $(shell find tests -name "test_*.py") 16 | COVERAGE_REPORT = coverage-reports 17 | COVERAGE_XML = coverage.xml 18 | BUILD_DIR = .aws-sam 19 | TEMP_DIR = temp 20 | DEPLOYMENT_FILE = .deploy 21 | ACCOUNT_ID = $(shell source ../../project.sh && cz_assert_profile &> /dev/null && \ 22 | aws sts get-caller-identity | jq -r -e '.Account') 23 | 24 | 25 | #################### 26 | # 27 | # Guard Defaults 28 | # 29 | #################### 30 | bucket = $${BUCKET:=cz-sam-deployment-$(ACCOUNT_ID)} # DEFAULT deployment bucket 31 | namespace = $${namespace:-${NAMESPACE}} # DEFAULT namespace 32 | 33 | 34 | #################### 35 | # 36 | # Makefile Niceties 37 | # 38 | #################### 39 | .PHONY: guard-% 40 | guard-%: 41 | @if [ -z "${${*}}" ] ; then \ 42 | printf \ 43 | "$(ERROR_COLOR)ERROR:$(NO_COLOR) Variable [$(ERROR_COLOR)$*$(NO_COLOR)] not set.\n"; \ 44 | exit 1; \ 45 | fi 46 | 47 | .PHONY: help ## Prints the names and descriptions of available targets 48 | help: 49 | @grep -E '^.PHONY: [a-zA-Z_%-\]+.*? ## .*$$' $(MAKEFILE_LIST) | cut -c 9- | sort | awk 'BEGIN {FS = "[ \t]+?## "}; {printf "\033[36m%-50s\033[0m %s\n", $$1, $$2}' 50 | 51 | 52 | .PHONY: default-% 53 | default-%: 54 | @printf "$(WARN_COLOR)$*$(NO_COLOR): $(INFO_COLOR)$($*)$(NO_COLOR)\n" 55 | 56 | 57 | .PHONY: defaults ## Prints the names and values of defaults 58 | defaults: 59 | @grep -E '^[a-z]+\ =.*DEFAULT' $(MAKEFILE_LIST) | cut -c 10- | cut -f1 -d= | sort | \ 60 | while read var ; do $(MAKE) default-$${var} ; done 61 | 62 | .PHONY: clean ## Clean python c files, reports, and build dir 63 | clean: 64 | -rm $(APP_ZIP) 65 | -rm $(LINT_RESULTS) 66 | -rm -rf $(COVERAGE_REPORT) $(COVERAGE_XML) 67 | -rm -rf $(BUILD_DIR) 68 | -find . -name '*.pyc' -exec rm -f {} + 69 | -find . -name '*.pyo' -exec rm -f {} + 70 | -find . -name '*~' -exec rm -f {} + 71 | -find . -name '__pycache__' -exec rm -fr {} + 72 | -find . -name '*.pytest_cache' -exec rm -fr {} + 73 | -find . -name '*.coverage' -exec rm -fr {} + 74 | -find . -name 'temp' -exec rm -fr {} + 75 | 76 | 77 | ################# 78 | # 79 | # Dev Targets 80 | # 81 | ################# 82 | .PHONY: lint ## Lint Python Files via Flake8 83 | lint: 84 | @flake8 --output-file=$(LINT_RESULTS) 85 | 86 | 87 | .PHONY: test ## Run Tests and Produce Coverage Report 88 | test: 89 | @pytest src tests 90 | 91 | 92 | ################# 93 | # 94 | # Convenience SAM wrapper targets for development. 95 | # All official deployment automation is done in the root Makefile. 96 | # 97 | ################# 98 | .PHONY: build-python 99 | build-python: 100 | mkdir -p $(TEMP_DIR) 101 | cp -r src $(TEMP_DIR)/src 102 | pip install -r requirements.txt -t $(TEMP_DIR) 103 | 104 | .PHONY: build ## Build sam app 105 | build: $(BUILD_DIR) 106 | $(BUILD_DIR): $(SRC_FILES) $(TEMPLATE_FILE) build-python 107 | sam build 108 | 109 | .PHONY: package 110 | package: $(APP_ZIP) 111 | $(APP_ZIP): $(BUILD_DIR) 112 | rm -f $@ ; cd $(TEMP_DIR) ; zip -rq ./../$@ . 113 | 114 | .PHONY: deploy 115 | deploy: $(DEPLOYMENT_FILE) 116 | $(DEPLOYMENT_FILE): $(PACKAGED_TEMPLATE_FILE) guard-namespace 117 | @$(MAKE) defaults 118 | @. ../../project.sh && cz_assert_profile && \ 119 | sam deploy \ 120 | --template-file $< \ 121 | --stack-name "cz-$(namespace)-$(APP_NAME)" \ 122 | --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM 123 | -------------------------------------------------------------------------------- /services/discovery/Makefile: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | # Include Makefile Constants 6 | include ../../MakefileConstants.mk 7 | 8 | #################### 9 | # 10 | # Project Constants 11 | # 12 | #################### 13 | APP_NAME = $(FEATURE_NAME)-discovery 14 | SRC_FILES = $(shell find src -name "*.py") 15 | TEST_FILES = $(shell find tests -name "test_*.py") 16 | COVERAGE_REPORT = coverage-reports 17 | COVERAGE_XML = coverage.xml 18 | BUILD_DIR = .aws-sam 19 | TEMP_DIR = temp 20 | DEPLOYMENT_FILE = .deploy 21 | ACCOUNT_ID = $(shell source ../../project.sh && cz_assert_profile &> /dev/null && \ 22 | aws sts get-caller-identity | jq -r -e '.Account') 23 | 24 | 25 | #################### 26 | # 27 | # Guard Defaults 28 | # 29 | #################### 30 | bucket = $${BUCKET:=cz-sam-deployment-$(ACCOUNT_ID)} # DEFAULT deployment bucket 31 | namespace = $${namespace:-${NAMESPACE}} # DEFAULT namespace 32 | 33 | 34 | #################### 35 | # 36 | # Makefile Niceties 37 | # 38 | #################### 39 | .PHONY: guard-% 40 | guard-%: 41 | @if [ -z "${${*}}" ] ; then \ 42 | printf \ 43 | "$(ERROR_COLOR)ERROR:$(NO_COLOR) Variable [$(ERROR_COLOR)$*$(NO_COLOR)] not set.\n"; \ 44 | exit 1; \ 45 | fi 46 | 47 | .PHONY: help ## Prints the names and descriptions of available targets 48 | help: 49 | @grep -E '^.PHONY: [a-zA-Z_%-\]+.*? ## .*$$' $(MAKEFILE_LIST) | cut -c 9- | sort | awk 'BEGIN {FS = "[ \t]+?## "}; {printf "\033[36m%-50s\033[0m %s\n", $$1, $$2}' 50 | 51 | 52 | .PHONY: default-% 53 | default-%: 54 | @printf "$(WARN_COLOR)$*$(NO_COLOR): $(INFO_COLOR)$($*)$(NO_COLOR)\n" 55 | 56 | 57 | .PHONY: defaults ## Prints the names and values of defaults 58 | defaults: 59 | @grep -E '^[a-z]+\ =.*DEFAULT' $(MAKEFILE_LIST) | cut -c 10- | cut -f1 -d= | sort | \ 60 | while read var ; do $(MAKE) default-$${var} ; done 61 | 62 | .PHONY: clean ## Clean python c files, reports, and build dir 63 | clean: 64 | -rm $(APP_ZIP) 65 | -rm $(LINT_RESULTS) 66 | -rm -rf $(COVERAGE_REPORT) $(COVERAGE_XML) 67 | -rm -rf $(BUILD_DIR) 68 | -find . -name '*.pyc' -exec rm -f {} + 69 | -find . -name '*.pyo' -exec rm -f {} + 70 | -find . -name '*~' -exec rm -f {} + 71 | -find . -name '__pycache__' -exec rm -fr {} + 72 | -find . -name '*.pytest_cache' -exec rm -fr {} + 73 | -find . -name '*.coverage' -exec rm -fr {} + 74 | -find . -name 'temp' -exec rm -fr {} + 75 | 76 | 77 | ################# 78 | # 79 | # Dev Targets 80 | # 81 | ################# 82 | .PHONY: lint ## Lint Python Files via Flake8 83 | lint: 84 | @flake8 --output-file=$(LINT_RESULTS) 85 | 86 | 87 | .PHONY: test ## Run Tests and Produce Coverage Report 88 | test: 89 | @pytest src tests 90 | 91 | 92 | ################# 93 | # 94 | # Convenience SAM wrapper targets for development. 95 | # All official deployment automation is done in the root Makefile. 96 | # 97 | ################# 98 | .PHONY: build-python 99 | build-python: 100 | mkdir -p $(TEMP_DIR) 101 | cp -r src $(TEMP_DIR)/src 102 | pip install -r requirements.txt -t $(TEMP_DIR) 103 | 104 | .PHONY: build ## Build sam app 105 | build: $(BUILD_DIR) 106 | $(BUILD_DIR): $(SRC_FILES) $(TEMPLATE_FILE) build-python 107 | sam build 108 | 109 | .PHONY: package 110 | package: $(APP_ZIP) 111 | $(APP_ZIP): $(BUILD_DIR) 112 | rm -f $@ ; cd $(TEMP_DIR) ; zip -rq ./../$@ . 113 | 114 | 115 | .PHONY: deploy 116 | deploy: $(DEPLOYMENT_FILE) 117 | $(DEPLOYMENT_FILE): $(PACKAGED_TEMPLATE_FILE) guard-namespace 118 | @$(MAKE) defaults 119 | @. ../../project.sh && cz_assert_profile && \ 120 | sam deploy \ 121 | --template-file $< \ 122 | --stack-name "cz-$(namespace)-$(APP_NAME)" \ 123 | --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM 124 | -------------------------------------------------------------------------------- /docs/releases/1.0.89.md: -------------------------------------------------------------------------------- 1 | # [1.0.89](https://github.com/Cloudzero/provision-account/compare/1.0.88...1.0.89) (2025-11-06) 2 | 3 | > Update IAM policies to latest AWS managed policies, improve CUR validation flexibility, upgrade Python runtime to 3.12, and add comprehensive permissions documentation 4 | 5 | ## New Features 6 | 7 | ### IAM Policy Updates 8 | * **Replace SecurityAudit with ComputeOptimizerReadOnlyAccess** - Align with latest AWS Compute Optimizer features for better rightsizing recommendations 9 | * **Add ReadOnlyOptimizationHub20251103 policy statement** - Enable access to AWS Cost Optimization Hub recommendations using correct `cost-optimization-hub:GetRecommendation` and `cost-optimization-hub:ListRecommendations` actions 10 | * **Add CloudFormationAccess20251103 policy statement** - Comprehensive CloudFormation read permissions (Describe*, Get*, List*) 11 | * **Remove duplicate CloudFormation actions** - Removed `cloudformation:DescribeStacks` and `cloudformation:ListStackResources` from CZActivityMonitoring20210423 statement (now covered by CloudFormationAccess20251103) 12 | 13 | ### CUR Validation Improvements 14 | * **Implement two-tier CUR validation** - IDEAL schema (preferred) and MINIMUM schema (fallback) 15 | * **Accept DAILY CUR reports** - In addition to preferred HOURLY reports 16 | * **Flexible AdditionalSchemaElements** - Accept any value, prefer ['RESOURCES'] 17 | * **Flexible ReportVersioning** - Accept any value, prefer CREATE_NEW_REPORT 18 | * **Flexible RefreshClosedReports** - Accept True or False, prefer True 19 | * Improves compatibility with existing customer CUR configurations 20 | 21 | ### Documentation (Fixes #28) 22 | * **Add comprehensive "AWS Permissions Explained" section to README** - Addresses 5+ year old community request 23 | * **7-category permission breakdown** - Cost & Billing Analysis, Compute Optimization, Container Cost Tracking, Resource Discovery, Activity Monitoring, Infrastructure Configuration, Optimization Recommendations 24 | * **Document AWS managed policies** - ComputeOptimizerReadOnlyAccess, ViewOnlyAccess, CloudWatchReadOnlyAccess, AWSBillingReadOnlyAccess 25 | * **Explain account types** - Master Payer vs Resource Owner with clear use cases 26 | * **Security & privacy information** - Read-only access, cross-account IAM roles, encryption, SOC 2 compliance 27 | * **Deployment options** - CloudFormation vs Terraform comparison 28 | 29 | ## Improvements 30 | 31 | ### Python Runtime 32 | * **Upgrade from Python 3.11 → 3.12** for all Lambda functions 33 | - `services/account_type/master_payer.yaml` - Inline CUR creation Lambda 34 | - `services/discovery/template.yaml` - Discovery service Lambda 35 | - `services/notification/template.yaml` - Notification service Lambda 36 | 37 | ### Code Quality 38 | * **Fix linting issue** - Convert `not (x in y)` to `x not in y` (E713) 39 | * **Add .venv to .gitignore** - Improve local development experience 40 | 41 | ## Files Modified 42 | 43 | **12 files changed** (+221 insertions, -30 deletions) 44 | 45 | ### CloudFormation Templates 46 | - `services/account_type/master_payer.yaml` 47 | - `services/account_type/resource_owner.yaml` 48 | - `services/discovery/template.yaml` 49 | - `services/notification/template.yaml` 50 | 51 | ### Terraform Modules 52 | - `terraform/cloudzero-payer/main.tf` 53 | - `terraform/cloudzero-resource/main.tf` 54 | 55 | ### Policy Documentation 56 | - `policies/managed.json` 57 | - `policies/master_payer.json` 58 | - `policies/resource_owner.json` 59 | 60 | ### Python Code 61 | - `services/discovery/src/app.py` 62 | 63 | ### Documentation & Config 64 | - `README.md` (+77 lines of permissions documentation) 65 | - `.gitignore` 66 | 67 | ## Testing 68 | 69 | ✅ All tests passing (18/18) 70 | - Discovery service: 9/9 tests passed (87.65% coverage) 71 | - Notification service: 9/9 tests passed (87.65% coverage) 72 | 73 | ✅ Linting clean (ruff) 74 | 75 | ## Breaking Changes 76 | 77 | **None** - All changes are backward compatible 78 | 79 | ## Deployment Notes 80 | 81 | ### CloudFormation 82 | - Templates can be deployed immediately 83 | - Existing stacks can be updated in place 84 | - No manual intervention required 85 | 86 | ### Terraform 87 | - Run `terraform plan` to review changes 88 | - Apply will update IAM policies in place 89 | - No resource recreation required 90 | 91 | ### Lambda Functions 92 | - Python 3.12 runtime available in all AWS regions 93 | - No code changes required for upgrade 94 | 95 | ## Related Issues 96 | 97 | Closes #28 - Request for service permission documentation (opened September 23, 2019) 98 | -------------------------------------------------------------------------------- /services/discovery/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode -------------------------------------------------------------------------------- /services/notification/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode -------------------------------------------------------------------------------- /terraform/cloudzero-resource/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | cz_account_id = "061190967865" 3 | } 4 | 5 | resource "aws_iam_role" "cloudzero" { 6 | name = "cloudzero-access" 7 | path = "/" 8 | assume_role_policy = jsonencode( 9 | { 10 | "Version" : "2012-10-17", 11 | "Statement" : [ 12 | { 13 | "Sid" : "", 14 | "Effect" : "Allow", 15 | "Principal" : { 16 | "AWS" : "arn:aws:iam::${local.cz_account_id}:root" 17 | }, 18 | "Action" : "sts:AssumeRole", 19 | "Condition" : { 20 | "StringEquals" : { 21 | "sts:ExternalId" : var.cloudzero_external_id 22 | } 23 | } 24 | } 25 | ] 26 | }) 27 | } 28 | 29 | resource "aws_iam_role_policy" "CloudZero" { 30 | name = "cloudzero-access-policy" 31 | role = aws_iam_role.cloudzero.id 32 | policy = jsonencode( 33 | { 34 | "Version" : "2012-10-17", 35 | "Statement" : [ 36 | { 37 | "Sid": "CZCostMonitoring20230119", 38 | "Effect": "Allow", 39 | "Action": [ 40 | "account:GetAccountInformation", 41 | "billing:Get*", 42 | "budgets:Describe*", 43 | "budgets:View*", 44 | "ce:Describe*", 45 | "ce:Get*", 46 | "ce:List*", 47 | "consolidatedbilling:Get*", 48 | "consolidatedbilling:List*", 49 | "cur:Describe*", 50 | "cur:Get*", 51 | "cur:Validate*", 52 | "cur:List*", 53 | "freetier:Get*", 54 | "invoicing:Get*", 55 | "invoicing:List*", 56 | "organizations:Describe*", 57 | "organizations:List*", 58 | "payments:Get*", 59 | "payments:List*", 60 | "pricing:*", 61 | "tax:Get*", 62 | "tax:List*" 63 | ], 64 | "Resource": "*" 65 | }, 66 | { 67 | "Sid": "CZActivityMonitoring20210423", 68 | "Effect": "Allow", 69 | "Action": [ 70 | "cloudtrail:Get*", 71 | "cloudtrail:List*", 72 | "cloudtrail:Describe*", 73 | "health:Describe*", 74 | "support:DescribeTrustedAdvisor*", 75 | "servicequotas:Get*", 76 | "servicequotas:List*", 77 | "resource-groups:Get*", 78 | "resource-groups:List*", 79 | "resource-groups:Search*", 80 | "tag:Get*", 81 | "tag:Describe*", 82 | "resource-explorer:List*", 83 | "account:ListRegions" 84 | ], 85 | "Resource": "*" 86 | }, 87 | { 88 | "Sid": "CZReservedCapacity20190912", 89 | "Effect": "Allow", 90 | "Action": [ 91 | "dynamodb:DescribeReserved*", 92 | "ec2:DescribeReserved*", 93 | "elasticache:DescribeReserved*", 94 | "es:DescribeReserved*", 95 | "rds:DescribeReserved*", 96 | "redshift:DescribeReserved*" 97 | ], 98 | "Resource": "*" 99 | }, 100 | { 101 | "Sid": "CloudZeroContainerInsightsAccess20210423", 102 | "Effect": "Allow", 103 | "Action": [ 104 | "logs:List*", 105 | "logs:Describe*", 106 | "logs:StartQuery", 107 | "logs:StopQuery", 108 | "logs:Filter*", 109 | "logs:Get*" 110 | ], 111 | "Resource": "arn:aws:logs:*:*:log-group:/aws/containerinsights/*" 112 | }, 113 | { 114 | "Sid": "CloudZeroCloudWatchContainerLogStreamAccess20210906", 115 | "Effect": "Allow", 116 | "Action": [ 117 | "logs:GetQueryResults", 118 | "logs:DescribeLogGroups" 119 | ], 120 | "Resource": "arn:aws:logs:*:*:log-group::log-stream:*" 121 | }, 122 | { 123 | "Sid": "CloudZeroCloudWatchMetricsAccess20210423", 124 | "Effect": "Allow", 125 | "Action": [ 126 | "autoscaling:Describe*", 127 | "cloudwatch:Describe*", 128 | "cloudwatch:Get*", 129 | "cloudwatch:List*" 130 | ], 131 | "Resource": "*" 132 | }, 133 | { 134 | "Sid": "ReadOnlyOptimizationHub20251103", 135 | "Effect": "Allow", 136 | "Action": [ 137 | "cost-optimization-hub:GetRecommendation", 138 | "cost-optimization-hub:ListRecommendations" 139 | ], 140 | "Resource": "*" 141 | }, 142 | { 143 | "Sid": "CloudFormationAccess20251103", 144 | "Effect": "Allow", 145 | "Action": [ 146 | "cloudformation:Describe*", 147 | "cloudformation:Get*", 148 | "cloudformation:List*" 149 | ], 150 | "Resource": "*" 151 | } 152 | ] 153 | }) 154 | } 155 | 156 | resource "aws_iam_role_policy_attachment" "compute_optimizer_access" { 157 | role = aws_iam_role.cloudzero.name 158 | policy_arn = "arn:aws:iam::aws:policy/ComputeOptimizerReadOnlyAccess" 159 | } 160 | 161 | resource "aws_iam_role_policy_attachment" "view_only_access" { 162 | role = aws_iam_role.cloudzero.name 163 | policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess" 164 | } -------------------------------------------------------------------------------- /terraform/cloudzero-payer/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | cz_account_id = "061190967865" 3 | } 4 | 5 | resource "aws_iam_role" "cloudzero" { 6 | name = "cloudzero-access" 7 | path = "/" 8 | assume_role_policy = jsonencode( 9 | { 10 | "Version" : "2012-10-17", 11 | "Statement" : [ 12 | { 13 | "Sid" : "", 14 | "Effect" : "Allow", 15 | "Principal" : { 16 | "AWS" : "arn:aws:iam::${local.cz_account_id}:root" 17 | }, 18 | "Action" : "sts:AssumeRole", 19 | "Condition" : { 20 | "StringEquals" : { 21 | "sts:ExternalId" : var.cloudzero_external_id 22 | } 23 | } 24 | } 25 | ] 26 | }) 27 | } 28 | 29 | resource "aws_iam_role_policy" "CloudZero" { 30 | name = "cloudzero-access-policy" 31 | role = aws_iam_role.cloudzero.id 32 | policy = jsonencode( 33 | { 34 | "Version" : "2012-10-17", 35 | "Statement" : [ 36 | { 37 | "Sid" : "AccessCURDataBucket1", 38 | "Effect" : "Allow", 39 | "Action" : [ 40 | "S3:get*", 41 | "S3:list*" 42 | ], 43 | "Resource" : [ 44 | "arn:aws:s3:::${var.AWS_CUR_bucket}", 45 | "arn:aws:s3:::${var.AWS_CUR_bucket}/*" 46 | ] 47 | }, 48 | { 49 | "Sid": "CZCostMonitoring20230119", 50 | "Effect": "Allow", 51 | "Action": [ 52 | "account:GetAccountInformation", 53 | "billing:Get*", 54 | "budgets:Describe*", 55 | "budgets:View*", 56 | "ce:Describe*", 57 | "ce:Get*", 58 | "ce:List*", 59 | "consolidatedbilling:Get*", 60 | "consolidatedbilling:List*", 61 | "cur:Describe*", 62 | "cur:Get*", 63 | "cur:Validate*", 64 | "cur:List*", 65 | "freetier:Get*", 66 | "invoicing:Get*", 67 | "invoicing:List*", 68 | "organizations:Describe*", 69 | "organizations:List*", 70 | "payments:Get*", 71 | "payments:List*", 72 | "pricing:*", 73 | "tax:Get*", 74 | "tax:List*" 75 | ], 76 | "Resource": "*" 77 | }, 78 | { 79 | "Sid": "CZActivityMonitoring20210423", 80 | "Effect": "Allow", 81 | "Action": [ 82 | "cloudtrail:Get*", 83 | "cloudtrail:List*", 84 | "cloudtrail:Describe*", 85 | "health:Describe*", 86 | "support:DescribeTrustedAdvisor*", 87 | "servicequotas:Get*", 88 | "servicequotas:List*", 89 | "resource-groups:Get*", 90 | "resource-groups:List*", 91 | "resource-groups:Search*", 92 | "tag:Get*", 93 | "tag:Describe*", 94 | "resource-explorer:List*", 95 | "account:ListRegions" 96 | ], 97 | "Resource": "*" 98 | }, 99 | { 100 | "Sid": "CZReservedCapacity20190912", 101 | "Effect": "Allow", 102 | "Action": [ 103 | "dynamodb:DescribeReserved*", 104 | "ec2:DescribeReserved*", 105 | "elasticache:DescribeReserved*", 106 | "es:DescribeReserved*", 107 | "rds:DescribeReserved*", 108 | "redshift:DescribeReserved*" 109 | ], 110 | "Resource": "*" 111 | }, 112 | { 113 | "Sid": "CloudZeroContainerInsightsAccess20210423", 114 | "Effect": "Allow", 115 | "Action": [ 116 | "logs:List*", 117 | "logs:Describe*", 118 | "logs:StartQuery", 119 | "logs:StopQuery", 120 | "logs:Filter*", 121 | "logs:Get*" 122 | ], 123 | "Resource": "arn:aws:logs:*:*:log-group:/aws/containerinsights/*" 124 | }, 125 | { 126 | "Sid": "CloudZeroCloudWatchContainerLogStreamAccess20210906", 127 | "Effect": "Allow", 128 | "Action": [ 129 | "logs:GetQueryResults", 130 | "logs:DescribeLogGroups" 131 | ], 132 | "Resource": "arn:aws:logs:*:*:log-group::log-stream:*" 133 | }, 134 | { 135 | "Sid": "CloudZeroCloudWatchMetricsAccess20210423", 136 | "Effect": "Allow", 137 | "Action": [ 138 | "autoscaling:Describe*", 139 | "cloudwatch:Describe*", 140 | "cloudwatch:Get*", 141 | "cloudwatch:List*" 142 | ], 143 | "Resource": "*" 144 | }, 145 | { 146 | "Sid": "ReadOnlyOptimizationHub20251103", 147 | "Effect": "Allow", 148 | "Action": [ 149 | "cost-optimization-hub:GetRecommendation", 150 | "cost-optimization-hub:ListRecommendations" 151 | ], 152 | "Resource": "*" 153 | }, 154 | { 155 | "Sid": "CloudFormationAccess20251103", 156 | "Effect": "Allow", 157 | "Action": [ 158 | "cloudformation:Describe*", 159 | "cloudformation:Get*", 160 | "cloudformation:List*" 161 | ], 162 | "Resource": "*" 163 | } 164 | ] 165 | }) 166 | } 167 | 168 | resource "aws_iam_role_policy_attachment" "compute_optimizer_access" { 169 | role = aws_iam_role.cloudzero.name 170 | policy_arn = "arn:aws:iam::aws:policy/ComputeOptimizerReadOnlyAccess" 171 | } 172 | 173 | resource "aws_iam_role_policy_attachment" "view_only_access" { 174 | role = aws_iam_role.cloudzero.name 175 | policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess" 176 | } -------------------------------------------------------------------------------- /services/account_type/resource_owner.yaml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | AWSTemplateFormatVersion: '2010-09-09' 6 | Description: CloudZero Resource Owner Account Template 7 | 8 | Parameters: 9 | ExternalId: 10 | Type: String 11 | Description: | 12 | Unique ExternalId for Customer Organization; for cross-account Role Access and 13 | associating this template with a Customer Organization 14 | ReactorAccountId: 15 | Type: String 16 | Description: | 17 | CloudZero AWS AccountID; for cross-account Role Access 18 | IsResourceOwnerAccount: 19 | Type: String 20 | Description: | 21 | Should this template instantiate any resources? 22 | 23 | Conditions: 24 | CreateResources: !Equals [ !Ref IsResourceOwnerAccount, 'true' ] 25 | 26 | Resources: 27 | Role: 28 | Type: AWS::IAM::Role 29 | Condition: CreateResources 30 | Properties: 31 | Path: '/cloudzero/' 32 | AssumeRolePolicyDocument: 33 | Version: 2012-10-17 34 | Statement: 35 | - Effect: Allow 36 | Principal: 37 | AWS: !Sub 'arn:aws:iam::${ReactorAccountId}:root' 38 | Action: 39 | - 'sts:AssumeRole' 40 | Condition: 41 | StringEquals: 42 | 'sts:ExternalId': !Ref ExternalId 43 | ManagedPolicyArns: 44 | - arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess 45 | - arn:aws:iam::aws:policy/ComputeOptimizerReadOnlyAccess 46 | - arn:aws:iam::aws:policy/job-function/ViewOnlyAccess 47 | - arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess 48 | 49 | RolePolicy: 50 | Type: AWS::IAM::Policy 51 | Condition: CreateResources 52 | Properties: 53 | PolicyName: !Sub 'cloudzero-resource-owner-policy-${ReactorAccountId}' 54 | PolicyDocument: 55 | Version: 2012-10-17 56 | Statement: 57 | - Sid: CZCostMonitoring20240422 58 | Effect: Allow 59 | Action: 60 | - account:GetAccountInformation 61 | - billing:Get* 62 | - budgets:Describe* 63 | - budgets:View* 64 | - ce:Describe* 65 | - ce:Get* 66 | - ce:List* 67 | - consolidatedbilling:Get* 68 | - consolidatedbilling:List* 69 | - cur:Describe* 70 | - cur:Get* 71 | - cur:Validate* 72 | - cur:List* 73 | - freetier:Get* 74 | - invoicing:Get* 75 | - invoicing:List* 76 | - organizations:Describe* 77 | - organizations:List* 78 | - payments:Get* 79 | - payments:List* 80 | - pricing:* 81 | - tax:Get* 82 | - tax:List* 83 | Resource: "*" 84 | - Sid: CZActivityMonitoring20210423 85 | Effect: Allow 86 | Action: 87 | - cloudtrail:Get* 88 | - cloudtrail:List* 89 | - cloudtrail:Describe* 90 | - health:Describe* 91 | - support:DescribeTrustedAdvisor* 92 | - servicequotas:Get* 93 | - servicequotas:List* 94 | - resource-groups:Get* 95 | - resource-groups:List* 96 | - resource-groups:Search* 97 | - tag:Get* 98 | - tag:Describe* 99 | - resource-explorer:List* 100 | - account:ListRegions 101 | Resource: "*" 102 | - Sid: CZReservedCapacity20190912 103 | Effect: Allow 104 | Action: 105 | - dynamodb:DescribeReserved* 106 | - ec2:DescribeReserved* 107 | - elasticache:DescribeReserved* 108 | - es:DescribeReserved* 109 | - rds:DescribeReserved* 110 | - redshift:DescribeReserved* 111 | Resource: "*" 112 | - Sid: CloudZeroContainerInsightsAccess20210423 113 | Effect: Allow 114 | Action: 115 | - logs:List* 116 | - logs:Describe* 117 | - logs:StartQuery 118 | - logs:StopQuery 119 | - logs:Filter* 120 | - logs:Get* 121 | Resource: arn:aws:logs:*:*:log-group:/aws/containerinsights/* 122 | - Sid: CloudZeroCloudWatchContainerLogStreamAccess20210906 123 | Effect: Allow 124 | Action: 125 | - logs:GetQueryResults 126 | - logs:DescribeLogGroups 127 | Resource: arn:aws:logs:*:*:log-group::log-stream:* 128 | - Sid: CloudZeroCloudWatchMetricsAccess20210423 129 | Effect: Allow 130 | Action: 131 | - autoscaling:Describe* 132 | - cloudwatch:Describe* 133 | - cloudwatch:Get* 134 | - cloudwatch:List* 135 | Resource: "*" 136 | - Sid: ReadOnlyOptimizationHub20251103 137 | Effect: Allow 138 | Action: 139 | - cost-optimization-hub:GetRecommendation 140 | - cost-optimization-hub:ListRecommendations 141 | Resource: "*" 142 | - Sid: CloudFormationAccess20251103 143 | Effect: Allow 144 | Action: 145 | - cloudformation:Describe* 146 | - cloudformation:Get* 147 | - cloudformation:List* 148 | Resource: "*" 149 | Roles: 150 | - !Ref Role 151 | 152 | Outputs: 153 | RoleArn: 154 | Condition: CreateResources 155 | Value: !GetAtt Role.Arn 156 | Description: Resource Owner Cross Account Role ARN 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudZero Account Provisioning Template for AWS 2 | 3 | ## About CloudZero 4 | CloudZero provides Cloud Cost Intelligence for Engineering Teams and is designed to eliminate manual work, build cost optimized software, and optimize your AWS bill. 5 | 6 | To learn more about CloudZero, visit [CloudZero.com](cloudzero.com) or start by creating an account at https://app.cloudzero.com and activating a free 30 day trial. 7 | 8 | ## About this template 9 | This template provides full transparency into the permissions and process CloudZero recommends for connecting their AWS accounts. In addition to using this template to fully automate the process, CloudZero supports multiple manual methods for connecting accounts to the CloudZero platform. You can learn more about your options at [docs.cloudzero.com](https://docs.cloudzero.com/docs/getting-started) 10 | 11 | ## AWS Permissions Explained 12 | 13 | CloudZero requires **read-only** access to your AWS account to provide cost intelligence and optimization recommendations. CloudZero **never** modifies your AWS resources, infrastructure, or configurations. 14 | 15 | ### Why CloudZero Needs AWS Access 16 | 17 | CloudZero analyzes your AWS usage to: 18 | - Break down costs by team, product, feature, or any dimension you choose 19 | - Identify optimization opportunities (rightsizing, reserved capacity, unused resources) 20 | - Track Kubernetes and container costs at the pod/service level 21 | - Monitor cost anomalies and unusual spending patterns 22 | - Provide unit cost metrics (cost per customer, per transaction, per deployment) 23 | 24 | ### Permission Categories 25 | 26 | | Category | AWS Services | Purpose | What CloudZero Accesses | 27 | |----------|--------------|---------|-------------------------| 28 | | **Cost & Billing Analysis** | S3 (CUR), Cost Explorer, Billing, Pricing, Tax, Invoicing | Analyze AWS spending and generate cost intelligence reports | Cost and Usage Report data, billing details, pricing information | 29 | | **Compute Optimization** | Compute Optimizer, Reserved Instances (EC2, RDS, DynamoDB, ElastiCache, Redshift, OpenSearch) | Identify rightsizing opportunities and reserved capacity recommendations | Instance types, utilization metrics, reservation coverage | 30 | | **Container Cost Tracking** | CloudWatch Logs (Container Insights), ECS/EKS metrics | Attribute costs to containers, pods, and Kubernetes services | Container metrics, pod-level resource usage, cluster information | 31 | | **Resource Discovery** | Organizations, Resource Groups, Resource Explorer, Tagging API | Map resources to teams, applications, and cost centers | Resource tags, account structure, organizational hierarchy | 32 | | **Activity Monitoring** | CloudTrail, Health API, CloudWatch Metrics, Auto Scaling | Track resource lifecycle, usage patterns, and scaling behavior | API activity logs, health events, metric data, scaling configurations | 33 | | **Infrastructure Configuration** | CloudFormation, Service Quotas | Understand resource relationships and service limits | Stack information, resource dependencies, quota usage | 34 | | **Optimization Recommendations** | AWS Optimization Hub, Trusted Advisor | Surface AWS-native cost and performance recommendations | Optimization suggestions, trusted advisor checks, service health | 35 | 36 | ### AWS Managed Policies Used 37 | 38 | CloudZero uses the following AWS-managed policies for broad read-only access: 39 | 40 | - **ComputeOptimizerReadOnlyAccess**: Provides access to AWS Compute Optimizer recommendations for rightsizing EC2 instances, Auto Scaling groups, EBS volumes, and Lambda functions 41 | - **ViewOnlyAccess**: AWS-managed policy providing read-only access to most AWS services for comprehensive resource discovery and monitoring 42 | - **CloudWatchReadOnlyAccess**: Read access to CloudWatch metrics, logs, and alarms for performance monitoring and cost attribution 43 | - **AWSBillingReadOnlyAccess**: Read access to billing, cost, and usage data for cost analysis and reporting 44 | 45 | ### Account Types 46 | 47 | CloudZero supports two primary account connection types: 48 | 49 | #### Master Payer Account 50 | The AWS account that contains your Cost and Usage Report (CUR) and is the payer for your organization. This account provides: 51 | - Access to detailed billing data via CUR in S3 52 | - Organization-wide cost visibility 53 | - Consolidated billing information 54 | - Typically only one per AWS Organization 55 | 56 | **Important**: CloudZero requires an HOURLY Cost and Usage Report. Daily reports are not supported. 57 | 58 | #### Resource Owner Account 59 | Member accounts in your AWS Organization that own and run resources. These accounts provide: 60 | - Resource-level cost attribution 61 | - Container and Kubernetes cost tracking 62 | - Activity monitoring and optimization recommendations 63 | - Tagging and resource grouping data 64 | 65 | ### Security & Privacy 66 | 67 | - **Read-Only Access**: All permissions are strictly read-only. CloudZero cannot create, modify, or delete any AWS resources 68 | - **Cross-Account IAM Roles**: Uses AWS best practice cross-account roles with external ID for secure, auditable access 69 | - **No Direct Access**: No SSH keys, API keys, or direct instance access required 70 | - **Encryption**: All data is encrypted in transit (TLS) and at rest 71 | - **Compliance**: CloudZero is SOC 2 Type II certified 72 | - **Data Retention**: Cost data is retained according to your CloudZero subscription agreement 73 | 74 | For more information about CloudZero's security practices, visit [cloudzero.com/security](https://www.cloudzero.com/security) 75 | 76 | ### Deployment Options 77 | 78 | This repository provides two deployment methods: 79 | 80 | 1. **CloudFormation Templates** (Recommended): Located in `services/` directory 81 | - Automated deployment via AWS CloudFormation 82 | - Creates IAM roles and policies automatically 83 | - Supports nested stacks for different account types 84 | 85 | 2. **Terraform Modules**: Located in `terraform/` directory 86 | - Infrastructure-as-code deployment 87 | - Version-controlled IAM configuration 88 | - Suitable for organizations using Terraform 89 | 90 | ## Questions? We got answers! 91 | If you have questions or want to report an issue with this template, feel free to open an issue or write to us at support@cloudzero.com 92 | -------------------------------------------------------------------------------- /services/notification/tests/unit/test_app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | import os 6 | import random 7 | from collections import namedtuple 8 | 9 | import pytest 10 | import json 11 | 12 | import src.app as app 13 | from src import cfnresponse 14 | 15 | EXPECTED_URL = 'url' 16 | 17 | 18 | # TODO: Replace these fixtures with Voluptuous Schemas + Hypothesis + Property Tests 19 | @pytest.fixture(scope='function') 20 | def cfn_event(): 21 | return { 22 | 'LogicalResourceId': 'some logical resource id', 23 | 'PhysicalResourceId': None, 24 | 'RequestId': 'some request id', 25 | 'RequestType': 'Create', 26 | 'ResourceProperties': { 27 | 'AccountId': 'str', 28 | 'Region': 'str', 29 | 'ExternalId': 'str', 30 | 'ReactorCallbackUrl': EXPECTED_URL, 31 | 'AccountName': 'str', 32 | 'ReactorId': 'str', 33 | 'Stacks': { 34 | 'AuditAccount': 'stack-arn', 35 | 'CloudTrailOwnerAccount': 'stack-arn', 36 | 'Discovery': 'stack-arn', 37 | 'MasterPayerAccount': 'stack-arn', 38 | 'ResourceOwnerAccount': 'stack-arn', 39 | 'LegacyAccount': 'stack-arn', 40 | } 41 | }, 42 | 'ResponseURL': 'https://cfn.amazonaws.com/callback', 43 | 'StackId': 'some-cfn-stack-id', 44 | } 45 | 46 | 47 | def random_bool(): 48 | return bool(random.getrandbits(1)) 49 | 50 | 51 | def nullable_arn(): 52 | return 'arn:aws:lambda:us-east-1:999999999999:function:name' if random_bool() else 'null' 53 | 54 | 55 | def nullable_string(): 56 | return 'foo' if random_bool() else 'null' 57 | 58 | 59 | def boolean_string(): 60 | return 'True' if random_bool() else 'False' 61 | 62 | 63 | @pytest.fixture(scope='function') 64 | def cfn_coeffect(): 65 | return { 66 | 'AuditAccount': { 67 | 'RoleArn': nullable_arn() 68 | }, 69 | 'CloudTrailOwnerAccount': { 70 | 'SQSQueueArn': nullable_arn(), 71 | 'SQSQueuePolicyName': nullable_string(), 72 | }, 73 | 'Discovery': { 74 | 'AuditCloudTrailBucketName': nullable_string(), 75 | 'AuditCloudTrailBucketPrefix': nullable_string(), 76 | 'CloudTrailSNSTopicArn': nullable_arn(), 77 | 'CloudTrailTrailArn': nullable_arn(), 78 | 'VisibleCloudTrailArns': nullable_string(), 79 | 'IsAuditAccount': boolean_string(), 80 | 'IsCloudTrailOwnerAccount': boolean_string(), 81 | 'IsMasterPayerAccount': boolean_string(), 82 | 'IsOrganizationMasterAccount': boolean_string(), 83 | 'IsOrganizationTrail': boolean_string(), 84 | 'IsResourceOwnerAccount': boolean_string(), 85 | 'MasterPayerBillingBucketName': nullable_string(), 86 | 'MasterPayerBillingBucketPath': nullable_string(), 87 | 'RemoteCloudTrailBucket': boolean_string(), 88 | }, 89 | 'MasterPayerAccount': { 90 | 'RoleArn': nullable_arn(), 91 | 'ReportS3Bucket': nullable_string(), 92 | 'ReportS3Prefix': nullable_string(), 93 | }, 94 | 'ResourceOwnerAccount': { 95 | 'RoleArn': nullable_arn(), 96 | }, 97 | 'LegacyAccount': { 98 | 'RoleArn': nullable_arn(), 99 | } 100 | } 101 | 102 | 103 | class Response: 104 | def __init__(self, status_code, json_data={}, data={}): 105 | self.status_code = status_code 106 | self._json = json_data 107 | self._data = {} 108 | self.text = json.dumps(self._json) 109 | 110 | def json(self): 111 | return self._json 112 | 113 | def data(self): 114 | return self._data 115 | 116 | 117 | @pytest.fixture(scope='function') 118 | def context(mocker): 119 | context = namedtuple('context', ['os', 'prefix']) 120 | orig_env = os.environ.copy() 121 | context.os = {'environ': os.environ} 122 | context.prefix = app.__name__ 123 | context.mock_cfnresponse_send = mocker.patch(f'{context.prefix}.cfnresponse.send', autospec=True) 124 | context.mock_http = mocker.patch(f'{context.prefix}.http') 125 | context.mock_cfn = mocker.patch(f'{context.prefix}.cfn', autospec=True) 126 | yield context 127 | os.environ = orig_env 128 | mocker.stopall() 129 | 130 | 131 | @pytest.mark.unit 132 | def test_handler_no_cfn_coeffects(context, cfn_event): 133 | response = Response(200) 134 | context.mock_http.return_value = response 135 | ret = app.handler(cfn_event, None) 136 | assert ret is None 137 | assert context.mock_cfnresponse_send.call_count == 1 138 | assert context.mock_http.request.call_count == 1 139 | ((_, _, status, output, _), _) = context.mock_cfnresponse_send.call_args 140 | expected = { 141 | 'version': '1', 142 | 'message_source': 'cfn', 143 | 'message_type': 'account-link-provisioned', 144 | 'data': { 145 | 'discovery': { 146 | 'audit_cloudtrail_bucket_name': None, 147 | 'audit_cloudtrail_bucket_prefix': None, 148 | 'cloudtrail_sns_topic_arn': None, 149 | 'cloudtrail_trail_arn': None, 150 | 'is_audit_account': False, 151 | 'is_cloudtrail_owner_account': False, 152 | 'is_master_payer_account': False, 153 | 'is_organization_trail': None, 154 | 'is_organization_master_account': False, 155 | 'is_resource_owner_account': False, 156 | 'master_payer_billing_bucket_name': None, 157 | 'master_payer_billing_bucket_path': None, 158 | 'remote_cloudtrail_bucket': True, 159 | 'visible_cloudtrail_arns': None, 160 | }, 161 | 'metadata': { 162 | 'cloud_region': 'str', 163 | 'cz_account_name': 'str', 164 | 'cloud_account_id': 'str', 165 | 'reactor_callback_url': EXPECTED_URL, 166 | 'external_id': 'str', 167 | 'reactor_id': 'str', 168 | }, 169 | 'links': { 170 | 'audit': {'role_arn': None}, 171 | 'legacy': {'role_arn': None}, 172 | 'master_payer': {'role_arn': None}, 173 | 'resource_owner': {'role_arn': None}, 174 | 'cloudtrail_owner': { 175 | 'sqs_queue_arn': None, 176 | 'sqs_queue_policy_name': None, 177 | }, 178 | } 179 | } 180 | } 181 | assert status == cfnresponse.SUCCESS 182 | assert output == expected 183 | (args, kwargs) = context.mock_http.request.call_args 184 | assert args == ('POST', EXPECTED_URL) 185 | assert json.loads(kwargs['body']) == expected 186 | 187 | 188 | @pytest.mark.unit 189 | def test_prepare_output(context, cfn_event, cfn_coeffect): 190 | world = { 191 | 'event': cfn_event, 192 | 'valid_cfn': cfn_coeffect, 193 | } 194 | is_master_payer_account = bool(cfn_coeffect['Discovery']['IsMasterPayerAccount'] == 'True') 195 | new_world = app.prepare_output(world) 196 | assert new_world['output']['data']['discovery']['is_master_payer_account'] == is_master_payer_account 197 | -------------------------------------------------------------------------------- /services/connected_account.yaml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | AWSTemplateFormatVersion: '2010-09-09' 6 | Description: CloudZero Connected Account Template 7 | 8 | Metadata: 9 | AWS::CloudFormation::Interface: 10 | ParameterGroups: 11 | - Label: 12 | default: Required 13 | Parameters: 14 | - AccountName 15 | - Label: 16 | default: CloudZero Cross Account Access 17 | Parameters: 18 | - ExternalId 19 | ParameterLabels: 20 | AccountName: 21 | default: | 22 | Descriptive Alias for your AWS Account; Generated by you; Used to ID your accounts in CloudZero platform 23 | ExternalId: 24 | default: | 25 | Unique ExternalId for your Organization; Generated by CloudZero; Used for Cross Account Roles 26 | 27 | Parameters: 28 | AccountName: 29 | Type: String 30 | Default: '' 31 | Description: | 32 | Optional Name you can set for your AWS Account; you can use this as a 33 | descriptive alias for AccountId. It's useful to set this here when you 34 | are automating provisioning many accounts with this template. 35 | ExternalId: 36 | Type: String 37 | Description: | 38 | Unique ExternalId for Customer Organization; for cross-account Role Access and 39 | associating this template with a Customer Organization 40 | Version: 41 | Type: String 42 | Default: 'latest' 43 | Description: | 44 | Version to target when deploying the stack. `latest` should be used by default. 45 | 46 | Mappings: 47 | CallbackConfiguration: 48 | prod: 49 | # TODO: Get dynamically for namespaces 50 | ReactorAccountId: '061190967865' 51 | ReactorId: 'f7a07d02-d509-4e8d-9fb9-69c7985a6bd8' 52 | ReactorCallbackUrl: 'https://api.cloudzero.com/accounts/v1/link' 53 | 54 | Conditions: 55 | ValidReactorCallbackUrl: !Not 56 | - !Equals [!FindInMap [CallbackConfiguration, prod, ReactorCallbackUrl], 'null'] 57 | 58 | Resources: 59 | Discovery: 60 | Type: AWS::CloudFormation::Stack 61 | Properties: 62 | Tags: 63 | - Key: cloudzero-stack 64 | Value: !Ref AWS::StackName 65 | - Key: cloudzero-reactor-account-id 66 | Value: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 67 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/discovery.yaml 68 | Parameters: 69 | Version: !Sub ${Version} 70 | 71 | ResourceOwnerAccount: 72 | Type: AWS::CloudFormation::Stack 73 | Properties: 74 | Tags: 75 | - Key: cloudzero-stack 76 | Value: !Ref AWS::StackName 77 | - Key: cloudzero-reactor-account-id 78 | Value: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 79 | Parameters: 80 | IsResourceOwnerAccount: !GetAtt Discovery.Outputs.IsResourceOwnerAccount 81 | ExternalId: !Ref ExternalId 82 | ReactorAccountId: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 83 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/resource_owner.yaml 84 | 85 | CloudTrailOwnerAccount: 86 | Type: AWS::CloudFormation::Stack 87 | Properties: 88 | Tags: 89 | - Key: cloudzero-stack 90 | Value: !Ref AWS::StackName 91 | - Key: cloudzero-reactor-account-id 92 | Value: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 93 | Parameters: 94 | IsCloudTrailOwnerAccount: !GetAtt Discovery.Outputs.IsCloudTrailOwnerAccount 95 | CloudTrailSNSTopicArn: !GetAtt Discovery.Outputs.CloudTrailSNSTopicArn 96 | ReactorAccountId: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 97 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/cloudtrail_owner.yaml 98 | 99 | AuditAccount: 100 | Type: AWS::CloudFormation::Stack 101 | Properties: 102 | Tags: 103 | - Key: cloudzero-stack 104 | Value: !Ref AWS::StackName 105 | - Key: cloudzero-reactor-account-id 106 | Value: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 107 | Parameters: 108 | IsAuditAccount: !GetAtt Discovery.Outputs.IsAuditAccount 109 | AuditCloudTrailBucketName: !GetAtt Discovery.Outputs.AuditCloudTrailBucketName 110 | ExternalId: !Ref ExternalId 111 | ReactorAccountId: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 112 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/audit.yaml 113 | 114 | MasterPayerAccount: 115 | Type: AWS::CloudFormation::Stack 116 | Properties: 117 | Tags: 118 | - Key: cloudzero-stack 119 | Value: !Ref AWS::StackName 120 | - Key: cloudzero-reactor-account-id 121 | Value: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 122 | Parameters: 123 | IsOrganizationMasterAccount: !GetAtt Discovery.Outputs.IsOrganizationMasterAccount 124 | IsMasterPayerAccount: !GetAtt Discovery.Outputs.IsMasterPayerAccount 125 | MasterPayerBillingBucketName: !GetAtt Discovery.Outputs.MasterPayerBillingBucketName 126 | ExternalId: !Ref ExternalId 127 | ReactorAccountId: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 128 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/master_payer.yaml 129 | 130 | 131 | NotificationFunctionStack: 132 | Type: AWS::CloudFormation::Stack 133 | Condition: ValidReactorCallbackUrl 134 | Properties: 135 | Tags: 136 | - Key: cloudzero-stack 137 | Value: !Ref AWS::StackName 138 | - Key: cloudzero-reactor-account-id 139 | Value: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 140 | Parameters: 141 | ReactorCallbackUrl: !FindInMap [CallbackConfiguration, prod, ReactorCallbackUrl] 142 | Version: !Sub ${Version} 143 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/notification.yaml 144 | 145 | 146 | NotifyCloudZeroReactor: 147 | Type: Custom::NotifyCloudZero 148 | Condition: ValidReactorCallbackUrl 149 | Properties: 150 | Tags: 151 | - Key: cloudzero-stack 152 | Value: !Ref AWS::StackName 153 | - Key: cloudzero-reactor-account-id 154 | Value: !FindInMap [CallbackConfiguration, prod, ReactorAccountId] 155 | ServiceToken: !GetAtt NotificationFunctionStack.Outputs.Arn 156 | AccountId: !Ref AWS::AccountId 157 | Version: '1' 158 | # Parameters from Other Resources in this Stack 159 | ExternalId: !Ref ExternalId 160 | ReactorCallbackUrl: !FindInMap [CallbackConfiguration, prod, ReactorCallbackUrl] 161 | AccountName: !Ref AccountName 162 | Region: !Ref AWS::Region 163 | ReactorId: !FindInMap [CallbackConfiguration, prod, ReactorId] 164 | # References to other stacks; it's easier for _this_ Resource to simply grab the outputs 165 | # programmatically b/c some of the outputs are conditional. 166 | Stacks: 167 | Discovery: !Ref Discovery 168 | ResourceOwnerAccount: !Ref ResourceOwnerAccount 169 | CloudTrailOwnerAccount: !Ref CloudTrailOwnerAccount 170 | AuditAccount: !Ref AuditAccount 171 | MasterPayerAccount: !Ref MasterPayerAccount 172 | LegacyAccount: !Ref ResourceOwnerAccount 173 | 174 | Outputs: 175 | AuditAccount: 176 | Value: !Ref AuditAccount 177 | CloudTrailOwnerAccount: 178 | Value: !Ref CloudTrailOwnerAccount 179 | Discovery: 180 | Value: !Ref Discovery 181 | MasterPayerAccount: 182 | Value: !Ref MasterPayerAccount 183 | NotificationFunctionStack: 184 | Value: !If 185 | - ValidReactorCallbackUrl 186 | - !Ref NotificationFunctionStack 187 | - !Ref AWS::NoValue 188 | NotifyCloudZeroReactor: 189 | Value: !If 190 | - ValidReactorCallbackUrl 191 | - !Ref NotifyCloudZeroReactor 192 | - !Ref AWS::NoValue 193 | ResourceOwnerAccount: 194 | Value: !Ref ResourceOwnerAccount 195 | -------------------------------------------------------------------------------- /services/connected_account_dev.yaml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | AWSTemplateFormatVersion: '2010-09-09' 6 | Description: CloudZero Connected Account Template 7 | 8 | Metadata: 9 | AWS::CloudFormation::Interface: 10 | ParameterGroups: 11 | - Label: 12 | default: Required 13 | Parameters: 14 | - AccountName 15 | - Label: 16 | default: CloudZero Cross Account Access 17 | Parameters: 18 | - ExternalId 19 | ParameterLabels: 20 | AccountName: 21 | default: | 22 | Descriptive Alias for your AWS Account; Generated by you; Used to ID your accounts in CloudZero platform 23 | ExternalId: 24 | default: | 25 | Unique ExternalId for your Organization; Generated by CloudZero; Used for Cross Account Roles 26 | 27 | Parameters: 28 | AccountName: 29 | Type: String 30 | Default: '' 31 | Description: | 32 | Optional Name you can set for your AWS Account; you can use this as a 33 | descriptive alias for AccountId. It's useful to set this here when you 34 | are automating provisioning many accounts with this template. 35 | ExternalId: 36 | Type: String 37 | Description: | 38 | Unique ExternalId for Customer Organization; for cross-account Role Access and 39 | associating this template with a Customer Organization 40 | Version: 41 | Type: String 42 | Default: 'latest' 43 | Description: | 44 | Version to target when deploying the stack. `latest` should be used by default. 45 | 46 | Mappings: 47 | CallbackConfiguration: 48 | dev: 49 | # TODO: Get dynamically for namespaces 50 | ReactorAccountId: '998146006915' 51 | ReactorId: '67ad3e83-1ec5-4f4f-b505-c27e2758b990' 52 | ReactorCallbackUrl: 'https://qc8rbx9mcg.execute-api.us-east-1.amazonaws.com/alfa/v1/link' 53 | 54 | Conditions: 55 | ValidReactorCallbackUrl: !Not 56 | - !Equals [!FindInMap [CallbackConfiguration, dev, ReactorCallbackUrl], 'null'] 57 | 58 | Resources: 59 | Discovery: 60 | Type: AWS::CloudFormation::Stack 61 | Properties: 62 | Tags: 63 | - Key: cloudzero-stack 64 | Value: !Ref AWS::StackName 65 | - Key: cloudzero-reactor-account-id 66 | Value: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 67 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/discovery.yaml 68 | Parameters: 69 | Version: !Sub ${Version} 70 | 71 | ResourceOwnerAccount: 72 | Type: AWS::CloudFormation::Stack 73 | Properties: 74 | Tags: 75 | - Key: cloudzero-stack 76 | Value: !Ref AWS::StackName 77 | - Key: cloudzero-reactor-account-id 78 | Value: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 79 | Parameters: 80 | IsResourceOwnerAccount: !GetAtt Discovery.Outputs.IsResourceOwnerAccount 81 | ExternalId: !Ref ExternalId 82 | ReactorAccountId: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 83 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/resource_owner.yaml 84 | 85 | CloudTrailOwnerAccount: 86 | Type: AWS::CloudFormation::Stack 87 | Properties: 88 | Tags: 89 | - Key: cloudzero-stack 90 | Value: !Ref AWS::StackName 91 | - Key: cloudzero-reactor-account-id 92 | Value: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 93 | Parameters: 94 | IsCloudTrailOwnerAccount: !GetAtt Discovery.Outputs.IsCloudTrailOwnerAccount 95 | CloudTrailSNSTopicArn: !GetAtt Discovery.Outputs.CloudTrailSNSTopicArn 96 | ReactorAccountId: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 97 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/cloudtrail_owner.yaml 98 | 99 | AuditAccount: 100 | Type: AWS::CloudFormation::Stack 101 | Properties: 102 | Tags: 103 | - Key: cloudzero-stack 104 | Value: !Ref AWS::StackName 105 | - Key: cloudzero-reactor-account-id 106 | Value: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 107 | Parameters: 108 | IsAuditAccount: !GetAtt Discovery.Outputs.IsAuditAccount 109 | AuditCloudTrailBucketName: !GetAtt Discovery.Outputs.AuditCloudTrailBucketName 110 | ExternalId: !Ref ExternalId 111 | ReactorAccountId: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 112 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/audit.yaml 113 | 114 | MasterPayerAccount: 115 | Type: AWS::CloudFormation::Stack 116 | Properties: 117 | Tags: 118 | - Key: cloudzero-stack 119 | Value: !Ref AWS::StackName 120 | - Key: cloudzero-reactor-account-id 121 | Value: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 122 | Parameters: 123 | IsOrganizationMasterAccount: !GetAtt Discovery.Outputs.IsOrganizationMasterAccount 124 | IsMasterPayerAccount: !GetAtt Discovery.Outputs.IsMasterPayerAccount 125 | MasterPayerBillingBucketName: !GetAtt Discovery.Outputs.MasterPayerBillingBucketName 126 | ExternalId: !Ref ExternalId 127 | ReactorAccountId: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 128 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/account_type/master_payer.yaml 129 | 130 | 131 | NotificationFunctionStack: 132 | Type: AWS::CloudFormation::Stack 133 | Condition: ValidReactorCallbackUrl 134 | Properties: 135 | Tags: 136 | - Key: cloudzero-stack 137 | Value: !Ref AWS::StackName 138 | - Key: cloudzero-reactor-account-id 139 | Value: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 140 | Parameters: 141 | ReactorCallbackUrl: !FindInMap [CallbackConfiguration, dev, ReactorCallbackUrl] 142 | Version: !Sub ${Version} 143 | TemplateURL: !Sub https://s3.amazonaws.com/cz-provision-account/${Version}/services/notification.yaml 144 | 145 | 146 | NotifyCloudZeroReactor: 147 | Type: Custom::NotifyCloudZero 148 | Condition: ValidReactorCallbackUrl 149 | Properties: 150 | Tags: 151 | - Key: cloudzero-stack 152 | Value: !Ref AWS::StackName 153 | - Key: cloudzero-reactor-account-id 154 | Value: !FindInMap [CallbackConfiguration, dev, ReactorAccountId] 155 | ServiceToken: !GetAtt NotificationFunctionStack.Outputs.Arn 156 | AccountId: !Ref AWS::AccountId 157 | Version: '1' 158 | # Parameters from Other Resources in this Stack 159 | ExternalId: !Ref ExternalId 160 | ReactorCallbackUrl: !FindInMap [CallbackConfiguration, dev, ReactorCallbackUrl] 161 | AccountName: !Ref AccountName 162 | Region: !Ref AWS::Region 163 | ReactorId: !FindInMap [CallbackConfiguration, dev, ReactorId] 164 | # References to other stacks; it's easier for _this_ Resource to simply grab the outputs 165 | # programmatically b/c some of the outputs are conditional. 166 | Stacks: 167 | Discovery: !Ref Discovery 168 | ResourceOwnerAccount: !Ref ResourceOwnerAccount 169 | CloudTrailOwnerAccount: !Ref CloudTrailOwnerAccount 170 | AuditAccount: !Ref AuditAccount 171 | MasterPayerAccount: !Ref MasterPayerAccount 172 | LegacyAccount: !Ref ResourceOwnerAccount 173 | 174 | Outputs: 175 | AuditAccount: 176 | Value: !Ref AuditAccount 177 | CloudTrailOwnerAccount: 178 | Value: !Ref CloudTrailOwnerAccount 179 | Discovery: 180 | Value: !Ref Discovery 181 | MasterPayerAccount: 182 | Value: !Ref MasterPayerAccount 183 | NotificationFunctionStack: 184 | Value: !If 185 | - ValidReactorCallbackUrl 186 | - !Ref NotificationFunctionStack 187 | - !Ref AWS::NoValue 188 | NotifyCloudZeroReactor: 189 | Value: !If 190 | - ValidReactorCallbackUrl 191 | - !Ref NotifyCloudZeroReactor 192 | - !Ref AWS::NoValue 193 | ResourceOwnerAccount: 194 | Value: !Ref ResourceOwnerAccount 195 | -------------------------------------------------------------------------------- /azure/cz_azure_billing_permissions_setup.ps1: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information. 4 | # 5 | # This PowerShell script grants CloudZero the necessary permissions to connect 6 | # your Azure EA or MCA account to CloudZero. 7 | # 8 | # Use this script instead of "Step 4: Grant Access to the Azure Billing API" in the documentation: 9 | # - EA accounts: https://docs.cloudzero.com/docs/connections-azure-ea#step-4-grant-access-to-the-azure-billing-api 10 | # - MCA accounts: https://docs.cloudzero.com/docs/connections-azure-mca#step-4-grant-access-to-the-azure-billing-api 11 | 12 | [CmdletBinding()] 13 | param ( 14 | [Parameter(Mandatory=$true)] 15 | [string] 16 | $BillingAccountId, 17 | # For internal use only 18 | [Parameter()] 19 | [switch] 20 | $DevEnv 21 | ) 22 | 23 | $resultObject = @{} 24 | 25 | $ErrorActionPreference = 'Stop' # Stop if we get any errors 26 | 27 | ###################################################################################################### 28 | # Set up and validate important information 29 | ###################################################################################################### 30 | $currentUser = Get-AzContext 31 | 32 | Write-Debug "AzContext: $(ConvertTo-Json -InputObject $currentUser)" 33 | 34 | $currentTenantId = $currentUser.Tenant.Id 35 | 36 | $resultObject.TenantId = $currentUser.Tenant.Id 37 | 38 | $cloudZeroServicePrincipalName = if ($DevEnv) {"CloudZeroPlatformDev"} else {"CloudZeroPlatform"} 39 | $cloudZeroServicePrincipal = Get-AzADServicePrincipal -DisplayName $cloudZeroServicePrincipalName 40 | 41 | if (!$cloudZeroServicePrincipal) { 42 | throw "Cannot find the Service Principal '$($cloudZeroServicePrincipalName). You must first complete the consent process. See the CloudZero application." 43 | } 44 | 45 | Write-Debug "CloudZero SPN: $(ConvertTo-Json -InputObject $cloudZeroServicePrincipal)" 46 | $resultObject.CloudZeroSpnId = $cloudZeroServicePrincipal.Id 47 | 48 | $microsoftCustomerAgreement = "MicrosoftCustomerAgreement" 49 | $enterpriseAgreement = "EnterpriseAgreement" 50 | $validAgreementTypes = @($microsoftCustomerAgreement, $enterpriseAgreement) 51 | 52 | 53 | ###################################################################################################### 54 | # Get the billing account information and validate it 55 | # 56 | # The CloudZeroPlatform must know what type of agreement is in use as there are differences in how 57 | # cost data is presented in the different agreement types. 58 | ###################################################################################################### 59 | $billingAccountInfo = if ($BillingAccountId) { (Get-AzBillingAccount -Name $BillingAccountId) } else { (Get-AzBillingAccount) } 60 | 61 | Write-Debug "Billing Account: $(ConvertTo-Json -InputObject $billingAccountInfo)" 62 | 63 | if ($billingAccountInfo.Length -gt 1) { 64 | Write-Error $billingAccountInfo 65 | throw "There are multiple billing accounts. You must select the billing account you are going to monitor on the CloudZero platform." 66 | } 67 | 68 | if (!($billingAccountInfo.AgreementType -in $validAgreementTypes)) { 69 | Write-Error $billingAccountInfo 70 | throw "Agreement type '$($billingAccountInfo.AgreementType)' is not supported. Supported agreement types: $($validAgreementTypes -join ", "))" 71 | } 72 | 73 | $resultObject.BillingAccountName = $billingAccountInfo.DisplayName 74 | $resultObject.BillingAccountId = $billingAccountInfo.Name 75 | $resultObject.AgreementType = $billingAccountInfo.AgreementType 76 | 77 | 78 | ###################################################################################################### 79 | # Set read only permissions on the billing account for the CloudZeroPlatform Service Principal 80 | # 81 | # There are some aspects cost data that are not present in the exported data. This includes items like 82 | # taxes and fees. To get these costs, the CloudZeroPlatform Service Principal must have read access 83 | # to the Billing Account (Microsoft Customer Agreement) or the Enrollment Account (Enterprise Agreement). 84 | # This read access gives the CloudZero platform access to invoices. 85 | ###################################################################################################### 86 | 87 | $getBillingRoleAssignmentPath = "/providers/Microsoft.Billing/billingAccounts/$($billingAccountInfo.Name)/billingRoleAssignments?api-version=2019-10-01-preview" 88 | $billingReaderRoleId = if ($billingAccountInfo.AgreementType -eq $enterpriseAgreement) {"24f8edb6-1668-4659-b5e2-40bb5f3a7d7e"} else {"50000000-aaaa-bbbb-cccc-100000000002"} 89 | $billingReaderRoleDefinitionId = "/providers/Microsoft.Billing/billingAccounts/$($billingAccountInfo.Name)/billingRoleDefinitions/$($billingReaderRoleId)" 90 | 91 | $billingRoleAssignmentsResult = Invoke-AzRestMethod -Path $getBillingRoleAssignmentPath -Method GET 92 | 93 | $addBillingReaderRole = $true 94 | 95 | # First check to see if the service principal has already been assigned the reader role 96 | if ($billingRoleAssignmentsResult) { 97 | $billingRoleAssignments = ConvertFrom-Json -InputObject $billingRoleAssignmentsResult.Content 98 | Write-Debug "Checking Billing Role Assigment: $(ConvertTo-Json -InputObject $billingRoleAssignments.value)" 99 | foreach ($billingRoleAssignment in $billingRoleAssignments.value) { 100 | if ($billingRoleAssignment.properties.principalId -eq $cloudZeroServicePrincipal.Id ` 101 | -and $billingRoleAssignment.properties.principalTenantId -eq $currentTenantId ` 102 | -and $billingRoleAssignment.properties.roleDefinitionId -eq $billingReaderRoleDefinitionId) { 103 | $addBillingReaderRole = $false 104 | Write-Host "Billing reader role is already assigned to $($cloudZeroServicePrincipal.DisplayName) service principal." 105 | } 106 | } 107 | } 108 | 109 | # If we have not found the correct role assignment, then assign the role. The API to assign the role is different for EA and MCA accounts 110 | if ($addBillingReaderRole) { 111 | if ($billingAccountInfo.AgreementType -eq $enterpriseAgreement) { 112 | $billingRoleAssignmentId = (New-Guid).Guid 113 | $putBillingRoleAssignmentPath = "/providers/Microsoft.Billing/billingAccounts/$($billingAccountInfo.Name)/billingRoleAssignments/$($billingRoleAssignmentId)?api-version=2019-10-01-preview" 114 | 115 | $props = @{ 116 | properties = @{ 117 | principalId = $cloudZeroServicePrincipal.Id 118 | principalTenantId = $currentTenantId 119 | roleDefinitionId = $billingReaderRoleDefinitionId 120 | } 121 | } 122 | 123 | $result = Invoke-AzRestMethod -Path $putBillingRoleAssignmentPath -Method PUT -Payload (ConvertTo-Json -InputObject $props) 124 | 125 | if (!($result.StatusCode -lt 300)) { 126 | Write-Error $result 127 | throw "A failure occurred assigning billing reader access to CloudZero service principal." 128 | } 129 | 130 | } else { 131 | $postBillingRoleAssignmentPath = "/providers/Microsoft.Billing/billingAccounts/$($billingAccountInfo.Name)/createBillingRoleAssignment?api-version=2019-10-01-preview" 132 | 133 | $props = @{ 134 | properties = @{ 135 | principalId = $cloudZeroServicePrincipal.Id 136 | roleDefinitionId = $billingReaderRoleDefinitionId 137 | } 138 | } 139 | 140 | $result = Invoke-AzRestMethod -Path $postBillingRoleAssignmentPath -Method POST -Payload (ConvertTo-Json -InputObject $props) 141 | 142 | if (!($result.StatusCode -lt 300)) { 143 | Write-Error $result 144 | throw "A failure occurred assigning billing reader access to $($cloudZeroServicePrincipal.DisplayName) service principal." 145 | } 146 | else { 147 | Write-Host "Added read only permissions for the billing account to $($cloudZeroServicePrincipal.DisplayName) service principal." 148 | } 149 | } 150 | } 151 | 152 | $resultObject.BillingReaderRoleAdded = $true 153 | 154 | Write-Output $resultObject -------------------------------------------------------------------------------- /scripts/snowflake/unload_billing_data.sql: -------------------------------------------------------------------------------- 1 | -- Existing role with permission to create the necessary objects 2 | SET CLOUDZERO_BILLING_ACCESS_SCRIPT_ROLE = 'ACCOUNTADMIN'; 3 | 4 | -- Resources that must already exist outside of Snowflake 5 | SET CLOUDZERO_BILLING_ACCESS_STORAGE_AWS_ROLE_ARN = ''; 6 | SET CLOUDZERO_BILLING_ACCESS_S3_UNLOAD_LOCATION = '/'; 7 | 8 | -- Objects that will be used if they exist, or created if they do not 9 | SET CLOUDZERO_BILLING_ACCESS_DATABASE = 'SNOWFLAKE_BILLING_DATA_ACCESS'; 10 | SET CLOUDZERO_BILLING_ACCESS_SCHEMA = 'PUBLIC'; 11 | SET CLOUDZERO_BILLING_ACCESS_WAREHOUSE = 'UNLOAD_SNOWFLAKE_BILLING_DATA'; 12 | SET CLOUDZERO_BILLING_ACCESS_STORAGE_INTEGRATION = 'CLOUDZERO_BILLING_DATA_S3_ACCESS'; 13 | 14 | -- Objects that be (re)created 15 | SET CLOUDZERO_BILLING_ACCESS_TASK = 'CLOUDZERO_UNLOAD_BILLING_DATA_TASK'; 16 | SET CLOUDZERO_BILLING_ACCESS_ROLE = 'CLOUDZERO_UNLOAD_BILLING_DATA_ROLE'; 17 | 18 | ---------------------------------------------------------------------------------------------------------------- 19 | /* 20 | * Create Storage Integration to give Snowflake access to the S3 bucket where we will unload billing data 21 | */ 22 | USE ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_SCRIPT_ROLE); 23 | CREATE STORAGE INTEGRATION IF NOT EXISTS IDENTIFIER($CLOUDZERO_BILLING_ACCESS_STORAGE_INTEGRATION) 24 | TYPE = EXTERNAL_STAGE 25 | STORAGE_PROVIDER = S3 26 | ENABLED = TRUE 27 | STORAGE_AWS_ROLE_ARN = $CLOUDZERO_BILLING_ACCESS_STORAGE_AWS_ROLE_ARN 28 | STORAGE_ALLOWED_LOCATIONS = ('*'); 29 | 30 | DESC STORAGE INTEGRATION IDENTIFIER($CLOUDZERO_BILLING_ACCESS_STORAGE_INTEGRATION); 31 | -- NOTE: Must update the IAM Role with STORAGE_AWS_IAM_USER_ARN and STORAGE_AWS_EXTERNAL_ID 32 | -- For details see: https://docs.snowflake.com/en/user-guide/data-load-s3-config-storage-integration.html#step-5-grant-the-iam-user-permissions-to-access-bucket-objects 33 | 34 | ------------------------------------------------------------------------------------------------------------------ 35 | 36 | /* 37 | * Create Storage Integration to give Snowflake access to the S3 bucket where we will unload billing data 38 | */ 39 | USE ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_SCRIPT_ROLE); 40 | CREATE DATABASE IF NOT EXISTS IDENTIFIER($CLOUDZERO_BILLING_ACCESS_DATABASE); 41 | USE DATABASE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_DATABASE); 42 | USE SCHEMA IDENTIFIER($CLOUDZERO_BILLING_ACCESS_SCHEMA); 43 | 44 | /* 45 | * Create Warehouse, Stored Procedures, and Task to collect billing data 46 | */ 47 | CREATE WAREHOUSE IF NOT EXISTS IDENTIFIER($CLOUDZERO_BILLING_ACCESS_WAREHOUSE) WITH WAREHOUSE_SIZE = 'XSMALL' AUTO_SUSPEND = 60 AUTO_RESUME = TRUE; 48 | USE WAREHOUSE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_WAREHOUSE); 49 | 50 | CREATE OR REPLACE PROCEDURE CLOUDZERO_UNLOAD_BILLING_DATA(FOR_MONTH TIMESTAMP, LOCATION VARCHAR, STORAGE_INTEGRATION VARCHAR) 51 | RETURNS VARCHAR 52 | LANGUAGE JAVASCRIPT 53 | EXECUTE AS CALLER 54 | AS 55 | $$ 56 | var parameters = snowflake.execute({sqlText: ` 57 | SELECT DATE_TRUNC('month', CONVERT_TIMEZONE('UTC', :1)) as RANGE_START, 58 | (RANGE_START + interval '1 month') as RANGE_END, 59 | TO_CHAR(RANGE_START, 'YYYYMMDD') || '-' || TO_CHAR(RANGE_END, 'YYYYMMDD') as BILLING_PERIOD, 60 | UUID_STRING() as ID;`, 61 | binds: [FOR_MONTH.toISOString()]}); 62 | parameters.next(); 63 | 64 | var range_start = parameters.RANGE_START; 65 | var range_end = parameters.RANGE_END; 66 | var billing_period = parameters.BILLING_PERIOD; 67 | var id = parameters.ID; 68 | 69 | var unload_loc = `s3://${LOCATION}/${billing_period}/${id}`; 70 | 71 | var billing_data_views = [['METERING_HISTORY', 'START_TIME'], 72 | ['METERING_DAILY_HISTORY', 'USAGE_DATE'], 73 | ['DATABASE_STORAGE_USAGE_HISTORY', 'USAGE_DATE'], 74 | ['STAGE_STORAGE_USAGE_HISTORY', 'USAGE_DATE']]; 75 | 76 | billing_data_views.forEach(params => { 77 | var [view, date_column] = params; 78 | snowflake.execute({sqlText: ` 79 | COPY INTO ${unload_loc}/${view} 80 | FROM ( 81 | SELECT * 82 | FROM SNOWFLAKE.ACCOUNT_USAGE.${view} 83 | WHERE ${date_column} >= :1 84 | AND ${date_column} < :2 85 | ) 86 | storage_integration = ${STORAGE_INTEGRATION} 87 | header = true 88 | file_format = (type = CSV, COMPRESSION = GZIP, NULL_IF=(''), 89 | FIELD_OPTIONALLY_ENCLOSED_BY = '"', 90 | EMPTY_FIELD_AS_NULL=TRUE, TIMESTAMP_FORMAT='YYYY-MM-DDTHH24:MI:SS TZHTZM');`, 91 | binds: [range_start, range_end] 92 | }); 93 | }); 94 | 95 | var manifest = `s3://${LOCATION}/${billing_period}/manifest.json`; 96 | snowflake.execute({sqlText: ` 97 | COPY INTO ${manifest} 98 | FROM ( 99 | SELECT PARSE_JSON(' 100 | {"timestamp": "' || SYSDATE()::string || '", 101 | "version": "1", 102 | "id": "' || :1 || '", 103 | "account": "' || LOWER(CURRENT_ACCOUNT()) || '", 104 | "region": "' || LOWER(CURRENT_REGION()) || '", 105 | "billingPeriod": "' || :2 || '"}' 106 | ) 107 | ) 108 | STORAGE_INTEGRATION = ${STORAGE_INTEGRATION} 109 | OVERWRITE = TRUE 110 | SINGLE = TRUE 111 | FILE_FORMAT = (type = JSON, COMPRESSION = NONE);`, 112 | binds: [id, billing_period] 113 | }); 114 | 115 | return id; 116 | $$; 117 | 118 | CREATE OR REPLACE PROCEDURE CLOUDZERO_UNLOAD_ALL_BILLING_DATA(LOCATION VARCHAR, STORAGE_INTEGRATION VARCHAR) 119 | RETURNS BOOLEAN 120 | LANGUAGE JAVASCRIPT 121 | EXECUTE AS CALLER 122 | AS 123 | $$ 124 | var result = snowflake.execute({sqlText: ` 125 | SELECT DISTINCT DATE_TRUNC('month', CONVERT_TIMEZONE('UTC', START_TIME)) as BILLED_MONTH 126 | FROM SNOWFLAKE.ACCOUNT_USAGE.METERING_HISTORY 127 | ORDER BY BILLED_MONTH DESC;`}); 128 | 129 | while(result.next()) { 130 | snowflake.execute({sqlText: `CALL CLOUDZERO_UNLOAD_BILLING_DATA(:1, :2, :3);`, 131 | binds: [result.BILLED_MONTH, LOCATION, STORAGE_INTEGRATION]}); 132 | } 133 | 134 | return true; 135 | $$; 136 | 137 | CREATE OR REPLACE PROCEDURE _TEMP_CLOUDZERO_CREATE_BILLING_ACCESS_TASK(LOCATION VARCHAR, STORAGE_INTEGRATION VARCHAR) 138 | RETURNS BOOLEAN 139 | LANGUAGE JAVASCRIPT 140 | EXECUTE AS CALLER 141 | AS 142 | $$ 143 | snowflake.execute({sqlText: ` 144 | CREATE OR REPLACE TASK IDENTIFIER($CLOUDZERO_BILLING_ACCESS_TASK) 145 | WAREHOUSE = $CLOUDZERO_BILLING_ACCESS_WAREHOUSE 146 | SCHEDULE = '360 minute' 147 | AS 148 | CALL CLOUDZERO_UNLOAD_BILLING_DATA( 149 | CURRENT_TIMESTAMP() - interval '8 hours', 150 | '${LOCATION}', 151 | '${STORAGE_INTEGRATION}'); 152 | `}) 153 | $$; 154 | CALL _TEMP_CLOUDZERO_CREATE_BILLING_ACCESS_TASK($CLOUDZERO_BILLING_ACCESS_S3_UNLOAD_LOCATION, 155 | $CLOUDZERO_BILLING_ACCESS_STORAGE_INTEGRATION); 156 | DROP PROCEDURE _TEMP_CLOUDZERO_CREATE_BILLING_ACCESS_TASK(VARCHAR, VARCHAR); 157 | 158 | /* 159 | * Create a Role with limited permissions to access billing data 160 | */ 161 | CREATE ROLE IF NOT EXISTS IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 162 | GRANT MONITOR USAGE ON ACCOUNT TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 163 | GRANT EXECUTE TASK ON ACCOUNT TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 164 | GRANT USAGE ON INTEGRATION IDENTIFIER($CLOUDZERO_BILLING_ACCESS_STORAGE_INTEGRATION) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 165 | GRANT USAGE, MONITOR ON DATABASE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_DATABASE) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 166 | GRANT USAGE, MONITOR ON SCHEMA IDENTIFIER($CLOUDZERO_BILLING_ACCESS_SCHEMA) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 167 | GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 168 | GRANT OPERATE, USAGE ON WAREHOUSE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_WAREHOUSE) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 169 | GRANT USAGE ON PROCEDURE CLOUDZERO_UNLOAD_BILLING_DATA(TIMESTAMP_NTZ, VARCHAR, VARCHAR) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 170 | GRANT USAGE ON PROCEDURE CLOUDZERO_UNLOAD_ALL_BILLING_DATA(VARCHAR, VARCHAR) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 171 | GRANT OWNERSHIP ON TASK IDENTIFIER($CLOUDZERO_BILLING_ACCESS_TASK) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 172 | 173 | /* 174 | * Initial UNLOAD of existing data and start the task to poll periodically. 175 | */ 176 | GRANT ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE) TO ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_SCRIPT_ROLE); 177 | USE ROLE IDENTIFIER($CLOUDZERO_BILLING_ACCESS_ROLE); 178 | CALL CLOUDZERO_UNLOAD_ALL_BILLING_DATA($CLOUDZERO_BILLING_ACCESS_S3_UNLOAD_LOCATION, 179 | $CLOUDZERO_BILLING_ACCESS_STORAGE_INTEGRATION); 180 | ALTER TASK IDENTIFIER($CLOUDZERO_BILLING_ACCESS_TASK) RESUME; 181 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | # Include Makefile Constants 6 | include MakefileConstants.mk 7 | 8 | #################### 9 | # 10 | # Project Constants 11 | # 12 | #################### 13 | VANTA_TAGS = "VantaOwner=$(OWNER)" "VantaDescription=$(FEATURE_NAME)" "VantaContainsUserData=false" 14 | ALL_CFN_TEMPLATES := $(shell find services -name "*.yaml" -a ! -name "packaged*.yaml" -a ! -path "*.aws-sam*" -a ! -path "*temp*") 15 | SAM_APPS := $(shell find services -name "setup.cfg" -a ! -path "*temp*" | grep -v '.aws-sam' | xargs -Ipath dirname path | uniq) 16 | SAM_APP_TEMPLATES := $(shell find $(SAM_APPS) -maxdepth 1 -name "$(TEMPLATE_FILE)") 17 | SAM_APP_TEST_RESULTS := $(SAM_APP_TEMPLATES:$(TEMPLATE_FILE)=$(COVERAGE_XML)) 18 | SAM_APP_LINT_RESULTS := $(SAM_APP_TEMPLATES:$(TEMPLATE_FILE)=$(LINT_RESULTS)) 19 | SAM_APP_ZIPS := $(SAM_APP_TEMPLATES:$(TEMPLATE_FILE)=$(APP_ZIP)) 20 | CFN_TEMPLATES := $(filter-out $(SAM_APP_TEMPLATES), $(ALL_CFN_TEMPLATES)) 21 | IAM_POLICIES := $(shell find policies -name "*.json") 22 | 23 | 24 | #################### 25 | # 26 | # Guard Defaults 27 | # 28 | #################### 29 | regions = $(shell aws ec2 describe-regions | jq -r -e '.Regions[].RegionName') 30 | version = $(shell git rev-list --count origin/master) 31 | 32 | 33 | #################### 34 | # 35 | # Makefile Niceties 36 | # 37 | #################### 38 | # Add an implicit guard for parameter input validation; use as target dependency guard-VARIABLE_NAME, e.g. guard-AWS_ACCESS_KEY_ID 39 | .PHONY: guard-% 40 | guard-%: 41 | @if [ -z "${${*}}" ] ; then \ 42 | printf \ 43 | "$(ERROR_COLOR)ERROR:$(NO_COLOR) Variable [$(ERROR_COLOR)$*$(NO_COLOR)] not set.\n"; \ 44 | exit 1; \ 45 | fi 46 | 47 | 48 | .PHONY: help ## Prints the names and descriptions of available targets 49 | help: 50 | @grep -E '^.PHONY: [a-zA-Z_%-\]+.*? ## .*$$' $(MAKEFILE_LIST) $${source_makefile} | cut -c 18- | sort | awk 'BEGIN {FS = "[ \t]+?## "}; {printf "\033[36m%-50s\033[0m %s\n", $$1, $$2}' 51 | 52 | 53 | ################# 54 | # 55 | # Dev Targets 56 | # 57 | ################# 58 | .PHONY: init ## Install package dependencies for python 59 | init: guard-VIRTUAL_ENV $(PYTHON_DEPENDENCY_FILE) 60 | $(PYTHON_DEPENDENCY_FILE): $(REQUIREMENTS_FILES) 61 | pip install pip==24.0 62 | for f in $^ ; do \ 63 | pip install -r $${f} ; \ 64 | done 65 | touch $(PYTHON_DEPENDENCY_FILE) 66 | 67 | 68 | .PHONY: lint 69 | lint: lint-all-templates lint-sam-apps 70 | 71 | 72 | .PHONY: test 73 | test: test-sam-apps 74 | 75 | 76 | .PHONY: lint-all-templates 77 | lint-all-templates: $(ALL_CFN_TEMPLATES) 78 | cfn-lint -i W2001 -t $? 79 | 80 | 81 | .PHONY: lint-sam-apps 82 | lint-sam-apps: $(SAM_APP_LINT_RESULTS) 83 | $(SAM_APP_LINT_RESULTS): 84 | cd $(@D) && $(MAKE) lint 85 | 86 | 87 | .PHONY: test-sam-apps 88 | test-sam-apps: $(SAM_APP_TEST_RESULTS) 89 | $(SAM_APP_TEST_RESULTS): 90 | cd $(@D) && $(MAKE) test 91 | 92 | 93 | .PHONY: test-sam-apps 94 | package-sam-apps: $(SAM_APP_ZIPS) 95 | $(SAM_APP_ZIPS): 96 | cd $(@D) && $(MAKE) package 97 | 98 | 99 | .PHONY: clean-sam-apps 100 | clean-sam-apps: 101 | @cwd=`pwd` ; \ 102 | for app in $(SAM_APPS) ; do \ 103 | cd $${app} ; $(MAKE) clean ; cd $${cwd} ; \ 104 | done 105 | 106 | 107 | .PHONY: clean ## Cleans up everything that isn't source code (similar to re-cloning the repo) 108 | clean: clean-sam-apps 109 | -rm -f $(PACKAGED_TEMPLATE_FILE) 110 | -touch $(REQUIREMENTS_FILES) 111 | -rm $(PYTHON_DEPENDENCY_FILE) 112 | -rm $(CFN_LINT_OUTPUT) 113 | -pip freeze | xargs pip uninstall -y -q 114 | 115 | 116 | ################### 117 | # 118 | # Deployment 119 | # 120 | ################### 121 | 122 | cfn-deploy: guard-stack_name guard-template_file guard-bucket 123 | @aws cloudformation deploy \ 124 | --template-file $${template_file} \ 125 | --stack-name $${stack_name} \ 126 | --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \ 127 | --no-fail-on-empty-changeset \ 128 | --parameter-overrides \ 129 | BucketName=$(bucket) \ 130 | --tags "cz:feature=$(FEATURE_NAME)" "cz:team=$(TEAM_NAME)" $(VANTA_TAGS) 131 | 132 | 133 | cfn-delete: guard-stack_name 134 | @aws cloudformation delete-stack \ 135 | --stack-name $${stack_name} 136 | @printf "Deleting stack $${stack_name} " 137 | @while aws cloudformation describe-stacks --stack-name $${stack_name} 2>/dev/null | grep -q IN_PROGRESS ; do \ 138 | printf "." ; \ 139 | sleep 1 ; \ 140 | done ; \ 141 | echo 142 | @aws cloudformation list-stacks | \ 143 | jq -re --arg stackName $${stack_name} \ 144 | '.StackSummaries | map(select(.StackName == $$stackName)) | .[0] | [.StackStatus, .StackStatusReason] | join(" ")' 145 | 146 | 147 | cfn-describe: guard-stack_name 148 | @aws cloudformation describe-stacks \ 149 | --stack-name $${stack_name} 150 | 151 | 152 | cfn-protect: guard-stack_name 153 | @printf "$(INFO_COLOR)Enabling Termination Protection on $(WARN_COLOR)$(stack_name)$(NO_COLOR).\n" 154 | @aws cloudformation update-termination-protection \ 155 | --enable-termination-protection \ 156 | --stack-name $${stack_name} 157 | 158 | 159 | cfn-dryrun: guard-stack_name guard-template_file guard-bucket 160 | @aws cloudformation deploy \ 161 | --template-file $${template_file} \ 162 | --stack-name $${stack_name} \ 163 | --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \ 164 | --no-fail-on-empty-changeset \ 165 | --no-execute-changeset \ 166 | --parameter-overrides \ 167 | BucketName=$(bucket) \ 168 | --tags "cz:feature=$(FEATURE_NAME)" "cz:team=$(TEAM_NAME)" $(VANTA_TAGS) 169 | 170 | 171 | 172 | $(PACKAGED_TEMPLATE_FILE): $(TEMPLATE_FILE) 173 | account_id=`aws sts get-caller-identity | jq -r -e '.Account'` && \ 174 | aws cloudformation package \ 175 | --template-file $< \ 176 | --output-template-file $@ \ 177 | --s3-bucket cz-sam-deployment-$${account_id} 178 | 179 | 180 | .PHONY: deploy-dry-run ## Almost-deploys SAM template to AWS stack =) 181 | deploy-dry-run: $(VIRTUAL_ENV) $(PACKAGED_TEMPLATE_FILE) 182 | @$(MAKE) cfn-dryrun stack_name=cz-$(FEATURE_NAME) template_file=$(PACKAGED_TEMPLATE_FILE) 183 | 184 | 185 | .PHONY: deploy-bucket 186 | deploy-bucket: 187 | @. ./project.sh && cz_assert_profile && \ 188 | regions=`aws ec2 describe-regions | jq -r -e '.Regions[].RegionName'` && \ 189 | $(MAKE) $(PACKAGED_TEMPLATE_FILE) && \ 190 | printf "$(INFO_COLOR)Deploying regionless $(WARN_COLOR)$(BUCKET)$(NO_COLOR) to us-east-1.\n" && \ 191 | $(MAKE) cfn-deploy stack_name=cz-$(FEATURE_NAME) template_file=$(PACKAGED_TEMPLATE_FILE) bucket=$(BUCKET) && \ 192 | $(MAKE) cfn-protect stack_name=cz-$(FEATURE_NAME) && \ 193 | for r in $${regions} ; do\ 194 | printf "$(INFO_COLOR)Deploying to $(WARN_COLOR)$${r}$(NO_COLOR).\n" && \ 195 | export AWS_DEFAULT_REGION="$${r}" && \ 196 | $(MAKE) cfn-deploy stack_name="cz-$(FEATURE_NAME)-$${r}" template_file=$(PACKAGED_TEMPLATE_FILE) bucket="$(BUCKET)-$${r}" ; \ 197 | $(MAKE) cfn-protect stack_name="cz-$(FEATURE_NAME)-$${r}" ; \ 198 | done 199 | 200 | 201 | copy-to-s3: guard-path 202 | @for key in $(CFN_TEMPLATES) $(IAM_POLICIES) ; do \ 203 | aws s3 cp $${key} s3://$(BUCKET)/$(path)/$${key} ; \ 204 | done && \ 205 | for app in $(SAM_APPS) ; do \ 206 | aws s3 cp $${app}/$(TEMPLATE_FILE) s3://$(BUCKET)/$(path)/$${app}.yaml && \ 207 | aws s3 cp $${app}/$(APP_ZIP) s3://$(BUCKET)/$(path)/$${app}.zip && \ 208 | for r in $(regions) ; do \ 209 | aws s3 cp $${app}/$(APP_ZIP) s3://$(BUCKET)-$${r}/$(path)/$${app}.zip ; \ 210 | done ; \ 211 | done 212 | 213 | 214 | .PHONY: deploy ## Deploys Artifacts to S3 Bucket 215 | deploy: 216 | find . -name $(APP_ZIP) -exec rm -rf {} \; && \ 217 | $(MAKE) package-sam-apps && \ 218 | $(MAKE) copy-to-s3 path=v$(SEMVER_MAJ_MIN).$(version) && \ 219 | [ $(version) != 'dev' ] && $(MAKE) copy-to-s3 path=latest || true 220 | 221 | .PHONY: update-dev 222 | update-dev: 223 | @aws cloudformation update-stack \ 224 | --stack-name cloudzero-connected-account-alfa \ 225 | --template-url https://cz-provision-account.s3.amazonaws.com/v$(SEMVER_MAJ_MIN).dev/services/connected_account_dev.yaml \ 226 | --parameters "$$(aws cloudformation describe-stacks --stack-name cloudzero-connected-account-alfa | jq '.Stacks[0].Parameters' | jq 'del(.[] | select (.ParameterKey == "Version"))' | jq '. += [{"ParameterKey": "Version", "ParameterValue": "v$(SEMVER_MAJ_MIN).dev"}]')" 227 | 228 | .PHONY: describe ## Return information about SAM-created stack from AWS 229 | describe: $(VIRTUAL_ENV) 230 | @$(MAKE) cfn-describe stack_name=cz-$(FEATURE_NAME) 231 | 232 | 233 | .PHONY: delete ## Removes SAM-created stack from AWS 234 | delete: $(VIRTUAL_ENV) 235 | @$(MAKE) cfn-delete stack_name=cz-$(FEATURE_NAME) 236 | -------------------------------------------------------------------------------- /docs/RELEASE_PROCESS.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | This guide outlines the steps and best practices for managing releases in the repository. Following this process ensures consistency, quality, and clear communication with all stakeholders, including necessary external approvals. 4 | 5 | ## Overview 6 | 7 | 1. **Create Release Document** 8 | 2. **Submit Pull Request (PR)** 9 | 3. **Review and Merge** 10 | 4. **Trigger Manual Release Workflow** 11 | 5. **Obtain External Approvals** 12 | 6. **Publish Release** 13 | 14 | --- 15 | 16 | ## Step-by-Step Process 17 | 18 | ### 1. Create a New Release Document 19 | 20 | - **Location:** [docs/releases](.) 21 | - **Filename Format:** `X.X.X.md` (e.g., `1.2.3.md`) 22 | - **Template:** Use the provided [Release Notes Template](#release-notes-template) below. 23 | 24 | **Instructions:** 25 | 26 | - Duplicate the release notes template. 27 | - Replace placeholders with the appropriate version number and details. 28 | - Save the file with the correct naming convention in the `docs/releases` folder. 29 | 30 | ### 2. Open a Pull Request (PR) 31 | 32 | - **Target Branch:** `develop` 33 | - **PR Title:** `Release X.X.X - [Brief Description]` 34 | 35 | **Automations:** 36 | 37 | - Opening a PR will automatically notify the stakeholder teams for review. 38 | 39 | ### 3. Review and Merge the PR 40 | 41 | - **Stakeholders:** Not Found 42 | - **Approval:** Obtain necessary approvals from the reviewers. 43 | - **Merge:** Once approved, merge the release notes PR into the `develop` branch. 44 | 45 | ### 4. Trigger the Manual Release Workflow 46 | 47 | - **Workflow Link:** [Release](https://github.com/provision-account/actions/workflows/release.yml) 48 | 49 | **Purpose:** 50 | 51 | - Ensures stakeholders review the functionality and public-facing documentation before publishing. 52 | - **External Approvals Required:** Designated stakeholders must manually approve the release. 53 | 54 | **Steps:** 55 | 56 | 1. Navigate to the **Actions** tab in your repository. 57 | 2. Select the **Manual Release** workflow. 58 | 3. Trigger the workflow and follow any on-screen instructions. 59 | 4. **Await Stakeholder Approval:** Stakeholders will receive a notification to review and approve the release. 60 | 61 | ### 5. (Optional) Obtain External Approvals 62 | 63 | - **Stakeholders Involved:** PM Team, Documentation Team, and any other designated external parties. 64 | - **Approval Process:** 65 | - Stakeholders review the functionality, documentation, and overall release readiness. 66 | - Upon satisfaction, stakeholders provide manual approval through the workflow interface. 67 | 68 | ### 6. Publish the Release 69 | 70 | - Once the manual release workflow is approved and completed, the release will be published. 71 | - Stakeholders will be notified upon successful publication. 72 | 73 | --- 74 | 75 | ## Creating Release Notes 76 | 77 | Effective release notes provide clear and concise information about the changes in each release. Follow these guidelines to create comprehensive release notes. 78 | 79 | ### Pro-Tip 80 | 81 | - **Review Changes:** Examine the GitHub diff between the previous and current release to identify all changes. 82 | - **Commit Messages:** Use commit titles to outline the changes and categorize them appropriately. Include a link to the relevant commit where possible. 83 | 84 | ### Release Notes Structure 85 | 86 | Use the following sections to organize your release notes: 87 | 88 | 1. **Upgrade Steps** 89 | 2. **Breaking Changes** 90 | 3. **New Features** 91 | 4. **Bug Fixes** 92 | 5. **Improvements** 93 | 6. **Other Changes** 94 | 95 | #### Upgrade Steps 96 | 97 | - **Purpose:** Detail any actions users must take to upgrade beyond updating dependencies. 98 | - **Content:** 99 | - Step-by-step instructions for the upgrade. 100 | - Pseudocode or code snippets highlighting necessary changes. 101 | - Recommendations to upgrade due to known issues in older versions. 102 | - **Note:** Ideally, no upgrade steps are required. 103 | 104 | #### Breaking Changes 105 | 106 | - **Purpose:** List all breaking changes that may affect users. 107 | - **Content:** 108 | - Comprehensive list of changes that are not backward compatible. 109 | - Typically included in major version releases. 110 | - **Note:** Aim to minimize breaking changes. 111 | 112 | #### New Features 113 | 114 | - **Purpose:** Describe new functionalities introduced in the release. 115 | - **Content:** 116 | - Detailed descriptions of each new feature. 117 | - Usage scenarios and benefits. 118 | - Include screenshots or diagrams where applicable. 119 | - Mention any caveats, warnings, or if the feature is in beta. 120 | 121 | #### Bug Fixes 122 | 123 | - **Purpose:** Highlight fixes for existing issues. 124 | - **Content:** 125 | - Description of the issues that have been resolved. 126 | - Reference to related features or functionalities. 127 | 128 | #### Improvements 129 | 130 | - **Purpose:** Outline enhancements made to existing features or workflows. 131 | - **Content:** 132 | - Performance optimizations. 133 | - Improved logging or error messaging. 134 | - Enhancements to user experience. 135 | 136 | #### Other Changes 137 | 138 | - **Purpose:** Capture miscellaneous changes that do not fit into the above categories. 139 | - **Content:** 140 | - Minor updates or maintenance tasks. 141 | - Documentation updates. 142 | - **Note:** Aim to keep this section empty by categorizing changes appropriately. 143 | 144 | --- 145 | 146 | ### Release Notes Template 147 | 148 | Copy and paste the following template to create your release notes. Replace placeholders with relevant information. 149 | 150 | ```markdown 151 | ## [X.X.X](https://github.com/provision-account/compare/Y.Y.Y...X.X.X) (YYYY-MM-DD) 152 | 153 | > Brief description of the release. 154 | 155 | ### Upgrade Steps 156 | * [ACTION REQUIRED] 157 | * Detailed upgrade instructions or steps. 158 | 159 | ### Breaking Changes 160 | * Description of breaking change 1. 161 | * Description of breaking change 2. 162 | 163 | ### New Features 164 | * **Feature Name:** Detailed description, usage scenarios, and any relevant notes or images. 165 | * **Feature Name:** Detailed description, usage scenarios, and any relevant notes or images. 166 | 167 | ### Bug Fixes 168 | * **Bug Fix Description:** Explanation of the issue and how it was resolved. 169 | * **Bug Fix Description:** Explanation of the issue and how it was resolved. 170 | 171 | ### Improvements 172 | * **Improvement Description:** Details about the enhancement. 173 | * **Improvement Description:** Details about the enhancement. 174 | 175 | ### Other Changes 176 | * **Change Description:** Brief explanation of the change. 177 | * **Change Description:** Brief explanation of the change. 178 | ``` 179 | 180 | **Example:** 181 | 182 | Examples can be found in the [docs/releases](https://github.com/provision-account/tree/main/docs/releases) folder. 183 | 184 | --- 185 | 186 | ## Best Practices 187 | 188 | - **Consistency:** Maintain a consistent format and structure across all release notes. 189 | - **Clarity:** Use clear and concise language to describe changes. 190 | - **Categorization:** Properly categorize changes to make it easier for users to find relevant information. 191 | - **Visual Aids:** Include screenshots or diagrams for new features to enhance understanding. 192 | - **Review:** Ensure all sections are thoroughly reviewed by relevant teams before publishing. 193 | - **Highlight External Approvals:** Clearly indicate when external approvals are required and obtained. 194 | 195 | --- 196 | 197 | ## Roles and Responsibilities 198 | 199 | - **Developer:** Drafts the release notes using the provided template. 200 | - **Product Management (PM) Team:** Reviews and approves the release notes for accuracy and completeness. 201 | - **Documentation Team:** Ensures that the release notes are well-documented and user-friendly. 202 | - **Stakeholders:** Review and provide external approval for the release through the Manual Release workflow. 203 | - **Release Manager:** Oversees the release process, ensuring all steps, including external approvals, are completed. 204 | 205 | --- 206 | 207 | ## External Approvals 208 | 209 | External approvals are a critical part of the release process to ensure that all stakeholders are aligned and that the release meets quality and functionality standards. 210 | 211 | ### Approval Workflow 212 | 213 | 1. **Initiate Approval:** 214 | - After triggering the Manual Release workflow, stakeholders receive a notification to review the release. 215 | 216 | 2. **Review Process:** 217 | - Stakeholders evaluate the functionality, documentation, and overall readiness of the release. 218 | - Any feedback or required changes are communicated back to the release manager or developer. 219 | 220 | 3. **Provide Approval:** 221 | - Once satisfied, stakeholders provide manual approval within the workflow interface. 222 | - The release process proceeds to publication upon receiving all necessary approvals. 223 | 224 | 4. **Handling Rejections:** 225 | - If a stakeholder rejects the release, the release manager must address the feedback and possibly iterate on the release notes or code before resubmitting for approval. 226 | 227 | **Note:** The release cannot be published until all required external approvals are obtained. 228 | 229 | --- 230 | 231 | ## FAQs 232 | 233 | **Q: What if there are no changes for a minor release?** 234 | A: Even if there are no significant changes, create a release document indicating that it is a maintenance or patch release. 235 | 236 | **Q: How often should releases be made?** 237 | A: Follow the project's release cadence, whether it's weekly, bi-weekly, monthly, etc., to ensure regular updates. 238 | 239 | **Q: Who approves the final release?** 240 | A: Designated stakeholders must manually approve the release through the Manual Release workflow. 241 | 242 | **Q: What happens if external approvals are not obtained in a timely manner?** 243 | A: Communicate with stakeholders to address any blockers and ensure that the release schedule accommodates the approval process. 244 | -------------------------------------------------------------------------------- /services/discovery/src/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | from pprint import pformat 6 | import logging 7 | 8 | import boto3 9 | from botocore.exceptions import ClientError 10 | from toolz.curried import assoc_in, get_in, keyfilter, merge, pipe, update_in 11 | from voluptuous import Any, ExactSequence, Schema, ALLOW_EXTRA, REMOVE_EXTRA 12 | 13 | from src import cfnresponse 14 | 15 | logger = logging.getLogger() 16 | logger.setLevel(logging.INFO) 17 | ct = boto3.client('cloudtrail') 18 | cur = boto3.client('cur', region_name='us-east-1') # cur is only in us-east-1 19 | orgs = boto3.client('organizations') 20 | s3 = boto3.client('s3') 21 | 22 | DEFAULT_OUTPUT = { 23 | 'AuditCloudTrailBucketPrefix': None, 24 | 'AuditCloudTrailBucketName': None, 25 | 'RemoteCloudTrailBucket': True, 26 | 'CloudTrailSNSTopicArn': None, 27 | 'CloudTrailTrailArn': None, 28 | 'IsOrganizationTrail': None, 29 | 'IsOrganizationMasterAccount': False, 30 | 'VisibleCloudTrailArns': None, 31 | 'IsAuditAccount': False, 32 | 'IsCloudTrailOwnerAccount': False, 33 | 'IsResourceOwnerAccount': False, 34 | 'IsMasterPayerAccount': False, 35 | 'MasterPayerBillingBucketName': None, 36 | 'MasterPayerBillingBucketPath': None, 37 | 'IsAccountOutsideOrganization': False, 38 | } 39 | 40 | 41 | ##################### 42 | # 43 | # Boundary Validation 44 | # 45 | ##################### 46 | INPUT_SCHEMA = Schema({ 47 | 'event': { 48 | 'RequestType': Any('Create', 'Update', 'Delete'), 49 | 'ResourceProperties': { 50 | 'AccountId': str 51 | }, 52 | 'ResponseURL': str, 53 | 'StackId': str 54 | } 55 | }, required=True, extra=REMOVE_EXTRA) 56 | 57 | OUTPUT_SCHEMA = Schema({ 58 | 'output': { 59 | 'IsAuditAccount': bool, 60 | 'AuditCloudTrailBucketName': Any(None, str), 61 | 'IsResourceOwnerAccount': bool, 62 | 'IsCloudTrailOwnerAccount': bool, 63 | 'CloudTrailSNSTopicArn': Any(None, str), 64 | 'IsMasterPayerAccount': bool, 65 | 'MasterPayerBillingBucketName': Any(None, str), 66 | }, 67 | }, required=True, extra=ALLOW_EXTRA) 68 | 69 | DEFAULT_PAYER_REPORTS = {'is_master_payer': False, 'report_definitions': []} 70 | NOT_IN_ORGANIZATION_RESPONSE = {} 71 | 72 | event_account_id = get_in(['event', 'ResourceProperties', 'AccountId']) 73 | coeffects_traillist = get_in(['coeffects', 'cloudtrail', 'trailList'], default=[]) 74 | coeffects_buckets = get_in(['coeffects', 's3', 'Buckets'], default=[]) 75 | coeffects_payer_reports = get_in(['coeffects', 'cur'], default=DEFAULT_PAYER_REPORTS) 76 | coeffects_master_account_id = get_in(['coeffects', 'organizations', 'Organization', 'MasterAccountId']) 77 | output_is_organization_master = get_in(['output', 'IsOrganizationMasterAccount']) 78 | output_is_account_outside_organization = get_in(['output', 'IsAccountOutsideOrganization']) 79 | 80 | 81 | ##################### 82 | # 83 | # Coeffects, i.e. from the outside world 84 | # 85 | ##################### 86 | def coeffects(world): 87 | return pipe(world, 88 | coeffects_cloudtrail, 89 | coeffects_s3, 90 | coeffects_cur, 91 | coeffects_organizations) 92 | 93 | 94 | def coeffect(name): 95 | def d(f): 96 | def w(world): 97 | data = {} 98 | try: 99 | data = f(world) 100 | except Exception: 101 | logger.warning(f'Failed to get {name} information.', exc_info=True) 102 | return assoc_in(world, ['coeffects', name], data) 103 | return w 104 | return d 105 | 106 | 107 | @coeffect('cloudtrail') 108 | def coeffects_cloudtrail(world): 109 | response = ct.describe_trails() 110 | return keyfilter(lambda x: x in {'trailList'}, response) 111 | 112 | 113 | @coeffect('s3') 114 | def coeffects_s3(world): 115 | response = s3.list_buckets() 116 | return keyfilter(lambda x: x in {'Buckets'}, response) 117 | 118 | 119 | @coeffect('cur') 120 | def coeffects_cur(world): 121 | try: 122 | return { 123 | 'report_definitions': cur.describe_report_definitions().get('ReportDefinitions', []), 124 | } 125 | except ClientError: 126 | logger.warning('Failed to access CUR DescribeReportDefinitions', exc_info=True) 127 | return DEFAULT_PAYER_REPORTS 128 | 129 | 130 | @coeffect('organizations') 131 | def coeffects_organizations(world): 132 | try: 133 | response = orgs.describe_organization() 134 | return keyfilter(lambda x: x in {'Organization'}, response) 135 | except ClientError: 136 | return NOT_IN_ORGANIZATION_RESPONSE 137 | 138 | 139 | ##################### 140 | # 141 | # Business Logic 142 | # 143 | ##################### 144 | MINIMUM_CLOUDTRAIL_CONFIGURATION = Schema({ 145 | "S3BucketName": str, 146 | "SnsTopicName": str, 147 | "SnsTopicARN": str, 148 | "IsMultiRegionTrail": True, 149 | "TrailARN": str, 150 | }, extra=ALLOW_EXTRA, required=True) 151 | 152 | 153 | IDEAL_CLOUDTRAIL_CONFIGURATION = MINIMUM_CLOUDTRAIL_CONFIGURATION.extend({ 154 | "IsOrganizationTrail": True, 155 | }, extra=ALLOW_EXTRA, required=True) 156 | 157 | 158 | def safe_check(schema, data): 159 | try: 160 | return schema(data) 161 | except Exception: 162 | logger.debug(f'Data {pformat(data)} did not match schema {schema}', exc_info=True) 163 | return None 164 | 165 | 166 | def keep_valid(schema, xs): 167 | return [ 168 | y for y in [safe_check(schema, x) for x in xs] 169 | if y is not None 170 | ] 171 | 172 | 173 | def get_first_valid_trail(world): 174 | trails = coeffects_traillist(world) 175 | logger.info(f'Found these CloudTrails: {trails}') 176 | valid_trails = keep_valid(IDEAL_CLOUDTRAIL_CONFIGURATION, trails) or keep_valid(MINIMUM_CLOUDTRAIL_CONFIGURATION, trails) 177 | logger.info(f'Found these _valid_ CloudTrails: {valid_trails}') 178 | return valid_trails[0] if valid_trails else {} 179 | 180 | 181 | def discover_audit_account(world): 182 | trail = get_first_valid_trail(world) 183 | trail_bucket = trail.get('S3BucketName') 184 | local_buckets = {x['Name'] for x in coeffects_buckets(world)} 185 | output = { 186 | 'IsAuditAccount': trail_bucket in local_buckets, 187 | 'RemoteCloudTrailBucket': trail_bucket not in local_buckets, 188 | 'AuditCloudTrailBucketName': trail_bucket, 189 | 'AuditCloudTrailBucketPrefix': trail.get('S3KeyPrefix'), 190 | } 191 | return update_in(world, ['output'], lambda x: merge(x or {}, output)) 192 | 193 | 194 | def discover_connected_account(world): 195 | output = { 196 | 'IsResourceOwnerAccount': True, 197 | } 198 | return update_in(world, ['output'], lambda x: merge(x or {}, output)) 199 | 200 | 201 | def get_visible_cloudtrail_arns(world): 202 | visible_trail_arns = [x.get('TrailARN') 203 | for x in coeffects_traillist(world)] 204 | return ','.join(visible_trail_arns) if visible_trail_arns else None 205 | 206 | 207 | def discover_cloudtrail_account(world): 208 | visible_trails = get_visible_cloudtrail_arns(world) 209 | trail = get_first_valid_trail(world) 210 | trail_topic = trail.get('SnsTopicARN') 211 | account_id = trail_topic.split(':')[4] if trail_topic else None 212 | output = { 213 | 'IsCloudTrailOwnerAccount': account_id == event_account_id(world), 214 | 'IsOrganizationTrail': trail.get('IsOrganizationTrail'), 215 | 'CloudTrailSNSTopicArn': trail_topic, 216 | 'CloudTrailTrailArn': trail.get('TrailARN'), 217 | 'VisibleCloudTrailArns': visible_trails, 218 | } 219 | return update_in(world, ['output'], lambda x: merge(x or {}, output)) 220 | 221 | 222 | IDEAL_BILLING_REPORT = Schema({ 223 | 'TimeUnit': 'HOURLY', 224 | 'Format': 'textORcsv', 225 | 'Compression': 'GZIP', 226 | 'AdditionalSchemaElements': ExactSequence(['RESOURCES']), 227 | 'S3Bucket': str, 228 | 'S3Prefix': str, 229 | 'S3Region': str, 230 | 'ReportVersioning': 'CREATE_NEW_REPORT', 231 | 'RefreshClosedReports': True, 232 | }, extra=ALLOW_EXTRA, required=True) 233 | 234 | MINIMUM_BILLING_REPORT = Schema({ 235 | 'TimeUnit': 'HOURLY', 236 | 'Format': 'textORcsv', 237 | 'Compression': 'GZIP', 238 | 'S3Bucket': str, 239 | 'S3Prefix': str, 240 | 'S3Region': str, 241 | 'RefreshClosedReports': bool, 242 | }, extra=ALLOW_EXTRA, required=True) 243 | 244 | 245 | def get_first_valid_report_definition(valid_report_definitions, default=None): 246 | return valid_report_definitions[0] if any(valid_report_definitions) else default 247 | 248 | 249 | def get_cur_bucket_if_local(world, report_definitions): 250 | logger.info(f'Found these ReportDefinitions: {report_definitions}') 251 | local_buckets = {x['Name'] for x in coeffects_buckets(world)} 252 | 253 | # Try to find ideal reports first (with CREATE_NEW_REPORT) 254 | ideal_report_definitions = keep_valid(IDEAL_BILLING_REPORT, report_definitions) 255 | logger.info(f'Found these _ideal_ ReportDefinitions: {ideal_report_definitions}') 256 | ideal_local_report_definitions = [x for x in ideal_report_definitions if x['S3Bucket'] in local_buckets] 257 | logger.info(f'Found these _ideal local_ ReportDefinitions: {ideal_local_report_definitions}') 258 | 259 | # Fall back to minimum requirements if no ideal reports found 260 | if not ideal_local_report_definitions: 261 | logger.info('No ideal reports found, falling back to minimum requirements') 262 | valid_report_definitions = keep_valid(MINIMUM_BILLING_REPORT, report_definitions) 263 | logger.info(f'Found these _valid_ ReportDefinitions: {valid_report_definitions}') 264 | valid_local_report_definitions = [x for x in valid_report_definitions if x['S3Bucket'] in local_buckets] 265 | logger.info(f'Found these _valid local_ ReportDefinitions: {valid_local_report_definitions}') 266 | first_valid_local = get_first_valid_report_definition(valid_local_report_definitions, default={}) 267 | else: 268 | first_valid_local = get_first_valid_report_definition(ideal_local_report_definitions, default={}) 269 | 270 | bucket_name = first_valid_local.get('S3Bucket') 271 | bucket_path = f"{first_valid_local.get('S3Prefix', '')}/{first_valid_local.get('ReportName', '')}" if bucket_name else None 272 | return (bucket_name, bucket_path) 273 | 274 | 275 | def discover_master_payer_account(world): 276 | is_account_not_in_organization = output_is_account_outside_organization(world) 277 | is_account_organization_master_account = output_is_organization_master(world) 278 | is_master_payer = is_account_not_in_organization or is_account_organization_master_account 279 | report_definitions = coeffects_payer_reports(world)['report_definitions'] 280 | (bucket_name, bucket_path) = get_cur_bucket_if_local(world, report_definitions) 281 | output = { 282 | 'IsMasterPayerAccount': is_master_payer, 283 | 'MasterPayerBillingBucketName': bucket_name, 284 | 'MasterPayerBillingBucketPath': bucket_path, 285 | } 286 | return update_in(world, ['output'], lambda x: merge(x or {}, output)) 287 | 288 | 289 | def discover_organization_master_account(world): 290 | account_id = event_account_id(world) 291 | master_account_id = coeffects_master_account_id(world) 292 | 293 | output = { 294 | 'IsOrganizationMasterAccount': account_id == master_account_id, 295 | 'IsAccountOutsideOrganization': master_account_id is None, 296 | } 297 | return update_in(world, ['output'], lambda x: merge(x or {}, output)) 298 | 299 | 300 | def discover_account_types(world): 301 | return pipe(world, 302 | discover_audit_account, 303 | discover_connected_account, 304 | discover_cloudtrail_account, 305 | discover_organization_master_account, 306 | discover_master_payer_account) 307 | 308 | 309 | ##################### 310 | # 311 | # Handler 312 | # 313 | ##################### 314 | def handler(event, context, **kwargs): 315 | status = cfnresponse.SUCCESS 316 | world = {} 317 | try: 318 | logger.info(f'Processing event {event}') 319 | world = pipe({'event': event, 'kwargs': kwargs}, 320 | INPUT_SCHEMA, 321 | coeffects, 322 | discover_account_types, 323 | OUTPUT_SCHEMA) 324 | except Exception as err: 325 | logger.exception(err) 326 | finally: 327 | output = world.get('output', DEFAULT_OUTPUT) 328 | logger.info(f'Sending output {output}') 329 | cfnresponse.send(event, context, status, output, event.get('PhysicalResourceId')) 330 | -------------------------------------------------------------------------------- /services/notification/src/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | import logging 6 | import json 7 | 8 | import boto3 9 | import urllib3 10 | from toolz.curried import assoc_in, get_in, keyfilter, merge, pipe, update_in 11 | from voluptuous import Any, Invalid, Match, Schema, ALLOW_EXTRA, REMOVE_EXTRA 12 | 13 | from src import cfnresponse 14 | 15 | cfn = boto3.resource('cloudformation') 16 | http = urllib3.PoolManager() 17 | logger = logging.getLogger() 18 | logger.setLevel(logging.INFO) 19 | 20 | 21 | DEFAULT_CFN_COEFFECT = { 22 | 'AuditAccount': { 23 | 'RoleArn': 'null', 24 | }, 25 | 'CloudTrailOwnerAccount': { 26 | 'SQSQueueArn': 'null', 27 | 'SQSQueuePolicyName': 'null' 28 | }, 29 | 'Discovery': { 30 | 'AuditCloudTrailBucketName': 'null', 31 | 'AuditCloudTrailBucketPrefix': 'null', 32 | 'CloudTrailSNSTopicArn': 'null', 33 | 'CloudTrailTrailArn': 'null', 34 | 'VisibleCloudTrailArns': 'null', 35 | 'IsAuditAccount': 'false', 36 | 'IsCloudTrailOwnerAccount': 'false', 37 | 'IsMasterPayerAccount': 'false', 38 | 'IsOrganizationMasterAccount': 'false', 39 | 'IsOrganizationTrail': 'null', 40 | 'IsResourceOwnerAccount': 'false', 41 | 'MasterPayerBillingBucketName': 'null', 42 | 'MasterPayerBillingBucketPath': 'null', 43 | 'RemoteCloudTrailBucket': 'true', 44 | }, 45 | 'MasterPayerAccount': { 46 | 'RoleArn': 'null', 47 | 'ReportS3Bucket': 'null', 48 | 'ReportS3Prefix': 'null', 49 | }, 50 | 'ResourceOwnerAccount': { 51 | 'RoleArn': 'null' 52 | }, 53 | 'LegacyAccount': { 54 | 'RoleArn': 'null' 55 | } 56 | } 57 | 58 | 59 | ##################### 60 | # 61 | # Boundary Validation 62 | # 63 | ##################### 64 | INPUT_SCHEMA = Schema({ 65 | 'event': { 66 | 'RequestType': Any('Create', 'Delete', 'Update'), 67 | 'ResourceProperties': { 68 | 'ExternalId': str, 69 | 'ReactorCallbackUrl': str, 70 | 'AccountName': str, 71 | 'ReactorId': str, 72 | 'AccountId': str, 73 | 'Region': str, 74 | 'Stacks': { 75 | 'Discovery': str, 76 | 'ResourceOwnerAccount': str, 77 | 'CloudTrailOwnerAccount': str, 78 | 'AuditAccount': str, 79 | 'MasterPayerAccount': str, 80 | 'LegacyAccount': str, 81 | } 82 | }, 83 | 'ResponseURL': str, 84 | 'StackId': str 85 | } 86 | }, required=True, extra=REMOVE_EXTRA) 87 | 88 | BOOLEAN_STRING = Schema(Any('null', 'true', 'false')) 89 | ARN = Schema(Match(r'^arn:(?:aws|aws-cn|aws-us-gov):([a-z0-9-]+):' 90 | r'((?:[a-z0-9-]*)|global):(\d{12}|aws)*:(.+$)$')) 91 | NULLABLE_ARN = Schema(Any('null', ARN)) 92 | NULLABLE_STRING = Schema(Any('null', str)) 93 | 94 | CFN_COEFFECT_SCHEMA = Schema({ 95 | 'AuditAccount': { 96 | 'RoleArn': NULLABLE_ARN 97 | }, 98 | 'CloudTrailOwnerAccount': { 99 | 'SQSQueueArn': NULLABLE_ARN, 100 | 'SQSQueuePolicyName': NULLABLE_STRING, 101 | }, 102 | 'Discovery': { 103 | 'AuditCloudTrailBucketName': NULLABLE_STRING, 104 | 'AuditCloudTrailBucketPrefix': NULLABLE_STRING, 105 | 'CloudTrailSNSTopicArn': NULLABLE_ARN, 106 | 'CloudTrailTrailArn': NULLABLE_ARN, 107 | 'VisibleCloudTrailArns': NULLABLE_STRING, 108 | 'IsAuditAccount': BOOLEAN_STRING, 109 | 'IsCloudTrailOwnerAccount': BOOLEAN_STRING, 110 | 'IsMasterPayerAccount': BOOLEAN_STRING, 111 | 'IsOrganizationMasterAccount': BOOLEAN_STRING, 112 | 'IsOrganizationTrail': BOOLEAN_STRING, 113 | 'IsResourceOwnerAccount': BOOLEAN_STRING, 114 | 'MasterPayerBillingBucketName': NULLABLE_STRING, 115 | 'MasterPayerBillingBucketPath': NULLABLE_STRING, 116 | 'RemoteCloudTrailBucket': BOOLEAN_STRING, 117 | }, 118 | 'MasterPayerAccount': { 119 | 'RoleArn': NULLABLE_ARN, 120 | 'ReportS3Bucket': NULLABLE_STRING, 121 | 'ReportS3Prefix': NULLABLE_STRING, 122 | }, 123 | 'ResourceOwnerAccount': { 124 | 'RoleArn': NULLABLE_ARN, 125 | }, 126 | 'LegacyAccount': { 127 | 'RoleArn': NULLABLE_ARN, 128 | } 129 | }, required=True, extra=ALLOW_EXTRA) 130 | 131 | 132 | NONEABLE_ARN = Schema(Any(None, ARN)) 133 | NONEABLE_BOOL = Schema(Any(None, bool)) 134 | NONEABLE_STRING = Schema(Any(None, str)) 135 | LINK_ROLE = Schema({'role_arn': NONEABLE_ARN}) 136 | ACCOUNT_LINK_PROVISIONED = Schema({ 137 | 'data': { 138 | 'metadata': { 139 | 'cloud_region': str, 140 | 'external_id': str, 141 | 'cloud_account_id': str, 142 | 'cz_account_name': str, 143 | 'reactor_id': str, 144 | 'reactor_callback_url': str, 145 | }, 146 | 'links': { 147 | 'audit': LINK_ROLE, 148 | 'cloudtrail_owner': { 149 | 'sqs_queue_arn': NONEABLE_ARN, 150 | 'sqs_queue_policy_name': NONEABLE_STRING, 151 | }, 152 | 'master_payer': LINK_ROLE, 153 | 'resource_owner': LINK_ROLE, 154 | 'legacy': LINK_ROLE, 155 | }, 156 | 'discovery': { 157 | 'audit_cloudtrail_bucket_name': NONEABLE_STRING, 158 | 'audit_cloudtrail_bucket_prefix': NONEABLE_STRING, 159 | 'cloudtrail_sns_topic_arn': NONEABLE_ARN, 160 | 'cloudtrail_trail_arn': NONEABLE_ARN, 161 | 'is_audit_account': bool, 162 | 'is_cloudtrail_owner_account': bool, 163 | 'is_organization_trail': NONEABLE_BOOL, 164 | 'is_organization_master_account': bool, 165 | 'is_master_payer_account': bool, 166 | 'is_resource_owner_account': bool, 167 | 'master_payer_billing_bucket_name': NONEABLE_STRING, 168 | 'master_payer_billing_bucket_path': NONEABLE_STRING, 169 | 'remote_cloudtrail_bucket': bool, 170 | } 171 | } 172 | }, required=True, extra=ALLOW_EXTRA) 173 | 174 | OUTPUT_SCHEMA = Schema({ 175 | 'output': ACCOUNT_LINK_PROVISIONED, 176 | }, required=True, extra=ALLOW_EXTRA) 177 | 178 | 179 | request_type = get_in(['event', 'RequestType']) 180 | properties = get_in(['event', 'ResourceProperties']) 181 | stacks = get_in(['event', 'ResourceProperties', 'Stacks']) 182 | reactor_callback_url = get_in(['event', 'ResourceProperties', 'ReactorCallbackUrl']) 183 | supported_metadata = {'Region', 'ExternalId', 'AccountId', 'AccountName', 'ReactorId', 'ReactorCallbackUrl'} 184 | callback_metadata = keyfilter(lambda x: x in supported_metadata) 185 | default_metadata = { 186 | 'version': '1', 187 | 'message_source': 'cfn', 188 | } 189 | 190 | 191 | ##################### 192 | # 193 | # Coeffects, i.e. from the outside world 194 | # 195 | ##################### 196 | def coeffects(world): 197 | return pipe(world, 198 | coeffects_cfn) 199 | 200 | 201 | def coeffect(name): 202 | def d(f): 203 | def w(world): 204 | data = {} 205 | try: 206 | data = f(world) 207 | except Exception: 208 | logger.warning(f'Failed to get {name} information.', exc_info=True) 209 | return assoc_in(world, ['coeffects', name], data) 210 | return w 211 | return d 212 | 213 | 214 | def outputs_to_dict(outputs): 215 | return { 216 | output['OutputKey']: output['OutputValue'] 217 | for output in outputs or [] 218 | } 219 | 220 | 221 | @coeffect('cloudformation') 222 | def coeffects_cfn(world): 223 | return { 224 | key: outputs_to_dict(cfn.Stack(name).outputs) 225 | for key, name in stacks(world, default={}).items() 226 | } 227 | 228 | 229 | ##################### 230 | # 231 | # Business Logic 232 | # 233 | ##################### 234 | def notify_cloudzero(world): 235 | return pipe(world, 236 | validate_cfn_coeffect, 237 | prepare_output) 238 | 239 | 240 | def validate_cfn_coeffect(world): 241 | cfn_coeffect = get_in(['coeffects', 'cloudformation'], world) 242 | try: 243 | return update_in(world, ['valid_cfn'], 244 | lambda x: merge(x or {}, CFN_COEFFECT_SCHEMA(cfn_coeffect))) 245 | except Invalid: 246 | logger.warning(cfn_coeffect) 247 | logger.warning('CloudFormation Coeffects are not valid; using defaults', exc_info=True) 248 | return update_in(world, ['valid_cfn'], 249 | lambda x: merge(x or {}, DEFAULT_CFN_COEFFECT)) 250 | 251 | 252 | def null_to_none(s): 253 | return None if s == 'null' else s 254 | 255 | 256 | def string_to_bool(s): 257 | """ 258 | Convert String to Bool 259 | 260 | >>> string_to_bool('True') 261 | True 262 | 263 | >>> string_to_bool('true') 264 | True 265 | 266 | >>> string_to_bool('False') 267 | False 268 | 269 | >>> string_to_bool('false') 270 | False 271 | 272 | >>> string_to_bool('null') 273 | 274 | >>> string_to_bool(None) 275 | 276 | >>> string_to_bool('') 277 | 278 | """ 279 | if not s: 280 | return None 281 | return None if s.lower() == 'null' else s.lower() == 'true' 282 | 283 | 284 | def prepare_output(world): 285 | valid_cfn = get_in(['valid_cfn'], world) 286 | metadata = callback_metadata(properties(world)) 287 | message_type = 'account-link-provisioned' if request_type(world) in {'Create', 'Update'} else 'account-link-deprovisioned' 288 | visible_cloudtrail_arns_string = null_to_none(get_in(['Discovery', 'VisibleCloudTrailArns'], valid_cfn)) 289 | visible_cloudtrail_arns = visible_cloudtrail_arns_string.split(',') if visible_cloudtrail_arns_string else None 290 | master_payer_billing_bucket_name = (null_to_none(get_in(['Discovery', 'MasterPayerBillingBucketName'], valid_cfn)) or 291 | null_to_none(get_in(['MasterPayerAccount', 'ReportS3Bucket'], valid_cfn))) 292 | master_payer_billing_bucket_path = (null_to_none(get_in(['Discovery', 'MasterPayerBillingBucketPath'], valid_cfn)) or 293 | null_to_none(get_in(['MasterPayerAccount', 'ReportS3Prefix'], valid_cfn))) 294 | output = { 295 | **default_metadata, 296 | 'message_type': message_type, 297 | 'data': { 298 | 'metadata': { 299 | 'cloud_region': metadata['Region'], 300 | 'external_id': metadata['ExternalId'], 301 | 'cloud_account_id': metadata['AccountId'], 302 | 'cz_account_name': metadata['AccountName'], 303 | 'reactor_id': metadata['ReactorId'], 304 | 'reactor_callback_url': metadata['ReactorCallbackUrl'], 305 | }, 306 | 'links': { 307 | 'audit': {'role_arn': null_to_none(get_in(['AuditAccount', 'RoleArn'], valid_cfn))}, 308 | 'cloudtrail_owner': { 309 | 'sqs_queue_arn': null_to_none(get_in(['CloudTrailOwnerAccount', 'SQSQueueArn'], valid_cfn)), 310 | 'sqs_queue_policy_name': null_to_none(get_in(['CloudTrailOwnerAccount', 'SQSQueuePolicyName'], valid_cfn)), 311 | }, 312 | 'master_payer': {'role_arn': null_to_none(get_in(['MasterPayerAccount', 'RoleArn'], valid_cfn))}, 313 | 'resource_owner': {'role_arn': null_to_none(get_in(['ResourceOwnerAccount', 'RoleArn'], valid_cfn))}, 314 | 'legacy': {'role_arn': null_to_none(get_in(['LegacyAccount', 'RoleArn'], valid_cfn))}, 315 | }, 316 | 'discovery': { 317 | 'audit_cloudtrail_bucket_name': null_to_none(get_in(['Discovery', 'AuditCloudTrailBucketName'], valid_cfn)), 318 | 'audit_cloudtrail_bucket_prefix': null_to_none(get_in(['Discovery', 'AuditCloudTrailBucketPrefix'], valid_cfn)), 319 | 'cloudtrail_sns_topic_arn': null_to_none(get_in(['Discovery', 'CloudTrailSNSTopicArn'], valid_cfn)), 320 | 'cloudtrail_trail_arn': null_to_none(get_in(['Discovery', 'CloudTrailTrailArn'], valid_cfn)), 321 | 322 | 'is_audit_account': string_to_bool(get_in(['Discovery', 'IsAuditAccount'], valid_cfn)), 323 | 'is_cloudtrail_owner_account': string_to_bool(get_in(['Discovery', 'IsCloudTrailOwnerAccount'], valid_cfn)), 324 | 'is_master_payer_account': string_to_bool(get_in(['Discovery', 'IsMasterPayerAccount'], valid_cfn)), 325 | 'is_organization_master_account': string_to_bool(get_in(['Discovery', 'IsOrganizationMasterAccount'], valid_cfn)), 326 | 'is_organization_trail': string_to_bool(get_in(['Discovery', 'IsOrganizationTrail'], valid_cfn)), 327 | 'is_resource_owner_account': string_to_bool(get_in(['Discovery', 'IsResourceOwnerAccount'], valid_cfn)), 328 | 'master_payer_billing_bucket_name': master_payer_billing_bucket_name, 329 | 'master_payer_billing_bucket_path': master_payer_billing_bucket_path, 330 | 'remote_cloudtrail_bucket': string_to_bool(get_in(['Discovery', 'RemoteCloudTrailBucket'], valid_cfn)), 331 | 'visible_cloudtrail_arns': visible_cloudtrail_arns, 332 | } 333 | } 334 | } 335 | return update_in(world, ['output'], lambda x: merge(x or {}, output)) 336 | 337 | 338 | ##################### 339 | # 340 | # Effects, i.e. changes to the outside world 341 | # 342 | ##################### 343 | def effects(world): 344 | return pipe(world, 345 | effects_reactor_callback) 346 | 347 | 348 | def effect(name): 349 | def d(f): 350 | def w(world): 351 | data = {} 352 | try: 353 | data = f(world) 354 | except Exception: 355 | logger.warning(f'Failed to effect {name} change.', exc_info=True) 356 | return assoc_in(world, ['effects', name], data) 357 | return w 358 | return d 359 | 360 | 361 | @effect('reactor') 362 | def effects_reactor_callback(world): 363 | url = reactor_callback_url(world) 364 | data = get_in(['output'], world) 365 | data_string = json.dumps(data) 366 | logger.info(f'Posting to {url} this data: {data_string}') 367 | response = http.request('POST', url, body=data_string.encode('utf-8')) 368 | response_text = response.data.decode('utf-8') 369 | logger.info(f'response {response.status}; text {response_text}') 370 | assert response.status == 200 371 | return response_text 372 | 373 | 374 | ##################### 375 | # 376 | # Handler 377 | # 378 | ##################### 379 | def handler(event, context, **kwargs): 380 | status = cfnresponse.SUCCESS 381 | world = {} 382 | try: 383 | logger.info(f'Processing event {json.dumps(event)}') 384 | world = pipe({'event': event, 'kwargs': kwargs}, 385 | INPUT_SCHEMA, 386 | coeffects, 387 | notify_cloudzero, 388 | effects, 389 | OUTPUT_SCHEMA) 390 | except Exception as err: 391 | logger.exception(err) 392 | finally: 393 | output = world.get('output') 394 | logger.info(f'Sending output {output}') 395 | cfnresponse.send(event, context, status, output, event.get('PhysicalResourceId')) 396 | -------------------------------------------------------------------------------- /services/account_type/master_payer.yaml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2016-present, CloudZero, Inc. All rights reserved. 3 | # Licensed under the BSD-style license. See LICENSE file in the project root for full license information. 4 | 5 | AWSTemplateFormatVersion: '2010-09-09' 6 | 7 | 8 | Description: CloudZero Master Payer Template 9 | 10 | 11 | Parameters: 12 | IsMasterPayerAccount: 13 | Description: Flag to indicate if this is the master payer account, i.e. existing valid CUR 14 | Type: String 15 | AllowedValues: 16 | - 'true' 17 | - 'false' 18 | IsOrganizationMasterAccount: 19 | Description: Flag to indicate if this is an organization master account 20 | Type: String 21 | AllowedValues: 22 | - 'true' 23 | - 'false' 24 | MasterPayerBillingBucketName: 25 | Description: The name of the S3 bucket responsible for billing data 26 | Type: String 27 | ReactorAccountId: 28 | Description: The CloudZero reactor AWS account ID 29 | Type: String 30 | Default: '061190967865' 31 | ExternalId: 32 | Description: | 33 | Unique ExternalId for Customer Organization; for cross-account Role Access and 34 | associating this template with a Customer Organization 35 | Type: String 36 | 37 | Conditions: 38 | ValidExistingBucketName: !Not [ !Equals [ !Ref MasterPayerBillingBucketName, 'null' ] ] 39 | CreateCurAndBucketAndPolicy: !And 40 | - !Not 41 | - Condition: ValidExistingBucketName 42 | - !Equals [ !Ref IsMasterPayerAccount, 'true' ] 43 | CreatePolicyForExistingCur: !And 44 | - Condition: ValidExistingBucketName 45 | - !Equals [ !Ref IsMasterPayerAccount, 'true' ] 46 | CreateRoleAndPolicy: !Or 47 | - Condition: CreatePolicyForExistingCur 48 | - Condition: CreateCurAndBucketAndPolicy 49 | 50 | Mappings: 51 | Defaults: 52 | Cur: 53 | BucketName: 'cz-cur-hourly-csv' 54 | 55 | Resources: 56 | Role: 57 | Condition: CreateRoleAndPolicy 58 | Type: 'AWS::IAM::Role' 59 | Properties: 60 | Path: '/cloudzero/' 61 | AssumeRolePolicyDocument: 62 | Version: '2012-10-17' 63 | Statement: 64 | - Sid: czServiceAccount01 65 | Effect: Allow 66 | Principal: 67 | AWS: !Sub 'arn:aws:iam::${ReactorAccountId}:root' 68 | Action: 69 | - 'sts:AssumeRole' 70 | Condition: 71 | StringEquals: 72 | 'sts:ExternalId': !Ref ExternalId 73 | ManagedPolicyArns: 74 | - arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess 75 | - arn:aws:iam::aws:policy/ComputeOptimizerReadOnlyAccess 76 | - arn:aws:iam::aws:policy/job-function/ViewOnlyAccess 77 | - arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess 78 | 79 | CostAndUsageReportResource: 80 | Condition: CreateCurAndBucketAndPolicy 81 | Type: 'Custom::CostAndUsageReport' 82 | Properties: 83 | ServiceToken: !GetAtt CostAndUsageReportLambda.Arn 84 | Region: !Ref AWS::Region 85 | BucketName: !Sub 86 | - '${BucketName}-${Id}' 87 | - { BucketName: !FindInMap [ Defaults, Cur, BucketName ], 88 | Id: !Select [ 2, !Split [ '/', !Ref 'AWS::StackId' ] ] } 89 | 90 | CostAndUsageReportRole: 91 | Condition: CreateCurAndBucketAndPolicy 92 | Type: 'AWS::IAM::Role' 93 | Properties: 94 | AssumeRolePolicyDocument: 95 | Version: 2012-10-17 96 | Statement: 97 | - Effect: Allow 98 | Principal: 99 | Service: lambda.amazonaws.com 100 | Action: sts:AssumeRole 101 | Policies: 102 | - PolicyName: CurAccess 103 | PolicyDocument: 104 | Version: 2012-10-17 105 | Statement: 106 | - Sid: AllowLogging 107 | Effect: Allow 108 | Action: 109 | - 'logs:CreateLogGroup' 110 | - 'logs:CreateLogStream' 111 | - 'logs:PutLogEvents' 112 | Resource: '*' 113 | - Sid: AllowCreateCur 114 | Effect: Allow 115 | Action: 116 | - 'cur:*' 117 | Resource: '*' 118 | - Sid: AllowVerifyCurBucket 119 | Effect: Allow 120 | Action: 121 | - 's3:*' 122 | Resource: 123 | - !Sub 124 | - 'arn:aws:s3:::${BucketName}-${Id}' 125 | - { BucketName: !FindInMap [ Defaults, Cur, BucketName ], 126 | Id: !Select [ 2, !Split [ '/', !Ref 'AWS::StackId' ] ] } 127 | - !Sub 128 | - 'arn:aws:s3:::${BucketName}-${Id}/*' 129 | - { BucketName: !FindInMap [ Defaults, Cur, BucketName ], 130 | Id: !Select [ 2, !Split [ '/', !Ref 'AWS::StackId' ] ] } 131 | 132 | # NOTE: This is an inline lambda function b/c we want it to be easy for you 133 | # to see the resource we're creating, in this case a Cost and Usage report. 134 | # We always prefer pure CFN to custom resources; however, CFN currently does 135 | # not support creating a CUR. 136 | CostAndUsageReportLambda: 137 | Condition: CreateCurAndBucketAndPolicy 138 | Type: 'AWS::Lambda::Function' 139 | Properties: 140 | Description: Create/Delete Cost and Usage Report 141 | Handler: index.handler 142 | Runtime: python3.12 143 | Role: !GetAtt CostAndUsageReportRole.Arn 144 | Timeout: 120 145 | Code: 146 | ZipFile: | 147 | import boto3 148 | import cfnresponse 149 | import json 150 | import logging 151 | import time 152 | 153 | cur = boto3.client('cur', region_name='us-east-1') # cur is only in us-east-1 154 | logger = logging.getLogger() 155 | logger.setLevel(logging.INFO) 156 | 157 | def handler(event, context): 158 | result = cfnresponse.SUCCESS 159 | report_name = 'cloudzero-cur-hourly-csv' 160 | s3_prefix = 'cloudzero' 161 | report_s3_prefix = f'{s3_prefix}/{report_name}' 162 | 163 | try: 164 | logger.info(f'Received event: {event}') 165 | bucket_name = event['ResourceProperties']['BucketName'] 166 | current_region = event['ResourceProperties']['Region'] 167 | valid_regions = {'us-west-1','us-west-2','ap-northeast-1','ap-southeast-1', 168 | 'eu-west-1','us-east-1','ap-southeast-2','eu-central-1'} 169 | region = current_region if current_region in valid_regions else 'us-east-1' 170 | s3 = boto3.resource('s3', region_name=region) 171 | bucket = s3.Bucket(bucket_name) 172 | 173 | if event['RequestType'] == 'Create': 174 | logger.info(f'Creating {bucket_name} in {region}') 175 | bucket.create(**({} if region == 'us-east-1' else {'CreateBucketConfiguration': {'LocationConstraint': region}})) 176 | bucket.wait_until_exists() 177 | policy = { 178 | 'Version': '2012-10-17', 179 | 'Statement': [ 180 | { 181 | 'Sid': 'AddBillReportsGet', 182 | 'Effect': 'Allow', 183 | 'Principal': { 184 | 'Service': 'billingreports.amazonaws.com' 185 | }, 186 | 'Action': [ 187 | 's3:GetBucketAcl', 188 | 's3:GetBucketPolicy' 189 | ], 190 | 'Resource': f'arn:aws:s3:::{bucket_name}' 191 | }, 192 | { 193 | 'Sid': 'AddBillReportsPut', 194 | 'Effect': 'Allow', 195 | 'Principal': { 196 | 'Service': 'billingreports.amazonaws.com' 197 | }, 198 | 'Action': 's3:PutObject', 199 | 'Resource': f'arn:aws:s3:::{bucket_name}/*' 200 | } 201 | ] 202 | } 203 | for attempt in range(5): 204 | try: 205 | logger.info(f'Adding bucket policy to {bucket_name}') 206 | bucket.Policy().put(Policy=json.dumps(policy)) 207 | except: 208 | time.sleep(1) 209 | else: 210 | break 211 | 212 | for attempt in range(5): 213 | try: 214 | logger.info(f'Creating {report_name}') 215 | cur.put_report_definition( 216 | ReportDefinition={ 217 | 'AdditionalSchemaElements': ['RESOURCES'], 218 | 'Compression': 'GZIP', 'Format': 'textORcsv', 219 | 'RefreshClosedReports': True, 220 | 'ReportName': report_name, 221 | 'ReportVersioning': 'CREATE_NEW_REPORT', 222 | 'S3Bucket': bucket_name, 'S3Prefix': s3_prefix, 223 | 'S3Region': region, 'TimeUnit': 'HOURLY', 224 | } 225 | ) 226 | except: 227 | time.sleep(1) 228 | else: 229 | break 230 | elif event['RequestType'] == 'Update': 231 | logger.warning(f'Update is unsupported') 232 | elif event['RequestType'] == 'Delete': 233 | logger.warning(f'We do not want to delete your billing data.') 234 | except Exception: 235 | logger.error('Failed to Update Cur Resource', exc_info=True) 236 | result = cfnresponse.FAILED 237 | finally: 238 | cfnresponse.send(event, context, result, {'ReportS3Bucket': bucket_name, 'ReportS3Prefix': report_s3_prefix}) 239 | 240 | 241 | 242 | 243 | RolePolicy: 244 | Condition: CreateRoleAndPolicy 245 | Type: 'AWS::IAM::Policy' 246 | Properties: 247 | PolicyName: !Sub 'cloudzero-master-payer-policy-${ReactorAccountId}' 248 | Roles: 249 | - !Ref Role 250 | PolicyDocument: 251 | Version: '2012-10-17' 252 | Statement: 253 | - Sid: CZMasterPayerBillingBucket20190912 254 | Effect: Allow 255 | Action: 256 | - s3:Get* 257 | - s3:List* 258 | Resource: !If 259 | - CreatePolicyForExistingCur 260 | - 261 | - !Sub 'arn:aws:s3:::${MasterPayerBillingBucketName}' 262 | - !Sub 'arn:aws:s3:::${MasterPayerBillingBucketName}/*' 263 | - 264 | - !Sub 265 | - 'arn:aws:s3:::${BucketName}-${Id}' 266 | - { BucketName: !FindInMap [ Defaults, Cur, BucketName ], 267 | Id: !Select [ 2, !Split [ '/', !Ref 'AWS::StackId' ] ] } 268 | - !Sub 269 | - 'arn:aws:s3:::${BucketName}-${Id}/*' 270 | - { BucketName: !FindInMap [ Defaults, Cur, BucketName ], 271 | Id: !Select [ 2, !Split [ '/', !Ref 'AWS::StackId' ] ] } 272 | - Sid: CZCostMonitoring20240422 273 | Effect: Allow 274 | Action: 275 | - account:GetAccountInformation 276 | - billing:Get* 277 | - budgets:Describe* 278 | - budgets:View* 279 | - ce:Describe* 280 | - ce:Get* 281 | - ce:List* 282 | - consolidatedbilling:Get* 283 | - consolidatedbilling:List* 284 | - cur:Describe* 285 | - cur:Get* 286 | - cur:Validate* 287 | - cur:List* 288 | - freetier:Get* 289 | - invoicing:Get* 290 | - invoicing:List* 291 | - organizations:Describe* 292 | - organizations:List* 293 | - payments:Get* 294 | - payments:List* 295 | - pricing:* 296 | - tax:Get* 297 | - tax:List* 298 | Resource: "*" 299 | - Sid: CZActivityMonitoring20210423 300 | Effect: Allow 301 | Action: 302 | - cloudtrail:Get* 303 | - cloudtrail:List* 304 | - cloudtrail:Describe* 305 | - health:Describe* 306 | - support:DescribeTrustedAdvisor* 307 | - servicequotas:Get* 308 | - servicequotas:List* 309 | - resource-groups:Get* 310 | - resource-groups:List* 311 | - resource-groups:Search* 312 | - tag:Get* 313 | - tag:Describe* 314 | - resource-explorer:List* 315 | - account:ListRegions 316 | Resource: "*" 317 | - Sid: CZReservedCapacity20190912 318 | Effect: Allow 319 | Action: 320 | - dynamodb:DescribeReserved* 321 | - ec2:DescribeReserved* 322 | - elasticache:DescribeReserved* 323 | - es:DescribeReserved* 324 | - rds:DescribeReserved* 325 | - redshift:DescribeReserved* 326 | Resource: "*" 327 | - Sid: CloudZeroContainerInsightsAccess20210423 328 | Effect: Allow 329 | Action: 330 | - logs:List* 331 | - logs:Describe* 332 | - logs:StartQuery 333 | - logs:StopQuery 334 | - logs:Filter* 335 | - logs:Get* 336 | Resource: arn:aws:logs:*:*:log-group:/aws/containerinsights/* 337 | - Sid: CloudZeroCloudWatchContainerLogStreamAccess20210906 338 | Effect: Allow 339 | Action: 340 | - logs:GetQueryResults 341 | - logs:DescribeLogGroups 342 | Resource: arn:aws:logs:*:*:log-group::log-stream:* 343 | - Sid: CloudZeroCloudWatchMetricsAccess20210423 344 | Effect: Allow 345 | Action: 346 | - autoscaling:Describe* 347 | - cloudwatch:Describe* 348 | - cloudwatch:Get* 349 | - cloudwatch:List* 350 | Resource: "*" 351 | - Sid: ReadOnlyOptimizationHub20251103 352 | Effect: Allow 353 | Action: 354 | - cost-optimization-hub:GetRecommendation 355 | - cost-optimization-hub:ListRecommendations 356 | Resource: "*" 357 | - Sid: CloudFormationAccess20251103 358 | Effect: Allow 359 | Action: 360 | - cloudformation:Describe* 361 | - cloudformation:Get* 362 | - cloudformation:List* 363 | Resource: "*" 364 | 365 | Outputs: 366 | RoleArn: 367 | Description: The cloudzero cross account role ARN 368 | Value: !If [ CreateRoleAndPolicy, !GetAtt Role.Arn, 'null' ] 369 | ReportS3Bucket: 370 | Description: The CUR bucket if it was created 371 | Value: !If [ CreateCurAndBucketAndPolicy, !GetAtt CostAndUsageReportResource.ReportS3Bucket, 'null' ] 372 | ReportS3Prefix: 373 | Description: The CUR bucket prefix if it was created 374 | Value: !If [ CreateCurAndBucketAndPolicy, !GetAtt CostAndUsageReportResource.ReportS3Prefix, 'null' ] 375 | --------------------------------------------------------------------------------