From 756d1a41dd339a4e9be42efdbb5e265e5399a511 Mon Sep 17 00:00:00 2001 From: yhortuk <17699384+yhortuk@users.noreply.github.com> Date: Sun, 18 Dec 2022 22:47:47 +0200 Subject: [PATCH] 0.1.0 --- .editorconfig | 21 + .gitattributes | 7 + .gitignore | 8 + LICENSE | 202 +++++ README.md | 20 + app/Aws/Certificate.php | 80 ++ app/Aws/ContinuousIntegration.php | 143 ++++ app/Aws/Dashboard.php | 47 ++ app/Aws/Domain.php | 91 +++ app/Aws/Lambda.php | 38 + app/Aws/Network.php | 175 +++++ app/Aws/PendingStack.php | 51 ++ app/Aws/Storage.php | 102 +++ app/Aws/SystemManager.php | 111 +++ app/Cloudformation.php | 44 ++ app/Commands/BootstrapCommand.php | 103 +++ app/Commands/BuildCommand.php | 47 ++ app/Commands/CiCommand.php | 27 + app/Commands/Command.php | 69 ++ app/Commands/DashboardCommand.php | 19 + app/Commands/DeployCommand.php | 36 + app/Commands/DestroyCommand.php | 33 + app/Commands/DomainCommand.php | 29 + app/Commands/EnvCommand.php | 29 + app/Commands/ExecCommand.php | 22 + app/Commands/LogCommand.php | 53 ++ app/Commands/NetworkCommand.php | 23 + app/Commands/PipelineCommand.php | 40 + app/Configs/BootstrapConfig.php | 55 ++ app/Configs/LayerConfig.php | 46 ++ app/Configs/UnloadConfig.php | 416 ++++++++++ app/Constructs/BucketConstruct.php | 48 ++ app/Constructs/CacheConstruct.php | 46 ++ app/Constructs/CloudfrontConstruct.php | 54 ++ app/Constructs/DatabaseConstruct.php | 85 +++ app/Constructs/DnsConstruct.php | 36 + app/Constructs/EnvironmentConstruct.php | 53 ++ app/Constructs/EventConstruct.php | 41 + app/Constructs/NetworkConstruct.php | 45 ++ app/Constructs/PoliciesConstruct.php | 23 + app/Constructs/QueueConstruct.php | 86 +++ app/Constructs/SessionConstruct.php | 49 ++ app/Handler.php | 28 + app/Oidcs/Bitbucket.php | 36 + app/Oidcs/Github.php | 37 + app/Oidcs/OidcFactory.php | 22 + app/Oidcs/OidcInterface.php | 14 + app/Path.php | 127 ++++ app/Providers/AppServiceProvider.php | 61 ++ app/System.php | 23 + app/Task.php | 40 + app/Tasks/CleanupFilesTask.php | 59 ++ app/Tasks/CleanupPipelineConfigTask.php | 16 + app/Tasks/CleanupVendorTask.php | 38 + .../CopyFormationToBuildDirectoryTask.php | 17 + app/Tasks/CopySourceToBuildDirectoryTask.php | 40 + .../DestroyComposerPlatformCheckTask.php | 18 + .../DestroyContinuousIntegrationTask.php | 13 + app/Tasks/EmptyAwsCredentialTask.php | 30 + app/Tasks/ExecuteBuildTask.php | 47 ++ app/Tasks/ExecuteDeployTask.php | 18 + app/Tasks/ExecuteSamBuildTask.php | 24 + app/Tasks/ExecuteSamDeleteTask.php | 26 + app/Tasks/ExecuteSamDeployTask.php | 31 + app/Tasks/ExtractStaticAssetsTask.php | 36 + app/Tasks/FlushEnvironmentTask.php | 13 + app/Tasks/GenerateMakefileTask.php | 26 + app/Tasks/GeneratePipelineTask.php | 68 ++ app/Tasks/GeneratePipelineTemplateTask.php | 25 + app/Tasks/GenerateSamConfigTask.php | 13 + app/Tasks/GenerateSamTemplateTask.php | 13 + app/Tasks/GenerateUnloadTemplateTask.php | 22 + app/Tasks/InitCertificateTask.php | 14 + app/Tasks/InitContinuousIntegrationTask.php | 24 + app/Tasks/InitEnvironmentTask.php | 45 ++ app/Tasks/InitNetworkTask.php | 24 + app/Tasks/PrepareBuildDirectoryTask.php | 18 + app/Tasks/SetupEnvFileTask.php | 15 + app/Tasks/UploadAssetTask.php | 55 ++ app/Templates/SamConfigTemplate.php | 88 +++ app/Templates/SamPipelineTemplate.php | 45 ++ app/Templates/SamTemplate.php | 217 ++++++ app/Templates/Template.php | 17 + app/Templates/UnloadTemplate.php | 20 + bootstrap/app.php | 55 ++ box.json | 20 + cloudformation/construct/certificate.yaml.php | 47 ++ cloudformation/construct/ci.yaml | 354 +++++++++ cloudformation/construct/dns.yaml.php | 54 ++ cloudformation/construct/website.yaml | 251 ++++++ cloudformation/deploy.js | 70 ++ cloudformation/network/nat-gateway.yaml | 168 ++++ cloudformation/network/nat-instance.yaml | 475 ++++++++++++ cloudformation/network/vpc-1az.yaml | 344 +++++++++ cloudformation/network/vpc-2az.yaml | 415 ++++++++++ cloudformation/network/vpc-sg.yaml | 33 + cloudformation/queue/standard.yaml | 87 +++ cloudformation/storage/bucket.yaml | 109 +++ cloudformation/storage/mysql-serverless.yaml | 168 ++++ cloudformation/storage/mysql.yaml | 160 ++++ cloudformation/storage/redis.yaml | 178 +++++ composer.json | 52 ++ config/app.php | 60 ++ config/commands.php | 82 ++ config/logo.php | 84 ++ logo.png | Bin 0 -> 12523 bytes phpunit.xml | 24 + resources/stop.flf | 718 ++++++++++++++++++ resources/unload.yaml.stub | 11 + resources/unload01.json | 160 ++++ tests/CreatesApplication.php | 22 + tests/Feature/InspireCommandTest.php | 13 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 5 + unload | 53 ++ 115 files changed, 8598 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/Aws/Certificate.php create mode 100644 app/Aws/ContinuousIntegration.php create mode 100644 app/Aws/Dashboard.php create mode 100644 app/Aws/Domain.php create mode 100644 app/Aws/Lambda.php create mode 100644 app/Aws/Network.php create mode 100644 app/Aws/PendingStack.php create mode 100644 app/Aws/Storage.php create mode 100644 app/Aws/SystemManager.php create mode 100644 app/Cloudformation.php create mode 100644 app/Commands/BootstrapCommand.php create mode 100644 app/Commands/BuildCommand.php create mode 100644 app/Commands/CiCommand.php create mode 100644 app/Commands/Command.php create mode 100644 app/Commands/DashboardCommand.php create mode 100644 app/Commands/DeployCommand.php create mode 100644 app/Commands/DestroyCommand.php create mode 100644 app/Commands/DomainCommand.php create mode 100644 app/Commands/EnvCommand.php create mode 100644 app/Commands/ExecCommand.php create mode 100644 app/Commands/LogCommand.php create mode 100644 app/Commands/NetworkCommand.php create mode 100644 app/Commands/PipelineCommand.php create mode 100644 app/Configs/BootstrapConfig.php create mode 100644 app/Configs/LayerConfig.php create mode 100644 app/Configs/UnloadConfig.php create mode 100644 app/Constructs/BucketConstruct.php create mode 100644 app/Constructs/CacheConstruct.php create mode 100644 app/Constructs/CloudfrontConstruct.php create mode 100644 app/Constructs/DatabaseConstruct.php create mode 100644 app/Constructs/DnsConstruct.php create mode 100644 app/Constructs/EnvironmentConstruct.php create mode 100644 app/Constructs/EventConstruct.php create mode 100644 app/Constructs/NetworkConstruct.php create mode 100644 app/Constructs/PoliciesConstruct.php create mode 100644 app/Constructs/QueueConstruct.php create mode 100644 app/Constructs/SessionConstruct.php create mode 100644 app/Handler.php create mode 100644 app/Oidcs/Bitbucket.php create mode 100644 app/Oidcs/Github.php create mode 100644 app/Oidcs/OidcFactory.php create mode 100644 app/Oidcs/OidcInterface.php create mode 100644 app/Path.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/System.php create mode 100644 app/Task.php create mode 100644 app/Tasks/CleanupFilesTask.php create mode 100644 app/Tasks/CleanupPipelineConfigTask.php create mode 100644 app/Tasks/CleanupVendorTask.php create mode 100644 app/Tasks/CopyFormationToBuildDirectoryTask.php create mode 100644 app/Tasks/CopySourceToBuildDirectoryTask.php create mode 100644 app/Tasks/DestroyComposerPlatformCheckTask.php create mode 100644 app/Tasks/DestroyContinuousIntegrationTask.php create mode 100644 app/Tasks/EmptyAwsCredentialTask.php create mode 100644 app/Tasks/ExecuteBuildTask.php create mode 100644 app/Tasks/ExecuteDeployTask.php create mode 100644 app/Tasks/ExecuteSamBuildTask.php create mode 100644 app/Tasks/ExecuteSamDeleteTask.php create mode 100644 app/Tasks/ExecuteSamDeployTask.php create mode 100644 app/Tasks/ExtractStaticAssetsTask.php create mode 100644 app/Tasks/FlushEnvironmentTask.php create mode 100644 app/Tasks/GenerateMakefileTask.php create mode 100644 app/Tasks/GeneratePipelineTask.php create mode 100644 app/Tasks/GeneratePipelineTemplateTask.php create mode 100644 app/Tasks/GenerateSamConfigTask.php create mode 100644 app/Tasks/GenerateSamTemplateTask.php create mode 100644 app/Tasks/GenerateUnloadTemplateTask.php create mode 100644 app/Tasks/InitCertificateTask.php create mode 100644 app/Tasks/InitContinuousIntegrationTask.php create mode 100644 app/Tasks/InitEnvironmentTask.php create mode 100644 app/Tasks/InitNetworkTask.php create mode 100644 app/Tasks/PrepareBuildDirectoryTask.php create mode 100644 app/Tasks/SetupEnvFileTask.php create mode 100644 app/Tasks/UploadAssetTask.php create mode 100644 app/Templates/SamConfigTemplate.php create mode 100644 app/Templates/SamPipelineTemplate.php create mode 100644 app/Templates/SamTemplate.php create mode 100644 app/Templates/Template.php create mode 100644 app/Templates/UnloadTemplate.php create mode 100644 bootstrap/app.php create mode 100644 box.json create mode 100644 cloudformation/construct/certificate.yaml.php create mode 100644 cloudformation/construct/ci.yaml create mode 100644 cloudformation/construct/dns.yaml.php create mode 100644 cloudformation/construct/website.yaml create mode 100644 cloudformation/deploy.js create mode 100644 cloudformation/network/nat-gateway.yaml create mode 100644 cloudformation/network/nat-instance.yaml create mode 100644 cloudformation/network/vpc-1az.yaml create mode 100644 cloudformation/network/vpc-2az.yaml create mode 100644 cloudformation/network/vpc-sg.yaml create mode 100644 cloudformation/queue/standard.yaml create mode 100644 cloudformation/storage/bucket.yaml create mode 100644 cloudformation/storage/mysql-serverless.yaml create mode 100644 cloudformation/storage/mysql.yaml create mode 100644 cloudformation/storage/redis.yaml create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/commands.php create mode 100644 config/logo.php create mode 100644 logo.png create mode 100644 phpunit.xml create mode 100644 resources/stop.flf create mode 100644 resources/unload.yaml.stub create mode 100644 resources/unload01.json create mode 100644 tests/CreatesApplication.php create mode 100755 tests/Feature/InspireCommandTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100755 unload diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fadfe99 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_style = space +indent_size = 1 + +[*.yaml] +indent_style = space +indent_size = 2 + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2045778 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto +/.github export-ignore +.styleci.yml export-ignore +.scrutinizer.yml export-ignore +BACKERS.md export-ignore +CONTRIBUTING.md export-ignore +CHANGELOG.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a542c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/vendor +/.idea +/.vscode +/.vagrant +.phpunit.result.cache +/.unload +/builds +composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f71f43 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..81c9d54 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +

+ +

