├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── pr-ci.yml ├── .gitignore ├── .golangci.yml ├── .pre-commit-config.yaml ├── .pylintrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── NOTICE ├── README.md ├── buildspec.yml ├── cfn ├── callback │ ├── callback.go │ ├── callback_notag.go │ ├── callback_test.go │ └── status.go ├── cfn.go ├── cfn_test.go ├── cfnerr │ ├── doc.go │ ├── error.go │ └── types.go ├── context.go ├── credentials │ ├── credentials.go │ └── credentials_test.go ├── doc.go ├── encoding │ ├── encoding.go │ ├── encoding_test.go │ ├── marshal.go │ ├── marshal_test.go │ ├── stringify.go │ ├── stringify_test.go │ ├── unmarshal.go │ ├── unstringify.go │ ├── unstringify_test.go │ └── values.go ├── entry_test.go ├── event.go ├── handler │ ├── doc.go │ ├── event.go │ ├── event_test.go │ ├── handler_test.go │ ├── request.go │ ├── request_test.go │ └── status.go ├── logging │ ├── cloudwatchlogs.go │ ├── cloudwatchlogs_test.go │ ├── logging.go │ └── logging_notag.go ├── metrics │ ├── doc.go │ ├── noop_publisher.go │ ├── publisher.go │ └── publisher_test.go ├── reportErr.go ├── response.go ├── response_test.go ├── scheduler │ ├── noop_scheduler.go │ ├── scheduler.go │ ├── scheduler_notag.go │ └── scheduler_test.go ├── test │ └── data │ │ ├── request.create.json │ │ ├── request.create2.json │ │ ├── request.invalid.json │ │ ├── request.read.invalid.json │ │ ├── request.read.invalid.validation.json │ │ ├── request.read.json │ │ ├── test.create.json │ │ ├── test.delete.json │ │ ├── test.invalid.json │ │ └── test.read.json └── types_test.go ├── examples ├── doc.go └── github-repo │ ├── .gitignore │ ├── .rpdk-config │ ├── Makefile │ ├── README.md │ ├── cmd │ ├── main.go │ └── resource │ │ ├── model.go │ │ └── resource.go │ ├── docs │ └── README.md │ ├── example-github-repo.json │ ├── resource-role.yaml │ └── template.yml ├── generate-examples.sh ├── go.mod ├── go.sum ├── python └── rpdk │ └── go │ ├── __init__.py │ ├── codegen.py │ ├── data │ └── go.gitignore │ ├── parser.py │ ├── resolver.py │ ├── templates │ ├── Makefile │ ├── README.md │ ├── config.go.tple │ ├── go.mod.tple │ ├── main.go.tple │ ├── makebuild │ ├── stubHandler.go.tple │ └── types.go.tple │ ├── utils.py │ └── version.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── data └── schema-with-typeconfiguration.json └── plugin ├── __init__.py ├── codegen_test.py └── parser_test.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 7 | -------------------------------------------------------------------------------- /.github/workflows/pr-ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: CloudFormation GO Plugin CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | env: 15 | AWS_DEFAULT_REGION: "us-east-1" 16 | AWS_SDK_LOAD_CONFIG: 1 17 | GOPATH: ${{ github.workspace }} 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | python: ["3.8", "3.9", "3.10", "3.11"] 22 | defaults: 23 | run: 24 | working-directory: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 25 | steps: 26 | - uses: actions/checkout@v3 27 | with: 28 | path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 29 | - uses: actions/setup-go@v3 30 | with: 31 | go-version: '>=1.21.0' 32 | - name: Set up Python ${{ matrix.python }} 33 | uses: actions/setup-python@v2 34 | with: 35 | python-version: ${{ matrix.python }} 36 | - name: Install dependencies 37 | run: | 38 | pip install pre-commit 39 | pip install --upgrade mypy 'attrs==19.2.0' -r https://raw.githubusercontent.com/aws-cloudformation/aws-cloudformation-rpdk/master/requirements.txt 40 | go get ./... 41 | go install github.com/go-critic/go-critic/cmd/gocritic@latest 42 | go install golang.org/x/tools/cmd/goimports@latest 43 | go install github.com/fzipp/gocyclo/cmd/gocyclo@latest 44 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 45 | - name: Install plugin 46 | run: | 47 | pip install . 48 | - name: pre-commit checks 49 | run: | 50 | pre-commit run --all-files 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | .vscode 28 | *.code-workspace 29 | .project 30 | bin/ 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # dotenv 88 | .env 89 | 90 | # virtualenv 91 | .venv 92 | venv/ 93 | ENV/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # macOS 106 | .DS_Store 107 | ._* 108 | 109 | # PyCharm 110 | *.iml 111 | 112 | # reStructuredText preview 113 | README.html 114 | 115 | .idea 116 | out.java 117 | out/ 118 | tmp/ 119 | 120 | # generated files 121 | examples/github-repo/makebuild 122 | examples/github-repo/cmd/resource/config.go 123 | 124 | # Binaries for programs and plugins 125 | *.exe 126 | *.exe~ 127 | *.dll 128 | *.so 129 | *.dylib 130 | 131 | # Test binary, built with `go test -c` 132 | *.test 133 | 134 | # Output of the go coverage tool, specifically when used with LiteIDE 135 | *.out 136 | 137 | # Dependency directories (remove the comment below to include it) 138 | # vendor/ 139 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | # Disable all linters. 3 | # Default: false 4 | disable-all: true 5 | # Enable specific linter 6 | # https://golangci-lint.run/usage/linters/#enabled-by-default 7 | enable: 8 | - errcheck 9 | - gosimple 10 | - govet 11 | - ineffassign 12 | - staticcheck 13 | - unused 14 | - gofmt 15 | - goimports 16 | - gocritic 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-case-conflict 6 | - id: end-of-file-fixer 7 | - id: mixed-line-ending 8 | args: 9 | - --fix=lf 10 | - id: trailing-whitespace 11 | - id: pretty-format-json 12 | args: 13 | - --autofix 14 | - --indent=4 15 | - --no-sort-keys 16 | exclude: cfn/test/data/request.read.invalid.json 17 | - id: check-merge-conflict 18 | - id: check-yaml 19 | - repo: https://github.com/pre-commit/mirrors-isort 20 | rev: v5.10.1 21 | hooks: 22 | - id: isort 23 | - repo: https://github.com/ambv/black 24 | rev: 22.10.0 25 | hooks: 26 | - id: black 27 | - repo: https://github.com/pycqa/flake8 28 | rev: '6.1.0' 29 | hooks: 30 | - id: flake8 31 | additional_dependencies: 32 | - flake8-bugbear>=19.3.0 33 | - flake8-builtins>=1.4.1 34 | - flake8-commas>=2.0.0 35 | - flake8-comprehensions>=2.1.0 36 | - flake8-debugger>=3.1.0 37 | - flake8-pep3101>=1.2.1 38 | exclude: templates/ 39 | - repo: https://github.com/pre-commit/pygrep-hooks 40 | rev: v1.10.0 41 | hooks: 42 | - id: python-check-blanket-noqa 43 | - id: python-check-mock-methods 44 | - id: python-no-log-warn 45 | - repo: https://github.com/PyCQA/bandit 46 | rev: "1.7.5" 47 | hooks: 48 | - id: bandit 49 | files: ^python/ 50 | additional_dependencies: 51 | - "importlib-metadata<5" # https://github.com/PyCQA/bandit/issues/956 52 | - repo: local 53 | hooks: 54 | - id: pylint-local 55 | name: pylint-local 56 | description: Run pylint in the local virtualenv 57 | entry: pylint "setup.py" "python/" "tests/" 58 | language: system 59 | # ignore all files, run on hard-coded modules instead 60 | pass_filenames: false 61 | always_run: true 62 | - id: pytest-local 63 | name: pytest-local 64 | description: Run pytest in the local virtualenv 65 | entry: > 66 | pytest 67 | --durations=5 68 | "tests/" 69 | language: system 70 | # ignore all files, run on hard-coded modules instead 71 | pass_filenames: false 72 | always_run: true 73 | - id: go-unit-tests 74 | name: go unit tests 75 | entry: go test ./... 76 | pass_filenames: false 77 | types: [go] 78 | language: system 79 | - id: go-build-mod 80 | name: go build mod 81 | entry: go build ./... 82 | pass_filenames: false 83 | types: [go] 84 | language: system 85 | - repo: https://github.com/golangci/golangci-lint 86 | rev: v1.55.2 87 | hooks: 88 | - id: golangci-lint-full 89 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | ignore=CVS,models.py,handlers.py,hook_models.py,hook_handlers.py,target_model.py 4 | jobs=1 5 | persistent=yes 6 | 7 | [MESSAGES CONTROL] 8 | 9 | disable= 10 | missing-docstring, # not everything needs a docstring 11 | fixme, # work in progress 12 | too-few-public-methods, # triggers when inheriting 13 | ungrouped-imports, # clashes with isort 14 | duplicate-code, # broken, setup.py 15 | wrong-import-order, # isort has precedence 16 | 17 | [BASIC] 18 | 19 | good-names=e,ex,f,fp,i,j,k,v,n,_ 20 | 21 | [FORMAT] 22 | 23 | indent-string=' ' 24 | max-line-length=88 25 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/awslabs/cloudformation-cli-go-plugin/issues), or [recently closed](https://github.com/awslabs/cloudformation-cli-go-plugin/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/awslabs/cloudformation-cli-go-plugin/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/awslabs/cloudformation-cli-go-plugin/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | 4 | graft python/rpdk/go 5 | 6 | # last rule wins, put excludes last 7 | global-exclude __pycache__ *.py[co] .DS_Store 8 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | AWS CloudFormation RPDK Go Plugin 2 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AWS CloudFormation Resource Provider Go Plugin 2 | 3 | The CloudFormation CLI (cfn) allows you to author your own resource providers that can be used by CloudFormation. 4 | 5 | This plugin library helps to provide Go runtime bindings for the execution of your providers by CloudFormation. 6 | 7 | Usage 8 | ----- 9 | 10 | If you are using this package to build resource providers for CloudFormation, install the [CloudFormation CLI Go Plugin](https://github.com/aws-cloudformation/cloudformation-cli-go-plugin) - this will automatically install the the [CloudFormation CLI](https://github.com/aws-cloudformation/cloudformation-cli)! A Python virtual environment is recommended. 11 | 12 | ```bash 13 | pip3 install cloudformation-cli-go-plugin 14 | ``` 15 | 16 | Refer to the documentation for the [CloudFormation CLI](https://github.com/aws-cloudformation/cloudformation-cli) for usage instructions. 17 | 18 | Development 19 | ----------- 20 | 21 | For changes to the plugin, a Python virtual environment is recommended. Check out and install the plugin in editable mode: 22 | 23 | ```bash 24 | python3 -m venv env 25 | source env/bin/activate 26 | pip3 install -e /path/to/cloudformation-cli-go-plugin 27 | ``` 28 | 29 | You may also want to check out the [CloudFormation CLI](https://github.com/aws-cloudformation/cloudformation-cli) if you wish to make edits to that. In this case, installing them in one operation works well: 30 | 31 | ```bash 32 | pip3 install \ 33 | -e /path/to/cloudformation-cli \ 34 | -e /path/to/cloudformation-cli-go-plugin 35 | ``` 36 | 37 | That ensures neither is accidentally installed from PyPI. 38 | 39 | Linting and running unit tests is done via [pre-commit](https://pre-commit.com/), and so is performed automatically on commit. The continuous integration also runs these checks. Manual options are available so you don't have to commit: 40 | 41 | ```bash 42 | # run all hooks on all files, mirrors what the CI runs 43 | pre-commit run --all-files 44 | # run unit tests only. can also be used for other hooks, e.g. black, isort, pytest-local 45 | pre-commit run pytest-local 46 | ``` 47 | 48 | Use `./generate-examples.sh` to run install `cloudformation-cli-go-plugin` locally and run `cfn generate` in each example. 49 | 50 | Getting started 51 | --------------- 52 | 53 | This plugin create a sample Go project and requires golang 1.8 or above and [godep](https://golang.github.io/dep/docs/introduction.html). For more information on installing and setting up your Go environment, please visit the official [Golang site](https://golang.org/). 54 | 55 | Community 56 | --------------- 57 | 58 | Join us on Discord! Connect & interact with CloudFormation developers & 59 | experts, find channels to discuss and get help for cfn-lint, CloudFormation registry, StackSets, 60 | Guard and more: 61 | 62 | [](https://discord.gg/9zpd7TTRwq) 63 | 64 | License 65 | ------- 66 | 67 | This library is licensed under the Apache 2.0 License. 68 | -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | phases: 3 | install: 4 | runtime-versions: 5 | python: 3.8 6 | golang: 1.12 7 | commands: 8 | - pip install pre-commit 9 | build: 10 | commands: 11 | - pre-commit --version 12 | -------------------------------------------------------------------------------- /cfn/callback/callback.go: -------------------------------------------------------------------------------- 1 | //go:build callback 2 | // +build callback 3 | 4 | /* 5 | Package callback provides functions for creating resource providers 6 | that may need to be called multiple times while waiting 7 | for resources to settle. 8 | */ 9 | package callback 10 | 11 | import ( 12 | "fmt" 13 | "log" 14 | 15 | "github.com/avast/retry-go" 16 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 17 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 18 | "github.com/aws/aws-sdk-go/aws" 19 | "github.com/aws/aws-sdk-go/service/cloudformation" 20 | "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" 21 | ) 22 | 23 | const ( 24 | // ServiceInternalError ... 25 | ServiceInternalError string = "ServiceInternal" 26 | // MaxRetries is the number of retries allowed to report status. 27 | MaxRetries uint = 3 28 | ) 29 | 30 | // CloudFormationCallbackAdapter used to report progress events back to CloudFormation. 31 | type CloudFormationCallbackAdapter struct { 32 | client cloudformationiface.CloudFormationAPI 33 | bearerToken string 34 | logger *log.Logger 35 | } 36 | 37 | // New creates a CloudFormationCallbackAdapter and returns a pointer to the struct. 38 | func New(client cloudformationiface.CloudFormationAPI, bearerToken string) *CloudFormationCallbackAdapter { 39 | return &CloudFormationCallbackAdapter{ 40 | client: client, 41 | bearerToken: bearerToken, 42 | logger: logging.New("callback"), 43 | } 44 | } 45 | 46 | // ReportStatus reports the status back to the Cloudformation service of a handler 47 | // that has moved from Pending to In_Progress 48 | func (c *CloudFormationCallbackAdapter) ReportStatus(operationStatus Status, model []byte, message string, errCode string) error { 49 | if err := c.reportProgress(errCode, operationStatus, InProgress, model, message); err != nil { 50 | return err 51 | } 52 | return nil 53 | } 54 | 55 | // ReportInitialStatus reports the initial status back to the Cloudformation service. 56 | func (c *CloudFormationCallbackAdapter) ReportInitialStatus() error { 57 | if err := c.reportProgress("", InProgress, Pending, []byte(""), ""); err != nil { 58 | return err 59 | } 60 | return nil 61 | } 62 | 63 | // ReportFailureStatus reports the failure status back to the Cloudformation service. 64 | func (c *CloudFormationCallbackAdapter) ReportFailureStatus(model []byte, errCode string, handlerError error) error { 65 | if err := c.reportProgress(errCode, Failed, InProgress, model, handlerError.Error()); err != nil { 66 | return err 67 | } 68 | return nil 69 | } 70 | 71 | // ReportProgress reports the current status back to the Cloudformation service. 72 | func (c *CloudFormationCallbackAdapter) reportProgress(errCode string, operationStatus Status, currentOperationStatus Status, resourceModel []byte, statusMessage string) error { 73 | 74 | in := cloudformation.RecordHandlerProgressInput{ 75 | BearerToken: aws.String(c.bearerToken), 76 | OperationStatus: aws.String(TranslateOperationStatus(operationStatus)), 77 | } 78 | 79 | if len(statusMessage) != 0 { 80 | in.SetStatusMessage(statusMessage) 81 | } 82 | 83 | if len(resourceModel) != 0 { 84 | in.SetResourceModel(string(resourceModel)) 85 | } 86 | 87 | if len(errCode) != 0 { 88 | in.SetErrorCode(TranslateErrorCode(errCode)) 89 | } 90 | 91 | if len(currentOperationStatus) != 0 { 92 | in.SetCurrentOperationStatus(TranslateOperationStatus(currentOperationStatus)) 93 | } 94 | 95 | // Do retries and emit logs. 96 | rerr := retry.Do( 97 | func() error { 98 | _, err := c.client.RecordHandlerProgress(&in) 99 | if err != nil { 100 | return err 101 | } 102 | return nil 103 | }, retry.OnRetry(func(n uint, err error) { 104 | s := fmt.Sprintf("Failed to record progress: try:#%d: %s\n ", n+1, err) 105 | c.logger.Println(s) 106 | 107 | }), retry.Attempts(MaxRetries), 108 | ) 109 | 110 | if rerr != nil { 111 | return cfnerr.New(ServiceInternalError, "Callback ReportProgress Error", rerr) 112 | } 113 | 114 | return nil 115 | } 116 | 117 | // TranslateErrorCode : Translate the error code into a standard Cloudformation error 118 | func TranslateErrorCode(errorCode string) string { 119 | 120 | // Ensure the error code conforms to one of the available 121 | switch errorCode { 122 | case cloudformation.HandlerErrorCodeNotUpdatable, 123 | cloudformation.HandlerErrorCodeInvalidRequest, 124 | cloudformation.HandlerErrorCodeAccessDenied, 125 | cloudformation.HandlerErrorCodeInvalidCredentials, 126 | cloudformation.HandlerErrorCodeAlreadyExists, 127 | cloudformation.HandlerErrorCodeNotFound, 128 | cloudformation.HandlerErrorCodeResourceConflict, 129 | cloudformation.HandlerErrorCodeThrottling, 130 | cloudformation.HandlerErrorCodeServiceLimitExceeded, 131 | cloudformation.HandlerErrorCodeNotStabilized, 132 | cloudformation.HandlerErrorCodeGeneralServiceException, 133 | cloudformation.HandlerErrorCodeServiceInternalError, 134 | cloudformation.HandlerErrorCodeNetworkFailure, 135 | cloudformation.HandlerErrorCodeInternalFailure: 136 | return errorCode 137 | default: 138 | // InternalFailure is CloudFormation's fallback error code when no more specificity is there 139 | return cloudformation.HandlerErrorCodeInternalFailure 140 | } 141 | } 142 | 143 | // TranslateOperationStatus Translate the operation Status into a standard Cloudformation error 144 | func TranslateOperationStatus(operationStatus Status) string { 145 | 146 | switch operationStatus { 147 | case Success: 148 | return cloudformation.OperationStatusSuccess 149 | case Failed: 150 | return cloudformation.OperationStatusFailed 151 | case InProgress: 152 | return cloudformation.OperationStatusInProgress 153 | case Pending: 154 | return cloudformation.OperationStatusPending 155 | default: 156 | // default will be to fail on unknown status 157 | return cloudformation.OperationStatusFailed 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /cfn/callback/callback_notag.go: -------------------------------------------------------------------------------- 1 | //go:build !callback 2 | // +build !callback 3 | 4 | package callback 5 | 6 | import ( 7 | "log" 8 | 9 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/service/cloudformation" 12 | "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" 13 | ) 14 | 15 | // CloudFormationCallbackAdapter used to report progress events back to CloudFormation. 16 | type CloudFormationCallbackAdapter struct { 17 | logger *log.Logger 18 | client cloudformationiface.CloudFormationAPI 19 | bearerToken string 20 | } 21 | 22 | // New creates a CloudFormationCallbackAdapter and returns a pointer to the struct. 23 | func New(client cloudformationiface.CloudFormationAPI, bearerToken string) *CloudFormationCallbackAdapter { 24 | return &CloudFormationCallbackAdapter{ 25 | client: client, 26 | bearerToken: bearerToken, 27 | logger: logging.New("callback"), 28 | } 29 | } 30 | 31 | // ReportStatus reports the status back to the Cloudformation service of a handler 32 | // that has moved from Pending to In_Progress 33 | func (c *CloudFormationCallbackAdapter) ReportStatus(operationStatus Status, model []byte, message string, errCode string) error { 34 | if err := c.reportProgress(errCode, operationStatus, InProgress, model, message); err != nil { 35 | return err 36 | } 37 | return nil 38 | } 39 | 40 | // ReportInitialStatus reports the initial status back to the Cloudformation service. 41 | func (c *CloudFormationCallbackAdapter) ReportInitialStatus() error { 42 | if err := c.reportProgress("", InProgress, Pending, []byte(""), ""); err != nil { 43 | return err 44 | } 45 | return nil 46 | } 47 | 48 | // ReportFailureStatus reports the failure status back to the Cloudformation service. 49 | func (c *CloudFormationCallbackAdapter) ReportFailureStatus(model []byte, errCode string, handlerError error) error { 50 | if err := c.reportProgress(errCode, Failed, InProgress, model, handlerError.Error()); err != nil { 51 | return err 52 | } 53 | return nil 54 | } 55 | 56 | // ReportProgress reports the current status back to the Cloudformation service. 57 | func (c *CloudFormationCallbackAdapter) reportProgress(code string, operationStatus Status, currentOperationStatus Status, resourceModel []byte, statusMessage string) error { 58 | 59 | in := cloudformation.RecordHandlerProgressInput{ 60 | BearerToken: aws.String(c.bearerToken), 61 | OperationStatus: aws.String(TranslateOperationStatus(operationStatus)), 62 | } 63 | 64 | if len(statusMessage) != 0 { 65 | in.SetStatusMessage(statusMessage) 66 | } 67 | 68 | if len(resourceModel) != 0 { 69 | in.SetResourceModel(string(resourceModel)) 70 | } 71 | 72 | if len(code) != 0 { 73 | in.SetErrorCode(TranslateErrorCode(code)) 74 | } 75 | 76 | if len(currentOperationStatus) != 0 { 77 | in.SetCurrentOperationStatus(string(currentOperationStatus)) 78 | } 79 | 80 | c.logger.Printf("Record progress: %v", &in) 81 | 82 | return nil 83 | } 84 | 85 | // TranslateErrorCode : Translate the error code into a standard Cloudformation error 86 | func TranslateErrorCode(errorCode string) string { 87 | switch errorCode { 88 | case cloudformation.HandlerErrorCodeNotUpdatable, 89 | cloudformation.HandlerErrorCodeInvalidRequest, 90 | cloudformation.HandlerErrorCodeAccessDenied, 91 | cloudformation.HandlerErrorCodeInvalidCredentials, 92 | cloudformation.HandlerErrorCodeAlreadyExists, 93 | cloudformation.HandlerErrorCodeNotFound, 94 | cloudformation.HandlerErrorCodeResourceConflict, 95 | cloudformation.HandlerErrorCodeThrottling, 96 | cloudformation.HandlerErrorCodeServiceLimitExceeded, 97 | cloudformation.HandlerErrorCodeNotStabilized, 98 | cloudformation.HandlerErrorCodeGeneralServiceException, 99 | cloudformation.HandlerErrorCodeServiceInternalError, 100 | cloudformation.HandlerErrorCodeNetworkFailure, 101 | cloudformation.HandlerErrorCodeInternalFailure: 102 | return errorCode 103 | default: 104 | // InternalFailure is CloudFormation's fallback error code when no more specificity is there 105 | return cloudformation.HandlerErrorCodeInternalFailure 106 | } 107 | } 108 | 109 | // TranslateOperationStatus Translate the operation Status into a standard Cloudformation error 110 | func TranslateOperationStatus(operationStatus Status) string { 111 | 112 | switch operationStatus { 113 | case Success: 114 | return cloudformation.OperationStatusSuccess 115 | case Failed: 116 | return cloudformation.OperationStatusFailed 117 | case InProgress: 118 | return cloudformation.OperationStatusInProgress 119 | case Pending: 120 | return cloudformation.OperationStatusPending 121 | default: 122 | // default will be to fail on unknown status 123 | return cloudformation.OperationStatusFailed 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /cfn/callback/callback_test.go: -------------------------------------------------------------------------------- 1 | package callback 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 8 | "github.com/aws/aws-sdk-go/service/cloudformation" 9 | "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" 10 | ) 11 | 12 | var MockModel = []byte("{\"foo\": \"bar\"}") 13 | 14 | // MockedEvents mocks the call to AWS CloudWatch Events 15 | type MockedCallback struct { 16 | cloudformationiface.CloudFormationAPI 17 | errCount int 18 | } 19 | 20 | func NewMockedCallback(errCount int) *MockedCallback { 21 | return &MockedCallback{ 22 | errCount: errCount, 23 | } 24 | } 25 | 26 | func (m *MockedCallback) RecordHandlerProgress(in *cloudformation.RecordHandlerProgressInput) (*cloudformation.RecordHandlerProgressOutput, error) { 27 | 28 | if m.errCount > 0 { 29 | m.errCount-- 30 | return nil, errors.New("error") 31 | } 32 | 33 | return nil, nil 34 | } 35 | 36 | func TestTranslateOperationStatus(t *testing.T) { 37 | type args struct { 38 | operationStatus Status 39 | } 40 | tests := []struct { 41 | name string 42 | args args 43 | want string 44 | }{ 45 | {"TestSUCCESS", args{"SUCCESS"}, cloudformation.OperationStatusSuccess}, 46 | {"TestFAILED", args{"FAILED"}, cloudformation.OperationStatusFailed}, 47 | {"TestIN_PROGRESS", args{"IN_PROGRESS"}, cloudformation.OperationStatusInProgress}, 48 | {"TestFoo", args{"Foo"}, cloudformation.OperationStatusFailed}, 49 | } 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | if got := TranslateOperationStatus(tt.args.operationStatus); got != tt.want { 53 | t.Errorf("TranslateOperationStatus() = %v, want %v", got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestTranslateErrorCode(t *testing.T) { 60 | type args struct { 61 | errorCode string 62 | } 63 | tests := []struct { 64 | name string 65 | args args 66 | want string 67 | }{ 68 | {"TestNotUpdatable", args{"NotUpdatable"}, cloudformation.HandlerErrorCodeNotUpdatable}, 69 | {"TestInvalidRequest", args{"InvalidRequest"}, cloudformation.HandlerErrorCodeInvalidRequest}, 70 | {"AccessDenied", args{"AccessDenied"}, cloudformation.HandlerErrorCodeAccessDenied}, 71 | {"TestInvalidCredentials", args{"InvalidCredentials"}, cloudformation.HandlerErrorCodeInvalidCredentials}, 72 | {"TestAlreadyExists", args{"AlreadyExists"}, cloudformation.HandlerErrorCodeAlreadyExists}, 73 | {"TestNotFound", args{"NotFound"}, cloudformation.HandlerErrorCodeNotFound}, 74 | {"TestResourceConflict", args{"ResourceConflict"}, cloudformation.HandlerErrorCodeResourceConflict}, 75 | {"TestThrottling", args{"Throttling"}, cloudformation.HandlerErrorCodeThrottling}, 76 | {"TestServiceLimitExceeded", args{"ServiceLimitExceeded"}, cloudformation.HandlerErrorCodeServiceLimitExceeded}, 77 | {"TestGeneralServiceException", args{"GeneralServiceException"}, cloudformation.HandlerErrorCodeGeneralServiceException}, 78 | {"TestServiceInternalError", args{"ServiceInternalError"}, cloudformation.HandlerErrorCodeServiceInternalError}, 79 | {"TestNetworkFailure", args{"NetworkFailure"}, cloudformation.HandlerErrorCodeNetworkFailure}, 80 | {"TestFoo", args{"foo"}, cloudformation.HandlerErrorCodeInternalFailure}, 81 | {"TestInternalFailure", args{"InternalFailure"}, cloudformation.HandlerErrorCodeInternalFailure}, 82 | } 83 | for _, tt := range tests { 84 | t.Run(tt.name, func(t *testing.T) { 85 | if got := TranslateErrorCode(tt.args.errorCode); got != tt.want { 86 | t.Errorf("TranslateErrorCode() = %v, want %v", got, tt.want) 87 | } 88 | }) 89 | } 90 | } 91 | 92 | func TestCloudFormationCallbackAdapterReportProgress(t *testing.T) { 93 | type fields struct { 94 | client cloudformationiface.CloudFormationAPI 95 | } 96 | type args struct { 97 | bearerToken string 98 | code string 99 | status Status 100 | operationStatus Status 101 | resourceModel []byte 102 | statusMessage string 103 | } 104 | tests := []struct { 105 | name string 106 | fields fields 107 | args args 108 | wantErr bool 109 | }{ 110 | {"TestRetryReturnNoErr", fields{NewMockedCallback(0)}, args{"123456", "ACCESSDENIED", "FAILED", "IN_PROGRESS", MockModel, "retry"}, false}, 111 | } 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | c := &CloudFormationCallbackAdapter{ 115 | client: tt.fields.client, 116 | logger: logging.New("callback: "), 117 | bearerToken: tt.args.bearerToken, 118 | } 119 | if err := c.reportProgress(tt.args.code, tt.args.status, tt.args.operationStatus, tt.args.resourceModel, tt.args.statusMessage); (err != nil) != tt.wantErr { 120 | t.Errorf("CloudFormationCallbackAdapter.ReportProgress() error = %v, wantErr %v", err, tt.wantErr) 121 | } 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /cfn/callback/status.go: -------------------------------------------------------------------------------- 1 | package callback 2 | 3 | // Status represents the status of the handler during invocation. 4 | type Status string 5 | 6 | const ( 7 | // UnknownStatus represents all states that aren't covered 8 | // elsewhere. 9 | UnknownStatus Status = "UNKNOWN" 10 | 11 | // InProgress is when a resource provider 12 | // is in the process of being operated on. 13 | InProgress Status = "IN_PROGRESS" 14 | 15 | // Success is when the resource provider 16 | // has finished it's operation. 17 | Success Status = "SUCCESS" 18 | 19 | // Failed is when the resource provider 20 | // has failed. 21 | Failed Status = "FAILED" 22 | 23 | // Pending is the resource provider 24 | // initial state. 25 | Pending Status = "PENDING" 26 | ) 27 | -------------------------------------------------------------------------------- /cfn/cfn.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 12 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/credentials" 13 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 14 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 15 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/metrics" 16 | 17 | "github.com/aws/aws-lambda-go/lambda" 18 | "github.com/aws/aws-sdk-go/service/cloudwatch" 19 | "github.com/aws/aws-sdk-go/service/cloudwatchlogs" 20 | ) 21 | 22 | const ( 23 | invalidRequestError = "InvalidRequest" 24 | serviceInternalError = "ServiceInternal" 25 | unmarshalingError = "UnmarshalingError" 26 | marshalingError = "MarshalingError" 27 | validationError = "Validation" 28 | timeoutError = "Timeout" 29 | sessionNotFoundError = "SessionNotFound" 30 | ) 31 | 32 | const ( 33 | unknownAction = "UNKNOWN" 34 | createAction = "CREATE" 35 | readAction = "READ" 36 | updateAction = "UPDATE" 37 | deleteAction = "DELETE" 38 | listAction = "LIST" 39 | ) 40 | 41 | var once sync.Once 42 | 43 | // Handler is the interface that all resource providers must implement 44 | // 45 | // Each method of Handler maps directly to a CloudFormation action. 46 | // Every action must return a progress event containing details of 47 | // any actions that were undertaken by the resource provider 48 | // or of any error that occurred during operation. 49 | type Handler interface { 50 | Create(request handler.Request) handler.ProgressEvent 51 | Read(request handler.Request) handler.ProgressEvent 52 | Update(request handler.Request) handler.ProgressEvent 53 | Delete(request handler.Request) handler.ProgressEvent 54 | List(request handler.Request) handler.ProgressEvent 55 | } 56 | 57 | // Start is the entry point called from a resource's main function 58 | // 59 | // We define two lambda entry points; MakeEventFunc is the entry point to all 60 | // invocations of a custom resource and MakeTestEventFunc is the entry point that 61 | // allows the CLI's contract testing framework to invoke the resource's CRUDL handlers. 62 | func Start(h Handler) { 63 | defer func() { 64 | if r := recover(); r != nil { 65 | log.Printf("Handler panicked: %s", r) 66 | panic(r) // Continue the panic 67 | } 68 | }() 69 | 70 | log.Printf("Handler starting") 71 | lambda.Start(makeEventFunc(h)) 72 | 73 | log.Printf("Handler finished") 74 | } 75 | 76 | // Tags are stored as key/value paired strings 77 | type tags map[string]string 78 | 79 | // eventFunc is the function signature required to execute an event from the Lambda SDK 80 | type eventFunc func(ctx context.Context, event *event) (response, error) 81 | 82 | // handlerFunc is the signature required for all actions 83 | type handlerFunc func(request handler.Request) handler.ProgressEvent 84 | 85 | // MakeEventFunc is the entry point to all invocations of a custom resource 86 | func makeEventFunc(h Handler) eventFunc { 87 | return func(ctx context.Context, event *event) (response, error) { 88 | ps := credentials.SessionFromCredentialsProvider(&event.RequestData.ProviderCredentials) 89 | m := metrics.New(cloudwatch.New(ps), event.ResourceType) 90 | once.Do(func() { 91 | l, err := logging.NewCloudWatchLogsProvider( 92 | cloudwatchlogs.New(ps), 93 | event.RequestData.ProviderLogGroupName, 94 | ) 95 | if err != nil { 96 | log.Printf("Error: %v, Logging to Stdout", err) 97 | m.PublishExceptionMetric(time.Now(), event.Action, err) 98 | l = os.Stdout 99 | } 100 | // Set default logger to output to CWL in the provider account 101 | logging.SetProviderLogOutput(l) 102 | }) 103 | re := newReportErr(m) 104 | 105 | handlerFn, cfnErr := router(event.Action, h) 106 | log.Printf("Handler received the %s action", event.Action) 107 | if cfnErr != nil { 108 | return re.report(event, "router error", cfnErr, serviceInternalError) 109 | } 110 | if err := validateEvent(event); err != nil { 111 | return re.report(event, "validation error", err, invalidRequestError) 112 | } 113 | rctx := handler.RequestContext{ 114 | StackID: event.StackID, 115 | Region: event.Region, 116 | AccountID: event.AWSAccountID, 117 | StackTags: event.RequestData.StackTags, 118 | SystemTags: event.RequestData.SystemTags, 119 | NextToken: event.NextToken, 120 | } 121 | request := handler.NewRequest( 122 | event.RequestData.LogicalResourceID, 123 | event.CallbackContext, 124 | rctx, 125 | credentials.SessionFromCredentialsProvider(&event.RequestData.CallerCredentials), 126 | event.RequestData.PreviousResourceProperties, 127 | event.RequestData.ResourceProperties, 128 | event.RequestData.TypeConfiguration, 129 | ) 130 | p := invoke(handlerFn, request, m, event.Action) 131 | r, err := newResponse(&p, event.BearerToken) 132 | if err != nil { 133 | log.Printf("Error creating response: %v", err) 134 | return re.report(event, "Response error", err, unmarshalingError) 135 | } 136 | if !isMutatingAction(event.Action) && r.OperationStatus == handler.InProgress { 137 | return re.report(event, "Response error", errors.New("READ and LIST handlers must return synchronous"), invalidRequestError) 138 | } 139 | return r, nil 140 | } 141 | } 142 | 143 | // router decides which handler should be invoked based on the action 144 | // It will return a route or an error depending on the action passed in 145 | func router(a string, h Handler) (handlerFunc, cfnerr.Error) { 146 | // Figure out which action was called and have a "catch-all" 147 | switch a { 148 | case createAction: 149 | return h.Create, nil 150 | case readAction: 151 | return h.Read, nil 152 | case updateAction: 153 | return h.Update, nil 154 | case deleteAction: 155 | return h.Delete, nil 156 | case listAction: 157 | return h.List, nil 158 | default: 159 | // No action matched, we should fail and return an InvalidRequestErrorCode 160 | return nil, cfnerr.New(invalidRequestError, "No action/invalid action specified", nil) 161 | } 162 | } 163 | 164 | // Invoke handles the invocation of the handerFn. 165 | func invoke(handlerFn handlerFunc, request handler.Request, metricsPublisher *metrics.Publisher, action string) handler.ProgressEvent { 166 | 167 | // Create a channel to received a signal that work is done. 168 | ch := make(chan handler.ProgressEvent, 1) 169 | 170 | // Ask the goroutine to do some work for us. 171 | go func() { 172 | // start the timer 173 | s := time.Now() 174 | metricsPublisher.PublishInvocationMetric(time.Now(), string(action)) 175 | 176 | // Report the work is done. 177 | pe := handlerFn(request) 178 | log.Printf("Received event: %s\nMessage: %s\n", 179 | pe.OperationStatus, 180 | pe.Message, 181 | ) 182 | e := time.Since(s) 183 | metricsPublisher.PublishDurationMetric(time.Now(), string(action), e.Seconds()*1e3) 184 | ch <- pe 185 | }() 186 | return <-ch 187 | } 188 | 189 | func isMutatingAction(action string) bool { 190 | switch action { 191 | case createAction: 192 | return true 193 | case updateAction: 194 | return true 195 | case deleteAction: 196 | return true 197 | } 198 | return false 199 | } 200 | -------------------------------------------------------------------------------- /cfn/cfn_test.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "reflect" 10 | "testing" 11 | "time" 12 | 13 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 14 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 15 | "github.com/aws/aws-lambda-go/lambdacontext" 16 | "github.com/aws/aws-sdk-go/aws" 17 | "github.com/aws/aws-sdk-go/aws/session" 18 | "github.com/aws/aws-sdk-go/service/cloudformation" 19 | ) 20 | 21 | func TestMakeEventFunc(t *testing.T) { 22 | start := time.Now() 23 | future := start.Add(time.Minute * 15) 24 | 25 | tc, cancel := context.WithDeadline(context.Background(), future) 26 | 27 | defer cancel() 28 | 29 | lc := lambdacontext.NewContext(tc, &lambdacontext.LambdaContext{}) 30 | 31 | f1 := func(callback map[string]interface{}, s *session.Session) handler.ProgressEvent { 32 | return handler.ProgressEvent{} 33 | } 34 | 35 | f2 := func(callback map[string]interface{}, s *session.Session) handler.ProgressEvent { 36 | return handler.ProgressEvent{ 37 | OperationStatus: handler.InProgress, 38 | Message: "In Progress", 39 | CallbackDelaySeconds: 130, 40 | } 41 | } 42 | 43 | f3 := func(callback map[string]interface{}, s *session.Session) handler.ProgressEvent { 44 | return handler.ProgressEvent{ 45 | OperationStatus: handler.Failed, 46 | } 47 | } 48 | 49 | f4 := func(callback map[string]interface{}, s *session.Session) (response handler.ProgressEvent) { 50 | defer func() { 51 | // Catch any panics and return a failed ProgressEvent 52 | if r := recover(); r != nil { 53 | err, ok := r.(error) 54 | if !ok { 55 | err = errors.New(fmt.Sprint(r)) 56 | } 57 | 58 | response = handler.NewFailedEvent(err) 59 | } 60 | }() 61 | panic("error") 62 | } 63 | 64 | type args struct { 65 | h Handler 66 | ctx context.Context 67 | event *event 68 | } 69 | tests := []struct { 70 | name string 71 | args args 72 | want response 73 | wantErr bool 74 | }{ 75 | {"Test simple CREATE", args{&MockHandler{f1}, lc, loadEvent("request.create.json", &event{})}, response{ 76 | BearerToken: "123456", 77 | }, false}, 78 | {"Test CREATE failed", args{&MockHandler{f3}, lc, loadEvent("request.create.json", &event{})}, response{ 79 | OperationStatus: handler.Failed, 80 | BearerToken: "123456", 81 | }, false}, 82 | {"Test simple CREATE async", args{&MockHandler{f2}, lc, loadEvent("request.create.json", &event{})}, response{ 83 | BearerToken: "123456", 84 | Message: "In Progress", 85 | OperationStatus: handler.InProgress, 86 | CallbackDelaySeconds: 130, 87 | }, false}, 88 | {"Test READ async should return err", args{&MockHandler{f2}, lc, loadEvent("request.read.json", &event{})}, response{ 89 | OperationStatus: handler.Failed, 90 | }, true}, 91 | {"Test account number should not error", args{&MockHandler{f1}, context.Background(), loadEvent("request.read.invalid.validation.json", &event{})}, response{ 92 | BearerToken: "123456", 93 | }, false}, 94 | {"Test invalid Action", args{&MockHandler{f1}, context.Background(), loadEvent("request.invalid.json", &event{})}, response{ 95 | OperationStatus: handler.Failed, 96 | }, true}, 97 | {"Test wrap panic", args{&MockHandler{f4}, context.Background(), loadEvent("request.create.json", &event{})}, response{ 98 | OperationStatus: handler.Failed, 99 | ErrorCode: cloudformation.HandlerErrorCodeGeneralServiceException, 100 | Message: "Unable to complete request: error", 101 | BearerToken: "123456", 102 | }, false}, 103 | } 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | f := makeEventFunc(tt.args.h) 107 | 108 | got, err := f(tt.args.ctx, tt.args.event) 109 | 110 | if (err != nil) != tt.wantErr { 111 | t.Errorf("makeEventFunc() = %v, wantErr %v", err, tt.wantErr) 112 | return 113 | } 114 | 115 | switch tt.wantErr { 116 | case true: 117 | if tt.want.OperationStatus != got.OperationStatus { 118 | t.Errorf("response = %v; want %v", got.OperationStatus, tt.want.OperationStatus) 119 | } 120 | 121 | case false: 122 | if !reflect.DeepEqual(tt.want, got) { 123 | t.Errorf("response = %v; want %v", got, tt.want) 124 | } 125 | 126 | } 127 | 128 | }) 129 | } 130 | } 131 | 132 | // loadEvent is a helper function that unmarshal the event from a file. 133 | func loadEvent(path string, evt *event) *event { 134 | validevent, err := openFixture(path) 135 | if err != nil { 136 | log.Fatalf("Unable to read fixture: %v", err) 137 | } 138 | 139 | if err := json.Unmarshal(validevent, evt); err != nil { 140 | log.Fatalf("Marshaling error with event: %v", err) 141 | } 142 | return evt 143 | } 144 | 145 | func TestMakeEventFuncModel(t *testing.T) { 146 | start := time.Now() 147 | future := start.Add(time.Minute * 15) 148 | tc, cancel := context.WithDeadline(context.Background(), future) 149 | defer cancel() 150 | lc := lambdacontext.NewContext(tc, &lambdacontext.LambdaContext{}) 151 | f1 := func(r handler.Request) handler.ProgressEvent { 152 | m := MockModel{} 153 | if len(r.CallbackContext) == 1 { 154 | if err := r.Unmarshal(&m); err != nil { 155 | return handler.NewFailedEvent(err) 156 | } 157 | return handler.ProgressEvent{ 158 | OperationStatus: handler.Success, 159 | ResourceModel: &m, 160 | Message: "Success", 161 | } 162 | } 163 | if err := r.Unmarshal(&m); err != nil { 164 | return handler.NewFailedEvent(err) 165 | } 166 | m.Property2 = aws.String("change") 167 | return handler.ProgressEvent{ 168 | OperationStatus: handler.InProgress, 169 | Message: "In Progress", 170 | CallbackDelaySeconds: 3, 171 | CallbackContext: map[string]interface{}{"foo": "bar"}, 172 | ResourceModel: &m, 173 | } 174 | } 175 | type args struct { 176 | h Handler 177 | ctx context.Context 178 | event *event 179 | } 180 | tests := []struct { 181 | name string 182 | args args 183 | want MockModel 184 | }{ 185 | {"Test CREATE async local with model change", args{&MockModelHandler{f1}, lc, loadEvent("request.create2.json", &event{})}, MockModel{ 186 | Property1: aws.String("abc"), 187 | Property2: aws.String("change"), 188 | }}, 189 | } 190 | for _, tt := range tests { 191 | t.Run(tt.name, func(t *testing.T) { 192 | f := makeEventFunc(tt.args.h) 193 | got, err := f(tt.args.ctx, tt.args.event) 194 | if err != nil { 195 | t.Errorf("TestMakeEventFuncModel() = %v", err) 196 | return 197 | } 198 | model, err := encoding.Stringify(got.ResourceModel) 199 | if err != nil { 200 | t.Errorf("TestMakeEventFuncModel() = %v", err) 201 | } 202 | wantrModel, err := encoding.Stringify(tt.want) 203 | if err != nil { 204 | t.Errorf("TestMakeEventFuncModel() = %v", err) 205 | } 206 | if wantrModel != model { 207 | t.Errorf("response = %v; want %v", model, wantrModel) 208 | } 209 | 210 | }) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /cfn/cfnerr/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package cfnerr defines a custom error type used by the RPDK Go plugin. 3 | */ 4 | package cfnerr 5 | -------------------------------------------------------------------------------- /cfn/cfnerr/error.go: -------------------------------------------------------------------------------- 1 | package cfnerr 2 | 3 | // New base error 4 | func New(code string, message string, origErr error) Error { 5 | var errs []error 6 | if origErr != nil { 7 | errs = append(errs, origErr) 8 | } 9 | return newBaseError(code, message, errs) 10 | } 11 | 12 | // NewBatchError groups one or more errors together for processing 13 | func NewBatchError(code string, message string, origErrs []error) BatchedErrors { 14 | return newBaseError(code, message, origErrs) 15 | } 16 | 17 | // An Error wraps lower level errors with code, message and an original error. 18 | // The underlying concrete error type may also satisfy other interfaces which 19 | // can be to used to obtain more specific information about the error. 20 | // 21 | // Calling Error() or String() will always include the full information about 22 | // an error based on its underlying type. 23 | type Error interface { 24 | // inherit the base error interface 25 | error 26 | 27 | // Returns an error code 28 | Code() string 29 | 30 | // Returns the error message 31 | Message() string 32 | 33 | // Returns the original error 34 | OrigErr() error 35 | } 36 | 37 | // BatchedErrors is a batch of errors which also wraps lower level errors with 38 | // code, message, and original errors. Calling Error() will include all errors 39 | // that occurred in the batch. 40 | type BatchedErrors interface { 41 | error 42 | 43 | // Returns all original errors 44 | OrigErrs() []error 45 | } 46 | -------------------------------------------------------------------------------- /cfn/cfnerr/types.go: -------------------------------------------------------------------------------- 1 | package cfnerr 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // Sprint returns a string of the formatted error code. 9 | // 10 | // Both extra and origErr are optional. If they are included their lines 11 | // will be added, but if they are not included their lines will be ignored. 12 | func Sprint(code, message, extra string, origErr error) string { 13 | msg := fmt.Sprintf("%s: %s", code, message) 14 | if extra != "" { 15 | msg = fmt.Sprintf("%s\n\t%s", msg, extra) 16 | } 17 | if origErr != nil { 18 | msg = fmt.Sprintf("%s\ncaused by: %s", msg, origErr.Error()) 19 | } 20 | return msg 21 | } 22 | 23 | // A baseError wraps the code and message which defines an error. It also 24 | // can be used to wrap an original error object. 25 | // 26 | // Should be used as the root for errors satisfying the awserr.Error. Also 27 | // for any error which does not fit into a specific error wrapper type. 28 | type baseError struct { 29 | // Classification of error 30 | code string 31 | 32 | // Detailed information about error 33 | message string 34 | 35 | // Optional original error this error is based off of. Allows building 36 | // chained errors. 37 | errs []error 38 | } 39 | 40 | // newBaseError returns an error object for the code, message, and errors. 41 | // 42 | // code is a short no whitespace phrase depicting the classification of 43 | // the error that is being created. 44 | // 45 | // message is the free flow string containing detailed information about the 46 | // error. 47 | // 48 | // origErrs is the error objects which will be nested under the new errors to 49 | // be returned. 50 | func newBaseError(code, message string, origErrs []error) *baseError { 51 | b := &baseError{ 52 | code: code, 53 | message: message, 54 | errs: origErrs, 55 | } 56 | 57 | return b 58 | } 59 | 60 | // Error returns the string representation of the error. 61 | // 62 | // See ErrorWithExtra for formatting. 63 | // 64 | // Satisfies the error interface. 65 | func (b baseError) Error() string { 66 | size := len(b.errs) 67 | if size > 0 { 68 | return Sprint(b.code, b.message, "", errorList(b.errs)) 69 | } 70 | 71 | return Sprint(b.code, b.message, "", nil) 72 | } 73 | 74 | // String returns the string representation of the error. 75 | // Alias for Error to satisfy the stringer interface. 76 | func (b baseError) String() string { 77 | return b.Error() 78 | } 79 | 80 | // Code returns the short phrase depicting the classification of the error. 81 | func (b baseError) Code() string { 82 | return b.code 83 | } 84 | 85 | // Message returns the error details message. 86 | func (b baseError) Message() string { 87 | return b.message 88 | } 89 | 90 | // MarshalJSON returns the error as a JSON string. 91 | func (b baseError) MarshalJSON() ([]byte, error) { 92 | return json.Marshal(b.Code()) 93 | } 94 | 95 | // OrigErr returns the original error if one was set. Nil is returned if no 96 | // error was set. This only returns the first element in the list. If the full 97 | // list is needed, use BatchedErrors. 98 | func (b baseError) OrigErr() error { 99 | switch len(b.errs) { 100 | case 0: 101 | return nil 102 | case 1: 103 | return b.errs[0] 104 | default: 105 | if err, ok := b.errs[0].(Error); ok { 106 | return NewBatchError(err.Code(), err.Message(), b.errs[1:]) 107 | } 108 | return NewBatchError("BatchedErrors", 109 | "multiple errors occurred", b.errs) 110 | } 111 | } 112 | 113 | // OrigErrs returns the original errors if one was set. An empty slice is 114 | // returned if no error was set. 115 | func (b baseError) OrigErrs() []error { 116 | return b.errs 117 | } 118 | 119 | // So that the Error interface type can be included as an anonymous field 120 | // in the requestError struct and not conflict with the error.Error() method. 121 | // 122 | //nolint:all 123 | type cfnError Error 124 | 125 | // A requestError wraps a request or service error. 126 | // 127 | // Composed of baseError for code, message, and original error. 128 | // 129 | //nolint:all 130 | type requestError struct { 131 | cfnError 132 | statusCode int 133 | requestID string 134 | } 135 | 136 | // newRequestError returns a wrapped error with additional information for 137 | // request status code, and service requestID. 138 | // 139 | // Should be used to wrap all request which involve service requests. Even if 140 | // the request failed without a service response, but had an HTTP status code 141 | // that may be meaningful. 142 | // 143 | // Also wraps original errors via the baseError. 144 | // 145 | //nolint:all 146 | func newRequestError(err Error, statusCode int, requestID string) *requestError { 147 | return &requestError{ 148 | cfnError: err, 149 | statusCode: statusCode, 150 | requestID: requestID, 151 | } 152 | } 153 | 154 | // Error returns the string representation of the error. 155 | // Satisfies the error interface. 156 | // 157 | //nolint:all 158 | func (r requestError) Error() string { 159 | extra := fmt.Sprintf("status code: %d, request id: %s", 160 | r.statusCode, r.requestID) 161 | return Sprint(r.Code(), r.Message(), extra, r.OrigErr()) 162 | } 163 | 164 | // String returns the string representation of the error. 165 | // Alias for Error to satisfy the stringer interface. 166 | // 167 | //nolint:all 168 | func (r requestError) String() string { 169 | return r.Error() 170 | } 171 | 172 | // StatusCode returns the wrapped status code for the error 173 | // 174 | //nolint:all 175 | func (r requestError) StatusCode() int { 176 | return r.statusCode 177 | } 178 | 179 | // RequestID returns the wrapped requestID 180 | // 181 | //nolint:all 182 | func (r requestError) RequestID() string { 183 | return r.requestID 184 | } 185 | 186 | // OrigErrs returns the original errors if one was set. An empty slice is 187 | // returned if no error was set. 188 | // 189 | //nolint:all 190 | func (r requestError) OrigErrs() []error { 191 | if b, ok := r.cfnError.(BatchedErrors); ok { 192 | return b.OrigErrs() 193 | } 194 | return []error{r.OrigErr()} 195 | } 196 | 197 | // An error list that satisfies the golang interface 198 | type errorList []error 199 | 200 | // Error returns the string representation of the error. 201 | // 202 | // Satisfies the error interface. 203 | func (e errorList) Error() string { 204 | msg := "" 205 | // How do we want to handle the array size being zero 206 | if size := len(e); size > 0 { 207 | for i := 0; i < size; i++ { 208 | msg += e[i].Error() 209 | // We check the next index to see if it is within the slice. 210 | // If it is, then we append a newline. We do this, because unit tests 211 | // could be broken with the additional '\n' 212 | if i+1 < size { 213 | msg += "\n" 214 | } 215 | } 216 | } 217 | return msg 218 | } 219 | -------------------------------------------------------------------------------- /cfn/context.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 8 | "github.com/aws/aws-sdk-go/aws/session" 9 | ) 10 | 11 | // contextKey is used to prevent collisions within the context package 12 | // and to guarantee returning the correct values from a context 13 | type contextKey string 14 | 15 | // callbackContextValues is used to guarantee the type of 16 | // values stored in the context 17 | type callbackContextValues map[string]interface{} 18 | 19 | const ( 20 | valuesKey = contextKey("user_callback_context") 21 | sessionKey = contextKey("aws_session") 22 | ) 23 | 24 | // SetContextValues creates a context to pass to handlers 25 | func SetContextValues(ctx context.Context, values map[string]interface{}) context.Context { 26 | return context.WithValue(ctx, valuesKey, callbackContextValues(values)) 27 | } 28 | 29 | // GetContextValues unwraps callbackContextValues from a given context 30 | func GetContextValues(ctx context.Context) (map[string]interface{}, error) { 31 | values, ok := ctx.Value(valuesKey).(callbackContextValues) 32 | if !ok { 33 | return nil, fmt.Errorf("Values not found") 34 | } 35 | 36 | return map[string]interface{}(values), nil 37 | } 38 | 39 | // SetContextSession adds the supplied session to the given context 40 | func SetContextSession(ctx context.Context, sess *session.Session) context.Context { 41 | return context.WithValue(ctx, sessionKey, sess) 42 | } 43 | 44 | // GetContextSession unwraps a session from a given context 45 | func GetContextSession(ctx context.Context) (*session.Session, error) { 46 | val, ok := ctx.Value(sessionKey).(*session.Session) 47 | if !ok { 48 | return nil, fmt.Errorf("Session not found") 49 | } 50 | 51 | return val, nil 52 | } 53 | 54 | // marshalCallback allows for a handler.ProgressEvent to be parsed into something 55 | // the RPDK can use to reinvoke the resource provider with the same context. 56 | // 57 | //nolint:all 58 | func marshalCallback(pevt *handler.ProgressEvent) (map[string]interface{}, int64) { 59 | return map[string]interface{}(pevt.CallbackContext), pevt.CallbackDelaySeconds 60 | } 61 | -------------------------------------------------------------------------------- /cfn/credentials/credentials.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package credentials providers helper functions for dealing with AWS credentials 3 | passed in to resource providers from CloudFormation. 4 | */ 5 | package credentials 6 | 7 | import ( 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/credentials" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | ) 12 | 13 | // CloudFormationCredentialsProviderName ... 14 | const CloudFormationCredentialsProviderName = "CloudFormationCredentialsProvider" 15 | 16 | const InvalidSessionError = "InvalidSession" 17 | 18 | // NewProvider ... 19 | func NewProvider(accessKeyID string, secretAccessKey string, sessionToken string) credentials.Provider { 20 | return &CloudFormationCredentialsProvider{ 21 | AccessKeyID: accessKeyID, 22 | SecretAccessKey: secretAccessKey, 23 | SessionToken: sessionToken, 24 | } 25 | } 26 | 27 | // CloudFormationCredentialsProvider ... 28 | type CloudFormationCredentialsProvider struct { 29 | retrieved bool 30 | 31 | // AccessKeyID ... 32 | AccessKeyID string `json:"accessKeyId"` 33 | 34 | // SecretAccessKey ... 35 | SecretAccessKey string `json:"secretAccessKey"` 36 | 37 | // SessionToken ... 38 | SessionToken string `json:"sessionToken"` 39 | } 40 | 41 | // Retrieve ... 42 | func (c *CloudFormationCredentialsProvider) Retrieve() (credentials.Value, error) { 43 | c.retrieved = false 44 | 45 | value := credentials.Value{ 46 | AccessKeyID: c.AccessKeyID, 47 | SecretAccessKey: c.SecretAccessKey, 48 | SessionToken: c.SessionToken, 49 | ProviderName: CloudFormationCredentialsProviderName, 50 | } 51 | 52 | c.retrieved = true 53 | 54 | return value, nil 55 | } 56 | 57 | // IsExpired ... 58 | func (c *CloudFormationCredentialsProvider) IsExpired() bool { 59 | return false 60 | } 61 | 62 | // SessionFromCredentialsProvider creates a new AWS SDK session from a credentials provider 63 | // 64 | // A credentials provider is an interface in the AWS SDK's credentials package (aws/credentials) 65 | // We transform it into a session for later use in the RPDK 66 | func SessionFromCredentialsProvider(provider credentials.Provider) *session.Session { 67 | creds := credentials.NewCredentials(provider) 68 | 69 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 70 | Config: aws.Config{ 71 | Credentials: creds, 72 | }, 73 | })) 74 | 75 | return sess 76 | } 77 | -------------------------------------------------------------------------------- /cfn/credentials/credentials_test.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import "testing" 4 | 5 | func TestCredentials(t *testing.T) { 6 | t.Run("New", func(t *testing.T) { 7 | creds := NewProvider("a", "b", "c") 8 | 9 | val, err := creds.Retrieve() 10 | if err != nil { 11 | t.Fatalf("Unable to retrieve credentials: %v", err) 12 | } 13 | if val.AccessKeyID != "a" { 14 | t.Fatalf("Incorrect access key: %v", val.AccessKeyID) 15 | } 16 | }) 17 | 18 | t.Run("Expired", func(t *testing.T) { 19 | creds := NewProvider("a", "b", "c") 20 | 21 | if creds.IsExpired() != false { 22 | t.Fatalf("Credentials should never expire") 23 | } 24 | }) 25 | } 26 | 27 | func TestSessionFromCredentialsProvider(t *testing.T) { 28 | t.Run("Happy Path", func(t *testing.T) { 29 | creds := NewProvider("a", "b", "c") 30 | sess := SessionFromCredentialsProvider(creds) 31 | 32 | if sess == nil { 33 | t.Fatalf("Unable to create session") 34 | } 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /cfn/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package cfn contains functionality to handle 3 | CloudFormation resource providers imeplement in Go. 4 | */ 5 | package cfn 6 | -------------------------------------------------------------------------------- /cfn/encoding/encoding.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package encoding defines types and functions used for dealing with stringified-JSON. 3 | */ 4 | package encoding 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "strconv" 10 | ) 11 | 12 | // String is a string type to be used when the default json marshaler/unmarshaler cannot be avoided 13 | type String string 14 | 15 | func NewString(ss string) *String { 16 | s := String(ss) 17 | return &s 18 | } 19 | 20 | func (s *String) Value() *string { 21 | return (*string)(s) 22 | } 23 | 24 | func (s String) MarshalJSON() ([]byte, error) { 25 | return json.Marshal(string(s)) 26 | } 27 | 28 | func (s *String) UnmarshalJSON(data []byte) error { 29 | var ss string 30 | err := json.Unmarshal(data, &ss) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | *s = String(ss) 36 | return nil 37 | } 38 | 39 | // Bool is a bool type to be used when the default json marshaler/unmarshaler cannot be avoided 40 | type Bool bool 41 | 42 | func NewBool(bb bool) *Bool { 43 | b := Bool(bb) 44 | return &b 45 | } 46 | 47 | func (b *Bool) Value() *bool { 48 | return (*bool)(b) 49 | } 50 | 51 | func (b Bool) MarshalJSON() ([]byte, error) { 52 | return json.Marshal(fmt.Sprint(bool(b))) 53 | } 54 | 55 | func (b *Bool) UnmarshalJSON(data []byte) error { 56 | var s string 57 | err := json.Unmarshal(data, &s) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | val, err := strconv.ParseBool(s) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | *b = Bool(val) 68 | return nil 69 | } 70 | 71 | // Int is an int type to be used when the default json marshaler/unmarshaler cannot be avoided 72 | type Int int64 73 | 74 | func NewInt(ii int64) *Int { 75 | i := Int(ii) 76 | return &i 77 | } 78 | 79 | func (i *Int) Value() *int64 { 80 | return (*int64)(i) 81 | } 82 | 83 | func (i Int) MarshalJSON() ([]byte, error) { 84 | return json.Marshal(fmt.Sprint(int64(i))) 85 | } 86 | 87 | func (i *Int) UnmarshalJSON(data []byte) error { 88 | var s string 89 | err := json.Unmarshal(data, &s) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | val, err := strconv.ParseInt(s, 0, 64) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | *i = Int(val) 100 | return nil 101 | } 102 | 103 | // Float is a float type to be used when the default json marshaler/unmarshaler cannot be avoided 104 | type Float float64 105 | 106 | func NewFloat(ff float64) *Float { 107 | f := Float(ff) 108 | return &f 109 | } 110 | 111 | func (f *Float) Value() *float64 { 112 | return (*float64)(f) 113 | } 114 | 115 | func (f Float) MarshalJSON() ([]byte, error) { 116 | return json.Marshal(fmt.Sprint(float64(f))) 117 | } 118 | 119 | func (f *Float) UnmarshalJSON(data []byte) error { 120 | var s string 121 | err := json.Unmarshal(data, &s) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | val, err := strconv.ParseFloat(s, 64) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | *f = Float(val) 132 | return nil 133 | } 134 | -------------------------------------------------------------------------------- /cfn/encoding/encoding_test.go: -------------------------------------------------------------------------------- 1 | package encoding_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | ) 13 | 14 | func TestEncoding(t *testing.T) { 15 | type Nested struct { 16 | SP *string `json:",omitempty"` 17 | BP *bool `json:",omitempty"` 18 | IP *int `json:"intField,omitempty"` 19 | FP *float64 `json:"floatPointer,omitempty"` 20 | 21 | S string `json:"stringValue,omitempty"` 22 | B bool `json:",omitempty"` 23 | I int 24 | F float64 `json:",omitempty"` 25 | } 26 | 27 | type Main struct { 28 | SP *string 29 | BP *bool `json:",omitempty"` 30 | IP *int `json:",omitempty"` 31 | FP *float64 `json:",omitempty"` 32 | NP *Nested `json:"nestedPointer,omitempty"` 33 | 34 | S string `json:",omitempty"` 35 | B bool `json:"boolValue,omitempty"` 36 | I int `json:",omitempty"` 37 | F float64 38 | N Nested `json:",omitempty"` 39 | } 40 | 41 | m := Main{ 42 | SP: aws.String("foo"), 43 | IP: aws.Int(42), 44 | NP: &Nested{ 45 | BP: aws.Bool(true), 46 | FP: aws.Float64(3.14), 47 | }, 48 | 49 | B: true, 50 | F: 2.72, 51 | N: Nested{ 52 | S: "bar", 53 | I: 54, 54 | }, 55 | } 56 | 57 | stringMap := map[string]interface{}{ 58 | "SP": "foo", 59 | "IP": "42", 60 | "nestedPointer": map[string]interface{}{ 61 | "BP": "true", 62 | "I": "0", 63 | "floatPointer": "3.14", 64 | }, 65 | 66 | "boolValue": "true", 67 | "F": "2.72", 68 | "N": map[string]interface{}{ 69 | "stringValue": "bar", 70 | "I": "54", 71 | }, 72 | } 73 | 74 | var err error 75 | 76 | rep, err := encoding.Marshal(m) 77 | if err != nil { 78 | t.Errorf("Unexpected error: %v", err) 79 | } 80 | 81 | // Test that rep can be unmarshalled as regular JSON 82 | var jsonTest map[string]interface{} 83 | err = json.Unmarshal(rep, &jsonTest) 84 | if err != nil { 85 | t.Errorf("Unexpected error: %v", err) 86 | } 87 | 88 | // And check it matches the expected form 89 | if diff := cmp.Diff(jsonTest, stringMap); diff != "" { 90 | t.Errorf(diff) 91 | } 92 | 93 | // Now check we can get the original struct back 94 | var b Main 95 | err = encoding.Unmarshal(rep, &b) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | if diff := cmp.Diff(m, b); diff != "" { 101 | t.Errorf(diff) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /cfn/encoding/marshal.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Marshal converts a value into stringified-JSON 8 | func Marshal(v interface{}) ([]byte, error) { 9 | stringified, err := Stringify(v) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | return json.Marshal(stringified) 15 | } 16 | -------------------------------------------------------------------------------- /cfn/encoding/marshal_test.go: -------------------------------------------------------------------------------- 1 | package encoding_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | ) 13 | 14 | func TestMarshaling(t *testing.T) { 15 | type Nested struct { 16 | SP *string `json:",omitempty"` 17 | BP *bool `json:",omitempty"` 18 | IP *int `json:"intField,omitempty"` 19 | FP *float64 `json:"floatPointer,omitempty"` 20 | 21 | S string `json:"stringValue,omitempty"` 22 | B bool `json:",omitempty"` 23 | I int 24 | F float64 `json:",omitempty"` 25 | } 26 | 27 | type Main struct { 28 | SP *string 29 | BP *bool `json:",omitempty"` 30 | IP *int `json:",omitempty"` 31 | FP *float64 `json:",omitempty"` 32 | NP *Nested `json:"nestedPointer,omitempty"` 33 | 34 | S string `json:",omitempty"` 35 | B bool `json:"boolValue,omitempty"` 36 | I int `json:",omitempty"` 37 | F float64 38 | N Nested `json:",omitempty"` 39 | } 40 | 41 | m := Main{ 42 | SP: aws.String("foo"), 43 | IP: aws.Int(42), 44 | NP: &Nested{ 45 | BP: aws.Bool(true), 46 | FP: aws.Float64(3.14), 47 | }, 48 | 49 | B: true, 50 | F: 2.72, 51 | N: Nested{ 52 | S: "bar", 53 | I: 54, 54 | }, 55 | } 56 | 57 | stringMap := map[string]interface{}{ 58 | "SP": "foo", 59 | "IP": "42", 60 | "nestedPointer": map[string]interface{}{ 61 | "BP": "true", 62 | "I": "0", 63 | "floatPointer": "3.14", 64 | }, 65 | 66 | "boolValue": "true", 67 | "F": "2.72", 68 | "N": map[string]interface{}{ 69 | "stringValue": "bar", 70 | "I": "54", 71 | }, 72 | } 73 | 74 | var err error 75 | 76 | rep, err := encoding.Marshal(m) 77 | if err != nil { 78 | t.Errorf("Unexpected error: %v", err) 79 | } 80 | 81 | // Test that rep can be unmarshalled as regular JSON 82 | var jsonTest map[string]interface{} 83 | err = json.Unmarshal(rep, &jsonTest) 84 | if err != nil { 85 | t.Errorf("Unexpected error: %v", err) 86 | } 87 | 88 | // And check it matches the expected form 89 | if diff := cmp.Diff(jsonTest, stringMap); diff != "" { 90 | t.Errorf(diff) 91 | } 92 | 93 | // Now check we can get the original struct back 94 | var b Main 95 | err = encoding.Unmarshal(rep, &b) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | if diff := cmp.Diff(m, b); diff != "" { 101 | t.Errorf(diff) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /cfn/encoding/stringify.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | func stringifyType(t reflect.Type) reflect.Type { 10 | switch t.Kind() { 11 | case reflect.Map: 12 | return reflect.MapOf(stringType, interfaceType) 13 | case reflect.Slice: 14 | return reflect.SliceOf(interfaceType) 15 | case reflect.Struct: 16 | return stringifyStructType(t) 17 | case reflect.Ptr: 18 | return stringifyType(t.Elem()) 19 | default: 20 | return stringType 21 | } 22 | } 23 | 24 | func stringifyStructType(t reflect.Type) reflect.Type { 25 | fields := make([]reflect.StructField, t.NumField()) 26 | 27 | for i := 0; i < t.NumField(); i++ { 28 | f := t.Field(i) 29 | 30 | fields[i] = reflect.StructField{ 31 | Name: f.Name, 32 | Type: stringifyType(f.Type), 33 | Tag: f.Tag, 34 | } 35 | } 36 | 37 | return reflect.StructOf(fields) 38 | } 39 | 40 | // Stringify converts any supported type into a stringified value 41 | func Stringify(v interface{}) (interface{}, error) { 42 | var err error 43 | 44 | if v == nil { 45 | return nil, nil 46 | } 47 | 48 | val := reflect.ValueOf(v) 49 | 50 | switch val.Kind() { 51 | case reflect.String, reflect.Bool, reflect.Int, reflect.Float64: 52 | return fmt.Sprint(v), nil 53 | case reflect.Map: 54 | out := make(map[string]interface{}) 55 | for _, key := range val.MapKeys() { 56 | v, err := Stringify(val.MapIndex(key).Interface()) 57 | switch { 58 | case err != nil: 59 | return nil, err 60 | case v != nil: 61 | out[key.String()] = v 62 | } 63 | } 64 | return out, nil 65 | case reflect.Slice: 66 | out := make([]interface{}, val.Len()) 67 | for i := 0; i < val.Len(); i++ { 68 | v, err = Stringify(val.Index(i).Interface()) 69 | switch { 70 | case err != nil: 71 | return nil, err 72 | case v != nil: 73 | out[i] = v 74 | } 75 | } 76 | return out, nil 77 | case reflect.Struct: 78 | t := val.Type() 79 | 80 | out := reflect.New(stringifyStructType(t)).Elem() 81 | for i := 0; i < t.NumField(); i++ { 82 | f := t.Field(i) 83 | 84 | v := val.FieldByName(f.Name) 85 | 86 | if tag, ok := f.Tag.Lookup("json"); ok { 87 | if strings.Contains(tag, ",omitempty") { 88 | if v.IsZero() { 89 | continue 90 | } 91 | } 92 | } 93 | 94 | s, err := Stringify(v.Interface()) 95 | switch { 96 | case err != nil: 97 | return nil, err 98 | case s != nil: 99 | out.Field(i).Set(reflect.ValueOf(s)) 100 | } 101 | } 102 | 103 | return out.Interface(), nil 104 | case reflect.Ptr: 105 | if val.IsNil() { 106 | return nil, nil 107 | } 108 | 109 | return Stringify(val.Elem().Interface()) 110 | } 111 | 112 | return nil, fmt.Errorf("Unsupported type: '%v'", val.Kind()) 113 | } 114 | -------------------------------------------------------------------------------- /cfn/encoding/stringify_test.go: -------------------------------------------------------------------------------- 1 | package encoding_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestStringifyTypes(t *testing.T) { 12 | type Struct struct { 13 | S string 14 | } 15 | 16 | s := "foo" 17 | b := true 18 | i := 42 19 | f := 3.14 20 | l := []interface{}{s, b, i, f} 21 | m := map[string]interface{}{ 22 | "l": l, 23 | } 24 | o := Struct{S: s} 25 | var nilPointer *Struct 26 | 27 | for _, testCase := range []struct { 28 | data interface{} 29 | expected interface{} 30 | }{ 31 | // Basic types 32 | {s, "foo"}, 33 | {b, "true"}, 34 | {i, "42"}, 35 | {f, "3.14"}, 36 | {l, []interface{}{"foo", "true", "42", "3.14"}}, 37 | {m, map[string]interface{}{"l": []interface{}{"foo", "true", "42", "3.14"}}}, 38 | {o, struct{ S string }{S: "foo"}}, 39 | 40 | // Pointers 41 | {&s, "foo"}, 42 | {&b, "true"}, 43 | {&i, "42"}, 44 | {&f, "3.14"}, 45 | {&l, []interface{}{"foo", "true", "42", "3.14"}}, 46 | {&m, map[string]interface{}{"l": []interface{}{"foo", "true", "42", "3.14"}}}, 47 | {&o, struct{ S string }{S: "foo"}}, 48 | 49 | // Nils are stripped 50 | {map[string]interface{}{"foo": nil}, map[string]interface{}{}}, 51 | 52 | // Nil pointers are nil 53 | {nilPointer, nil}, 54 | 55 | // Nils are nil 56 | {nil, nil}, 57 | } { 58 | actual, err := encoding.Stringify(testCase.data) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | if d := cmp.Diff(actual, testCase.expected); d != "" { 64 | t.Errorf(d) 65 | } 66 | } 67 | } 68 | 69 | func TestStringifyModel(t *testing.T) { 70 | type Model struct { 71 | BucketName *string 72 | Key *string 73 | Body *string 74 | IsBase64Encoded *bool 75 | ContentType *string 76 | ContentLength *int 77 | ACL *string 78 | Grants map[string][]string 79 | } 80 | 81 | m := Model{ 82 | BucketName: aws.String("foo"), 83 | Key: aws.String("bar"), 84 | Body: aws.String("baz"), 85 | ContentType: aws.String("quux"), 86 | ACL: aws.String("mooz"), 87 | } 88 | 89 | expected := struct { 90 | BucketName string 91 | Key string 92 | Body string 93 | IsBase64Encoded string 94 | ContentType string 95 | ContentLength string 96 | ACL string 97 | Grants map[string]interface{} 98 | }{ 99 | BucketName: "foo", 100 | Key: "bar", 101 | Body: "baz", 102 | ContentType: "quux", 103 | ACL: "mooz", 104 | Grants: map[string]interface{}{}, 105 | } 106 | 107 | actual, err := encoding.Stringify(m) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | 112 | if d := cmp.Diff(actual, expected); d != "" { 113 | t.Errorf(d) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /cfn/encoding/unmarshal.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Unmarshal converts stringified-JSON into the passed-in type 8 | func Unmarshal(data []byte, v interface{}) error { 9 | var dataMap map[string]interface{} 10 | var err error 11 | err = json.Unmarshal(data, &dataMap) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | err = Unstringify(dataMap, v) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /cfn/encoding/unstringify.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func convertStruct(i interface{}, t reflect.Type, pointer bool) (reflect.Value, error) { 11 | m, ok := i.(map[string]interface{}) 12 | if !ok { 13 | return zeroValue, fmt.Errorf("Cannot convert %T to struct", i) 14 | } 15 | 16 | out := reflect.New(t) 17 | 18 | err := Unstringify(m, out.Interface()) 19 | if err != nil { 20 | return zeroValue, err 21 | } 22 | 23 | if !pointer { 24 | out = out.Elem() 25 | } 26 | 27 | return out, nil 28 | } 29 | 30 | func convertSlice(i interface{}, t reflect.Type, pointer bool) (reflect.Value, error) { 31 | s, ok := i.([]interface{}) 32 | if !ok { 33 | return zeroValue, fmt.Errorf("Cannot convert %T to slice", i) 34 | } 35 | 36 | out := reflect.New(t) 37 | out.Elem().Set(reflect.MakeSlice(t, len(s), len(s))) 38 | 39 | for j, v := range s { 40 | val, err := convertType(t.Elem(), v) 41 | if err != nil { 42 | return zeroValue, err 43 | } 44 | 45 | out.Elem().Index(j).Set(val) 46 | } 47 | 48 | if !pointer { 49 | out = out.Elem() 50 | } 51 | 52 | return out, nil 53 | } 54 | 55 | func convertMap(i interface{}, t reflect.Type, pointer bool) (reflect.Value, error) { 56 | m, ok := i.(map[string]interface{}) 57 | if !ok { 58 | return zeroValue, fmt.Errorf("Cannot convert %T to map with string keys", i) 59 | } 60 | 61 | out := reflect.New(t) 62 | out.Elem().Set(reflect.MakeMap(t)) 63 | 64 | for k, v := range m { 65 | val, err := convertType(t.Elem(), v) 66 | if err != nil { 67 | return zeroValue, err 68 | } 69 | 70 | out.Elem().SetMapIndex(reflect.ValueOf(k), val) 71 | } 72 | 73 | if !pointer { 74 | out = out.Elem() 75 | } 76 | 77 | return out, nil 78 | } 79 | 80 | func convertString(i interface{}, pointer bool) (reflect.Value, error) { 81 | s, ok := i.(string) 82 | 83 | if !ok { 84 | return zeroValue, fmt.Errorf("Cannot convert %T to string", i) 85 | } 86 | 87 | if pointer { 88 | return reflect.ValueOf(&s), nil 89 | } 90 | 91 | return reflect.ValueOf(s), nil 92 | } 93 | 94 | func convertBool(i interface{}, pointer bool) (reflect.Value, error) { 95 | var b bool 96 | var err error 97 | 98 | switch v := i.(type) { 99 | case bool: 100 | b = v 101 | 102 | case string: 103 | b, err = strconv.ParseBool(v) 104 | if err != nil { 105 | return zeroValue, err 106 | } 107 | 108 | default: 109 | return zeroValue, fmt.Errorf("Cannot convert %T to bool", i) 110 | } 111 | 112 | if pointer { 113 | return reflect.ValueOf(&b), nil 114 | } 115 | 116 | return reflect.ValueOf(b), nil 117 | } 118 | 119 | func convertInt(i interface{}, pointer bool) (reflect.Value, error) { 120 | var n int 121 | 122 | switch v := i.(type) { 123 | case int: 124 | n = v 125 | 126 | case float64: 127 | n = int(v) 128 | 129 | case string: 130 | n64, err := strconv.ParseInt(v, 0, 32) 131 | if err != nil { 132 | return zeroValue, err 133 | } 134 | 135 | n = int(n64) 136 | 137 | default: 138 | return zeroValue, fmt.Errorf("Cannot convert %T to bool", i) 139 | } 140 | 141 | if pointer { 142 | return reflect.ValueOf(&n), nil 143 | } 144 | 145 | return reflect.ValueOf(n), nil 146 | } 147 | 148 | func convertFloat64(i interface{}, pointer bool) (reflect.Value, error) { 149 | var f float64 150 | var err error 151 | 152 | switch v := i.(type) { 153 | case float64: 154 | f = v 155 | 156 | case int: 157 | f = float64(v) 158 | 159 | case string: 160 | f, err = strconv.ParseFloat(v, 64) 161 | if err != nil { 162 | return zeroValue, err 163 | } 164 | 165 | default: 166 | return zeroValue, fmt.Errorf("Cannot convert %T to bool", i) 167 | } 168 | 169 | if pointer { 170 | return reflect.ValueOf(&f), nil 171 | } 172 | 173 | return reflect.ValueOf(f), nil 174 | } 175 | 176 | func convertType(t reflect.Type, i interface{}) (reflect.Value, error) { 177 | pointer := false 178 | if t.Kind() == reflect.Ptr { 179 | pointer = true 180 | t = t.Elem() 181 | } 182 | 183 | switch t.Kind() { 184 | case reflect.Struct: 185 | return convertStruct(i, t, pointer) 186 | 187 | case reflect.Slice: 188 | return convertSlice(i, t, pointer) 189 | 190 | case reflect.Map: 191 | return convertMap(i, t, pointer) 192 | 193 | case reflect.String: 194 | return convertString(i, pointer) 195 | 196 | case reflect.Bool: 197 | return convertBool(i, pointer) 198 | 199 | case reflect.Int: 200 | return convertInt(i, pointer) 201 | 202 | case reflect.Float64: 203 | return convertFloat64(i, pointer) 204 | 205 | default: 206 | return zeroValue, fmt.Errorf("Unsupported type %v", t) 207 | } 208 | } 209 | 210 | // Unstringify takes a stringified representation of a value 211 | // and populates it into the supplied interface 212 | func Unstringify(data map[string]interface{}, v interface{}) error { 213 | t := reflect.TypeOf(v).Elem() 214 | 215 | val := reflect.ValueOf(v).Elem() 216 | 217 | for i := 0; i < t.NumField(); i++ { 218 | f := t.Field(i) 219 | 220 | jsonName := f.Name 221 | jsonTag := strings.Split(f.Tag.Get("json"), ",") 222 | if len(jsonTag) > 0 && jsonTag[0] != "" { 223 | jsonName = jsonTag[0] 224 | } 225 | 226 | if value, ok := data[jsonName]; ok { 227 | newValue, err := convertType(f.Type, value) 228 | if err != nil { 229 | return err 230 | } 231 | 232 | val.FieldByName(f.Name).Set(newValue) 233 | } 234 | } 235 | 236 | return nil 237 | } 238 | -------------------------------------------------------------------------------- /cfn/encoding/unstringify_test.go: -------------------------------------------------------------------------------- 1 | package encoding_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestUnstringifyStruct(t *testing.T) { 12 | type Model struct { 13 | S string 14 | SP *string 15 | B bool 16 | BP *bool 17 | I int 18 | IP *int 19 | F float64 20 | FP *float64 21 | } 22 | 23 | expected := Model{ 24 | S: "foo", 25 | SP: aws.String("bar"), 26 | B: true, 27 | BP: aws.Bool(true), 28 | I: 42, 29 | IP: aws.Int(42), 30 | F: 3.14, 31 | FP: aws.Float64(22), 32 | } 33 | 34 | t.Run("Convert strings", func(t *testing.T) { 35 | var actual Model 36 | 37 | err := encoding.Unstringify(map[string]interface{}{ 38 | "S": "foo", 39 | "SP": "bar", 40 | "B": "true", 41 | "BP": "true", 42 | "I": "42", 43 | "IP": "42", 44 | "F": "3.14", 45 | "FP": "22", 46 | }, &actual) 47 | 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | if d := cmp.Diff(actual, expected); d != "" { 53 | t.Error(d) 54 | } 55 | }) 56 | 57 | t.Run("Original types", func(t *testing.T) { 58 | var actual Model 59 | 60 | err := encoding.Unstringify(map[string]interface{}{ 61 | "S": "foo", 62 | "SP": "bar", 63 | "B": true, 64 | "BP": true, 65 | "I": 42, 66 | "IP": 42, 67 | "F": 3.14, 68 | "FP": 22.0, 69 | }, &actual) 70 | 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | if d := cmp.Diff(actual, expected); d != "" { 76 | t.Error(d) 77 | } 78 | }) 79 | 80 | t.Run("Compatible types", func(t *testing.T) { 81 | var actual Model 82 | 83 | err := encoding.Unstringify(map[string]interface{}{ 84 | "S": "foo", 85 | "SP": "bar", 86 | "B": true, 87 | "BP": true, 88 | "I": float64(42), 89 | "IP": float64(42), 90 | "F": 3.14, 91 | "FP": int(22), 92 | }, &actual) 93 | 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | 98 | if d := cmp.Diff(actual, expected); d != "" { 99 | t.Error(d) 100 | } 101 | }) 102 | } 103 | 104 | func TestUnstringifySlices(t *testing.T) { 105 | type Model struct { 106 | S []string 107 | SP []*string 108 | B []bool 109 | BP []*bool 110 | I []int 111 | IP []*int 112 | F []float64 113 | FP []*float64 114 | } 115 | 116 | expected := Model{ 117 | S: []string{"foo"}, 118 | SP: []*string{aws.String("bar")}, 119 | B: []bool{true}, 120 | BP: []*bool{aws.Bool(true)}, 121 | I: []int{42}, 122 | IP: []*int{aws.Int(42)}, 123 | F: []float64{3.14}, 124 | FP: []*float64{aws.Float64(22)}, 125 | } 126 | 127 | t.Run("Convert strings", func(t *testing.T) { 128 | var actual Model 129 | 130 | err := encoding.Unstringify(map[string]interface{}{ 131 | "S": []interface{}{"foo"}, 132 | "SP": []interface{}{"bar"}, 133 | "B": []interface{}{"true"}, 134 | "BP": []interface{}{"true"}, 135 | "I": []interface{}{"42"}, 136 | "IP": []interface{}{"42"}, 137 | "F": []interface{}{"3.14"}, 138 | "FP": []interface{}{"22"}, 139 | }, &actual) 140 | 141 | if err != nil { 142 | t.Fatal(err) 143 | } 144 | 145 | if d := cmp.Diff(actual, expected); d != "" { 146 | t.Error(d) 147 | } 148 | }) 149 | 150 | t.Run("Original types", func(t *testing.T) { 151 | var actual Model 152 | 153 | err := encoding.Unstringify(map[string]interface{}{ 154 | "S": []interface{}{"foo"}, 155 | "SP": []interface{}{"bar"}, 156 | "B": []interface{}{true}, 157 | "BP": []interface{}{true}, 158 | "I": []interface{}{42}, 159 | "IP": []interface{}{42}, 160 | "F": []interface{}{3.14}, 161 | "FP": []interface{}{22.0}, 162 | }, &actual) 163 | 164 | if err != nil { 165 | t.Fatal(err) 166 | } 167 | 168 | if d := cmp.Diff(actual, expected); d != "" { 169 | t.Error(d) 170 | } 171 | }) 172 | 173 | t.Run("Compatible types", func(t *testing.T) { 174 | var actual Model 175 | 176 | err := encoding.Unstringify(map[string]interface{}{ 177 | "S": []interface{}{"foo"}, 178 | "SP": []interface{}{"bar"}, 179 | "B": []interface{}{true}, 180 | "BP": []interface{}{true}, 181 | "I": []interface{}{float64(42)}, 182 | "IP": []interface{}{float64(42)}, 183 | "F": []interface{}{3.14}, 184 | "FP": []interface{}{int(22)}, 185 | }, &actual) 186 | 187 | if err != nil { 188 | t.Fatal(err) 189 | } 190 | 191 | if d := cmp.Diff(actual, expected); d != "" { 192 | t.Error(d) 193 | } 194 | }) 195 | } 196 | 197 | func TestUnstringifyMaps(t *testing.T) { 198 | type Model struct { 199 | S map[string]string 200 | SP map[string]*string 201 | B map[string]bool 202 | BP map[string]*bool 203 | I map[string]int 204 | IP map[string]*int 205 | F map[string]float64 206 | FP map[string]*float64 207 | } 208 | 209 | expected := Model{ 210 | S: map[string]string{"Val": "foo"}, 211 | SP: map[string]*string{"Val": aws.String("bar")}, 212 | B: map[string]bool{"Val": true}, 213 | BP: map[string]*bool{"Val": aws.Bool(true)}, 214 | I: map[string]int{"Val": 42}, 215 | IP: map[string]*int{"Val": aws.Int(42)}, 216 | F: map[string]float64{"Val": 3.14}, 217 | FP: map[string]*float64{"Val": aws.Float64(22)}, 218 | } 219 | 220 | t.Run("Convert strings", func(t *testing.T) { 221 | var actual Model 222 | 223 | err := encoding.Unstringify(map[string]interface{}{ 224 | "S": map[string]interface{}{"Val": "foo"}, 225 | "SP": map[string]interface{}{"Val": "bar"}, 226 | "B": map[string]interface{}{"Val": "true"}, 227 | "BP": map[string]interface{}{"Val": "true"}, 228 | "I": map[string]interface{}{"Val": "42"}, 229 | "IP": map[string]interface{}{"Val": "42"}, 230 | "F": map[string]interface{}{"Val": "3.14"}, 231 | "FP": map[string]interface{}{"Val": "22"}, 232 | }, &actual) 233 | 234 | if err != nil { 235 | t.Fatal(err) 236 | } 237 | 238 | if d := cmp.Diff(actual, expected); d != "" { 239 | t.Error(d) 240 | } 241 | }) 242 | 243 | t.Run("Original types", func(t *testing.T) { 244 | var actual Model 245 | 246 | err := encoding.Unstringify(map[string]interface{}{ 247 | "S": map[string]interface{}{"Val": "foo"}, 248 | "SP": map[string]interface{}{"Val": "bar"}, 249 | "B": map[string]interface{}{"Val": true}, 250 | "BP": map[string]interface{}{"Val": true}, 251 | "I": map[string]interface{}{"Val": 42}, 252 | "IP": map[string]interface{}{"Val": 42}, 253 | "F": map[string]interface{}{"Val": 3.14}, 254 | "FP": map[string]interface{}{"Val": 22.0}, 255 | }, &actual) 256 | 257 | if err != nil { 258 | t.Fatal(err) 259 | } 260 | 261 | if d := cmp.Diff(actual, expected); d != "" { 262 | t.Error(d) 263 | } 264 | }) 265 | 266 | t.Run("Compatible types", func(t *testing.T) { 267 | var actual Model 268 | 269 | err := encoding.Unstringify(map[string]interface{}{ 270 | "S": map[string]interface{}{"Val": "foo"}, 271 | "SP": map[string]interface{}{"Val": "bar"}, 272 | "B": map[string]interface{}{"Val": true}, 273 | "BP": map[string]interface{}{"Val": true}, 274 | "I": map[string]interface{}{"Val": float64(42)}, 275 | "IP": map[string]interface{}{"Val": float64(42)}, 276 | "F": map[string]interface{}{"Val": 3.14}, 277 | "FP": map[string]interface{}{"Val": int(22)}, 278 | }, &actual) 279 | 280 | if err != nil { 281 | t.Fatal(err) 282 | } 283 | 284 | if d := cmp.Diff(actual, expected); d != "" { 285 | t.Error(d) 286 | } 287 | }) 288 | } 289 | 290 | func TestUnstringifyPointers(t *testing.T) { 291 | type Model struct { 292 | SSP *[]string 293 | SMP *map[string]string 294 | } 295 | 296 | expected := Model{ 297 | SSP: &[]string{"foo"}, 298 | SMP: &map[string]string{"bar": "baz"}, 299 | } 300 | 301 | var actual Model 302 | 303 | err := encoding.Unstringify(map[string]interface{}{ 304 | "SSP": []interface{}{"foo"}, 305 | "SMP": map[string]interface{}{"bar": "baz"}, 306 | }, &actual) 307 | 308 | if err != nil { 309 | t.Fatal(err) 310 | } 311 | 312 | if d := cmp.Diff(actual, expected); d != "" { 313 | t.Error(d) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /cfn/encoding/values.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | var zeroValue reflect.Value 8 | 9 | var interfaceType = reflect.TypeOf((*interface{})(nil)).Elem() 10 | var stringType = reflect.TypeOf("") 11 | -------------------------------------------------------------------------------- /cfn/entry_test.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestMarshalling(t *testing.T) { 11 | t.Run("Happy Path", func(t *testing.T) { 12 | validevent, err := openFixture("request.read.json") 13 | if err != nil { 14 | t.Fatalf("Unable to read fixture: %v", err) 15 | } 16 | 17 | evt := &event{} 18 | 19 | if err := json.Unmarshal(validevent, evt); err != nil { 20 | t.Fatalf("Marshaling error with event: %v", err) 21 | } 22 | 23 | if evt.Action != readAction { 24 | t.Fatalf("Incorrect action (%v), expected: %v", evt.Action, readAction) 25 | } 26 | 27 | if evt.RequestData.LogicalResourceID != "myBucket" { 28 | t.Fatalf("Incorrect Logical Resource ID: %v", evt.RequestData.LogicalResourceID) 29 | } 30 | }) 31 | 32 | t.Run("Invalid Body", func(t *testing.T) { 33 | invalidevent, err := openFixture("request.read.invalid.json") 34 | if err != nil { 35 | t.Fatalf("Unable to read fixture: %v", err) 36 | } 37 | 38 | evt := &event{} 39 | if err := json.Unmarshal(invalidevent, evt); err == nil { 40 | t.Fatalf("Marshaling failed to throw an error: %#v", err) 41 | } 42 | }) 43 | } 44 | 45 | func TestRouter(t *testing.T) { 46 | t.Run("Happy Path", func(t *testing.T) { 47 | actions := []string{ 48 | createAction, 49 | readAction, 50 | updateAction, 51 | deleteAction, 52 | listAction, 53 | } 54 | 55 | for _, a := range actions { 56 | fn, err := router(a, &EmptyHandler{}) 57 | if err != nil { 58 | t.Fatalf("Unable to select '%v' handler: %v", a, err) 59 | } 60 | 61 | if fn == nil { 62 | t.Fatalf("Handler was not returned") 63 | } 64 | } 65 | }) 66 | 67 | t.Run("Failed Path", func(t *testing.T) { 68 | fn, err := router(unknownAction, &EmptyHandler{}) 69 | if err != nil && err.Code() != invalidRequestError { 70 | t.Errorf("Unspecified error returned: %v", err) 71 | } else if err == nil { 72 | t.Errorf("There should have been an error") 73 | } 74 | 75 | if fn != nil { 76 | t.Fatalf("Handler should be nil") 77 | } 78 | }) 79 | } 80 | 81 | func TestValidateEvent(t *testing.T) { 82 | t.Run("Happy Path", func(t *testing.T) { 83 | validevent, err := openFixture("request.read.json") 84 | if err != nil { 85 | t.Fatalf("Unable to read fixture: %v", err) 86 | } 87 | 88 | evt := &event{} 89 | 90 | if err := json.Unmarshal(validevent, evt); err != nil { 91 | t.Fatalf("Marshaling error with event: %v", err) 92 | } 93 | 94 | if err := validateEvent(evt); err != nil { 95 | t.Fatalf("Failed to validate: %v", err) 96 | } 97 | }) 98 | 99 | t.Run("Failed Validation", func(t *testing.T) { 100 | invalidevent, err := openFixture("request.read.invalid.validation.json") 101 | if err != nil { 102 | t.Fatalf("Unable to read fixture: %v", err) 103 | } 104 | 105 | evt := &event{} 106 | 107 | if err := json.Unmarshal(invalidevent, evt); err != nil { 108 | t.Fatalf("Marshaling error with event: %v", err) 109 | } 110 | 111 | if err := validateEvent(evt); err != nil { 112 | t.Fatalf("Failed to validate: %v", err) 113 | } 114 | }) 115 | } 116 | 117 | func TestHandler(t *testing.T) { 118 | // no-op 119 | } 120 | 121 | // helper func to load fixtures from the disk 122 | func openFixture(name string) ([]byte, error) { 123 | d, err := os.Getwd() 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | return os.ReadFile(filepath.Join(d, "test", "data", name)) 129 | } 130 | -------------------------------------------------------------------------------- /cfn/event.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 7 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/credentials" 8 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 9 | 10 | "gopkg.in/validator.v2" 11 | ) 12 | 13 | // Event base structure, it will be internal to the RPDK. 14 | type event struct { 15 | AWSAccountID string `json:"awsAccountId"` 16 | BearerToken string `json:"bearerToken" validate:"nonzero"` 17 | Region string `json:"region" validate:"nonzero"` 18 | Action string `json:"action"` 19 | ResourceType string `json:"resourceType"` 20 | ResourceTypeVersion encoding.Float `json:"resourceTypeVersion"` 21 | CallbackContext map[string]interface{} `json:"callbackContext,omitempty"` 22 | RequestData requestData `json:"requestData"` 23 | StackID string `json:"stackId"` 24 | 25 | NextToken string 26 | } 27 | 28 | // RequestData is internal to the RPDK. It contains a number of fields that are for 29 | // internal use only. 30 | type requestData struct { 31 | CallerCredentials credentials.CloudFormationCredentialsProvider `json:"callerCredentials"` 32 | LogicalResourceID string `json:"logicalResourceId"` 33 | ResourceProperties json.RawMessage `json:"resourceProperties"` 34 | PreviousResourceProperties json.RawMessage `json:"previousResourceProperties"` 35 | ProviderCredentials credentials.CloudFormationCredentialsProvider `json:"providerCredentials"` 36 | ProviderLogGroupName string `json:"providerLogGroupName"` 37 | StackTags tags `json:"stackTags"` 38 | SystemTags tags `json:"systemTags"` 39 | TypeConfiguration json.RawMessage `json:"typeConfiguration"` 40 | } 41 | 42 | // validateEvent ensures the event struct generated from the Lambda SDK is correct 43 | // A number of the RPDK values are required to be a certain type/length 44 | func validateEvent(event *event) error { 45 | if err := validator.Validate(event); err != nil { 46 | return cfnerr.New(validationError, "Failed Validation", err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // resourceHandlerRequest is internal to the RPDK. It contains a number of fields that are for 53 | // internal contract testing use only. 54 | // 55 | //nolint:all 56 | type resourceHandlerRequest struct { 57 | ClientRequestToken string `json:"clientRequestToken"` 58 | DesiredResourceState json.RawMessage `json:"desiredResourceState"` 59 | PreviousResourceState json.RawMessage `json:"previousResourceState"` 60 | DesiredResourceTags tags `json:"desiredResourceTags"` 61 | SystemTags tags `json:"systemTags"` 62 | AWSAccountID string `json:"awsAccountId"` 63 | AwsPartition string `json:"awsPartition"` 64 | LogicalResourceIdentifier string `json:"logicalResourceIdentifier"` 65 | NextToken string `json:"nextToken"` 66 | Region string `json:"region"` 67 | } 68 | -------------------------------------------------------------------------------- /cfn/handler/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package handler contains types that are passed into and out of a 3 | resource provider's CRUDL functions. 4 | */ 5 | package handler 6 | -------------------------------------------------------------------------------- /cfn/handler/event.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 5 | "github.com/aws/aws-sdk-go/service/cloudformation" 6 | ) 7 | 8 | // ProgressEvent represent the progress of CRUD handlers. 9 | type ProgressEvent struct { 10 | // OperationStatus indicates whether the handler has reached a terminal state or is 11 | // still computing and requires more time to complete. 12 | OperationStatus Status `json:"status,omitempty"` 13 | 14 | // HandlerErrorCode should be provided when OperationStatus is FAILED or IN_PROGRESS. 15 | HandlerErrorCode string `json:"errorCode,omitempty"` 16 | 17 | // Message which can be shown to callers to indicate the 18 | // nature of a progress transition or callback delay; for example a message 19 | // indicating "propagating to edge." 20 | Message string `json:"message,omitempty"` 21 | 22 | // CallbackContext is an arbitrary datum which the handler can return in an 23 | // IN_PROGRESS event to allow the passing through of additional state or 24 | // metadata between subsequent retries; for example to pass through a Resource 25 | // identifier which can be used to continue polling for stabilization 26 | CallbackContext map[string]interface{} `json:"callbackContext,omitempty"` 27 | 28 | // CallbackDelaySeconds will be scheduled with an initial delay of no less than the number 29 | // of seconds specified in the progress event. Set this value to <= 0 to 30 | // indicate no callback should be made. 31 | CallbackDelaySeconds int64 `json:"callbackDelaySeconds,omitempty"` 32 | 33 | // ResourceModel is the output resource instance populated by a READ/LIST for synchronous results 34 | // and by CREATE/UPDATE/DELETE for final response validation/confirmation 35 | ResourceModel interface{} `json:"resourceModel,omitempty"` 36 | 37 | // ResourceModels is the output resource instances populated by a LIST for 38 | // synchronous results. ResourceModels must be returned by LIST so it's 39 | // always included in the response. When ResourceModels is not set, null is 40 | // returned. 41 | ResourceModels []interface{} `json:"resourceModels"` 42 | 43 | // NextToken is the token used to request additional pages of resources for a LIST operation 44 | NextToken string `json:"nextToken,omitempty"` 45 | } 46 | 47 | // NewProgressEvent creates a new event with 48 | // a default OperationStatus of Unkown 49 | func NewProgressEvent() ProgressEvent { 50 | return ProgressEvent{ 51 | OperationStatus: UnknownStatus, 52 | } 53 | } 54 | 55 | // NewFailedEvent creates a generic failure progress event 56 | // based on the error passed in. 57 | func NewFailedEvent(err error) ProgressEvent { 58 | cerr := cfnerr.New( 59 | cloudformation.HandlerErrorCodeGeneralServiceException, 60 | "Unable to complete request: "+err.Error(), 61 | err, 62 | ) 63 | 64 | return ProgressEvent{ 65 | OperationStatus: Failed, 66 | Message: cerr.Message(), 67 | HandlerErrorCode: cloudformation.HandlerErrorCodeGeneralServiceException, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cfn/handler/event_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws/aws-sdk-go/service/cloudformation" 7 | "github.com/google/go-cmp/cmp" 8 | 9 | "encoding/json" 10 | 11 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 12 | ) 13 | 14 | func TestProgressEventMarshalJSON(t *testing.T) { 15 | type Model struct { 16 | Name *encoding.String 17 | Version *encoding.Float 18 | } 19 | 20 | for _, tt := range []struct { 21 | name string 22 | event ProgressEvent 23 | expected string 24 | }{ 25 | { 26 | name: "not updatable", 27 | event: ProgressEvent{ 28 | Message: "foo", 29 | OperationStatus: Failed, 30 | ResourceModel: Model{ 31 | Name: encoding.NewString("Douglas"), 32 | Version: encoding.NewFloat(42.1), 33 | }, 34 | HandlerErrorCode: cloudformation.HandlerErrorCodeNotUpdatable, 35 | }, 36 | expected: `{"status":"FAILED","errorCode":"NotUpdatable","message":"foo","resourceModel":{"Name":"Douglas","Version":"42.1"},"resourceModels":null}`, 37 | }, 38 | { 39 | name: "list with 1 result", 40 | event: ProgressEvent{ 41 | OperationStatus: Success, 42 | ResourceModels: []interface{}{ 43 | Model{ 44 | Name: encoding.NewString("Douglas"), 45 | Version: encoding.NewFloat(42.1), 46 | }, 47 | }, 48 | }, 49 | expected: `{"status":"SUCCESS","resourceModels":[{"Name":"Douglas","Version":"42.1"}]}`, 50 | }, 51 | { 52 | name: "list with empty array", 53 | event: ProgressEvent{ 54 | OperationStatus: Success, 55 | ResourceModels: []interface{}{}, 56 | }, 57 | expected: `{"status":"SUCCESS","resourceModels":[]}`, 58 | }, 59 | } { 60 | t.Run(tt.name, func(t *testing.T) { 61 | 62 | actual, err := json.Marshal(tt.event) 63 | if err != nil { 64 | t.Errorf("Unexpected error marshaling event JSON: %s", err) 65 | } 66 | 67 | if diff := cmp.Diff(string(actual), tt.expected); diff != "" { 68 | t.Errorf(diff) 69 | } 70 | }) 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /cfn/handler/handler_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | ) 8 | 9 | type Props struct { 10 | Color *string `json:"color"` 11 | } 12 | 13 | func TestNewRequest(t *testing.T) { 14 | rctx := RequestContext{} 15 | t.Run("Happy Path", func(t *testing.T) { 16 | prev := Props{} 17 | curr := Props{} 18 | 19 | req := NewRequest("foo", nil, rctx, nil, []byte(`{"color": "red"}`), []byte(`{"color": "green"}`), nil) 20 | 21 | if err := req.UnmarshalPrevious(&prev); err != nil { 22 | t.Fatalf("Unable to unmarshal props: %v", err) 23 | } 24 | 25 | if aws.StringValue(prev.Color) != "red" { 26 | t.Fatalf("Previous Properties don't match: %v", prev.Color) 27 | } 28 | 29 | if err := req.Unmarshal(&curr); err != nil { 30 | t.Fatalf("Unable to unmarshal props: %v", err) 31 | } 32 | 33 | if aws.StringValue(curr.Color) != "green" { 34 | t.Fatalf("Properties don't match: %v", curr.Color) 35 | } 36 | 37 | if req.LogicalResourceID != "foo" { 38 | t.Fatalf("Invalid Logical Resource ID: %v", req.LogicalResourceID) 39 | } 40 | 41 | }) 42 | 43 | t.Run("ResourceProps", func(t *testing.T) { 44 | t.Run("Invalid Body", func(t *testing.T) { 45 | req := NewRequest("foo", nil, rctx, nil, []byte(``), []byte(``), nil) 46 | 47 | invalid := struct { 48 | Color *int `json:"color"` 49 | }{} 50 | 51 | err := req.Unmarshal(&invalid) 52 | if err == nil { 53 | t.Fatalf("Didn't throw an error") 54 | } 55 | 56 | if err.Code() != bodyEmptyError { 57 | t.Fatalf("Wrong error returned: %v", err) 58 | } 59 | }) 60 | 61 | t.Run("Invalid Marshal", func(t *testing.T) { 62 | req := NewRequest("foo", nil, rctx, nil, []byte(`{"color": "ref"}`), []byte(`---BAD JSON---`), nil) 63 | 64 | var invalid Props 65 | 66 | err := req.Unmarshal(&invalid) 67 | if err == nil { 68 | t.Fatalf("Didn't throw an error") 69 | } 70 | 71 | if err.Code() != marshalingError { 72 | t.Fatalf("Wrong error returned: %v", err) 73 | } 74 | }) 75 | }) 76 | 77 | t.Run("PreviousResourceProps", func(t *testing.T) { 78 | t.Run("Invalid Marshal", func(t *testing.T) { 79 | req := NewRequest("foo", nil, rctx, nil, []byte(`---BAD JSON---`), []byte(`{"color": "green"}`), nil) 80 | 81 | var invalid Props 82 | 83 | err := req.UnmarshalPrevious(&invalid) 84 | if err == nil { 85 | t.Fatalf("Didn't throw an error") 86 | } 87 | 88 | if err.Code() != marshalingError { 89 | t.Fatalf("Wrong error returned: %v", err) 90 | } 91 | }) 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /cfn/handler/request.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws/session" 5 | 6 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 7 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 8 | ) 9 | 10 | const ( 11 | // marshalingError occurs when we can't marshal data from one format into another. 12 | marshalingError = "Marshaling" 13 | 14 | // bodyEmptyError happens when the resource body is empty 15 | bodyEmptyError = "BodyEmpty" 16 | ) 17 | 18 | // Request is passed to actions with customer related data 19 | // such as resource states 20 | type Request struct { 21 | // The logical ID of the resource in the CloudFormation stack 22 | LogicalResourceID string 23 | 24 | // The callback context is an arbitrary datum which the handler can return in an 25 | // IN_PROGRESS event to allow the passing through of additional state or 26 | // metadata between subsequent retries; for example to pass through a Resource 27 | // identifier which can be used to continue polling for stabilization 28 | CallbackContext map[string]interface{} 29 | 30 | // The RequestContext is information about the current 31 | // invocation. 32 | RequestContext RequestContext 33 | 34 | // An authenticated AWS session that can be used with the AWS Go SDK 35 | Session *session.Session 36 | 37 | previousResourcePropertiesBody []byte 38 | resourcePropertiesBody []byte 39 | typeConfigurationBody []byte 40 | } 41 | 42 | // RequestContext represents information about the current 43 | // invocation request of the handler. 44 | type RequestContext struct { 45 | // The stack ID of the CloudFormation stack 46 | StackID string 47 | 48 | // The Region of the requester 49 | Region string 50 | 51 | // The Account ID of the requester 52 | AccountID string 53 | 54 | // The stack tags associated with the cloudformation stack 55 | StackTags map[string]string 56 | 57 | // The SystemTags associated with the request 58 | SystemTags map[string]string 59 | 60 | // The NextToken provided in the request 61 | NextToken string 62 | } 63 | 64 | // NewRequest returns a new Request based on the provided parameters 65 | func NewRequest(id string, ctx map[string]interface{}, requestCTX RequestContext, sess *session.Session, previousBody, body, typeConfig []byte) Request { 66 | return Request{ 67 | LogicalResourceID: id, 68 | CallbackContext: ctx, 69 | Session: sess, 70 | previousResourcePropertiesBody: previousBody, 71 | resourcePropertiesBody: body, 72 | RequestContext: requestCTX, 73 | typeConfigurationBody: typeConfig, 74 | } 75 | } 76 | 77 | // UnmarshalPrevious populates the provided interface 78 | // with the previous properties of the resource 79 | func (r *Request) UnmarshalPrevious(v interface{}) cfnerr.Error { 80 | if len(r.previousResourcePropertiesBody) == 0 { 81 | return nil 82 | } 83 | 84 | if err := encoding.Unmarshal(r.previousResourcePropertiesBody, v); err != nil { 85 | return cfnerr.New(marshalingError, "Unable to convert type", err) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | // Unmarshal populates the provided interface 92 | // with the current properties of the resource 93 | func (r *Request) Unmarshal(v interface{}) cfnerr.Error { 94 | if len(r.resourcePropertiesBody) == 0 { 95 | return cfnerr.New(bodyEmptyError, "Body is empty", nil) 96 | } 97 | 98 | if err := encoding.Unmarshal(r.resourcePropertiesBody, v); err != nil { 99 | return cfnerr.New(marshalingError, "Unable to convert type", err) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | // UnmarshalTypeConfig populates the provided interface 106 | // with the current properties of the model 107 | func (r *Request) UnmarshalTypeConfig(v interface{}) error { 108 | if len(r.typeConfigurationBody) == 0 { 109 | return cfnerr.New(bodyEmptyError, "Type Config is empty", nil) 110 | } 111 | 112 | if err := encoding.Unmarshal(r.typeConfigurationBody, v); err != nil { 113 | return cfnerr.New(marshalingError, "Unable to convert type", err) 114 | } 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /cfn/handler/request_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestUnmarshal(t *testing.T) { 11 | type Detail struct { 12 | Build *int 13 | IsProduction *bool 14 | } 15 | 16 | type Model struct { 17 | Name *string 18 | Version *float64 19 | Detail *Detail 20 | } 21 | 22 | req := Request{ 23 | LogicalResourceID: "foo", 24 | previousResourcePropertiesBody: []byte(`{"Name":"bar","Version":"0.1","Detail":{"Build":"57","IsProduction":"false"}}`), 25 | resourcePropertiesBody: []byte(`{"Name":"baz","Version":"2.3","Detail":{"Build":"69","IsProduction":"true"}}`), 26 | } 27 | 28 | expectedPrevious := Model{ 29 | Name: aws.String("bar"), 30 | Version: aws.Float64(0.1), 31 | Detail: &Detail{ 32 | Build: aws.Int(57), 33 | IsProduction: aws.Bool(false), 34 | }, 35 | } 36 | 37 | expectedCurrent := Model{ 38 | Name: aws.String("baz"), 39 | Version: aws.Float64(2.3), 40 | Detail: &Detail{ 41 | Build: aws.Int(69), 42 | IsProduction: aws.Bool(true), 43 | }, 44 | } 45 | 46 | actual := Model{} 47 | 48 | // Previous body 49 | err := req.UnmarshalPrevious(&actual) 50 | if err != nil { 51 | t.Error(err) 52 | } 53 | if diff := cmp.Diff(actual, expectedPrevious); diff != "" { 54 | t.Errorf(diff) 55 | } 56 | 57 | // Current body 58 | err = req.Unmarshal(&actual) 59 | if err != nil { 60 | t.Error(err) 61 | } 62 | if diff := cmp.Diff(actual, expectedCurrent); diff != "" { 63 | t.Errorf(diff) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /cfn/handler/status.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | // Status represents the status of the handler. 4 | type Status string 5 | 6 | const ( 7 | // UnknownStatus represents all states that aren't covered 8 | // elsewhere 9 | UnknownStatus Status = "UNKNOWN" 10 | 11 | // InProgress should be returned when a resource provider 12 | // is in the process of being operated on. 13 | InProgress Status = "IN_PROGRESS" 14 | 15 | // Success should be returned when the resource provider 16 | // has finished it's operation. 17 | Success Status = "SUCCESS" 18 | 19 | // Failed should be returned when the resource provider 20 | // has failed 21 | Failed Status = "FAILED" 22 | ) 23 | -------------------------------------------------------------------------------- /cfn/logging/cloudwatchlogs.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/cloudwatchlogs" 11 | "github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface" 12 | 13 | "github.com/segmentio/ksuid" 14 | ) 15 | 16 | // NewCloudWatchLogsProvider creates a io.Writer that writes 17 | // to a specifc log group. 18 | // 19 | // Each time NewCloudWatchLogsProvider is used, a new log stream is created 20 | // inside the log group. The log stream will have a unique, random identifer 21 | // 22 | // sess := session.Must(aws.NewConfig()) 23 | // svc := cloudwatchlogs.New(sess) 24 | // 25 | // provider, err := NewCloudWatchLogsProvider(svc, "pineapple-pizza") 26 | // if err != nil { 27 | // panic(err) 28 | // } 29 | // 30 | // // set log output to the provider, all log messages will then be 31 | // // pushed through the Write func and sent to CloudWatch Logs 32 | // log.SetOutput(provider) 33 | // log.Printf("Eric loves pineapple pizza!") 34 | func NewCloudWatchLogsProvider(client cloudwatchlogsiface.CloudWatchLogsAPI, logGroupName string) (io.Writer, error) { 35 | logger := New("internal: ") 36 | 37 | // If we're running in SAM CLI, we can return the stdout 38 | if len(os.Getenv("AWS_SAM_LOCAL")) > 0 && len(os.Getenv("AWS_FORCE_INTEGRATIONS")) == 0 { 39 | return stdErr, nil 40 | } 41 | 42 | ok, err := CloudWatchLogGroupExists(client, logGroupName) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if !ok { 48 | logger.Printf("Need to create loggroup: %v", logGroupName) 49 | if err := CreateNewCloudWatchLogGroup(client, logGroupName); err != nil { 50 | return nil, err 51 | } 52 | } 53 | 54 | logStreamName := ksuid.New() 55 | // need to create logstream 56 | if err := CreateNewLogStream(client, logGroupName, logStreamName.String()); err != nil { 57 | return nil, err 58 | } 59 | 60 | provider := &cloudWatchLogsProvider{ 61 | client: client, 62 | 63 | logGroupName: logGroupName, 64 | logStreamName: logStreamName.String(), 65 | 66 | logger: logger, 67 | } 68 | 69 | if _, err := provider.Write([]byte("Initialization of log stream")); err != nil { 70 | return nil, err 71 | } 72 | 73 | return provider, nil 74 | } 75 | 76 | type cloudWatchLogsProvider struct { 77 | client cloudwatchlogsiface.CloudWatchLogsAPI 78 | 79 | logGroupName string 80 | logStreamName string 81 | 82 | sequence string 83 | 84 | logger *log.Logger 85 | } 86 | 87 | func (p *cloudWatchLogsProvider) Write(b []byte) (int, error) { 88 | p.logger.Printf("Need to write: %v", string(b)) 89 | 90 | input := &cloudwatchlogs.PutLogEventsInput{ 91 | LogGroupName: aws.String(p.logGroupName), 92 | LogStreamName: aws.String(p.logStreamName), 93 | 94 | LogEvents: []*cloudwatchlogs.InputLogEvent{ 95 | { 96 | Message: aws.String(string(b)), 97 | Timestamp: aws.Int64(time.Now().UnixNano() / int64(time.Millisecond)), 98 | }, 99 | }, 100 | } 101 | 102 | if len(p.sequence) != 0 { 103 | input.SetSequenceToken(p.sequence) 104 | } 105 | 106 | resp, err := p.client.PutLogEvents(input) 107 | 108 | if err != nil { 109 | return 0, err 110 | } 111 | 112 | p.sequence = *resp.NextSequenceToken 113 | 114 | return len(b), nil 115 | } 116 | 117 | // CloudWatchLogGroupExists checks if a log group exists 118 | // 119 | // Using the client provided, it will check the CloudWatch Logs 120 | // service to verify the log group 121 | // 122 | // sess := session.Must(aws.NewConfig()) 123 | // svc := cloudwatchlogs.New(sess) 124 | // 125 | // // checks if the pineapple-pizza log group exists 126 | // ok, err := LogGroupExists(svc, "pineapple-pizza") 127 | // if err != nil { 128 | // panic(err) 129 | // } 130 | // if ok { 131 | // // do something 132 | // } 133 | func CloudWatchLogGroupExists(client cloudwatchlogsiface.CloudWatchLogsAPI, logGroupName string) (bool, error) { 134 | resp, err := client.DescribeLogGroups(&cloudwatchlogs.DescribeLogGroupsInput{ 135 | Limit: aws.Int64(1), 136 | LogGroupNamePrefix: aws.String(logGroupName), 137 | }) 138 | 139 | if err != nil { 140 | return false, err 141 | } 142 | 143 | if len(resp.LogGroups) == 0 || *resp.LogGroups[0].LogGroupName != logGroupName { 144 | return false, nil 145 | } 146 | 147 | return true, nil 148 | } 149 | 150 | // CreateNewCloudWatchLogGroup creates a log group in CloudWatch Logs. 151 | // 152 | // Using a passed in client to create the call to the service, it 153 | // will create a log group of the specified name 154 | // 155 | // sess := session.Must(aws.NewConfig()) 156 | // svc := cloudwatchlogs.New(sess) 157 | // 158 | // if err := CreateNewCloudWatchLogGroup(svc, "pineapple-pizza"); err != nil { 159 | // panic("Unable to create log group", err) 160 | // } 161 | func CreateNewCloudWatchLogGroup(client cloudwatchlogsiface.CloudWatchLogsAPI, logGroupName string) error { 162 | if _, err := client.CreateLogGroup(&cloudwatchlogs.CreateLogGroupInput{ 163 | LogGroupName: aws.String(logGroupName), 164 | }); err != nil { 165 | return err 166 | } 167 | 168 | return nil 169 | } 170 | 171 | // CreateNewLogStream creates a log stream inside of a LogGroup 172 | func CreateNewLogStream(client cloudwatchlogsiface.CloudWatchLogsAPI, logGroupName string, logStreamName string) error { 173 | _, err := client.CreateLogStream(&cloudwatchlogs.CreateLogStreamInput{ 174 | LogGroupName: aws.String(logGroupName), 175 | LogStreamName: aws.String(logStreamName), 176 | }) 177 | 178 | return err 179 | } 180 | -------------------------------------------------------------------------------- /cfn/logging/logging.go: -------------------------------------------------------------------------------- 1 | //go:build logging 2 | // +build logging 3 | 4 | /* 5 | Package logging provides support for logging to cloudwatch 6 | within resource providers. 7 | */ 8 | package logging 9 | 10 | import ( 11 | "io" 12 | "log" 13 | "os" 14 | "syscall" 15 | ) 16 | 17 | // define a new stdErr since we'll over-write the default stdout/err 18 | // to prevent data leaking into the service account 19 | var stdErr = os.NewFile(uintptr(syscall.Stderr), "/dev/stderr") 20 | 21 | var providerLogOutput io.Writer 22 | 23 | const ( 24 | loggerError = "Logger" 25 | ) 26 | 27 | // SetProviderLogOutput ... 28 | func SetProviderLogOutput(w io.Writer) { 29 | 30 | log.SetOutput(w) 31 | 32 | providerLogOutput = w 33 | } 34 | 35 | // New sets up a logger that writes to the stderr 36 | func New(prefix string) *log.Logger { 37 | var w io.Writer 38 | 39 | if providerLogOutput != nil { 40 | w = io.MultiWriter(stdErr, providerLogOutput) 41 | } else { 42 | w = stdErr 43 | } 44 | 45 | // we create our own stderr since we're going to nuke the existing one 46 | return log.New(w, prefix, log.LstdFlags) 47 | } 48 | -------------------------------------------------------------------------------- /cfn/logging/logging_notag.go: -------------------------------------------------------------------------------- 1 | //go:build !logging 2 | // +build !logging 3 | 4 | package logging 5 | 6 | import ( 7 | "io" 8 | "log" 9 | "os" 10 | "syscall" 11 | ) 12 | 13 | // define a new stdErr since we'll over-write the default stdout/err 14 | // to prevent data leaking into the service account 15 | var stdErr = os.NewFile(uintptr(syscall.Stderr), "/dev/stderr") 16 | 17 | // SetProviderLogOutput ... 18 | func SetProviderLogOutput(w io.Writer) { 19 | // no-op 20 | } 21 | 22 | // New sets up a logger that writes to the stderr 23 | func New(prefix string) *log.Logger { 24 | // we create our own stderr since we're going to nuke the existing one 25 | return log.New(os.Stderr, prefix, log.LstdFlags) 26 | } 27 | -------------------------------------------------------------------------------- /cfn/metrics/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package metrics handles sending metrics about resource providers to CloudWatch 3 | */ 4 | package metrics 5 | -------------------------------------------------------------------------------- /cfn/metrics/noop_publisher.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 7 | "github.com/aws/aws-sdk-go/service/cloudwatch" 8 | "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" 9 | ) 10 | 11 | func newNoopClient() *noopCloudWatchClient { 12 | return &noopCloudWatchClient{ 13 | logger: logging.New("metrics"), 14 | } 15 | } 16 | 17 | type noopCloudWatchClient struct { 18 | logger *log.Logger 19 | cloudwatchiface.CloudWatchAPI 20 | } 21 | 22 | func (n *noopCloudWatchClient) PutMetricData(input *cloudwatch.PutMetricDataInput) (*cloudwatch.PutMetricDataOutput, error) { 23 | // out implementation doesn't care about the response 24 | return nil, nil 25 | } 26 | -------------------------------------------------------------------------------- /cfn/metrics/publisher.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/service/cloudwatch" 13 | "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" 14 | ) 15 | 16 | const ( 17 | // MetricNameSpaceRoot is the Metric name space root. 18 | MetricNameSpaceRoot = "AWS/CloudFormation" 19 | // MetricNameHanderException is a metric type. 20 | MetricNameHanderException = "HandlerException" 21 | // MetricNameHanderDuration is a metric type. 22 | MetricNameHanderDuration = "HandlerInvocationDuration" 23 | // MetricNameHanderInvocationCount is a metric type. 24 | MetricNameHanderInvocationCount = "HandlerInvocationCount" 25 | // DimensionKeyAcionType is the Action key in the dimension. 26 | DimensionKeyAcionType = "Action" 27 | // DimensionKeyExceptionType is the ExceptionType in the dimension. 28 | DimensionKeyExceptionType = "ExceptionType" 29 | // DimensionKeyResourceType is the ResourceType in the dimension. 30 | DimensionKeyResourceType = "ResourceType" 31 | // ServiceInternalError ... 32 | ServiceInternalError string = "ServiceInternal" 33 | ) 34 | 35 | // A Publisher represents an object that publishes metrics to AWS Cloudwatch. 36 | type Publisher struct { 37 | client cloudwatchiface.CloudWatchAPI // AWS CloudWatch Service Client 38 | namespace string // custom resouces's namespace 39 | logger *log.Logger 40 | resourceType string // type of resource 41 | } 42 | 43 | // New creates a new Publisher. 44 | func New(client cloudwatchiface.CloudWatchAPI, resType string) *Publisher { 45 | if len(os.Getenv("AWS_SAM_LOCAL")) > 0 { 46 | client = newNoopClient() 47 | } 48 | rn := ResourceTypeName(resType) 49 | return &Publisher{ 50 | client: client, 51 | logger: logging.New("metrics"), 52 | namespace: fmt.Sprintf("%s/%s", MetricNameSpaceRoot, rn), 53 | resourceType: rn, 54 | } 55 | } 56 | 57 | // PublishExceptionMetric publishes an exception metric. 58 | func (p *Publisher) PublishExceptionMetric(date time.Time, action string, e error) { 59 | v := strings.ReplaceAll(e.Error(), "\n", " ") 60 | dimensions := map[string]string{ 61 | DimensionKeyAcionType: string(action), 62 | DimensionKeyExceptionType: v, 63 | DimensionKeyResourceType: p.resourceType, 64 | } 65 | p.publishMetric(MetricNameHanderException, dimensions, cloudwatch.StandardUnitCount, 1.0, date) 66 | } 67 | 68 | // PublishInvocationMetric publishes an invocation metric. 69 | func (p *Publisher) PublishInvocationMetric(date time.Time, action string) { 70 | dimensions := map[string]string{ 71 | DimensionKeyAcionType: string(action), 72 | DimensionKeyResourceType: p.resourceType, 73 | } 74 | p.publishMetric(MetricNameHanderInvocationCount, dimensions, cloudwatch.StandardUnitCount, 1.0, date) 75 | } 76 | 77 | // PublishDurationMetric publishes an duration metric. 78 | // 79 | // A duration metric is the timing of something. 80 | func (p *Publisher) PublishDurationMetric(date time.Time, action string, secs float64) { 81 | dimensions := map[string]string{ 82 | DimensionKeyAcionType: string(action), 83 | DimensionKeyResourceType: p.resourceType, 84 | } 85 | p.publishMetric(MetricNameHanderDuration, dimensions, cloudwatch.StandardUnitMilliseconds, secs, date) 86 | } 87 | 88 | func (p *Publisher) publishMetric(metricName string, data map[string]string, unit string, value float64, date time.Time) { 89 | 90 | var d []*cloudwatch.Dimension 91 | 92 | for k, v := range data { 93 | dim := &cloudwatch.Dimension{ 94 | Name: aws.String(k), 95 | Value: aws.String(v), 96 | } 97 | d = append(d, dim) 98 | } 99 | md := []*cloudwatch.MetricDatum{ 100 | { 101 | MetricName: aws.String(metricName), 102 | Unit: aws.String(unit), 103 | Value: aws.Float64(value), 104 | Dimensions: d, 105 | Timestamp: &date}, 106 | } 107 | pi := cloudwatch.PutMetricDataInput{ 108 | Namespace: aws.String(p.namespace), 109 | MetricData: md, 110 | } 111 | _, err := p.client.PutMetricData(&pi) 112 | if err != nil { 113 | p.logger.Printf("An error occurred while publishing metrics: %s", err) 114 | 115 | } 116 | } 117 | 118 | // ResourceTypeName returns a type name by removing (::) and replaing with (/) 119 | // 120 | // Example 121 | // 122 | // r := metrics.ResourceTypeName("AWS::Service::Resource") 123 | // 124 | // // Will return "AWS/Service/Resource" 125 | func ResourceTypeName(t string) string { 126 | return strings.ReplaceAll(t, "::", "/") 127 | 128 | } 129 | -------------------------------------------------------------------------------- /cfn/reportErr.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 8 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/metrics" 9 | ) 10 | 11 | // reportErr is an unexported struct that handles reporting of errors. 12 | type reportErr struct { 13 | metricsPublisher *metrics.Publisher 14 | } 15 | 16 | // NewReportErr is a factory func that returns a pointer to a struct 17 | func newReportErr(metricsPublisher *metrics.Publisher) *reportErr { 18 | return &reportErr{ 19 | metricsPublisher: metricsPublisher, 20 | } 21 | } 22 | 23 | // Report publishes errors and reports error status to Cloudformation. 24 | func (r *reportErr) report(event *event, message string, err error, errCode string) (response, error) { 25 | m := fmt.Sprintf("Unable to complete request; %s error", message) 26 | r.metricsPublisher.PublishExceptionMetric(time.Now(), string(event.Action), err) 27 | return newFailedResponse(cfnerr.New(serviceInternalError, m, err), event.BearerToken), err 28 | } 29 | -------------------------------------------------------------------------------- /cfn/response.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 5 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 6 | "github.com/aws/aws-sdk-go/service/cloudformation" 7 | ) 8 | 9 | // response represents a response to the 10 | // cloudformation service from a resource handler. 11 | // The zero value is ready to use. 12 | type response struct { 13 | // Message which can be shown to callers to indicate the nature of a 14 | // progress transition or callback delay; for example a message 15 | // indicating "propagating to edge" 16 | Message string `json:"message,omitempty"` 17 | 18 | // The operationStatus indicates whether the handler has reached a terminal 19 | // state or is still computing and requires more time to complete 20 | OperationStatus handler.Status `json:"status,omitempty"` 21 | 22 | // ResourceModel it The output resource instance populated by a READ/LIST for 23 | // synchronous results and by CREATE/UPDATE/DELETE for final response 24 | // validation/confirmation 25 | ResourceModel interface{} `json:"resourceModel,omitempty"` 26 | 27 | // ErrorCode is used to report granular failures back to CloudFormation 28 | ErrorCode string `json:"errorCode,omitempty"` 29 | 30 | // BearerToken is used to report progress back to CloudFormation and is 31 | // passed back to CloudFormation 32 | BearerToken string `json:"bearerToken,omitempty"` 33 | 34 | // ResourceModels is the output resource instances populated by a LIST for 35 | // synchronous results. ResourceModels must be returned by LIST so it's 36 | // always included in the response. When ResourceModels is not set, null is 37 | // returned. 38 | ResourceModels []interface{} `json:"resourceModels"` 39 | 40 | // NextToken the token used to request additional pages of resources for a LIST operation 41 | NextToken string `json:"nextToken,omitempty"` 42 | 43 | // CallbackContext is an arbitrary datum which the handler can return in an 44 | // IN_PROGRESS event to allow the passing through of additional state or 45 | // metadata between subsequent retries; for example to pass through a Resource 46 | // identifier which can be used to continue polling for stabilization 47 | CallbackContext map[string]interface{} `json:"callbackContext,omitempty"` 48 | 49 | // CallbackDelaySeconds will be scheduled with an initial delay of no less than the number 50 | // of seconds specified in the progress event. Set this value to <= 0 to 51 | // indicate no callback should be made. 52 | CallbackDelaySeconds int64 `json:"callbackDelaySeconds,omitempty"` 53 | } 54 | 55 | // newFailedResponse returns a response pre-filled with the supplied error 56 | func newFailedResponse(err error, bearerToken string) response { 57 | return response{ 58 | OperationStatus: handler.Failed, 59 | ErrorCode: cloudformation.HandlerErrorCodeInternalFailure, 60 | Message: err.Error(), 61 | BearerToken: bearerToken, 62 | } 63 | } 64 | 65 | // newResponse converts a progress event into a useable reponse 66 | // for the CloudFormation Resource Provider service to understand. 67 | func newResponse(pevt *handler.ProgressEvent, bearerToken string) (response, error) { 68 | model, err := encoding.Stringify(pevt.ResourceModel) 69 | if err != nil { 70 | return response{}, err 71 | } 72 | 73 | var models []interface{} 74 | if pevt.ResourceModels != nil { 75 | models = make([]interface{}, len(pevt.ResourceModels)) 76 | for i := range pevt.ResourceModels { 77 | m, err := encoding.Stringify(pevt.ResourceModels[i]) 78 | if err != nil { 79 | return response{}, err 80 | } 81 | 82 | models[i] = m 83 | } 84 | } 85 | 86 | resp := response{ 87 | BearerToken: bearerToken, 88 | Message: pevt.Message, 89 | OperationStatus: pevt.OperationStatus, 90 | ResourceModel: model, 91 | ResourceModels: models, 92 | NextToken: pevt.NextToken, 93 | CallbackContext: pevt.CallbackContext, 94 | CallbackDelaySeconds: pevt.CallbackDelaySeconds, 95 | } 96 | 97 | if pevt.HandlerErrorCode != "" { 98 | resp.ErrorCode = pevt.HandlerErrorCode 99 | } 100 | 101 | return resp, nil 102 | } 103 | -------------------------------------------------------------------------------- /cfn/response_test.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws/aws-sdk-go/service/cloudformation" 7 | "github.com/google/go-cmp/cmp" 8 | 9 | "encoding/json" 10 | 11 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/encoding" 12 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 13 | ) 14 | 15 | func TestResponseMarshalJSON(t *testing.T) { 16 | type Model struct { 17 | Name *encoding.String 18 | Version *encoding.Float 19 | } 20 | 21 | for _, tt := range []struct { 22 | name string 23 | response response 24 | expected string 25 | }{ 26 | { 27 | name: "updated failed", 28 | response: response{ 29 | Message: "foo", 30 | OperationStatus: handler.Failed, 31 | ResourceModel: Model{ 32 | Name: encoding.NewString("Douglas"), 33 | Version: encoding.NewFloat(42.1), 34 | }, 35 | ErrorCode: cloudformation.HandlerErrorCodeNotUpdatable, 36 | BearerToken: "xyzzy", 37 | }, 38 | expected: `{"message":"foo","status":"FAILED","resourceModel":{"Name":"Douglas","Version":"42.1"},"errorCode":"NotUpdatable","bearerToken":"xyzzy","resourceModels":null}`, 39 | }, 40 | { 41 | name: "list with 1 result", 42 | response: response{ 43 | OperationStatus: handler.Success, 44 | ResourceModels: []interface{}{ 45 | Model{ 46 | Name: encoding.NewString("Douglas"), 47 | Version: encoding.NewFloat(42.1), 48 | }, 49 | }, 50 | BearerToken: "xyzzy", 51 | }, 52 | expected: `{"status":"SUCCESS","bearerToken":"xyzzy","resourceModels":[{"Name":"Douglas","Version":"42.1"}]}`, 53 | }, 54 | { 55 | name: "list with empty array", 56 | response: response{ 57 | OperationStatus: handler.Success, 58 | ResourceModels: []interface{}{}, 59 | BearerToken: "xyzzy", 60 | }, 61 | expected: `{"status":"SUCCESS","bearerToken":"xyzzy","resourceModels":[]}`, 62 | }, 63 | } { 64 | t.Run(tt.name, func(t *testing.T) { 65 | 66 | actual, err := json.Marshal(tt.response) 67 | if err != nil { 68 | t.Errorf("Unexpected error marshaling response JSON: %s", err) 69 | } 70 | 71 | if diff := cmp.Diff(string(actual), tt.expected); diff != "" { 72 | t.Errorf(diff) 73 | } 74 | }) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /cfn/scheduler/noop_scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 7 | "github.com/aws/aws-sdk-go/service/cloudwatchevents" 8 | "github.com/aws/aws-sdk-go/service/cloudwatchevents/cloudwatcheventsiface" 9 | ) 10 | 11 | type noopCloudWatchClient struct { 12 | cloudwatcheventsiface.CloudWatchEventsAPI 13 | logger *log.Logger 14 | } 15 | 16 | func newNoopCloudWatchClient() *noopCloudWatchClient { 17 | return &noopCloudWatchClient{ 18 | logger: logging.New("scheduler"), 19 | } 20 | } 21 | 22 | func (m *noopCloudWatchClient) PutRule(in *cloudwatchevents.PutRuleInput) (*cloudwatchevents.PutRuleOutput, error) { 23 | m.logger.Printf("Rule name: %v", *in.Name) 24 | // out implementation doesn't care about the response 25 | return nil, nil 26 | } 27 | 28 | func (m *noopCloudWatchClient) PutTargets(in *cloudwatchevents.PutTargetsInput) (*cloudwatchevents.PutTargetsOutput, error) { 29 | m.logger.Printf("Target ID: %v", *in.Targets[0].Id) 30 | // out implementation doesn't care about the response 31 | return nil, nil 32 | 33 | } 34 | 35 | func (m *noopCloudWatchClient) DeleteRule(in *cloudwatchevents.DeleteRuleInput) (*cloudwatchevents.DeleteRuleOutput, error) { 36 | m.logger.Printf("Rule name: %v", *in.Name) 37 | // out implementation doesn't care about the response 38 | return nil, nil 39 | } 40 | 41 | func (m *noopCloudWatchClient) RemoveTargets(in *cloudwatchevents.RemoveTargetsInput) (*cloudwatchevents.RemoveTargetsOutput, error) { 42 | m.logger.Printf("Target ID: %v", *in.Ids[0]) 43 | // out implementation doesn't care about the response 44 | return nil, nil 45 | } 46 | -------------------------------------------------------------------------------- /cfn/scheduler/scheduler.go: -------------------------------------------------------------------------------- 1 | //go:build scheduler 2 | // +build scheduler 3 | 4 | /* 5 | Package scheduler handles rescheduling resource provider handlers 6 | when required by in_progress events. 7 | */ 8 | package scheduler 9 | 10 | import ( 11 | "context" 12 | "errors" 13 | "fmt" 14 | "log" 15 | "time" 16 | 17 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 18 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 19 | "github.com/aws/aws-lambda-go/lambdacontext" 20 | "github.com/aws/aws-sdk-go/aws" 21 | "github.com/aws/aws-sdk-go/service/cloudwatchevents" 22 | "github.com/aws/aws-sdk-go/service/cloudwatchevents/cloudwatcheventsiface" 23 | "github.com/google/uuid" 24 | ) 25 | 26 | const ( 27 | HandlerPrepend string = "reinvoke-handler-%s" 28 | TargentPrepend string = "reinvoke-target-%s" 29 | ) 30 | 31 | const ( 32 | // ServiceInternalError is used when there's a downstream error 33 | // in the code. 34 | ServiceInternalError string = "ServiceInternal" 35 | ) 36 | 37 | // Result holds the confirmation of the rescheduled invocation. 38 | type Result struct { 39 | // Denotes if the computation was done locally. 40 | ComputeLocal bool 41 | IDS ScheduleIDS 42 | } 43 | 44 | // ScheduleIDS is of the invocation 45 | type ScheduleIDS struct { 46 | // The Cloudwatch target ID. 47 | Target string 48 | // The Cloudwatch handler ID. 49 | Handler string 50 | } 51 | 52 | // Scheduler is the implementation of the rescheduler of an invoke 53 | // 54 | // Invokes will be rescheduled if a handler takes longer than 60 55 | // seconds. The invoke is rescheduled through CloudWatch Events 56 | // via a CRON expression 57 | type Scheduler struct { 58 | logger *log.Logger 59 | client cloudwatcheventsiface.CloudWatchEventsAPI 60 | } 61 | 62 | // New creates a CloudWatchScheduler and returns a pointer to the struct. 63 | func New(client cloudwatcheventsiface.CloudWatchEventsAPI) *Scheduler { 64 | return &Scheduler{ 65 | logger: logging.New("scheduler"), 66 | client: client, 67 | } 68 | } 69 | 70 | // Reschedule when a handler requests a sub-minute callback delay, and if the lambda 71 | // invocation has enough runtime (with 20% buffer), we can reschedule from a thread wait 72 | // otherwise we re-invoke through CloudWatchEvents which have a granularity of 73 | // minutes. re-invoke through CloudWatchEvents no less than 1 minute from now. 74 | func (s *Scheduler) Reschedule(lambdaCtx context.Context, secsFromNow int64, callbackRequest string, invocationIDS *ScheduleIDS) (*Result, error) { 75 | 76 | lc, hasValue := lambdacontext.FromContext(lambdaCtx) 77 | 78 | if !hasValue { 79 | return nil, cfnerr.New(ServiceInternalError, "Lambda Context has no value", errors.New("Lambda Context has no value")) 80 | } 81 | 82 | deadline, _ := lambdaCtx.Deadline() 83 | secondsUnitDeadline := time.Until(deadline).Seconds() 84 | 85 | if secsFromNow <= 0 { 86 | err := errors.New("Scheduled seconds must be greater than 0") 87 | return nil, cfnerr.New(ServiceInternalError, "Scheduled seconds must be greater than 0", err) 88 | } 89 | 90 | if secsFromNow < 60 && secondsUnitDeadline > float64(secsFromNow)*1.2 { 91 | 92 | s.logger.Printf("Scheduling re-invoke locally after %v seconds, with Context %s", secsFromNow, string(callbackRequest)) 93 | 94 | time.Sleep(time.Duration(secsFromNow) * time.Second) 95 | 96 | return &Result{ComputeLocal: true, IDS: *invocationIDS}, nil 97 | } 98 | 99 | // re-invoke through CloudWatchEvents no less than 1 minute from now. 100 | if secsFromNow < 60 { 101 | secsFromNow = 60 102 | } 103 | 104 | cr := GenerateOneTimeCronExpression(secsFromNow, time.Now()) 105 | s.logger.Printf("Scheduling re-invoke at %s \n", cr) 106 | _, rerr := s.client.PutRule(&cloudwatchevents.PutRuleInput{ 107 | 108 | Name: aws.String(invocationIDS.Handler), 109 | ScheduleExpression: aws.String(cr), 110 | State: aws.String(cloudwatchevents.RuleStateEnabled), 111 | }) 112 | 113 | if rerr != nil { 114 | return nil, cfnerr.New(ServiceInternalError, "Schedule error", rerr) 115 | } 116 | _, perr := s.client.PutTargets(&cloudwatchevents.PutTargetsInput{ 117 | Rule: aws.String(invocationIDS.Handler), 118 | Targets: []*cloudwatchevents.Target{ 119 | &cloudwatchevents.Target{ 120 | Arn: aws.String(lc.InvokedFunctionArn), 121 | Id: aws.String(invocationIDS.Target), 122 | Input: aws.String(string(callbackRequest)), 123 | }, 124 | }, 125 | }) 126 | if perr != nil { 127 | return nil, cfnerr.New(ServiceInternalError, "Schedule error", perr) 128 | } 129 | 130 | return &Result{ComputeLocal: false, IDS: *invocationIDS}, nil 131 | } 132 | 133 | // CleanupEvents is used to clean up Cloudwatch Events. 134 | // After a re-invocation, the CWE rule which generated the reinvocation should be scrubbed. 135 | func (s *Scheduler) CleanupEvents(ruleName string, targetID string) error { 136 | 137 | if len(ruleName) == 0 { 138 | return cfnerr.New(ServiceInternalError, "Unable to complete request", errors.New("ruleName is required")) 139 | } 140 | if len(targetID) == 0 { 141 | return cfnerr.New(ServiceInternalError, "Unable to complete request", errors.New("targetID is required")) 142 | } 143 | _, err := s.client.RemoveTargets(&cloudwatchevents.RemoveTargetsInput{ 144 | Ids: []*string{ 145 | aws.String(targetID), 146 | }, 147 | Rule: aws.String(ruleName), 148 | }) 149 | if err != nil { 150 | es := fmt.Sprintf("Error cleaning CloudWatchEvents Target (targetId=%s)", targetID) 151 | s.logger.Println(es) 152 | return cfnerr.New(ServiceInternalError, es, err) 153 | } 154 | s.logger.Printf("CloudWatchEvents Target (targetId=%s) removed", targetID) 155 | 156 | _, rerr := s.client.DeleteRule(&cloudwatchevents.DeleteRuleInput{ 157 | Name: aws.String(ruleName), 158 | }) 159 | if rerr != nil { 160 | es := fmt.Sprintf("Error cleaning CloudWatchEvents (ruleName=%s)", ruleName) 161 | s.logger.Println(es) 162 | return cfnerr.New(ServiceInternalError, es, rerr) 163 | } 164 | s.logger.Printf("CloudWatchEvents Rule (ruleName=%s) removed", ruleName) 165 | 166 | return nil 167 | } 168 | 169 | // GenerateOneTimeCronExpression a cron(..) expression for a single instance 170 | // at Now+minutesFromNow 171 | // 172 | // Example 173 | // 174 | // // Will generate a cron string of: "1 0 0 0 0" 175 | // scheduler.GenerateOneTimeCronExpression(60, time.Now()) 176 | func GenerateOneTimeCronExpression(secFromNow int64, t time.Time) string { 177 | a := t.Add(time.Second * time.Duration(secFromNow)) 178 | return fmt.Sprintf("cron(%02d %02d %02d %02d ? %d)", a.Minute(), a.Hour(), a.Day(), a.Month(), a.Year()) 179 | } 180 | 181 | // GenerateCloudWatchIDS creates the targetID and handlerID for invocation 182 | func GenerateCloudWatchIDS() (*ScheduleIDS, error) { 183 | uuid, err := uuid.NewUUID() 184 | 185 | if err != nil { 186 | return nil, cfnerr.New(ServiceInternalError, "uuid error", err) 187 | } 188 | 189 | handlerID := fmt.Sprintf(HandlerPrepend, uuid) 190 | targetID := fmt.Sprintf(TargentPrepend, uuid) 191 | 192 | return &ScheduleIDS{ 193 | Target: targetID, 194 | Handler: handlerID, 195 | }, nil 196 | } 197 | -------------------------------------------------------------------------------- /cfn/scheduler/scheduler_notag.go: -------------------------------------------------------------------------------- 1 | //go:build !scheduler 2 | // +build !scheduler 3 | 4 | /* 5 | Package scheduler handles rescheduling resource provider handlers 6 | when required by in_progress events. 7 | */ 8 | package scheduler 9 | 10 | import ( 11 | "context" 12 | "errors" 13 | "fmt" 14 | "log" 15 | "time" 16 | 17 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/cfnerr" 18 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/logging" 19 | "github.com/aws/aws-lambda-go/lambdacontext" 20 | "github.com/aws/aws-sdk-go/aws" 21 | "github.com/aws/aws-sdk-go/service/cloudwatchevents" 22 | "github.com/aws/aws-sdk-go/service/cloudwatchevents/cloudwatcheventsiface" 23 | "github.com/google/uuid" 24 | ) 25 | 26 | const ( 27 | HandlerPrepend string = "reinvoke-handler-%s" 28 | TargentPrepend string = "reinvoke-target-%s" 29 | ) 30 | 31 | const ( 32 | // ServiceInternalError is used when there's a downstream error 33 | // in the code. 34 | ServiceInternalError string = "ServiceInternal" 35 | ) 36 | 37 | // Result holds the confirmation of the rescheduled invocation. 38 | type Result struct { 39 | // Denotes if the computation was done locally. 40 | ComputeLocal bool 41 | IDS ScheduleIDS 42 | } 43 | 44 | // ScheduleIDS is of the invocation 45 | type ScheduleIDS struct { 46 | // The Cloudwatch target ID. 47 | Target string 48 | // The Cloudwatch handler ID. 49 | Handler string 50 | } 51 | 52 | // Scheduler is the implementation of the rescheduler of an invoke 53 | // 54 | // Invokes will be rescheduled if a handler takes longer than 60 55 | // seconds. The invoke is rescheduled through CloudWatch Events 56 | // via a CRON expression 57 | type Scheduler struct { 58 | client cloudwatcheventsiface.CloudWatchEventsAPI 59 | logger *log.Logger 60 | } 61 | 62 | // New creates a CloudWatchScheduler and returns a pointer to the struct. 63 | func New(client cloudwatcheventsiface.CloudWatchEventsAPI) *Scheduler { 64 | return &Scheduler{ 65 | logger: logging.New("scheduler"), 66 | client: newNoopCloudWatchClient(), 67 | } 68 | } 69 | 70 | // Reschedule when a handler requests a sub-minute callback delay, and if the lambda 71 | // invocation has enough runtime (with 20% buffer), we can reschedule from a thread wait 72 | // otherwise we re-invoke through CloudWatchEvents which have a granularity of 73 | // minutes. re-invoke through CloudWatchEvents no less than 1 minute from now. 74 | func (s *Scheduler) Reschedule(lambdaCtx context.Context, secsFromNow int64, callbackRequest string, invocationIDS *ScheduleIDS) (*Result, error) { 75 | 76 | lc, hasValue := lambdacontext.FromContext(lambdaCtx) 77 | 78 | if !hasValue { 79 | return nil, cfnerr.New(ServiceInternalError, "Lambda Context has no value", errors.New("Lambda Context has no value")) 80 | } 81 | 82 | deadline, _ := lambdaCtx.Deadline() 83 | secondsUnitDeadline := time.Until(deadline).Seconds() 84 | 85 | if secsFromNow <= 0 { 86 | err := errors.New("Scheduled seconds must be greater than 0") 87 | return nil, cfnerr.New(ServiceInternalError, "Scheduled seconds must be greater than 0", err) 88 | } 89 | 90 | if secsFromNow < 60 && secondsUnitDeadline > float64(secsFromNow)*1.2 { 91 | 92 | s.logger.Printf("Scheduling re-invoke locally after %v seconds, with Context %s", secsFromNow, string(callbackRequest)) 93 | 94 | time.Sleep(time.Duration(secsFromNow) * time.Second) 95 | 96 | return &Result{ComputeLocal: true, IDS: *invocationIDS}, nil 97 | } 98 | 99 | // re-invoke through CloudWatchEvents no less than 1 minute from now. 100 | if secsFromNow < 60 { 101 | secsFromNow = 60 102 | } 103 | 104 | cr := GenerateOneTimeCronExpression(secsFromNow, time.Now()) 105 | s.logger.Printf("Scheduling re-invoke at %s \n", cr) 106 | _, rerr := s.client.PutRule(&cloudwatchevents.PutRuleInput{ 107 | 108 | Name: aws.String(invocationIDS.Handler), 109 | ScheduleExpression: aws.String(cr), 110 | State: aws.String(cloudwatchevents.RuleStateEnabled), 111 | }) 112 | 113 | if rerr != nil { 114 | return nil, cfnerr.New(ServiceInternalError, "Schedule error", rerr) 115 | } 116 | _, perr := s.client.PutTargets(&cloudwatchevents.PutTargetsInput{ 117 | Rule: aws.String(invocationIDS.Handler), 118 | Targets: []*cloudwatchevents.Target{ 119 | { 120 | Arn: aws.String(lc.InvokedFunctionArn), 121 | Id: aws.String(invocationIDS.Target), 122 | Input: aws.String(string(callbackRequest)), 123 | }, 124 | }, 125 | }) 126 | if perr != nil { 127 | return nil, cfnerr.New(ServiceInternalError, "Schedule error", perr) 128 | } 129 | 130 | return &Result{ComputeLocal: false, IDS: *invocationIDS}, nil 131 | } 132 | 133 | // CleanupEvents is used to clean up Cloudwatch Events. 134 | // After a re-invocation, the CWE rule which generated the reinvocation should be scrubbed. 135 | func (s *Scheduler) CleanupEvents(ruleName string, targetID string) error { 136 | 137 | if len(ruleName) == 0 { 138 | return cfnerr.New(ServiceInternalError, "Unable to complete request", errors.New("ruleName is required")) 139 | } 140 | if len(targetID) == 0 { 141 | return cfnerr.New(ServiceInternalError, "Unable to complete request", errors.New("targetID is required")) 142 | } 143 | _, err := s.client.RemoveTargets(&cloudwatchevents.RemoveTargetsInput{ 144 | Ids: []*string{ 145 | aws.String(targetID), 146 | }, 147 | Rule: aws.String(ruleName), 148 | }) 149 | if err != nil { 150 | es := fmt.Sprintf("Error cleaning CloudWatchEvents Target (targetId=%s)", targetID) 151 | s.logger.Println(es) 152 | return cfnerr.New(ServiceInternalError, es, err) 153 | } 154 | s.logger.Printf("CloudWatchEvents Target (targetId=%s) removed", targetID) 155 | 156 | _, rerr := s.client.DeleteRule(&cloudwatchevents.DeleteRuleInput{ 157 | Name: aws.String(ruleName), 158 | }) 159 | if rerr != nil { 160 | es := fmt.Sprintf("Error cleaning CloudWatchEvents (ruleName=%s)", ruleName) 161 | s.logger.Println(es) 162 | return cfnerr.New(ServiceInternalError, es, rerr) 163 | } 164 | s.logger.Printf("CloudWatchEvents Rule (ruleName=%s) removed", ruleName) 165 | 166 | return nil 167 | } 168 | 169 | // GenerateOneTimeCronExpression a cron(..) expression for a single instance 170 | // at Now+minutesFromNow 171 | // 172 | // Example 173 | // 174 | // // Will generate a cron string of: "1 0 0 0 0" 175 | // scheduler.GenerateOneTimeCronExpression(60, time.Now()) 176 | func GenerateOneTimeCronExpression(secFromNow int64, t time.Time) string { 177 | a := t.Add(time.Second * time.Duration(secFromNow)) 178 | return fmt.Sprintf("cron(%02d %02d %02d %02d ? %d)", a.Minute(), a.Hour(), a.Day(), a.Month(), a.Year()) 179 | } 180 | 181 | // GenerateCloudWatchIDS creates the targetID and handlerID for invocation 182 | func GenerateCloudWatchIDS() (*ScheduleIDS, error) { 183 | uuid, err := uuid.NewUUID() 184 | 185 | if err != nil { 186 | return nil, cfnerr.New(ServiceInternalError, "uuid error", err) 187 | } 188 | 189 | handlerID := fmt.Sprintf(HandlerPrepend, uuid) 190 | targetID := fmt.Sprintf(TargentPrepend, uuid) 191 | 192 | return &ScheduleIDS{ 193 | Target: targetID, 194 | Handler: handlerID, 195 | }, nil 196 | } 197 | -------------------------------------------------------------------------------- /cfn/test/data/request.create.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsAccountId": "123456789012", 3 | "bearerToken": "123456", 4 | "region": "us-east-1", 5 | "action": "CREATE", 6 | "responseEndpoint": "cloudformation.us-west-2.amazonaws.com", 7 | "resourceType": "AWS::Test::TestModel", 8 | "resourceTypeVersion": "1.0", 9 | "requestContext": {}, 10 | "requestData": { 11 | "callerCredentials": { 12 | "accessKeyId": "IASAYK835GAIFHAHEI23", 13 | "secretAccessKey": "66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5/poh2O0", 14 | "sessionToken": "lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg" 15 | }, 16 | "platformCredentials": { 17 | "accessKeyId": "32IEHAHFIAG538KYASAI", 18 | "secretAccessKey": "0O2hop/5vllVHjbA8u52hK8rLcroZpnL5NPGOi66", 19 | "sessionToken": "gqe6eIsFPHOlfhc3RKl5s5Y6Dy9PYvN1CEYsswz5TQUsE8WfHD6LPK549euXm4Vn4INBY9nMJ1cJe2mxTYFdhWHSnkOQv2SHemal" 20 | }, 21 | "logicalResourceId": "myBucket", 22 | "resourceProperties": { 23 | "property1": "abc", 24 | "property2": 123 25 | } 26 | }, 27 | "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968" 28 | } 29 | -------------------------------------------------------------------------------- /cfn/test/data/request.create2.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsAccountId": "123456789012", 3 | "bearerToken": "123456", 4 | "region": "us-east-1", 5 | "action": "CREATE", 6 | "responseEndpoint": "cloudformation.us-west-2.amazonaws.com", 7 | "resourceType": "AWS::Test::TestModel", 8 | "resourceTypeVersion": "1.0", 9 | "requestContext": {}, 10 | "requestData": { 11 | "callerCredentials": { 12 | "accessKeyId": "IASAYK835GAIFHAHEI23", 13 | "secretAccessKey": "66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5/poh2O0", 14 | "sessionToken": "lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg" 15 | }, 16 | "platformCredentials": { 17 | "accessKeyId": "32IEHAHFIAG538KYASAI", 18 | "secretAccessKey": "0O2hop/5vllVHjbA8u52hK8rLcroZpnL5NPGOi66", 19 | "sessionToken": "gqe6eIsFPHOlfhc3RKl5s5Y6Dy9PYvN1CEYsswz5TQUsE8WfHD6LPK549euXm4Vn4INBY9nMJ1cJe2mxTYFdhWHSnkOQv2SHemal" 20 | }, 21 | "logicalResourceId": "myBucket", 22 | "resourceProperties": { 23 | "property1": "abc" 24 | } 25 | }, 26 | "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968" 27 | } 28 | -------------------------------------------------------------------------------- /cfn/test/data/request.invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsAccountId": "123456789012", 3 | "bearerToken": "123456", 4 | "region": "us-east-1", 5 | "action": "INVALID", 6 | "responseEndpoint": "cloudformation.us-west-2.amazonaws.com", 7 | "resourceType": "AWS::Test::TestModel", 8 | "resourceTypeVersion": "1.0", 9 | "requestContext": {}, 10 | "requestData": { 11 | "callerCredentials": { 12 | "accessKeyId": "IASAYK835GAIFHAHEI23", 13 | "secretAccessKey": "66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5/poh2O0", 14 | "sessionToken": "lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg" 15 | }, 16 | "platformCredentials": { 17 | "accessKeyId": "32IEHAHFIAG538KYASAI", 18 | "secretAccessKey": "0O2hop/5vllVHjbA8u52hK8rLcroZpnL5NPGOi66", 19 | "sessionToken": "gqe6eIsFPHOlfhc3RKl5s5Y6Dy9PYvN1CEYsswz5TQUsE8WfHD6LPK549euXm4Vn4INBY9nMJ1cJe2mxTYFdhWHSnkOQv2SHemal" 20 | }, 21 | "logicalResourceId": "myBucket", 22 | "resourceProperties": { 23 | "property1": "abc", 24 | "property2": 123 25 | } 26 | }, 27 | "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968" 28 | } 29 | -------------------------------------------------------------------------------- /cfn/test/data/request.read.invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsAccountId": "123456789012", 3 | "bearerToken": "123456", 4 | "region": "us-east-1", 5 | "action": "READ", 6 | "responseEndpoint": cloudformation.us-west-2.amazonaws.com", 7 | "resourceType": "AWS::Test::TestModel", 8 | "resourceTypeVersion": "1.0", 9 | "requestContext": {}, 10 | "requestData": { 11 | "callerCredentials": { 12 | "accessKeyId": "IASAYK835GAIFHAHEI23", 13 | "secretAccessKey": "66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5/poh2O0", 14 | "sessionToken": "lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg" 15 | }, 16 | "platformCredentials": { 17 | "accessKeyId": "32IEHAHFIAG538KYASAI", 18 | "secretAccessKey": "0O2hop/5vllVHjbA8u52hK8rLcroZpnL5NPGOi66", 19 | "sessionToken": "gqe6eIsFPHOlfhc3RKl5s5Y6Dy9PYvN1CEYsswz5TQUsE8WfHD6LPK549euXm4Vn4INBY9nMJ1cJe2mxTYFdhWHSnkOQv2SHemal" 20 | }, 21 | "logicalResourceId": "myBucket", 22 | "resourceProperties": { 23 | "property1": "abc", 24 | "property2": 123 25 | } 26 | }, 27 | "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968" 28 | } 29 | -------------------------------------------------------------------------------- /cfn/test/data/request.read.invalid.validation.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsAccountId": "1", 3 | "bearerToken": "123456", 4 | "region": "us-east-1", 5 | "action": "CREATE", 6 | "responseEndpoint": "cloudformation.us-west-2.amazonaws.com", 7 | "resourceType": "AWS::Test::TestModel", 8 | "resourceTypeVersion": "1.0", 9 | "requestContext": { 10 | "invocation": "2", 11 | "callbackContext": { 12 | "contextPropertyA": "Value" 13 | }, 14 | "cloudWatchEventsRuleName": "reinvoke-handler-4754ac8a-623b-45fe-84bc-f5394118a8be", 15 | "cloudWatchEventsTargetId": "reinvoke-target-4754ac8a-623b-45fe-84bc-f5394118a8be" 16 | }, 17 | "requestData": { 18 | "callerCredentials": { 19 | "accessKeyId": "IASAYK835GAIFHAHEI23", 20 | "secretAccessKey": "66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5/poh2O0", 21 | "sessionToken": "lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg" 22 | }, 23 | "platformCredentials": { 24 | "accessKeyId": "32IEHAHFIAG538KYASAI", 25 | "secretAccessKey": "0O2hop/5vllVHjbA8u52hK8rLcroZpnL5NPGOi66", 26 | "sessionToken": "gqe6eIsFPHOlfhc3RKl5s5Y6Dy9PYvN1CEYsswz5TQUsE8WfHD6LPK549euXm4Vn4INBY9nMJ1cJe2mxTYFdhWHSnkOQv2SHemal" 27 | }, 28 | "logicalResourceId": "myBucket", 29 | "resourceProperties": {}, 30 | "systemTags": { 31 | "aws:cloudformation:stack-id": "SampleStack" 32 | }, 33 | "stackTags": { 34 | "tag1": "abc" 35 | }, 36 | "previousStackTags": { 37 | "tag1": "def" 38 | } 39 | }, 40 | "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968" 41 | } 42 | -------------------------------------------------------------------------------- /cfn/test/data/request.read.json: -------------------------------------------------------------------------------- 1 | { 2 | "awsAccountId": "123456789012", 3 | "bearerToken": "123456", 4 | "region": "us-east-1", 5 | "action": "READ", 6 | "responseEndpoint": "cloudformation.us-west-2.amazonaws.com", 7 | "resourceType": "AWS::Test::TestModel", 8 | "resourceTypeVersion": "1.0", 9 | "requestContext": {}, 10 | "requestData": { 11 | "callerCredentials": { 12 | "accessKeyId": "IASAYK835GAIFHAHEI23", 13 | "secretAccessKey": "66iOGPN5LnpZorcLr8Kh25u8AbjHVllv5/poh2O0", 14 | "sessionToken": "lameHS2vQOknSHWhdFYTxm2eJc1JMn9YBNI4nV4mXue945KPL6DHfW8EsUQT5zwssYEC1NvYP9yD6Y5s5lKR3chflOHPFsIe6eqg" 15 | }, 16 | "platformCredentials": { 17 | "accessKeyId": "32IEHAHFIAG538KYASAI", 18 | "secretAccessKey": "0O2hop/5vllVHjbA8u52hK8rLcroZpnL5NPGOi66", 19 | "sessionToken": "gqe6eIsFPHOlfhc3RKl5s5Y6Dy9PYvN1CEYsswz5TQUsE8WfHD6LPK549euXm4Vn4INBY9nMJ1cJe2mxTYFdhWHSnkOQv2SHemal" 20 | }, 21 | "logicalResourceId": "myBucket", 22 | "resourceProperties": { 23 | "property1": "abc", 24 | "property2": 123 25 | } 26 | }, 27 | "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968" 28 | } 29 | -------------------------------------------------------------------------------- /cfn/test/data/test.create.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "CREATE", 3 | "credentials": { 4 | "accessKeyId": "123456789", 5 | "secretAccessKey": "1234566", 6 | "sessionToken": "1234567" 7 | }, 8 | "callbackContext": null, 9 | "Request": { 10 | "clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b", 11 | "desiredResourceState": {}, 12 | "previousResourceState": {}, 13 | "desiredResourceTags": null, 14 | "systemTags": null, 15 | "awsAccountId": "", 16 | "awsPartition": "", 17 | "logicalResourceIdentifier": "", 18 | "nextToken": "", 19 | "region": "" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cfn/test/data/test.delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "DELETE", 3 | "credentials": { 4 | "accessKeyId": "123456789", 5 | "secretAccessKey": "1234566", 6 | "sessionToken": "1234567" 7 | }, 8 | "callbackContext": null, 9 | "Request": { 10 | "clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b", 11 | "desiredResourceState": {}, 12 | "previousResourceState": {}, 13 | "desiredResourceTags": null, 14 | "systemTags": null, 15 | "awsAccountId": "", 16 | "awsPartition": "", 17 | "logicalResourceIdentifier": "", 18 | "nextToken": "", 19 | "region": "" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cfn/test/data/test.invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "INVALID", 3 | "credentials": { 4 | "accessKeyId": "123456789", 5 | "secretAccessKey": "1234566", 6 | "sessionToken": "1234567" 7 | }, 8 | "callbackContext": null, 9 | "Request": { 10 | "clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b", 11 | "desiredResourceState": {}, 12 | "previousResourceState": {}, 13 | "desiredResourceTags": null, 14 | "systemTags": null, 15 | "awsAccountId": "", 16 | "awsPartition": "", 17 | "logicalResourceIdentifier": "", 18 | "nextToken": "", 19 | "region": "" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cfn/test/data/test.read.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "READ", 3 | "credentials": { 4 | "accessKeyId": "123456789", 5 | "secretAccessKey": "1234566", 6 | "sessionToken": "1234567" 7 | }, 8 | "callbackContext": null, 9 | "Request": { 10 | "clientRequestToken": "17603535-aefd-4820-ad48-55739e0d571b", 11 | "desiredResourceState": {}, 12 | "previousResourceState": {}, 13 | "desiredResourceTags": null, 14 | "systemTags": null, 15 | "awsAccountId": "", 16 | "awsPartition": "", 17 | "logicalResourceIdentifier": "", 18 | "nextToken": "", 19 | "region": "" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cfn/types_test.go: -------------------------------------------------------------------------------- 1 | package cfn 2 | 3 | import ( 4 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "github.com/aws/aws-sdk-go/service/cloudwatch" 7 | "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" 8 | ) 9 | 10 | // EmptyHandler is a implementation of Handler 11 | // 12 | // This implementation of the handlers is only used for testing. 13 | type EmptyHandler struct{} 14 | 15 | func (h *EmptyHandler) Create(request handler.Request) handler.ProgressEvent { 16 | return handler.ProgressEvent{} 17 | } 18 | 19 | func (h *EmptyHandler) Read(request handler.Request) handler.ProgressEvent { 20 | return handler.ProgressEvent{} 21 | } 22 | 23 | func (h *EmptyHandler) Update(request handler.Request) handler.ProgressEvent { 24 | return handler.ProgressEvent{} 25 | } 26 | 27 | func (h *EmptyHandler) Delete(request handler.Request) handler.ProgressEvent { 28 | return handler.ProgressEvent{} 29 | } 30 | 31 | func (h *EmptyHandler) List(request handler.Request) handler.ProgressEvent { 32 | return handler.ProgressEvent{} 33 | } 34 | 35 | // MockHandler is a implementation of Handler 36 | // 37 | // This implementation of the handlers is only used for testing. 38 | type MockHandler struct { 39 | fn func(callback map[string]interface{}, s *session.Session) handler.ProgressEvent 40 | } 41 | 42 | func (m *MockHandler) Create(request handler.Request) handler.ProgressEvent { 43 | return m.fn(request.CallbackContext, request.Session) 44 | } 45 | 46 | func (m *MockHandler) Read(request handler.Request) handler.ProgressEvent { 47 | return m.fn(request.CallbackContext, request.Session) 48 | } 49 | 50 | func (m *MockHandler) Update(request handler.Request) handler.ProgressEvent { 51 | return m.fn(request.CallbackContext, request.Session) 52 | } 53 | 54 | func (m *MockHandler) Delete(request handler.Request) handler.ProgressEvent { 55 | return m.fn(request.CallbackContext, request.Session) 56 | } 57 | 58 | func (m *MockHandler) List(request handler.Request) handler.ProgressEvent { 59 | return m.fn(request.CallbackContext, request.Session) 60 | } 61 | 62 | // MockedMetrics mocks the call to AWS CloudWatch Metrics 63 | // 64 | // This implementation of the handlers is only used for testing. 65 | type MockedMetrics struct { 66 | cloudwatchiface.CloudWatchAPI 67 | ResourceTypeName string 68 | HandlerExceptionCount int 69 | HandlerInvocationDurationCount int 70 | HandlerInvocationCount int 71 | } 72 | 73 | // NewMockedMetrics is a factory function that returns a new MockedMetrics. 74 | // 75 | // This implementation of the handlers is only used for testing. 76 | func NewMockedMetrics() *MockedMetrics { 77 | return &MockedMetrics{} 78 | } 79 | 80 | // PutMetricData mocks the PutMetricData method. 81 | // 82 | // This implementation of the handlers is only used for testing. 83 | func (m *MockedMetrics) PutMetricData(in *cloudwatch.PutMetricDataInput) (*cloudwatch.PutMetricDataOutput, error) { 84 | m.ResourceTypeName = *in.Namespace 85 | d := in.MetricData[0].MetricName 86 | switch *d { 87 | case "HandlerException": 88 | m.HandlerExceptionCount++ 89 | case "HandlerInvocationDuration": 90 | m.HandlerInvocationDurationCount++ 91 | case "HandlerInvocationCount": 92 | m.HandlerInvocationCount++ 93 | } 94 | 95 | return nil, nil 96 | } 97 | 98 | // MockModel mocks a resource model 99 | // 100 | // This implementation of the handlers is only used for testing. 101 | type MockModel struct { 102 | Property1 *string `json:"property1,omitempty"` 103 | Property2 *string `json:"property2,omitempty"` 104 | } 105 | 106 | // MockModelHandler is a implementation of Handler 107 | // 108 | // This implementation of the handlers is only used for testing. 109 | type MockModelHandler struct { 110 | fn func(r handler.Request) handler.ProgressEvent 111 | } 112 | 113 | func (m *MockModelHandler) Create(request handler.Request) handler.ProgressEvent { 114 | return m.fn(request) 115 | } 116 | 117 | func (m *MockModelHandler) Read(request handler.Request) handler.ProgressEvent { 118 | return m.fn(request) 119 | } 120 | 121 | func (m *MockModelHandler) Update(request handler.Request) handler.ProgressEvent { 122 | return m.fn(request) 123 | } 124 | 125 | func (m *MockModelHandler) Delete(request handler.Request) handler.ProgressEvent { 126 | return m.fn(request) 127 | } 128 | 129 | func (m *MockModelHandler) List(request handler.Request) handler.ProgressEvent { 130 | return m.fn(request) 131 | } 132 | -------------------------------------------------------------------------------- /examples/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package examples contains example resource provider implementations. 3 | */ 4 | package examples 5 | -------------------------------------------------------------------------------- /examples/github-repo/.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | ._* 4 | 5 | # our logs 6 | rpdk.log 7 | 8 | #compiled file 9 | bin/ 10 | 11 | #vender 12 | vender/ 13 | -------------------------------------------------------------------------------- /examples/github-repo/.rpdk-config: -------------------------------------------------------------------------------- 1 | { 2 | "typeName": "Example::GitHub::Repo", 3 | "language": "go", 4 | "runtime": "provided.al2", 5 | "entrypoint": "bootstrap", 6 | "testEntrypoint": "bootstrap", 7 | "settings": { 8 | "import_path": "github.com/aws-cloudformation/cloudformation-cli-go-plugin/examples/github-repo", 9 | "protocolVersion": "2.0.0", 10 | "pluginVersion": "2.0.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/github-repo/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test clean 2 | 3 | build: 4 | cfn generate 5 | env GOOS=linux go build -ldflags="-s -w" -tags="logging" -o bin/handler cmd/main.go 6 | 7 | test: 8 | cfn generate 9 | env GOOS=linux go build -ldflags="-s -w" -o bin/handler cmd/main.go 10 | 11 | clean: 12 | rm -rf bin 13 | -------------------------------------------------------------------------------- /examples/github-repo/README.md: -------------------------------------------------------------------------------- 1 | # Example::GitHub::Repo 2 | 3 | Manages a public GitHub repository. 4 | 5 | ## Build 6 | You can create a build of the resource for local testing by running `make`, it will create 7 | a build of your resource that can be used with AWS SAM CLI. 8 | 9 | ### Local testing 10 | 11 | ```bash 12 | $ sam local invoke -e ./events/create.json TypeFunction 13 | ``` 14 | 15 | Will executed your function without calling any AWS resources, unless your code interacts 16 | with AWS. 17 | 18 | ### Deploy 19 | Using `make deploy` will create a production version of the resource, which will interact 20 | with CloudFormation and CloudWatch Logs. 21 | 22 | ## Run 23 | 24 | | Name | Description | Required | 25 | |:-----|:------------|:---------| 26 | | Name | Repository Name | ✅ | 27 | | Owner | Organization/User where the repo will be created | ✅ | 28 | | Description | Description of the repository | | 29 | | Homepage | Home page of the project | | 30 | | OauthToken | Personal Access Token | ✅ | 31 | 32 | Every input property of a resource is usable as an output. 33 | -------------------------------------------------------------------------------- /examples/github-repo/cmd/main.go: -------------------------------------------------------------------------------- 1 | // Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn" 10 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 11 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/examples/github-repo/cmd/resource" 12 | ) 13 | 14 | // Handler is a container for the CRUDL actions exported by resources 15 | type Handler struct{} 16 | 17 | // Create wraps the related Create function exposed by the resource code 18 | func (r *Handler) Create(req handler.Request) handler.ProgressEvent { 19 | return wrap(req, resource.Create) 20 | } 21 | 22 | // Read wraps the related Read function exposed by the resource code 23 | func (r *Handler) Read(req handler.Request) handler.ProgressEvent { 24 | return wrap(req, resource.Read) 25 | } 26 | 27 | // Update wraps the related Update function exposed by the resource code 28 | func (r *Handler) Update(req handler.Request) handler.ProgressEvent { 29 | return wrap(req, resource.Update) 30 | } 31 | 32 | // Delete wraps the related Delete function exposed by the resource code 33 | func (r *Handler) Delete(req handler.Request) handler.ProgressEvent { 34 | return wrap(req, resource.Delete) 35 | } 36 | 37 | // List wraps the related List function exposed by the resource code 38 | func (r *Handler) List(req handler.Request) handler.ProgressEvent { 39 | return wrap(req, resource.List) 40 | } 41 | 42 | // main is the entry point of the application. 43 | func main() { 44 | cfn.Start(&Handler{}) 45 | } 46 | 47 | type handlerFunc func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error) 48 | 49 | func wrap(req handler.Request, f handlerFunc) (response handler.ProgressEvent) { 50 | defer func() { 51 | // Catch any panics and return a failed ProgressEvent 52 | if r := recover(); r != nil { 53 | err, ok := r.(error) 54 | if !ok { 55 | err = errors.New(fmt.Sprint(r)) 56 | } 57 | 58 | log.Printf("Trapped error in handler: %v", err) 59 | 60 | response = handler.NewFailedEvent(err) 61 | } 62 | }() 63 | 64 | // Populate the previous model 65 | prevModel := &resource.Model{} 66 | if err := req.UnmarshalPrevious(prevModel); err != nil { 67 | log.Printf("Error unmarshaling prev model: %v", err) 68 | return handler.NewFailedEvent(err) 69 | } 70 | 71 | // Populate the current model 72 | currentModel := &resource.Model{} 73 | if err := req.Unmarshal(currentModel); err != nil { 74 | log.Printf("Error unmarshaling model: %v", err) 75 | return handler.NewFailedEvent(err) 76 | } 77 | 78 | response, err := f(req, prevModel, currentModel) 79 | if err != nil { 80 | log.Printf("Error returned from handler function: %v", err) 81 | return handler.NewFailedEvent(err) 82 | } 83 | 84 | return response 85 | } 86 | -------------------------------------------------------------------------------- /examples/github-repo/cmd/resource/model.go: -------------------------------------------------------------------------------- 1 | // Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT. 2 | // Updates to this type are made my editing the schema file and executing the 'generate' command. 3 | package resource 4 | 5 | // Model is autogenerated from the json schema 6 | type Model struct { 7 | Name *string `json:",omitempty"` 8 | Owner *string `json:",omitempty"` 9 | Description *string `json:",omitempty"` 10 | Homepage *string `json:",omitempty"` 11 | OauthToken *string `json:",omitempty"` 12 | URL *string `json:",omitempty"` 13 | } 14 | -------------------------------------------------------------------------------- /examples/github-repo/cmd/resource/resource.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler" 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/google/go-github/github" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | func makeGitHubClient(token string) *github.Client { 16 | ts := oauth2.StaticTokenSource( 17 | &oauth2.Token{AccessToken: token}, 18 | ) 19 | 20 | tc := oauth2.NewClient(context.Background(), ts) 21 | 22 | return github.NewClient(tc) 23 | } 24 | 25 | func parseURL(url string) (string, string) { 26 | parts := strings.Split(url, "/") 27 | 28 | return parts[len(parts)-2], parts[len(parts)-1] 29 | } 30 | 31 | // Create handles the Create event from the Cloudformation service. 32 | func Create(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { 33 | client := makeGitHubClient(*currentModel.OauthToken) 34 | 35 | log.Printf("Attempting to create repository: %s/%s", *currentModel.Owner, *currentModel.Name) 36 | 37 | repo, resp, err := client.Repositories.Create(context.Background(), "", &github.Repository{ 38 | Name: currentModel.Name, 39 | Homepage: currentModel.Homepage, 40 | Description: currentModel.Description, 41 | Owner: &github.User{ 42 | Name: currentModel.Owner, 43 | }, 44 | }) 45 | 46 | if err != nil { 47 | return handler.ProgressEvent{}, err 48 | } 49 | 50 | if resp.StatusCode != 201 { 51 | log.Printf("Got a non-201 error code: %v", resp.Status) 52 | return handler.ProgressEvent{}, fmt.Errorf("Status Code: %d, Status: %v", resp.StatusCode, resp.Status) 53 | } 54 | 55 | currentModel.URL = aws.String(repo.GetURL()) 56 | 57 | return handler.ProgressEvent{ 58 | OperationStatus: handler.Success, 59 | Message: "Create Complete", 60 | ResourceModel: currentModel, 61 | }, nil 62 | } 63 | 64 | // Read handles the Read event from the Cloudformation service. 65 | func Read(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { 66 | owner, repoName := parseURL(*currentModel.URL) 67 | 68 | log.Printf("Looking for repository: %s/%s", *currentModel.Owner, *currentModel.Name) 69 | client := makeGitHubClient(*currentModel.OauthToken) 70 | repo, resp, err := client.Repositories.Get(context.Background(), owner, repoName) 71 | if err != nil { 72 | return handler.ProgressEvent{}, err 73 | } 74 | 75 | if resp.StatusCode != 200 { 76 | log.Printf("Unable to find repository: %s", resp.Status) 77 | return handler.ProgressEvent{}, fmt.Errorf("Status Code: %d, Status: %v", resp.StatusCode, resp.Status) 78 | } 79 | 80 | currentModel.Name = repo.Name 81 | currentModel.Owner = repo.Owner.Name 82 | currentModel.Description = repo.Description 83 | currentModel.Homepage = repo.Homepage 84 | 85 | return handler.ProgressEvent{ 86 | OperationStatus: handler.Success, 87 | Message: "Read Complete", 88 | ResourceModel: currentModel, 89 | }, nil 90 | } 91 | 92 | // Update handles the Update event from the Cloudformation service. 93 | func Update(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { 94 | owner, repoName := parseURL(*currentModel.URL) 95 | 96 | log.Printf("Looking for repository: %s/%s", *currentModel.Owner, *currentModel.Name) 97 | client := makeGitHubClient(*currentModel.OauthToken) 98 | 99 | _, resp, err := client.Repositories.Edit(context.Background(), owner, repoName, &github.Repository{ 100 | Homepage: currentModel.Homepage, 101 | Description: currentModel.Description, 102 | }) 103 | if err != nil { 104 | return handler.ProgressEvent{}, err 105 | } 106 | 107 | if resp.StatusCode != 200 { 108 | log.Printf("Unable to find repository: %s", resp.Status) 109 | return handler.ProgressEvent{}, fmt.Errorf("Status Code: %d, Status: %v", resp.StatusCode, resp.Status) 110 | } 111 | 112 | return handler.ProgressEvent{ 113 | OperationStatus: handler.Success, 114 | Message: "Update Complete", 115 | ResourceModel: currentModel, 116 | }, nil 117 | } 118 | 119 | // Delete handles the Delete event from the Cloudformation service. 120 | func Delete(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { 121 | owner, repoName := parseURL(*currentModel.URL) 122 | 123 | log.Printf("Looking for repository: %s/%s", *currentModel.Owner, *currentModel.Name) 124 | client := makeGitHubClient(*currentModel.OauthToken) 125 | 126 | resp, err := client.Repositories.Delete(context.Background(), owner, repoName) 127 | if err != nil { 128 | return handler.ProgressEvent{}, err 129 | } 130 | 131 | if resp.StatusCode != 200 { 132 | log.Printf("Unable to find repository: %s", resp.Status) 133 | return handler.ProgressEvent{}, fmt.Errorf("Status Code: %d, Status: %v", resp.StatusCode, resp.Status) 134 | } 135 | 136 | return handler.ProgressEvent{ 137 | OperationStatus: handler.Success, 138 | Message: "Delete Complete", 139 | ResourceModel: currentModel, 140 | }, nil 141 | } 142 | 143 | // List handles the List event from the Cloudformation service. 144 | func List(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) { 145 | return handler.ProgressEvent{ 146 | OperationStatus: handler.Success, 147 | Message: "List Complete", 148 | ResourceModel: currentModel, 149 | }, nil 150 | } 151 | -------------------------------------------------------------------------------- /examples/github-repo/docs/README.md: -------------------------------------------------------------------------------- 1 | # Example::GitHub::Repo 2 | 3 | Manages a GitHub Repo 4 | 5 | ## Syntax 6 | 7 | To declare this entity in your AWS CloudFormation template, use the following syntax: 8 | 9 | ### JSON 10 | 11 |
12 | { 13 | "Type" : "Example::GitHub::Repo", 14 | "Properties" : { 15 | "Name" : String, 16 | "Owner" : String, 17 | "Description" : String, 18 | "Homepage" : String, 19 | "OauthToken" : String, 20 | } 21 | } 22 |23 | 24 | ### YAML 25 | 26 |
27 | Type: Example::GitHub::Repo 28 | Properties: 29 | Name: String 30 | Owner: String 31 | Description: String 32 | Homepage: String 33 | OauthToken: String 34 |35 | 36 | ## Properties 37 | 38 | #### Name 39 | 40 | Name of the repository on GitHub 41 | 42 | _Required_: Yes 43 | 44 | _Type_: String 45 | 46 | _Minimum Length_:
1
47 |
48 | _Maximum Length_: 50
49 |
50 | _Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
51 |
52 | #### Owner
53 |
54 | Where to create the repository, either a user or an organization
55 |
56 | _Required_: Yes
57 |
58 | _Type_: String
59 |
60 | _Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
61 |
62 | #### Description
63 |
64 | The title of the TPS report is a mandatory element.
65 |
66 | _Required_: No
67 |
68 | _Type_: String
69 |
70 | _Minimum Length_: 20
71 |
72 | _Maximum Length_: 250
73 |
74 | _Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
75 |
76 | #### Homepage
77 |
78 | Homepage of the project
79 |
80 | _Required_: No
81 |
82 | _Type_: String
83 |
84 | _Minimum Length_: 20
85 |
86 | _Maximum Length_: 250
87 |
88 | _Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
89 |
90 | #### OauthToken
91 |
92 | OAuth token from GitHub
93 |
94 | _Required_: Yes
95 |
96 | _Type_: String
97 |
98 | _Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
99 |
100 | ## Return Values
101 |
102 | ### Ref
103 |
104 | When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the URL.
105 |
106 | ### Fn::GetAtt
107 |
108 | The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values.
109 |
110 | For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html).
111 |
112 | #### URL
113 |
114 | URL to the repository
115 |
--------------------------------------------------------------------------------
/examples/github-repo/example-github-repo.json:
--------------------------------------------------------------------------------
1 | {
2 | "typeName": "Example::GitHub::Repo",
3 | "description": "Manages a GitHub Repo",
4 | "sourceUrl": "https://github.com/aws-cloudformation/cloudformation-cli.git",
5 | "properties": {
6 | "Name": {
7 | "description": "Name of the repository on GitHub",
8 | "type": "string",
9 | "minLength": 1,
10 | "maxLength": 50
11 | },
12 | "Owner": {
13 | "description": "Where to create the repository, either a user or an organization",
14 | "type": "string"
15 | },
16 | "Description": {
17 | "description": "The title of the TPS report is a mandatory element.",
18 | "type": "string",
19 | "minLength": 20,
20 | "maxLength": 250
21 | },
22 | "Homepage": {
23 | "description": "Homepage of the project",
24 | "type": "string",
25 | "minLength": 20,
26 | "maxLength": 250
27 | },
28 | "OauthToken": {
29 | "description": "OAuth token from GitHub",
30 | "type": "string"
31 | },
32 | "URL": {
33 | "description": "URL to the repository",
34 | "type": "string"
35 | }
36 | },
37 | "required": [
38 | "Name",
39 | "OauthToken",
40 | "Owner"
41 | ],
42 | "additionalProperties": false,
43 | "readOnlyProperties": [
44 | "/properties/URL"
45 | ],
46 | "primaryIdentifier": [
47 | "/properties/URL"
48 | ],
49 | "handlers": {
50 | "create": {
51 | "permissions": []
52 | },
53 | "read": {
54 | "permissions": []
55 | },
56 | "update": {
57 | "permissions": []
58 | },
59 | "delete": {
60 | "permissions": []
61 | },
62 | "list": {
63 | "permissions": []
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/examples/github-repo/resource-role.yaml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: "2010-09-09"
2 | Description: >
3 | This CloudFormation template creates a role assumed by CloudFormation
4 | during CRUDL operations to mutate resources on behalf of the customer.
5 |
6 | Resources:
7 | ExecutionRole:
8 | Type: AWS::IAM::Role
9 | Properties:
10 | MaxSessionDuration: 8400
11 | AssumeRolePolicyDocument:
12 | Version: '2012-10-17'
13 | Statement:
14 | - Effect: Allow
15 | Principal:
16 | Service: resources.cloudformation.amazonaws.com
17 | Action: sts:AssumeRole
18 | Condition:
19 | StringEquals:
20 | aws:SourceAccount:
21 | Ref: AWS::AccountId
22 | StringLike:
23 | aws:SourceArn:
24 | Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/Example-GitHub-Repo/*
25 | Path: "/"
26 | Policies:
27 | - PolicyName: ResourceTypePolicy
28 | PolicyDocument:
29 | Version: '2012-10-17'
30 | Statement:
31 | - Effect: Deny
32 | Action:
33 | - "*"
34 | Resource: "*"
35 | Outputs:
36 | ExecutionRoleArn:
37 | Value:
38 | Fn::GetAtt: ExecutionRole.Arn
39 |
--------------------------------------------------------------------------------
/examples/github-repo/template.yml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: "2010-09-09"
2 | Transform: AWS::Serverless-2016-10-31
3 | Description: AWS SAM template for the Example::GitHub::Repo resource type
4 |
5 | Globals:
6 | Function:
7 | Timeout: 60 # docker start-up times can be long for SAM CLI
8 |
9 | Resources:
10 | TypeFunction:
11 | Type: AWS::Serverless::Function
12 | Metadata:
13 | BuildMethod: go1.x
14 | Properties:
15 | Handler: bootstrap
16 | Runtime: provided.al2
17 | Architectures:
18 | - x86_64
19 | CodeUri: bin/
20 | # Uncomment to test with AWS resources
21 | # Environment:
22 | # Variables:
23 | # AWS_FORCE_INTEGRATIONS: "true"
24 |
25 | TestEntrypoint:
26 | Type: AWS::Serverless::Function
27 | Metadata:
28 | BuildMethod: go1.x
29 | Properties:
30 | Handler: bootstrap
31 | Runtime: provided.al2
32 | Architectures:
33 | - x86_64
34 | CodeUri: bin/
35 |
--------------------------------------------------------------------------------
/generate-examples.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ ! -d "env" ]; then
4 | python3 -m venv env
5 | fi
6 |
7 | function deactivateEnv {
8 | rv=$?
9 | deactivate
10 | exit $?
11 | }
12 |
13 | trap "deactivateEnv" EXIT
14 |
15 | source env/bin/activate
16 |
17 | # uninstall cloudformation-cli-go-plugin if it exists
18 | pip3 show cloudformation-cli-go-plugin
19 | SHOW_RV=$?
20 |
21 | if [ "$SHOW_RV" == "0" ]; then
22 | pip3 uninstall -y cloudformation-cli-go-plugin
23 | fi
24 |
25 | pip3 install -e .
26 |
27 | EXAMPLES=( github-repo )
28 | for EXAMPLE in "${EXAMPLES[@]}"
29 | do
30 | cd examples/$EXAMPLE
31 | cfn generate
32 | done
33 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/aws-cloudformation/cloudformation-cli-go-plugin
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/avast/retry-go v2.7.0+incompatible
7 | github.com/aws/aws-lambda-go v1.37.0
8 | github.com/aws/aws-sdk-go v1.44.197
9 | github.com/google/go-cmp v0.5.9
10 | github.com/google/go-github v17.0.0+incompatible
11 | github.com/google/uuid v1.3.0
12 | github.com/segmentio/ksuid v1.0.4
13 | golang.org/x/oauth2 v0.5.0
14 | gopkg.in/validator.v2 v2.0.1
15 | )
16 |
17 | require (
18 | github.com/golang/protobuf v1.5.2 // indirect
19 | github.com/google/go-querystring v1.1.0 // indirect
20 | github.com/jmespath/go-jmespath v0.4.0 // indirect
21 | golang.org/x/net v0.6.0 // indirect
22 | google.golang.org/appengine v1.6.7 // indirect
23 | google.golang.org/protobuf v1.28.0 // indirect
24 | )
25 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/avast/retry-go v2.7.0+incompatible h1:XaGnzl7gESAideSjr+I8Hki/JBi+Yb9baHlMRPeSC84=
2 | github.com/avast/retry-go v2.7.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
3 | github.com/aws/aws-lambda-go v1.37.0 h1:WXkQ/xhIcXZZ2P5ZBEw+bbAKeCEcb5NtiYpSwVVzIXg=
4 | github.com/aws/aws-lambda-go v1.37.0/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM=
5 | github.com/aws/aws-sdk-go v1.44.197 h1:pkg/NZsov9v/CawQWy+qWVzJMIZRQypCtYjUBXFomF8=
6 | github.com/aws/aws-sdk-go v1.44.197/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
9 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
10 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
11 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
12 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
13 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
14 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
15 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
16 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
17 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
18 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
19 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
20 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
21 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
22 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
23 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
24 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
25 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
26 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
27 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
28 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
29 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
32 | github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
33 | github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
34 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
35 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
36 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
37 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
38 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
39 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
40 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
41 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
42 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
43 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
44 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
45 | golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
46 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
47 | golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
48 | golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
49 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
50 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
51 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
52 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
53 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
54 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
55 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
56 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
57 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
58 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
59 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
60 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
61 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
62 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
63 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
64 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
65 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
66 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
67 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
68 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
69 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
70 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
71 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
72 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
73 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
74 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
75 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
77 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
78 | gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=
79 | gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8=
80 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
81 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
82 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
83 |
--------------------------------------------------------------------------------
/python/rpdk/go/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | __version__ = "2.2.0"
4 |
5 | logging.getLogger(__name__).addHandler(logging.NullHandler())
6 |
--------------------------------------------------------------------------------
/python/rpdk/go/data/go.gitignore:
--------------------------------------------------------------------------------
1 | # macOS
2 | .DS_Store
3 | ._*
4 |
5 | # our logs
6 | rpdk.log*
7 |
8 | #compiled file
9 | bin/
10 |
11 | #vender
12 | vender/
13 |
14 | # contains credentials
15 | sam-tests/
16 |
--------------------------------------------------------------------------------
/python/rpdk/go/parser.py:
--------------------------------------------------------------------------------
1 | def setup_subparser(subparsers, parents):
2 | parser = subparsers.add_parser(
3 | "go",
4 | description="This sub command generates IDE and build files for Go",
5 | parents=parents,
6 | )
7 | parser.set_defaults(language="go")
8 |
9 | parser.add_argument(
10 | "-p",
11 | "--import-path",
12 | help="Select the go language import path.",
13 | )
14 |
15 | return parser
16 |
--------------------------------------------------------------------------------
/python/rpdk/go/resolver.py:
--------------------------------------------------------------------------------
1 | from rpdk.core.jsonutils.resolver import UNDEFINED, ContainerType
2 |
3 | PRIMITIVE_TYPES = {
4 | "string": "string",
5 | "integer": "int",
6 | "boolean": "bool",
7 | "number": "float64",
8 | UNDEFINED: "interface{}",
9 | }
10 |
11 |
12 | def translate_item_type(resolved_type):
13 | """
14 | translate_item_type converts JSON schema item types into Go types
15 | """
16 |
17 | # Another model
18 | if resolved_type.container == ContainerType.MODEL:
19 | return resolved_type.type
20 |
21 | # Primitive type
22 | if resolved_type.container == ContainerType.PRIMITIVE:
23 | return PRIMITIVE_TYPES[resolved_type.type]
24 |
25 | # Something more complex
26 | return translate_type(resolved_type)
27 |
28 |
29 | def translate_type(resolved_type):
30 | """
31 | translate_type converts JSON schema types into Go types
32 | """
33 |
34 | # Another model
35 | if resolved_type.container == ContainerType.MODEL:
36 | return "*" + resolved_type.type
37 |
38 | # Primitive type
39 | if resolved_type.container == ContainerType.PRIMITIVE:
40 | return "*" + PRIMITIVE_TYPES[resolved_type.type]
41 |
42 | # Composite type
43 | item_type = translate_item_type(resolved_type.type)
44 |
45 | # A dict
46 | if resolved_type.container == ContainerType.DICT:
47 | return f"map[string]{item_type}"
48 |
49 | # A list
50 | if resolved_type.container in (ContainerType.LIST, ContainerType.SET):
51 | return f"[]{item_type}"
52 |
53 | raise ValueError(f"Unknown container type {resolved_type.container}")
54 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build test clean
2 |
3 | build:
4 | make -f makebuild # this runs build steps required by the cfn cli
5 |
6 | test:
7 | cfn generate
8 | env GOOS=linux go build -ldflags="-s -w" -tags="lambda.norpc,$(TAGS)" -o bin/bootstrap cmd/main.go
9 |
10 | clean:
11 | rm -rf bin
12 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/README.md:
--------------------------------------------------------------------------------
1 | # {{ type_name }}
2 |
3 | Congratulations on starting development!
4 |
5 | Next steps:
6 |
7 | 1. Populate the JSON schema describing your resource, `{{ schema_path.name }}`
8 | 2. The RPDK will automatically generate the correct resource model from the
9 | schema whenever the project is built via Make.
10 | You can also do this manually with the following command: `{{ executable }} generate`
11 | 3. Implement your resource handlers by adding code to provision your resources in your resource handler's methods.
12 |
13 | Please don't modify files `{{ files }}`, as they will be automatically overwritten.
14 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/config.go.tple:
--------------------------------------------------------------------------------
1 | // Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT.
2 | // Updates to this type are made my editing the schema file and executing the 'generate' command.
3 | package resource
4 | import "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler"
5 |
6 | {% for model_name, properties in models.items() %}
7 |
8 | {% if model_name == "ResourceModel" %}
9 | {% set model_name = "Model" %}
10 | {% else %}
11 | {% set model_name = model_name | uppercase_first_letter %}
12 | {% endif %}
13 | // {{model_name}} is autogenerated from the json schema
14 | type {{model_name}} struct {
15 | {% for name, type in properties.items() %}
16 | {{ name|uppercase_first_letter }} {{ type|translate_type }} `json:",omitempty"`
17 | {% endfor %}
18 | }
19 |
20 | {% endfor %}
21 |
22 | // Configuration returns a resource's configuration.
23 | func Configuration(req handler.Request) (*TypeConfiguration, error) {
24 | // Populate the type configuration
25 | typeConfig := &TypeConfiguration{}
26 | if err := req.UnmarshalTypeConfig(typeConfig); err != nil {
27 | return typeConfig, err
28 | }
29 | return typeConfig, nil
30 | }
31 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/go.mod.tple:
--------------------------------------------------------------------------------
1 | module {{ path }}
2 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/main.go.tple:
--------------------------------------------------------------------------------
1 | // Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT.
2 | package main
3 |
4 | import (
5 | "errors"
6 | "fmt"
7 | "log"
8 |
9 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn"
10 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler"
11 | "{{ path }}"
12 | )
13 |
14 | // Handler is a container for the CRUDL actions exported by resources
15 | type Handler struct{}
16 |
17 | {% for method in ("Create", "Read", "Update", "Delete", "List") %}
18 |
19 | // {{ method }} wraps the related {{ method }} function exposed by the resource code
20 | func (r *Handler) {{ method }}(req handler.Request) handler.ProgressEvent {
21 | return wrap(req, resource.{{ method }})
22 | }
23 | {% endfor %}
24 |
25 | // main is the entry point of the application.
26 | func main() {
27 | cfn.Start(&Handler{})
28 | }
29 |
30 | type handlerFunc func(handler.Request, *resource.Model, *resource.Model) (handler.ProgressEvent, error)
31 |
32 | func wrap(req handler.Request, f handlerFunc) (response handler.ProgressEvent) {
33 | defer func() {
34 | // Catch any panics and return a failed ProgressEvent
35 | if r := recover(); r != nil {
36 | err, ok := r.(error)
37 | if !ok {
38 | err = errors.New(fmt.Sprint(r))
39 | }
40 |
41 | log.Printf("Trapped error in handler: %v", err)
42 |
43 | response = handler.NewFailedEvent(err)
44 | }
45 | }()
46 |
47 | // Populate the previous model
48 | prevModel := &resource.Model{}
49 | if err := req.UnmarshalPrevious(prevModel); err != nil {
50 | log.Printf("Error unmarshaling prev model: %v", err)
51 | return handler.NewFailedEvent(err)
52 | }
53 |
54 | // Populate the current model
55 | currentModel := &resource.Model{}
56 | if err := req.Unmarshal(currentModel); err != nil {
57 | log.Printf("Error unmarshaling model: %v", err)
58 | return handler.NewFailedEvent(err)
59 | }
60 |
61 | response, err := f(req, prevModel, currentModel)
62 | if err != nil {
63 | log.Printf("Error returned from handler function: %v", err)
64 | return handler.NewFailedEvent(err)
65 | }
66 |
67 | return response
68 | }
69 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/makebuild:
--------------------------------------------------------------------------------
1 | # This file is autogenerated, do not edit;
2 | # changes will be undone by the next 'generate' command.
3 |
4 | .PHONY: build
5 | build:
6 | cfn generate
7 | env GOARCH=amd64 GOOS=linux go build -ldflags="-s -w" -tags="lambda.norpc,$(TAGS)" -o bin/bootstrap cmd/main.go
8 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/stubHandler.go.tple:
--------------------------------------------------------------------------------
1 | package resource
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/aws-cloudformation/cloudformation-cli-go-plugin/cfn/handler"
7 | )
8 |
9 | {% for method in ("Create", "Read", "Update", "Delete", "List") %}
10 |
11 | // {{ method }} handles the {{ method }} event from the Cloudformation service.
12 | func {{ method }}(req handler.Request, prevModel *Model, currentModel *Model) (handler.ProgressEvent, error) {
13 | // Add your code here:
14 | // * Make API calls (use req.Session)
15 | // * Mutate the model
16 | // * Check/set any callback context (req.CallbackContext / response.CallbackContext)
17 | // * Access the resource's configuration with the Configuration function. (c, err := Configuration(req))
18 |
19 | /*
20 | // Construct a new handler.ProgressEvent and return it
21 | response := handler.ProgressEvent{
22 | OperationStatus: handler.Success,
23 | Message: "{{ method }} complete",
24 | ResourceModel: currentModel,
25 | }
26 |
27 | return response, nil
28 | */
29 |
30 | // Not implemented, return an empty handler.ProgressEvent
31 | // and an error
32 | return handler.ProgressEvent{}, errors.New("Not implemented: {{ method }}")
33 | }
34 | {% endfor %}
35 |
--------------------------------------------------------------------------------
/python/rpdk/go/templates/types.go.tple:
--------------------------------------------------------------------------------
1 | // Code generated by 'cfn generate', changes will be undone by the next invocation. DO NOT EDIT.
2 | // Updates to this type are made my editing the schema file and executing the 'generate' command.
3 | package resource
4 |
5 | {% for model_name, properties in models.items() %}
6 |
7 | {% if model_name == "ResourceModel" %}
8 | {% set model_name = "Model" %}
9 | {% else %}
10 | {% set model_name = model_name | uppercase_first_letter %}
11 | {% endif %}
12 | // {{model_name}} is autogenerated from the json schema
13 | type {{model_name}} struct {
14 | {% for name, type in properties.items() %}
15 | {{ name|uppercase_first_letter }} {{ type|translate_type }} `json:",omitempty"`
16 | {% endfor %}
17 | }
18 |
19 | {% endfor %}
20 |
--------------------------------------------------------------------------------
/python/rpdk/go/utils.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | # https://golang.org/ref/spec#Keywords
4 | LANGUAGE_KEYWORDS = {
5 | "break",
6 | "default",
7 | "func",
8 | "interface",
9 | "select",
10 | "case",
11 | "defer",
12 | "go",
13 | "map",
14 | "struct",
15 | "chan",
16 | "else",
17 | "goto",
18 | "package",
19 | "switch",
20 | "const",
21 | "fallthrough",
22 | "if",
23 | "range",
24 | "type",
25 | "continue",
26 | "for",
27 | "import",
28 | "return",
29 | "var",
30 | }
31 |
32 |
33 | def safe_reserved(string: str) -> str:
34 | if string in LANGUAGE_KEYWORDS:
35 | return string + "_"
36 | return string
37 |
38 |
39 | def validate_path(default: str) -> Callable[[str], str]:
40 | def _validate_namespace(value: str) -> str:
41 | if not value:
42 | return default
43 |
44 | namespace = value
45 |
46 | return namespace
47 |
48 | return _validate_namespace
49 |
--------------------------------------------------------------------------------
/python/rpdk/go/version.py:
--------------------------------------------------------------------------------
1 | """
2 | The version package contains warning messages that should be displayed
3 | to a Go plugin user when upgrading from an older version of the plugin
4 | """
5 |
6 | import semver
7 | from typing import List
8 |
9 | # pylint: disable=pointless-string-statement
10 | """
11 | If there are breaking changes that need to be communicated to a user,
12 | add them to this dict. For readability, an opening and closing newline
13 | is recommended for each warning message
14 | """
15 | WARNINGS = {
16 | # FIXME: Version number to be finalised
17 | semver.VersionInfo(
18 | 0, 1, 3
19 | ): """
20 | Generated models no longer use the types exported in the encoding package.
21 | Your model's fields have been regenerated using standard pointer types
22 | (*string, *int, etc) as used in the AWS Go SDK.
23 | The AWS SDK has helper functions that you can use to get and set your model's values.
24 |
25 | Make the following changes to your handler code as needed:
26 |
27 | * Replace `encoding.New{Type}` with `aws.{Type}`
28 | * Replace `model.{field}.Value()` with `aws.{Type}Value(model.{field})`
29 |
30 | Where {Type} is either String, Bool, Int, or Float64 and {field} is any field within
31 | your generated model.
32 | """
33 | }
34 |
35 |
36 | def check_version(current_version: str) -> List[str]:
37 | """
38 | check_version compares the user's current plugin version with each
39 | version in WARNINGS and returns any appropriate messages
40 | """
41 |
42 | if current_version is not None:
43 | current_version = semver.VersionInfo.parse(current_version)
44 |
45 | return [
46 | f"Change message for Go plugin v{version}:" + WARNINGS[version]
47 | for version in sorted(WARNINGS.keys())
48 | if current_version is None or current_version < version
49 | ]
50 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | license_file = LICENSE
3 | description-file = README.md
4 |
5 | [flake8]
6 | exclude =
7 | .git,
8 | __pycache__,
9 | build,
10 | dist,
11 | *.pyc,
12 | *.egg-info,
13 | .cache,
14 | .eggs,
15 | .tox
16 | max-complexity = 10
17 | max-line-length = 88
18 | select = C,E,F,W,B,B950
19 | # C812, C815, W503 clash with black, F723 false positive
20 | ignore = E501,C812,C815,C816,W503,F723
21 |
22 | [isort]
23 | line_length = 88
24 | indent = ' '
25 | multi_line_output = 3
26 | default_section = FIRSTPARTY
27 | skip = env
28 | include_trailing_comma = true
29 | combine_as_imports = True
30 | force_grid_wrap = 0
31 | known_standard_library = dataclasses
32 | known_first_party = rpdk
33 | known_third_party = boto3,jinja2,cloudformation_cli_python_lib,pytest
34 |
35 | [tool:pytest]
36 | # can't do anything about 3rd party modules, so don't spam us
37 | filterwarnings =
38 | ignore::DeprecationWarning:botocore
39 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os.path
3 | import re
4 | from setuptools import setup
5 |
6 | HERE = os.path.abspath(os.path.dirname(__file__))
7 |
8 |
9 | def read(*parts):
10 | with open(os.path.join(HERE, *parts), "r", encoding="utf-8") as fp:
11 | return fp.read()
12 |
13 |
14 | # https://packaging.python.org/guides/single-sourcing-package-version/
15 | def find_version(*file_paths):
16 | version_file = read(*file_paths)
17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
18 | if version_match:
19 | return version_match.group(1)
20 | raise RuntimeError("Unable to find version string.")
21 |
22 |
23 | setup(
24 | name="cloudformation-cli-go-plugin",
25 | version=find_version("python", "rpdk", "go", "__init__.py"),
26 | description=__doc__,
27 | long_description=read("README.md"),
28 | long_description_content_type="text/markdown",
29 | author="Amazon Web Services",
30 | author_email="aws-cloudformation-developers@amazon.com",
31 | url="https://github.com/aws-cloudformation/cloudformation-cli-go-plugin/",
32 | # https://packaging.python.org/guides/packaging-namespace-packages/
33 | packages=["rpdk.go"],
34 | package_dir={"": "python"},
35 | # package_data -> use MANIFEST.in instead
36 | include_package_data=True,
37 | zip_safe=True,
38 | install_requires=["cloudformation-cli>=0.1.14", "semver>=2.9.0"],
39 | python_requires=">=3.8",
40 | entry_points={
41 | "rpdk.v1.languages": ["go = rpdk.go.codegen:GoLanguagePlugin"],
42 | "rpdk.v1.parsers": ["go = rpdk.go.parser:setup_subparser"],
43 | },
44 | license="Apache License 2.0",
45 | classifiers=[
46 | "Development Status :: 4 - Beta",
47 | "Intended Audience :: Developers",
48 | "License :: OSI Approved :: Apache Software License",
49 | "Natural Language :: English",
50 | "Topic :: Software Development :: Build Tools",
51 | "Topic :: Software Development :: Code Generators",
52 | "Operating System :: OS Independent",
53 | "Programming Language :: Python :: 3 :: Only",
54 | "Programming Language :: Python :: 3.8",
55 | "Programming Language :: Python :: 3.9",
56 | "Programming Language :: Python :: 3.10",
57 | "Programming Language :: Python :: 3.11",
58 | ],
59 | keywords="Amazon Web Services AWS CloudFormation",
60 | )
61 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli-go-plugin/a96dc21b798d1d23e7d1fb7cd6e17b4f0fc96362/tests/__init__.py
--------------------------------------------------------------------------------
/tests/data/schema-with-typeconfiguration.json:
--------------------------------------------------------------------------------
1 | {
2 | "typeName": "Company::Test::Type",
3 | "description": "Test type",
4 | "typeConfiguration": {
5 | "properties": {
6 | "Credentials": {
7 | "$ref": "#/definitions/Credentials"
8 | }
9 | },
10 | "additionalProperties": false,
11 | "required": [
12 | "Credentials"
13 | ]
14 | },
15 | "definitions": {
16 | "Credentials": {
17 | "type": "object",
18 | "properties": {
19 | "ApiKey": {
20 | "description": "API key",
21 | "type": "string"
22 | },
23 | "ApplicationKey": {
24 | "description": "application key",
25 | "type": "string"
26 | },
27 | "CountryCode": {
28 | "type": "string"
29 | }
30 | },
31 | "additionalProperties": false
32 | }
33 | },
34 | "properties": {
35 | "Type": {
36 | "type": "string",
37 | "description": "The type of the monitor",
38 | "enum": [
39 | "composite"
40 | ]
41 | }
42 | },
43 | "required": [
44 | "Type"
45 | ],
46 | "primaryIdentifier": [
47 | "/properties/Type"
48 | ],
49 | "additionalProperties": false
50 | }
51 |
--------------------------------------------------------------------------------
/tests/plugin/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli-go-plugin/a96dc21b798d1d23e7d1fb7cd6e17b4f0fc96362/tests/plugin/__init__.py
--------------------------------------------------------------------------------
/tests/plugin/codegen_test.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=redefined-outer-name,protected-access
2 | import pytest
3 |
4 | from pathlib import Path
5 | from rpdk.core.exceptions import DownstreamError
6 | from rpdk.core.project import Project
7 | from rpdk.go.codegen import GoLanguagePlugin
8 | from shutil import copyfile
9 | from unittest.mock import patch
10 |
11 | TYPE_NAME = "foo::bar::baz"
12 |
13 | TEST_TARGET_INFO = {
14 | "My::Example::Resource": {
15 | "TargetName": "My::Example::Resource",
16 | "TargetType": "RESOURCE",
17 | "Schema": {
18 | "typeName": "My::Example::Resource",
19 | "additionalProperties": False,
20 | "properties": {
21 | "Id": {"type": "string"},
22 | "Tags": {
23 | "type": "array",
24 | "uniqueItems": False,
25 | "items": {"$ref": "#/definitions/Tag"},
26 | },
27 | },
28 | "required": [],
29 | "definitions": {
30 | "Tag": {
31 | "type": "object",
32 | "additionalProperties": False,
33 | "properties": {
34 | "Value": {"type": "string"},
35 | "Key": {"type": "string"},
36 | },
37 | "required": ["Value", "Key"],
38 | }
39 | },
40 | },
41 | "ProvisioningType": "FULLY_MUTTABLE",
42 | "IsCfnRegistrySupportedType": True,
43 | "SchemaFileAvailable": True,
44 | },
45 | "My::Other::Resource": {
46 | "TargetName": "My::Other::Resource",
47 | "TargetType": "RESOURCE",
48 | "Schema": {
49 | "typeName": "My::Other::Resource",
50 | "additionalProperties": False,
51 | "properties": {
52 | "Id": {"type": "string"},
53 | "Tags": {
54 | "type": "array",
55 | "uniqueItems": False,
56 | "items": {"$ref": "#/definitions/Tag"},
57 | },
58 | },
59 | "required": [],
60 | "definitions": {
61 | "Tag": {
62 | "type": "object",
63 | "additionalProperties": False,
64 | "properties": {
65 | "Value": {"type": "string"},
66 | "Key": {"type": "string"},
67 | },
68 | "required": ["Value", "Key"],
69 | }
70 | },
71 | },
72 | "ProvisioningType": "NOT_PROVISIONABLE",
73 | "IsCfnRegistrySupportedType": False,
74 | "SchemaFileAvailable": True,
75 | },
76 | }
77 |
78 |
79 | @pytest.fixture
80 | def plugin():
81 | return GoLanguagePlugin()
82 |
83 |
84 | @pytest.fixture
85 | def resource_project(tmp_path):
86 | project = Project(root=tmp_path)
87 |
88 | patch_plugins = patch.dict(
89 | "rpdk.core.plugin_registry.PLUGIN_REGISTRY",
90 | {"go": lambda: GoLanguagePlugin},
91 | clear=True,
92 | )
93 | patch_wizard = patch(
94 | "rpdk.go.codegen.input_with_validation", autospec=True, side_effect=[False]
95 | )
96 | with patch_plugins, patch_wizard:
97 | project.init(TYPE_NAME, "go")
98 | return project
99 |
100 |
101 | def get_files_in_project(project):
102 | return {
103 | str(child.relative_to(project.root)): child for child in project.root.rglob("*")
104 | }
105 |
106 |
107 | def test_initialize_resource(resource_project):
108 | assert resource_project.settings == {
109 | "import_path": "False",
110 | "protocolVersion": "2.0.0",
111 | }
112 |
113 | files = get_files_in_project(resource_project)
114 | assert set(files) == {
115 | ".gitignore",
116 | ".rpdk-config",
117 | "Makefile",
118 | "README.md",
119 | "cmd",
120 | "cmd/resource",
121 | "cmd/resource/resource.go",
122 | "foo-bar-baz.json",
123 | "go.mod",
124 | "internal",
125 | "example_inputs/inputs_1_invalid.json",
126 | "example_inputs/inputs_1_update.json",
127 | "example_inputs/inputs_1_create.json",
128 | "example_inputs",
129 | "template.yml",
130 | }
131 |
132 | readme = files["README.md"].read_text()
133 | assert resource_project.type_name in readme
134 |
135 | assert resource_project.entrypoint in files["template.yml"].read_text()
136 |
137 |
138 | def test_generate_resource(resource_project):
139 | resource_project.load_schema()
140 | before = get_files_in_project(resource_project)
141 | resource_project.generate()
142 | after = get_files_in_project(resource_project)
143 | files = after.keys() - before.keys() - {"resource-role.yaml"}
144 |
145 | assert files == {
146 | "makebuild",
147 | "cmd/main.go",
148 | "cmd/resource/config.go",
149 | "cmd/resource/model.go",
150 | }
151 |
152 |
153 | def test_generate_resource_go_failure(resource_project):
154 | resource_project.load_schema()
155 |
156 | with patch("rpdk.go.codegen.subprocess_run") as mock_subprocess:
157 | mock_subprocess.side_effect = FileNotFoundError()
158 | with pytest.raises(DownstreamError, match="go fmt failed"):
159 | resource_project.generate()
160 |
161 |
162 | def test_generate_resource_with_type_configuration(tmp_path):
163 | type_name = "schema::with::typeconfiguration"
164 | project = Project(root=tmp_path)
165 |
166 | patch_plugins = patch.dict(
167 | "rpdk.core.plugin_registry.PLUGIN_REGISTRY",
168 | {"go": lambda: GoLanguagePlugin},
169 | clear=True,
170 | )
171 | patch_wizard = patch(
172 | "rpdk.go.codegen.input_with_validation", autospec=True, side_effect=[False]
173 | )
174 | with patch_plugins, patch_wizard:
175 | project.init(type_name, "go")
176 |
177 | copyfile(
178 | str(Path.cwd() / "tests/data/schema-with-typeconfiguration.json"),
179 | str(project.root / "schema-with-typeconfiguration.json"),
180 | )
181 | project.type_info = ("schema", "with", "typeconfiguration")
182 | project.load_schema()
183 | project.load_configuration_schema()
184 | project.generate()
185 |
186 | type_configuration_schema_file = project.root / "schema-with-typeconfiguration.json"
187 | assert type_configuration_schema_file.is_file()
188 |
--------------------------------------------------------------------------------
/tests/plugin/parser_test.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | from rpdk.go.parser import setup_subparser
3 |
4 |
5 | def test_setup_subparser():
6 | parser = argparse.ArgumentParser()
7 | subparsers = parser.add_subparsers(dest="subparser_name")
8 |
9 | sub_parser = setup_subparser(subparsers, [])
10 |
11 | args = sub_parser.parse_args(["-p", "/path/"])
12 |
13 | assert args.language == "go"
14 | assert args.import_path == "/path/"
15 |
--------------------------------------------------------------------------------