├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── src ├── Blocks │ ├── Block.php │ ├── BlockInterface.php │ ├── Data.php │ ├── Provider.php │ ├── Resource.php │ └── Variable.php ├── Helpers │ └── Aws │ │ └── Aws.php ├── Macros │ └── Aws │ │ └── Aws.php └── Terraform.php └── tests └── test.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | nbproject 3 | vendor 4 | terraform.tf.json 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 AOL Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # terraform-php 2 | Use PHP to generate Terraform configuration files 3 | 4 | ## Overview 5 | Terraform is great, but HCL is a configuration language (like JSON or YAML), not a programming language. Although it has some language primitives, it lacks a number of things such as loops and if statements. It would be great if we could just "code," right? This project allows you to do just that. You write in pure PHP, and generate fully-valid Terraform configs in either HCL or JSON, your choice. 6 | 7 | Additionally, this project provides some helper functions that talk to the respective APIs (AWS only at the moment) that allow for dynamic config generation. For example, you can lookup a VPC's CIDR block, perform some math, and create an ENI (via Terraform) for the first available IP in each subnet. 8 | 9 | There are also macros for some commonly-used functions that provide sensible defaults. 10 | 11 | ## Installation 12 | This is meant to be used as a library for your own PHP-based projects. As such, having the following in your composer.json will load this project and its dependencies. 13 | ``` 14 | "repositories": [ 15 | { 16 | "type": "git", 17 | "url": "https://github.com/aol/terraform-php" 18 | } 19 | ], 20 | "require": { 21 | "aol/terraform-php": "dev-develop" 22 | } 23 | ``` 24 | 25 | ## Usage 26 | This project uses PHP magic methods (namely `__set()` and `__get()`), and does not hardcode support for any Terraform resources. That means that we're automatically compatible with any new resources that Terraform implements. 27 | Below is an example of what using this project could look like. Note the use of macros for creating security groups, and the manual creation of the `aws_elasticache_cluster` resource. 28 | 29 | ``` 30 | $projectLongName = 'My Test Project'; 31 | $projectShortName = 'mtp'; 32 | 33 | $vpcId = 'vpc-xxxxxxxx'; 34 | 35 | // create security group for web hosts and ELB 36 | $rules = [ 37 | [ 38 | 'cidr_blocks' => ['0.0.0.0/0'], 39 | 'ports' => [80, 443], 40 | 'protocol' => 'tcp', 41 | ], 42 | [ 43 | 'cidr_blocks' => ['172.31.0.0/16'], 44 | ], 45 | ]; 46 | $sgWeb = AwsMacros::securityGroup("$projectShortName-web", $vpcId, $rules); 47 | $sgWeb->description = "Allow web traffic for $projectLongName"; 48 | $terraform->sgWeb = $sgWeb; 49 | 50 | // create security group for Redis 51 | $rules = [ 52 | [ 53 | 'cidr_blocks' => ['172.31.0.0/16'], 54 | ], 55 | ]; 56 | $sgRedis = AwsMacros::securityGroup("$projectShortName-redis", $vpcId, $rules); 57 | $sgRedis->description = "$projectLongName Redis"; 58 | $terraform->sgRedis = $sgRedis; 59 | 60 | foreach (['prod', 'dev', 'staging'] as $env) { 61 | // create redis cluster for multiple environments 62 | $name = "{$projectShortName}-{$env}-redis"; 63 | $tags['Name'] = $name; 64 | 65 | $redis = new Resource('aws_elasticache_cluster', $name); 66 | $redis->cluster_id = substr($name, 0, 20); 67 | $redis->engine = 'redis'; 68 | $redis->node_type = 'cache.t2.micro'; 69 | $redis->num_cache_nodes = 1; 70 | $redis->parameter_group_name = 'default.redis2.8'; 71 | $redis->port = 6379; 72 | $redis->subnet_group_name = 'xxxxx'; 73 | $redis->security_group_ids = [$sgRedis->getTfProp('id')]; 74 | $redis->tags = $tags; 75 | $terraform->{"redis_$name"} = $redis; 76 | } 77 | ``` 78 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aol/terraform-php", 3 | "authors": [ 4 | { 5 | "name": "Ameir Abdeldayem", 6 | "email": "ameirh@gmail.com" 7 | } 8 | ], 9 | "autoload": { 10 | "psr-4": { 11 | "Terraform\\": "src/" 12 | } 13 | }, 14 | "require": { 15 | "aws/aws-sdk-php": "~3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Blocks/Block.php: -------------------------------------------------------------------------------- 1 | _block = $block; 12 | $this->_type = $type; 13 | $this->_name = $name; 14 | } 15 | 16 | public function &__get($name) 17 | { 18 | if (array_key_exists($name, $this->_data)) { 19 | return $this->_data[$name]; 20 | } 21 | } 22 | 23 | public function __set($name, $value) 24 | { 25 | $this->_data[$name] = $value; 26 | } 27 | 28 | public function getType() 29 | { 30 | return $this->_type; 31 | } 32 | 33 | public function getName() 34 | { 35 | return $this->_name; 36 | } 37 | 38 | public function toArray() 39 | { 40 | return [$this->_block => [$this->_type => $this->_data]]; 41 | } 42 | 43 | public function getData() 44 | { 45 | return $this->_data; 46 | } 47 | 48 | public function dump() 49 | { 50 | print_r($this->terraform); 51 | } 52 | 53 | public function toJson() 54 | { 55 | return json_encode($this->terraform, JSON_PRETTY_PRINT); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Blocks/BlockInterface.php: -------------------------------------------------------------------------------- 1 | _block => [$this->_type => [$this->_name => $this->_data]]]; 15 | } 16 | 17 | public function getTfProp($property = 'id', $encapsulate = true) 18 | { 19 | $resource = "{$this->_type}.{$this->_name}.{$property}"; 20 | if ($encapsulate) { 21 | $resource = '${data.' . $resource . '}'; 22 | } 23 | return $resource; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Blocks/Provider.php: -------------------------------------------------------------------------------- 1 | _block => [$this->_type => [$this->_name => $this->_data]]]; 15 | } 16 | 17 | public function getTfProp($property = 'id', $encapsulate = true) 18 | { 19 | $resource = "{$this->_type}.{$this->_name}.{$property}"; 20 | if ($encapsulate) { 21 | $resource = '${' . $resource . '}'; 22 | } 23 | return $resource; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Blocks/Variable.php: -------------------------------------------------------------------------------- 1 | default = $variableValues; 11 | } 12 | } -------------------------------------------------------------------------------- /src/Helpers/Aws/Aws.php: -------------------------------------------------------------------------------- 1 | aws = new Sdk([ 16 | 'region' => $region, 17 | 'version' => 'latest', 18 | ]); 19 | } 20 | 21 | public function listAvailabilityZones($options = [], $fullResponse = false) 22 | { 23 | $ec2 = $this->aws->createEc2(); 24 | $result = $ec2->describeAvailabilityZones($options); 25 | 26 | return $fullResponse ? $result->toArray() : array_column($result->toArray()['AvailabilityZones'], 'ZoneName'); 27 | } 28 | 29 | public function listVpcs($options = [], $fullResponse = false) 30 | { 31 | $ec2 = $this->aws->createEc2(); 32 | $result = $ec2->describeVpcs($options); 33 | 34 | return $fullResponse ? $result->toArray() : array_column($result->toArray()['Vpcs'], 'VpcId'); 35 | } 36 | 37 | public function listSubnets($options = [], $fullResponse = false) 38 | { 39 | $ec2 = $this->aws->createEc2(); 40 | $result = $ec2->describeSubnets($options); 41 | 42 | return $fullResponse ? $result->toArray() : array_column($result->toArray()['Subnets'], 'SubnetId'); 43 | } 44 | 45 | public function findAmi($options = [], $fullResponse = false) 46 | { 47 | $ec2 = $this->aws->createEc2(); 48 | $result = $ec2->describeImages($options); 49 | 50 | return $fullResponse ? $result->toArray() : array_column($result->toArray()['Images'], 'ImageId'); 51 | } 52 | 53 | public function getSdk() 54 | { 55 | return $this->aws; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Macros/Aws/Aws.php: -------------------------------------------------------------------------------- 1 | ['0.0.0.0/0'], 14 | 'from_port' => 0, 15 | 'to_port' => 0, 16 | 'protocol' => -1, 17 | ]; 18 | 19 | $ingress = []; 20 | if (!count($rules)) { 21 | $ingress = $defaults; 22 | } 23 | foreach ($rules as $rule) { 24 | $b = $defaults; 25 | foreach (['cidr_blocks', 'protocol'] as $key) { 26 | if (isset($rule[$key])) { 27 | $b[$key] = $rule[$key]; 28 | } 29 | } 30 | if (isset($rule['ports'])) { 31 | foreach ($rule['ports'] as $port) { 32 | $b['from_port'] = $b['to_port'] = $port; 33 | $ingress[] = $b; 34 | } 35 | } else { 36 | $ingress[] = $b; 37 | } 38 | } 39 | $sg = new Resource('aws_security_group', $name); 40 | $sg->ingress = $ingress; 41 | $sg->egress = $defaults; 42 | $sg->vpc_id = $vpcId; 43 | $sg->name = $name; 44 | $sg->description = "$name security group"; 45 | $sg->tags = ['Name' => $name]; 46 | 47 | return $sg; 48 | } 49 | 50 | public static function iamUser($name, $path = '/') 51 | { 52 | $user = new Resource('aws_iam_user', $name); 53 | $user->name = $name; 54 | $user->path = $path; 55 | 56 | return $user; 57 | } 58 | 59 | public static function iamUserPolicy($name, $user, array $policy) 60 | { 61 | $userPolicy = new Resource('aws_iam_user_policy', $name); 62 | $userPolicy->name = $name; 63 | $userPolicy->user = $user; 64 | $userPolicy->policy = Terraform::jsonEncode([ 65 | 'Version' => '2012-10-17', 66 | 'Statement' => $policy, 67 | ]); 68 | 69 | return $userPolicy; 70 | } 71 | 72 | public static function iamRole($name, array $policy = [], $path = '/') 73 | { 74 | $defaults = [ 75 | 'Action' => 'sts:AssumeRole', 76 | 'Principal' => ['Service' => 'ec2.amazonaws.com'], 77 | 'Effect' => 'Allow', 78 | 'Sid' => '', 79 | ]; 80 | $policy += $defaults; 81 | 82 | $role = new Resource('aws_iam_role', $name); 83 | $role->name = $name; 84 | $role->path = $path; 85 | $role->assume_role_policy = Terraform::jsonEncode([ 86 | 'Version' => '2012-10-17', 87 | 'Statement' => [$policy], 88 | ] 89 | ); 90 | 91 | return $role; 92 | } 93 | 94 | public static function iamRolePolicy($name, $role, array $policy) 95 | { 96 | $rolePolicy = new Resource('aws_iam_role_policy', $name); 97 | $rolePolicy->name = $name; 98 | $rolePolicy->role = $role; 99 | $rolePolicy->policy = Terraform::jsonEncode([ 100 | 'Version' => '2012-10-17', 101 | 'Statement' => $policy, 102 | ]); 103 | 104 | return $rolePolicy; 105 | } 106 | 107 | public static function iamInstanceProfile($name, $role) 108 | { 109 | $instanceProfile = new Resource('aws_iam_instance_profile', $name); 110 | $instanceProfile->name = $name; 111 | $instanceProfile->role = $role; 112 | 113 | return $instanceProfile; 114 | } 115 | 116 | public static function autoscalingPolicy($name, array $policy) 117 | { 118 | $defaults = [ 119 | 'name' => $name, 120 | 'adjustment_type' => "ChangeInCapacity", 121 | 'cooldown' => 300, 122 | 'scaling_adjustment' => 2, 123 | ]; 124 | $policy += $defaults; 125 | 126 | $autoscalingPolicy = new Resource('aws_autoscaling_policy', $name); 127 | foreach ($policy as $key => $value) { 128 | $autoscalingPolicy->$key = $value; 129 | } 130 | 131 | return $autoscalingPolicy; 132 | } 133 | 134 | public static function autoscalingNotification($name, $groupNames, $topicArn, array $options = []) 135 | { 136 | $defaults = [ 137 | 'notifications' => [ 138 | "autoscaling:EC2_INSTANCE_LAUNCH", 139 | "autoscaling:EC2_INSTANCE_LAUNCH_ERROR", 140 | "autoscaling:EC2_INSTANCE_TERMINATE", 141 | "autoscaling:EC2_INSTANCE_TERMINATE_ERROR", 142 | ], 143 | ]; 144 | $options += $defaults; 145 | 146 | $autoscalingNotification = new Resource('aws_autoscaling_notification', $name); 147 | foreach ($options as $key => $value) { 148 | $autoscalingNotification->$key = $value; 149 | } 150 | $autoscalingNotification->group_names = (array)$groupNames; 151 | $autoscalingNotification->topic_arn = $topicArn; 152 | 153 | return $autoscalingNotification; 154 | } 155 | 156 | public static function cloudwatchMetricAlarm($name, array $policy) 157 | { 158 | $defaults = [ 159 | 'alarm_name' => $name, 160 | 'evaluation_periods' => 1, 161 | 'metric_name' => "CPUUtilization", 162 | 'comparison_operator' => "GreaterThanThreshold", 163 | 'threshold' => 60, 164 | 'namespace' => "AWS/EC2", 165 | 'period' => "60", 166 | 'statistic' => "Average", 167 | ]; 168 | $policy += $defaults; 169 | 170 | $cloudwatchMetricAlarm = new Resource('aws_cloudwatch_metric_alarm', $name); 171 | foreach ($policy as $key => $value) { 172 | $cloudwatchMetricAlarm->$key = $value; 173 | } 174 | 175 | return $cloudwatchMetricAlarm; 176 | } 177 | 178 | public static function elb($name, array $options) 179 | { 180 | $defaults = [ 181 | 'name' => $name, 182 | 'listener' => [ 183 | 'instance_port' => 80, 184 | 'instance_protocol' => "http", 185 | 'lb_port' => 80, 186 | 'lb_protocol' => "http", 187 | ], 188 | 'health_check' => [ 189 | 'healthy_threshold' => 2, 190 | 'unhealthy_threshold' => 2, 191 | 'timeout' => 5, 192 | 'target' => "HTTP:80/", 193 | 'interval' => 30, 194 | ], 195 | 'cross_zone_load_balancing' => true, 196 | 'tags' => ['Name' => $name], 197 | ]; 198 | $options += $defaults; 199 | 200 | $elb = new Resource('aws_elb', $name); 201 | foreach ($options as $key => $value) { 202 | $elb->$key = $value; 203 | } 204 | 205 | return $elb; 206 | } 207 | 208 | public static function s3Bucket($name, array $options = []) 209 | { 210 | $defaults = [ 211 | 'bucket' => $name, 212 | 'acl' => 'private', 213 | 'tags' => ['Name' => $name], 214 | ]; 215 | $options += $defaults; 216 | 217 | $s3Bucket = new Resource('aws_s3_bucket', $name); 218 | foreach ($options as $key => $value) { 219 | $s3Bucket->$key = $value; 220 | } 221 | return $s3Bucket; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Terraform.php: -------------------------------------------------------------------------------- 1 | terraform[$name]; 14 | } 15 | 16 | public function __set($name, $value) 17 | { 18 | if (!($value instanceof Block)) { 19 | throw new \Exception('Value must be a type of block.'); 20 | } 21 | if (isset($this->terraform[$name])) { 22 | fwrite(STDERR, "Warning: $name is already set." . PHP_EOL); 23 | } 24 | $this->terraform[$name] = $value; 25 | } 26 | 27 | public function save($format = 'json', $filename = null) 28 | { 29 | if ($filename === null) { 30 | $filename = "terraform.tf" . ($format == 'json' ? '.json' : ''); 31 | } 32 | file_put_contents($filename, $format == 'json' ? $this->toJson() : $this->toHcl()); 33 | } 34 | 35 | public function deepMerge() 36 | { 37 | $a = []; 38 | foreach ($this->terraform as $key => $value) { 39 | $a = array_merge_recursive($a, $value->toArray()); 40 | } 41 | 42 | return $a; 43 | } 44 | 45 | public function toHcl() 46 | { 47 | $a = $this->deepMerge(); 48 | 49 | return self::hclEncode($a); 50 | } 51 | 52 | public function toJson() 53 | { 54 | $a = $this->deepMerge(); 55 | 56 | return self::jsonEncode($a); 57 | } 58 | 59 | public static function jsonEncode($input, $pretty = true) 60 | { 61 | $flag = $pretty ? JSON_PRETTY_PRINT : 0; 62 | return json_encode($input, $flag | JSON_UNESCAPED_SLASHES); 63 | } 64 | 65 | public static function hclEncode($input) 66 | { 67 | $s = ''; 68 | foreach ($input as $blockType => $blocks) { 69 | foreach ($blocks as $blockName => $block) { 70 | // these blocks are treated differently 71 | if (in_array($blockType, ['variable', 'provider'])) { 72 | $blockText = ''; 73 | $s .= PHP_EOL . $blockType; 74 | $s .= ' "' . $blockName . '"'; 75 | $s .= ' {'; 76 | $blockText = self::serializeToHcl($block); 77 | $s .= $blockText; 78 | $s .= PHP_EOL . '}' . PHP_EOL; 79 | } else { 80 | foreach ($block as $name => $values) { 81 | $blockText = ''; 82 | $s .= PHP_EOL . $blockType; 83 | $s .= ' "' . $blockName . '"'; 84 | $s .= ' "' . $name . '" {'; 85 | $blockText = self::serializeToHcl($values); 86 | $s .= $blockText; 87 | $s .= PHP_EOL . '}' . PHP_EOL; 88 | } 89 | } 90 | } 91 | } 92 | return $s; 93 | } 94 | 95 | public static function serializeToHcl(array $values, $indentLevel = 1) 96 | { 97 | $indent = self::indent($indentLevel); 98 | 99 | $hcl = ''; 100 | foreach ($values as $key => $value) { 101 | // handle cases where key can be specified multiple times (like ingress, egress, tag) 102 | // this will be an array of hashes 103 | if (isset($value[0]) && is_array($value[0])) { 104 | foreach ($value as $k => $v) { 105 | $hcl .= PHP_EOL . $indent . "$key = {"; 106 | $hcl .= self::serializeToHcl($v, $indentLevel + 1); 107 | $hcl .= PHP_EOL . $indent . "}"; 108 | } 109 | } elseif (is_array($value)) { 110 | $hcl .= PHP_EOL . $indent . "$key = "; 111 | if (self::arrayIsAssociative($value)) { 112 | $hcl .= '{'; 113 | $hcl .= self::serializeToHcl($value, $indentLevel + 1); 114 | $hcl .= PHP_EOL . $indent . '}'; 115 | } else { 116 | $hcl .= self::jsonEncode($value, false); 117 | } 118 | } else { 119 | $hcl .= PHP_EOL . $indent . "$key = " . ((strlen($value) && $value[0] == '$') ? '"' . $value . '"' : self::jsonEncode($value)); 120 | } 121 | } 122 | return $hcl; 123 | } 124 | 125 | public function dump() 126 | { 127 | var_dump($this->terraform); 128 | } 129 | 130 | public static function indent($level) 131 | { 132 | return str_repeat("\t", $level); 133 | } 134 | 135 | // http://stackoverflow.com/a/4254008 136 | public static function arrayIsAssociative(array $array) 137 | { 138 | return count(array_filter(array_keys($array), 'is_string')) > 0; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tests/test.php: -------------------------------------------------------------------------------- 1 | region = 'us-east-1'; 13 | $terraform->provider = $provider; 14 | 15 | foreach (['prod', 'dev', 'staging'] as $env) { 16 | $lc = new Resource('aws_launch_configuration', 'my_launch_configuration_' . $env); 17 | $lc->image_id = 'ami-eb02508e'; 18 | $lc->instance_type = 't2.micro'; 19 | $lc->key_name = 'my_key'; 20 | $lc->lifecycle = ['create_before_destroy' => true]; 21 | $terraform->{"lc_$env"} = $lc; 22 | } 23 | 24 | $rules = [ 25 | '0.0.0.0/0' => [80, 443, 8010], 26 | '10.0.0.0/8,192.168.1.0/24' => [0], 27 | ]; 28 | $sg = AwsMacros::securityGroup('my_sg', 'vpc-12345678', $rules); 29 | $sg->description = 'We can update properties like this.'; 30 | $terraform->sg = $sg; 31 | 32 | $role = AwsMacros::iamRole('my_role'); 33 | $terraform->role = $role; 34 | 35 | $statement = [ 36 | "Effect" => "Allow", 37 | "Action" => [ 38 | "ec2:AttachNetworkInterface", 39 | ], 40 | "Resource" => ["*"], 41 | ]; 42 | $rolePolicy = AwsMacros::iamRolePolicy('my_policy', $role->getTfProp('id'), $statement); 43 | var_dump($rolePolicy); 44 | $terraform->rolePolicy = $rolePolicy; 45 | 46 | $subnets = []; 47 | $aws = new AwsHelpers\Aws(); 48 | foreach ($aws->listAvailabilityZones() as $key => $availabilityZone) { 49 | $lastChar = strtoupper(substr($availabilityZone, -1)); 50 | $subnets['public_name_' . $key] = 'Public ' . $lastChar; 51 | $subnets['public_zone_' . $key] = $availabilityZone; 52 | $subnets['private_name_' . $key] = 'Private ' . $lastChar; 53 | $subnets['private_zone_' . $key] = $availabilityZone; 54 | } 55 | 56 | // list all VPCs 57 | print_r($aws->listVpcs()); 58 | 59 | // list all VPCs 60 | print_r($aws->listSubnets()); 61 | 62 | $varSubnets = new \Terraform\Blocks\Variable('subnets', $subnets); 63 | $terraform->varSubnets = $varSubnets; 64 | 65 | $terraform->save(); 66 | --------------------------------------------------------------------------------