+ +### The simplest way to build and deploy a serverless PHP application with AWS Cloud + +The opinionated wrapper on top of AWS SAM that simplifies the entry point for PHP developers in the serverless world. It was highly inspired by the Laravel Vapor platform, but attempts to offer an AWS way for the deployment process while being cost-effective for small projects. + +For now supports Laravel framework only, but has plans to expand to other frameworks too. + +- Straingforward configuration format to setup core application infractructure. +- Start with nearly zero costs on AWS free tier and scale to production capacity in a few simple steps. +- Provides templates for Github and Bitbucket to bootstrap your automated CI/CD pipeline. +- Build on top of existing and battle tested tools like AWS SAM, AWS Cloudformation and Bref.sh. + +For full documentation, visit [unload.sh](https://unload.sh/). + +## License + +Unload is an open-source software licensed under the Apache License Version 2.0 license. diff --git a/app/Aws/Certificate.php b/app/Aws/Certificate.php new file mode 100644 index 0000000..2c8a689 --- /dev/null +++ b/app/Aws/Certificate.php @@ -0,0 +1,80 @@ +cloudformation = new CloudFormationClient(['region' => 'us-east-1', 'profile' => $unload->profile(), 'version' => 'latest',]); + $this->domain = $domain; + $this->unload = $unload; + } + + public function createStack(): ?PendingStack + { + if (!$this->unload->domains()) { + return null; + } + + $domains = $this->domain->listRoot(); + $stackName = $this->unload->certificateStackName(); + + try { + $this->cloudformation->describeStacks(['StackName' => $stackName])->get('Stacks'); + $this->cloudformation->updateStack([ + 'StackName' => $stackName, + 'EnableTerminationProtection' => true, + 'TemplateBody' => Cloudformation::get("construct/certificate", compact('domains')), + 'Capabilities' => ['CAPABILITY_IAM'], + 'Tags' => $this->unload->unloadTags(), + ]); + } catch (CloudFormationException $e) { + + if (Str::of($e->getMessage())->contains('No updates are to be performed')) { + return null; + } + + $this->cloudformation->createStack([ + 'StackName' => $stackName, + 'EnableTerminationProtection' => true, + 'TemplateBody' => Cloudformation::get("construct/certificate", compact('domains')), + 'Capabilities' => ['CAPABILITY_IAM'], + 'Tags' => $this->unload->unloadTags(), + ]); + } + + return new PendingStack($stackName, $this->cloudformation); + } + + public function deleteStack(): ?PendingStack + { + try { + $this->cloudformation->describeStacks(['StackName' => $this->unload->certificateStackName()])->get('Stacks'); + $this->cloudformation->deleteStack(['StackName' => $this->unload->certificateStackName()]); + return new PendingStack($this->unload->certificateStackName(), $this->cloudformation); + } catch (CloudFormationException) { + return null; + } + } + + public function getCertificateArn(): string + { + $outputs = collect($this->cloudformation->describeStacks(['StackName' => $this->unload->certificateStackName()]) + ->search('Stacks[0].Outputs'))->keyBy('OutputKey'); + + return $outputs->get('CertificateArn')['OutputValue']; + } +} diff --git a/app/Aws/ContinuousIntegration.php b/app/Aws/ContinuousIntegration.php new file mode 100644 index 0000000..52ee7f3 --- /dev/null +++ b/app/Aws/ContinuousIntegration.php @@ -0,0 +1,143 @@ +cloudformation = new CloudFormationClient(['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest',]); + $this->iam = new IamClient(['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest',]); + $this->unload = $unload; + } + + public function createStack(OidcInterface $provider): PendingStack + { + $oidcArn = $this->getOpenIDConnectProviderArn($provider); + $parameters = [ + [ + 'ParameterKey' => 'Application', + 'ParameterValue' => $this->unload->app(), + ], + [ + 'ParameterKey' => 'Env', + 'ParameterValue' => $this->unload->env(), + ], + [ + 'ParameterKey' => 'IdentityProviderThumbprint', + 'ParameterValue' => $provider->thumbprint(), + ], + [ + 'ParameterKey' => 'OidcClientId', + 'ParameterValue' => $provider->audience(), + ], + [ + 'ParameterKey' => 'OidcProviderUrl', + 'ParameterValue' => $provider->url(), + ], + [ + 'ParameterKey' => 'SubjectClaim', + 'ParameterValue' => $provider->claim(), + ], + [ + 'ParameterKey' => 'CreateNewOidcProvider', + 'ParameterValue' => $oidcArn ? "false" : "true", + ], + ]; + + try { + $this->cloudformation->describeStacks(['StackName' => $this->unload->ciStackName()])->get('Stacks'); + $this->cloudformation->updateStack([ + 'StackName' => $this->unload->ciStackName(), + 'TemplateBody' => file_get_contents(base_path('cloudformation/construct/ci.yaml')), + 'EnableTerminationProtection' => true, + 'OnFailure' => 'DELETE', + 'Capabilities' => ['CAPABILITY_IAM', 'CAPABILITY_AUTO_EXPAND'], + 'TimeoutInMinutes' => 5, + 'Parameters' => $parameters, + 'Tags' => $this->unload->unloadTags() + ]); + } catch (CloudFormationException) { + $this->cloudformation->createStack([ + 'StackName' => $this->unload->ciStackName(), + 'TemplateBody' => file_get_contents(base_path('cloudformation/construct/ci.yaml')), + 'EnableTerminationProtection' => true, + 'OnFailure' => 'DELETE', + 'Capabilities' => ['CAPABILITY_IAM', 'CAPABILITY_AUTO_EXPAND'], + 'TimeoutInMinutes' => 5, + 'Parameters' => $parameters, + 'Tags' => $this->unload->unloadTags() + ]); + } + + return new PendingStack($this->unload->ciStackName(), $this->cloudformation); + } + + public function deleteStack(): ?PendingStack + { + try { + $this->cloudformation->describeStacks(['StackName' => $this->unload->ciStackName()])->get('Stacks'); + $this->cloudformation->deleteStack(['StackName' => $this->unload->ciStackName()]); + return new PendingStack($this->unload->ciStackName(), $this->cloudformation); + } catch (CloudFormationException) { + return null; + } + } + + public function getPipelineExecutionRoleArn(): string + { + $outputs = collect($this->cloudformation->describeStacks(['StackName' => $this->unload->ciStackName()]) + ->search('Stacks[0].Outputs'))->keyBy('OutputKey'); + return $outputs->get('PipelineExecutionRole')['OutputValue']; + } + + public function getArtifactsBucketName(): string + { + $outputs = collect($this->cloudformation->describeStacks(['StackName' => $this->unload->ciStackName()]) + ->search('Stacks[0].Outputs'))->keyBy('OutputKey'); + + $bucketArn = $outputs->get('ArtifactsBucket')['OutputValue']; + return Str::of($bucketArn)->replace('arn:aws:s3:::', '')->toString(); + } + + public function getOpenIDConnectProviderArn(OidcInterface $oidc): ?string + { + $condition = str_replace('https://', '', $oidc->url()); + + $arn = $this->iam->listOpenIDConnectProviders()->search( + "OpenIDConnectProviderList[?contains(Arn, '$condition')].Arn" + )[0] ?? null; + + return $arn; + } + + public function getAssetsBucketName(): string + { + $outputs = collect($this->cloudformation->describeStacks(['StackName' => $this->unload->appStackName()]) + ->search('Stacks[0].Outputs'))->keyBy('OutputKey'); + + $bucketArn = $outputs->get('AppAssetBucketArn')['OutputValue']; + return Str::of($bucketArn)->replace('arn:aws:s3:::', '')->toString(); + } + + public function applicationStackExists(): bool + { + try { + $this->cloudformation->describeStacks(['StackName' => $this->unload->appStackName()]); + return true; + } catch (CloudFormationException $e) { + return false; + } + } +} diff --git a/app/Aws/Dashboard.php b/app/Aws/Dashboard.php new file mode 100644 index 0000000..1ae7692 --- /dev/null +++ b/app/Aws/Dashboard.php @@ -0,0 +1,47 @@ +sts = $sts; + $this->unload = $unload; + } + + public function generateUrl(): string + { + /** @var \Aws\Credentials\Credentials $session */ + $session = $this->sts->getCredentials()->wait(); + $credentials = [ + 'sessionId' => $session->getAccessKeyId(), + 'sessionKey' => $session->getSecretKey(), + 'sessionToken' => $session->getSecurityToken(), + ]; + + $requestParameters = '?Action=getSigninToken'; + $requestParameters .= '&DurationSeconds=43200'; + $requestParameters .= '&Session='.urlencode(json_encode($credentials)); + + $federationUrl = "https://signin.aws.amazon.com/federation{$requestParameters}"; + $federation = json_decode((new Client)->get($federationUrl)->getBody()->getContents()); + + $requestParameters = '?Action=login'; + $requestParameters .= '&Destination='.urlencode("https://{$this->unload->region()}.console.aws.amazon.com/lambda/home?region={$this->unload->region()}#/applications/{$this->unload->appStackName()}?tab=monitoring"); + $requestParameters .= '&SigninToken='.urlencode($federation->SigninToken); + $requestParameters .= '&Issuer='.urlencode("https://example.com"); + + return "https://signin.aws.amazon.com/federation$requestParameters"; + } +} diff --git a/app/Aws/Domain.php b/app/Aws/Domain.php new file mode 100644 index 0000000..fa6909e --- /dev/null +++ b/app/Aws/Domain.php @@ -0,0 +1,91 @@ +route53 = new Route53Client(['profile' => $unload->profile(), 'region' => $unload->region(), 'version' => 'latest']); + $this->unload = $unload; + } + + public function list($onlyRoot = false, $wildcard = false): Collection + { + $zones = []; + $domains = $this->unload->domains(); + $wildcards = []; + $registeredZones = $this->route53->listHostedZones(); + + foreach($domains as $domain) { + $host = parse_url((strpos($domain, '://') === FALSE ? 'http://' : '') . trim($domain), PHP_URL_HOST); + + if (!preg_match('/[a-z0-9][a-z0-9\-]{0,63}\.[a-z]{2,6}(\.[a-z]{1,2})?$/i', $host, $match)) { + throw new \Exception("Failed to parse domain zone. Check that '$domain' is a valid domain name"); + } + + $zone = $match[0]; + $registeredZone = $registeredZones->search("HostedZones[?Name=='{$zone}.']"); + if (!$registeredZone) { + continue; + } + + $zoneId = str_replace('/hostedzone/', '', $registeredZone[0]['Id']); + $zones[$onlyRoot ? $zone : $domain] = $zoneId; + if ($wildcard && $zone != $domain) { + $wildcards["*.$zone"] = $zoneId; + } + } + + return collect($zones)->merge($wildcards); + } + + public function listRoot(): Collection + { + return $this->list(true, true); + } + + public function register(string $domain): array + { + $zones = $this->route53->listHostedZones()->search("HostedZones[?Name=='{$domain}.']"); + + if ($zones) { + throw new \BadMethodCallException("Domain '$domain' already exists."); + } + + $zone = $this->route53->createHostedZone([ + 'CallerReference' => Uuid::uuid4()->toString(), + 'Name' => $domain, + ]); + + $this->route53->changeTagsForResource([ + 'ResourceType' => 'hostedzone', + 'ResourceId' => str_replace('/hostedzone/', '', $zone->search('HostedZone.Id')), + 'AddTags' => $this->unload->unloadTags(), + ]); + + return $zone->search('DelegationSet.NameServers'); + } + + public function deregister(string $domain): array + { + $zones = $this->route53->listHostedZones()->search("HostedZones[?Name=='{$domain}.']"); + + if (!$zones) { + throw new \BadMethodCallException("Domain '$domain' doesn't exists."); + } + + $this->route53->deleteHostedZone([ + 'CallerReference' => Uuid::uuid4()->toString(), + 'Name' => $domain, + ]); + } +} diff --git a/app/Aws/Lambda.php b/app/Aws/Lambda.php new file mode 100644 index 0000000..dec5a11 --- /dev/null +++ b/app/Aws/Lambda.php @@ -0,0 +1,38 @@ +lambda = $lambda; + $this->unload = $unload; + } + + public function exec(string $command): string + { + $command = json_encode($command); + + $response = $this->lambda->invoke([ + 'FunctionName' => $this->unload->cliFunction(), + 'LogType' => 'Tail', + 'Payload' => $command, + ]); + + $payload = json_decode($response->get('Payload')->getContents()); + + if($response->get('FunctionError')) { + $log = base64_decode($response->get('LogResult')); + throw new \BadMethodCallException("$payload->errorMessage\n\n$log"); + } + + return $payload->output; + } +} diff --git a/app/Aws/Network.php b/app/Aws/Network.php new file mode 100644 index 0000000..2be8c57 --- /dev/null +++ b/app/Aws/Network.php @@ -0,0 +1,175 @@ +cloudformation = new CloudFormationClient(['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest',]); + $this->s3 = new S3Client(['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest',]); + $this->ci = $ci; + $this->unload = $unload; + } + + public function createStack($vpc, $nat): PendingStack + { + $artifactsBucketName = $this->ci->getArtifactsBucketName(); + + $vpcTemplatePath = "cloudformation/network/vpc-$vpc.yaml"; + $vpcTemplateUrl = $this->s3->putObject([ + 'Bucket' => $artifactsBucketName, + 'Key' => $vpcTemplatePath, + 'Body' => fopen(base_path($vpcTemplatePath), 'r'), + ])->get('ObjectURL'); + + $natTemplatePath = "cloudformation/network/nat-$nat.yaml"; + $natTemplateUrl = $this->s3->putObject([ + 'Bucket' => $artifactsBucketName, + 'Key' => $natTemplatePath, + 'Body' => fopen(base_path($natTemplatePath), 'r'), + ])->get('ObjectURL'); + + + $resources = [ + 'VpcStack' => [ + 'Type' => 'AWS::CloudFormation::Stack', + 'Properties' => [ + 'Tags' => $this->unload->unloadGlobalTags(), + 'TemplateURL' => $vpcTemplateUrl, + ], + ] + ]; + + foreach (['1az' => ['A'], '2az' => ['A', 'B'],][$vpc] as $subnetZone) { + if ($nat == 'gateway') { + $stackName ="Nat{$subnetZone}SubnetZoneStack"; + $resources[$stackName] = [ + 'Type' => 'AWS::CloudFormation::Stack', + 'Properties' => [ + 'Tags' => $this->unload->unloadGlobalTags(), + 'TemplateURL' => $natTemplateUrl, + 'Parameters' => [ + 'VpcRouteTablePrivate' => new TaggedValue('GetAtt', "VpcStack.Outputs.RouteTable{$subnetZone}Private"), + 'VpcSubnetPublic' => new TaggedValue('GetAtt', "VpcStack.Outputs.Subnet{$subnetZone}Public"), + ] + ], + ]; + continue; + } + + $stackName ="Nat{$subnetZone}SubnetZoneStack"; + $resources[$stackName] = [ + 'Type' => 'AWS::CloudFormation::Stack', + 'Properties' => [ + 'Tags' => $this->unload->unloadGlobalTags(), + 'TemplateURL' => $natTemplateUrl, + 'Parameters' => [ + 'VpcId' => new TaggedValue('GetAtt', 'VpcStack.Outputs.VPC'), + 'VpcCidrBlock' => new TaggedValue('GetAtt', 'VpcStack.Outputs.CidrBlock'), + 'VpcRouteTablePrivate' => new TaggedValue('GetAtt', "VpcStack.Outputs.RouteTable{$subnetZone}Private"), + 'VpcSubnetPublic' => new TaggedValue('GetAtt', "VpcStack.Outputs.Subnet{$subnetZone}Public"), + ] + ], + ]; + } + + $template = Yaml::dump([ + 'AWSTemplateFormatVersion' => '2010-09-09', + 'Description' => 'VPC: network resources', + 'Resources' => $resources, + 'Outputs' => [ + 'VpcId' => [ + 'Description' => 'VPC', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.VPC'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-VPC'), + ] + ], + 'VpcCidrBlock' => [ + 'Description' => 'VPC CIDR Block', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.CidrBlock'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-CidrBlock'), + ] + ], + 'VpcRouteTablesPrivate' => [ + 'Description' => 'List Of Private Route Tables', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.RouteTablesPrivate'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-RouteTablesPrivate'), + ] + ], + 'VpcRouteTablesPublic' => [ + 'Description' => 'List Of Public Route Tables', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.RouteTablesPublic'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-RouteTablesPublic'), + ] + ], + 'VpcSubnetsPrivate' => [ + 'Description' => 'List Of Private Subnets', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.SubnetsPrivate'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-SubnetsPrivate'), + ] + ], + 'VpcSubnetsPublic' => [ + 'Description' => 'List Of Public Subnets', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.SubnetsPublic'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-SubnetsPublic'), + ] + ], + 'VpcAZsNumber' => [ + 'Description' => 'Number of AZs', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.NumberOfAZs'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-AZs'), + ] + ], + 'VpcAZs' => [ + 'Description' => 'List of AZs', + 'Value' => new TaggedValue('GetAtt', 'VpcStack.Outputs.AZs'), + 'Export' => [ + 'Name' => new TaggedValue('Sub', '${AWS::StackName}-AZList'), + ] + ], + ], + ]); + + $stackName = $this->unload->networkStackName(); + try { + $this->cloudformation->describeStacks(['StackName' => $stackName])->get('Stacks'); + $this->cloudformation->updateStack([ + 'StackName' => $stackName, + 'EnableTerminationProtection' => true, + 'TemplateBody' => $template, + 'Capabilities' => ['CAPABILITY_IAM'], + 'Tags' => $this->unload->unloadGlobalTags(), + ]); + } catch (CloudFormationException) { + $this->cloudformation->createStack([ + 'StackName' => $stackName, + 'EnableTerminationProtection' => true, + 'TemplateBody' => $template, + 'Capabilities' => ['CAPABILITY_IAM'], + 'Tags' => $this->unload->unloadGlobalTags(), + ]); + } + + return new PendingStack($stackName, $this->cloudformation); + } +} diff --git a/app/Aws/PendingStack.php b/app/Aws/PendingStack.php new file mode 100644 index 0000000..b1e09fb --- /dev/null +++ b/app/Aws/PendingStack.php @@ -0,0 +1,51 @@ +cloudformation = $cloudformation; + $this->stackName = $stackName; + } + + public function wait($section = null): void + { + $section = $section ?? (new ConsoleOutput())->section(); + $stackEventsTable = new Table($section); + $stackEventsTable->setStyle('compact'); + $stackEventsTable->render(); + + $stackInProgress = true; + $stackEvents = []; + + while($stackInProgress) { + foreach ($this->cloudformation->describeStackEvents(['StackName' => $this->stackName])->get('StackEvents') as $event) { + if (in_array($event['EventId'], $stackEvents)) { + continue; + } + $stackEvents[] = $event['EventId']; + $stackEventsTable->appendRow( + collect($event)->only(['ResourceStatus', 'ResourceType', 'LogicalResourceId'])->values()->prepend("\t")->toArray() + ); + } + + $stackStatus = $this->cloudformation->describeStacks(['StackName' => $this->stackName])->search('Stacks[0].StackStatus'); + if (in_array($stackStatus, ['CREATE_COMPLETE', 'UPDATE_COMPLETE', 'DELETE_COMPLETE'])) { + $stackInProgress = false; + } + + sleep(0.5); + } + + $section->clear(count($stackEvents)); + } +} diff --git a/app/Aws/Storage.php b/app/Aws/Storage.php new file mode 100644 index 0000000..9db3fce --- /dev/null +++ b/app/Aws/Storage.php @@ -0,0 +1,102 @@ +s3 = new S3Client(['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest',]); + $this->rds = new RdsClient(['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest',]); + $this->unload = $unload; + } + + public function listApplicationDBClusterSnapshots(): Collection + { + return collect($this->rds->describeDBClusterSnapshots()->search('DBClusterSnapshots[].DBClusterSnapshotIdentifier')); + } + + public function deleteApplicationDBClusterSnapshot(string $id): void + { + $this->rds->deleteDBClusterSnapshot(['DBClusterSnapshotIdentifier' => $id]); + } + + public function listApplicationDbSnapshots(): Collection + { + return collect($this->rds->describeDBSnapshots()->search('DBSnapshots[].DBSnapshotIdentifier')); + } + + public function deleteApplicationDbSnapshot(string $id): void + { + $this->rds->deleteDBSnapshot(['DBSnapshotIdentifier' => $id]); + } + + public function listApplicationBuckets(): Collection + { + $buckets = $this->s3->listBuckets(); + $buckets = $buckets['Buckets']; + $applicationBuckets = []; + + foreach($buckets as $bucket) { + try { + $tags = $this->s3->getBucketTagging(['Bucket' => $bucket['Name']])->get('TagSet'); +// if ($tags == $this->unload->unloadTags()) { + $bucket['Versioning'] = $this->s3->getBucketVersioning(['Bucket' => $bucket['Name']])->get('Status') == 'Enabled'; + $applicationBuckets[] = $bucket; +// } + } catch (\Exception $e) {} + } + + return collect($applicationBuckets); + } + + public function deleteBucket($bucketName) + { + $objects = $this->s3->getIterator('ListObjects', ([ + 'Bucket' => $bucketName + ])); + + foreach ($objects as $object) { + $this->s3->deleteObject([ + 'Bucket' => $bucketName, + 'Key' => $object['Key'], + ]); + } + + $this->s3->deleteBucket(['Bucket' => $bucketName,]); + } + + public function deleteVersionedBucket($bucketName) + { + $versions = $this->s3->listObjectVersions([ + 'Bucket' => $bucketName + ])->getPath('Versions'); + + foreach ((array) $versions as $version) { + $this->s3->deleteObject([ + 'Bucket' => $bucketName, + 'Key' => $version['Key'], + 'VersionId' => $version['VersionId'] + ]); + } + + $this->s3->putBucketVersioning([ + 'Bucket' => $bucketName, + 'VersioningConfiguration' => [ + 'Status' => 'Suspended', + ], + ]); + + $this->s3->deleteBucket(['Bucket' => $bucketName,]); + } +} diff --git a/app/Aws/SystemManager.php b/app/Aws/SystemManager.php new file mode 100644 index 0000000..ebbd428 --- /dev/null +++ b/app/Aws/SystemManager.php @@ -0,0 +1,111 @@ +ssm = new SsmClient(['profile' => $unload->profile(), 'region' => $unload->region(), 'version' => '2014-11-06']); + $this->unload = $unload; + } + + public function flushEnvironment(): void + { + $environmentParts = $this->ssm->getParametersByPath(['Path' => $this->unload->ssmPath(),]); + + foreach($environmentParts->search('Parameters') as $parameter) { + $this->ssm->deleteParameter(['Name' => $parameter['Name'],]); + } + } + + public function fetchEnvironment(bool $decrypt = false): string + { + $environmentParts = $this->ssm->getParametersByPath(['Path' => $this->unload->ssmEnvPath(),]); + $encryptedEnvironment = collect($environmentParts->search('Parameters'))->implode('Value'); + + if ($decrypt) { + $secret = $this->ssm->getParameter(['Name' => $this->unload->ssmCiPath('key'),])->search('Parameter.Value'); + $encrypter = new Encrypter(base64_decode($secret), 'aes-256-cbc'); + try { + return $encrypter->decrypt($encryptedEnvironment); + } catch (DecryptException) { + return ''; + } + } + + return $encryptedEnvironment; + } + + public function putEnvironment( + string $newEnvironment, + string $existingEnvironment = null, + bool $rotate = false + ): void { + if ($newEnvironment == $existingEnvironment) { + return; + } + + $secret = random_bytes(32); + if (!$rotate) { + $secret = $this->ssm->getParameter(['Name' => $this->unload->ssmCiPath('key'),])->search('Parameter.Value'); + $secret = base64_decode($secret); + } + + $encrypter = new Encrypter($secret, 'aes-256-cbc'); + + $env = $encrypter->encrypt($newEnvironment); + $parts = str_split($env, 4000); + $existingParts = $this->retrieveParametersByPath($this->unload->ssmEnvPath()); + + if ($existingParts->count() > count($parts)) { + for ($key = count($parts); $key <= $existingParts->count(); $key++) { + $this->ssm->deleteParameter(['Name' => $this->unload->ssmEnvPath('p'.$key)]); + } + } + + foreach($parts as $key => $part) { + $this->putParameter($this->unload->ssmEnvPath('p'.++$key), $part); + } + + if ($rotate) { + $this->putCiParameter('key', base64_encode($secret)); + } + } + + public function putCiParameter(string $name, string $value, $secure = false): void + { + $this->putParameter($this->unload->ssmCiPath($name), $value, $secure); + } + + public function putParameter(string $name, string $value, $secure = false): void + { + $properties = [ + 'Name' => $name, + 'Type' => $secure ? 'SecureString' : 'String', + 'Value' => $value, + 'Overwrite' => true, + ]; + + $this->ssm->putParameter($properties); + $this->ssm->addTagsToResource([ + 'ResourceType' => 'Parameter', + 'ResourceId' => $name, + 'Tags' => $this->unload->unloadTags(), + ]); + } + + public function retrieveParametersByPath(string $path): Collection + { + return collect($this->ssm->getParametersByPath(['Path' => $path])->search('Parameters'))->keyBy('Name'); + } +} diff --git a/app/Cloudformation.php b/app/Cloudformation.php new file mode 100644 index 0000000..57d6004 --- /dev/null +++ b/app/Cloudformation.php @@ -0,0 +1,44 @@ +ask('App name (example: sample)', null); + $php = $this->choice('App php version', ['8.0', '8.1', '8.2'], '8.1'); + $environments = explode('/', $this->choice('App environment', ['production', 'production/development'], 'production')); + $provider = $this->choice('App repository provider', ['github', 'bitbucket'], 'github'); + $repository = $this->ask('App repository path (example: username/app-name)', null); + $audience = null; + $repositoryUuid = ''; + + if ($provider == 'bitbucket') { + $this->line(" Bitbucket OIDC integration requires Audience and Repository UUID."); + $this->line(" Visit https://bitbucket.org/$repository/admin/addon/admin/pipelines/openid-connect to find yours."); + $audience = $this->ask('Bitbucket audience'); + $repositoryUuid = $this->ask('Bitbucket repository uuid'); + } + + $tasks = Task::chain([new CleanupPipelineConfigTask()]); + + foreach($environments as $env) { + $envName = ucfirst($env); + $this->info($envName); + + $region = $this->askWithCompletion("$envName aws region", self::REGIONS); + $branch = $this->ask("$envName $provider branch", 'master'); + $profile = $this->ask("$envName aws profile", "$app-$env"); + + try { + call_user_func(CredentialProvider::ini($profile))->wait(); + } catch (CredentialsException $e) { + $this->warn("Profile $provider not found at ~/.aws/credentials. Please create one and then retry."); + $this->error($e->getMessage()); + return; + } + + $vpc = $this->choice("$envName aws vpc size", ["1az", "2az"], "1az"); + $nat = $this->choice("$envName aws nat type", ["gateway", "instance"], "instance"); + + $bootstrap = new BootstrapConfig(compact( + 'region', 'profile', 'env', 'app', 'repositoryUuid', + 'provider', 'repository', 'branch', 'vpc', 'nat', 'audience', 'php' + )); + + $tasks->add(new GenerateUnloadTemplateTask($bootstrap)); + $tasks->add(new InitContinuousIntegrationTask($bootstrap)); + $tasks->add(new InitNetworkTask($bootstrap)); + $tasks->add(new InitEnvironmentTask($bootstrap)); + $tasks->add(new GeneratePipelineTemplateTask($bootstrap)); + } + + $tasks->add([ + new GeneratePipelineTask($provider, count($environments)), + new CleanupPipelineConfigTask() + ])->execute($this); + } +} diff --git a/app/Commands/BuildCommand.php b/app/Commands/BuildCommand.php new file mode 100644 index 0000000..2abfb3e --- /dev/null +++ b/app/Commands/BuildCommand.php @@ -0,0 +1,47 @@ +newLine(); + $this->info("Building {$this->unload->app()} project for {$this->unload->env()} environment"); + + Task::chain([ + new PrepareBuildDirectoryTask(), + new CopyFormationToBuildDirectoryTask(), + new CopySourceToBuildDirectoryTask(), + new GenerateMakefileTask(), + new ExecuteBuildTask(), + new DestroyComposerPlatformCheckTask(), + new ExtractStaticAssetsTask(), + new CleanupFilesTask(), + new CleanupVendorTask(), + new EmptyAwsCredentialTask(), + new SetupEnvFileTask(), + new GenerateSamTemplateTask(), + new ExecuteSamBuildTask(), + ])->execute($this); + } +} diff --git a/app/Commands/CiCommand.php b/app/Commands/CiCommand.php new file mode 100644 index 0000000..169951b --- /dev/null +++ b/app/Commands/CiCommand.php @@ -0,0 +1,27 @@ +option('repository')) { + $this->error('Invalid github repository. Example: username/myrepo'); + return; + } + + $bootstrap = new BootstrapConfig($this->options()); + Task::chain([new InitContinuousIntegrationTask($bootstrap)])->execute($this); + + $this->newLine(); + $this->line("Continuous integration for {$bootstrap->env()} has been configured."); + } +} diff --git a/app/Commands/Command.php b/app/Commands/Command.php new file mode 100644 index 0000000..d74d70a --- /dev/null +++ b/app/Commands/Command.php @@ -0,0 +1,69 @@ +addOption('config', 'c', InputOption::VALUE_OPTIONAL); + } + + public function initialize(InputInterface $input, OutputInterface $output) + { + parent::initialize($input, $output); + $config = UnloadConfig::fromCommand($input); + App::singleton(UnloadConfig::class, fn () => $config); + $this->section = (new ConsoleOutput())->section(); + $this->unload = $config; + } + + public function call($command, array $arguments = []) + { + $arguments = collect($this->options())->mapWithKeys(fn($value, $key) => ["--$key" => $value])->merge($arguments); + return parent::call($command, $arguments->toArray()); + } + + public function step($description, $task = null, bool $nested = false): mixed + { + $result = null; + + if ($task) { + $task = function () use (&$result, $task, $description, $nested) { + $result = $task(); + $this->output->write("\033[10000D"); + $this->output->write(" $description "); + return $result; + }; + } + + with(new Task( + $this->output ?: new NullOutput() + ))->render($description, $task); + + return $result; + } + + public function info($string, $verbosity = null): void + { + with(new Info( + $this->output ?: new NullOutput() + ))->render($string, $verbosity ??= OutputInterface::VERBOSITY_NORMAL); + } +} diff --git a/app/Commands/DashboardCommand.php b/app/Commands/DashboardCommand.php new file mode 100644 index 0000000..592b551 --- /dev/null +++ b/app/Commands/DashboardCommand.php @@ -0,0 +1,19 @@ +info('Generating dashboard for the application'); + + return System::browser($dashboard->generateUrl()); + } +} diff --git a/app/Commands/DeployCommand.php b/app/Commands/DeployCommand.php new file mode 100644 index 0000000..e73ea84 --- /dev/null +++ b/app/Commands/DeployCommand.php @@ -0,0 +1,36 @@ +call('build'); + + $this->newLine(); + $this->info("Deploying {$this->unload->app()} project to the {$this->unload->accountId()} ({$this->unload->profile()}) account"); + + Task::chain($ci->applicationStackExists() ? [ + new InitCertificateTask(), + new GenerateSamConfigTask(), + new UploadAssetTask(), + new ExecuteSamDeployTask(), + ] : [ + new InitCertificateTask(), + new GenerateSamConfigTask(), + new ExecuteSamDeployTask(), + new UploadAssetTask(), + ])->execute($this); + } +} diff --git a/app/Commands/DestroyCommand.php b/app/Commands/DestroyCommand.php new file mode 100644 index 0000000..3a7cd94 --- /dev/null +++ b/app/Commands/DestroyCommand.php @@ -0,0 +1,33 @@ +alert('This is dangerous operation!!!'); + $this->comment("1) It will remove the application stack for {$this->unload->env()} environment"); + $this->comment("2) It will remove the application variables in {$this->unload->env()} environment"); + $this->comment("3) It will remove the continuous integration stack for {$this->unload->env()} environment"); + + if (!$this->confirm('Are you sure you want to processed?')) { + return; + } + + Task::chain([ + new ExecuteSamDeleteTask(), + new FlushEnvironmentTask(), + new DestroyContinuousIntegrationTask(), + ]) + ->execute($this); + } +} diff --git a/app/Commands/DomainCommand.php b/app/Commands/DomainCommand.php new file mode 100644 index 0000000..152c8e4 --- /dev/null +++ b/app/Commands/DomainCommand.php @@ -0,0 +1,29 @@ +argument('domain'); + + if (!$domain) { + $this->error('Invalid domain specified. Example: example.com'); + return; + } + + $this->info("Registering '$domain' in the dns service."); + + $nameservers = $dns->register($domain); + + $this->line("Domain '$domain' has been successfully registered."); + $this->line('Use the following nameservers to configure within your domain register:'); + $this->table([], array_map(fn($nameserver) => (array) $nameserver, $nameservers)); + } +} diff --git a/app/Commands/EnvCommand.php b/app/Commands/EnvCommand.php new file mode 100644 index 0000000..3ea5d50 --- /dev/null +++ b/app/Commands/EnvCommand.php @@ -0,0 +1,29 @@ +info('Retrieving environment configuration from system manager'); + + $environment = $manager->fetchEnvironment(decrypt: true); + $newEnvironment = System::open($environment); + + $this->step( + 'Saving environment configuration to system manager', + fn() => $manager->putEnvironment($newEnvironment, $environment, (bool) $this->option('rotate')) + ); + + $this->newLine(); + $this->line(" SSM Path: {$this->unload->ssmPath()}"); + } +} diff --git a/app/Commands/ExecCommand.php b/app/Commands/ExecCommand.php new file mode 100644 index 0000000..7051d25 --- /dev/null +++ b/app/Commands/ExecCommand.php @@ -0,0 +1,22 @@ +argument('arguments')); + + $this->newLine(); + $this->info("Running '{$command}' in {$this->unload->cliFunction()} function"); + + $stream = $lambda->exec($command); + $this->output->write($stream); + } +} diff --git a/app/Commands/LogCommand.php b/app/Commands/LogCommand.php new file mode 100644 index 0000000..e11bf59 --- /dev/null +++ b/app/Commands/LogCommand.php @@ -0,0 +1,53 @@ +argument('function')); + $functionType = $function[0] ?? ''; + $functionName = match($functionType) { + 'web' => 'WebFunction', + 'cli' => 'CliFunction', + 'deploy' => 'DeployFunction', + 'worker' => ucfirst($function[1]).'WorkerFunction', + default => throw new \Exception("Specified '$functionType' function type isn't valid, please use one of web, cli, api, deploy or worker:*") + }; + + $command = sprintf( + "sam logs --profile=%s --region=%s --stack-name=%s --name=%s", + $config->profile(), + $config->region(), + $config->appStackName(), + $functionName, + ); + + if ($this->option('start')) { + $command .= ' --s='.$this->option('start'); + } + + if ($this->option('end')) { + $command .= ' --e='.$this->option('start'); + } + + if ($this->option('filter')) { + $command .= ' --filter='.$this->option('filter'); + } + + if ($this->option('tail')) { + $command .= ' --tail '; + } + + $deploy = Process::fromShellCommandline($command, null, ['SAM_CLI_TELEMETRY' => 0]); + $deploy->setTimeout(3600); + $deploy->run(fn ($type, $line) => $this->output->write($line)); + } +} diff --git a/app/Commands/NetworkCommand.php b/app/Commands/NetworkCommand.php new file mode 100644 index 0000000..79c1d55 --- /dev/null +++ b/app/Commands/NetworkCommand.php @@ -0,0 +1,23 @@ +options()); + + $this->newLine(); + $this->info("Running network deployment for {$boostrap->env()} environment"); + + Task::chain([new InitNetworkTask($boostrap)])->execute($this); + } +} diff --git a/app/Commands/PipelineCommand.php b/app/Commands/PipelineCommand.php new file mode 100644 index 0000000..cf3c793 --- /dev/null +++ b/app/Commands/PipelineCommand.php @@ -0,0 +1,40 @@ +info("Generating {$this->option('provider')} continuous integration pipeline for {$this->option('stages')} stage deployment"); + + Task::chain([ + new GeneratePipelineTask($this->option('provider'), $this->option('stages'), $this->option('definition')) + ])->execute($this); + } +} + + + + + + + + + + + + + + + + + + + diff --git a/app/Configs/BootstrapConfig.php b/app/Configs/BootstrapConfig.php new file mode 100644 index 0000000..f4ba30d --- /dev/null +++ b/app/Configs/BootstrapConfig.php @@ -0,0 +1,55 @@ +config, 'provider'); + } + + public function repository(): string + { + return Arr::get($this->config, 'repository'); + } + + public function repositoryOrganization(): string + { + $data = explode('/', (string) Arr::get($this->config, 'repository')); + return $data[0] ?? ''; + } + + public function repositoryName(): string + { + $data = explode('/', (string) Arr::get($this->config, 'repository')); + return $data[1] ?? ''; + } + + public function repositoryUuid(): string + { + return (string) Arr::get($this->config, 'repositoryUuid'); + } + + public function audience(): string + { + return (string) Arr::get($this->config, 'audience'); + } + + public function branch(): string + { + return Arr::get($this->config, 'branch'); + } + + public function vpc(): string + { + return Arr::get($this->config, 'vpc'); + } + + public function nat(): string + { + return Arr::get($this->config, 'nat'); + } +} diff --git a/app/Configs/LayerConfig.php b/app/Configs/LayerConfig.php new file mode 100644 index 0000000..999b7b6 --- /dev/null +++ b/app/Configs/LayerConfig.php @@ -0,0 +1,46 @@ +unload = $unload; + $this->layers = json_decode(file_get_contents($layersPath), true); + } + + public function php(): string + { + $version = $this->layers["php-{$this->version()}"][$this->unload->region()]; + return "arn:aws:lambda:{$this->unload->region()}:209497400698:layer:php-{$this->version()}:$version"; + } + + public function fpm(): string + { + $version = $this->layers["php-{$this->version()}-fpm"][$this->unload->region()]; + return "arn:aws:lambda:{$this->unload->region()}:209497400698:layer:php-{$this->version()}-fpm:$version"; + } + + public function console(): string + { + $version = $this->layers["console"][$this->unload->region()]; + return "arn:aws:lambda:{$this->unload->region()}:209497400698:layer:console:$version"; + } + + public function version(): string + { + return number_format($this->unload->php(), 1, '', ' '); + } +} diff --git a/app/Configs/UnloadConfig.php b/app/Configs/UnloadConfig.php new file mode 100644 index 0000000..2840940 --- /dev/null +++ b/app/Configs/UnloadConfig.php @@ -0,0 +1,416 @@ +config = $config; + $this->ignoreFiles = $ignoreFiles; + $this->input = $input; + } + + public static function fromCommand(InputInterface $input = null): self + { + $unloadConfigPath = Path::unloadTemplatePath($input?->getOption('config')); + $config = []; + if (file_exists($unloadConfigPath)) { + $config = Yaml::parse(file_get_contents($unloadConfigPath)); + + $validator = new \Opis\JsonSchema\Validator(); + $validator->resolver()->registerFile('https://unload.dev/unload01.json', resource_path('unload01.json')); + $validated = $validator->validate(Helper::toJSON($config), 'https://unload.dev/unload01.json'); + + if (!$validated->isValid()) { + print_r((new ErrorFormatter())->format($validated->error())); + die; + } + } + + $ignoreFiles = []; + if (file_exists(Path::ignoreFile())) { + $ignoreFiles = explode(PHP_EOL, file_get_contents(Path::ignoreFile())); + } + + return new self( + $config, + $ignoreFiles, + $input, + ); + } + + public function app(): string + { + return (string) Arr::get($this->config, 'app'); + } + + public function env(): string + { + return Arr::get($this->config, 'env', 'develop'); + } + + public function resourcePrefix(): string + { + return "unload-{$this->env()}-{$this->app()}"; + } + + public function ssmPath(string $parameter = null): string + { + return rtrim("/{$this->app()}/{$this->env()}/$parameter", '/'); + } + + public function ssmEnvPath(string $parameter = null): string + { + return $this->ssmPath("env/$parameter"); + } + + public function ssmCiPath(string $parameter = null): string + { + return $this->ssmPath("ci/$parameter"); + } + + public function region(): string + { + return Arr::get($this->config, 'region', 'us-east-1'); + } + + public function profile(): ?string + { + if (!file_exists(getenv('HOME').'/.aws/credentials')) { + return null; + } + + return Arr::get($this->config, 'profile', 'default'); + } + + public function runtime(): string + { + $runtime = Arr::get($this->config, 'runtime', 'provided'); + if ($runtime == 'provided') { + return 'provided.al2'; + } + return 'docker'; + } + + public function memory(): int + { + return (int) Arr::get($this->config, 'memory', 1024); + } + + public function timeout(): int + { + return (int) Arr::get($this->config, 'timeout', 300); + } + + public function tmp(): int + { + return (int) Arr::get($this->config, 'tmp', 512); + } + + public function concurrency(): ?int + { + return (int) Arr::get($this->config, 'concurrency'); + } + + public function provision(): int + { + return (int) Arr::get($this->config, 'provision'); + } + + public function php(): string + { + if(isset($this->config['php'])) { + return (string) $this->config['php']; + } + return '8.1'; + } + + public function warm(): bool + { + return Arr::get($this->config, 'warm', 'no') == 'yes'; + } + + public function network(): string|false + { + return Arr::get($this->config, 'network'); + } + + public function appStackName(): string + { + return "unload-{$this->env()}-{$this->app()}-app"; + } + + public function ciStackName(): string + { + return "unload-{$this->env()}-{$this->app()}-ci"; + } + + public function networkStackName(): string + { + return "unload-{$this->env()}-network"; + } + + public function certificateStackName(): string + { + return "unload-{$this->env()}-{$this->app()}-acm"; + } + + public function buckets(): array + { + return (array) Arr::get($this->config, 'buckets', []); + } + + public function queues(): array + { + return (array) Arr::get($this->config, 'queues', []); + } + + public function domains(): array + { + $domains = (array) Arr::get($this->config, 'domains', []); + sort($domains); + return $domains; + } + + public function firewallGeoLocations(): array + { + if (Arr::has($this->config, 'firewall.geo-blacklist')) { + return (array) Arr::get($this->config, 'firewall.geo-blacklist', []); + } elseif (Arr::has($this->config, 'firewall.geo-whitelist')) { + return (array) Arr::get($this->config, 'firewall.geo-whitelist', []); + } else { + return []; + } + } + + public function firewallGeoType(): string + { + if (Arr::has($this->config, 'firewall.geo-blacklist')) { + return 'blacklist'; + } elseif (Arr::has($this->config, 'firewall.geo-whitelist')) { + return 'whitelist'; + } else { + return 'none'; + } + } + + public function firewallBurstLimit() + { + return Arr::get($this->config, 'firewall.burst-limit', 5000); + } + + public function firewallRateLimit() + { + return Arr::get($this->config, 'firewall.rate-limit', 10000); + } + + public function database(): array + { + return (array) Arr::get($this->config, 'database', []); + } + + public function cache(): array + { + return (array) Arr::get($this->config, 'cache', []); + } + + public function nat(): string|false + { + return Arr::get($this->config, 'nat', false); + } + + public function build(): array + { + return array_filter(explode(PHP_EOL, Arr::get($this->config, 'build', ''))); + } + + public function deploy(): string + { + return Str::replace('php artisan ', '', (string) Arr::get($this->config, 'deploy', '')); + } + + public function tmpBuildAssetHash(): string + { + return file_get_contents(Path::tmpAssetHash()); + } + + public function cliFunction(): string + { + return "{$this->resourcePrefix()}-cli"; + } + + public function deployFunction(): string + { + return "CodeDeployHook_{$this->resourcePrefix()}"; + } + + public function cliFunctionMemory(): int + { + return (int) Arr::get($this->config, 'cli.memory', $this->memory()); + } + + public function cliFunctionTimeout(): int + { + return (int) Arr::get($this->config, 'cli.timeout', 900); + } + + public function cliFunctionTmp(): int + { + return (int) Arr::get($this->config, 'cli.tmp', $this->tmp()); + } + + public function cliFunctionConcurrency(): int + { + return (int) Arr::get($this->config, 'cli.concurrency', $this->concurrency()); + } + + public function cliFunctionProvision(): int + { + return (int) Arr::get($this->config, 'cli.provision', $this->provision()); + } + + public function webFunction(): string + { + return "{$this->resourcePrefix()}-web"; + } + + public function webFunctionMemory(): int + { + return (int) Arr::get($this->config, 'web.memory', $this->memory()); + } + + public function webFunctionTimeout(): int + { + return (int) Arr::get($this->config, 'web.timeout', 30); + } + + public function webFunctionConcurrency(): int + { + return (int) Arr::get($this->config, 'web.concurrency', $this->concurrency()); + } + + public function webFunctionProvision(): int + { + return (int) Arr::get($this->config, 'web.provision', $this->provision()); + } + + public function webFunctionTmp(): int + { + return (int) Arr::get($this->config, 'web.tmp', $this->tmp()); + } + + public function workerFunction(string $queue): string + { + $queue = strtolower($queue); + return "{$this->resourcePrefix()}-worker-$queue"; + } + + public function databaseName(): string + { + return Str::replace(['-', '_'], '', $this->app()); + } + + public function databaseUsername(): string + { + return $this->databaseName().'_user'; + } + + public function accountId() + { + if (!$this->accountId) { + $sts = new StsClient(['region' => $this->region(), 'profile' => $this->profile(), 'version' => '2011-06-15']); + $this->accountId = $sts->getCallerIdentity()->search('Account'); + } + return $this->accountId; + } + + public function signing(): bool + { + return Arr::get($this->config, 'signing') === 'yes'; + } + + public function assetsBucket(): string + { + return "{$this->resourcePrefix()}-assets-{$this->accountId()}"; + } + + public function ignoreFiles(): array + { + return (array) $this->ignoreFiles; + } + + public function template(bool $relative = false): string + { + return Path::unloadTemplatePath($this->env(), $relative); + } + + public function unloadTags(): array + { + return [ + [ + 'Key' => 'unload', + 'Value' => 'unload', + ], + [ + 'Key' => 'unload:app', + 'Value' => $this->app(), + ], + [ + 'Key' => 'unload:env', + 'Value' => $this->env(), + ], + [ + 'Key' => 'unload:version', + 'Value' => App::version(), + ], + ]; + } + + public function unloadGlobalTags(): array + { + return [ + [ + 'Key' => 'unload', + 'Value' => 'unload', + ], + [ + 'Key' => 'unload:version', + 'Value' => App::version(), + ], + ]; + } + + public function unloadTagsPlain(): array + { + return [ + 'unload' => 'unload', + 'unload:app' => $this->app(), + 'unload:env' => $this->env(), + 'unload:version' => App::version(), + ]; + } + + public function toArray(): array + { + return $this->config; + } +} diff --git a/app/Constructs/BucketConstruct.php b/app/Constructs/BucketConstruct.php new file mode 100644 index 0000000..c4c9d75 --- /dev/null +++ b/app/Constructs/BucketConstruct.php @@ -0,0 +1,48 @@ +unloadConfig->buckets()) { + return $this; + } + + foreach ($this->unloadConfig->buckets() as $bucketName => $bucketDefinition) { + $bucketStack = ucfirst(strtolower($bucketName)).'BucketStack'; + $bucketRef = ['BucketName' => new TaggedValue('GetAtt', "$bucketStack.Outputs.BucketName"),]; + + $this->append('Policies', [ + ['S3CrudPolicy' => $bucketRef,], + ]); + + $this->append('Resources', [ + $bucketStack => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("storage/bucket.yaml"), + 'Parameters' => [ + 'Access' => ucfirst($bucketDefinition['access'] ?? 'private'), + 'Versioning' => ($bucketDefinition['versioning'] ?? 'no') == 'yes', + 'NoncurrentVersionExpirationInDays' => $bucketDefinition['version-expiration'] ?? 0, + 'ExpirationInDays' => $bucketDefinition['expiration'] ?? 0, + 'ExpirationPrefix' => $bucketDefinition['expiration-prefix'] ?? '', + ] + ], + ] + ]); + } + + $defaultBucketStack = ucfirst(strtolower(array_keys($this->unloadConfig->buckets())[0])).'BucketStack'; + return $this->append('Globals.Function.Environment.Variables', [ + 'AWS_BUCKET' => new TaggedValue('GetAtt', "$defaultBucketStack.Outputs.BucketName"), + 'FILESYSTEM_DRIVER' => 's3', + ]); + } +} diff --git a/app/Constructs/CacheConstruct.php b/app/Constructs/CacheConstruct.php new file mode 100644 index 0000000..78d6881 --- /dev/null +++ b/app/Constructs/CacheConstruct.php @@ -0,0 +1,46 @@ +unloadConfig->cache()) { + return $this; + } + + $cache = $this->unloadConfig->cache(); + + $this->append('Globals.Function.Environment.Variables', [ + 'REDIS_HOST' => new TaggedValue('GetAtt', 'CacheStack.Outputs.PrimaryEndPointAddress'), + 'REDIS_PORT' => new TaggedValue('GetAtt', 'CacheStack.Outputs.PrimaryEndPointPort'), + ]); + + $this->append('Resources', [ + 'CacheStack' => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("storage/redis.yaml"), + 'Parameters' => Arr::whereNotNull([ + 'VpcId' => new TaggedValue('Ref', 'VpcId'), + 'VpcSubnetsPrivate' => new TaggedValue('Ref', 'VpcSubnetsPrivate'), + 'VpcSecurityGroup' => new TaggedValue('GetAtt', 'SecurityGroupStack.Outputs.ClientSecurityGroup'), + 'CacheNodeType' => Arr::get($cache, 'size'), + 'EngineVersion' => Arr::get($cache, 'version'), + 'NumShards' => Arr::get($cache, 'shards'), + 'NumReplicas' => Arr::get($cache, 'replicas'), + 'SnapshotRetentionLimit' => Arr::get($cache, 'snapshot-retention'), + ]), + ], + ] + ]); + + return $this; + } +} diff --git a/app/Constructs/CloudfrontConstruct.php b/app/Constructs/CloudfrontConstruct.php new file mode 100644 index 0000000..bcb481b --- /dev/null +++ b/app/Constructs/CloudfrontConstruct.php @@ -0,0 +1,54 @@ +unloadConfig->domains()); + + $this->append('Resources', [ + 'CloudfrontStack' => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("construct/website.yaml"), + 'Parameters' => [ + 'PipelineRoleArn' => new TaggedValue('Ref', 'PipelineRoleArn'), + 'GeoRestrictionLocations' => implode(',', $this->unloadConfig->firewallGeoLocations()), + 'GeoRestrictionType' => $this->unloadConfig->firewallGeoType(), + 'Domains' => $domains, + 'ExistingCertificate' => $this->unloadConfig->domains() ? new TaggedValue('Ref', 'CertificateArn') : '', + 'DistributionName' => $this->unloadConfig->appStackName(), +// 'EndpointOriginDomain' => new TaggedValue('Sub', '${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com'), + 'EndpointOriginDomain' => new TaggedValue('Select', [2, new TaggedValue('Split', ['/', new TaggedValue('GetAtt', 'WebFunctionUrl.FunctionUrl')])]), + ], + ], + ] + ]); + + if ($domains) { + $this->append('Outputs', [ + 'AppCloudfrontDomains' => [ + 'Description' => 'Application cloudfront domains', + 'Value' => $domains + ], + ]); + } + + return $this->append('Outputs', [ + 'AppCloudfrontURL' => [ + 'Description' => 'Application cloudfront url', + 'Value' => new TaggedValue('GetAtt', 'CloudfrontStack.Outputs.URL'), + ], + 'AppAssetBucketArn' => [ + 'Description' => 'Application assets bucket arn', + 'Value' => new TaggedValue('GetAtt', 'CloudfrontStack.Outputs.AssetsBucketArn'), + ], + ]); + } +} diff --git a/app/Constructs/DatabaseConstruct.php b/app/Constructs/DatabaseConstruct.php new file mode 100644 index 0000000..aacea11 --- /dev/null +++ b/app/Constructs/DatabaseConstruct.php @@ -0,0 +1,85 @@ +unloadConfig->database()) { + return $this; + } + + $database = $this->unloadConfig->database(); + + $this->append('Globals.Function.Environment.Variables', [ + 'DB_HOST' => new TaggedValue('GetAtt', 'DatabaseStack.Outputs.DNSName') + ]); + + if ($database['engine'] == 'mysql') { + $requiresPublicDatabase = ($database['publiclyAccessible'] ?? 'no') == 'yes'; + $this->append('Resources', [ + 'DatabaseStack' => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("storage/mysql.yaml"), + 'Parameters' => array_filter([ + 'VpcId' => new TaggedValue('Ref', 'VpcId'), + 'VpcSubnetsPrivate' => $requiresPublicDatabase + ? new TaggedValue('Ref', 'VpcSubnetsPublic') + : new TaggedValue('Ref', 'VpcSubnetsPrivate'), + 'VpcSecurityGroup' => new TaggedValue('GetAtt', 'SecurityGroupStack.Outputs.ClientSecurityGroup'), + + 'DBPubliclyAccessible' => $requiresPublicDatabase, + 'DBInstanceClass' => Arr::get($database, 'size'), + 'DBAllocatedStorage' => Arr::get($database, 'disk'), + 'DBBackupRetentionPeriod' => Arr::get($database, 'backup-retention'), + 'EngineVersion' => Arr::get($database, 'version'), + 'DBMultiAZ' => Arr::get($database, 'multi-az', 'no') == 'yes', + + 'DBName' => $this->unloadConfig->databaseName(), + 'DBMasterUsername' => $this->unloadConfig->databaseUsername(), + 'DBMasterUserPassword' => $this->unloadConfig->ssmCiPath('database'), + ]), + ], + ] + ]); + } + + if ($database['engine'] == 'aurora') { + $this->append('Resources', [ + 'DatabaseStack' => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("storage/mysql-serverless.yaml"), + 'Parameters' => array_filter([ + 'VpcId' => new TaggedValue('Ref', 'VpcId'), + 'VpcSubnetsPrivate' => new TaggedValue('Ref', 'VpcSubnetsPrivate'), + 'VpcSecurityGroup' => new TaggedValue('GetAtt', 'SecurityGroupStack.Outputs.ClientSecurityGroup'), + + 'MinCapacity' => Arr::get($database, 'min-capacity'), + 'MaxCapacity' => Arr::get($database, 'max-capacity'), + 'EngineVersion' => Arr::get($database, 'version'), + 'DBBackupRetentionPeriod' => Arr::get($database, 'backup-retention'), + + 'AutoPause' => isset($database['auto-pause']), + 'SecondsUntilAutoPause' => Arr::get($database, 'auto-pause'), + + 'DBName' => $this->unloadConfig->databaseName(), + 'DBMasterUsername' => $this->unloadConfig->databaseUsername(), + 'DBMasterUserPassword' => $this->unloadConfig->ssmCiPath('database'), + ]), + ], + ] + ]); + } + + return $this; + } +} diff --git a/app/Constructs/DnsConstruct.php b/app/Constructs/DnsConstruct.php new file mode 100644 index 0000000..762b808 --- /dev/null +++ b/app/Constructs/DnsConstruct.php @@ -0,0 +1,36 @@ +unloadConfig->domains()) { + return $this; + } + + /** @var Domain $domain */ + $domain = App::make(Domain::class); + $domains = $domain->list(); + + return $this->append('Resources', [ + 'DNSStack' => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("construct/dns.yaml", compact('domains')), + 'Parameters' => [ + 'DistributionDomain' => new TaggedValue('GetAtt', 'CloudfrontStack.Outputs.URL'), + ], + ], + ] + ]); + } +} diff --git a/app/Constructs/EnvironmentConstruct.php b/app/Constructs/EnvironmentConstruct.php new file mode 100644 index 0000000..926b82d --- /dev/null +++ b/app/Constructs/EnvironmentConstruct.php @@ -0,0 +1,53 @@ + $this->unloadConfig->profile(), 'region' => $this->unloadConfig->region(), 'version' => 'latest']); + $vpcParameters = collect($cloudformation->describeStacks(['StackName' => $this->unloadConfig->networkStackName()])->search('Stacks[0].Outputs')) + ->mapWithKeys(function ($value, $key) { + return [$value['OutputKey'] => [ + 'Type' => 'String', + 'Default' => $value['OutputValue'], + ]]; + }); + + $stackParameters = collect([ + 'CiSecret' => [ + 'Type' => 'AWS::SSM::Parameter::Value', + 'NoEcho' => true, + 'Default' => $this->unloadConfig->ssmCiPath('key'), + ], + 'EnvAssetUrl' => [ + 'Type' => 'String', + 'Default' => '', + ], + 'CertificateArn' => [ + 'Type' => 'String', + 'Default' => '', + ], + 'PipelineRoleArn' => [ + 'Type' => 'String', + 'Default' => '', + ] + ])->merge($vpcParameters)->toArray(); + + $environmentVariables = [ + 'CI_SECRET' => new TaggedValue('Ref', 'CiSecret'), + 'ASSET_URL' => new TaggedValue('Ref', 'EnvAssetUrl'), + 'APP_CONFIG_CACHE' => '/tmp/config.php', + 'BREF_PING_DISABLE' => 1, + 'BREF_AUTOLOAD_PATH' => '/var/task/autoload.php', + ]; + + return $this->append('Parameters', $stackParameters) + ->append('Globals.Function.Environment.Variables', $environmentVariables); + } +} diff --git a/app/Constructs/EventConstruct.php b/app/Constructs/EventConstruct.php new file mode 100644 index 0000000..71a558f --- /dev/null +++ b/app/Constructs/EventConstruct.php @@ -0,0 +1,41 @@ +append('Resources.WebFunction.Properties.Events.HttpApi', [ +// 'Type' => 'HttpApi', +// 'Properties' => [ +// 'Method' => 'ANY', +// 'Path' => '/{proxy+}', +// 'RouteSettings' => [ +// 'ThrottlingBurstLimit' => $this->unloadConfig->firewallBurstLimit(), +// 'ThrottlingRateLimit' => $this->unloadConfig->firewallRateLimit(), +// ] +// ] +// ]); + + $this->append('Resources.CliFunction.Properties.Events.Schedule', [ + 'Type' => 'Schedule', + 'Properties' => [ + 'Schedule' => 'rate(1 minute)', + 'Input' => '"schedule:run"' + ] + ]); + + if ($this->unloadConfig->warm()) { + $this->append('Resources.WebFunction.Properties.Events.Warmer', [ + 'Type' => 'Schedule', + 'Properties' => [ + 'Schedule' => 'rate(5 minutes)', + 'Input' => '{"warmer": true}' + ] + ]); + } + + return $this; + } +} diff --git a/app/Constructs/NetworkConstruct.php b/app/Constructs/NetworkConstruct.php new file mode 100644 index 0000000..395b26e --- /dev/null +++ b/app/Constructs/NetworkConstruct.php @@ -0,0 +1,45 @@ + [ + new TaggedValue( + 'GetAtt', + 'SecurityGroupStack.Outputs.ClientSecurityGroup' + ), + ], + 'SubnetIds' => new TaggedValue( + 'If', + [ + 'HasSingleAZ', + new TaggedValue('Split', [',', new TaggedValue('Select', [0, $subnetIds])]), + $subnetIds + ] + ), + ]; + + $vpcStack = [ + 'SecurityGroupStack' => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("network/vpc-sg.yaml"), + 'Parameters' => [ + 'VpcId' => new TaggedValue('Ref', 'VpcId'), + ], + ], + ], + ]; + + return $this->append('Globals.Function.VpcConfig', $vpcConfig)->append('Resources', $vpcStack); + } +} diff --git a/app/Constructs/PoliciesConstruct.php b/app/Constructs/PoliciesConstruct.php new file mode 100644 index 0000000..72730c2 --- /dev/null +++ b/app/Constructs/PoliciesConstruct.php @@ -0,0 +1,23 @@ +get('Policies')) { + return $this; + } + + foreach ($this->get('Resources') as $name => $resource) { + if (!str_ends_with($name, 'Function')) { + continue; + } + + $this->append("Resources.$name.Properties.Policies", $this->get('Policies')); + } + + return $this->forget('Policies'); + } +} diff --git a/app/Constructs/QueueConstruct.php b/app/Constructs/QueueConstruct.php new file mode 100644 index 0000000..0eb7e01 --- /dev/null +++ b/app/Constructs/QueueConstruct.php @@ -0,0 +1,86 @@ +unloadConfig->queues()) { + return $this; + } + + foreach ($this->unloadConfig->queues() as $queueName => $queueDefinition) { + $queueWorkerFunction = ucfirst(strtolower($queueName)).'WorkerFunction'; + $queueStack = ucfirst(strtolower($queueName)).'QueueStack'; + $queueRef = new TaggedValue('GetAtt', "$queueStack.Outputs.Name"); + + $this->append('Policies', [ + ['SQSSendMessagePolicy' => ['QueueName' => $queueRef],], + ['SQSPollerPolicy' => ['QueueName' => $queueRef],] + ]); + + $defaultQueueName = strtolower((string) array_keys($this->unloadConfig->queues())[0]); + $variableName = $queueName == $defaultQueueName ? 'SQS_QUEUE' : 'SQS_QUEUE_'.strtoupper($queueName); + $this->append('Globals.Function.Environment.Variables', [ + $variableName => $queueRef, + ]); + + $this->append('Resources', [ + $queueWorkerFunction => [ + 'Type' => 'AWS::Serverless::Function', + 'Properties' => array_filter([ + 'FunctionName' => $this->unloadConfig->workerFunction($queueName), + 'MemorySize' => Arr::get($queueDefinition, 'memory', $this->unloadConfig->memory()), + 'Timeout' => Arr::get($queueDefinition, 'timeout', $this->unloadConfig->timeout()), + 'PackageType' => 'Zip', + 'Handler' => 'worker.php', + 'EphemeralStorage' => [ + 'Size' => Arr::get($queueDefinition,'tmp', $this->unloadConfig->tmp()), + ], + 'ReservedConcurrentExecutions' => Arr::get($queueDefinition, 'concurrency', $this->unloadConfig->concurrency()), + 'Events' => [ + "{$queueStack}Event" => [ + 'Type' => 'SQS', + 'Properties' => [ + 'Queue' => new TaggedValue('GetAtt', "$queueStack.Outputs.Arn"), + ] + ] + ], + 'Environment' => [ + 'Variables' => [ + 'SQS_WORKER_QUEUE' => $queueRef, + ], + ], + 'Layers' => [ + $this->layer->php(), + ], + ]), + ], + $queueStack => [ + 'Type' => 'AWS::Serverless::Application', + 'Properties' => [ + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + 'Location' => Cloudformation::compile("queue/standard.yaml"), + 'Parameters' => array_filter([ + 'DelaySeconds' => Arr::get($queueDefinition, 'delay'), + 'MessageRetentionPeriod' => Arr::get($queueDefinition, 'retention'), + 'ReceiveMessageWaitTimeSeconds' => (Arr::get($queueDefinition, 'wait') == 'long') ? 20 : 0, + 'VisibilityTimeout' => Arr::get($queueDefinition, 'visibility-timeout', $this->unloadConfig->timeout()), + 'MaxReceiveCount' => Arr::get($queueDefinition, 'tries'), + ]) + ], + ] + ]); + } + + return $this->append('Globals.Function.Environment.Variables', [ + 'QUEUE_CONNECTION' => 'sqs', + 'SQS_PREFIX' => new TaggedValue('Sub', 'https://sqs.${AWS::Region}.amazonaws.com/${AWS::AccountId}'), + ]); + } +} diff --git a/app/Constructs/SessionConstruct.php b/app/Constructs/SessionConstruct.php new file mode 100644 index 0000000..b5500c7 --- /dev/null +++ b/app/Constructs/SessionConstruct.php @@ -0,0 +1,49 @@ + new TaggedValue('Ref', 'CacheTable')]; + + $this->append('Policies', [ + ['DynamoDBCrudPolicy' => $sessionTableRef,], + ]); + + $this->append( + 'Globals.Function.Environment.Variables.DYNAMODB_CACHE_TABLE', + new TaggedValue('Ref', 'CacheTable') + ); + + return $this->append('Resources', [ + 'CacheTable' => [ + 'Type' => 'AWS::DynamoDB::Table', + 'Properties' => [ + 'TableClass' => 'STANDARD', + 'BillingMode' => 'PAY_PER_REQUEST', + 'AttributeDefinitions' => [ + [ + 'AttributeName' => 'key', + 'AttributeType' => 'S' + ] + ], + 'KeySchema' => [ + [ + 'AttributeName' => 'key', + 'KeyType' => 'HASH', + ] + ], + 'TimeToLiveSpecification' => [ + 'AttributeName' => 'expires_at', + 'Enabled' => true, + ], + 'Tags' => $this->unloadConfig->unloadTags(), + ], + ], + ]); + } +} diff --git a/app/Handler.php b/app/Handler.php new file mode 100644 index 0000000..91a7cdc --- /dev/null +++ b/app/Handler.php @@ -0,0 +1,28 @@ +getAwsErrorMessage())); +// return; +// } +// +// parent::renderForConsole($output, $exception); +// } +} diff --git a/app/Oidcs/Bitbucket.php b/app/Oidcs/Bitbucket.php new file mode 100644 index 0000000..979b560 --- /dev/null +++ b/app/Oidcs/Bitbucket.php @@ -0,0 +1,36 @@ +organization = $organization; + $this->audience = $audience; + $this->repositoryUuid = $repositoryUuid; + } + + public function thumbprint(): string + { + return 'a031c46782e6e6c662c2c87c76da9aa62ccabd8e'; + } + + public function url(): string + { + return "https://api.bitbucket.org/2.0/workspaces/$this->organization/pipelines-config/identity/oidc"; + } + + public function audience(): string + { + return $this->audience; + } + + public function claim(): string + { + return "$this->repositoryUuid:*"; + } +} diff --git a/app/Oidcs/Github.php b/app/Oidcs/Github.php new file mode 100644 index 0000000..eeb6d27 --- /dev/null +++ b/app/Oidcs/Github.php @@ -0,0 +1,37 @@ +organization = $organization; + $this->repository = $repository; + $this->branch = $branch; + } + + public function thumbprint(): string + { + return '6938fd4d98bab03faadb97b34396831e3780aea1'; + } + + public function url(): string + { + return 'https://token.actions.githubusercontent.com'; + } + + public function audience(): string + { + return 'sts.amazonaws.com'; + } + + public function claim(): string + { + return "repo:$this->organization/$this->repository:ref:refs/heads/$this->branch"; + } +} diff --git a/app/Oidcs/OidcFactory.php b/app/Oidcs/OidcFactory.php new file mode 100644 index 0000000..d584e80 --- /dev/null +++ b/app/Oidcs/OidcFactory.php @@ -0,0 +1,22 @@ +provider()) { + case 'github': { + return new Github($config->repositoryOrganization(), $config->repositoryName(), $config->branch()); + } + case 'bitbucket': { + return new Bitbucket($config->repositoryOrganization(), $config->audience(), $config->repositoryUuid()); + } + } + + throw new \BadMethodCallException("Invalid CI provider: {$config->provider()}"); + } +} diff --git a/app/Oidcs/OidcInterface.php b/app/Oidcs/OidcInterface.php new file mode 100644 index 0000000..37a4401 --- /dev/null +++ b/app/Oidcs/OidcInterface.php @@ -0,0 +1,14 @@ +app->singleton(StsClient::class, function () { + $unload = $this->app->make(UnloadConfig::class); + $configuration = ['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest']; + return new StsClient($configuration); + }); + $this->app->singleton(LambdaClient::class, function() { + $unload = $this->app->make(UnloadConfig::class); + $configuration = ['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest']; + return new LambdaClient($configuration); + }); + $this->app->singleton(S3Client::class, function () { + $unload = $this->app->make(UnloadConfig::class); + $configuration = ['region' => $unload->region(), 'profile' => $unload->profile(), 'version' => 'latest']; + return new S3Client($configuration); + }); + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + Artisan::starting( + function ($artisan) { + $version = shell_exec('sam --version 2> /dev/null'); + + if (!$version) { + throw new \Exception('AWS SAM binary is required, but is not found on the system. See https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html'); + } + + $artisan->setVersion(app()->version() . "\n $version"); + } + ); + } +} diff --git a/app/System.php b/app/System.php new file mode 100644 index 0000000..4a4cdfc --- /dev/null +++ b/app/System.php @@ -0,0 +1,23 @@ +> `tty`"); + + $newContent = file_get_contents($tmpFilePath); + unlink($tmpFilePath); + + return $newContent; + } + + public static function browser(string $url): int + { + return (int) exec("open '$url' 2>/dev/null || xdg-open '$url' 2>/dev/null"); + } +} diff --git a/app/Task.php b/app/Task.php new file mode 100644 index 0000000..9f289d0 --- /dev/null +++ b/app/Task.php @@ -0,0 +1,40 @@ +tasks = $tasks; + return $task; + } + + public function add($task): self + { + $this->tasks = array_merge($this->tasks, is_array($task) ? $task : [$task]); + return $this; + } + + public function when($condition, $task) + { + if ($condition) { + $this->add($task); + } + return $this; + } + + public function execute(Commands\Command $command): void + { + /** @var CleanupVendorTask $task */ + foreach($this->tasks as $task) { + $command->step($task::class, fn() => App::call([$task, 'handle'], ['output' => $command->getOutput()])); + } + } +} diff --git a/app/Tasks/CleanupFilesTask.php b/app/Tasks/CleanupFilesTask.php new file mode 100644 index 0000000..36e668c --- /dev/null +++ b/app/Tasks/CleanupFilesTask.php @@ -0,0 +1,59 @@ +merge($unloadConfig->ignoreFiles()) + ->each(fn(string $path) => $this->removeGlob($path)); + } + + protected function removeGlob(string $path): void + { + foreach(File::glob($path) as $path) { + if (File::isDirectory($path)) { + File::deleteDirectory($path, false); + return; + } + File::delete($path); + } + } +} diff --git a/app/Tasks/CleanupPipelineConfigTask.php b/app/Tasks/CleanupPipelineConfigTask.php new file mode 100644 index 0000000..bd779d9 --- /dev/null +++ b/app/Tasks/CleanupPipelineConfigTask.php @@ -0,0 +1,16 @@ +in(Path::current()) + ->exclude('.idea') + ->exclude('.unload') + ->exclude('.aws-sam') + ->exclude('.github') + ->exclude('unload_test') + ->exclude('vendor') + ->exclude('node_modules') + ->notName('rr') + ->notName('node_modules') + ->notPath('/^'.preg_quote('tests', '/').'/') + ->ignoreVcs(true) + ->ignoreDotFiles(true); + + foreach($sources as $source) { + if ($source->isLink()) { + continue; + } + + if ($source->isDir()) { + File::copyDirectory($source->getRealPath(), Path::tmpApp($source->getRelativePathname())); + } else { + File::copy($source->getRealPath(), Path::tmpApp($source->getRelativePathname())); + } + } + } +} diff --git a/app/Tasks/DestroyComposerPlatformCheckTask.php b/app/Tasks/DestroyComposerPlatformCheckTask.php new file mode 100644 index 0000000..848aa85 --- /dev/null +++ b/app/Tasks/DestroyComposerPlatformCheckTask.php @@ -0,0 +1,18 @@ +deleteStack()?->wait(); + } +} diff --git a/app/Tasks/EmptyAwsCredentialTask.php b/app/Tasks/EmptyAwsCredentialTask.php new file mode 100644 index 0000000..8c2c68f --- /dev/null +++ b/app/Tasks/EmptyAwsCredentialTask.php @@ -0,0 +1,30 @@ +in(Path::tmpApp('config')); + + foreach($configs as $config) { + if ($config->isDir()) { + continue; + } + + $content = Str::of(File::get($config->getRealPath())) + ->replace('AWS_ACCESS_KEY_ID', 'UNLOAD_NULL') + ->replace('AWS_SECRET_ACCESS_KEY', 'UNLOAD_NULL') + ->replace('AWS_SESSION_TOKEN', 'UNLOAD_NULL') + ->toString(); + + File::put($config->getRealPath(), $content); + } + } +} diff --git a/app/Tasks/ExecuteBuildTask.php b/app/Tasks/ExecuteBuildTask.php new file mode 100644 index 0000000..3afdf2c --- /dev/null +++ b/app/Tasks/ExecuteBuildTask.php @@ -0,0 +1,47 @@ +build(); + if (!$commands) { + $commands = $this->defaultBuildCommands(); + } + $commands[] = 'php artisan vendor:publish --tag=unload --force'; + + foreach($commands as $command) { + $run = Process::fromShellCommandline($command, Path::tmpApp()); + + $run->run(function ($type, $line) { + echo $line; + }); + + if ($run->getExitCode() && $runError = $run->getErrorOutput()) { + throw new \BadMethodCallException($runError); + } + } + } + + protected function defaultBuildCommands(): array + { + $commands = [ + 'composer install --ignore-platform-reqs --no-dev --prefer-dist --no-interaction --no-progress --ignore-platform-reqs --optimize-autoloader --classmap-authoritative', + 'php artisan route:cache', + ]; + + if (File::exists(Path::tmpApp('package.json'))) { + $commands[] = 'npm install'; + $commands[] = 'npm run prod'; + } + + return $commands; + } +} diff --git a/app/Tasks/ExecuteDeployTask.php b/app/Tasks/ExecuteDeployTask.php new file mode 100644 index 0000000..f390da4 --- /dev/null +++ b/app/Tasks/ExecuteDeployTask.php @@ -0,0 +1,18 @@ +deploy(); + + foreach($commands as $command) { + Artisan::call('exec', $command); + } + } +} diff --git a/app/Tasks/ExecuteSamBuildTask.php b/app/Tasks/ExecuteSamBuildTask.php new file mode 100644 index 0000000..96bd8f5 --- /dev/null +++ b/app/Tasks/ExecuteSamBuildTask.php @@ -0,0 +1,24 @@ + 0] + ); + + $build->run(); + + if ($build->getExitCode() && $buildError = $build->getErrorOutput()) { + throw new \BadMethodCallException($buildError); + } + } +} diff --git a/app/Tasks/ExecuteSamDeleteTask.php b/app/Tasks/ExecuteSamDeleteTask.php new file mode 100644 index 0000000..5e7c9b5 --- /dev/null +++ b/app/Tasks/ExecuteSamDeleteTask.php @@ -0,0 +1,26 @@ +appStackName()} --region={$unload->region()} --profile={$unload->profile()} --no-prompts", + null, + ['SAM_CLI_TELEMETRY' => 0] + ); + + $delete->setTimeout(3600); + $delete->run(fn ($type, $line) => $output->write($line)); + + if ($delete->getExitCode()) { + throw new \Exception('Failed to delete stack: ', $delete->getOutput()); + } + } +} diff --git a/app/Tasks/ExecuteSamDeployTask.php b/app/Tasks/ExecuteSamDeployTask.php new file mode 100644 index 0000000..6cceb77 --- /dev/null +++ b/app/Tasks/ExecuteSamDeployTask.php @@ -0,0 +1,31 @@ + 0] + ); + + $deploy->setTimeout(3600); + $deploy->run(fn ($type, $line) => $output->write($line)); + + if ($deploy->getExitCode()) { + throw new \Exception('AWS sam failed to deploy the stack. Please check cloudformation output for exact reason.'); + } + } +} diff --git a/app/Tasks/ExtractStaticAssetsTask.php b/app/Tasks/ExtractStaticAssetsTask.php new file mode 100644 index 0000000..3914309 --- /dev/null +++ b/app/Tasks/ExtractStaticAssetsTask.php @@ -0,0 +1,36 @@ +in(Path::tmpApp('public')) + ->notName('*.php') + ->notName('.htaccess') + ->ignoreVcs(true) + ->ignoreDotFiles(false); + + foreach($assets as $asset) { + if ($asset->isLink()) { + continue; + } + + if ($asset->isDir()) { + File::copyDirectory($asset->getRealPath(), Path::tmpAsset($asset->getRelativePathname())); + } else { + File::copy($asset->getRealPath(), Path::tmpAsset($asset->getRelativePathname())); + } + } + } +} diff --git a/app/Tasks/FlushEnvironmentTask.php b/app/Tasks/FlushEnvironmentTask.php new file mode 100644 index 0000000..726c9e7 --- /dev/null +++ b/app/Tasks/FlushEnvironmentTask.php @@ -0,0 +1,13 @@ +flushEnvironment(); + } +} diff --git a/app/Tasks/GenerateMakefileTask.php b/app/Tasks/GenerateMakefileTask.php new file mode 100644 index 0000000..b1e1b01 --- /dev/null +++ b/app/Tasks/GenerateMakefileTask.php @@ -0,0 +1,26 @@ +/dev/null {} + + find $(ARTIFACTS_DIR) -type d -exec touch -t 201203101513 2>/dev/null {} + +MAKEFILE + ); + } +} diff --git a/app/Tasks/GeneratePipelineTask.php b/app/Tasks/GeneratePipelineTask.php new file mode 100644 index 0000000..80a10fb --- /dev/null +++ b/app/Tasks/GeneratePipelineTask.php @@ -0,0 +1,68 @@ +provider = $provider; + $this->stages = $stages; + $this->definition = $definition; + } + + public function handle(): void + { + if ($this->definition) { + $tmp = $this->definition; + } else { + $tmp = tempnam(sys_get_temp_dir(),''); + unlink($tmp); + mkdir($tmp); + $clone = Process::fromShellCommandline("git clone https://github.com/unloadphp/unload-pipeline.git $tmp"); + $clone->run(function($out, $text) { + echo $text; + }); + } + + $templateProvider = [ + 'github' => 'GitHub-Actions', + 'bitbucket' => 'Bitbucket-Pipelines', + ][$this->provider]; + $templateName = [ + 1 => 'one-stage-pipeline-template', + 2 => 'two-stage-pipeline-template', + ][$this->stages]; + $tmpGithubActions = "$tmp/$templateProvider/$templateName\n"; + + $input = new InputStream(); + $input->write(self::USE_CUSTOM_PIPELINE_TEMPLATE); + $input->write($tmpGithubActions); + + $process = Process::fromShellCommandline("sam pipeline init"); + $process->setTimeout(null); + $process->setInput($input); + $stream = fopen('php://stdin', 'w+'); + $input->write($stream); + + $process->start(function ($output, $text) use ($input, $process, &$builder) { + echo $text; + + if (str_contains($text, 'Successfully created the pipeline configuration file(s)')) { + $input->close(); + $process->stop(); + } + }); + + $process->wait(); + } +} diff --git a/app/Tasks/GeneratePipelineTemplateTask.php b/app/Tasks/GeneratePipelineTemplateTask.php new file mode 100644 index 0000000..7f12463 --- /dev/null +++ b/app/Tasks/GeneratePipelineTemplateTask.php @@ -0,0 +1,25 @@ +config = $config; + } + + public function handle(): void + { + $ci = new ContinuousIntegration($this->config); + $pipelineTemplate = new SamPipelineTemplate($this->config, $ci); + $pipelineTemplate->make(); + } +} diff --git a/app/Tasks/GenerateSamConfigTask.php b/app/Tasks/GenerateSamConfigTask.php new file mode 100644 index 0000000..7056b33 --- /dev/null +++ b/app/Tasks/GenerateSamConfigTask.php @@ -0,0 +1,13 @@ +make(); + } +} diff --git a/app/Tasks/GenerateSamTemplateTask.php b/app/Tasks/GenerateSamTemplateTask.php new file mode 100644 index 0000000..1d71157 --- /dev/null +++ b/app/Tasks/GenerateSamTemplateTask.php @@ -0,0 +1,13 @@ +make(); + } +} diff --git a/app/Tasks/GenerateUnloadTemplateTask.php b/app/Tasks/GenerateUnloadTemplateTask.php new file mode 100644 index 0000000..35da47d --- /dev/null +++ b/app/Tasks/GenerateUnloadTemplateTask.php @@ -0,0 +1,22 @@ +config = $config; + } + + public function handle(): void + { + $unloadTemplate = new UnloadTemplate($this->config); + $unloadTemplate->make(); + } +} diff --git a/app/Tasks/InitCertificateTask.php b/app/Tasks/InitCertificateTask.php new file mode 100644 index 0000000..5a98e15 --- /dev/null +++ b/app/Tasks/InitCertificateTask.php @@ -0,0 +1,14 @@ +createStack()?->wait(); + } +} diff --git a/app/Tasks/InitContinuousIntegrationTask.php b/app/Tasks/InitContinuousIntegrationTask.php new file mode 100644 index 0000000..56bc2e0 --- /dev/null +++ b/app/Tasks/InitContinuousIntegrationTask.php @@ -0,0 +1,24 @@ +config = $config; + } + + public function handle(): void + { + $ci = new ContinuousIntegration($this->config); + $oidc = OidcFactory::fromBootstrap($this->config); + $ci->createStack($oidc)->wait(); + } +} diff --git a/app/Tasks/InitEnvironmentTask.php b/app/Tasks/InitEnvironmentTask.php new file mode 100644 index 0000000..8c08da0 --- /dev/null +++ b/app/Tasks/InitEnvironmentTask.php @@ -0,0 +1,45 @@ +config = $config; + } + + public function handle(): void + { + $appKey = 'base64:'.base64_encode(random_bytes(32)); + $password = Str::random(41); + $initEnvironment = <<config->app()} +APP_KEY={$appKey} + +APP_DEBUG=false +APP_ENV={$this->config->env()} +SESSION_DRIVER=dynamodb +CACHE_DRIVER=dynamodb +CACHE_STORE=dynamodb + +DB_CONNECTION=mysql +DB_PORT=3306 + +DB_DATABASE={$this->config->databaseName()} +DB_USERNAME={$this->config->databaseUsername()} +DB_PASSWORD={$password} + +ENV; + + $parameterStore = new SystemManager($this->config); + $parameterStore->putEnvironment($initEnvironment, rotate: true); + $parameterStore->putCiParameter('database', $password, secure: true); + } +} diff --git a/app/Tasks/InitNetworkTask.php b/app/Tasks/InitNetworkTask.php new file mode 100644 index 0000000..823e987 --- /dev/null +++ b/app/Tasks/InitNetworkTask.php @@ -0,0 +1,24 @@ +config = $config; + } + + public function handle(): void + { + $ci = new ContinuousIntegration($this->config); + $network = new Network($this->config, $ci); + $network->createStack($this->config->vpc(), $this->config->nat())->wait(); + } +} diff --git a/app/Tasks/PrepareBuildDirectoryTask.php b/app/Tasks/PrepareBuildDirectoryTask.php new file mode 100644 index 0000000..194820c --- /dev/null +++ b/app/Tasks/PrepareBuildDirectoryTask.php @@ -0,0 +1,18 @@ +fetchEnvironment()); + } +} diff --git a/app/Tasks/UploadAssetTask.php b/app/Tasks/UploadAssetTask.php new file mode 100644 index 0000000..ab22ab5 --- /dev/null +++ b/app/Tasks/UploadAssetTask.php @@ -0,0 +1,55 @@ +getAssetsBucketName(); + $assetHash = $unload->tmpBuildAssetHash(); + $commands = []; + + $uploadFn = function ($file) use ($assetBucket, $assetHash, $s3) { + return Coroutine::of(function () use ($file, $assetHash, $assetBucket, $s3) { + $fileName = str($file->getPathname())->replace(Path::tmpAssetDirectory(), '')->toString(); + + try { + yield $s3->headObject([ + 'Bucket' => $assetBucket, + 'Key' => "assets/$assetHash{$fileName}", + ]); + + echo "\n Already exists, skipped |> assets/$assetHash{$fileName}"; + } catch (\Exception $e) { + echo "\n Uploading |> assets/$assetHash{$fileName}"; + yield $s3->putObject([ + 'Bucket' => $assetBucket, + 'Key' => "assets/$assetHash{$fileName}", + 'Body' => fopen($file->getRealPath(), 'r'), + ]); + } + }); + }; + + foreach(File::allFiles(Path::tmpAssetDirectory()) as $file) { + $commands[] = $uploadFn($file); + } + + try { + Utils::all($commands); + echo PHP_EOL; + } catch (AwsException $e) { + throw new \Exception('Failed to upload assets: ', $e->getAwsErrorMessage()); + } + } +} diff --git a/app/Templates/SamConfigTemplate.php b/app/Templates/SamConfigTemplate.php new file mode 100644 index 0000000..c27ad56 --- /dev/null +++ b/app/Templates/SamConfigTemplate.php @@ -0,0 +1,88 @@ +ciConfiguration(); + $vpcParameters = $this->vpcConfiguration(); + $certificate = $this->getCertificate(); + $assetHash = $this->calculateAssetHash(); + + $profile = ''; + if($this->unloadConfig->profile()) { + $profile = sprintf('profile = "%s"', $this->unloadConfig->profile()); + } + + return File::put( + Path::tmpSamConfig(), + << $this->unloadConfig->profile(), 'region' => $this->unloadConfig->region(), 'version' => 'latest']); + collect($cloudformation->describeStacks(['StackName' => $this->unloadConfig->networkStackName()])->search('Stacks[0].Outputs')) + ->each(function ($value) use (&$vpcParameters) { + $vpcParameters .= "{$value['OutputKey']}={$value['OutputValue']} "; + }); + return $vpcParameters; + } + + protected function ciConfiguration(): array + { + $cloudformation = new CloudFormationClient(['profile' => $this->unloadConfig->profile(), 'region' => $this->unloadConfig->region(), 'version' => 'latest']); + $outputs = collect($cloudformation->describeStacks(['StackName' => $this->unloadConfig->ciStackName()])->search('Stacks[0].Outputs'))->keyBy('OutputKey'); + return [ + str_replace('arn:aws:s3:::', '', $outputs->get('ArtifactsBucket')['OutputValue']), + $outputs->get('CloudFormationExecutionRole')['OutputValue'] + ]; + } + + protected function calculateAssetHash(): string + { + $filesHash = []; + foreach (File::allFiles(Path::tmpAssetDirectory()) as $file) { + $filesHash[] = md5_file($file->getRealPath()); + } + $assetHash = md5(implode(',', $filesHash)); + file_put_contents(Path::tmpAssetHash(), $assetHash); + return $assetHash; + } + + protected function getCertificate(): string + { + try { + return App::make(Certificate::class)->getCertificateArn(); + } catch (\Exception $e) { + return ''; + } + } +} diff --git a/app/Templates/SamPipelineTemplate.php b/app/Templates/SamPipelineTemplate.php new file mode 100644 index 0000000..68c6214 --- /dev/null +++ b/app/Templates/SamPipelineTemplate.php @@ -0,0 +1,45 @@ +ci = $ci; + parent::__construct($unloadConfig); + } + + public function make(): bool + { + $pipelineConfigPath = Path::tmpSamPipelineConfig(); + $pipelineDirectory = File::dirname($pipelineConfigPath); + if (!File::exists($pipelineDirectory)) { + File::makeDirectory($pipelineDirectory, 0777, true); + File::put($pipelineConfigPath, 'version = 0.1' . PHP_EOL); + } + + $stage = $this->unloadConfig->env(); + $pipelineConfiguration = "[$stage]" . PHP_EOL; + $pipelineConfiguration .= "[$stage.pipeline_bootstrap]" . PHP_EOL; + $pipelineConfiguration .= "[$stage.pipeline_bootstrap.parameters]" . PHP_EOL; + $pipelineConfiguration .= sprintf('%s_region = "%s"', $stage, $this->unloadConfig->region()) . PHP_EOL; + $pipelineConfiguration .= sprintf('%s_git_branch = "%s"', $stage, $this->unloadConfig->branch()) . PHP_EOL; + $pipelineConfiguration .= sprintf('%s_pipeline_execution_role = "%s"', $stage, $this->ci->getPipelineExecutionRoleArn()) . PHP_EOL; + $pipelineConfiguration .= sprintf('%s_unload_template = "%s"', $stage, $this->unloadConfig->template(relative: true)) . PHP_EOL; + $pipelineConfiguration .= sprintf('%s_unload_version = "%s"', $stage, App::version()) . PHP_EOL; + $pipelineConfiguration .= sprintf('%s_php_version = "%s"', $stage, $this->unloadConfig->php()) . PHP_EOL; + + return File::append($pipelineConfigPath, $pipelineConfiguration); + } +} diff --git a/app/Templates/SamTemplate.php b/app/Templates/SamTemplate.php new file mode 100644 index 0000000..94af274 --- /dev/null +++ b/app/Templates/SamTemplate.php @@ -0,0 +1,217 @@ +layer = $layer; + $this->ssm = new SsmClient(['profile' => $unloadConfig->profile(), 'region' => $unloadConfig->region(), 'version' => 'latest']); + parent::__construct($unloadConfig); + } + + public function make(): bool + { + return $this + ->bootstrap() + ->setupNetwork() + ->setupEvents() + ->setupEnvironment() + ->setupCloudfront() + ->setupDns() + ->setupQueues() + ->setupBuckets() + ->setupDatabases() + ->setupSession() + ->setupCache() + ->setupPolicies() + ->toYaml(); + } + + protected function bootstrap(): self + { + $this->samTemplate = [ + 'AWSTemplateFormatVersion' => '2010-09-09', + 'Transform' => 'AWS::Serverless-2016-10-31', + 'Description' => 'App: application resources', + 'Parameters' => [], + 'Policies' => [], + 'Outputs' => [], + 'Conditions' => [ + 'HasSingleAZ' => new TaggedValue('Equals', [new TaggedValue('Ref', 'VpcAZsNumber'), '1']), + ], + 'Globals' => [ + 'Function' => [ + 'AutoPublishAlias' => $this->unloadConfig->env(), + 'Runtime' => $this->unloadConfig->runtime(), + 'Tags' => $this->unloadConfig->unloadTagsPlain(), + ], +// 'HttpApi' => [ +// 'Tags' => $this->unloadConfig->unloadTagsPlain(), +// ] + ], + 'Resources' => [ + 'WebFunction' => [ + 'Type' => 'AWS::Serverless::Function', + 'Properties' => array_filter([ + 'FunctionName' => $this->unloadConfig->webFunction(), + 'MemorySize' => $this->unloadConfig->webFunctionMemory(), + 'Timeout' => $this->unloadConfig->webFunctionTimeout(), + 'PackageType' => 'Zip', + 'Handler' => 'public/index.php', + 'FunctionUrlConfig' => [ + 'AuthType' => 'NONE' + ], + 'DeploymentPreference' => [ + 'Type' => 'AllAtOnce', + 'Hooks' => [ + 'PreTraffic' => new TaggedValue('Ref', 'DeployFunction') + ], + ], + 'ReservedConcurrentExecutions' => $this->unloadConfig->webFunctionConcurrency(), + 'ProvisionedConcurrencyConfig' => array_filter([ + 'ProvisionedConcurrentExecutions' => $this->unloadConfig->webFunctionProvision(), + ]), + 'EphemeralStorage' => [ + 'Size' => $this->unloadConfig->webFunctionTmp(), + ], + 'Layers' => [ + $this->layer->fpm(), + ], + ]), + ], + 'CliFunction' => [ + 'Type' => 'AWS::Serverless::Function', + 'Properties' => array_filter([ + 'FunctionName' => $this->unloadConfig->cliFunction(), + 'MemorySize' => $this->unloadConfig->cliFunctionMemory(), + 'Timeout' => $this->unloadConfig->cliFunctionTimeout(), + 'PackageType' => 'Zip', + 'Handler' => 'artisan', + 'ReservedConcurrentExecutions' => $this->unloadConfig->cliFunctionConcurrency(), + 'ProvisionedConcurrencyConfig' => array_filter([ + 'ProvisionedConcurrentExecutions' => $this->unloadConfig->cliFunctionProvision(), + ]), + 'EphemeralStorage' => [ + 'Size' => $this->unloadConfig->cliFunctionTmp(), + ], + 'Layers' => [ + $this->layer->php(), + $this->layer->console(), + ], + ]), + ], + 'DeployFunction' => [ + 'Type' => 'AWS::Serverless::Function', + 'Properties' => array_filter([ + 'FunctionName' => $this->unloadConfig->deployFunction(), + 'PackageType' => 'Zip', + 'Policies' => [ + [ + 'Version' => "2012-10-17", + 'Statement' => [ + [ + 'Effect' => 'Allow', + 'Action' => ['codedeploy:PutLifecycleEventHookExecutionStatus'], + 'Resource' => new TaggedValue('Sub', 'arn:${AWS::Partition}:codedeploy:${AWS::Region}:${AWS::AccountId}:deploymentgroup:${ServerlessDeploymentApplication}/*'), + ] + ] + ], + [ + 'Version' => "2012-10-17", + 'Statement' => [ + [ + 'Effect' => 'Allow', + 'Action' => ['lambda:InvokeFunction'], + 'Resource' => new TaggedValue('Sub', ['${CliFunctionArn}:*', ['CliFunctionArn' => new TaggedValue('GetAtt', 'CliFunction.Arn')]]), + ] + ] + ] + ], + 'DeploymentPreference' => [ + 'Enabled' => false, + 'Role' => '', + ], + 'Environment' => [ + 'Variables' => [ + 'CliFunction' => new TaggedValue('Ref', 'CliFunction.Version'), + 'CliDeployCommand' => $this->unloadConfig->deploy(), + ], + ], + 'Runtime' => 'nodejs12.x', + 'InlineCode' => Cloudformation::get('deploy.js'), + 'Handler' => 'index.handler', + ]), + ], + ] + ]; + return $this; + } + + protected function append($key, $config): self + { + if (is_array($config)) { + $templatePart = Arr::get($this->samTemplate, $key, []); + $templatePart = array_merge_recursive($templatePart, $config); + Arr::set($this->samTemplate, $key, $templatePart); + } else { + Arr::set($this->samTemplate, $key, $config); + } + + return $this; + } + + protected function get($key): mixed + { + return Arr::get($this->samTemplate, $key); + } + + protected function forget($key): self + { + Arr::forget($this->samTemplate, $key); + return $this; + } + + protected function toYaml(): bool + { + return File::put(Path::tmpTemplate(), Yaml::dump($this->samTemplate, 15)); + } +} diff --git a/app/Templates/Template.php b/app/Templates/Template.php new file mode 100644 index 0000000..12b35b5 --- /dev/null +++ b/app/Templates/Template.php @@ -0,0 +1,17 @@ +unloadConfig = $unloadConfig; + } + + public abstract function make(): bool; +} diff --git a/app/Templates/UnloadTemplate.php b/app/Templates/UnloadTemplate.php new file mode 100644 index 0000000..9e93be0 --- /dev/null +++ b/app/Templates/UnloadTemplate.php @@ -0,0 +1,20 @@ +unloadConfig->toArray() as $key => $value) { + $template = $template->replace("%$key%", $value); + } + + File::put($this->unloadConfig->template(), $template->toString()); + + return true; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..2f214e5 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,55 @@ +singleton( + Illuminate\Contracts\Console\Kernel::class, + LaravelZero\Framework\Kernel::class +); + +//$app->singleton( +// Illuminate\Contracts\Debug\ExceptionHandler::class, +// Illuminate\Foundation\Exceptions\Handler::class +//); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + Illuminate\Foundation\Exceptions\Handler::class +); + +/* +|-------------------------------------------------------------------------- +| Return The Application +|-------------------------------------------------------------------------- +| +| This script returns the application instance. The instance is given to +| the calling script so we can separate the building of the instances +| from the actual running of the application and sending responses. +| +*/ + +return $app; diff --git a/box.json b/box.json new file mode 100644 index 0000000..879b428 --- /dev/null +++ b/box.json @@ -0,0 +1,20 @@ +{ + "chmod": "0755", + "directories": [ + "app", + "bootstrap", + "config", + "cloudformation", + "vendor", + "resources" + ], + "files": [ + "composer.json" + ], + "exclude-composer-files": false, + "compression": "GZ", + "compactors": [ + "KevinGH\\Box\\Compactor\\Php", + "KevinGH\\Box\\Compactor\\Json" + ] +} diff --git a/cloudformation/construct/certificate.yaml.php b/cloudformation/construct/certificate.yaml.php new file mode 100644 index 0000000..fbfec12 --- /dev/null +++ b/cloudformation/construct/certificate.yaml.php @@ -0,0 +1,47 @@ +--- +# Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'ACM: certificate for multiple domains' +Resources: + Certificate: + Type: 'AWS::CertificateManager::Certificate' + DeletionPolicy: Retain + Properties: + DomainName: keys()->first() ?> + + DomainValidationOptions: + $zone): ?> + + - DomainName: '' + + HostedZoneId: + + + + SubjectAlternativeNames: + + $zone): ?> + + - '' + + + + ValidationMethod: DNS +Outputs: + CertificateArn: + Description: 'ACM Certificate ARN' + Value: !Ref Certificate + Export: + Name: !Sub '${AWS::StackName}-CertificateArn' diff --git a/cloudformation/construct/ci.yaml b/cloudformation/construct/ci.yaml new file mode 100644 index 0000000..51c3c04 --- /dev/null +++ b/cloudformation/construct/ci.yaml @@ -0,0 +1,354 @@ +--- +# Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: 'CI: deployment resources' +Parameters: + Application: + Type: String + Env: + Type: String + PipelineExecutionRoleArn: + Default: "" + Type: String + CloudFormationExecutionRoleArn: + Default: "" + Type: String + ArtifactsBucketArn: + Type: String + Default: "" + CreateImageRepository: + Type: String + Default: false + AllowedValues: [true, false] + ImageRepositoryArn: + Default: "" + Type: String + IdentityProviderThumbprint: + Type: String + OidcClientId: + Type: String + OidcProviderUrl: + Type: String + UseOidcProvider: + Type: String + Default: true + AllowedValues: [true, false] + SubjectClaim: + Type: String + CreateNewOidcProvider: + Type: String + AllowedValues: [true, false] + +Conditions: + MissingOidcProvider: !Equals [!Ref CreateNewOidcProvider, "true"] + DontUseOidc: !Not [!Equals [!Ref UseOidcProvider, "true"] ] + MissingPipelineExecutionRole: !Equals [!Ref PipelineExecutionRoleArn, ""] + MissingCloudFormationExecutionRole: !Equals [!Ref CloudFormationExecutionRoleArn, ""] + MissingArtifactsBucket: !Equals [!Ref ArtifactsBucketArn, ""] + ShouldHaveImageRepository: !Or [!Equals [!Ref CreateImageRepository, "true"], !Not [!Equals [!Ref ImageRepositoryArn, ""]]] + MissingImageRepository: !And [!Condition ShouldHaveImageRepository, !Equals [!Ref ImageRepositoryArn, ""]] + +Resources: + OidcProvider: + Type: AWS::IAM::OIDCProvider + Condition: MissingOidcProvider + Properties: + ClientIdList: + - !Ref OidcClientId + ThumbprintList: + - !Ref IdentityProviderThumbprint + Url: !Ref OidcProviderUrl + + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Condition: MissingCloudFormationExecutionRole + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: + - 'sts:AssumeRole' + Policies: + - PolicyName: GrantCloudFormationFullAccess + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: '*' + Resource: '*' + + PipelineExecutionRole: + Type: AWS::IAM::Role + Condition: MissingPipelineExecutionRole + Properties: + Tags: + - Key: Role + Value: pipeline-execution-role + AssumeRolePolicyDocument: !Sub + - | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:${AWS::Partition}:iam::${AWS::AccountId}:oidc-provider/${Url}" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "ForAllValues:StringLike": { + "${Url}:aud": "${OidcClientId}", + "${Url}:sub": "${SubjectClaim}" + } + } + } + ] + } + - Url: !Select [ 1, !Split [ "//", !Ref OidcProviderUrl ] ] + + + ArtifactsBucket: + Type: AWS::S3::Bucket + Condition: MissingArtifactsBucket + DeletionPolicy: "Retain" + Properties: + LoggingConfiguration: + DestinationBucketName: + !Ref ArtifactsLoggingBucket + LogFilePrefix: "artifacts-logs" + VersioningConfiguration: + Status: Enabled + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + + ArtifactsBucketPolicy: + Type: AWS::S3::BucketPolicy + Condition: MissingArtifactsBucket + Properties: + Bucket: !Ref ArtifactsBucket + PolicyDocument: + Statement: + - Effect: "Deny" + Action: "s3:*" + Principal: "*" + Resource: + - !Join [ '',[ !GetAtt ArtifactsBucket.Arn, '/*' ] ] + - !GetAtt ArtifactsBucket.Arn + Condition: + Bool: + aws:SecureTransport: false + - Effect: "Allow" + Action: + - 's3:GetObject*' + - 's3:PutObject*' + - 's3:GetBucket*' + - 's3:List*' + Resource: + - !Join ['',[!GetAtt ArtifactsBucket.Arn, '/*']] + - !GetAtt ArtifactsBucket.Arn + Principal: + AWS: + - Fn::If: + - MissingPipelineExecutionRole + - !GetAtt PipelineExecutionRole.Arn + - !Ref PipelineExecutionRoleArn + - Fn::If: + - MissingCloudFormationExecutionRole + - !GetAtt CloudFormationExecutionRole.Arn + - !Ref CloudFormationExecutionRoleArn + + ArtifactsLoggingBucket: + Type: AWS::S3::Bucket + Condition: MissingArtifactsBucket + DeletionPolicy: "Retain" + Properties: + AccessControl: "LogDeliveryWrite" + VersioningConfiguration: + Status: Enabled + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + + ArtifactsLoggingBucketPolicy: + Type: AWS::S3::BucketPolicy + Condition: MissingArtifactsBucket + Properties: + Bucket: !Ref ArtifactsLoggingBucket + PolicyDocument: + Statement: + - Effect: "Deny" + Action: "s3:*" + Principal: "*" + Resource: + - !Join [ '',[ !GetAtt ArtifactsLoggingBucket.Arn, '/*' ] ] + - !GetAtt ArtifactsLoggingBucket.Arn + Condition: + Bool: + aws:SecureTransport: false + + PipelineExecutionRolePermissionPolicy: + Type: AWS::IAM::Policy + Condition: MissingPipelineExecutionRole + Properties: + PolicyName: PipelineExecutionRolePermissions + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: 'iam:PassRole' + Resource: + Fn::If: + - MissingCloudFormationExecutionRole + - !GetAtt CloudFormationExecutionRole.Arn + - !Ref CloudFormationExecutionRoleArn + - Effect: Allow + Action: + - "cloudformation:CreateChangeSet" + - "cloudformation:DescribeChangeSet" + - "cloudformation:ExecuteChangeSet" + - "cloudformation:DeleteStack" + - "cloudformation:DescribeStackEvents" + - "cloudformation:DescribeStacks" + - "cloudformation:GetTemplate" + - "cloudformation:GetTemplateSummary" + - "cloudformation:DescribeStackResource" + Resource: '*' + - Effect: Allow + Action: + - 's3:DeleteObject' + - 's3:GetObject*' + - 's3:PutObject*' + - 's3:GetBucket*' + - 's3:List*' + Resource: + Fn::If: + - MissingArtifactsBucket + - - !Join [ '',[ !GetAtt ArtifactsBucket.Arn, '/*' ] ] + - !GetAtt ArtifactsBucket.Arn + - - !Join [ '',[ !Ref ArtifactsBucketArn, '/*' ] ] + - !Ref ArtifactsBucketArn + - Effect: Allow + Action: + - 'ssm:GetParametersByPath' + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${Application}/${Env}/env" + - Effect: Allow + Action: + - 'route53:ListHostedZones' + Resource: "*" + - Fn::If: + - ShouldHaveImageRepository + - Effect: "Allow" + Action: "ecr:GetAuthorizationToken" + Resource: "*" + - !Ref AWS::NoValue + - Fn::If: + - ShouldHaveImageRepository + - Effect: "Allow" + Action: + - "ecr:GetDownloadUrlForLayer" + - "ecr:BatchDeleteImage" + - "ecr:BatchGetImage" + - "ecr:BatchCheckLayerAvailability" + - "ecr:PutImage" + - "ecr:InitiateLayerUpload" + - "ecr:UploadLayerPart" + - "ecr:CompleteLayerUpload" + Resource: + Fn::If: + - MissingImageRepository + - !GetAtt ImageRepository.Arn + - !Ref ImageRepositoryArn + - !Ref AWS::NoValue + Roles: + - !Ref PipelineExecutionRole + + ImageRepository: + Type: AWS::ECR::Repository + Condition: MissingImageRepository + Properties: + RepositoryPolicyText: + Version: "2012-10-17" + Statement: + - Sid: LambdaECRImageRetrievalPolicy + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - "ecr:GetDownloadUrlForLayer" + - "ecr:BatchGetImage" + - "ecr:GetRepositoryPolicy" + - "ecr:SetRepositoryPolicy" + - "ecr:DeleteRepositoryPolicy" + - Sid: AllowPushPull + Effect: Allow + Principal: + AWS: + - Fn::If: + - MissingPipelineExecutionRole + - !GetAtt PipelineExecutionRole.Arn + - !Ref PipelineExecutionRoleArn + - Fn::If: + - MissingCloudFormationExecutionRole + - !GetAtt CloudFormationExecutionRole.Arn + - !Ref CloudFormationExecutionRoleArn + Action: + - "ecr:GetDownloadUrlForLayer" + - "ecr:BatchGetImage" + - "ecr:BatchCheckLayerAvailability" + - "ecr:PutImage" + - "ecr:InitiateLayerUpload" + - "ecr:UploadLayerPart" + - "ecr:CompleteLayerUpload" + +Outputs: + CloudFormationExecutionRole: + Description: ARN of the IAM Role(CloudFormationExecutionRole) + Value: + Fn::If: + - MissingCloudFormationExecutionRole + - !GetAtt CloudFormationExecutionRole.Arn + - !Ref CloudFormationExecutionRoleArn + + PipelineExecutionRole: + Description: ARN of the IAM Role(PipelineExecutionRole) + Value: + Fn::If: + - MissingPipelineExecutionRole + - !GetAtt PipelineExecutionRole.Arn + - !Ref PipelineExecutionRoleArn + + ArtifactsBucket: + Description: ARN of the Artifacts bucket + Value: + Fn::If: + - MissingArtifactsBucket + - !GetAtt ArtifactsBucket.Arn + - !Ref ArtifactsBucketArn + + ImageRepository: + Description: ARN of the ECR image repository + Condition: ShouldHaveImageRepository + Value: + Fn::If: + - MissingImageRepository + - !GetAtt ImageRepository.Arn + - !Ref ImageRepositoryArn diff --git a/cloudformation/construct/dns.yaml.php b/cloudformation/construct/dns.yaml.php new file mode 100644 index 0000000..ed059d3 --- /dev/null +++ b/cloudformation/construct/dns.yaml.php @@ -0,0 +1,54 @@ +--- +# Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'ACM: certificate for multiple domains' +Parameters: + DistributionDomain: + Type: String +Resources: + + + $zone): ?> + + + Route53RecordV2: + + Type: 'AWS::Route53::RecordSetGroup' + Properties: + HostedZoneId: + + RecordSets: + - Name: + + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !Ref DistributionDomain + + Route53RecordIPv6: + + Type: 'AWS::Route53::RecordSetGroup' + Properties: + HostedZoneId: + + RecordSets: + - Name: + + Type: AAAA + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !Ref DistributionDomain + + diff --git a/cloudformation/construct/website.yaml b/cloudformation/construct/website.yaml new file mode 100644 index 0000000..37f86ed --- /dev/null +++ b/cloudformation/construct/website.yaml @@ -0,0 +1,251 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Distribution: combining S3, CloudFront and Route53' +Parameters: + DistributionName: + Description: 'Distribution Name' + Type: String + Default: '' + PipelineRoleArn: + Description: 'Pipeline role ARN' + Type: String + Default: '' + ParentAlertStack: + Description: 'Optional but recommended stack name of parent alert stack based on operations/alert.yaml template.' + Type: String + Default: '' + ParentS3StackAccessLog: + Description: 'Optional stack name of parent s3 stack based on state/s3.yaml template (with Access set to ElbAccessLogWrite) to store access logs.' + Type: String + Default: '' + ParentWAFStack: + Description: 'Optional stack name of parent WAF stack based on the security/waf.yaml template.' + Type: String + Default: '' + EndpointOriginDomain: + Description: 'Optional domain for http origin.' + Type: String + Default: '' + EndpointOriginPath: + Description: 'Optional path for http origin.' + Type: String + Default: '' + Domains: + Description: 'Optional domains list for cloudfront aliases' + Type: String + Default: '' + ExistingCertificate: + Description: 'Optional ACM Certificate ARN or IAM Certificate ID. Certificate must be created in the us-east-1 region!' + Type: String + Default: '' + GeoRestrictionLocations: + Description: 'Optional ISO2 countries list for geo restrictions in Cloudfront distribution' + Type: String + Default: '' + GeoRestrictionType: + Description: 'Optional geo restction type in Cloudfront distribution' + Type: String + Default: none + AllowedValues: [ none, whitelist, blacklist ] + DefaultErrorPagePath: + Description: 'Optional path of the error page for the website (e.g. /error.html).' + Type: String + Default: '' + DefaultErrorResponseCode: + Description: 'The HTTP status code that you want to return along with the error page (requires DefaultErrorPagePath).' + Type: String + Default: '404' + AllowedValues: [ '200', '404' ] +Conditions: + HasS3Bucket: !Not [ !Equals [ !Ref ParentS3StackAccessLog, '' ] ] + HasCertificate: !Not [ !Equals [ !Ref ExistingCertificate, '' ] ] + HasWAF: !Not [ !Equals [ !Ref ParentWAFStack, '' ] ] + HasDefaultErrorPagePath: !Not [ !Equals [ !Ref DefaultErrorPagePath, '' ] ] + HasAlertTopic: !Not [ !Equals [ !Ref ParentAlertStack, '' ] ] + HasRegionNorthVirginia: !Equals [ !Ref 'AWS::Region', 'us-east-1' ] + HasAlertTopicAndRegionNorthVirginia: !And [ !Condition HasAlertTopic, !Condition HasRegionNorthVirginia ] + HasGeoRestiction: !Not [ !Equals [ !Ref GeoRestrictionType, none] ] +Resources: + AssetsBucket: + Type: 'AWS::S3::Bucket' + DeletionPolicy: "Retain" + Properties: + PublicAccessBlockConfiguration: # AWS Foundational Security Best Practices v1.0.0 S3.8 + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + S3BucketPolicy: + Type: 'AWS::S3::BucketPolicy' + Properties: + Bucket: !Ref AssetsBucket + PolicyDocument: + Statement: + - Action: 's3:GetObject' + Effect: Allow + Resource: !Sub 'arn:aws:s3:::${AssetsBucket}/*' + Principal: + CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId + - Sid: AllowReadWriteByPipelineUser + Action: + - 's3:GetObject' + - 's3:PutObject' + Effect: Allow + Resource: !Sub 'arn:aws:s3:::${AssetsBucket}/*' + Principal: + AWS: !Ref PipelineRoleArn + - Sid: AllowSSLRequestsOnly # AWS Foundational Security Best Practices v1.0.0 S3.5 + Effect: Deny + Principal: '*' + Action: 's3:*' + Resource: + - !GetAtt 'AssetsBucket.Arn' + - !Sub '${AssetsBucket.Arn}/*' + Condition: + Bool: + 'aws:SecureTransport': false + CloudFrontOriginAccessIdentity: + Type: 'AWS::CloudFront::CloudFrontOriginAccessIdentity' + Properties: + CloudFrontOriginAccessIdentityConfig: + Comment: !Ref DistributionName + CloudfrontRequestFunction: + Type: 'AWS::CloudFront::Function' + Properties: + Name: !Sub "${DistributionName}-RequestFunction" + AutoPublish: true + FunctionConfig: + Comment: 'Append x-forwarded-host header to origin request' + Runtime: 'cloudfront-js-1.0' + FunctionCode: | + function handler(event) { + var request = event.request; + request.headers["x-forwarded-host"] = request.headers["host"]; + return request; + } + CloudFrontDistribution: + Type: 'AWS::CloudFront::Distribution' + Properties: + DistributionConfig: + Aliases: !If + - HasCertificate + - !Split [',', !Ref Domains] + - [] + Comment: !Ref DistributionName + CustomErrorResponses: !If + - HasDefaultErrorPagePath + - - ErrorCode: 403 # 403 from S3 indicates that the file does not exists + ResponseCode: !Ref DefaultErrorResponseCode + ResponsePagePath: !Ref DefaultErrorPagePath + - [ ] + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + - PUT + - PATCH + - POST + - DELETE + DefaultTTL: 0 + ForwardedValues: + Cookies: + Forward: all + QueryString: true + Headers: + - "Accept" + - "Accept-Language" + - "Content-Type" + - "Origin" + - "Referer" + - "User-Agent" + - "X-Requested-With" + - "X-Forwarded-Host" + TargetOriginId: endpointorigin + ViewerProtocolPolicy: 'redirect-to-https' + FunctionAssociations: + - EventType: 'viewer-request' + FunctionARN: !GetAtt CloudfrontRequestFunction.FunctionARN + CacheBehaviors: + - AllowedMethods: + - GET + - HEAD + - OPTIONS + CachedMethods: + - GET + - HEAD + Compress: true + DefaultTTL: 3600 # in seconds + ForwardedValues: + Cookies: + Forward: none + QueryString: false + MaxTTL: 86400 # in seconds + MinTTL: 60 # in seconds + TargetOriginId: s3origin + PathPattern: /assets/* + ViewerProtocolPolicy: 'redirect-to-https' + Enabled: true + HttpVersion: http2 + IPV6Enabled: true + Logging: !If [ HasS3Bucket, { Bucket: { 'Fn::ImportValue': !Sub '${ParentS3StackAccessLog}-BucketDomainName' }, Prefix: !Ref 'AWS::StackName' }, !Ref 'AWS::NoValue' ] + Origins: + - DomainName: !GetAtt 'AssetsBucket.RegionalDomainName' + Id: s3origin + S3OriginConfig: + OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}' + - DomainName: !Ref EndpointOriginDomain + OriginPath: !Ref EndpointOriginPath + Id: endpointorigin + CustomOriginConfig: + OriginProtocolPolicy: 'https-only' + PriceClass: 'PriceClass_All' + ViewerCertificate: !If + - HasCertificate + - AcmCertificateArn: !Ref ExistingCertificate + MinimumProtocolVersion: 'TLSv1.1_2016' + SslSupportMethod: 'sni-only' + - !Ref 'AWS::NoValue' + Restrictions: + GeoRestriction: + Locations: !If [HasGeoRestiction, !Split [',', !Ref GeoRestrictionLocations], []] + RestrictionType: !Ref GeoRestrictionType + WebACLId: !If + - HasWAF + - { 'Fn::ImportValue': !Sub '${ParentWAFStack}-WebACL' } + - !Ref 'AWS::NoValue' +Outputs: + AssetsBucketName: + Description: 'Name of the S3 bucket storing the static assets.' + Value: !Ref AssetsBucket + Export: + Name: !Sub '${AWS::StackName}-BucketName' + AssetsBucketArn: + Description: 'Arn of the S3 bucket storing the static assets.' + Value: !GetAtt AssetsBucket.Arn + Export: + Name: !Sub '${AWS::StackName}-AssetsBucketArn' + URL: + Description: 'URL to website.' + Value: !GetAtt 'CloudFrontDistribution.DomainName' + Export: + Name: !Sub '${AWS::StackName}-URL' + DistributionId: + Description: 'CloudFront distribution id' + Value: !Ref CloudFrontDistribution + Export: + Name: !Sub '${AWS::StackName}-DistributionId' diff --git a/cloudformation/deploy.js b/cloudformation/deploy.js new file mode 100644 index 0000000..49b5469 --- /dev/null +++ b/cloudformation/deploy.js @@ -0,0 +1,70 @@ +// Copyright 2022 unload.sh +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict'; + +const AWS = require('aws-sdk'); +const codedeploy = new AWS.CodeDeploy({apiVersion: '2014-10-06'}); +var lambda = new AWS.Lambda(); + +exports.handler = async (event, context, callback) => { + + var deploymentId = event.DeploymentId; + var lifecycleEventHookExecutionId = event.LifecycleEventHookExecutionId; + var cliFunction = process.env.CliFunction; + var deployCommands = process.env.CliDeployCommand.split("\n") + + console.log("BeforeAllowTraffic cliFunction: " + cliFunction); + console.log("BeforeAllowTraffic deployCommands: " + deployCommands); + + var lambdaResult = "Succeeded"; + for (const command of deployCommands) { + console.log("Running: " + command); + + var cliParams = { + FunctionName: cliFunction, + Payload: JSON.stringify(command), + LogType: 'Tail', + }; + + // Invoke the updated Lambda function. + var response = await lambda.invoke(cliParams).promise(); + var payload = JSON.parse(response.Payload); + var logResult = Buffer.from(response.LogResult, 'base64'); + var output = logResult.toString('ascii'); + + console.log("Output: " + output); + console.log("Payload: " + JSON.stringify(payload)); + + // Check if the status code returned by the updated + // function is 400. If it is, then it failed. If + // is not, then it succeeded. + if (payload.errorType){ + lambdaResult = "Failed"; + break; + } + } + + // Complete the PreTraffic Hook by sending CodeDeploy the validation status + var params = { + deploymentId: deploymentId, + lifecycleEventHookExecutionId: lifecycleEventHookExecutionId, + status: lambdaResult // status can be 'Succeeded' or 'Failed' + }; + + // // Pass CodeDeploy the prepared validation test results. + await codedeploy.putLifecycleEventHookExecutionStatus(params).promise(); + + console.log("CodeDeploy status updated successfully"); + callback(null, "CodeDeploy status updated successfully"); +} \ No newline at end of file diff --git a/cloudformation/network/nat-gateway.yaml b/cloudformation/network/nat-gateway.yaml new file mode 100644 index 0000000..3f0665f --- /dev/null +++ b/cloudformation/network/nat-gateway.yaml @@ -0,0 +1,168 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'VPC: serverless NAT Gateway' +Parameters: + VpcRouteTablePrivate: + Description: 'Vpc VpcRouteTablePrivate' + Type: String + VpcSubnetPublic: + Description: 'Vpc VpcSubnetPublic' + Type: String + + ParentAlertStack: + Description: 'Optional but recommended stack name of parent alert stack based on operations/alert.yaml template.' + Type: String + Default: '' + SubnetZone: + Description: 'Subnet zone.' + Type: String + Default: A + AllowedValues: + - A + - B + - C + - D +Conditions: + HasAlertTopic: !Not [ !Equals [ !Ref ParentAlertStack, '' ] ] +Resources: + NatEIP: + Type: 'AWS::EC2::EIP' + UpdateReplacePolicy: Delete + Properties: + Domain: vpc + NatGateway: + Type: 'AWS::EC2::NatGateway' + Properties: + AllocationId: !GetAtt 'NatEIP.AllocationId' + SubnetId: !Ref VpcSubnetPublic + Route: + Type: 'AWS::EC2::Route' + Properties: + RouteTableId: !Ref VpcRouteTablePrivate + DestinationCidrBlock: '0.0.0.0/0' + NatGatewayId: !Ref NatGateway + AlarmNatGatewayErrorPortAllocation: + Condition: HasAlertTopic + Type: 'AWS::CloudWatch::Alarm' + Properties: + AlarmDescription: !Sub 'NAT gateway ${SubnetZone} could not allocate a source port' + Namespace: 'AWS/NATGateway' + MetricName: ErrorPortAllocation + Statistic: Sum + Period: 60 + EvaluationPeriods: 1 + ComparisonOperator: GreaterThanThreshold + Threshold: 0 + AlarmActions: + - { 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' } + Dimensions: + - Name: NatGatewayId + Value: !Ref NatGateway + AlarmNatGatewayPacketsDropCount: + Condition: HasAlertTopic + Type: 'AWS::CloudWatch::Alarm' + Properties: + AlarmDescription: !Sub 'NAT gateway ${SubnetZone} dropped packets' + Namespace: 'AWS/NATGateway' + MetricName: PacketsDropCount + Statistic: Sum + Period: 60 + EvaluationPeriods: 1 + ComparisonOperator: GreaterThanThreshold + Threshold: 0 + AlarmActions: + - { 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' } + Dimensions: + - Name: NatGatewayId + Value: !Ref NatGateway + AlarmNatGatewayBandwidth: + Condition: HasAlertTopic + Type: 'AWS::CloudWatch::Alarm' + Properties: + AlarmActions: + - { 'Fn::ImportValue': !Sub '${ParentAlertStack}-TopicARN' } + AlarmDescription: !Sub 'NAT gateway ${SubnetZone} bandwidth utilization is over 80%' + ComparisonOperator: GreaterThanThreshold + EvaluationPeriods: 1 + Metrics: + - Id: 'in1' + Label: 'InFromDestination' + MetricStat: + Metric: + Namespace: 'AWS/NATGateway' + MetricName: BytesInFromDestination # bytes per minute + Dimensions: + - Name: NatGatewayId + Value: !Ref NatGateway + Period: 60 + Stat: Sum + Unit: Bytes + ReturnData: false + - Id: 'in2' + Label: 'InFromSource' + MetricStat: + Metric: + Namespace: 'AWS/NATGateway' + MetricName: BytesInFromSource # bytes per minute + Dimensions: + - Name: NatGatewayId + Value: !Ref NatGateway + Period: 60 + Stat: Sum + Unit: Bytes + ReturnData: false + - Id: 'out1' + Label: 'OutToDestination' + MetricStat: + Metric: + Namespace: 'AWS/NATGateway' + MetricName: BytesOutToDestination # bytes per minute + Dimensions: + - Name: NatGatewayId + Value: !Ref NatGateway + Period: 60 + Stat: Sum + Unit: Bytes + ReturnData: false + - Id: 'out2' + Label: 'OutToSource' + MetricStat: + Metric: + Namespace: 'AWS/NATGateway' + MetricName: BytesOutToSource # bytes per minute + Dimensions: + - Name: NatGatewayId + Value: !Ref NatGateway + Period: 60 + Stat: Sum + Unit: Bytes + ReturnData: false + - Expression: '(in1+in2+out1+out2)/2/60*8/1000/1000/1000' # to Gbit/s + Id: 'bandwidth' + Label: 'Bandwidth' + ReturnData: true + Threshold: 36 # hard limit is 45 Gbit/s + TreatMissingData: notBreaching +Outputs: + StackName: + Description: 'Stack name.' + Value: !Sub '${AWS::StackName}' + IPAddress: + Description: 'The public IP address of the NAT gateway.' + Value: !Ref NatEIP + Export: + Name: !Sub '${AWS::StackName}-IPAddress' diff --git a/cloudformation/network/nat-instance.yaml b/cloudformation/network/nat-instance.yaml new file mode 100644 index 0000000..7e457b6 --- /dev/null +++ b/cloudformation/network/nat-instance.yaml @@ -0,0 +1,475 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'VPC: highly available NAT instance' +Parameters: + VpcCidrBlock: + Description: 'Vpc Cidr Block' + Type: String + VpcId: + Description: 'Vpc Ref' + Type: String + VpcRouteTablePrivate: + Description: 'Vpc VpcRouteTablePrivate' + Type: String + VpcSubnetPublic: + Description: 'Vpc VpcSubnetPublic' + Type: String + PermissionsBoundary: + Description: 'Optional ARN for a policy that will be used as the permission boundary for all roles created by this template.' + Type: String + Default: '' + KeyName: + Description: 'Optional key pair of the ec2-user to establish a SSH connection to the NAT instance.' + Type: String + Default: '' + IAMUserSSHAccess: + Description: 'Synchronize public keys of IAM users to enable personalized SSH access (Doc: https://cloudonaut.io/manage-aws-ec2-ssh-access-with-iam/).' + Type: String + Default: false + AllowedValues: + - true + - false + NATInstanceType: + Description: 'Instance type of the NAT instance. Keep in mind that different instances come with different network capabilities.' + Type: String + Default: 't3.nano' + LogsRetentionInDays: + Description: 'Specifies the number of days you want to retain log events.' + Type: Number + Default: 14 + AllowedValues: [ 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653 ] + SubDomainNameWithDot: + Description: 'Name that is used to create the DNS entry with trailing dot, e.g. §{SubDomainNameWithDot}§{HostedZoneName}. Leave blank for naked (or apex and bare) domain. Requires ParentZoneStack parameter!' + Type: String + Default: 'nat.' + ManagedPolicyArns: + Description: 'Optional comma-delimited list of IAM managed policy ARNs to attach to the instance''s IAM role' + Type: String + Default: '' +Mappings: + RegionMap: + 'af-south-1': + NATAMI: 'ami-007da8686850bd56f' + 'eu-north-1': + NATAMI: 'ami-0a9b1be2951ef12c3' + 'ap-south-1': + NATAMI: 'ami-0e813f885a024399b' + 'eu-west-3': + NATAMI: 'ami-00ec2680d0d697ad0' + 'eu-west-2': + NATAMI: 'ami-011ad77f622e0c2fb' + 'eu-south-1': + NATAMI: 'ami-0fbbb2c6a4ec1e30d' + 'eu-west-1': + NATAMI: 'ami-0a56c2c5f57ae7f9a' + 'ap-northeast-3': + NATAMI: 'ami-05bd005a72b2ad111' + 'ap-northeast-2': + NATAMI: 'ami-0e3e1b45a02a17920' + 'me-south-1': + NATAMI: 'ami-03cba91974d807acf' + 'ap-northeast-1': + NATAMI: 'ami-0a7529bfaaf195a8c' + 'sa-east-1': + NATAMI: 'ami-08936d1fc0040fc58' + 'ca-central-1': + NATAMI: 'ami-0e9d1e46c539f526c' + 'ap-east-1': + NATAMI: 'ami-0c4e0cd8bb5f12fe9' + 'ap-southeast-1': + NATAMI: 'ami-018cd92db227b86e1' + 'ap-southeast-2': + NATAMI: 'ami-0e72186c833bb56c0' + 'ap-southeast-3': + NATAMI: 'ami-060fa367685ed9d0a' + 'eu-central-1': + NATAMI: 'ami-00371432b1bceae1d' + 'us-east-1': + NATAMI: 'ami-05ed49079214c998a' + 'us-east-2': + NATAMI: 'ami-0a8c79bd758e15082' + 'us-west-1': + NATAMI: 'ami-01a3c8305ad5936a2' + 'us-west-2': + NATAMI: 'ami-054c198626cd4a7c6' +Conditions: + HasPermissionsBoundary: !Not [ !Equals [ !Ref PermissionsBoundary, '' ] ] + HasKeyName: !Not [ !Equals [ !Ref KeyName, '' ] ] + HasIAMUserSSHAccess: !Equals [ !Ref IAMUserSSHAccess, 'true' ] + HasManagedPolicyArns: !Not [ !Equals [ !Ref ManagedPolicyArns, '' ] ] +Resources: + NATInstanceProfile: + Type: 'AWS::IAM::InstanceProfile' + DependsOn: + - EIP + - Route + Properties: + Roles: + - !Ref NATIAMRole + MockNetworkInterface: + Type: 'AWS::EC2::NetworkInterface' + Properties: + SubnetId: !Ref VpcSubnetPublic + Route: + Type: 'AWS::EC2::Route' + Properties: + RouteTableId: !Ref VpcRouteTablePrivate + DestinationCidrBlock: '0.0.0.0/0' + NetworkInterfaceId: !Ref MockNetworkInterface + EIP: + Type: 'AWS::EC2::EIP' + Properties: + Domain: vpc + Logs: + Type: 'AWS::Logs::LogGroup' + Properties: + RetentionInDays: !Ref LogsRetentionInDays + NATSecurityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: !Ref 'AWS::StackName' + SecurityGroupEgress: + - IpProtocol: udp + FromPort: 123 + ToPort: 123 + CidrIp: '0.0.0.0/0' + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: '0.0.0.0/0' + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: '0.0.0.0/0' + SecurityGroupIngress: + - IpProtocol: udp + FromPort: 123 + ToPort: 123 + CidrIp: !Ref VpcCidrBlock + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: !Ref VpcCidrBlock + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: !Ref VpcCidrBlock + VpcId: !Ref VpcId + NATIAMRole: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: 'ec2.amazonaws.com' + Action: 'sts:AssumeRole' + ManagedPolicyArns: !If [ HasManagedPolicyArns, !Split [ ',', !Ref ManagedPolicyArns ], !Ref 'AWS::NoValue' ] + PermissionsBoundary: !If [ HasPermissionsBoundary, !Ref PermissionsBoundary, !Ref 'AWS::NoValue' ] + Policies: + - PolicyName: ec2 + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: Stmt1425023276000 + Effect: Allow + Action: + - 'ec2:AssociateAddress' + - 'ec2:ModifyInstanceAttribute' + - 'ec2:CreateRoute' + - 'ec2:ReplaceRoute' + - 'ec2:DeleteRoute' + Resource: + - '*' + - PolicyName: logs + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'logs:CreateLogGroup' + - 'logs:CreateLogStream' + - 'logs:PutLogEvents' + - 'logs:DescribeLogStreams' + Resource: !GetAtt 'Logs.Arn' + NATIAMPolicySSHAccess: + Type: 'AWS::IAM::Policy' + Condition: HasIAMUserSSHAccess + Properties: + Roles: + - !Ref NATIAMRole + PolicyName: iam + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'iam:ListUsers' + Resource: + - '*' + - Effect: Allow + Action: + - 'iam:ListSSHPublicKeys' + - 'iam:GetSSHPublicKey' + Resource: + - !Sub 'arn:aws:iam::${AWS::AccountId}:user/*' + LaunchTemplate: + Type: 'AWS::EC2::LaunchTemplate' + Metadata: + 'AWS::CloudFormation::Init': + configSets: + default: !If [ HasIAMUserSSHAccess, [ awslogs, ssh-access, config ], [ awslogs, config ] ] + awslogs: + packages: + yum: + awslogs: [ ] + files: + '/etc/awslogs/awscli.conf': + content: !Sub | + [default] + region = ${AWS::Region} + [plugins] + cwlogs = cwlogs + mode: '000644' + owner: root + group: root + '/etc/awslogs/awslogs.conf': + content: !Sub | + [general] + state_file = /var/lib/awslogs/agent-state + [/var/log/messages] + datetime_format = %b %d %H:%M:%S + file = /var/log/messages + log_stream_name = {instance_id}/var/log/messages + log_group_name = ${Logs} + [/var/log/secure] + datetime_format = %b %d %H:%M:%S + file = /var/log/secure + log_stream_name = {instance_id}/var/log/secure + log_group_name = ${Logs} + [/var/log/cron] + datetime_format = %b %d %H:%M:%S + file = /var/log/cron + log_stream_name = {instance_id}/var/log/cron + log_group_name = ${Logs} + [/var/log/cloud-init.log] + datetime_format = %b %d %H:%M:%S + file = /var/log/cloud-init.log + log_stream_name = {instance_id}/var/log/cloud-init.log + log_group_name = ${Logs} + [/var/log/cfn-init.log] + datetime_format = %Y-%m-%d %H:%M:%S + file = /var/log/cfn-init.log + log_stream_name = {instance_id}/var/log/cfn-init.log + log_group_name = ${Logs} + [/var/log/cfn-hup.log] + datetime_format = %Y-%m-%d %H:%M:%S + file = /var/log/cfn-hup.log + log_stream_name = {instance_id}/var/log/cfn-hup.log + log_group_name = ${Logs} + [/var/log/cfn-init-cmd.log] + datetime_format = %Y-%m-%d %H:%M:%S + file = /var/log/cfn-init-cmd.log + log_stream_name = {instance_id}/var/log/cfn-init-cmd.log + log_group_name = ${Logs} + [/var/log/cloud-init-output.log] + file = /var/log/cloud-init-output.log + log_stream_name = {instance_id}/var/log/cloud-init-output.log + log_group_name = ${Logs} + [/var/log/dmesg] + file = /var/log/dmesg + log_stream_name = {instance_id}/var/log/dmesg + log_group_name = ${Logs} + mode: '000644' + owner: root + group: root + services: + sysvinit: + awslogs: + enabled: true + ensureRunning: true + packages: + yum: + - awslogs + files: + - '/etc/awslogs/awslogs.conf' + - '/etc/awslogs/awscli.conf' + ssh-access: + files: + '/opt/authorized_keys_command.sh': + content: | + #!/bin/bash -e + if [ -z "$1" ]; then + exit 1 + fi + UnsaveUserName="$1" + UnsaveUserName=${UnsaveUserName//".plus."/"+"} + UnsaveUserName=${UnsaveUserName//".equal."/"="} + UnsaveUserName=${UnsaveUserName//".comma."/","} + UnsaveUserName=${UnsaveUserName//".at."/"@"} + aws iam list-ssh-public-keys --user-name "$UnsaveUserName" --query "SSHPublicKeys[?Status == 'Active'].[SSHPublicKeyId]" --output text | while read -r KeyId; do + aws iam get-ssh-public-key --user-name "$UnsaveUserName" --ssh-public-key-id "$KeyId" --encoding SSH --query "SSHPublicKey.SSHPublicKeyBody" --output text + done + mode: '000755' + owner: root + group: root + '/opt/import_users.sh': + content: | + #!/bin/bash -e + aws iam list-users --query "Users[].[UserName]" --output text | while read User; do + SaveUserName="$User" + SaveUserName=${SaveUserName//"+"/".plus."} + SaveUserName=${SaveUserName//"="/".equal."} + SaveUserName=${SaveUserName//","/".comma."} + SaveUserName=${SaveUserName//"@"/".at."} + if [ "${#SaveUserName}" -le "32" ]; then + if ! id -u "$SaveUserName" >/dev/null 2>&1; then + #sudo will read each file in /etc/sudoers.d, skipping file names that end in ‘~’ or contain a ‘.’ character to avoid causing problems with package manager or editor temporary/backup files. + SaveUserFileName=$(echo "$SaveUserName" | tr "." " ") + /usr/sbin/useradd "$SaveUserName" + echo "$SaveUserName ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/$SaveUserFileName" + fi + else + echo "Can not import IAM user ${SaveUserName}. User name is longer than 32 characters." + fi + done + mode: '000755' + owner: root + group: root + '/etc/cron.d/import_users': + content: | + */10 * * * * root /opt/import_users.sh + mode: '000644' + owner: root + group: root + commands: + 'a_configure_sshd_command': + command: 'sed -e ''/AuthorizedKeysCommand / s/^#*/#/'' -i /etc/ssh/sshd_config; echo -e ''\nAuthorizedKeysCommand /opt/authorized_keys_command.sh'' >> /etc/ssh/sshd_config' + test: '! grep -q ''^AuthorizedKeysCommand /opt/authorized_keys_command.sh'' /etc/ssh/sshd_config' + 'b_configure_sshd_commanduser': + command: 'sed -e ''/AuthorizedKeysCommandUser / s/^#*/#/'' -i /etc/ssh/sshd_config; echo -e ''\nAuthorizedKeysCommandUser nobody'' >> /etc/ssh/sshd_config' + test: '! grep -q ''^AuthorizedKeysCommandUser nobody'' /etc/ssh/sshd_config' + 'c_import_users': + command: './import_users.sh' + cwd: '/opt' + services: + sysvinit: + sshd: + enabled: true + ensureRunning: true + commands: + - 'a_configure_sshd_command' + - 'b_configure_sshd_commanduser' + config: + files: + '/etc/cfn/cfn-hup.conf': + content: !Sub | + [main] + stack=${AWS::StackId} + region=${AWS::Region} + interval=1 + mode: '000400' + owner: root + group: root + '/etc/cfn/hooks.d/cfn-auto-reloader.conf': + content: !Sub | + [cfn-auto-reloader-hook] + triggers=post.update + path=Resources.LaunchTemplate.Metadata.AWS::CloudFormation::Init + action=/opt/aws/bin/cfn-init --verbose --stack=${AWS::StackName} --region=${AWS::Region} --resource=LaunchTemplate + runas=root + services: + sysvinit: + cfn-hup: + enabled: true + ensureRunning: true + files: + - '/etc/cfn/cfn-hup.conf' + - '/etc/cfn/hooks.d/cfn-auto-reloader.conf' + + Properties: + LaunchTemplateData: + BlockDeviceMappings: + - DeviceName: '/dev/xvda' + Ebs: + Encrypted: true + VolumeType: gp3 + IamInstanceProfile: + Name: !Ref NATInstanceProfile + ImageId: !FindInMap [ RegionMap, !Ref 'AWS::Region', NATAMI ] + InstanceType: !Ref NATInstanceType + KeyName: !If [ HasKeyName, !Ref KeyName, !Ref 'AWS::NoValue' ] + MetadataOptions: + HttpTokens: required + NetworkInterfaces: + - AssociatePublicIpAddress: true + DeviceIndex: 0 + Groups: + - !Ref NATSecurityGroup + UserData: + 'Fn::Base64': !Sub + - | + #!/bin/bash -ex + trap '/opt/aws/bin/cfn-signal -e 1 --region ${Region} --stack ${StackName} --resource NATAutoScalingGroup' ERR + TOKEN=$(curl --silent --max-time 60 -X PUT http://169.254.169.254/latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 30") + INSTANCEID=$(curl --silent --max-time 60 -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id) + aws --region ${Region} ec2 associate-address --instance-id $INSTANCEID --allocation-id ${EIPAllocationId} + aws --region ${Region} ec2 modify-instance-attribute --instance-id $INSTANCEID --source-dest-check "{\"Value\": false}" + aws --region ${Region} ec2 replace-route --route-table-id ${RouteTablePrivate} --destination-cidr-block "0.0.0.0/0" --instance-id $INSTANCEID || aws --region ${Region} ec2 create-route --route-table-id ${RouteTablePrivate} --destination-cidr-block "0.0.0.0/0" --instance-id $INSTANCEID + /opt/aws/bin/cfn-init -v --stack ${StackName} --resource LaunchTemplate --region ${Region} + /opt/aws/bin/cfn-signal -e 0 --region ${Region} --stack ${StackName} --resource NATAutoScalingGroup + - RouteTablePrivate: !Ref VpcRouteTablePrivate + Region: !Ref 'AWS::Region' + StackName: !Ref 'AWS::StackName' + EIPAllocationId: !GetAtt 'EIP.AllocationId' + NATAutoScalingGroup: + Type: 'AWS::AutoScaling::AutoScalingGroup' + Properties: + LaunchTemplate: + LaunchTemplateId: !Ref LaunchTemplate + Version: !GetAtt 'LaunchTemplate.LatestVersionNumber' + MaxSize: '1' + MinSize: '1' + Tags: + - Key: Name + Value: !Sub + - 'NAT instance ${CidrBlock}' + - CidrBlock: !Ref VpcCidrBlock + PropagateAtLaunch: true + VPCZoneIdentifier: + - !Ref VpcSubnetPublic + CreationPolicy: + ResourceSignal: + Count: 1 + Timeout: PT10M + UpdatePolicy: + AutoScalingRollingUpdate: + PauseTime: PT10M + SuspendProcesses: + - HealthCheck + - ReplaceUnhealthy + - AZRebalance + - AlarmNotification + - ScheduledActions + WaitOnResourceSignals: true +Outputs: + IPAddress: + Description: 'The public IP address of the NAT instance.' + Value: !Ref EIP + Export: + Name: !Sub '${AWS::StackName}-IPAddress' diff --git a/cloudformation/network/vpc-1az.yaml b/cloudformation/network/vpc-1az.yaml new file mode 100644 index 0000000..2e78b2e --- /dev/null +++ b/cloudformation/network/vpc-1az.yaml @@ -0,0 +1,344 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'VPC: public and private subnets in one zone' +Parameters: + ClassB: + Description: 'Class B of VPC (10.XXX.0.0/16)' + Type: Number + Default: 0 + ConstraintDescription: 'Must be in the range [0-255]' + MinValue: 0 + MaxValue: 255 +Resources: + VPC: + Type: 'AWS::EC2::VPC' + Properties: + CidrBlock: !Sub '10.${ClassB}.0.0/16' + EnableDnsSupport: true + EnableDnsHostnames: true + InstanceTenancy: default + Tags: + - Key: Name + Value: !Sub '10.${ClassB}.0.0/16' + VPCCidrBlock: + Type: 'AWS::EC2::VPCCidrBlock' + Properties: + AmazonProvidedIpv6CidrBlock: true + VpcId: !Ref VPC + InternetGateway: + Type: 'AWS::EC2::InternetGateway' + Properties: + Tags: + - Key: Name + Value: !Sub '10.${ClassB}.0.0/16' + EgressOnlyInternetGateway: + Type: 'AWS::EC2::EgressOnlyInternetGateway' + Properties: + VpcId: !Ref VPC + VPCGatewayAttachment: + Type: 'AWS::EC2::VPCGatewayAttachment' + Properties: + VpcId: !Ref VPC + InternetGatewayId: !Ref InternetGateway + SubnetAPublic: + DependsOn: VPCCidrBlock + Type: 'AWS::EC2::Subnet' + Properties: + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Sub '10.${ClassB}.0.0/20' + Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] + MapPublicIpOnLaunch: true + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A public' + - Key: Reach + Value: public + SubnetAPrivate: + DependsOn: VPCCidrBlock + Type: 'AWS::EC2::Subnet' + Properties: + AssignIpv6AddressOnCreation: false + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Sub '10.${ClassB}.16.0/20' + Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A private' + - Key: Reach + Value: private + RouteTableAPublic: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A Public' + RouteTableAPrivate: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A Private' + RouteTableAssociationAPublic: + Type: 'AWS::EC2::SubnetRouteTableAssociation' + Properties: + SubnetId: !Ref SubnetAPublic + RouteTableId: !Ref RouteTableAPublic + RouteTableAssociationAPrivate: + Type: 'AWS::EC2::SubnetRouteTableAssociation' + Properties: + SubnetId: !Ref SubnetAPrivate + RouteTableId: !Ref RouteTableAPrivate + RouteTablePublicAInternetRoute: + Type: 'AWS::EC2::Route' + DependsOn: VPCGatewayAttachment + Properties: + RouteTableId: !Ref RouteTableAPublic + DestinationCidrBlock: '0.0.0.0/0' + GatewayId: !Ref InternetGateway + RouteTablePublicAInternetRouteIPv6: + Type: 'AWS::EC2::Route' + DependsOn: VPCGatewayAttachment + Properties: + RouteTableId: !Ref RouteTableAPublic + DestinationIpv6CidrBlock: '::/0' + GatewayId: !Ref InternetGateway + RouteTablePrivateAInternetRouteIPv6: + Type: 'AWS::EC2::Route' + Properties: + RouteTableId: !Ref RouteTableAPrivate + DestinationIpv6CidrBlock: '::/0' + EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway + NetworkAclPublic: + Type: 'AWS::EC2::NetworkAcl' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: Public + NetworkAclPrivate: + Type: 'AWS::EC2::NetworkAcl' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: Private + SubnetNetworkAclAssociationAPublic: + Type: 'AWS::EC2::SubnetNetworkAclAssociation' + Properties: + SubnetId: !Ref SubnetAPublic + NetworkAclId: !Ref NetworkAclPublic + SubnetNetworkAclAssociationAPrivate: + Type: 'AWS::EC2::SubnetNetworkAclAssociation' + Properties: + SubnetId: !Ref SubnetAPrivate + NetworkAclId: !Ref NetworkAclPrivate + NetworkAclEntryInPublicAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: false + CidrBlock: '0.0.0.0/0' + NetworkAclEntryInPublicAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: false + Ipv6CidrBlock: '::/0' + NetworkAclEntryOutPublicAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: true + CidrBlock: '0.0.0.0/0' + NetworkAclEntryOutPublicAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: true + Ipv6CidrBlock: '::/0' + NetworkAclEntryInPrivateAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: false + CidrBlock: '0.0.0.0/0' + NetworkAclEntryInPrivateAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: false + Ipv6CidrBlock: '::/0' + NetworkAclEntryOutPrivateAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: true + CidrBlock: '0.0.0.0/0' + NetworkAclEntryOutPrivateAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: true + Ipv6CidrBlock: '::/0' + + SubnetBPrivate: + DependsOn: SubnetAPrivate + Type: 'AWS::EC2::Subnet' + Properties: + AssignIpv6AddressOnCreation: false + AvailabilityZone: !Select [ 1, !GetAZs '' ] + CidrBlock: !Sub '10.${ClassB}.48.0/20' + Ipv6CidrBlock: !Select [3, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'B private' + - Key: Reach + Value: private + RouteTableBPrivate: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'B Private' + RouteTableAssociationBPrivate: + Type: 'AWS::EC2::SubnetRouteTableAssociation' + Properties: + SubnetId: !Ref SubnetBPrivate + RouteTableId: !Ref RouteTableBPrivate + RouteTablePrivateBInternetRouteIPv6: + Type: 'AWS::EC2::Route' + Properties: + RouteTableId: !Ref RouteTableBPrivate + DestinationIpv6CidrBlock: '::/0' + EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway + DynamoDBVpcEndpoint: + Type: 'AWS::EC2::VPCEndpoint' + Properties: + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.dynamodb' + VpcId: !Ref VPC + VpcEndpointType: 'Gateway' + RouteTableIds: [!Ref RouteTableAPrivate, !Ref RouteTableBPrivate] + +Outputs: + StackName: + Description: 'Stack name.' + Value: !Sub '${AWS::StackName}' + NumberOfAZs: + Description: 'Number of AZs' + Value: 1 + Export: + Name: !Sub '${AWS::StackName}-AZs' + AZs: + Description: 'List of AZs' + Value: !Select [0, !GetAZs ''] + Export: + Name: !Sub '${AWS::StackName}-AZList' + AZA: + Description: 'AZ of A' + Value: !Select [0, !GetAZs ''] + Export: + Name: !Sub '${AWS::StackName}-AZA' + CidrBlock: + Description: 'The set of IP addresses for the VPC.' + Value: !GetAtt 'VPC.CidrBlock' + Export: + Name: !Sub '${AWS::StackName}-CidrBlock' + CidrBlockIPv6: + Description: 'The set of IPv6 addresses for the VPC.' + Value: !Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'] + Export: + Name: !Sub '${AWS::StackName}-CidrBlockIPv6' + VPC: + Description: 'VPC.' + Value: !Ref VPC + Export: + Name: !Sub '${AWS::StackName}-VPC' + InternetGateway: + Description: 'InternetGateway.' + Value: !Ref InternetGateway + Export: + Name: !Sub '${AWS::StackName}-InternetGateway' + SubnetsPublic: + Description: 'Subnets public.' + Value: !Ref SubnetAPublic + Export: + Name: !Sub '${AWS::StackName}-SubnetsPublic' + SubnetsPrivate: + Description: 'Subnets private.' + Value: !Join [',', [!Ref SubnetAPrivate, !Ref SubnetBPrivate]] + Export: + Name: !Sub '${AWS::StackName}-SubnetsPrivate' + RouteTablesPrivate: + Description: 'Route tables private.' + Value: !Join [',', [!Ref RouteTableAPrivate, !Ref RouteTableBPrivate]] + Export: + Name: !Sub '${AWS::StackName}-RouteTablesPrivate' + RouteTablesPublic: + Description: 'Route tables public.' + Value: !Ref RouteTableAPublic + Export: + Name: !Sub '${AWS::StackName}-RouteTablesPublic' + SubnetAPublic: + Description: 'Subnet A public.' + Value: !Ref SubnetAPublic + Export: + Name: !Sub '${AWS::StackName}-SubnetAPublic' + RouteTableAPublic: + Description: 'Route table A public.' + Value: !Ref RouteTableAPublic + Export: + Name: !Sub '${AWS::StackName}-RouteTableAPublic' + SubnetAPrivate: + Description: 'Subnet A private.' + Value: !Ref SubnetAPrivate + Export: + Name: !Sub '${AWS::StackName}-SubnetAPrivate' + RouteTableAPrivate: + Description: 'Route table A private.' + Value: !Ref RouteTableAPrivate + Export: + Name: !Sub '${AWS::StackName}-RouteTableAPrivate' diff --git a/cloudformation/network/vpc-2az.yaml b/cloudformation/network/vpc-2az.yaml new file mode 100644 index 0000000..5540297 --- /dev/null +++ b/cloudformation/network/vpc-2az.yaml @@ -0,0 +1,415 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'VPC: public and private subnets in two availability zones' +Parameters: + ClassB: + Description: 'Class B of VPC (10.XXX.0.0/16)' + Type: Number + Default: 0 + ConstraintDescription: 'Must be in the range [0-255]' + MinValue: 0 + MaxValue: 255 +Resources: + VPC: + Type: 'AWS::EC2::VPC' + Properties: + CidrBlock: !Sub '10.${ClassB}.0.0/16' + EnableDnsSupport: true + EnableDnsHostnames: true + InstanceTenancy: default + Tags: + - Key: Name + Value: !Sub '10.${ClassB}.0.0/16' + VPCCidrBlock: + Type: 'AWS::EC2::VPCCidrBlock' + Properties: + AmazonProvidedIpv6CidrBlock: true + VpcId: !Ref VPC + InternetGateway: + Type: 'AWS::EC2::InternetGateway' + Properties: + Tags: + - Key: Name + Value: !Sub '10.${ClassB}.0.0/16' + EgressOnlyInternetGateway: + Type: 'AWS::EC2::EgressOnlyInternetGateway' + Properties: + VpcId: !Ref VPC + VPCGatewayAttachment: + Type: 'AWS::EC2::VPCGatewayAttachment' + Properties: + VpcId: !Ref VPC + InternetGatewayId: !Ref InternetGateway + SubnetAPublic: + DependsOn: VPCCidrBlock + Type: 'AWS::EC2::Subnet' + Properties: + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Sub '10.${ClassB}.0.0/20' + Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] + MapPublicIpOnLaunch: true + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A public' + - Key: Reach + Value: public + SubnetAPrivate: + DependsOn: VPCCidrBlock + Type: 'AWS::EC2::Subnet' + Properties: + AssignIpv6AddressOnCreation: false + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Sub '10.${ClassB}.16.0/20' + Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A private' + - Key: Reach + Value: private + SubnetBPublic: + DependsOn: VPCCidrBlock + Type: 'AWS::EC2::Subnet' + Properties: + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: !Sub '10.${ClassB}.32.0/20' + Ipv6CidrBlock: !Select [2, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] + MapPublicIpOnLaunch: true + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'B public' + - Key: Reach + Value: public + SubnetBPrivate: + DependsOn: VPCCidrBlock + Type: 'AWS::EC2::Subnet' + Properties: + AssignIpv6AddressOnCreation: false + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: !Sub '10.${ClassB}.48.0/20' + Ipv6CidrBlock: !Select [3, !Cidr [!Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'], 4, 64]] + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'B private' + - Key: Reach + Value: private + RouteTableAPublic: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A Public' + RouteTableAPrivate: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'A Private' + RouteTableBPublic: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'B Public' + RouteTableBPrivate: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: 'B Private' + RouteTableAssociationAPublic: + Type: 'AWS::EC2::SubnetRouteTableAssociation' + Properties: + SubnetId: !Ref SubnetAPublic + RouteTableId: !Ref RouteTableAPublic + RouteTableAssociationAPrivate: + Type: 'AWS::EC2::SubnetRouteTableAssociation' + Properties: + SubnetId: !Ref SubnetAPrivate + RouteTableId: !Ref RouteTableAPrivate + RouteTableAssociationBPublic: + Type: 'AWS::EC2::SubnetRouteTableAssociation' + Properties: + SubnetId: !Ref SubnetBPublic + RouteTableId: !Ref RouteTableBPublic + RouteTableAssociationBPrivate: + Type: 'AWS::EC2::SubnetRouteTableAssociation' + Properties: + SubnetId: !Ref SubnetBPrivate + RouteTableId: !Ref RouteTableBPrivate + RouteTablePublicAInternetRoute: + Type: 'AWS::EC2::Route' + DependsOn: VPCGatewayAttachment + Properties: + RouteTableId: !Ref RouteTableAPublic + DestinationCidrBlock: '0.0.0.0/0' + GatewayId: !Ref InternetGateway + RouteTablePublicAInternetRouteIPv6: + Type: 'AWS::EC2::Route' + DependsOn: VPCGatewayAttachment + Properties: + RouteTableId: !Ref RouteTableAPublic + DestinationIpv6CidrBlock: '::/0' + GatewayId: !Ref InternetGateway + RouteTablePrivateAInternetRouteIPv6: + Type: 'AWS::EC2::Route' + Properties: + RouteTableId: !Ref RouteTableAPrivate + DestinationIpv6CidrBlock: '::/0' + EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway + RouteTablePublicBInternetRoute: + Type: 'AWS::EC2::Route' + DependsOn: VPCGatewayAttachment + Properties: + RouteTableId: !Ref RouteTableBPublic + DestinationCidrBlock: '0.0.0.0/0' + GatewayId: !Ref InternetGateway + RouteTablePublicBInternetRouteIPv6: + Type: 'AWS::EC2::Route' + DependsOn: VPCGatewayAttachment + Properties: + RouteTableId: !Ref RouteTableBPublic + DestinationIpv6CidrBlock: '::/0' + GatewayId: !Ref InternetGateway + RouteTablePrivateBInternetRouteIPv6: + Type: 'AWS::EC2::Route' + Properties: + RouteTableId: !Ref RouteTableBPrivate + DestinationIpv6CidrBlock: '::/0' + EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway + NetworkAclPublic: + Type: 'AWS::EC2::NetworkAcl' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: Public + NetworkAclPrivate: + Type: 'AWS::EC2::NetworkAcl' + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: Private + SubnetNetworkAclAssociationAPublic: + Type: 'AWS::EC2::SubnetNetworkAclAssociation' + Properties: + SubnetId: !Ref SubnetAPublic + NetworkAclId: !Ref NetworkAclPublic + SubnetNetworkAclAssociationAPrivate: + Type: 'AWS::EC2::SubnetNetworkAclAssociation' + Properties: + SubnetId: !Ref SubnetAPrivate + NetworkAclId: !Ref NetworkAclPrivate + SubnetNetworkAclAssociationBPublic: + Type: 'AWS::EC2::SubnetNetworkAclAssociation' + Properties: + SubnetId: !Ref SubnetBPublic + NetworkAclId: !Ref NetworkAclPublic + SubnetNetworkAclAssociationBPrivate: + Type: 'AWS::EC2::SubnetNetworkAclAssociation' + Properties: + SubnetId: !Ref SubnetBPrivate + NetworkAclId: !Ref NetworkAclPrivate + NetworkAclEntryInPublicAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: false + CidrBlock: '0.0.0.0/0' + NetworkAclEntryInPublicAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: false + Ipv6CidrBlock: '::/0' + NetworkAclEntryOutPublicAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: true + CidrBlock: '0.0.0.0/0' + NetworkAclEntryOutPublicAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPublic + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: true + Ipv6CidrBlock: '::/0' + NetworkAclEntryInPrivateAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: false + CidrBlock: '0.0.0.0/0' + NetworkAclEntryInPrivateAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: false + Ipv6CidrBlock: '::/0' + NetworkAclEntryOutPrivateAllowAll: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 99 + Protocol: -1 + RuleAction: allow + Egress: true + CidrBlock: '0.0.0.0/0' + NetworkAclEntryOutPrivateAllowAllIPv6: + Type: 'AWS::EC2::NetworkAclEntry' + Properties: + NetworkAclId: !Ref NetworkAclPrivate + RuleNumber: 98 + Protocol: -1 + RuleAction: allow + Egress: true + Ipv6CidrBlock: '::/0' + DynamoDBVpcEndpoint: + Type: 'AWS::EC2::VPCEndpoint' + Properties: + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.dynamodb' + VpcId: !Ref VPC + VpcEndpointType: 'Gateway' + RouteTableIds: [ !Ref RouteTableAPrivate, !Ref RouteTableBPrivate ] + +Outputs: + NumberOfAZs: + Description: 'Number of AZs' + Value: 2 + Export: + Name: !Sub '${AWS::StackName}-AZs' + AZs: + Description: 'List of AZs' + Value: !Join [',', [!Select [0, !GetAZs ''], !Select [1, !GetAZs '']]] + Export: + Name: !Sub '${AWS::StackName}-AZList' + AZA: + Description: 'AZ of A' + Value: !Select [0, !GetAZs ''] + Export: + Name: !Sub '${AWS::StackName}-AZA' + AZB: + Description: 'AZ of B' + Value: !Select [1, !GetAZs ''] + Export: + Name: !Sub '${AWS::StackName}-AZB' + CidrBlock: + Description: 'The set of IP addresses for the VPC.' + Value: !GetAtt 'VPC.CidrBlock' + Export: + Name: !Sub '${AWS::StackName}-CidrBlock' + CidrBlockIPv6: + Description: 'The set of IPv6 addresses for the VPC.' + Value: !Select [0, !GetAtt 'VPC.Ipv6CidrBlocks'] + Export: + Name: !Sub '${AWS::StackName}-CidrBlockIPv6' + VPC: + Description: 'VPC.' + Value: !Ref VPC + Export: + Name: !Sub '${AWS::StackName}-VPC' + InternetGateway: + Description: 'InternetGateway.' + Value: !Ref InternetGateway + Export: + Name: !Sub '${AWS::StackName}-InternetGateway' + SubnetsPublic: + Description: 'Subnets public.' + Value: !Join [',', [!Ref SubnetAPublic, !Ref SubnetBPublic]] + Export: + Name: !Sub '${AWS::StackName}-SubnetsPublic' + SubnetsPrivate: + Description: 'Subnets private.' + Value: !Join [',', [!Ref SubnetAPrivate, !Ref SubnetBPrivate]] + Export: + Name: !Sub '${AWS::StackName}-SubnetsPrivate' + RouteTablesPrivate: + Description: 'Route tables private.' + Value: !Join [',', [!Ref RouteTableAPrivate, !Ref RouteTableBPrivate]] + Export: + Name: !Sub '${AWS::StackName}-RouteTablesPrivate' + RouteTablesPublic: + Description: 'Route tables public.' + Value: !Join [',', [!Ref RouteTableAPublic, !Ref RouteTableBPublic]] + Export: + Name: !Sub '${AWS::StackName}-RouteTablesPublic' + SubnetAPublic: + Description: 'Subnet A public.' + Value: !Ref SubnetAPublic + Export: + Name: !Sub '${AWS::StackName}-SubnetAPublic' + RouteTableAPublic: + Description: 'Route table A public.' + Value: !Ref RouteTableAPublic + Export: + Name: !Sub '${AWS::StackName}-RouteTableAPublic' + SubnetAPrivate: + Description: 'Subnet A private.' + Value: !Ref SubnetAPrivate + Export: + Name: !Sub '${AWS::StackName}-SubnetAPrivate' + RouteTableAPrivate: + Description: 'Route table A private.' + Value: !Ref RouteTableAPrivate + Export: + Name: !Sub '${AWS::StackName}-RouteTableAPrivate' + SubnetBPublic: + Description: 'Subnet B public.' + Value: !Ref SubnetBPublic + Export: + Name: !Sub '${AWS::StackName}-SubnetBPublic' + RouteTableBPublic: + Description: 'Route table B public.' + Value: !Ref RouteTableBPublic + Export: + Name: !Sub '${AWS::StackName}-RouteTableBPublic' + SubnetBPrivate: + Description: 'Subnet B private.' + Value: !Ref SubnetBPrivate + Export: + Name: !Sub '${AWS::StackName}-SubnetBPrivate' + RouteTableBPrivate: + Description: 'Route table B private.' + Value: !Ref RouteTableBPrivate + Export: + Name: !Sub '${AWS::StackName}-RouteTableBPrivate' diff --git a/cloudformation/network/vpc-sg.yaml b/cloudformation/network/vpc-sg.yaml new file mode 100644 index 0000000..da6489e --- /dev/null +++ b/cloudformation/network/vpc-sg.yaml @@ -0,0 +1,33 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'State: Database client security group' +Parameters: + VpcId: + Description: 'VPC: indentifier.' + Type: String +Resources: + ClientSecurityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: !Ref 'AWS::StackName' + VpcId: !Ref VpcId +Outputs: + ClientSecurityGroup: + Description: 'Use this Security Group to reference client traffic.' + Value: !Ref ClientSecurityGroup + Export: + Name: !Sub '${AWS::StackName}-ClientSecurityGroup' diff --git a/cloudformation/queue/standard.yaml b/cloudformation/queue/standard.yaml new file mode 100644 index 0000000..b7ca54f --- /dev/null +++ b/cloudformation/queue/standard.yaml @@ -0,0 +1,87 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Worker: AWS SQS queue' +Parameters: + DelaySeconds: + Description: 'The time in seconds that the delivery of all messages in the queue is delayed' + Type: Number + Default: 0 + MinValue: 0 + MaxValue: 900 + MaximumMessageSize: + Description: 'The limit of how many bytes that a message can contain before Amazon SQS rejects it' + Type: Number + Default: 262144 + MinValue: 1024 + MaxValue: 262144 + MessageRetentionPeriod: + Description: 'The number of seconds that Amazon SQS retains a message' + Type: Number + Default: 345600 + MinValue: 60 + MaxValue: 1209600 + ReceiveMessageWaitTimeSeconds: + Description: "Specifies the duration, in seconds, that the ReceiveMessage action call waits until a message is in the queue in order to include it in the response, as opposed to returning an empty response if a message isn't yet available" + Type: Number + Default: 0 + MinValue: 0 + MaxValue: 20 + VisibilityTimeout: + Description: 'The length of time during which a message will be unavailable after a message is delivered from the queue' + Type: Number + Default: 30 + MinValue: 0 + MaxValue: 43200 + MaxReceiveCount: + Description: 'The number of times a message is delivered to the source queue before being moved to the dead-letter queue' + Type: Number + Default: 3 + MinValue: 1 + MaxValue: 1000 +Resources: + Queue: + Type: 'AWS::SQS::Queue' + Properties: + DelaySeconds: !Ref DelaySeconds + MaximumMessageSize: !Ref MaximumMessageSize + MessageRetentionPeriod: !Ref MessageRetentionPeriod + ReceiveMessageWaitTimeSeconds: !Ref ReceiveMessageWaitTimeSeconds + RedrivePolicy: + deadLetterTargetArn: !GetAtt 'DeadLetterQueue.Arn' + maxReceiveCount: !Ref MaxReceiveCount + VisibilityTimeout: !Ref VisibilityTimeout + DeadLetterQueue: + Type: 'AWS::SQS::Queue' + Properties: + MessageRetentionPeriod: 1209600 +Outputs: + Name: + Value: !GetAtt 'Queue.QueueName' + Export: + Name: !Sub '${AWS::StackName}-Name' + Arn: + Value: !GetAtt 'Queue.Arn' + Export: + Name: !Sub '${AWS::StackName}-Arn' + Url: + Value: !Ref Queue + Export: + Name: !Sub '${AWS::StackName}-Url' + Prefix: + Value: !Sub 'https://sqs.${AWS::Region}.amazonaws.com/${AWS::AccountId}' + Export: + Name: !Sub '${AWS::StackName}-Prefix' diff --git a/cloudformation/storage/bucket.yaml b/cloudformation/storage/bucket.yaml new file mode 100644 index 0000000..075326c --- /dev/null +++ b/cloudformation/storage/bucket.yaml @@ -0,0 +1,109 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Storage: S3 bucket' +Parameters: + BucketName: + Description: 'Optional name of the bucket.' + Type: String + Default: '' + Access: + Description: 'Access policy of the bucket.' + Type: String + Default: Private + AllowedValues: [ Private, PublicRead] + Versioning: + Description: 'Enable versioning to keep a backup if objects change.' + Type: String + Default: true + AllowedValues: [ true, false, 'suspended' ] + NoncurrentVersionExpirationInDays: + Description: 'Remove noncurrent object versions after days (set to 0 to disable).' + Type: Number + Default: 0 + MinValue: 0 + ExpirationInDays: + Description: 'Remove objects after days (set to 0 to disable).' + Type: Number + Default: 0 + MinValue: 0 + ExpirationPrefix: + Description: 'Optional key prefix for expiring objects.' + Type: String + Default: '' +Conditions: + HasPrivateAccess: !Equals [ !Ref Access, Private ] + HasPublicReadAccess: !Equals [ !Ref Access, PublicRead ] + HasBucketName: !Not [ !Equals [ !Ref BucketName, '' ] ] + HasVersioning: !Equals [ !Ref Versioning, true ] + HadVersioning: !Equals [ !Ref Versioning, 'suspended' ] + HasNoncurrentVersionExpirationInDays: !Not [ !Equals [ !Ref NoncurrentVersionExpirationInDays, 0 ] ] + HasExpirationInDays: !Not [ !Equals [ !Ref ExpirationInDays, 0 ] ] + HasExpirationPrefix: !Not [ !Equals [ !Ref ExpirationPrefix, '' ] ] + HasPublicAccessBlock: !Not [ !Condition HasPublicReadAccess ] +Resources: + Bucket: # cannot be deleted with data + Type: 'AWS::S3::Bucket' + DeletionPolicy: "Retain" + Properties: + BucketName: !If [ HasBucketName, !Ref BucketName, !Ref 'AWS::NoValue' ] + LifecycleConfiguration: + Rules: + - AbortIncompleteMultipartUpload: + DaysAfterInitiation: 7 + Status: Enabled + - NoncurrentVersionExpirationInDays: !If [ HasNoncurrentVersionExpirationInDays, !Ref NoncurrentVersionExpirationInDays, 1 ] + Status: !If [ HasNoncurrentVersionExpirationInDays, Enabled, Disabled ] + - ExpirationInDays: !If [ HasExpirationInDays, !Ref ExpirationInDays, 1 ] + Prefix: !If [ HasExpirationPrefix, !Ref ExpirationPrefix, !Ref 'AWS::NoValue' ] + Status: !If [ HasExpirationInDays, Enabled, Disabled ] + PublicAccessBlockConfiguration: !If [ HasPublicAccessBlock, { BlockPublicAcls: true, BlockPublicPolicy: true, IgnorePublicAcls: true, RestrictPublicBuckets: true }, !Ref 'AWS::NoValue' ] + VersioningConfiguration: !If [ HasVersioning, { Status: Enabled }, !If [ HadVersioning, { Status: Suspended }, !Ref 'AWS::NoValue' ] ] + BucketPolicy: + Type: 'AWS::S3::BucketPolicy' + Properties: + Bucket: !Ref Bucket + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AllowSSLRequestsOnly + Effect: Deny + Principal: '*' + Action: 's3:*' + Resource: + - !GetAtt 'Bucket.Arn' + - !Sub '${Bucket.Arn}/*' + Condition: + Bool: + 'aws:SecureTransport': false + - !If + - HasPublicReadAccess + - Principal: '*' + Action: 's3:GetObject' + Effect: Allow + Resource: !Sub '${Bucket.Arn}/*' + - !Ref 'AWS::NoValue' +Outputs: + BucketName: + Description: 'Name of the bucket' + Value: !Ref Bucket + Export: + Name: !Sub '${AWS::StackName}-BucketName' + BucketDomainName: + Description: 'Domain name of the bucket.' + Value: !GetAtt 'Bucket.DomainName' + Export: + Name: !Sub '${AWS::StackName}-BucketDomainName' diff --git a/cloudformation/storage/mysql-serverless.yaml b/cloudformation/storage/mysql-serverless.yaml new file mode 100644 index 0000000..fa59019 --- /dev/null +++ b/cloudformation/storage/mysql-serverless.yaml @@ -0,0 +1,168 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'State: RDS Aurora Serverless MySQL' +Parameters: + VpcId: + Description: 'Vpc Ref' + Type: String + VpcSubnetsPrivate: + Description: 'Vpc Subnets' + Type: String + VpcSecurityGroup: + Description: 'Vpc Security Group' + Type: String + DBSnapshotIdentifier: + Description: 'Optional identifier for the DB cluster snapshot from which you want to restore (leave blank to create an empty cluster).' + Type: String + Default: '' + DBName: + Description: 'Name of the database (ignored when DBSnapshotIdentifier is set, value used from snapshot).' + Type: String + Default: '' + DBBackupRetentionPeriod: + Description: 'The number of days to keep snapshots of the cluster.' + Type: Number + MinValue: 1 + MaxValue: 35 + Default: 30 + DBMasterUsername: + Description: 'The master user name for the DB instance (ignored when DBSnapshotIdentifier is set, value used from snapshot).' + Type: String + NoEcho: true + Default: master + DBMasterUserPassword: + Description: 'The master password for the DB instance (ignored when DBSnapshotIdentifier is set, value used from snapshot).' + Type: String + NoEcho: true + Default: '' + EnableDataApi: + Description: 'Enable the Data API.' + Type: String + AllowedValues: ['true', 'false'] + Default: 'false' + AutoPause: + Description: 'Enable automatic pause for a Serverless Aurora cluster.' + Type: String + AllowedValues: ['true', 'false'] + Default: 'true' + MaxCapacity: + Description: 'The maximum capacity units for a Serverless Aurora cluster.' + Type: String + AllowedValues: [1, 2, 4, 8, 16, 32, 64, 128, 256] + Default: 2 + MinCapacity: + Description: 'The minimum capacity units for a Serverless Aurora cluster.' + Type: String + AllowedValues: [1, 2, 4, 8, 16, 32, 64, 128, 256] + Default: 2 + SecondsUntilAutoPause: + Description: 'The time, in seconds, before a Serverless Aurora cluster is paused.' + Type: Number + MinValue: 1 + MaxValue: 86400 + Default: 300 + EngineVersion: + Description: 'Aurora Serverless MySQL version.' + Type: String + Default: '5.6.10a' + AllowedValues: ['5.6.10a', '5.7.mysql-aurora.2.07.1'] +Mappings: + EngineVersionMap: + '5.6.10a': + ClusterParameterGroupFamily: 'aurora5.6' + EngineVersion: '5.6.10a' + Engine: aurora + '5.7.mysql-aurora.2.07.1': + ClusterParameterGroupFamily: 'aurora-mysql5.7' + EngineVersion: '5.7.mysql_aurora.2.07.1' + Engine: 'aurora-mysql' +Conditions: + HasNotDBSnapshotIdentifier: !Equals [!Ref DBSnapshotIdentifier, ''] + HasDBSnapshotIdentifier: !Not [!Condition HasNotDBSnapshotIdentifier] +Resources: + ClusterSecurityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: !Ref 'AWS::StackName' + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 3306 + ToPort: 3306 + SourceSecurityGroupId: !Ref VpcSecurityGroup + VpcId: !Ref VpcId + DBSubnetGroup: + Type: 'AWS::RDS::DBSubnetGroup' + Properties: + DBSubnetGroupDescription: !Ref 'AWS::StackName' + SubnetIds: !Split [',', !Ref VpcSubnetsPrivate] + DBClusterParameterGroup: + Type: 'AWS::RDS::DBClusterParameterGroup' + Properties: + Description: !Ref 'AWS::StackName' + Family: !FindInMap [EngineVersionMap, !Ref EngineVersion, ClusterParameterGroupFamily] + Parameters: + character_set_client: utf8 + character_set_connection: utf8 + character_set_database: utf8 + character_set_filesystem: utf8 + character_set_results: utf8 + character_set_server: utf8 + collation_connection: utf8_general_ci + collation_server: utf8_general_ci + DBCluster: + DeletionPolicy: Snapshot # default + UpdateReplacePolicy: Snapshot + Type: 'AWS::RDS::DBCluster' + Properties: + BackupRetentionPeriod: !Ref DBBackupRetentionPeriod + DatabaseName: !If [HasDBSnapshotIdentifier, !Ref 'AWS::NoValue', !Ref DBName] + DBClusterParameterGroupName: !Ref DBClusterParameterGroup + DBSubnetGroupName: !Ref DBSubnetGroup + EnableHttpEndpoint: !Ref EnableDataApi + Engine: !FindInMap [EngineVersionMap, !Ref EngineVersion, Engine] + EngineMode: serverless + EngineVersion: !FindInMap [EngineVersionMap, !Ref EngineVersion, EngineVersion] + MasterUsername: !If [HasDBSnapshotIdentifier, !Ref 'AWS::NoValue', !Ref DBMasterUsername] + MasterUserPassword: !If + - HasDBSnapshotIdentifier + - !Ref 'AWS::NoValue' + - !Sub '{{resolve:ssm-secure:${DBMasterUserPassword}:1}}' + ScalingConfiguration: + AutoPause: !Ref AutoPause + MaxCapacity: !Ref MaxCapacity + MinCapacity: !Ref MinCapacity + SecondsUntilAutoPause: !Ref SecondsUntilAutoPause + SnapshotIdentifier: !If [HasDBSnapshotIdentifier, !Ref DBSnapshotIdentifier, !Ref 'AWS::NoValue'] + StorageEncrypted: true + VpcSecurityGroupIds: + - !Ref ClusterSecurityGroup +Outputs: + ClusterName: + Description: 'The name of the cluster.' + Value: !Ref DBCluster + Export: + Name: !Sub '${AWS::StackName}-ClusterName' + DNSName: + Description: 'The connection endpoint for the DB cluster.' + Value: !GetAtt 'DBCluster.Endpoint.Address' + Export: + Name: !Sub '${AWS::StackName}-DNSName' + SecurityGroupId: + Description: 'The security group used to manage access to RDS Aurora Serverless.' + Value: !Ref ClusterSecurityGroup + Export: + Name: !Sub '${AWS::StackName}-SecurityGroupId' diff --git a/cloudformation/storage/mysql.yaml b/cloudformation/storage/mysql.yaml new file mode 100644 index 0000000..04756ea --- /dev/null +++ b/cloudformation/storage/mysql.yaml @@ -0,0 +1,160 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Storage: RDS MySQL' +Parameters: + VpcId: + Description: 'Vpc Ref' + Type: String + VpcSubnetsPrivate: + Description: 'Vpc Subnets' + Type: String + VpcSecurityGroup: + Description: 'Vpc Security Group' + Type: String + DBPubliclyAccessible: + Description: 'Optional make RDS instance publicly accessible' + Type: String + Default: false + AllowedValues: [ true, false ] + DBSnapshotIdentifier: + Description: 'Optional name or Amazon Resource Name (ARN) of the DB snapshot from which you want to restore (leave blank to create an empty database).' + Type: String + Default: '' + DBAllocatedStorage: + Description: 'The allocated storage size, specified in GB (ignored when DBSnapshotIdentifier is set, value used from snapshot).' + Type: Number + Default: 5 + MinValue: 5 + MaxValue: 16384 + DBInstanceClass: + Description: 'The instance type of database server.' + Type: String + Default: 'db.t2.micro' + DBName: + Description: 'Name of the database (ignored when DBSnapshotIdentifier is set, value used from snapshot).' + Type: String + Default: '' + DBBackupRetentionPeriod: + Description: 'The number of days to keep snapshots of the database.' + Type: Number + MinValue: 0 + MaxValue: 35 + Default: 30 + DBMasterUsername: + Description: 'The master user name for the DB instance (ignored when DBSnapshotIdentifier is set, value used from snapshot).' + Type: String + NoEcho: true + Default: master + DBMasterUserPassword: + Description: 'The master password for the DB instance (ignored when DBSnapshotIdentifier is set, value used from snapshot. Also ignored when ParentSecretStack is used).' + Type: String + NoEcho: true + Default: '' + DBMultiAZ: + Description: 'Specifies if the database instance is deployed to multiple Availability Zones for HA.' + Type: String + Default: true + AllowedValues: [true, false] + DBOptionGroupName: + Description: 'Optional name of an existing DB option group.' + Type: String + Default: '' + DBParameterGroupName: + Description: 'Optional name of an existing DB parameter group.' + Type: String + Default: '' + PreferredBackupWindow: + Description: 'The daily time range in UTC during which you want to create automated backups.' + Type: String + Default: '09:54-10:24' + PreferredMaintenanceWindow: + Description: The weekly time range (in UTC) during which system maintenance can occur. + Type: String + Default: 'sat:07:00-sat:07:30' + EngineVersion: + Description: 'MySQL version.' + Type: String + Default: '8.0.23' + AllowedValues: ['8.0.23', '8.0.15', '5.7.25', '5.7.21', '5.6.41', '5.5.61'] +Conditions: + HasPublicAccess: !Equals [!Ref DBPubliclyAccessible, true] + HasDBOptionGroupName: !Not [!Equals [!Ref DBOptionGroupName, '']] + HasDBParameterGroupName: !Not [!Equals [!Ref DBParameterGroupName, '']] +Resources: + DatabaseSecurityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: !Ref 'AWS::StackName' + VpcId: !Ref VpcId + SecurityGroupIngress: !If + - HasPublicAccess + - - IpProtocol: tcp + FromPort: 3306 + ToPort: 3306 + CidrIp: '0.0.0.0/0' + - - IpProtocol: tcp + FromPort: 3306 + ToPort: 3306 + SourceSecurityGroupId: !Ref VpcSecurityGroup + DBSubnetGroup: + Type: 'AWS::RDS::DBSubnetGroup' + Properties: + DBSubnetGroupDescription: !Ref 'AWS::StackName' + SubnetIds: !Split [',', !Ref VpcSubnetsPrivate] + DBInstance: + DeletionPolicy: Snapshot # default + UpdateReplacePolicy: Snapshot + Type: 'AWS::RDS::DBInstance' + Properties: + AllocatedStorage: !Ref DBAllocatedStorage + AllowMajorVersionUpgrade: false + AutoMinorVersionUpgrade: true + BackupRetentionPeriod: !Ref DBBackupRetentionPeriod + CopyTagsToSnapshot: true + DBInstanceClass: !Ref DBInstanceClass + DBName: !Ref DBName + DBParameterGroupName: !If [HasDBParameterGroupName, !Ref DBParameterGroupName, !Ref 'AWS::NoValue'] + DBSubnetGroupName: !Ref DBSubnetGroup + Engine: mysql + EngineVersion: !Ref EngineVersion + MasterUsername: !Ref DBMasterUsername + MasterUserPassword: !Sub '{{resolve:ssm-secure:${DBMasterUserPassword}:1}}' + MultiAZ: !Ref DBMultiAZ + OptionGroupName: !If [HasDBOptionGroupName, !Ref DBOptionGroupName, !Ref 'AWS::NoValue'] + PreferredBackupWindow: !Ref PreferredBackupWindow + PreferredMaintenanceWindow: !Ref PreferredMaintenanceWindow + StorageType: gp2 + StorageEncrypted: true + PubliclyAccessible: !Ref DBPubliclyAccessible + VPCSecurityGroups: + - !Ref DatabaseSecurityGroup +Outputs: + Name: + Description: 'The name of the database instance.' + Value: !Ref DBInstance + Export: + Name: !Sub '${AWS::StackName}-InstanceName' + DNSName: + Description: 'The connection endpoint for the database.' + Value: !GetAtt 'DBInstance.Endpoint.Address' + Export: + Name: !Sub '${AWS::StackName}-DNSName' + SecurityGroupId: + Description: 'The security group used to manage access to RDS MySQL.' + Value: !Ref DatabaseSecurityGroup + Export: + Name: !Sub '${AWS::StackName}-SecurityGroupId' diff --git a/cloudformation/storage/redis.yaml b/cloudformation/storage/redis.yaml new file mode 100644 index 0000000..f46ad7e --- /dev/null +++ b/cloudformation/storage/redis.yaml @@ -0,0 +1,178 @@ +--- +# Copyright 2018 widdix GmbH +# Modification Copyright 2022 unload.sh +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +AWSTemplateFormatVersion: '2010-09-09' +Description: 'State: ElastiCache Redis' +Parameters: + VpcId: + Description: 'Vpc Ref' + Type: String + VpcSubnetsPrivate: + Description: 'Vpc Subnets' + Type: String + VpcSecurityGroup: + Description: 'Vpc Security Group' + Type: String + EngineVersion: + Description: 'Redis version' + Type: String + Default: '5.0.0' + AllowedValues: + - '4.0.10' + - '5.0.0' + - '5.0.3' + - '5.0.4' + - '5.0.5' + - '5.0.6' + - '6.x' + CacheNodeType: + Description: 'The compute and memory capacity of the nodes in the node group (shard).' + Type: 'String' + Default: 'cache.t2.micro' + TransitEncryption: + Description: 'Enable encryption for data in transit? (see https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/in-transit-encryption.html)' + Type: 'String' + Default: 'true' + AllowedValues: + - 'true' + - 'false' + AuthToken: + Description: 'Optional password (16 to 128 characters) used to authenticate against Redis (requires TransitEncryption := true; leave blank to disable password-protection).' + Type: 'String' + Default: '' + MaxLength: 128 + SnapshotRetentionLimit: + Description: 'The number of days for which ElastiCache retains automatic snapshots before deleting them (set to 0 to disable backups).' + Type: Number + Default: 35 + MinValue: 0 + MaxValue: 35 + NumShards: + Description: 'Number of shards in the cluster.' + Type: 'Number' + Default: 1 + MinValue: 1 + MaxValue: 250 + NumReplicas: + Description: 'Number of replicas per shard.' + Type: 'Number' + Default: 1 + MinValue: 0 + MaxValue: 5 +Mappings: + EngineVersionMap: + '4.0.10': + CacheParameterGroupFamily: 'redis4.0' + '5.0.0': + CacheParameterGroupFamily: 'redis5.0' + '5.0.3': + CacheParameterGroupFamily: 'redis5.0' + '5.0.4': + CacheParameterGroupFamily: 'redis5.0' + '5.0.5': + CacheParameterGroupFamily: 'redis5.0' + '5.0.6': + CacheParameterGroupFamily: 'redis5.0' + '6.x': + CacheParameterGroupFamily: 'redis6.x' +Conditions: + HasAuthToken: !Not [!Equals [!Ref AuthToken, '']] + HasAutomaticFailoverEnabled: !Not [!Equals [!Ref NumReplicas, 0]] + HasClusterModeEnabled: !Not [!Equals [!Ref NumShards, 1]] + HasClusterModeDisabled: !Not [!Condition HasClusterModeEnabled] +Resources: + CacheParameterGroup: + Type: 'AWS::ElastiCache::ParameterGroup' + Properties: + CacheParameterGroupFamily: !FindInMap [EngineVersionMap, !Ref EngineVersion, CacheParameterGroupFamily] + Description: !Ref 'AWS::StackName' + Properties: !If [HasClusterModeEnabled, {'cluster-enabled': 'yes'}, {}] + CacheSubnetGroupName: + Type: 'AWS::ElastiCache::SubnetGroup' + Properties: + Description: !Ref 'AWS::StackName' + SubnetIds: !Split + - ',' + - !Ref VpcSubnetsPrivate + SecurityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: !Ref 'AWS::StackName' + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 6379 + ToPort: 6379 + SourceSecurityGroupId: !Ref VpcSecurityGroup + ReplicationGroup: + DeletionPolicy: Snapshot + UpdateReplacePolicy: Snapshot + Type: 'AWS::ElastiCache::ReplicationGroup' + Properties: + ReplicationGroupDescription: !Ref 'AWS::StackName' + AtRestEncryptionEnabled: true + AuthToken: !If [HasAuthToken, !Ref AuthToken, !Ref 'AWS::NoValue'] + AutomaticFailoverEnabled: !If [HasAutomaticFailoverEnabled, true, false] + MultiAZEnabled: !If [HasAutomaticFailoverEnabled, true, false] + CacheNodeType: !Ref CacheNodeType + CacheParameterGroupName: !Ref CacheParameterGroup + CacheSubnetGroupName: !Ref CacheSubnetGroupName + Engine: redis + EngineVersion: !Ref EngineVersion + NumNodeGroups: !Ref NumShards + ReplicasPerNodeGroup: !Ref NumReplicas + PreferredMaintenanceWindow: 'sat:07:00-sat:08:00' + SecurityGroupIds: + - !Ref SecurityGroup + SnapshotRetentionLimit: !Ref SnapshotRetentionLimit + SnapshotWindow: '00:00-03:00' + TransitEncryptionEnabled: !Ref TransitEncryption + UpdatePolicy: + UseOnlineResharding: true +Outputs: + ClusterName: + Description: 'The name of the cluster' + Value: !Ref ReplicationGroup + Export: + Name: !Sub '${AWS::StackName}-ClusterName' + PrimaryEndPointAddress: + Condition: HasClusterModeDisabled + Description: 'The DNS address of the primary read-write cache node.' + Value: !GetAtt 'ReplicationGroup.PrimaryEndPoint.Address' + Export: + Name: !Sub '${AWS::StackName}-PrimaryEndPointAddress' + PrimaryEndPointPort: + Condition: HasClusterModeDisabled + Description: 'The port that the primary read-write cache engine is listening on.' + Value: !GetAtt 'ReplicationGroup.PrimaryEndPoint.Port' + Export: + Name: !Sub '${AWS::StackName}-PrimaryEndPointPort' + ConfigurationEndPointAddress: + Condition: HasClusterModeEnabled + Description: 'The DNS address of the configuration node.' + Value: !GetAtt 'ReplicationGroup.ConfigurationEndPoint.Address' + Export: + Name: !Sub '${AWS::StackName}-ConfigurationEndPointAddress' + ConfigurationEndPointPort: + Condition: HasClusterModeEnabled + Description: 'The port of the configuration node.' + Value: !GetAtt 'ReplicationGroup.ConfigurationEndPoint.Port' + Export: + Name: !Sub '${AWS::StackName}-ConfigurationEndPointPort' + SecurityGroupId: + Description: 'The security group used to manage access to Elasticache Redis.' + Value: !Ref SecurityGroup + Export: + Name: !Sub '${AWS::StackName}-SecurityGroupId' diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3d1e838 --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "unload/unload", + "description": "The Unload CLI, build and deploy a serverless application.", + "keywords": ["unload", "serverless", "aws", "cli", "lambda", "faas", "laravel", "bref"], + "homepage": "https://unload.sh", + "type": "project", + "license": "Apache-2.0", + "support": { + "issues": "https://github.com/unload/unload/issues", + "source": "https://github.com/unload/unload" + }, + "authors": [ + { + "name": "Yaroslav Khortiuk", + "email": "yk@unload.sh" + } + ], + "require": { + "php": "^8.0|^8.1|^8.2", + "aws/aws-sdk-php": "^3.220", + "illuminate/encryption": "^9.25", + "laminas/laminas-text": "^2.9", + "nunomaduro/termwind": "^1.3", + "opis/json-schema": "^2.3", + "symfony/yaml": "^6.0", + "laravel-zero/framework": "^9.0" + }, + "require-dev": { + "laravel/tinker": "^2.7", + "mockery/mockery": "^1.4.4", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5.20" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "config": { + "preferred-install": "dist", + "sort-packages": true, + "optimize-autoloader": true + }, + "minimum-stability": "dev", + "prefer-stable": true, + "bin": ["builds/unload"] +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..8231060 --- /dev/null +++ b/config/app.php @@ -0,0 +1,60 @@ + ' UNLOAD', + + /* + |-------------------------------------------------------------------------- + | Application Version + |-------------------------------------------------------------------------- + | + | This value determines the "version" your application is currently running + | in. You may want to follow the "Semantic Versioning" - Given a version + | number MAJOR.MINOR.PATCH when an update happens: https://semver.org. + | + */ + + 'version' => app('git.version'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. This can be overridden using + | the global command line "--env" option when calling commands. + | + */ + + 'env' => 'local', + + /* + |-------------------------------------------------------------------------- + | Autoloaded Service Providers + |-------------------------------------------------------------------------- + | + | The service providers listed here will be automatically loaded on the + | request to your application. Feel free to add your own services to + | this array to grant expanded functionality to your applications. + | + */ + + 'providers' => [ + App\Providers\AppServiceProvider::class, + ], + +]; diff --git a/config/commands.php b/config/commands.php new file mode 100644 index 0000000..7a6f6c3 --- /dev/null +++ b/config/commands.php @@ -0,0 +1,82 @@ + NunoMaduro\LaravelConsoleSummary\SummaryCommand::class, + + /* + |-------------------------------------------------------------------------- + | Commands Paths + |-------------------------------------------------------------------------- + | + | This value determines the "paths" that should be loaded by the console's + | kernel. Foreach "path" present on the array provided below the kernel + | will extract all "Illuminate\Console\Command" based class commands. + | + */ + + 'paths' => [app_path('Commands')], + + /* + |-------------------------------------------------------------------------- + | Added Commands + |-------------------------------------------------------------------------- + | + | You may want to include a single command class without having to load an + | entire folder. Here you can specify which commands should be added to + | your list of commands. The console's kernel will try to load them. + | + */ + + 'add' => [ + // .. + ], + + /* + |-------------------------------------------------------------------------- + | Hidden Commands + |-------------------------------------------------------------------------- + | + | Your application commands will always be visible on the application list + | of commands. But you can still make them "hidden" specifying an array + | of commands below. All "hidden" commands can still be run/executed. + | + */ + + 'hidden' => [ + NunoMaduro\LaravelConsoleSummary\SummaryCommand::class, + Symfony\Component\Console\Command\DumpCompletionCommand::class, + Symfony\Component\Console\Command\HelpCommand::class, +// Illuminate\Console\Scheduling\ScheduleRunCommand::class, +// Illuminate\Console\Scheduling\ScheduleListCommand::class, +// Illuminate\Console\Scheduling\ScheduleFinishCommand::class, +// LaravelZero\Framework\Commands\StubPublishCommand::class, + ], + + /* + |-------------------------------------------------------------------------- + | Removed Commands + |-------------------------------------------------------------------------- + | + | Do you have a service provider that loads a list of commands that + | you don't need? No problem. Laravel Zero allows you to specify + | below a list of commands that you don't to see in your app. + | + */ + + 'remove' => [ + // .. + ], + +]; diff --git a/config/logo.php b/config/logo.php new file mode 100644 index 0000000..111c853 --- /dev/null +++ b/config/logo.php @@ -0,0 +1,84 @@ + true, + + /* + |-------------------------------------------------------------------------- + | Logo Name + |-------------------------------------------------------------------------- + | + | This value determines the text that is rendered for the logo. + | It defaults to the app name, but it can be any other text + | value if the logo should be different to the app name. + | + */ + 'name' => config('app.name'), + + /* + |-------------------------------------------------------------------------- + | Default Font + |-------------------------------------------------------------------------- + | + | This option defines the font which should be used for rendering. + | By default, one default font is shipped. However, you are free + | to download and use additional fonts: http://www.figlet.org. + | + */ + + 'font' => resource_path('stop.flf'), + + /* + |-------------------------------------------------------------------------- + | Output Width + |-------------------------------------------------------------------------- + | + | This option defines the maximum width of the output string. This is + | used for word-wrap as well as justification. Be careful when using + | small values, because they may result in an undefined behavior. + | + */ + + 'outputWidth' => 120, + + /* + |-------------------------------------------------------------------------- + | Justification + |-------------------------------------------------------------------------- + | + | This option defines the justification of the logo text. By default, + | justification is provided, which will work well on most of your + | console apps. Of course, you are free to change this value. + | + */ + + 'justification' => null, + + /* + |-------------------------------------------------------------------------- + | Right To Left + |-------------------------------------------------------------------------- + | + | This option defines the option in which the text is written. By, default + | the setting of the font-file is used. When justification is not defined, + | a text written from right-to-left is automatically right-aligned. + | + | Possible values: "right-to-left", "left-to-right", null + | + */ + + 'rightToLeft' => null, + +]; diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..15f22d5ff86a3ece6b4eb85e051837b4193dc1a6 GIT binary patch literal 12523 zcmdseWmr{RyY8Z-OFAW#?vMs)5kv{4+eNdeg>*LvC>??bB3;tm-O|#aba%4{@B8g{ ze|ul&$2sTUIbN5pHRl@hnPZHn?&p4np|6$Yu`$RoAP@+)!gCo_2n696eD6kk0RE*W zBUlF?2#%`qQjnrS$_;RVZueZv5dy(!zyFI6$BIJ%fgr_OsB6MCUn&Wk*xGOyy|Xoj za=6&ofzc3%sDz81k%<))PGbx;vw(>)?$tFh(pbC`W7Ob($@S7s8ftFw+|2>1=BBJ} z;$~$c^o~(N97EJa7znU|!i{KLY^-69!Y*Qrf5{aF-|ug8GJ-)4?@WbNW#s-f1YC(R zn#19C!knDW&dwapyd1U;W}MtYLPDHeJe)i{>|g}Dqbm$<E&l88K-7C0Y^=cd#)2M>@=r!|1O&PWO*+fJJi}y>sN`=HR-Y z77dN)-{XZP-#Ol2{{0d)sQq6Te_vT!{PiX~BL_#Qx~m;jj8PTpXzS!)0{u(E{X_o} zBJBV*fuLi~bU|2**5kNkp#a3w711UI*J5Z5xIQMIrVy&v$Ow}4&zHM{?-834omZ?_fFyX&+YwMtHYilhoZ3KI7B+B_eZ~rf+^7rh3=>sR={Kr+m#Xrslg#iz90IpQJ z=(h%e&_EPqB-LF$@65UCs*hix?hlo4vY=J_B-0f=*Gfuy(aZU>V78YJbCU3^qJuBN z_Iq64q;4-^OS7k};s8pxQb$}drUhCcCe5NB8e*I9(Upk|zL}wE^;(+yp6K4Bm43ZX z(wgY&!+xQ&*|nT-WllN)_{4aIk_xU>AS~@)O@Q4h;|8tL8yD&aO`;@TkeJ5-2miV=1jyoZd z>yCbq?z;HU(k?IYMJ&y_bv> zp={Q;Wkr1qO~8J_)uchUkT-Qr0tc-GH$HjA<;HSBwu?@#dI|HL(7yVJMy`4s?);Ce zqLQmi#6oR^%1XNH_5dbKu<>l_7W&jMDnTESt|rN3-VfxRzJ1LJom8+x=pHuhP-8V35g2E)I5tKQJ333U`j{+k8Xl$*P9w z=E|-ONfkz*5<%qPK)hif*mt&U4ejf`&eDhLLF5t= z={f@G(x^tu_!%_7KvYU*xVft-@qKj?I8*pCy)s-VY6|o%Mo99~6LNJ(T#jDS&_L|q zgioK+^2h>fLwOFpPWmg%@~pva`Gb52GT1+v@32&{a8F({x~)?if2v-z={_r5pOYxAncG>&mz^Y+j_O9OVUVlBVkeEla@wVs*?HR;8?A?+(+%}1nLd;HD=A2{Si{jGv=6I1$Cy5gxe-pVjA#C_X>R_Y zndMjb0tMIIeXO!f+D1gAdg_iLfvZb#&$S4c@GrzY*IOtC*E zp&#!Ji4aI3%ksjd9$W8R?X4UJV!D~t>f_+-msAA@H;Bz{Z!g}KgotSCq=*hm;8HJI z!eDu~zl`DMm?fbF-vhJ7oc)?<-m_qev+AoGXBtL%XB$0?3p~}AeA0{oRsBjd zVtQxQPMFVM$_`!}YT(JlPxOmxXe(H&;qC&GAvJq0qC#>lcR-Ns#J8m_dH`D1l}*UP&1NwEFtm!{c%z zd)S=sjl?uEui2nXrd$Ma1arV%;}uS5XlO>Hg_Tt}IlpD|^z_9@+uR)0%ao^qQBk;- z<9Sak#id5;m^t|P2m=EH{cwp%NxK`;@rewCZwjW5j z{j{z(1-)-Y%f5I{l^DelAD5T&nVM8tj`!M0?v0|JU)e4$%QBdh?Ss&rOID$ho%`mx zes;t1QrSD-#WLkvRyNYoQuF#q23D2L%+mtxiWmFUs-B}wW<%81U^+{M_RHv2;BACH;^?ZjG~Hgh#IDxZyhIQmL0|9qrh zZT9g_l!VV{wk%oLc*;3a)lJpi|~`Tlx6uldpl-MWBMU;{DfD5ohE5#g-hMDvryOt>B%RDrxqX z`G@MmM8<1_sZ(WUxO;rhWqFu4YqGK!2I}8H`!+ArY}Zd7G;+EI1~vy_D+Oydxbcsk zvvFQ%Mf>?7ZvU#l+-tfMC+9Qw(JC`tI^CJ6A9V}BsP+3mA%Fu)jrvgwELp>=bQyo#I!>Oa*jK$m_i(1@ogtk2HsTK_5!RoLcfx;dqYj*d=@G_v^diljz#ih%9%(U8Pi>VpyaW!VO6HA=%@{KtnBv;7T09N|!B z2pXoz!}mw41O3GVmKE9sT4g~Sr15#_=@434TJ!NdB8HdA0as^xj(vgJO)N^!pQHKt z`7Q2&m`J2LqR5G0Zyzl>&f4@1d4eSlHtF`H?kAO~n95bn+T!ab@ome=pu##4Ys+!J zmpi{oa`h-!Tkwuz*Qs3GDsJlR{zPx|!D8e1r2GJkpvpDb0wiM~raoH_C zQ|T;Icix-tSRcv6fB4YvWNR`rB4psZd5@lNk!}rL&y_N}_PcLILn8atIg0K6(b3sO zfir?aUr$PmTx|CAMVqY2Hcxh})VYhpddG>L`udi9;YB@NS}Gyuf_xmM7`pZ_+|zGq zEuF{Gz_*Dc7^-nx8*cP;SMgp0%e7o;4XlQ5Jlx|$OBcZuW7+H}R>;X&i#@SJHQZW+ zEZ|DCZ`Hm3+Oxk@f^W`{rx8J3;Vn8(7>_ty4Y(ooo;6 z<@4vy@tl+tzrW4rHti>^Kbv<4j$|xOO#}~Q;o!L6Z4_I2h5r4m;>#wEPZ_uA)uaHb zv(LL>ioM^JIVv?V+WGxhACcb*u`LLJRbw;0EUPFTa=ue#89ZHWPk(rLi2S^pk57@r z;N82&`YuZ-t1)aW>lp7Zk1agzZqDjQOROzVM$3*4AHN=ZLruNj0LA<@_!(UXA%1*d zbr(k|8M*%p?pV)xnpa1!N^I+>2{nA_Q$ee)wd{4|rYl#4zHM zkT4obb=EoWQNQ1v?SWb=CZwiDP>Z>7ZHYWX(OYtGbWBP;mitg*`^A1}<;@rsCGt!u zdk#52aA%*Y`Q_A5qw)3rZue=O?E#nHr5PD?Sk$7V;Ut{IqECt7n0AZJ2&<_%A=KH* z8!!!C;;4-Ibl>WgB+G6*!(p0vt@VUtjKmSD?c1BHsYXu`|9}r91O)zl37n1tEzuSg z+Da);v02|1;PG()Vcbre>I_|-(J$KD6K}~&!)tx7I-X>iy?U0HuhbQlavkloHKDL< zh&!K;jGV{=#b|A7LkX8hnUk8WaYR-7uC$aI^oNNFGML7{S7A)-Bt!^@prJv;&wZSU zUx~Q>{GIST3uP+^o8qIv_$^MWx)wdx*HgaNl2tSm0x%hMs7O($opNg4{8k6crUUaj zWH=jlI16)+EiE<`Tesf)cr=PiB&g}`HpK|+r}v$BrOiyI2zI!Jwsu&0It`_ONkujt z%Q~^?@%o7Fu4jcHqq4e05UH%5G71%>GXhDwd@;2nM(JFdtzy8ue47JXR#ujj%Mb#a zt9?@_s6Ncz4?K?B{nUEif=TCnR>&wfCg2EUSYFIE>?E}6AuRali z)K)1P?$X{k|Bw(gLSo{u*w_OpDX9|6ah7=tw9e^iGKsru$E=~A?(Sz&Qi#f*1$|Ar zBA2F0O}YmLh?u3@A}NLZK!!7J@k23fNHbk0&M7Y^q?eDBQBXkNQ;%g9*FR5WGu?F1 zfpHew=*-!ds~X98!*i{J^36xTzl>%wHO@0=60d!~6Gp~sYIt{hQw^XV*!iGF&#Ts7 zzmlzB;l%8%U%YQECaiMPb#kh!1=+OAL!8Nb+bz-6)-yQPKpAk^q$o>-A|@=v&&C#AE=dK7PczzByZ1 zpKlOJpj=*A8Q6RbtkjQ)kdOvMbZzv{=UJr2v(b;$RT5T$s>9q)f2aB?!#F<1e?QZy zvRx=TOy;l!=~&v@nr)BoaYt7dYA_B}D~JawG1Bh8FOJq$epOg8Gz4MP28y^HGf7EH zyJml-G_DvW%*g5U48mDghGJsL6=Hcln zOQlix*5p^Y#l0lqmU4^Hk7;RxBG^!_11hHq^{N~J%g4GLq7ApgdsuK)wJ(>^Z}Z~@ zC{_z~s^}OQgPqBv$9_N+KZ|>kBOoATL{gBEg}M+tomQ1(>>eEKJU>_glfq(VUXC<_ zshsWOiIeEpH)PpgJR3?`ZVSd;*&NTOjONzU*Qb68o9!0C?#WZhd~mwA5G640B3;g1 zq0QdNzjUg^vW^TXaX*6rpb_p&e%QISPvIC=`w(E_t*KAW4}8)%3><2dO37pA@wJ2Cg02OhU#*7HJO^B&8i z$H(yPxw-^3p4HIs@HPcroX@3?Pft&$8r%f{+LIwG{`L)7F`kWkvqpWmhkj#Y;~W4$ zmuy;t`ZL-b^=F0Jonn)H;Wiw`RwIZd6@O9#cPl%BCyMm?3S39Wa@CUBQOx6ESh%>j z;4LJPbWYl{^{!}iOGCRslK~ja0n_6Wix=AiCEm` z!!%RV(@U9A;>HgRfN|Vho+uf!4g{bR;4%HQi2=nz-k}hXul5jB0^=G62g=f3hexhv zkg@#iD?yv&rHuI;{KZWceP!cMt$A&20Z1kvM zx>-2eozsC1d9tj_6nBJ^&@1QAKVuKn9uVxyspAf8uuO45TxA3$Vc{Lmi(ZGQQvR` z+(5&N7caORes^1@4p`Z{7<@OM+TSRCnTmmoiX<*x%iE5M1=06>b3NO>xZEm&lwN}O z_3PLE+c|YzQ?rU~oME;(!ZNd=pa|@%(e8Y=7{H^D!cVug*bEv!1DkAd@0lBpoN0ar zAe?W7m9O&xkpLsNy)adNy=n!y)npO$;Y>>l!XvNqB?1Fbgi(-lZ=RC?cd1jIBB4XV zrg++3M|VAw;a6k_RwfJLSz^Z*G55+IRAf!Id0zM@*w;^Na+5JHOZUc@XAA5kYG2R0 zu2EILow_oFLIb}_2lJ2>mz3b)04U1wtmUW)B&CRgk;nz9pEDQ%n zM@RQSh@q5-kuz%VoQ~~6AsLM#Chv!^aI%)Q^rVHvMPmL$GV7d;;JNYaEH!) zQy{26{bl9Ww6?P|us!_I`}P9W`}zPiX6i+zYVL5At-5!W z2VK=_MVQ1RZ7sfg=YfG9U274JlDIEl_{n7&J#@dX>IPf2V39F85^ia3r`tUqAHIEi z?7xudSA@sG!J%2>@TBZKDJdyY)Rm{~JgoVO97c4O!!qxWIvd0>V!lt1*}%XOhtU>< zoqsF@OxqtbQ|}g}FJ9P&kV}y0PMn6m(ff>e~&mmq3O>3#dfc3$Q|=F3mMzP{$O)%ICK_8|B)`ywTMFwoWgu)l8yEU~kr1Eo;A z!dUphz%Y9^uEh2Jn6f5q1eHi-FHDv={0xfWB{mYDuj4R=sjfTVq6E3!4NXKbCHr%8q%Ed2K zVH{p&$L_^?bx-eYBQtFVAnWss3+|r#ToDo$X8cgcBAH8E?OFaf`8F;o^H!Km5&WXG|zkVgjI< zL#$akMIb_*g=nHc3kg+j63$PK4FrZRFB>x)K=Zc^3DcHWT+N!hB<}3UDs##S@v$g4 zleE)8h{Dp5TWj3|vCx8&)2cfV)VoLAAjc-I4y2e2eirIqHfZ!9TkyJKFFXJ7IM8p< zBEEO7BV0FV&gqC@m#bE<#Ac?_r8LjPj&*;3|5>qqL*j2WRn^vl!op}lAtCNZZNuUW z04|?`g8VBO+Q005G$0UCHAePTT$O1Z1*JKOx4sN-q0~e-NKdbaU1l7*dnnWgJu4~U zGCljn8dz4wd&F%X1oO4Fw#FkS?%XbzZ*7%AMnxUno#X)$AchuQ`V0(+XS<=|)>6ti zx9`TRd*e_D;mJy`w=XQSaLxsAhas-pR%&>hA8YX~V>?U*yt3SongOUFMFu zf7+`HCx=-;CjgTR)Q~Rj?!4g=CY@N{zJKQl=VTp7J{Ww~r3&Nj?L+dq(>pf8e4$vn zd+B6)kY8s#=rHXG;L~$uWm95un+FKr(+@esLqqYq?iH&H2zULA9-e{zJxm zS3Oe>yTKsa4@L_HcEZVELuq37pdaY5rFtiG#gv)Y_DQDc$^x#Rcr~5pasKqo7jYx_!aVt9R+Izgd%2>i#v$4Y| z5Mw|jBj7XbiliDz9+7)l_%@v1YJw}2U8A(Ll*zbU>xUAQRWO4X&U}1pdeay04{*U! zifyA2u>>CWu-Hs4fwD9;co?ogQlxp5iCltmraY^ucp$c7_yhQIjclM?riEqqo!>`T2R9^~47UIo}SN+AcC{c1LiK2Qh+0w7NY zlxX%e=(CKR9M7XnXkYw;CPqt1l1zVPXjoWSPPznuK7qhl>-SqRmEN+yP*mi0`2D;k z0S}s~#%S0_Jg+6XF)L(vVrke<3SPLoENi%-TEtvZ{{|cKb9D3*0J(51;!yRlE@kt@yQbM?W9#UjVc`KFcRe}}9zCc~rR(%UQnPUx{LT01)MP`{=pYA|>Fyn9>*QZ>ko<2SEtB|$q% zDVmvysaa1Bo9x4<=;&6qr}+zHR`1`x2VO_;jh84l-Abc*BHo$!GF2GGkbEN)Lb|!uQae6?)Tf8 zpz4e&<9$7lHmh`VBd~waRZCVEJ6ng^|Cu>yqN4O{cjyh~xnT;ot$s<_fRYNeueDo3 zESq{`G~FVWBlRAo&EG(D@Q$@Pi z8fP(uA@w(>GfM6fItBU-E@p zQMs0r4VuUEDGv4ZwJo;z%L+LS_V)JXG*Y`^y6ya`KzWtZpR1Prv1P{B$3gT3 zQI{qcA_QuHSSo$j^YHr)9sAF8$=a*jKdZAfUDy`d6^OseM?5`N$(ox(Uq#I}Vt>m* zU}mlz9Z^+t!bNA#)mZ&KR~MqDitB+7JkRLrbca+J&a|hVn9OU2QyDTPXnFX%M@|eF ztXYGQC5T2K*nlVmU@aGwv;svhm{AY_(N-8R!m>9j^Ohmqlyf60wk9nvptwl-Y+v4wPCi$TRq}-g19B5f# zypH=4i#Z=Th$TpB*~&ytx}aAPgH!WUXV`$y2x$mdTq$TatgiGY@djTM5}s_1^IEp~ z%_^k|69Kzf0n31IAaZ%NDdpK>vsY>K0S=(X+RJnauod_00x_E=&E=GV=LwOu^#=** zm$s5;hlf%?1t^NWAD7yKS3niszZ@JGXav~R{^bM}%aO9mN;yrp!zFjZI9*OfSEIRuwo`^sM~A#m>&IIho(;oqNXTcP~GqRo5L2 z`o$91?~r5glY>ASG$zsoSi0o|1Fsf9g)MDtgz7iA*|YzKEN*RugFZ>piMBt9{LS_C z;?7P4s1TD<6LUe$jzcXP;*a}E3lPgv`ELvN`+0|&$H$3qaB#r#U+0fkyPt7H$HdrQ z972oCV89Vt`7hYcChet=B02#bBSxgVGfJTw7q?DAOIcrU44xnP_=;;R5A zv)f5Yp&FynVTs*H6^!d!)LycKK?rsVe8o^vC*o zQH9y~7(Ly75tI(YX)+LaG3ez!+&41IxckSOyuFo@`9i1h(?MhafvF~$%g9gR?DLk% zKDjWY+-8Otw8V-gATOT6*I$q2sQ5dRXKYRuKe{~K=@Y@0LUyW4he+`1x?*5q#ab6* z=;-KVMy~Qr24a#_r~Y6j0m-l{`iWv5RrXImoM+LBGO9+oO(}7#8m(D*{z7Ph>CS3azLdR{#8Y z@}iAesy+SWK$5A=eaLE}Ak=x@HCW4o7BIHdjg5`{%eFI>*wYy62b|^i?gsRbZ@6Zv zt~G39V^dRJAHxHt8qR4BI<8U)9C|LHffQf~V ze@O7x;xxmA0)H*}!ISqmF))7a(bn)+q{v4Sk)Q;?AfH^nbCb9|r>CT(M5fY>Bo~0O z^={ZW3}?t<_NR%ZIFkn?7D=NTf&#|uo#EbmLw}IP&u^jDZ-3mk6Puf#nI3Q&G=2c} zlIg*p3}EiSg-sp0{6|pchK6we>Omj~l~z`E0@7!3bF){3s3-BM4SAaTuF$*%o^ibo zWO0c*5xAEudfR{lb;{3$1DB2b$_D&z}MD+kS6e%=93u7Z5zG z8b$4SZ}JUoYzC|;W9~3)r%EJCy8_=bFfoP1Ct#Z|G>U=NV>>V_tb7%u^C52^uN!L5 z4FIvL?N=U@(cawLEUm1xv58|UD=V*S>^eU#2LxV$PF19q2lL^{Ne4(jGBpF!zy0*{ z-?D@BKAZuY-kdID1UT9jn1wGEWMz4somJ6%qTO+AN3{BK55_H4YwzsVhCc8q~ zr+y(y2P2l5A`r!YQd3ha^}h2ml@<{cGz6yr`j^YbDy%fV+YS;ud}#N(8z1aNI%w0} z%ee6Szub-v-<#L#_IM2a`Ay-z+q|AG9}9Rcu|yoTtvRdKVcdtmtfS-vXQtkAx(6(!g8q_#nR` zrUw*4fE?_KWmY4spxjws4;jzbFu6GVqa?t^H1Y%5_c~jdYTiOKGM?Rc(QuC67&JN% z>E$2>o+za2srn$dgQh&sOXwHO< z3VS9dCNyi|%o&-P&?*F(M}TnFt#`rCl#g0X4P&dRt25d7nFV@(Y`(tDVMNcWVD-F9 zTX7mb(7wEDvF({ETmjqpckw}h_^9o=ngdDDaNurSc0Z((cH6crV{H$wnx zO#bx7;YN6!lYD+u2lLkGF06)?bD z&bzujl;N!F18u+pHLLAti}V}9KohZV`BxbTr~q#G0B0 zvEPEK?n3IanqVas)Yyj^-5l@ zAfo^d)4v_A?{Oe|&x?g~GH7UM0H3E2dB44w8nzDjqmfD*o%5?J2{*vE9V+L)P%56C zEH-#Fn*FK^lnwXhYrW7&b>E8t5ndA~KOruT=)P6~-BcPTCN$8*Clo_fzc_!siMIIMm3>X~_36p#x4R_@=&mpvFBvo zQ5ry=yc|E6i3t**`vgTqB;2a&>c9yc3UP7q{euHZ0J}zNovgqwN6M4$SPI|f6S)t^ z#u8GBdyq1|N|VvmrR3174e;>2>mDo8qZkr9qx@BA6Apsa3OG;^oRX4~Zh}XrNW!s> zaW!4`lVcb3w{LH2rEdEHF-9wMSWS2w1~v798r+);b(&K}K=t|T{s`&$ab~sVBy4>CwkOyhX>l_z#d9(@vu>b&;N{uK xVC2~=g%IQaJWPvI%JBc;q1ykGQ^4omPwjJ+L*@gK@Basaf~>Mkk(8m|e*lP$l;{8e literal 0 HcmV?d00001 diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..dba39c3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + ./tests/Feature + + + ./tests/Unit + + + + + ./app + + + diff --git a/resources/stop.flf b/resources/stop.flf new file mode 100644 index 0000000..f064242 --- /dev/null +++ b/resources/stop.flf @@ -0,0 +1,718 @@ +flf2a$ 7 6 20 15 3 +Stop by David Walton +Derived from Rounded by Nick Miners N.M.Miners@durham.ac.uk +21 August 1994 +$$# +$$# +$$# +$$# +$$# +$$# +$$## + _ # +| |# +| |# +|_|# + _ # +|_|# + ## + _ _ # +( )( )# +|/ |/ # + # + # + # + ## + __ _ # + _| || |_ # +(_ || _)# + _| || |_ # +(_ || _)# + |__||_| # + ## + _ # + _| |_ # +| ___)# +|___ |# +(_ _|# + |_| # + ## + _ _ # +(_) | |# + / / # + / / # + / / _ # +|_| (_)# + ## + ___ # + / _ \ # +( (_) ) # + ) _ ( # +( (/ \ # + \__/\_)# + ## + _ # +( )# +|/ # + # + # + # + ## + __ # + / _)# + / / # +( ( # + \ \_ # + \__)# + ## + __ # +(_ \ # + \ \ # + ) )# + _/ / # +(__/ # + ## + _ _ _ # +( \| |/ )# + \ _ / # +(_ (_) _)# + / \ # +(_/|_|\_)# + ## + # + _ # + _| |_ # +(_ _)# + |_| # + # + ## + # + # + # + # + _ # +( )# +|/ ## + # + # + ___ # +(___)# + # + # + ## + # + # + # + # + _ # +(_)# + ## + _ # + | |# + / / # + / / # + / / # +|_| # + ## + ______ # + / __ |# +| | //| |# +| |// | |# +| /__| |# + \_____/ # + ## + __ # + / |# +/_/ |# + | |# + | |# + |_|# + ## + ______ # +(_____ \ # + ____) )# + /_____/ # + _______ # +(_______)# + ## + ________# +(_______/# + ____ # + (___ \ # + _____) )# +(______/ # + ## + __ # + / / # + / /____ # +|___ _)# + | | # + |_| # + ## + _______ # +(_______)# + ______ # +(_____ \ # + _____) )# +(______/ # + ## + __ # + / / # + / /_ # + / __ \ # +( (__) )# + \____/ # + ## + _______ # +(_______)# + _ # + / ) # + / / # + (_/ # + ## + _____ # + / ___ \ # +( ( ) )# + > > < < # +( (___) )# + \_____/ # + ## + ____ # + / __ \ # +( (__) )# + \__ / # + / / # + /_/ # + ## + # + # + _ # +(_)# + _ # +(_)# + ## + # + # + _ # +(_)# + _ # +( )# +|/ ## + # + _ _ # + / )/ )# +( (( ( # + \_)\_)# + # + ## + # + ___ # +(___)# + ___ # +(___)# + # + ## + # + _ _ # +( \( \ # + ) )) )# +(_/(_/ # + # + ## + ____ # +(___ \ # + ) )# + /_/ # + _ # + (_) # + ## + $ $ # + $ $ # + $ $ # + $ _|_$ # + $__ | $ # +$(_/|_/|_/$# + $ $ ## + # + /\ # + / \ # + / /\ \ # +| |__| |# +|______|# + ## + ______ # +(____ \ # + ____) )# +| __ ( # +| |__) )# +|______/ # + ## + ______ # + / _____)# +| / # +| | # +| \_____ # + \______)# + ## + _____ # +(____ \ # + _ \ \ # +| | | |# +| |__/ / # +|_____/ # + ## + _______ # +(_______)# + _____ # +| ___) # +| |_____ # +|_______)# + ## + _______ # +(_______)# + _____ # +| ___) # +| | # +|_| # + ## + ______ # + / _____)# +| / ___ # +| | (___)# +| \____/|# + \_____/ # + ## + _ _ # +| | | |# +| |__ | |# +| __)| |# +| | | |# +|_| |_|# + ## + _____ # +(_____)# + _ # + | | # + _| |_ # +(_____)# + ## + _____ # + (_____)# + _ # + | | # + ___| | # +(____/ # + ## + _ _ # +| | / )# +| | / / # +| |< < # +| | \ \ # +|_| \_)# + ## + _ # +| | # +| | # +| | # +| |_____ # +|_______)# + ## + ______ # +| ___ \ # +| | _ | |# +| || || |# +| || || |# +|_||_||_|# + ## + ______ # +| ___ \ # +| | | |# +| | | |# +| | | |# +|_| |_|# + ## + _____ # + / ___ \ # +| | | |# +| | | |# +| |___| |# + \_____/ # + ## + ______ # +(_____ \ # + _____) )# +| ____/ # +| | # +|_| # + ## + _____ # + / ___ \ # +| | | |# +| | |_|# + \ \____ # + \_____)# + ## + ______ # +(_____ \ # + _____) )# +(_____ ( # + | |# + |_|# + ## + _ # + | | # + \ \ # + \ \ # + _____) )# +(______/ # + ## + _______ # +(_______)# + _ # +| | # +| |_____ # + \______)# + ## + _ _ # +| | | |# +| | | |# +| | | |# +| |___| |# + \______|# + ## + _ _ # +| | | |# +| | | |# + \ \/ / # + \ / # + \/ # + ## + _ _ _ # +| || || |# +| || || |# +| ||_|| |# +| |___| |# + \______|# + ## + _ _ # +\ \ / /# + \ \/ / # + ) ( # + / /\ \ # +/_/ \_\# + ## + _ _ # +| | | |# +| |___| |# + \_____/ # + ___ # + (___) # + ## + _______ # +(_______)# + __ # + / / # + / /____ # +(_______)# + ## + ___ # +| _)# +| | # +| | # +| |_ # +|___)# + ## + _ # +| | # + \ \ # + \ \ # + \ \ # + |_|# + ## + ___ # +(_ |# + | |# + | |# + _| |# +(___|# + ## + /\ # + //\\ # + (____)# + # + # + # + ## + # + # + # + # + _______ # +(_______)# + ## + _ # +( )# + \|# + # + # + # + ## + # + # + ____ # + / _ |# +( ( | |# + \_||_|# + ## + _ # +| | # +| | _ # +| || \ # +| |_) )# +|____/ # + ## + # + # + ____ # + / ___)# +( (___ # + \____)# + ## + _ # + | |# + _ | |# + / || |# +( (_| |# + \____|# + ## + # + # + ____ # + / _ )# +( (/ / # + \____)# + ## + ___ # + / __)# +| |__ # +| __)# +| | # +|_| # + ## + # + # + ____ # + / _ |# +( ( | |# + \_|| |# +(_____|## + _ # +| | # +| | _ # +| || \ # +| | | |# +|_| |_|# + ## + _ # +(_)# + _ # +| |# +| |# +|_|# + ## + _ # + (_)# + _ # + | |# + | |# + _| |# +(__/ ## + _ # +| | # +| | _ # +| | / )# +| |< ( # +|_| \_)# + ## + _ # +| |# +| |# +| |# +| |# +|_|# + ## + # + # + ____ # +| \ # +| | | |# +|_|_|_|# + ## + # + # + ____ # +| _ \ # +| | | |# +|_| |_|# + ## + # + # + ___ # + / _ \ # +| |_| |# + \___/ # + ## + # + # + ____ # +| _ \ # +| | | |# +| ||_/ # +|_| ## + # + # + ____ # + / _ |# +| | | |# + \_|| |# + |_|## + # + # + ____ # + / ___)# +| | # +|_| # + ## + # + # + ___ # + /___)# +|___ |# +(___/ # + ## + # + _ # +| |_ # +| _) # +| |__ # + \___)# + ## + # + # + _ _ # +| | | |# +| |_| |# + \____|# + ## + # + # + _ _ # +| | | |# + \ V / # + \_/ # + ## + # + # + _ _ _ # +| | | |# +| | | |# + \____|# + ## + # + # + _ _ # +( \ / )# + ) X ( # +(_/ \_)# + ## + # + # + _ _ # +| | | |# +| |_| |# + \__ |# +(____/ ## + # + # + _____ # +(___ )# + / __/ # +(_____)# + ## + __ # + / _)# + | | # +( ( # + | |_ # + \__)# + ## + _ # +| |# +|_|# + _ # +| |# +|_|# + ## + __ # +(_ \ # + | | # + ) )# + _| | # +(__/ # + ## + __ _ # + / \/ )# +(_/\__/ # + # + # + # + ## + _ _ # +(_) _ (_)# + / \ # + / _ \ # + / /_\ \ # +|_______|# + ## + _ _ # +(_)___(_)# + / ___ \ # +| | | |# +| |___| |# + \_____/ # + ## + _ _ # +(_) (_)# + _ _ # +| | | |# +| |___| |# + \______|# + ## + _ _ # +(_) (_)# + ____ # + / _ |# +( ( | |# + \_||_|# + ## + _ _ # +(_) (_)# + ___ # + / _ \ # +| |_| |# + \___/ # + ## + _ _ # +(_) (_)# + _ _ # +| | | |# +| |_| |# + \____|# + ## + ___ # + / _ \ # +| | ) )# +| |< ( # +| | ) )# +|_|(_/ # + ## diff --git a/resources/unload.yaml.stub b/resources/unload.yaml.stub new file mode 100644 index 0000000..cc71805 --- /dev/null +++ b/resources/unload.yaml.stub @@ -0,0 +1,11 @@ +version: 0.1 +app: %app% + +profile: %profile% +env: %env% +region: %region% +runtime: provided +php: %php% + +build: | + composer install --ignore-platform-reqs --no-dev --prefer-dist --no-interaction --no-progress --ignore-platform-reqs --optimize-autoloader --classmap-authoritative diff --git a/resources/unload01.json b/resources/unload01.json new file mode 100644 index 0000000..2b06c85 --- /dev/null +++ b/resources/unload01.json @@ -0,0 +1,160 @@ +{ + "$id": "https://unload.dev/unload01.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "version": { + "type": "number", + "enum" : [0.1] + }, + "app": { + "type": "string" + }, + "env": { + "type": "string" + }, + "region": { + "type": "string", + "enum" : [ + "ca-central-1", + "eu-central-1", + "eu-north-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-east-1", + "ap-south-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-southeast-1", + "ap-southeast-2", + "eu-south-1", + "af-south-1", + "me-south-1" + ] + }, + "runtime": { + "type": "string", + "enum": ["provided"] + }, + "memory": { + "type": "integer", + "minimum": 128, + "maximum": 10240 + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 900 + }, + "tmp": { + "type": "integer", + "minimum": 512, + "maximum": 10240, + "default": 512 + }, + "php": { + "type": "number", + "enum": [7.3, 7.4, 8, 8.1] + }, + "database": { + "type": "object", + "properties": { + "engine": { + "type": "string", + "enum": ["aurora", "mysql"] + } + }, + "if": { + "properties": { "engine": { "const": "aurora" } } + }, + "then": { + "properties": { + "version": { + "type": "string" + }, + "min-capacity": { + "type": "integer", + "enum": [1, 2, 4, 8, 16, 32, 64, 128, 256] + }, + "max-capacity": { + "type": "integer", + "enum": [1, 2, 4, 8, 16, 32, 64, 128, 256] + }, + "auto-pause": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "exclusiveMinimum": false + }, + "backup-retention": { + "type": "integer", + "minimum": 0, + "maximum": 35 + } + }, + "required": ["version", "min-capacity", "max-capacity", "auto-pause"] + }, + "else": { + }, + "required": ["engine"] + }, + "cache": { + "type": "object", + "properties": { + "engine": { + "type": "string", + "enum": ["redis"] + } + }, + "if": { + "properties": { "engine": { "const": "redis" } } + }, + "then": { + "properties": { + "version": { + "type": "string", + "enum": [ + "3.2.6", + "4.0.10", + "5.0.0", + "5.0.3", + "5.0.4", + "5.0.5", + "5.0.6", + "6.x" + ] + }, + "size": { + "type": "string" + }, + "shards": { + "type": "integer", + "minimum": 1, + "maximum": 250 + }, + "replicas": { + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "snapshot": { + "type": "integer", + "minimum": 0, + "maximum": 35 + } + } + }, + "else": { + }, + "required": ["engine"] + } + }, + "required": ["version", "app", "env", "region"] +} diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php new file mode 100644 index 0000000..547152f --- /dev/null +++ b/tests/CreatesApplication.php @@ -0,0 +1,22 @@ +make(Kernel::class)->bootstrap(); + + return $app; + } +} diff --git a/tests/Feature/InspireCommandTest.php b/tests/Feature/InspireCommandTest.php new file mode 100755 index 0000000..9c61ca9 --- /dev/null +++ b/tests/Feature/InspireCommandTest.php @@ -0,0 +1,13 @@ +artisan('inspire')->assertExitCode(0); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..50602b9 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +toBeTrue(); +//}); diff --git a/unload b/unload new file mode 100755 index 0000000..33aa752 --- /dev/null +++ b/unload @@ -0,0 +1,53 @@ +#!/usr/bin/env php +make(Illuminate\Contracts\Console\Kernel::class); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +/* +|-------------------------------------------------------------------------- +| Shutdown The Application +|-------------------------------------------------------------------------- +| +| Once Artisan has finished running, we will fire off the shutdown events +| so that any final work may be done by the application before we shut +| down the process. This is the last thing to happen to the request. +| +*/ + +$kernel->terminate($input, $status); + +exit($status);