diff --git a/app/Cfnpp/Functions.php b/app/Cfnpp/Functions.php index 29131a8..6321ce1 100644 --- a/app/Cfnpp/Functions.php +++ b/app/Cfnpp/Functions.php @@ -83,7 +83,10 @@ class Functions */ public function mf_replace(Node $original, Node $target, NodeFunction $function): ?Node { - // TODO: Deal with nodefunctionvalue + if ($function instanceof NodeFunctionValue) + { + return new NodeValue(null, $target->hasName() ? $target->getName() : null, $function->getValue()); + } $replacement = new Node(null, $target->hasName() ? $target->getName() : null); $replacement->setChildren($function->getChildren()); diff --git a/app/CloudFormation/Client.php b/app/CloudFormation/Client.php new file mode 100644 index 0000000..77de129 --- /dev/null +++ b/app/CloudFormation/Client.php @@ -0,0 +1,74 @@ +cfn = new CloudFormationClient([ + 'version' => 'latest', + 'region' => $region ?? env('AWS_REGION'), + 'profile' => $profile ?? env('AWS_PROFILE') + ]); + } + + public function getStack(string $stack): ?Stack + { + try + { + $stack_description = $this->cfn->describeStacks([ + 'StackName' => $stack + ]); + } + catch (\Aws\Exception\AwsException $ex) + { + if ($ex->getAwsErrorMessage() == 'Stack with id '.$stack.' does not exist') + { + return null; + } + throw new \Exception($ex->getAwsErrorMessage(), $ex->getAwsErrorCode()); + } + return new Stack($stack_description['Stacks'][0]); + } + + public function createStack(string $stack_name, string $template_body, array $parameters = []): Stack + { + $response = $this->cfn->createStack($this->buildStackRequest($stack_name, $template_body, $parameters)); + return $this->getStack($response['StackId']); + } + + public function updateStack(string $stack_name, string $template_body, array $parameters = []): Stack + { + $response = $this->cfn->updateStack($this->buildStackRequest($stack_name, $template_body, $parameters)); + return $this->getStack($response['StackId']); + } + + protected function buildStackRequest(string $stack_name, string $template_body, array $parameters = []): array + { + $request = [ + 'StackName' => $stack_name, + 'TemplateBody' => $template_body, + 'Capabilities' => ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], + 'TimeoutInMinutes' => 5 + ]; + if (sizeof($parameters)) + { + $request['Parameters'] = []; + foreach ($parameters as $k => $v) + { + $request['Parameters'][] = [ + 'ParameterKey' => $k, + 'ParameterValue' => $v + ]; + } + } + return $request; + } +} diff --git a/app/CloudFormation/Stack.php b/app/CloudFormation/Stack.php new file mode 100644 index 0000000..1bbde49 --- /dev/null +++ b/app/CloudFormation/Stack.php @@ -0,0 +1,63 @@ +stack_data = $data; + } + + public function getId(): string + { + return $this->stack_data['StackId']; + } + + public function getName(): string + { + return $this->stack_data['StackName']; + } + + public function getStatus(): string + { + return $this->stack_data['StackStatus']; + } + + public function getStatusReason(): ?string + { + return $this->stack_data['StackStatusReason'] ?? null; + } + + public function getParameters(): array + { + if (!isset($this->stack_data['Parameters'])) + { + return []; + } + $parameters = []; + foreach ($this->stack_data['Parameters'] as $parameter) + { + $parameters[$parameter['ParameterKey']] = $parameter['ParameterValue']; + } + return $parameters; + } + + public function getOutputs(): array + { + if (!isset($this->stack_data['Outputs'])) + { + return []; + } + $outputs = []; + foreach ($this->stack_data['Outputs'] as $output) + { + $outputs[$output['OutputKey']] = $output['OutputValue']; + } + return $outputs; + } +} diff --git a/app/Commands/Stack/Deploy.php b/app/Commands/Stack/Deploy.php new file mode 100644 index 0000000..862d921 --- /dev/null +++ b/app/Commands/Stack/Deploy.php @@ -0,0 +1,85 @@ + + */ +class Deploy extends Command +{ + /** + * The signature of the command. + * + * @var string + */ + protected $signature = 'stack:deploy {in_file} {--format=Yaml}'; + + /** + * The description of the command. + * + * @var string + */ + protected $description = 'Read an input file, process, output result to a file'; + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + $engine = new \App\Engine\Engine(); + + $serializer = $this->getSerializer(); + $unserializer = $this->getUnserializer(); + $engine->setSerializer($serializer)->setUnserializer($unserializer)->setCompiler(new \App\Cfnpp\Compiler()); + $options = new \App\Cfnpp\Options(); + + $output = $engine->process($this->argument('in_file'), $options); + + file_put_contents($this->argument('out_file'), $output); + } + + /** + * Retrieve a serializer instance based on the format specified in the command options. + * + * @return \App\Serialize\ISerialize + */ + protected function getSerializer(): \App\Serialize\ISerialize + { + $format = $this->option('format'); + $class = '\\App\\Serialize\\'.$format; + + if (!class_exists($class)) + { + throw new \Exception('Unknown formatter: '.$format); + } + + return new $class(); + } + + /** + * Retrieve a unserializer instance based on the format specified in the command options. + * + * @return \App\Serialize\IUnserialize + */ + protected function getUnserializer(): \App\Serialize\IUnserialize + { + $format = $this->option('format'); + $class = '\\App\\Serialize\\'.$format; + + if (!class_exists($class)) + { + throw new \Exception('Unknown formatter: '.$format); + } + + return new $class(); + } +} diff --git a/app/Commands/TrashCommand.php b/app/Commands/TrashCommand.php new file mode 100644 index 0000000..ef8b87c --- /dev/null +++ b/app/Commands/TrashCommand.php @@ -0,0 +1,68 @@ +setVariables(['A' => true]); + $options->setParameters(['LBType' => 'internal', 'Other' => true]); + + $expr = new \App\Cfnpp\Expression\Expression('"internal" Other eq', $options); + + if ($expr->isComplete()) + { + echo 'Got value: '.PHP_EOL; + var_dump($expr->toArray()); + } + else + { + echo 'Expression not resolved: '.PHP_EOL; + var_dump($expr->toCloudformation()); + } + /* + $cfn = new \App\CloudFormation\Client(); + $stack = $cfn->getStack('CloudBender'); + if (!isset($stack)) + throw new \Exception("Stack not found"); + var_dump($stack->getId(), $stack->getName(), $stack->getStatus(), $stack->getStatusReason(), $stack->getParameters(), $stack->getOutputs()); + */ + } + + /** + * Define the command's schedule. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @return void + */ + public function schedule(Schedule $schedule): void + { + // $schedule->command(static::class)->everyMinute(); + } +} diff --git a/doc1.yaml b/doc1.yaml new file mode 100644 index 0000000..a3960e7 --- /dev/null +++ b/doc1.yaml @@ -0,0 +1,18 @@ +cfnpp: + stack: + - doc3.yaml + - doc4.yaml + variables: + A: 2 + B: 2 + +AwsThing: '2020-01-01' + +Resources: + Thing: + A: 1 + B: 2 + AnArr: + - 1 + - 2 + Thing3: !var C diff --git a/doc2.yaml b/doc2.yaml new file mode 100644 index 0000000..c3e8f4b --- /dev/null +++ b/doc2.yaml @@ -0,0 +1,14 @@ +cfnpp: + stack: + - doc1.yaml + variables: + A: 1 + parameters: + MyParam: asdf + +Parameters: + MyParam: + Type: String + Default: '' + Description: 'My parameter' + diff --git a/doc3.yaml b/doc3.yaml new file mode 100644 index 0000000..d52b42f --- /dev/null +++ b/doc3.yaml @@ -0,0 +1,10 @@ +cfnpp: + stack: + - doc4.yaml + variables: + C: 3 + D: 3 + +Resources: + NewResource: + Type: AWS::Custom diff --git a/doc4.yaml b/doc4.yaml new file mode 100644 index 0000000..d40b518 --- /dev/null +++ b/doc4.yaml @@ -0,0 +1 @@ +Resources: 1 diff --git a/ecs.yaml b/ecs.yaml new file mode 100644 index 0000000..ff8fb54 --- /dev/null +++ b/ecs.yaml @@ -0,0 +1,133 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Deploy a service into an ECS cluster behind a private load balancer. +Parameters: + StackName: + Type: String + Default: production + Description: The name of the parent cluster stack that you created. Necessary + to locate and reference resources created by that stack. + ServiceName: + Type: String + Default: nginx + Description: A name for the service + ImageUrl: + Type: String + Default: nginx + Description: The url of a docker image that contains the application process that + will handle the traffic for this service + ContainerPort: + Type: Number + Default: 80 + Description: What port number the application inside the docker container is binding to + ContainerCpu: + Type: Number + Default: 256 + Description: How much CPU to give the container. 1024 is 1 CPU + ContainerMemory: + Type: Number + Default: 512 + Description: How much memory in megabytes to give the container + Path: + Type: String + Default: "*" + Description: A path on the public load balancer that this service + should be connected to. Use * to send all load balancer + traffic to this service. + Priority: + Type: Number + Default: 1 + Description: The priority for the routing rule added to the load balancer. + This only applies if your have multiple services which have been + assigned to different paths on the load balancer. + DesiredCount: + Type: Number + Default: 2 + Description: How many copies of the service task to run + Role: + Type: String + Default: "" + Description: (Optional) An IAM role to give the service's containers if the code within needs to + access other AWS resources like S3 buckets, DynamoDB tables, etc + +Conditions: + HasCustomRole: !Not [ !Equals [!Ref 'Role', ''] ] + +Resources: + + # The task definition. This is a simple metadata description of what + # container to run, and what resource requirements it has. + TaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: !Ref 'ServiceName' + Cpu: !Ref 'ContainerCpu' + Memory: !Ref 'ContainerMemory' + TaskRoleArn: + Fn::If: + - 'HasCustomRole' + - !Ref 'Role' + - !Ref "AWS::NoValue" + ContainerDefinitions: + - Name: !Ref 'ServiceName' + Cpu: !Ref 'ContainerCpu' + Memory: !Ref 'ContainerMemory' + Image: !Ref 'ImageUrl' + PortMappings: + - ContainerPort: !Ref 'ContainerPort' + + # The service. The service is a resource which allows you to run multiple + # copies of a type of task, and gather up their logs and metrics, as well + # as monitor the number of running tasks and replace any that have crashed + Service: + Type: AWS::ECS::Service + DependsOn: LoadBalancerRule + Properties: + ServiceName: !Ref 'ServiceName' + Cluster: + Fn::ImportValue: + !Join [':', [!Ref 'StackName', 'ClusterName']] + DeploymentConfiguration: + MaximumPercent: 200 + MinimumHealthyPercent: 75 + DesiredCount: !Ref 'DesiredCount' + TaskDefinition: !Ref 'TaskDefinition' + LoadBalancers: + - ContainerName: !Ref 'ServiceName' + ContainerPort: !Ref 'ContainerPort' + TargetGroupArn: !Ref 'TargetGroup' + + # A target group. This is used for keeping track of all the tasks, and + # what IP addresses / port numbers they have. You can query it yourself, + # to use the addresses yourself, but most often this target group is just + # connected to an application load balancer, or network load balancer, so + # it can automatically distribute traffic across all the targets. + TargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + Properties: + HealthCheckIntervalSeconds: 6 + HealthCheckPath: / + HealthCheckProtocol: HTTP + HealthCheckTimeoutSeconds: 5 + HealthyThresholdCount: 2 + Name: !Ref 'ServiceName' + Port: 80 + Protocol: HTTP + UnhealthyThresholdCount: 2 + VpcId: + Fn::ImportValue: + !Join [':', [!Ref 'StackName', 'VPCId']] + + # Create a rule on the load balancer for routing traffic to the target group + LoadBalancerRule: + Type: AWS::ElasticLoadBalancingV2::ListenerRule + Properties: + Actions: + - TargetGroupArn: !Ref 'TargetGroup' + Type: 'forward' + Conditions: + - Field: path-pattern + Values: [!Ref 'Path'] + ListenerArn: + Fn::ImportValue: + !Join [':', [!Ref 'StackName', 'PrivateListener']] + Priority: !Ref 'Priority' diff --git a/expressions.txt b/expressions.txt new file mode 100644 index 0000000..1eca09f --- /dev/null +++ b/expressions.txt @@ -0,0 +1,41 @@ +- naked variable names +- single quoted strings +- numbers are numbers +- postfix? +- english-y operators? + +LoadBalancerType 'internet-facing' eq LoadBalancerType defined or + +Maybe function calls for some of the kind of stuff we need in expressions? + +'LoadBalancerType' parameter 'internet-facing' eq + +How do we differentiate between parameters and variables? Do we shove those all in one bucket? + +Should statements that reference parameters simply be evaluated as far as we can, then the +remainder converted into a cfn condition+fn::if? + + +Pass 1: + - Load all files + - Build dependency graph + +Pass 2: + - Run through files in dependency graph + - Run mergeFunctions + merge for each + +Pass 3: + - Load all variables (does this need a dependency graph as well?) -- can grab these from the final document + - Load all parameters (again, do we need a dependency graph?) -- can grab these from the final document + - Evaluate variables + parameters + +Pass 3: + - Run other functions (e.g., !var, !if, etc) + +So maybe pass 1: + mergeFunctions + merge + +Pass 2: + + diff --git a/idea.txt b/idea.txt new file mode 100644 index 0000000..630b41e --- /dev/null +++ b/idea.txt @@ -0,0 +1,67 @@ +Okay so... + +Take document 1 + document 2 +Do a recursive merge + - Named properties overwrite scalars + - This is based on the _target_ type, if the source has a map and the target has a scalar... it overwrites the entire map with the scalar, + if the target is a map and the source is a scalar, the scalar is tossed away and overwritten + +Later, implement ways to control this merge. E.g., "!Replace" to replace an entire block. + +Things we might need: + !Replace -- replace entire block + !Unset -- remove value, any children, etc + + + +So: +``` +AwsWhatever: '2020-01-01' +Resources: + SecurityGroup: + VpcId: 1234 + Name: Test + UseSuperSecurity: True + Subnets: + - us-east-1a + - us-east-1b + Users: + Alice: 'aws:arn:1234:alice' + Bob: 'aws:arn:1234:bob' +``` ++ +``` +Resources: + SecurityGroup: + Name: NewGroup + UseSuperSecurity: !Unset ~ + NewValue: True + Subnets: + - us-east-1c + Users: !Replace + Charlie: 'aws:arn:1234:charlie' +``` += +``` +AwsWhatever: '2020-01-01' +Resources: + SecurityGroup: + # Values not specified are unchanged + VpcId: 1234 + # Scalar values specified are replaced + Name: NewGroup + # !Unset removes a value/block/etc + # UserSuperSecurity: + # Values that don't exist in the original doc are added + NewValue: True + # Arrays are appended + Subnets: + - us-east-1a + - us-east-1b + - us-east-1c + # Replace stops the merge and just does a copy from the new document + Users: + Charlie: 'aws:arn:1234:charlie' +``` + + diff --git a/out.yaml b/out.yaml new file mode 100644 index 0000000..56e2349 --- /dev/null +++ b/out.yaml @@ -0,0 +1,10 @@ +cfnpp: + parameters: + CreateDnsRecord: whatever + variables: + A: true +Resources: + Asdf: False! +Metadata: + Stack: + - '' diff --git a/phan.txt b/phan.txt new file mode 100644 index 0000000..8f2ad34 --- /dev/null +++ b/phan.txt @@ -0,0 +1,29 @@ +app/CloudFormation/Client.php:6 PhanPluginNoCommentOnClass Class \App\CloudFormation\Client has no doc comment +app/CloudFormation/Client.php:8 PhanPluginNoCommentOnProtectedProperty Protected property \App\CloudFormation\Client->cfn has no doc comment +app/CloudFormation/Client.php:8 PhanPluginUnknownPropertyType Property \App\CloudFormation\Client->cfn has an initial type that cannot be inferred (Types inferred after analysis: \Aws\CloudFormation\CloudFormationClient) +app/CloudFormation/Client.php:20 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Client::getStack has no doc comment +app/CloudFormation/Client.php:34 PhanTypeMismatchArgumentInternal Argument 2 ($code) is $ex->getAwsErrorCode() of type null|string but \Exception::__construct() takes int +app/CloudFormation/Client.php:39 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Client::createStack has no doc comment +app/CloudFormation/Client.php:39 PhanPluginUnknownArrayMethodParamType Method \App\CloudFormation\Client::createStack has a parameter type of array for $parameters, but does not specify any key types or value types +app/CloudFormation/Client.php:42 PhanTypeMismatchReturnNullable Returning $this->getStack($response['StackId']) of type ?\App\CloudFormation\Stack but createStack() is declared to return \App\CloudFormation\Stack (expected returned value to be non-nullable) +app/CloudFormation/Client.php:45 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Client::updateStack has no doc comment +app/CloudFormation/Client.php:45 PhanPluginUnknownArrayMethodParamType Method \App\CloudFormation\Client::updateStack has a parameter type of array for $parameters, but does not specify any key types or value types +app/CloudFormation/Client.php:48 PhanTypeMismatchReturnNullable Returning $this->getStack($response['StackId']) of type ?\App\CloudFormation\Stack but updateStack() is declared to return \App\CloudFormation\Stack (expected returned value to be non-nullable) +app/CloudFormation/Client.php:52 PhanPluginNoCommentOnProtectedMethod Protected method \App\CloudFormation\Client::buildStackRequest has no doc comment +app/CloudFormation/Client.php:52 PhanPluginUnknownArrayMethodParamType Method \App\CloudFormation\Client::buildStackRequest has a parameter type of array for $parameters, but does not specify any key types or value types +app/CloudFormation/Client.php:52 PhanPluginUnknownArrayMethodReturnType Method \App\CloudFormation\Client::buildStackRequest() has a return type of array, but does not specify any key types or value types +app/CloudFormation/Stack.php:5 PhanPluginNoCommentOnClass Class \App\CloudFormation\Stack has no doc comment +app/CloudFormation/Stack.php:7 PhanPluginNoCommentOnProtectedProperty Protected property \App\CloudFormation\Stack->stack_data has no doc comment +app/CloudFormation/Stack.php:7 PhanPluginUnknownPropertyType Property \App\CloudFormation\Stack->stack_data has an initial type that cannot be inferred (Types inferred after analysis: array|array) +app/CloudFormation/Stack.php:9 PhanPluginUnknownArrayMethodParamType Method \App\CloudFormation\Stack::__construct has a parameter type of array for $data, but does not specify any key types or value types +app/CloudFormation/Stack.php:14 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Stack::getId has no doc comment +app/CloudFormation/Stack.php:19 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Stack::getName has no doc comment +app/CloudFormation/Stack.php:24 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Stack::getStatus has no doc comment +app/CloudFormation/Stack.php:29 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Stack::getStatusReason has no doc comment +app/CloudFormation/Stack.php:34 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Stack::getParameters has no doc comment +app/CloudFormation/Stack.php:34 PhanPluginUnknownArrayMethodReturnType Method \App\CloudFormation\Stack::getParameters() has a return type of array, but does not specify any key types or value types +app/CloudFormation/Stack.php:45 PhanPluginNoCommentOnPublicMethod Public method \App\CloudFormation\Stack::getOutputs has no doc comment +app/CloudFormation/Stack.php:45 PhanPluginUnknownArrayMethodReturnType Method \App\CloudFormation\Stack::getOutputs() has a return type of array, but does not specify any key types or value types +app/Commands/TrashCommand.php:8 PhanPluginNoCommentOnClass Class \App\Commands\TrashCommand has no doc comment +app/Commands/TrashCommand.php:29 PhanPluginAlwaysReturnMethod Method \App\Commands\TrashCommand::handle has a return type of mixed, but may fail to return a value +app/Commands/TrashCommand.php:29 PhanTypeMissingReturn Method \App\Commands\TrashCommand::handle is declared to return mixed in phpdoc but has no return value diff --git a/test.yaml b/test.yaml new file mode 100644 index 0000000..1cfb0de --- /dev/null +++ b/test.yaml @@ -0,0 +1,25 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: A sample template +Resources: + MyEC2Instance: #An inline comment + Type: "AWS::EC2::Instance" + Properties: + ImageId: "ami-0ff8a91507f77f867" #Another comment -- This is a Linux AMI + InstanceType: t2.micro + KeyName: testkey + BlockDeviceMappings: + - + DeviceName: /dev/sdm + Ebs: + VolumeType: io1 + Iops: 200 + DeleteOnTermination: false + VolumeSize: 20 + TestKey: !Replace asdf + OtherKey: !Replace + Test: 1 + Foo: "bar" + Nest: + - 1 + - 2 + - 3 diff --git a/test2.yaml b/test2.yaml new file mode 100644 index 0000000..91f9a3e --- /dev/null +++ b/test2.yaml @@ -0,0 +1,9 @@ +Resources: + Thing1: + Asdf: !Sub "test" + Foo: !Unset ~ + Fdsa: !Replace + - foo + - bar + - baz + diff --git a/test3.yaml b/test3.yaml new file mode 100644 index 0000000..6a2b0b2 --- /dev/null +++ b/test3.yaml @@ -0,0 +1,20 @@ +cfnpp: + parameters: + MultiAz: True + Param1: !var UseSecurity + variables: + UseSecurity: False + UseMultiAz: False + +Resources: + MyResource: + Name: A Resource + AvailabilityZones: !if + - MultiAz Param1 and + - + - us-east-1a + - us-east-1b + - + - us-east-1a + Test: !param Param1 + #Test2: !expr UseSecurity not diff --git a/tmp.yml b/tmp.yml new file mode 100644 index 0000000..e407c3a --- /dev/null +++ b/tmp.yml @@ -0,0 +1,11 @@ +cfnpp: + parameters: + CreateDnsRecord: whatever + variables: + A: true + +Resources: + Asdf: !if + - CreateDnsRecord 1 and A or not + - True! + - False!