diff --git a/Tests/Module Tests/ConvertFrom-ProviderSchema.Tests.ps1 b/Tests/Module Tests/ConvertFrom-ProviderSchema.Tests.ps1 new file mode 100644 index 000000000..40c62e92d --- /dev/null +++ b/Tests/Module Tests/ConvertFrom-ProviderSchema.Tests.ps1 @@ -0,0 +1,441 @@ +Describe 'ConvertFrom-ProviderSchema' { + + BeforeAll { + # Source the function under test and its dependencies + . "$PSScriptRoot/../../ci/ConvertFrom-ProviderSchema.ps1" + } + + Context 'Basic resource with primitive properties' { + BeforeAll { + $schema = @{ + typeName = 'AWS::S3::Bucket' + properties = [PSCustomObject]@{ + BucketName = [PSCustomObject]@{ + type = 'string' + description = 'The name of the bucket' + } + VersioningEnabled = [PSCustomObject]@{ + type = 'boolean' + } + } + required = @('BucketName') + definitions = $null + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Returns a ResourceType with the correct name' { + $result.ResourceType.Name | Should -Be 'AWS::S3::Bucket' + } + + It 'Marks required properties as Required True' { + $result.ResourceType.Value.Properties.BucketName.Required | Should -Be 'True' + } + + It 'Marks non-required properties as Required False' { + $result.ResourceType.Value.Properties.VersioningEnabled.Required | Should -Be 'False' + } + + It 'Maps string type to PrimitiveType String' { + $result.ResourceType.Value.Properties.BucketName.PrimitiveType | Should -Be 'String' + } + + It 'Maps boolean type to PrimitiveType Boolean' { + $result.ResourceType.Value.Properties.VersioningEnabled.PrimitiveType | Should -Be 'Boolean' + } + + It 'Includes Documentation URL' { + $result.ResourceType.Value.Documentation | Should -Match 'docs.aws.amazon.com' + } + } + + Context 'Resource with integer and number properties' { + BeforeAll { + $schema = @{ + typeName = 'AWS::AutoScaling::Group' + properties = [PSCustomObject]@{ + MaxSize = [PSCustomObject]@{ + type = 'integer' + } + DesiredCapacity = [PSCustomObject]@{ + type = 'number' + } + } + definitions = $null + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Maps integer type to PrimitiveType Integer' { + $result.ResourceType.Value.Properties.MaxSize.PrimitiveType | Should -Be 'Integer' + } + + It 'Maps number type to PrimitiveType Double' { + $result.ResourceType.Value.Properties.DesiredCapacity.PrimitiveType | Should -Be 'Double' + } + } + + Context 'Resource with array of primitives' { + BeforeAll { + $schema = @{ + typeName = 'AWS::EC2::SecurityGroup' + properties = [PSCustomObject]@{ + SecurityGroupIngress = [PSCustomObject]@{ + type = 'array' + items = [PSCustomObject]@{ + type = 'string' + } + } + } + definitions = $null + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Maps array type to Type List' { + $result.ResourceType.Value.Properties.SecurityGroupIngress.Type | Should -Be 'List' + } + + It 'Maps array items primitive type to PrimitiveItemType' { + $result.ResourceType.Value.Properties.SecurityGroupIngress.PrimitiveItemType | Should -Be 'String' + } + } + + Context 'Resource with array of complex type ($ref)' { + BeforeAll { + $schema = @{ + typeName = 'AWS::ECS::Service' + properties = [PSCustomObject]@{ + LoadBalancers = [PSCustomObject]@{ + type = 'array' + items = [PSCustomObject]@{ + '$ref' = '#/definitions/LoadBalancer' + } + } + } + definitions = [PSCustomObject]@{ + LoadBalancer = [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ + ContainerName = [PSCustomObject]@{ + type = 'string' + } + ContainerPort = [PSCustomObject]@{ + type = 'integer' + } + } + required = @('ContainerPort') + } + } + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Maps array of $ref to Type List with ItemType' { + $result.ResourceType.Value.Properties.LoadBalancers.Type | Should -Be 'List' + $result.ResourceType.Value.Properties.LoadBalancers.ItemType | Should -Be 'LoadBalancer' + } + + It 'Generates a PropertyType entry for the definition' { + $result.PropertyTypes.Keys | Should -Contain 'AWS::ECS::Service.LoadBalancer' + } + + It 'PropertyType has correct properties' { + $pt = $result.PropertyTypes['AWS::ECS::Service.LoadBalancer'] + $pt.Value.Properties.ContainerName.PrimitiveType | Should -Be 'String' + $pt.Value.Properties.ContainerPort.PrimitiveType | Should -Be 'Integer' + } + + It 'PropertyType respects required array' { + $pt = $result.PropertyTypes['AWS::ECS::Service.LoadBalancer'] + $pt.Value.Properties.ContainerPort.Required | Should -Be 'True' + $pt.Value.Properties.ContainerName.Required | Should -Be 'False' + } + } + + Context 'Resource with Tag property' { + BeforeAll { + $schema = @{ + typeName = 'AWS::EC2::Instance' + properties = [PSCustomObject]@{ + Tags = [PSCustomObject]@{ + type = 'array' + items = [PSCustomObject]@{ + '$ref' = '#/definitions/Tag' + } + } + } + definitions = [PSCustomObject]@{ + Tag = [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ + Key = [PSCustomObject]@{ + type = 'string' + } + Value = [PSCustomObject]@{ + type = 'string' + } + } + } + } + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Detects Tag definition and sets ItemType to Tag' { + $result.ResourceType.Value.Properties.Tags.ItemType | Should -Be 'Tag' + $result.ResourceType.Value.Properties.Tags.Type | Should -Be 'List' + } + + It 'Does not generate a PropertyType entry for Tag' { + $result.PropertyTypes.Keys | Should -Not -Contain 'AWS::EC2::Instance.Tag' + } + } + + Context 'Resource with TagsEntry property (not a Tag)' { + BeforeAll { + $schema = @{ + typeName = 'AWS::AmazonMQ::Broker' + properties = [PSCustomObject]@{ + Tags = [PSCustomObject]@{ + type = 'array' + items = [PSCustomObject]@{ + '$ref' = '#/definitions/TagsEntry' + } + } + } + definitions = [PSCustomObject]@{ + TagsEntry = [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ + Key = [PSCustomObject]@{ + type = 'string' + } + Value = [PSCustomObject]@{ + type = 'string' + } + } + } + } + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Does NOT treat TagsEntry as Tag (different name)' { + $result.ResourceType.Value.Properties.Tags.ItemType | Should -Be 'TagsEntry' + } + + It 'Generates a PropertyType entry for TagsEntry' { + $result.PropertyTypes.Keys | Should -Contain 'AWS::AmazonMQ::Broker.TagsEntry' + } + } + + Context 'Resource with $ref property (non-array complex type)' { + BeforeAll { + $schema = @{ + typeName = 'AWS::S3::Bucket' + properties = [PSCustomObject]@{ + LoggingConfiguration = [PSCustomObject]@{ + '$ref' = '#/definitions/LoggingConfiguration' + } + } + definitions = [PSCustomObject]@{ + LoggingConfiguration = [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ + DestinationBucketName = [PSCustomObject]@{ + type = 'string' + } + LogFilePrefix = [PSCustomObject]@{ + type = 'string' + } + } + } + } + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Maps $ref property to Type with definition name' { + $result.ResourceType.Value.Properties.LoggingConfiguration.Type | Should -Be 'LoggingConfiguration' + } + + It 'Generates PropertyType entry for the definition' { + $result.PropertyTypes.Keys | Should -Contain 'AWS::S3::Bucket.LoggingConfiguration' + } + } + + Context 'Resource with object/map property' { + BeforeAll { + $schema = @{ + typeName = 'AWS::CloudFormation::Stack' + properties = [PSCustomObject]@{ + Parameters = [PSCustomObject]@{ + type = 'object' + additionalProperties = [PSCustomObject]@{ + type = 'string' + } + } + } + definitions = $null + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Maps object with additionalProperties to Type Map' { + $result.ResourceType.Value.Properties.Parameters.Type | Should -Be 'Map' + } + } + + Context 'Resource with oneOf/anyOf property' { + BeforeAll { + $schema = @{ + typeName = 'AWS::Events::Rule' + properties = [PSCustomObject]@{ + Target = [PSCustomObject]@{ + oneOf = @( + [PSCustomObject]@{ '$ref' = '#/definitions/EcsParameters' } + [PSCustomObject]@{ type = 'string' } + ) + } + } + definitions = [PSCustomObject]@{ + EcsParameters = [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ + TaskDefinitionArn = [PSCustomObject]@{ + type = 'string' + } + } + } + } + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Maps oneOf with $ref to Type with definition name' { + $result.ResourceType.Value.Properties.Target.Type | Should -Be 'EcsParameters' + } + } + + Context 'Definitions without properties are skipped' { + BeforeAll { + $schema = @{ + typeName = 'AWS::Lambda::Function' + properties = [PSCustomObject]@{ + Runtime = [PSCustomObject]@{ + type = 'string' + } + } + definitions = [PSCustomObject]@{ + RuntimeEnum = [PSCustomObject]@{ + type = 'string' + enum = @('python3.9', 'nodejs18.x') + } + } + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Does not generate PropertyType for enum-only definitions' { + $result.PropertyTypes.Count | Should -Be 0 + } + } + + Context 'Empty schema handling' { + BeforeAll { + $schema = @{ + typeName = 'AWS::Empty::Resource' + properties = $null + definitions = $null + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + } + + It 'Returns a ResourceType even with no properties' { + $result.ResourceType.Name | Should -Be 'AWS::Empty::Resource' + } + + It 'Returns empty PropertyTypes' { + $result.PropertyTypes.Count | Should -Be 0 + } + } + + Context 'Integration: output is compatible with Convert-SpecToFunction input format' { + BeforeAll { + $schema = @{ + typeName = 'AWS::EC2::VPC' + properties = [PSCustomObject]@{ + CidrBlock = [PSCustomObject]@{ + type = 'string' + } + EnableDnsHostnames = [PSCustomObject]@{ + type = 'boolean' + } + Tags = [PSCustomObject]@{ + type = 'array' + items = [PSCustomObject]@{ + '$ref' = '#/definitions/Tag' + } + } + } + required = @('CidrBlock') + definitions = [PSCustomObject]@{ + Tag = [PSCustomObject]@{ + type = 'object' + properties = [PSCustomObject]@{ + Key = [PSCustomObject]@{ + type = 'string' + } + Value = [PSCustomObject]@{ + type = 'string' + } + } + } + } + } | ConvertTo-Json -Depth 10 | ConvertFrom-Json + + $result = ConvertFrom-ProviderSchema -SchemaObject $schema + $resource = $result.ResourceType + } + + It 'Has .Name property accessible' { + $resource.Name | Should -Not -BeNullOrEmpty + } + + It 'Has .Value.Documentation accessible' { + $resource.Value.Documentation | Should -Not -BeNullOrEmpty + } + + It 'Has .Value.Properties.PSObject.Properties iterable' { + $props = @($resource.Value.Properties.PSObject.Properties) + $props.Count | Should -BeGreaterThan 0 + } + + It 'Each property has Required field' { + foreach ($prop in $resource.Value.Properties.PSObject.Properties) { + $prop.Value.Required | Should -BeIn @('True', 'False') + } + } + + It 'String properties have PrimitiveType' { + $resource.Value.Properties.CidrBlock.PrimitiveType | Should -Be 'String' + } + + It 'Tag array properties have Type List and ItemType Tag' { + $resource.Value.Properties.Tags.Type | Should -Be 'List' + $resource.Value.Properties.Tags.ItemType | Should -Be 'Tag' + } + } +} diff --git a/Tests/Module Tests/SchemaIntegration.Tests.ps1 b/Tests/Module Tests/SchemaIntegration.Tests.ps1 new file mode 100644 index 000000000..2eb36627f --- /dev/null +++ b/Tests/Module Tests/SchemaIntegration.Tests.ps1 @@ -0,0 +1,189 @@ +Describe 'Schema Integration Tests' -Tag 'Integration' { + + BeforeAll { + # Source the CI functions needed + . "$PSScriptRoot/../../ci/ConvertFrom-ProviderSchema.ps1" + . "$PSScriptRoot/../../ci/Convert-SpecToFunction.ps1" + + # Download a single schema to test with + $script:SchemaUrl = 'https://schema.cloudformation.eu-west-1.amazonaws.com/CloudformationSchema.zip' + $script:ZipPath = Join-Path ([System.IO.Path]::GetTempPath()) "VS-Test-Schema-$(Get-Date -Format 'yyyyMMddHHmmss').zip" + $script:ExtractPath = Join-Path ([System.IO.Path]::GetTempPath()) "VS-Test-SchemaExtract-$(Get-Date -Format 'yyyyMMddHHmmss')" + + try { + Invoke-WebRequest -Uri $script:SchemaUrl -OutFile $script:ZipPath -UseBasicParsing -ErrorAction Stop + if (Test-Path $script:ExtractPath) { Remove-Item $script:ExtractPath -Recurse -Force } + Expand-Archive -Path $script:ZipPath -DestinationPath $script:ExtractPath -Force + $script:SchemaDownloaded = $true + } catch { + $script:SchemaDownloaded = $false + Write-Warning "Could not download schema zip - integration tests will fail: $_" + } + } + + AfterAll { + # Clean up temp files + if ($script:ZipPath -and (Test-Path $script:ZipPath)) { Remove-Item $script:ZipPath -Force -ErrorAction SilentlyContinue } + if ($script:ExtractPath -and (Test-Path $script:ExtractPath)) { Remove-Item $script:ExtractPath -Recurse -Force -ErrorAction SilentlyContinue } + } + + Context 'Schema download and extraction' { + It 'Successfully downloads the schema zip from eu-west-1' { + $script:SchemaDownloaded | Should -Be $true + } + + It 'Zip contains JSON schema files' { + $files = Get-ChildItem $script:ExtractPath -Filter '*.json' + $files.Count | Should -BeGreaterThan 100 + } + + It 'Contains the S3 Bucket schema' { + Test-Path (Join-Path $script:ExtractPath 'aws-s3-bucket.json') | Should -Be $true + } + + It 'Contains the EC2 Instance schema' { + Test-Path (Join-Path $script:ExtractPath 'aws-ec2-instance.json') | Should -Be $true + } + } + + Context 'ConvertFrom-ProviderSchema with real S3 Bucket schema' { + BeforeAll { + $s3SchemaPath = Join-Path $script:ExtractPath 'aws-s3-bucket.json' + $script:S3Schema = Get-Content $s3SchemaPath -Raw | ConvertFrom-Json + $script:S3Result = ConvertFrom-ProviderSchema -SchemaObject $script:S3Schema + } + + It 'Returns AWS::S3::Bucket as resource name' { + $script:S3Result.ResourceType.Name | Should -Be 'AWS::S3::Bucket' + } + + It 'Has BucketName property' { + $script:S3Result.ResourceType.Value.Properties.BucketName | Should -Not -BeNullOrEmpty + } + + It 'BucketName is a String type' { + $script:S3Result.ResourceType.Value.Properties.BucketName.PrimitiveType | Should -Be 'String' + } + + It 'Tags property is a List with ItemType Tag' { + $script:S3Result.ResourceType.Value.Properties.Tags.Type | Should -Be 'List' + $script:S3Result.ResourceType.Value.Properties.Tags.ItemType | Should -Be 'Tag' + } + + It 'Generates multiple PropertyTypes from definitions' { + $script:S3Result.PropertyTypes.Count | Should -BeGreaterThan 10 + } + + It 'Has LifecycleConfiguration property type' { + $script:S3Result.PropertyTypes.Keys | Should -Contain 'AWS::S3::Bucket.LifecycleConfiguration' + } + } + + Context 'ConvertFrom-ProviderSchema with real EC2 Instance schema' { + BeforeAll { + $ec2SchemaPath = Join-Path $script:ExtractPath 'aws-ec2-instance.json' + $script:EC2Schema = Get-Content $ec2SchemaPath -Raw | ConvertFrom-Json + $script:EC2Result = ConvertFrom-ProviderSchema -SchemaObject $script:EC2Schema + } + + It 'Returns AWS::EC2::Instance as resource name' { + $script:EC2Result.ResourceType.Name | Should -Be 'AWS::EC2::Instance' + } + + It 'Has InstanceType property as String' { + $script:EC2Result.ResourceType.Value.Properties.InstanceType.PrimitiveType | Should -Be 'String' + } + + It 'Has SecurityGroupIds as List' { + $script:EC2Result.ResourceType.Value.Properties.SecurityGroupIds.Type | Should -Be 'List' + } + + It 'Has Tags property as List with ItemType Tag' { + $script:EC2Result.ResourceType.Value.Properties.Tags.Type | Should -Be 'List' + $script:EC2Result.ResourceType.Value.Properties.Tags.ItemType | Should -Be 'Tag' + } + } + + Context 'End-to-end: Convert-SpecToFunction generates valid PowerShell from real schema' { + BeforeAll { + # Use the real S3 bucket schema to generate a function file + $s3SchemaPath = Join-Path $script:ExtractPath 'aws-s3-bucket.json' + $s3Schema = Get-Content $s3SchemaPath -Raw | ConvertFrom-Json + $s3Converted = ConvertFrom-ProviderSchema -SchemaObject $s3Schema + + # Convert-SpecToFunction writes to $PSScriptRoot/../VaporShell/Public/... + # which resolves to the real repo directories since we source from ci/ + try { + Convert-SpecToFunction -Resource $s3Converted.ResourceType -ResourceType Resource + $script:GeneratedResourceFile = Get-ChildItem "$PSScriptRoot/../../VaporShell/Public/Resource Types" -Filter 'New-VSS3Bucket.ps1' -ErrorAction SilentlyContinue + } catch { + Write-Warning "Resource generation failed: $_" + } + + # Generate one property type + $lifecyclePT = $s3Converted.PropertyTypes['AWS::S3::Bucket.LifecycleConfiguration'] + if ($lifecyclePT) { + try { + Convert-SpecToFunction -Resource $lifecyclePT -ResourceType Property + $script:GeneratedPropertyFile = Get-ChildItem "$PSScriptRoot/../../VaporShell/Public/Resource Property Types" -Filter 'Add-VSS3BucketLifecycleConfiguration.ps1' -ErrorAction SilentlyContinue + } catch { + Write-Warning "Property generation failed: $_" + } + } + } + + It 'Generates New-VSS3Bucket.ps1 resource function' { + $script:GeneratedResourceFile | Should -Not -BeNullOrEmpty + } + + It 'Generated resource function is valid PowerShell (zero parse errors)' { + $errors = $null + $null = [System.Management.Automation.PSParser]::Tokenize( + (Get-Content $script:GeneratedResourceFile.FullName -Raw), [ref]$errors + ) + $errors.Count | Should -Be 0 + } + + It 'Generated resource function contains correct function name' { + $content = Get-Content $script:GeneratedResourceFile.FullName -Raw + $content | Should -Match 'function New-VSS3Bucket' + } + + It 'Generated resource function contains BucketName parameter' { + $content = Get-Content $script:GeneratedResourceFile.FullName -Raw + $content | Should -Match '\$BucketName' + } + + It 'Generated resource function contains Tags parameter with TransformTag' { + $content = Get-Content $script:GeneratedResourceFile.FullName -Raw + $content | Should -Match 'TransformTag' + } + + It 'Generated resource function contains LogicalId parameter' { + $content = Get-Content $script:GeneratedResourceFile.FullName -Raw + $content | Should -Match '\$LogicalId' + } + + It 'Generated resource function outputs correct type' { + $content = Get-Content $script:GeneratedResourceFile.FullName -Raw + $content | Should -Match "OutputType\('Vaporshell\.Resource\.S3\.Bucket'\)" + } + + It 'Generates Add-VSS3BucketLifecycleConfiguration.ps1 property function' { + $script:GeneratedPropertyFile | Should -Not -BeNullOrEmpty + } + + It 'Generated property function is valid PowerShell (zero parse errors)' { + $errors = $null + $null = [System.Management.Automation.PSParser]::Tokenize( + (Get-Content $script:GeneratedPropertyFile.FullName -Raw), [ref]$errors + ) + $errors.Count | Should -Be 0 + } + + It 'Generated property function contains correct function name' { + $content = Get-Content $script:GeneratedPropertyFile.FullName -Raw + $content | Should -Match 'function Add-VSS3BucketLifecycleConfiguration' + } + } +} diff --git a/VaporShell/Private/Import-AWSSDK.ps1 b/VaporShell/Private/Import-AWSSDK.ps1 index ed2bae792..4633e7b0b 100644 --- a/VaporShell/Private/Import-AWSSDK.ps1 +++ b/VaporShell/Private/Import-AWSSDK.ps1 @@ -1,49 +1,31 @@ function Import-AWSSDK { [CmdletBinding()] - Param() - Process { - # Load the AWSSDK assemblies without conflict and kill any warning messages thrown by AWS.Tools.* modules + param() + process { + # Load the AWSSDK assemblies via AWS.Tools modules or from the VaporShell module directory try { $currentWarningPref = $WarningPreference - $WarningPreference = "SilentlyContinue" $currentErrorPref = $ErrorActionPreference - $ErrorActionPreference = "SilentlyContinue" - $awsModules = if ($tools = (Get-Module AWS.Tools* -ListAvailable -Verbose:$false).Name | Where-Object {$_ -match '^AWS\.Tools\.(CloudFormation|S3)$'}) { - $tools | Select-Object -Unique - } - else { - (Get-Module AWS* -ListAvailable -Verbose:$false).Name | Select-Object -Unique - } + $WarningPreference = 'SilentlyContinue' + $ErrorActionPreference = 'SilentlyContinue' + @( - 'AWSSDK.CloudFormation.dll' - 'AWSSDK.S3.dll' + @{ Assembly = 'AWSSDK.CloudFormation.dll'; Module = 'AWS.Tools.CloudFormation' } + @{ Assembly = 'AWSSDK.S3.dll'; Module = 'AWS.Tools.S3' } ) | ForEach-Object { - $assemblyName = $_ - if ($null -eq ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {$_.Location -match $assemblyName})) { - $toolsModule = switch ($assemblyName) { - 'AWSSDK.CloudFormation.dll' {'AWS.Tools.CloudFormation'} - 'AWSSDK.S3.dll' {'AWS.Tools.S3'} - } - if ($awsModules -contains $toolsModule) { + $assemblyName = $_.Assembly + $toolsModule = $_.Module + if ($null -eq ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.Location -match $assemblyName })) { + if (Get-Module $toolsModule -ListAvailable -Verbose:$false) { Write-Verbose "Importing $assemblyName via module $toolsModule" Import-Module $toolsModule -Verbose:$false -ErrorAction SilentlyContinue - } - elseif ($awsModules -contains 'AWSPowerShell.NetCore') { - Write-Verbose "Importing $assemblyName via module AWSPowerShell.NetCore" - Import-Module 'AWSPowerShell.NetCore' -Verbose:$false -ErrorAction SilentlyContinue - } - elseif ($awsModules -contains 'AWSPowerShell') { - Write-Verbose "Importing $assemblyName via module AWSPowerShell" - Import-Module 'AWSPowerShell' -Verbose:$false -ErrorAction SilentlyContinue - } - else { + } else { Write-Verbose "Importing $assemblyName from VaporShell module base" [System.Reflection.Assembly]::LoadFrom((Join-Path $PSScriptRoot $assemblyName)) | Out-Null } } } - } - catch {} + } catch {} finally { $WarningPreference = $currentWarningPref $ErrorActionPreference = $currentErrorPref diff --git a/VaporShell/Private/ProcessRequest.ps1 b/VaporShell/Private/ProcessRequest.ps1 index 019899e01..6c192b39a 100644 --- a/VaporShell/Private/ProcessRequest.ps1 +++ b/VaporShell/Private/ProcessRequest.ps1 @@ -1,32 +1,27 @@ function ProcessRequest { <# .SYNOPSIS - Receives AWS SDK requests, then sends them to the appropriate processor function depending on PowerShell version, as PSv3 does not allow dot sourcing method names. + Receives AWS SDK requests and sends them to the processor function. #> [cmdletbinding()] - Param + param ( - [parameter(Mandatory = $false,Position=0)] + [parameter(Mandatory = $false, Position = 0)] [String] $ParameterSetName, - [parameter(Mandatory = $false,Position=1)] + [parameter(Mandatory = $false, Position = 1)] [String] $ProfileName = $env:AWS_PROFILE, - [parameter(Mandatory = $true,Position=2)] + [parameter(Mandatory = $true, Position = 2)] [String] $Method, - [parameter(Mandatory = $true,Position=3)] + [parameter(Mandatory = $true, Position = 3)] $Request, - [parameter(Mandatory = $false,Position=4)] + [parameter(Mandatory = $false, Position = 4)] [String] $Expand ) - Process { - if ($PSVersionTable.PSVersion.Major -eq 3) { - ProcessRequest3 @PSBoundParameters - } - else { - ProcessRequest4 @PSBoundParameters - } + process { + ProcessRequest4 @PSBoundParameters } -} \ No newline at end of file +} diff --git a/VaporShell/Private/ProcessRequest3.ps1 b/VaporShell/Private/ProcessRequest3.ps1 deleted file mode 100644 index 6f3375d59..000000000 --- a/VaporShell/Private/ProcessRequest3.ps1 +++ /dev/null @@ -1,116 +0,0 @@ -function ProcessRequest3 { - <# - .SYNOPSIS - Receives AWS SDK requests, then sends them to the appropriate processor function depending on PowerShell version, as PSv3 does not allow dot sourcing method names. - #> - [cmdletbinding()] - Param - ( - [parameter(Mandatory = $false,Position=0)] - [String] - $ParameterSetName, - [parameter(Mandatory = $false,Position=1)] - [String] - $ProfileName = $env:AWS_PROFILE, - [parameter(Mandatory = $true,Position=2)] - [String] - $Method, - [parameter(Mandatory = $true,Position=3)] - $Request, - [parameter(Mandatory = $false,Position=4)] - [String] - $Expand - ) - Process { - if (!$ProfileName) { - $ProfileName = "default" - $PSBoundParameters["ProfileName"] = "default" - } - $results = @() - try { - $service = ($request.PSObject.TypeNames)[0].split('.')[1] - $sharedFile = New-Object Amazon.Runtime.CredentialManagement.SharedCredentialsFile -ErrorAction Stop - $matchedProfile = $sharedFile.ListProfiles() | Where-Object {$_.Name -eq $ProfileName} - if ($null -eq $matchedProfile) { - $creds = [Amazon.Runtime.FallbackCredentialsFactory]::GetCredentials() - $endPoint = if ([Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint()) { - [Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint() - } - else { - # Need to set a default if we can't resolve the region - Write-Warning "Unable to resolve target region! Defaulting to us-east-1 and continuing in 5 seconds." - Write-Warning "If you do not want to execute method [$Method] on service [$service] in this region," - Write-Warning "please set the environment variable 'AWS_REGION' or run the following to set a region" - Write-Warning "on the shared credential file:`n`n`tSet-VSCredential -ProfileName $ProfileName -Region " - Start-Sleep -Seconds 5 - [Amazon.RegionEndpoint]::USEast1 - } - } - else { - $creds = New-Object Amazon.Runtime.StoredProfileAWSCredentials $ProfileName -ErrorAction Stop - $endPoint = if ($matchedProfile.Region) { - $matchedProfile.Region - } - elseif ([Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint()) { - [Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint() - } - else { - # Need to set a default if we can't resolve the region - Write-Warning "Unable to resolve target region! Defaulting to us-east-1 and continuing in 5 seconds." - Write-Warning "If you do not want to execute method [$Method] on service [$service] in this region," - Write-Warning "please set the environment variable 'AWS_REGION' or run the following to set a region" - Write-Warning "on the shared credential file:`n`n`tSet-VSCredential -ProfileName $ProfileName -Region " - Start-Sleep -Seconds 5 - [Amazon.RegionEndpoint]::USEast1 - } - } - Write-Verbose "Building '$service' client in region '$($endPoint.DisplayName)' [$($endPoint.SystemName)]" - if ($endPoint) { - $client = New-Object "Amazon.$($service).Amazon$($service)Client" $creds,$endPoint -ErrorAction Stop - } - else { - return (New-VSError -String "No region set for profile '$ProfileName'! Please run the following to set a region:`n`nSet-VSCredential -ProfileName $ProfileName -Region ") - } - } - catch { - return (New-VSError -String "$($_.Exception.Message)") - } - Write-Verbose "Processing request:`n$($PSBoundParameters | Format-Table -AutoSize | Out-String)" - $i = 0 - do { - $i++ - $result = $client.PSObject.Methods[$Method].Invoke($Request) - if ($Expand) { - $results += $result.$Expand - } - else { - $results += $result - } - if ($result.NextToken -and !$request.MaxResults) { - $Request.NextToken = $result.NextToken - $done = $false - } - else { - $done = $true - } - } - until ($done) - if (!$result) { - return - } - if ($results) { - return $results - } - elseif ($IsCoreCLR) { - if ($result.Result) { - return $result.Result - } - elseif ($result.Exception) { - return (New-VSError $result) - } - } - else { - return $result - } - } -} diff --git a/VaporShell/Private/ProcessRequest4.ps1 b/VaporShell/Private/ProcessRequest4.ps1 index 2ec43c18c..0bb2171cb 100644 --- a/VaporShell/Private/ProcessRequest4.ps1 +++ b/VaporShell/Private/ProcessRequest4.ps1 @@ -1,85 +1,75 @@ function ProcessRequest4 { <# .SYNOPSIS - Receives AWS SDK requests, then sends them to the appropriate processor function depending on PowerShell version, as PSv3 does not allow dot sourcing method names. + Receives AWS SDK requests and processes them using the AWS SDK for .NET. #> [cmdletbinding()] - Param + param ( - [parameter(Mandatory = $false,Position=0)] + [parameter(Mandatory = $false, Position = 0)] [String] $ParameterSetName, - [parameter(Mandatory = $false,Position=1)] + [parameter(Mandatory = $false, Position = 1)] [String] $ProfileName = $env:AWS_PROFILE, - [parameter(Mandatory = $true,Position=2)] + [parameter(Mandatory = $true, Position = 2)] [String] $Method, - [parameter(Mandatory = $true,Position=3)] + [parameter(Mandatory = $true, Position = 3)] $Request, - [parameter(Mandatory = $false,Position=4)] + [parameter(Mandatory = $false, Position = 4)] [String] $Expand ) - Process { + process { if (!$ProfileName) { - $ProfileName = "default" - $PSBoundParameters["ProfileName"] = "default" + $ProfileName = 'default' + $PSBoundParameters['ProfileName'] = 'default' } $results = @() try { $service = ($request.PSObject.TypeNames)[0].split('.')[1] - $sharedFile = New-Object Amazon.Runtime.CredentialManagement.SharedCredentialsFile -ErrorAction Stop - $matchedProfile = $sharedFile.ListProfiles() | Where-Object {$_.Name -eq $ProfileName} - if ($null -eq $matchedProfile) { - $creds = [Amazon.Runtime.FallbackCredentialsFactory]::GetCredentials() - $endPoint = if ([Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint()) { - [Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint() - } - else { - # Need to set a default if we can't resolve the region - Write-Warning "Unable to resolve target region! Defaulting to us-east-1 and continuing in 5 seconds." - Write-Warning "If you do not want to execute method [$Method] on service [$service] in this region," - Write-Warning "please set the environment variable 'AWS_REGION' or run the following to set a region" - Write-Warning "on the shared credential file:`n`n`tSet-VSCredential -ProfileName $ProfileName -Region " - Start-Sleep -Seconds 5 - [Amazon.RegionEndpoint]::USEast1 + + # Use CredentialProfileStoreChain (modern replacement for StoredProfileAWSCredentials) + $chain = New-Object Amazon.Runtime.CredentialManagement.CredentialProfileStoreChain -ErrorAction Stop + $creds = $null + $endPoint = $null + + if ($chain.TryGetAWSCredentials($ProfileName, [ref]$creds)) { + # Successfully resolved credentials from profile + $profile = $null + if ($chain.TryGetProfile($ProfileName, [ref]$profile) -and $profile.Region) { + $endPoint = $profile.Region } + } else { + # Fall back to default credential resolution (env vars, instance profile, etc.) + $creds = [Amazon.Runtime.FallbackCredentialsFactory]::GetCredentials() } - else { - $creds = New-Object Amazon.Runtime.StoredProfileAWSCredentials $ProfileName -ErrorAction Stop - $endPoint = if ($matchedProfile.Region) { - $matchedProfile.Region - } - elseif ([Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint()) { + + # Resolve region if not found from profile + if (-not $endPoint) { + $endPoint = if ([Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint()) { [Amazon.Runtime.FallbackRegionFactory]::GetRegionEndpoint() - } - else { - # Need to set a default if we can't resolve the region - Write-Warning "Unable to resolve target region! Defaulting to us-east-1 and continuing in 5 seconds." - Write-Warning "If you do not want to execute method [$Method] on service [$service] in this region," - Write-Warning "please set the environment variable 'AWS_REGION' or run the following to set a region" - Write-Warning "on the shared credential file:`n`n`tSet-VSCredential -ProfileName $ProfileName -Region " - Start-Sleep -Seconds 5 - [Amazon.RegionEndpoint]::USEast1 + } elseif ($env:AWS_REGION) { + [Amazon.RegionEndpoint]::GetBySystemName($env:AWS_REGION) + } elseif ($env:AWS_DEFAULT_REGION) { + [Amazon.RegionEndpoint]::GetBySystemName($env:AWS_DEFAULT_REGION) + } else { + Write-Warning 'Unable to resolve target region! Defaulting to eu-west-1.' + Write-Warning "Set the environment variable 'AWS_REGION' or use Set-VSCredential -ProfileName $ProfileName -Region " + [Amazon.RegionEndpoint]::EUWest1 } } + Write-Verbose "Building '$service' client in region '$($endPoint.DisplayName)' [$($endPoint.SystemName)]" - if ($endPoint) { - $client = New-Object "Amazon.$($service).Amazon$($service)Client" $creds,$endPoint -ErrorAction Stop - } - else { - return (New-VSError -String "No region set for profile '$ProfileName'! Please run the following to set a region:`n`nSet-VSCredential -ProfileName $ProfileName -Region ") - } - } - catch { + $client = New-Object "Amazon.$($service).Amazon$($service)Client" $creds, $endPoint -ErrorAction Stop + } catch { return (New-VSError -String "$($_.Exception.Message)") } - if ($client | Get-Member -MemberType Method -Name "$Method*" | Where-Object {$_.Name -eq "$($Method)Async"}) { + if ($client | Get-Member -MemberType Method -Name "$Method*" | Where-Object { $_.Name -eq "$($Method)Async" }) { $useAsync = $true Write-Verbose "Processing async request:`n$($PSBoundParameters | Format-Table -AutoSize | Out-String)" - } - else { + } else { $useAsync = $false Write-Verbose "Processing request:`n$($PSBoundParameters | Format-Table -AutoSize | Out-String)" } @@ -90,29 +80,24 @@ function ProcessRequest4 { $result = $client."$($Method)Async"($Request) if ($Expand) { $results += $result.Result.$Expand - } - else { + } else { $results += $result.Result } - } - else { + } else { $result = $client.$Method($Request) if ($Expand) { $results += $result.$Expand - } - else { + } else { $results += $result } } if ($result.Result.NextToken -and !$request.MaxResults) { $Request.NextToken = $result.Result.NextToken $done = $false - } - elseif ($result.NextToken -and !$request.MaxResults) { + } elseif ($result.NextToken -and !$request.MaxResults) { $Request.NextToken = $result.NextToken $done = $false - } - else { + } else { $done = $true } } @@ -122,16 +107,11 @@ function ProcessRequest4 { } if ($results) { return $results - } - elseif ($IsCoreCLR) { - if ($result.Result) { - return $result.Result - } - elseif ($result.Exception) { - return (New-VSError $result) - } - } - else { + } elseif ($result.Result) { + return $result.Result + } elseif ($result.Exception) { + return (New-VSError $result) + } else { return $result } } diff --git a/VaporShell/Private/PseudoParams.txt b/VaporShell/Private/PseudoParams.txt index bece4ea60..ca85cd652 100644 --- a/VaporShell/Private/PseudoParams.txt +++ b/VaporShell/Private/PseudoParams.txt @@ -6,4 +6,5 @@ AWS::Region AWS::StackName AWS::Include AWS::Partition -AWS::URLSuffix \ No newline at end of file +AWS::URLSuffix +AWS::LanguageExtensions diff --git a/VaporShell/Public/Export-Vaporshell.ps1 b/VaporShell/Public/Export-Vaporshell.ps1 index c7c3fe6f8..c5b92ed18 100644 --- a/VaporShell/Public/Export-Vaporshell.ps1 +++ b/VaporShell/Public/Export-Vaporshell.ps1 @@ -2,26 +2,26 @@ function Export-Vaporshell { <# .SYNOPSIS Exports the template object to JSON file. - + .DESCRIPTION Exports the template object to JSON file. Requires the Vaporshell input object to be type 'Vaporshell.Template' - + .PARAMETER VaporshellTemplate The input template object - + .PARAMETER As Specify JSON or YAML for your preferred output. Defaults to JSON. - **Important**: In order to use YAML, you must have cfn-flip installed: https://github.com/awslabs/aws-cfn-template-flip - + **Important**: In order to use YAML, you must have the powershell-yaml module installed: Install-Module powershell-yaml + .PARAMETER Path Path to save the resulting JSON file. - + .PARAMETER ValidateTemplate Validates the template using the AWS .NET SDK - + .PARAMETER Force Forces an overwrite if the Path already exists @@ -39,24 +39,23 @@ function Export-Vaporshell { Vaporshell #> [cmdletbinding()] - Param + param ( - [parameter(Mandatory = $true,Position = 0,ValueFromPipeline = $true)] + [parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] [ValidateScript( { if ($_.Resources) { $true - } - else { - $PSCmdlet.ThrowTerminatingError((New-VSError -String "Unable to find any resources on this Vaporshell template. Resources are required in CloudFormation templates at the minimum.")) + } else { + $PSCmdlet.ThrowTerminatingError((New-VSError -String 'Unable to find any resources on this Vaporshell template. Resources are required in CloudFormation templates at the minimum.')) } })] [PSTypeName('Vaporshell.Template')] $VaporshellTemplate, - [parameter(Mandatory = $false,Position = 1)] - [ValidateSet("JSON","YAML")] + [parameter(Mandatory = $false, Position = 1)] + [ValidateSet('JSON', 'YAML')] [System.String] - $As = "JSON", - [parameter(Mandatory = $false,Position = 2)] + $As = 'JSON', + [parameter(Mandatory = $false, Position = 2)] [System.String] $Path, [parameter(Mandatory = $false)] @@ -66,28 +65,43 @@ function Export-Vaporshell { [Switch] $Force ) - Begin { + begin { $ForcePref = @{} if ($Force) { - $ForcePref.add("Force",$True) + $ForcePref.add('Force', $True) } } - Process { - Write-Verbose "Converting template object to JSON" + process { + Write-Verbose 'Converting template object to JSON' $JSON = ConvertTo-Json -Depth 100 -InputObject $VaporshellTemplate -Verbose:$false | Format-Json } - End { - if ($As -eq "YAML") { + end { + if ($As -eq 'YAML') { if (Get-Command cfn-flip -ErrorAction SilentlyContinue) { - Write-Verbose "Converting JSON to YAML with cfn-flip" + Write-Verbose 'Converting JSON to YAML with cfn-flip' $Final = $JSON | cfn-flip - } - else { - Write-Warning "cfn-flip not found in PATH! Skipping conversion to YAML to prevent failure." + } elseif (Get-Module powershell-yaml -ListAvailable -ErrorAction SilentlyContinue) { + Import-Module powershell-yaml -ErrorAction SilentlyContinue + Write-Verbose 'Converting JSON to YAML with powershell-yaml (cfn-flip not found)' + $obj = $JSON | ConvertFrom-Json -Depth 100 + $Final = ConvertTo-Yaml -Data $obj + + # Post-process: restore .0 suffix on whole-number floats. + # ConvertTo-Yaml emits [double]60.0 as '60' (integer). + # CFN treats 60 vs 60.0 as a template diff, triggering resource replacement. + # Find "key": value.0 pairs in JSON, then fix those specific keys in YAML. + $keyFloatPattern = [regex]'"([^"]+)"\s*:\s*(\d+)\.0\b' + $keyFloatMatches = $keyFloatPattern.Matches(($JSON -join "`n")) + foreach ($m in $keyFloatMatches) { + $key = $m.Groups[1].Value + $val = $m.Groups[2].Value + $Final = $Final -replace "(?m)(${key}:\s+)${val}(\s*)$", "`${1}${val}.0`$2" + } + } else { + Write-Warning 'YAML conversion requires cfn-flip (pip install cfn-flip) or the powershell-yaml module (Install-Module powershell-yaml)' $Final = $JSON } - } - else { + } else { $Final = $JSON } if ($ValidateTemplate) { @@ -96,9 +110,8 @@ function Export-Vaporshell { if ($Path) { Write-Verbose "Exporting template to: $Path" $Final | Set-Content -Path $Path @ForcePref -Verbose:$false - } - else { + } else { return ($Final -join "`n") } } -} \ No newline at end of file +} diff --git a/VaporShell/Public/Import-Vaporshell.ps1 b/VaporShell/Public/Import-Vaporshell.ps1 index 374c2e7f5..5c18088c4 100644 --- a/VaporShell/Public/Import-Vaporshell.ps1 +++ b/VaporShell/Public/Import-Vaporshell.ps1 @@ -16,33 +16,35 @@ function Import-Vaporshell { Vaporshell #> [OutputType('Vaporshell.Template')] - [cmdletbinding(DefaultParameterSetName = "Path")] - Param + [cmdletbinding(DefaultParameterSetName = 'Path')] + param ( - [parameter(Mandatory = $true,Position = 0,ParameterSetName = "Path")] - [Alias("FullName")] - [ValidateScript( {Test-Path $_})] + [parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Path')] + [Alias('FullName')] + [ValidateScript( { Test-Path $_ })] [String] $Path, - [parameter(Mandatory = $true,Position = 0,ValueFromPipeline = $true,ParameterSetName = "TemplateBody")] + [parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ParameterSetName = 'TemplateBody')] [String] $TemplateBody, - [parameter(Mandatory = $true,Position = 0,ParameterSetName = "RawUrl")] + [parameter(Mandatory = $true, Position = 0, ParameterSetName = 'RawUrl')] [String] $RawUrl ) - if ($PSCmdlet.ParameterSetName -eq "Path") { + if ($PSCmdlet.ParameterSetName -eq 'Path') { $TemplateBody = [System.IO.File]::ReadAllText((Resolve-Path $Path)) - } - elseif ($PSCmdlet.ParameterSetName -eq "RawUrl") { + } elseif ($PSCmdlet.ParameterSetName -eq 'RawUrl') { $TemplateBody = (Invoke-WebRequest -Uri $RawUrl).Content } - if ($TemplateBody -match "Resources:") { - if (Get-Command cfn-flip -ErrorAction SilentlyContinue) { + if ($TemplateBody -match 'Resources:') { + if (Get-Module powershell-yaml -ListAvailable -ErrorAction SilentlyContinue) { + Import-Module powershell-yaml -ErrorAction SilentlyContinue + $yamlObj = ConvertFrom-Yaml -Yaml $TemplateBody + $TemplateBody = $yamlObj | ConvertTo-Json -Depth 100 + } elseif (Get-Command cfn-flip -ErrorAction SilentlyContinue) { $TemplateBody = ($TemplateBody | cfn-flip) - } - else { - $PSCmdlet.ThrowTerminatingError((New-VSError -String "Template appears to be YAML but cfn-flip is not found in PATH. Unable to convert to JSON to import into Powershell. Please install cfn-flip then restart this console.")) + } else { + $PSCmdlet.ThrowTerminatingError((New-VSError -String 'Template appears to be YAML but neither powershell-yaml module nor cfn-flip is available. Install with: Install-Module powershell-yaml')) } } $tempObj = ConvertFrom-Json -InputObject $TemplateBody -Verbose:$false @@ -50,11 +52,11 @@ function Import-Vaporshell { $tempObj = $tempObj.TemplateBody } $toString = { - Process { + process { $params = @{ VaporshellTemplate = $this - As = 'JSON' - Force = $true + As = 'JSON' + Force = $true } if ($args[0]) { $params['Verbose'] = $args[0] @@ -63,18 +65,18 @@ function Import-Vaporshell { } } $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "ToString" + Name = 'ToString' Value = $toString } Add-Member @memberParam -Force $toJSON = { - Process { + process { $params = @{ VaporshellTemplate = $this - As = 'JSON' - Force = $true + As = 'JSON' + Force = $true } if ($args[0]) { $params['Path'] = $args[0] @@ -86,18 +88,18 @@ function Import-Vaporshell { } } $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "ToJSON" + Name = 'ToJSON' Value = $toJSON } Add-Member @memberParam $toYAML = { - Process { + process { $params = @{ VaporshellTemplate = $this - As = 'YAML' - Force = $true + As = 'YAML' + Force = $true } if ($args[0]) { $params['Path'] = $args[0] @@ -109,14 +111,14 @@ function Import-Vaporshell { } } $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "ToYAML" + Name = 'ToYAML' Value = $toYAML } Add-Member @memberParam $validate = { - Process { + process { $params = @{ TemplateBody = $this.ToJSON() } @@ -130,16 +132,16 @@ function Import-Vaporshell { } } $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "Validate" + Name = 'Validate' Value = $validate } Add-Member @memberParam $addMetadata = { - Process { - $ObjName = "Metadata" - $allowedTypes = "Vaporshell.Transform","Vaporshell.Metadata" + process { + $ObjName = 'Metadata' + $allowedTypes = 'Vaporshell.Transform', 'Vaporshell.Metadata' foreach ($obj in $args) { if ([string]$($obj.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { if ( -not ($this.$ObjName)) { @@ -147,17 +149,16 @@ function Import-Vaporshell { $this | Add-Member -MemberType NoteProperty -Name "$ObjName" -Value ([PSCustomObject]@{}) } Add-Member -InputObject $this.$ObjName -MemberType NoteProperty -Name $($obj.LogicalID) -Value $($obj.Props) - } - else { - throw "You must use one of the following object types with this parameter: $($allowedTypes -join ", ")" + } else { + throw "You must use one of the following object types with this parameter: $($allowedTypes -join ', ')" } } } } $addParameter = { - Process { - $ObjName = "Parameters" - $allowedTypes = "Vaporshell.Parameter" + process { + $ObjName = 'Parameters' + $allowedTypes = 'Vaporshell.Parameter' foreach ($obj in $args) { if ([string]$($obj.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { if ( -not ($this.$ObjName)) { @@ -165,17 +166,16 @@ function Import-Vaporshell { $this | Add-Member -MemberType NoteProperty -Name "$ObjName" -Value ([PSCustomObject]@{}) } Add-Member -InputObject $this.$ObjName -MemberType NoteProperty -Name $($obj.LogicalID) -Value $($obj.Props) - } - else { - throw "You must use one of the following object types with this parameter: $($allowedTypes -join ", ")" + } else { + throw "You must use one of the following object types with this parameter: $($allowedTypes -join ', ')" } } } } $addMapping = { - Process { - $ObjName = "Mappings" - $allowedTypes = "Vaporshell.Transform","Vaporshell.Mapping" + process { + $ObjName = 'Mappings' + $allowedTypes = 'Vaporshell.Transform', 'Vaporshell.Mapping' foreach ($obj in $args) { if ([string]$($obj.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { if ( -not ($this.$ObjName)) { @@ -183,17 +183,16 @@ function Import-Vaporshell { $this | Add-Member -MemberType NoteProperty -Name "$ObjName" -Value ([PSCustomObject]@{}) } Add-Member -InputObject $this.$ObjName -MemberType NoteProperty -Name $($obj.LogicalID) -Value $($obj.Props) - } - else { - throw "You must use one of the following object types with this parameter: $($allowedTypes -join ", ")" + } else { + throw "You must use one of the following object types with this parameter: $($allowedTypes -join ', ')" } } } } $addCondition = { - Process { - $ObjName = "Conditions" - $allowedTypes = "Vaporshell.Transform","Vaporshell.Condition" + process { + $ObjName = 'Conditions' + $allowedTypes = 'Vaporshell.Transform', 'Vaporshell.Condition' foreach ($obj in $args) { if ([string]$($obj.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { if ( -not ($this.$ObjName)) { @@ -201,24 +200,22 @@ function Import-Vaporshell { $this | Add-Member -MemberType NoteProperty -Name "$ObjName" -Value ([PSCustomObject]@{}) } Add-Member -InputObject $this.$ObjName -MemberType NoteProperty -Name $($obj.LogicalID) -Value $($obj.Props) - } - else { - throw "You must use one of the following object types with this parameter: $($allowedTypes -join ", ")" + } else { + throw "You must use one of the following object types with this parameter: $($allowedTypes -join ', ')" } } } } $addResource = { - Process { - $ObjName = "Resources" - $allowedTypes = "Vaporshell.Transform","Vaporshell.Resource" + process { + $ObjName = 'Resources' + $allowedTypes = 'Vaporshell.Transform', 'Vaporshell.Resource' foreach ($obj in $args) { - if ($obj.Props.Type -like "AWS::Serverless*" -and $this.Transform -ne "AWS::Serverless-2016-10-31") { + if ($obj.Props.Type -like 'AWS::Serverless*' -and $this.Transform -ne 'AWS::Serverless-2016-10-31') { if ( -not ($this.Transform)) { - $this | Add-Member -MemberType NoteProperty -Name Transform -Value "AWS::Serverless-2016-10-31" - } - else { - $this.Transform = "AWS::Serverless-2016-10-31" + $this | Add-Member -MemberType NoteProperty -Name Transform -Value 'AWS::Serverless-2016-10-31' + } else { + $this.Transform = 'AWS::Serverless-2016-10-31' } } if ([string]$($obj.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { @@ -227,17 +224,16 @@ function Import-Vaporshell { $this | Add-Member -MemberType NoteProperty -Name "$ObjName" -Value ([PSCustomObject]@{}) } Add-Member -InputObject $this.$ObjName -MemberType NoteProperty -Name $($obj.LogicalID) -Value $($obj.Props) - } - else { - throw "You must use one of the following object types with this parameter: $($allowedTypes -join ", ")" + } else { + throw "You must use one of the following object types with this parameter: $($allowedTypes -join ', ')" } } } } $addOutput = { - Process { - $ObjName = "Outputs" - $allowedTypes = "Vaporshell.Transform","Vaporshell.Output" + process { + $ObjName = 'Outputs' + $allowedTypes = 'Vaporshell.Transform', 'Vaporshell.Output' foreach ($obj in $args) { if ([string]$($obj.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { if ( -not ($this.$ObjName)) { @@ -245,84 +241,81 @@ function Import-Vaporshell { $this | Add-Member -MemberType NoteProperty -Name "$ObjName" -Value ([PSCustomObject]@{}) } Add-Member -InputObject $this.$ObjName -MemberType NoteProperty -Name $($obj.LogicalID) -Value $($obj.Props) - } - else { - throw "You must use one of the following object types with this parameter: $($allowedTypes -join ", ")" + } else { + throw "You must use one of the following object types with this parameter: $($allowedTypes -join ', ')" } } } } $addTransform = { - Process { - $ObjName = "Transform" - $allowedTypes = "Vaporshell.Transform.Include" + process { + $ObjName = 'Transform' + $allowedTypes = 'Vaporshell.Transform.Include' foreach ($obj in $args) { if ([string]$($obj.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { if ( -not ($this.$ObjName)) { $this | Add-Member -MemberType NoteProperty -Name "$ObjName" -Value $($obj.Props) - } - else { + } else { throw "There is already a $ObjName property on this object!" } - } - else { - throw "You must use one of the following object types with this parameter: $($allowedTypes -join ", ")" + } else { + throw "You must use one of the following object types with this parameter: $($allowedTypes -join ', ')" } } } } $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "AddMetadata" + Name = 'AddMetadata' Value = $addMetadata } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "AddParameter" + Name = 'AddParameter' Value = $addParameter } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "AddMapping" + Name = 'AddMapping' Value = $addMapping } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "AddCondition" + Name = 'AddCondition' Value = $addCondition } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "AddResource" + Name = 'AddResource' Value = $addResource } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "AddOutput" + Name = 'AddOutput' Value = $addOutput } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "AddTransform" + Name = 'AddTransform' Value = $addTransform } Add-Member @memberParam $remMetadata = { - Process { - $ObjName = "Metadata" + process { + $ObjName = 'Metadata' foreach ($LogicalID in $args) { $this.$ObjName.PSObject.Properties.Remove($LogicalID) if ([string]::IsNullOrWhiteSpace($($this.$ObjName.PSObject.Properties | Out-String))) { @@ -333,8 +326,8 @@ function Import-Vaporshell { } } $remParameter = { - Process { - $ObjName = "Parameters" + process { + $ObjName = 'Parameters' foreach ($LogicalID in $args) { $this.$ObjName.PSObject.Properties.Remove($LogicalID) if ([string]::IsNullOrWhiteSpace($($this.$ObjName.PSObject.Properties | Out-String))) { @@ -345,8 +338,8 @@ function Import-Vaporshell { } } $remMapping = { - Process { - $ObjName = "Mappings" + process { + $ObjName = 'Mappings' foreach ($LogicalID in $args) { $this.$ObjName.PSObject.Properties.Remove($LogicalID) if ([string]::IsNullOrWhiteSpace($($this.$ObjName.PSObject.Properties | Out-String))) { @@ -357,8 +350,8 @@ function Import-Vaporshell { } } $remCondition = { - Process { - $ObjName = "Conditions" + process { + $ObjName = 'Conditions' foreach ($LogicalID in $args) { $this.$ObjName.PSObject.Properties.Remove($LogicalID) if ([string]::IsNullOrWhiteSpace($($this.$ObjName.PSObject.Properties | Out-String))) { @@ -369,8 +362,8 @@ function Import-Vaporshell { } } $remResource = { - Process { - $ObjName = "Resources" + process { + $ObjName = 'Resources' foreach ($LogicalID in $args) { $this.$ObjName.PSObject.Properties.Remove($LogicalID) if ([string]::IsNullOrWhiteSpace($($this.$ObjName.PSObject.Properties | Out-String))) { @@ -381,8 +374,8 @@ function Import-Vaporshell { } } $remOutput = { - Process { - $ObjName = "Outputs" + process { + $ObjName = 'Outputs' foreach ($LogicalID in $args) { $this.$ObjName.PSObject.Properties.Remove($LogicalID) if ([string]::IsNullOrWhiteSpace($($this.$ObjName.PSObject.Properties | Out-String))) { @@ -393,57 +386,57 @@ function Import-Vaporshell { } } $remTransform = { - $ObjName = "Transform" + $ObjName = 'Transform' if ($this.$ObjName) { $this.PSObject.Properties.Remove($ObjName) } } $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "RemoveMetadata" + Name = 'RemoveMetadata' Value = $remMetadata } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "RemoveParameter" + Name = 'RemoveParameter' Value = $remParameter } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "RemoveMapping" + Name = 'RemoveMapping' Value = $remMapping } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "RemoveCondition" + Name = 'RemoveCondition' Value = $remCondition } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "RemoveResource" + Name = 'RemoveResource' Value = $remResource } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "RemoveOutput" + Name = 'RemoveOutput' Value = $remOutput } Add-Member @memberParam $memberParam = @{ - MemberType = "ScriptMethod" + MemberType = 'ScriptMethod' InputObject = $tempObj - Name = "RemoveTransform" + Name = 'RemoveTransform' Value = $remTransform } Add-Member @memberParam diff --git a/VaporShell/Public/Intrinsic Functions/Add-FnLength.ps1 b/VaporShell/Public/Intrinsic Functions/Add-FnLength.ps1 new file mode 100644 index 000000000..6ba5a76b7 --- /dev/null +++ b/VaporShell/Public/Intrinsic Functions/Add-FnLength.ps1 @@ -0,0 +1,50 @@ +function Add-FnLength { + <# + .SYNOPSIS + Adds the intrinsic function "Fn::Length" to a resource property + + .DESCRIPTION + The intrinsic function Fn::Length returns the number of elements within an array or the number of characters in a string. + + .LINK + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-length.html + + .PARAMETER Object + The array or string for which you want to get the length. + + .EXAMPLE + Add-FnLength -Object (Add-FnSplit -Delimiter "," -SourceString "a,b,c") + + When the template is exported, this will convert to: {"Fn::Length":{"Fn::Split":[",","a,b,c"]}} + + .NOTES + You can use the following functions in the Fn::Length function: + Fn::Split + Fn::GetAZs + Ref + + .FUNCTIONALITY + Vaporshell + #> + [OutputType('Vaporshell.Function.Length')] + [cmdletbinding()] + Param + ( + [parameter(Mandatory = $true,Position = 0)] + [ValidateScript({ + $allowedTypes = "Vaporshell.Function.Split","Vaporshell.Function.GetAZs","Vaporshell.Function.Ref","System.String","System.Object[]" + if ([string]$($_.PSTypeNames) -match "($(($allowedTypes|ForEach-Object{[RegEx]::Escape($_)}) -join '|'))") { + $true + } + else { + $PSCmdlet.ThrowTerminatingError((New-VSError -String "This parameter only accepts the following types: $($allowedTypes -join ", "). The current types of the value are: $($_.PSTypeNames -join ", ").")) + } + })] + $Object + ) + $obj = [PSCustomObject][Ordered]@{ + "Fn::Length" = $Object + } + $obj | Add-ObjectDetail -TypeName 'Vaporshell.Function','Vaporshell.Function.Length' + Write-Verbose "Resulting JSON from $($MyInvocation.MyCommand): `n`n`t$($obj | ConvertTo-Json -Depth 10 -Compress)`n" +} diff --git a/VaporShell/Public/Intrinsic Functions/Add-FnToJsonString.ps1 b/VaporShell/Public/Intrinsic Functions/Add-FnToJsonString.ps1 new file mode 100644 index 000000000..866be9aba --- /dev/null +++ b/VaporShell/Public/Intrinsic Functions/Add-FnToJsonString.ps1 @@ -0,0 +1,38 @@ +function Add-FnToJsonString { + <# + .SYNOPSIS + Adds the intrinsic function "Fn::ToJsonString" to a resource property + + .DESCRIPTION + The intrinsic function Fn::ToJsonString converts an object or array to its corresponding JSON string. + + .LINK + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-ToJsonString.html + + .PARAMETER Object + The object or array to convert to a JSON string. + + .EXAMPLE + Add-FnToJsonString -Object @{key1 = "value1"; key2 = "value2"} + + When the template is exported, this will convert to: {"Fn::ToJsonString":{"key1":"value1","key2":"value2"}} + + .NOTES + You can use any intrinsic function within Fn::ToJsonString. + + .FUNCTIONALITY + Vaporshell + #> + [OutputType('Vaporshell.Function.ToJsonString')] + [cmdletbinding()] + Param + ( + [parameter(Mandatory = $true,Position = 0)] + $Object + ) + $obj = [PSCustomObject][Ordered]@{ + "Fn::ToJsonString" = $Object + } + $obj | Add-ObjectDetail -TypeName 'Vaporshell.Function','Vaporshell.Function.ToJsonString' + Write-Verbose "Resulting JSON from $($MyInvocation.MyCommand): `n`n`t$($obj | ConvertTo-Json -Depth 10 -Compress)`n" +} diff --git a/VaporShell/Public/Primary Functions/New-VaporResource.ps1 b/VaporShell/Public/Primary Functions/New-VaporResource.ps1 index d2c0f2d0a..3d35d78bd 100644 --- a/VaporShell/Public/Primary Functions/New-VaporResource.ps1 +++ b/VaporShell/Public/Primary Functions/New-VaporResource.ps1 @@ -140,7 +140,7 @@ function New-VaporResource { [System.String] $DeletionPolicy, [parameter(Mandatory = $false)] - [ValidateSet("Delete","Retain","Snapshot","RetainExceptOnCreate")] + [ValidateSet("Delete","Retain","Snapshot")] [System.String] $UpdateReplacePolicy, [parameter(Mandatory = $false,Position = 5)] diff --git a/VaporShell/Public/SDK Wrappers/Invoke-VSPackage.ps1 b/VaporShell/Public/SDK Wrappers/Invoke-VSPackage.ps1 index ed8915430..fb5598d3f 100644 --- a/VaporShell/Public/SDK Wrappers/Invoke-VSPackage.ps1 +++ b/VaporShell/Public/SDK Wrappers/Invoke-VSPackage.ps1 @@ -2,53 +2,53 @@ function Invoke-VSPackage { <# .SYNOPSIS Packages the local artifacts (local paths) that your AWS CloudFormation template references. - + .DESCRIPTION Packages the local artifacts (local paths) that your AWS CloudFormation template references. The command uploads local artifacts, such as source code for an AWS Lambda function or a Swagger file for an AWS API Gateway REST API, to an S3 bucket. The command returns a copy of your template, replacing references to local artifacts with the S3 location where the command uploaded the artifacts. - + .PARAMETER TemplateBody A JSON or YAML string containing the template body. - + .PARAMETER TemplateFile The path to the local file containing the template. - + .PARAMETER S3Bucket The name of the S3 bucket where this command uploads the artifacts that are referenced in your template. - + .PARAMETER S3Prefix A prefix name that the command adds to the artifacts' name when it uploads them to the S3 bucket. The prefix name is a path name (folder name) for the S3 bucket. - + .PARAMETER KMSKeyId The ID of an AWS KMS key that the command uses to encrypt artifacts that are at rest in the S3 bucket. - + .PARAMETER OutputTemplateFile The path to the file where the command writes the output AWS CloudFormation template. If you don't specify a path, the command writes the template to the standard output. - + .PARAMETER UseJson - Indicates whether to use JSON as the format for the output AWS CloudFormation template. YAML is used by default (if cfn-flip is available). - + Indicates whether to use JSON as the format for the output AWS CloudFormation template. YAML is used by default. + .PARAMETER Force Indicates whether to override existing files in the S3 bucket. Specify this flag to upload artifacts even if they match existing artifacts in the S3 bucket. - + .PARAMETER ProfileName The name of the configuration profile to deploy the stack with. Defaults to $env:AWS_PROFILE, if set. - + .FUNCTIONALITY Vaporshell #> - [cmdletbinding(DefaultParameterSetName = "TemplateFile")] - Param + [cmdletbinding(DefaultParameterSetName = 'TemplateFile')] + param ( - [parameter(Mandatory = $true,Position = 0,ParameterSetName = "TemplateBody",ValueFromPipeline = $true)] + [parameter(Mandatory = $true, Position = 0, ParameterSetName = 'TemplateBody', ValueFromPipeline = $true)] [String] $TemplateBody, - [parameter(Mandatory = $true,Position = 0,ParameterSetName = "TemplateFile")] + [parameter(Mandatory = $true, Position = 0, ParameterSetName = 'TemplateFile')] [ValidateScript( { Test-Path $_ })] [String] $TemplateFile, - [parameter(Mandatory = $true,Position = 1)] + [parameter(Mandatory = $true, Position = 1)] [String] $S3Bucket, [parameter(Mandatory = $false)] @@ -64,57 +64,54 @@ function Invoke-VSPackage { [Switch] $UseJson, [parameter(Mandatory = $false)] - [Alias("ForceUpload")] + [Alias('ForceUpload')] [Switch] $Force, [parameter(Mandatory = $false)] [String] $ProfileName = $env:AWS_PROFILE ) - Begin { + begin { $prof = @{} if ($ProfileName) { - $prof["ProfileName"] = $ProfileName + $prof['ProfileName'] = $ProfileName } $typeHash = @{ - "AWS::ApiGateway::RestApi" = "BodyS3Location" - "AWS::Lambda::Function" = "Code" - "AWS::Serverless::Function" = "CodeUri" - "AWS::Serverless::Api" = "DefinitionUri" - "AWS::ElasticBeanstalk::ApplicationVersion" = "SourceBundle" - "AWS::CloudFormation::Stack" = "TemplateUrl" + 'AWS::ApiGateway::RestApi' = 'BodyS3Location' + 'AWS::Lambda::Function' = 'Code' + 'AWS::Serverless::Function' = 'CodeUri' + 'AWS::Serverless::Api' = 'DefinitionUri' + 'AWS::ElasticBeanstalk::ApplicationVersion' = 'SourceBundle' + 'AWS::CloudFormation::Stack' = 'TemplateUrl' } $s3Params = @{} if ($S3Prefix) { - $s3Params["BucketName"] = "$($S3Bucket)/$($S3Prefix)" + $s3Params['BucketName'] = "$($S3Bucket)/$($S3Prefix)" $baseUrl = "$($S3Bucket)/$($S3Prefix)" - } - else { - $s3Params["BucketName"] = $S3Bucket + } else { + $s3Params['BucketName'] = $S3Bucket $baseUrl = $($S3Bucket) } if ($KMSKeyId) { - $s3Params["KMSKeyId"] = $KMSKeyId + $s3Params['KMSKeyId'] = $KMSKeyId } try { Write-Verbose "Checking if bucket '$S3Bucket' exists" $bucketLoc = Get-VSS3BucketLocation -BucketName "$S3Bucket" @prof -Verbose:$false Write-Verbose "Bucket '$S3Bucket' found in $($bucketLoc.Value)" - } - catch { + } catch { if ($Force) { Write-Verbose "Creating new bucket '$S3Bucket'" New-VSS3Box -BucketName "$S3Bucket" @prof -Verbose:$false - } - else { + } else { $PSCmdlet.ThrowTerminatingError($_) } } } - Process { - Add-Type -AssemblyName "System.IO.Compression.Filesystem" - if ($PSCmdlet.ParameterSetName -eq "TemplateFile") { - Write-Verbose "Getting TemplateBody from TemplateFile path" + process { + Add-Type -AssemblyName 'System.IO.Compression.Filesystem' + if ($PSCmdlet.ParameterSetName -eq 'TemplateFile') { + Write-Verbose 'Getting TemplateBody from TemplateFile path' $templateFilePath = (Resolve-Path $TemplateFile).Path $tempParent = (Get-Item $templateFilePath).Directory.FullName $TemplateBody = [System.IO.File]::ReadAllText($templateFilePath) @@ -127,22 +124,19 @@ function Invoke-VSPackage { $propName = $typeHash["$($Resource.Type)"] if ($propName -and $Resource.Properties.$propName) { Write-Verbose "Checking '$($Resource.Type)' resource --- property '$($propName)'" - if (($Resource.Properties.$propName -notlike "s3://*") -and ($Resource.Properties.$propName -notlike "http*")) { + if (($Resource.Properties.$propName -notlike 's3://*') -and ($Resource.Properties.$propName -notlike 'http*')) { $found = $true if (Test-Path ("$tempParent\$($Resource.Properties.$propName.TrimStart('.'))")) { $filePath = (Resolve-Path "$tempParent\$($Resource.Properties.$propName.TrimStart('.'))").Path Write-Verbose "File found in template directory at: $filePath" - } - elseif (Test-Path $Resource.Properties.$propName) { + } elseif (Test-Path $Resource.Properties.$propName) { $filePath = (Resolve-Path $Resource.Properties.$propName).Path if ($filePath -like "$($pwd.Path)*") { Write-Verbose "File found in current working directory at: $filePath" - } - else { + } else { Write-Verbose "File found at: $filePath" } - } - else { + } else { $found = $false } if ($found) { @@ -150,69 +144,60 @@ function Invoke-VSPackage { if ($fileInfo.PSIsContainer) { if ($S3Prefix) { $key = "$($S3Prefix)/$($fileInfo.BaseName).zip" - } - else { + } else { $key = "$($fileInfo.BaseName).zip" } $outFile = Join-Path $fileInfo.Parent.FullName $key if (Test-Path $outFile) { Remove-Item $outFile -Force } - [System.IO.Compression.Zipfile]::CreateFromDirectory($filePath,$outFile) - } - else { + [System.IO.Compression.Zipfile]::CreateFromDirectory($filePath, $outFile) + } else { if ($S3Prefix) { $key = "$($S3Prefix)/$($fileInfo.Name)" - } - else { + } else { $key = "$($fileInfo.Name)" } $outFile = $filePath } if ($Force) { - Write-Verbose "Uploading object!" + Write-Verbose 'Uploading object!' $obj = New-VSS3Object -Key $key -FilePath $outFile @s3Params @prof -Verbose:$false - } - else { - Write-Verbose "Checking if object exists in bucket" + } else { + Write-Verbose 'Checking if object exists in bucket' $existsMeta = Get-VSS3ObjectMetadata -BucketName $baseUrl -Key $key -ErrorAction SilentlyContinue -Verbose:$false if (!$existsMeta) { - Write-Verbose "Object not found -- uploading!" + Write-Verbose 'Object not found -- uploading!' $obj = New-VSS3Object -Key $key -FilePath $outFile @s3Params @prof -Verbose:$false - } - elseif ($existsMeta.ContentLength -eq (Get-Item $outFile).Length) { + } elseif ($existsMeta.ContentLength -eq (Get-Item $outFile).Length) { Write-Warning "Object '$key' already exists in bucket and is the same size. No action apparently necessary -- If this file needs to be reuploaded, re-run this command with the Force parameter included." return - } - else { - Write-Warning "Object already exists at this location and Force parameter not used. No action taken to prevent accidental overwrites. -- If this object needs to be overwritten, re-run this command with the Force parameter included." + } else { + Write-Warning 'Object already exists at this location and Force parameter not used. No action taken to prevent accidental overwrites. -- If this object needs to be overwritten, re-run this command with the Force parameter included.' return } } $Resource.Properties.$propName = "s3://$baseUrl/$key" - } - else { + } else { Write-Warning "$propName value '$($Resource.Properties.$propName)' does not appear to be an S3 URL but is also not locatable in the current working directory or the direct of the template (if provided). Please specify the full path of the local $propName to upload to the S3 bucket '$baseUrl'" } } } $tempPSON.Resources.$res = $Resource - } - catch { + } catch { $PSCmdlet.ThrowTerminatingError($_) } } $finalParams = @{} if ($OutputTemplateFile) { - $finalParams["Path"] = $OutputTemplateFile + $finalParams['Path'] = $OutputTemplateFile } if ($UseJson) { - $finalParams["As"] = "JSON" - } - else { - $finalParams["As"] = "YAML" + $finalParams['As'] = 'JSON' + } else { + $finalParams['As'] = 'YAML' } - Write-Verbose "Exporting resolved template" - Export-Vaporshell -VaporshellTemplate $tempPSON @finalParams -Force + Write-Verbose 'Exporting resolved template' + Export-Vaporshell -VaporshellTemplate $tempPSON @finalParams -Force } -} \ No newline at end of file +} diff --git a/VaporShell/VaporShell.psd1 b/VaporShell/VaporShell.psd1 index 5ba5c3e24..03a82f56a 100644 --- a/VaporShell/VaporShell.psd1 +++ b/VaporShell/VaporShell.psd1 @@ -9,43 +9,36 @@ @{ # Script module or binary module file associated with this manifest. - RootModule = 'VaporShell.psm1' + RootModule = 'VaporShell.psm1' # Version number of this module. # NB do not change this in ECP when rebuilding only without making manual code changes in the repository. # Date will be appended to the PS module version automatically as part of the build process - ModuleVersion = '2.16.0' + ModuleVersion = '2.18.0' # ID used to uniquely identify this module - GUID = 'd526494c-6e59-41ff-ad05-eedbc1473b6a' + GUID = 'd526494c-6e59-41ff-ad05-eedbc1473b6a' # Author of this module - Author = 'Nate Ferrell' + Author = 'Nate Ferrell' # Company or vendor of this module - CompanyName = 'SCRT HQ' + CompanyName = 'SCRT HQ' # Copyright statement for this module - Copyright = '(c) SCRT HQ 2017 . All rights reserved.' + Copyright = '(c) 2017 Nate Ferrell / SCRT HQ. All rights reserved. Modifications (c) 2024 ITV.' # Description of the functionality provided by this module - Description = "A PowerShell module for building, packaging and deploying AWS CloudFormation templates + Description = 'A PowerShell module for building, packaging and deploying AWS CloudFormation templates Prerequisites -- PowerShell 3+ - - On Linux or macOS? Grab PowerShell 6 here: https://github.com/powershell/powershell#get-powershell -- .NET 4.7.2+ OR .netstandard 1.3+ - - if you have PowerShell 4 or greater, you're covered! - -For further information, please checkout the README on the GitHub page and the module website: - -Readme: https://github.com/scrthq/VaporShell/blob/master/README.md -Website: https://vaporshell.io/ -" +- PowerShell 5.1+ (PowerShell 7+ recommended) +- .netstandard 2.0+ +' # Minimum version of the Windows PowerShell engine required by this module - PowerShellVersion = '3.0' + PowerShellVersion = '5.1' # Name of the Windows PowerShell host required by this module # PowerShellHostName = '' @@ -53,44 +46,44 @@ Website: https://vaporshell.io/ # Minimum version of the Windows PowerShell host required by this module # PowerShellHostVersion = '' - # Minimum version of Microsoft .NET Framework required by this module - DotNetFrameworkVersion = '4.7.2' + # Minimum version of Microsoft .NET Framework required by this module (Windows PowerShell only) + # DotNetFrameworkVersion = '4.7.2' # Minimum version of the common language runtime (CLR) required by this module # CLRVersion = '' # Processor architecture (None, X86, Amd64) required by this module - ProcessorArchitecture = 'None' + ProcessorArchitecture = 'None' # Modules that must be imported into the global environment prior to importing this module - RequiredModules = @() + RequiredModules = @() # Assemblies that must be loaded prior to importing this module - RequiredAssemblies = @() + RequiredAssemblies = @() # Script files (.ps1) that are run in the caller's environment prior to importing this module. - ScriptsToProcess = @() + ScriptsToProcess = @() # Type files (.ps1xml) to be loaded when importing this module - TypesToProcess = @() + TypesToProcess = @() # Format files (.ps1xml) to be loaded when importing this module - FormatsToProcess = @() + FormatsToProcess = @() # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess # NestedModules = @('VaporShell.DSL.psm1') # Functions to export from this module - FunctionsToExport = '*' + FunctionsToExport = '*' # Cmdlets to export from this module - CmdletsToExport = @() + CmdletsToExport = @() # Variables to export from this module - VariablesToExport = '*' + VariablesToExport = '*' # Aliases to export from this module - AliasesToExport = '*' + AliasesToExport = '*' # DSC resources to export from this module # DscResourcesToExport = @() @@ -99,10 +92,10 @@ Website: https://vaporshell.io/ # ModuleList = @() # List of all files packaged with this module - FileList = @() + FileList = @() # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. - PrivateData = @{ + PrivateData = @{ PSData = @{ @@ -110,13 +103,13 @@ Website: https://vaporshell.io/ Tags = 'AWS', 'CloudFormation', 'CFN', 'DevOps', 'Automation', 'JSON', 'YAML', 'IaC', 'InfrastructureAsCode', 'PSEdition_Core', 'PSEdition_Desktop', 'Windows', 'Mac', 'Linux' # A URL to the license for this module. - LicenseUri = 'https://github.com/scrthq/VaporShell/blob/master/LICENSE' + LicenseUri = 'https://github.com/ITV/VaporShell/blob/main/LICENSE' # A URL to the main website for this project. - ProjectUri = 'https://github.com/scrthq/VaporShell' + ProjectUri = 'https://github.com/ITV/VaporShell' # A URL to an icon representing this module. - IconUri = 'https://spotinst.com/app/themes/spotinst-theme/dist/images/features/elastigroup/intro/icons/cloudformation.svg' + # IconUri = '' # ReleaseNotes of this module # ReleaseNotes = '' diff --git a/build.ps1 b/build.ps1 index 16e0baa4f..5a13bb200 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -Param( +param( # Process-specific parameters [Parameter()] [string] @@ -7,17 +7,15 @@ Param( [Parameter()] [hashtable] $Dependencies = @{ - Configuration = '1.3.1' - PackageManagement = '1.3.1' - PowerShellGet = '2.1.2' - InvokeBuild = '5.5.2' + Configuration = '1.3.1' + InvokeBuild = '5.5.2' }, [Parameter()] [Switch] $NoUpdate, #region: Invoke-Build parameters [Parameter()] - [ValidateSet('Init','Clean','Build','DotnetOnly','Test','Analyze','Deploy','Full')] + [ValidateSet('Init', 'Clean', 'Build', 'DotnetOnly', 'Test', 'Analyze', 'Deploy', 'Full')] [string[]] $Task, [Parameter()] @@ -31,34 +29,9 @@ Param( $Summary #endregion: Invoke-Build parameters ) -#region: Import Azure Pipeline Helper functions from Gist or cached version if already pulled. -# Gist is specified via Commit SHA so future Gist updates cannot introduce breaking changes to -# scripts pinned to the specific commit. -$helperUri = @( - 'https://gist.githubusercontent.com' - 'scrthq' # User - 'a99cc06e75eb31769d01b2adddc6d200' # Gist ID - 'raw' - '958909a13527fa8c345b6bb552a737b0d9862bc0' # Commit SHA - 'AzurePipelineHelpers.ps1' # Filename -) -join '/' -$fileUri = $helperUri -replace "[$([RegEx]::Escape("$(([System.IO.Path]::GetInvalidFileNameChars() + [System.IO.Path]::GetInvalidPathChars()) -join '')"))]","_" -$ciPath = [System.IO.Path]::Combine($PSScriptRoot,'ci') -$localGistPath = [System.IO.Path]::Combine($ciPath,$fileUri) -if (Test-Path $localGistPath) { - Write-Host -ForegroundColor Cyan "##[section] Importing Azure Pipelines Helper from Cached Gist: $localGistPath" - $helperContent = Get-Content $localGistPath -Raw -} -else { - Write-Host -ForegroundColor Cyan "##[section] Cleaning out stale Gist scripts from the CI Path" - Get-ChildItem $ciPath -Filter 'https___gist.githubusercontent.com_scrthq*.ps1' | Remove-Item -Force - Write-Host -ForegroundColor Cyan "##[section] Importing Azure Pipelines Helper from Gist: $helperUri" - $helperContent = Invoke-RestMethod -Uri $helperUri - $helperContent | Set-Content $localGistPath -Force -} -. $localGistPath $ModuleName -Set-BuildVariables -#endregion + +# Import build helper functions +. (Join-Path $PSScriptRoot 'ci/BuildHelpers.ps1') Add-Heading "Setting PSGallery InstallationPolicy to 'Trusted'" if ((Get-PSRepository -Name PSGallery).InstallationPolicy -ne 'Trusted') { @@ -77,7 +50,7 @@ $PSDefaultParameterValues = @{ 'Install-Module:Scope' = 'CurrentUser' 'Install-Module:SkipPublisherCheck' = $true } -Add-Heading "Resolving module dependencies" +Add-Heading 'Resolving module dependencies' $moduleDependencies = @() foreach ($module in $Dependencies.Keys) { $moduleDependencies += @{ @@ -85,7 +58,7 @@ foreach ($module in $Dependencies.Keys) { MinimumVersion = $Dependencies[$module] } } -(Import-PowerShellDataFile ([System.IO.Path]::Combine($PSScriptRoot,$ModuleName,"$ModuleName.psd1"))).RequiredModules | ForEach-Object { +(Import-PowerShellDataFile ([System.IO.Path]::Combine($PSScriptRoot, $ModuleName, "$ModuleName.psd1"))).RequiredModules | ForEach-Object { $item = $_ if ($item -is [hashtable]) { $hash = @{ @@ -95,8 +68,7 @@ foreach ($module in $Dependencies.Keys) { $hash['RequiredVersion'] = $item['ModuleVersion'] } $moduleDependencies += $hash - } - else { + } else { if ($Dependencies.Keys -notcontains $item) { $moduleDependencies += @{ Name = $item @@ -106,8 +78,7 @@ foreach ($module in $Dependencies.Keys) { } try { $null = Get-PackageProvider -Name Nuget -ForceBootstrap -Verbose:$false -ErrorAction Stop -} -catch { +} catch { throw } foreach ($item in $moduleDependencies) { @@ -118,13 +89,12 @@ foreach ($item in $moduleDependencies) { $imported | Remove-Module } Import-Module @item - } - catch { + } catch { Write-BuildLog "[$($item['Name'])] Installing missing module" Install-Module @item Import-Module @item } } -Add-Heading "Executing Invoke-Build" +Add-Heading 'Executing Invoke-Build' Invoke-Build -ModuleName $ModuleName @PSBoundParameters diff --git a/ci/BuildHelpers.ps1 b/ci/BuildHelpers.ps1 new file mode 100644 index 000000000..50c978ba5 --- /dev/null +++ b/ci/BuildHelpers.ps1 @@ -0,0 +1,113 @@ +# Build helper functions for VaporShell +# Replaces the legacy Azure Pipelines gist dependency + +$env:_BuildStart = Get-Date -Format 'o' + +function Write-BuildLog { + [CmdletBinding()] + param( + [parameter(Mandatory, Position = 0, ValueFromRemainingArguments, ValueFromPipeline)] + [System.Object] + $Message, + [parameter()] + [Alias('c', 'Command')] + [Switch] + $Cmd, + [parameter()] + [Alias('w')] + [Switch] + $Warning, + [parameter()] + [Alias('s', 'e')] + [Switch] + $Severe, + [parameter()] + [Alias('x', 'nd', 'n')] + [Switch] + $Clean + ) + Begin { + if ($Severe) { $fg = 'Red' } + elseif ($Warning) { $fg = 'Yellow' } + elseif ($Cmd) { $fg = 'Magenta' } + else { $fg = 'Gray' } + } + Process { + $date = "[$((Get-Date).ToString("HH:mm:ss")) +$(((Get-Date) - (Get-Date $env:_BuildStart)).ToString())]" + $fmtMsg = if ($Clean) { + $Message -split "[\r\n]" | Where-Object { $_ } + } + else { + $Message -split "[\r\n]" | Where-Object { $_ } | ForEach-Object { "$date $_" } + } + Write-Host -ForegroundColor $fg $($fmtMsg -join "`n") + } +} + +function Write-BuildError { + param( + [parameter(Mandatory, Position = 0, ValueFromRemainingArguments, ValueFromPipeline)] + [System.String] + $Message + ) + Process { + Write-Error $Message + } +} + +function Add-Heading { + param( + [parameter(Position = 0, ValueFromRemainingArguments)] + [String] + $Title + ) + $date = "[$((Get-Date).ToString("HH:mm:ss")) +$(((Get-Date) - (Get-Date $env:_BuildStart)).ToString())]" + Write-Host -ForegroundColor Cyan "`n$date $Title" +} + +function Get-PSGalleryVersion { + [CmdletBinding()] + Param ( + [Parameter(Mandatory, Position = 0)] + [String] + $Module + ) + Process { + $Uri = "https://www.powershellgallery.com/api/v2/Packages?`$filter=Id eq '$Module' and IsLatestVersion" + Invoke-RestMethod $URI | + Select-Object @{n = 'Name'; ex = { $_.title.('#text') } }, + @{n = 'Version'; ex = { + if ($_.properties.NormalizedVersion) { $_.properties.NormalizedVersion } + else { $_.properties.Version } + } + } + } +} + +function Get-NextModuleVersion { + [CmdletBinding()] + Param ( + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] + [string] + $GalleryVersion, + [Parameter(Mandatory)] + [string] + $ManifestVersion + ) + Process { + $dateString = Get-Date -Format 'yyyyMMdd' + if ([string]::IsNullOrEmpty($GalleryVersion)) { + $GalleryVersion = "0.0.1.$dateString" + } + if ([System.Version]$ManifestVersion -gt [System.Version]$GalleryVersion) { + $split = $ManifestVersion.Split('.') + } + else { + $split = $GalleryVersion.Split('.') + $split[2] = [string]([int]$split[2] + 1) + } + '{0}.{1}.{2}.{3}' -f $split[0], $split[1], $split[2], $dateString + } +} diff --git a/ci/Convert-SpecToFunction.ps1 b/ci/Convert-SpecToFunction.ps1 index 1a8c12069..394215976 100644 --- a/ci/Convert-SpecToFunction.ps1 +++ b/ci/Convert-SpecToFunction.ps1 @@ -134,7 +134,7 @@ function $FunctionName { UpdateReplacePolicy differs from the DeletionPolicy attribute in that it only applies to resources replaced during stack updates. Use DeletionPolicy for resources deleted when a stack is deleted, or when the resource definition itself is deleted from the template as part of a stack update. - You must use one of the following options: "Delete","Retain","Snapshot","RetainExceptOnCreate"`n + You must use one of the following options: "Delete","Retain","Snapshot"`n "@ } @@ -408,7 +408,7 @@ function $FunctionName { if ($addCommonCfnProperty['UpdateReplacePolicy']) { $scriptContents += @" - [ValidateSet("Delete","Retain","Snapshot","RetainExceptOnCreate")] + [ValidateSet("Delete","Retain","Snapshot")] [System.String] `$UpdateReplacePolicy,`n "@ diff --git a/ci/ConvertFrom-ProviderSchema.ps1 b/ci/ConvertFrom-ProviderSchema.ps1 new file mode 100644 index 000000000..f09080b7f --- /dev/null +++ b/ci/ConvertFrom-ProviderSchema.ps1 @@ -0,0 +1,399 @@ +function ConvertFrom-ProviderSchema { + <# + .SYNOPSIS + Converts a CloudFormation Resource Provider Schema (JSON Schema format) into the + legacy resource spec structure expected by Convert-SpecToFunction. + + .DESCRIPTION + Takes a parsed JSON Schema object (from the new per-resource schema files) and + transforms it into the same shape that the old monolithic CloudFormation Resource + Specification used. This allows Convert-SpecToFunction to work unchanged. + + Returns a hashtable with: + - ResourceTypes: hashtable of resource objects keyed by type name + - PropertyTypes: hashtable of property type objects keyed by fully-qualified name + + .PARAMETER SchemaObject + The parsed JSON object from a resource provider schema file. + + .FUNCTIONALITY + Vaporshell + #> + + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Object] + $SchemaObject + ) + + process { + $typeName = $SchemaObject.typeName # e.g. AWS::S3::Bucket + $shortService = ($typeName -replace '^AWS::' -replace '::.*$') # e.g. S3 + + # Build documentation URL from typeName + $docSlug = ($typeName -replace '::', '-').ToLower() + $documentation = "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/$docSlug.html" + + # Determine which properties are required + $requiredProps = @() + if ($SchemaObject.required) { + $requiredProps = @($SchemaObject.required) + } + + # Helper: resolve a $ref to a definition name + # e.g. "#/definitions/AccelerateConfiguration" -> "AccelerateConfiguration" + function Get-DefinitionName { + param([string]$Ref) + if ($Ref -match '#/definitions/(.+)$') { + return $Matches[1] + } + return $null + } + + # Helper: determine if a definition represents the standard AWS Tag structure. + # Only the exact name 'Tag' is treated as a tag. Other Key/Value definitions + # (like TagsEntry, TagsMap) are legitimate property types. + function Test-IsTagDefinition { + param([string]$DefName, [object]$DefObj) + if ($DefName -eq 'Tag') { return $true } + return $false + } + + # Helper: convert a JSON Schema property into legacy spec property format + function Convert-PropertyToLegacy { + param( + [string]$PropName, + [object]$PropObj, + [bool]$IsRequired, + [object]$Definitions + ) + + $legacy = [ordered]@{ + Documentation = $documentation + Required = if ($IsRequired) { 'True' } else { 'False' } + } + + # Case 1: $ref to a definition (complex type) + if ($PropObj.'$ref') { + $defName = Get-DefinitionName $PropObj.'$ref' + if ($defName) { + if ($Definitions -and $Definitions.$defName) { + if (Test-IsTagDefinition -DefName $defName -DefObj $Definitions.$defName) { + $legacy['ItemType'] = 'Tag' + $legacy['Type'] = 'List' + } else { + $legacy['Type'] = $defName + } + } else { + $legacy['Type'] = $defName + } + } + return [PSCustomObject]$legacy + } + + # Case 2: array type + if ($PropObj.type -eq 'array') { + $legacy['Type'] = 'List' + if ($PropObj.items) { + if ($PropObj.items.'$ref') { + $defName = Get-DefinitionName $PropObj.items.'$ref' + if ($defName) { + if ($Definitions -and $Definitions.$defName -and + (Test-IsTagDefinition -DefName $defName -DefObj $Definitions.$defName)) { + $legacy['ItemType'] = 'Tag' + } else { + $legacy['ItemType'] = $defName + } + } + } elseif ($PropObj.items.type) { + # Array of primitives + $legacy['PrimitiveItemType'] = Convert-JsonTypeToPrimitive $PropObj.items.type + } + } + return [PSCustomObject]$legacy + } + + # Case 3: object type (Map or inline complex type) + if ($PropObj.type -eq 'object') { + if ($PropObj.additionalProperties -or $PropObj.patternProperties) { + $legacy['Type'] = 'Map' + } elseif ($PropObj.properties) { + # Inline object with properties — treat as a named type reference + # The extraction logic will create a property type for this + $legacy['Type'] = $PropName + } else { + # Object with no defined properties + $legacy['PrimitiveType'] = 'Json' + } + return [PSCustomObject]$legacy + } + + # Case 4: primitive types + if ($PropObj.type) { + $legacy['PrimitiveType'] = Convert-JsonTypeToPrimitive $PropObj.type + return [PSCustomObject]$legacy + } + + # Case 5: oneOf/anyOf — try to determine best type + if ($PropObj.oneOf -or $PropObj.anyOf) { + $variants = if ($PropObj.oneOf) { $PropObj.oneOf } else { $PropObj.anyOf } + # Look for a $ref or primitive in the variants + foreach ($variant in $variants) { + if ($variant.'$ref') { + $defName = Get-DefinitionName $variant.'$ref' + if ($defName) { + $legacy['Type'] = $defName + return [PSCustomObject]$legacy + } + } + } + # Fall back to string if we have mixed types + $legacy['PrimitiveType'] = 'String' + return [PSCustomObject]$legacy + } + + # Fallback: treat as String + $legacy['PrimitiveType'] = 'String' + return [PSCustomObject]$legacy + } + + function Convert-JsonTypeToPrimitive { + param([object]$JsonType) + # $JsonType can be a string or array + $t = if ($JsonType -is [array]) { $JsonType[0] } else { $JsonType } + switch ($t) { + 'string' { return 'String' } + 'integer' { return 'Integer' } + 'number' { return 'Double' } + 'boolean' { return 'Boolean' } + 'object' { return 'Json' } + 'array' { return 'Json' } + default { return 'String' } + } + } + + # Build resource properties in legacy format + $legacyProperties = [ordered]@{} + if ($SchemaObject.properties) { + foreach ($prop in $SchemaObject.properties.PSObject.Properties) { + $isRequired = $prop.Name -in $requiredProps + $legacyProperties[$prop.Name] = Convert-PropertyToLegacy ` + -PropName $prop.Name ` + -PropObj $prop.Value ` + -IsRequired $isRequired ` + -Definitions $SchemaObject.definitions + } + } + + # Build the resource entry matching old spec format + $resourceEntry = [PSCustomObject]@{ + Name = $typeName + Value = [PSCustomObject]@{ + Documentation = $documentation + Properties = [PSCustomObject]$legacyProperties + } + } + + # Build property type entries from definitions AND inline objects + $propertyTypes = @{} + + # Recursive function to extract property types from definitions, + # including inline object definitions nested within other definitions + function Extract-PropertyTypes { + param( + [string]$ParentTypeName, + [object]$Definitions, + [hashtable]$PropertyTypesRef + ) + + if (-not $Definitions) { return } + + foreach ($def in $Definitions.PSObject.Properties) { + $defName = $def.Name + $defObj = $def.Value + + # Skip Tag — it's handled specially + if (Test-IsTagDefinition -DefName $defName -DefObj $defObj) { + continue + } + + # Handle oneOf/anyOf definitions by flattening all variant properties + # into a single property type (each property becomes optional). + # This covers union types like TargetConfiguration, McpTargetConfiguration, etc. + if (-not $defObj.properties -and ($defObj.oneOf -or $defObj.anyOf)) { + $variants = if ($defObj.oneOf) { $defObj.oneOf } else { $defObj.anyOf } + $qualifiedName = "$ParentTypeName.$defName" + + if ($PropertyTypesRef.ContainsKey($qualifiedName)) { + continue + } + + $unionProperties = [ordered]@{} + foreach ($variant in $variants) { + if ($variant.properties) { + foreach ($vProp in $variant.properties.PSObject.Properties) { + if (-not $unionProperties.Contains($vProp.Name)) { + $unionProperties[$vProp.Name] = Convert-PropertyToLegacy ` + -PropName $vProp.Name ` + -PropObj $vProp.Value ` + -IsRequired $false ` + -Definitions $Definitions + } + } + } + } + + if ($unionProperties.Count -gt 0) { + $propTypeEntry = [PSCustomObject]@{ + Name = $qualifiedName + Value = [PSCustomObject]@{ + Documentation = $documentation + Properties = [PSCustomObject]$unionProperties + } + } + $PropertyTypesRef[$qualifiedName] = $propTypeEntry + } + continue + } + + # Skip definitions that don't have properties (e.g. simple enums/strings) + if (-not $defObj.properties) { + continue + } + + $qualifiedName = "$ParentTypeName.$defName" + + # Skip if already processed + if ($PropertyTypesRef.ContainsKey($qualifiedName)) { + continue + } + + # Determine required properties for this definition + $defRequired = @() + if ($defObj.required) { + $defRequired = @($defObj.required) + } + + $defProperties = [ordered]@{} + foreach ($defProp in $defObj.properties.PSObject.Properties) { + $isReq = $defProp.Name -in $defRequired + $propValue = $defProp.Value + + # Check if this property is an inline object with its own properties + # (not a $ref, and type=object with properties defined inline) + if ($propValue.type -eq 'object' -and $propValue.properties -and + -not $propValue.additionalProperties -and -not $propValue.patternProperties) { + # This is an inline complex type — extract it as a named property type + $inlineDefName = $defProp.Name + $inlineQualifiedName = "$ParentTypeName.$inlineDefName" + + if (-not $PropertyTypesRef.ContainsKey($inlineQualifiedName)) { + $inlineRequired = @() + if ($propValue.required) { + $inlineRequired = @($propValue.required) + } + + $inlineProperties = [ordered]@{} + foreach ($inlineProp in $propValue.properties.PSObject.Properties) { + $inlineIsReq = $inlineProp.Name -in $inlineRequired + $inlineProperties[$inlineProp.Name] = Convert-PropertyToLegacy ` + -PropName $inlineProp.Name ` + -PropObj $inlineProp.Value ` + -IsRequired $inlineIsReq ` + -Definitions $Definitions + } + + $inlinePropTypeEntry = [PSCustomObject]@{ + Name = $inlineQualifiedName + Value = [PSCustomObject]@{ + Documentation = $documentation + Properties = [PSCustomObject]$inlineProperties + } + } + $PropertyTypesRef[$inlineQualifiedName] = $inlinePropTypeEntry + + # Recursively check the inline object for further nested objects + $syntheticDef = [PSCustomObject]@{ + $inlineDefName = $propValue + } + # Don't recurse further for now — inline objects rarely nest more than one level + } + + # Map this property as a reference to the extracted type + $defProperties[$defProp.Name] = Convert-PropertyToLegacy ` + -PropName $defProp.Name ` + -PropObj ([PSCustomObject]@{ '$ref' = "#/definitions/$inlineDefName" }) ` + -IsRequired $isReq ` + -Definitions $Definitions + } else { + $defProperties[$defProp.Name] = Convert-PropertyToLegacy ` + -PropName $defProp.Name ` + -PropObj $propValue ` + -IsRequired $isReq ` + -Definitions $Definitions + } + } + + $propTypeEntry = [PSCustomObject]@{ + Name = $qualifiedName + Value = [PSCustomObject]@{ + Documentation = $documentation + Properties = [PSCustomObject]$defProperties + } + } + + $PropertyTypesRef[$qualifiedName] = $propTypeEntry + } + } + + if ($SchemaObject.definitions) { + Extract-PropertyTypes -ParentTypeName $typeName -Definitions $SchemaObject.definitions -PropertyTypesRef $propertyTypes + } + + # Also extract inline objects from top-level resource properties + if ($SchemaObject.properties) { + foreach ($prop in $SchemaObject.properties.PSObject.Properties) { + $propValue = $prop.Value + if ($propValue.type -eq 'object' -and $propValue.properties -and + -not $propValue.additionalProperties -and -not $propValue.patternProperties) { + # Inline object at resource level — extract as a property type + $inlineDefName = $prop.Name + $inlineQualifiedName = "$typeName.$inlineDefName" + + if (-not $propertyTypes.ContainsKey($inlineQualifiedName)) { + $inlineRequired = @() + if ($propValue.required) { + $inlineRequired = @($propValue.required) + } + + $inlineProperties = [ordered]@{} + foreach ($inlineProp in $propValue.properties.PSObject.Properties) { + $inlineIsReq = $inlineProp.Name -in $inlineRequired + $inlineProperties[$inlineProp.Name] = Convert-PropertyToLegacy ` + -PropName $inlineProp.Name ` + -PropObj $inlineProp.Value ` + -IsRequired $inlineIsReq ` + -Definitions $SchemaObject.definitions + } + + $inlinePropTypeEntry = [PSCustomObject]@{ + Name = $inlineQualifiedName + Value = [PSCustomObject]@{ + Documentation = $documentation + Properties = [PSCustomObject]$inlineProperties + } + } + $propertyTypes[$inlineQualifiedName] = $inlinePropTypeEntry + } + } + } + } + + # Return both the resource and its property types + [PSCustomObject]@{ + ResourceType = $resourceEntry + PropertyTypes = $propertyTypes + } + } +} diff --git a/ci/Update-VSResourceFunctions.ps1 b/ci/Update-VSResourceFunctions.ps1 index f7ff7df21..c93add42c 100644 --- a/ci/Update-VSResourceFunctions.ps1 +++ b/ci/Update-VSResourceFunctions.ps1 @@ -1,88 +1,183 @@ function Update-VSResourceFunctions { <# .SYNOPSIS - Updates the Resource and Property Type functions + Updates the Resource and Property Type functions using CloudFormation Resource Provider Schemas. .DESCRIPTION - Updates the Resource and Property Type functions + Downloads the per-resource JSON Schema files from the CloudFormation registry for each region, + merges them to get maximum resource coverage, then generates PowerShell functions for each + resource and property type. - .PARAMETER Region - The AWS region by location whose specification sheet you'd like to use to update your functions + Uses the new schema format (https://schema.cloudformation..amazonaws.com/CloudformationSchema.zip) + instead of the deprecated monolithic CloudFormation Resource Specification. .FUNCTIONALITY Vaporshell #> [CmdletBinding()] - Param() + param() $vsPath = (Resolve-Path "$PSScriptRoot/../VaporShell").Path $vsTypeFuncPath = (Resolve-Path "$vsPath/Public/Resource Types").Path $vsPropFuncPath = (Resolve-Path "$vsPath/Public/Resource Property Types").Path - # URLs for CFN spec - $regHash = @{ - 'us-east-1 (N. Virginia)' = 'https://d1uauaxba7bl26.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'ap-east-1 (Hong Kong)' = 'https://cfn-resource-specifications-ap-east-1-prod.s3.ap-east-1.amazonaws.com/latest/gzip/CloudFormationResourceSpecification.json' - 'ap-south-1 (Mumbai)' = 'https://d2senuesg1djtx.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'ap-northeast-3 (Osaka-Local)' = 'https://d2zq80gdmjim8k.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'ap-northeast-2 (Seoul)' = 'https://d1ane3fvebulky.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'ap-southeast-1 (Singapore)' = 'https://doigdx0kgq9el.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'ap-southeast-2 (Sydney)' = 'https://d2stg8d246z9di.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'ap-northeast-1 (Tokyo)' = 'https://d33vqc0rt9ld30.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'ca-central-1 (Canada-Central)' = 'https://d2s8ygphhesbe7.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'cn-north-1 (Beijing)' = 'https://cfn-resource-specifications-cn-north-1-prod.s3.cn-north-1.amazonaws.com.cn/latest/gzip/CloudFormationResourceSpecification.json' - 'cn-northwest-1 (Ningxia)' = 'https://cfn-resource-specifications-cn-northwest-1-prod.s3.cn-northwest-1.amazonaws.com.cn/latest/gzip/CloudFormationResourceSpecification.json' - 'eu-central-1 (Frankfurt)' = 'https://d1mta8qj7i28i2.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'eu-west-1 (Ireland)' = 'https://d3teyb21fexa9r.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'eu-west-2 (London)' = 'https://d1742qcu2c1ncx.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'eu-west-3 (Paris)' = 'https://d2d0mfegowb3wk.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'eu-north-1 (Stockholm)' = 'https://diy8iv58sj6ba.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'us-gov-east-1 (GovCloud East)' = 'https://s3.us-gov-east-1.amazonaws.com/cfn-resource-specifications-us-gov-east-1-prod/latest/CloudFormationResourceSpecification.json' - 'us-gov-west-1 (GovCloud West)' = 'https://s3.us-gov-west-1.amazonaws.com/cfn-resource-specifications-us-gov-west-1-prod/latest/CloudFormationResourceSpecification.json' - 'me-south-1 (Bahrain)' = 'https://cfn-resource-specifications-me-south-1-prod.s3.me-south-1.amazonaws.com/latest/gzip/CloudFormationResourceSpecification.json' - 'sa-east-1 (São Paulo)' = 'https://d3c9jyj3w509b0.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'us-east-2 (Ohio)' = 'https://dnwj8swjjbsbt.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'us-west-1 (N. California)' = 'https://d68hl49wbnanq.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - 'us-west-2 (Oregon)' = 'https://d201a2mn26r7lk.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - } + # All regions that publish CloudFormation Resource Provider Schemas + $regions = @( + 'us-east-1' + 'us-east-2' + 'us-west-1' + 'us-west-2' + 'af-south-1' + 'ap-east-1' + 'ap-south-1' + 'ap-south-2' + 'ap-southeast-1' + 'ap-southeast-2' + 'ap-southeast-3' + 'ap-southeast-4' + 'ap-southeast-5' + 'ap-northeast-1' + 'ap-northeast-2' + 'ap-northeast-3' + 'ca-central-1' + 'ca-west-1' + 'eu-central-1' + 'eu-central-2' + 'eu-west-1' + 'eu-west-2' + 'eu-west-3' + 'eu-north-1' + 'eu-south-1' + 'eu-south-2' + 'il-central-1' + 'me-central-1' + 'me-south-1' + 'sa-east-1' + ) + + # China regions use different TLD + $chinaRegions = @( + @{ Region = 'cn-north-1'; Suffix = 'amazonaws.com.cn' } + @{ Region = 'cn-northwest-1'; Suffix = 'amazonaws.com.cn' } + ) + + # GovCloud regions + $govRegions = @( + 'us-gov-east-1' + 'us-gov-west-1' + ) + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) "VaporShell-SchemaDownload-$(Get-Date -Format 'yyyyMMddHHmmss')" + New-Item -ItemType Directory -Path $tempPath -Force | Out-Null - # Get us-east-1 as the base + # Collect all resource and property types across regions $final = @{ ResourceTypes = @{} PropertyTypes = @{} } - Write-Host "Getting CloudFormation spec from region: us-east-1 (N. Virginia)" - $URL = 'https://d1uauaxba7bl26.cloudfront.net/latest/gzip/CloudFormationResourceSpecification.json' - $specs = Invoke-RestMethod $URL -Verbose:$false - foreach ($resource in $specs.ResourceTypes.PSObject.Properties) { - $final['ResourceTypes'][$resource.Name] = $resource - } - foreach ($resource in $specs.PropertyTypes.PSObject.Properties) { - $final['PropertyTypes'][$resource.Name] = $resource - } - # Get the rest and add anything missing from us-east-1 for full coverage - foreach ($region in $regHash.GetEnumerator() | Where-Object {$_.Key -ne 'us-east-1 (N. Virginia)'}) { + # Helper to download and process a schema zip + function Import-SchemaZip { + param( + [string]$Url, + [string]$RegionName, + [string]$TempBasePath + ) + + $zipPath = Join-Path $TempBasePath "$RegionName.zip" + $extractPath = Join-Path $TempBasePath $RegionName + try { - Write-Host "Getting CloudFormation spec from region: $($region.Key)" - $specs = Invoke-RestMethod $region.Value -Verbose:$false - if ($newResources = $specs.ResourceTypes.PSObject.Properties | Where-Object {$_.Name -notin $final['ResourceTypes'].Keys}) { - Write-Host -ForegroundColor Green "Found $($newResources.Count) new resource types in region: $($region.Key)`n- $($newResources.Name -join "`n- ")" - foreach ($resource in $newResources) { - $final['ResourceTypes'][$resource.Name] = $resource - } + Write-Host "Downloading CloudFormation schemas from region: $RegionName" + Invoke-WebRequest -Uri $Url -OutFile $zipPath -UseBasicParsing -ErrorAction Stop + + Write-Host "Extracting schemas for region: $RegionName" + if (Test-Path $extractPath) { + Remove-Item $extractPath -Recurse -Force } - if ($newProps = $specs.PropertyTypes.PSObject.Properties | Where-Object {$_.Name -notin $final['PropertyTypes'].Keys}) { - Write-Host -ForegroundColor Magenta "Found $($newProps.Count) new property types in region: $($region.Key)`n- $($newProps.Name -join "`n- ")" - foreach ($resource in $newProps) { - $final['PropertyTypes'][$resource.Name] = $resource + Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force + + # Process each schema file + $schemaFiles = Get-ChildItem -Path $extractPath -Filter '*.json' -File + $newResourceCount = 0 + $newPropertyCount = 0 + + foreach ($schemaFile in $schemaFiles) { + try { + $schema = Get-Content $schemaFile.FullName -Raw | ConvertFrom-Json -ErrorAction Stop + + # Skip non-AWS resources (third-party types) + if ($schema.typeName -notmatch '^AWS::') { + continue + } + + $converted = ConvertFrom-ProviderSchema -SchemaObject $schema + + # Add resource type if not already present + if ($converted.ResourceType -and $converted.ResourceType.Name -and + -not $final['ResourceTypes'].ContainsKey($converted.ResourceType.Name)) { + $final['ResourceTypes'][$converted.ResourceType.Name] = $converted.ResourceType + $newResourceCount++ + } + + # Add property types if not already present + foreach ($ptKey in $converted.PropertyTypes.Keys) { + if (-not $final['PropertyTypes'].ContainsKey($ptKey)) { + $final['PropertyTypes'][$ptKey] = $converted.PropertyTypes[$ptKey] + $newPropertyCount++ + } + } + } catch { + Write-Verbose "Failed to process schema file: $($schemaFile.Name) - $_" } } + + if ($newResourceCount -gt 0) { + Write-Host -ForegroundColor Green "Found $newResourceCount new resource types in region: $RegionName" + } + if ($newPropertyCount -gt 0) { + Write-Host -ForegroundColor Magenta "Found $newPropertyCount new property types in region: $RegionName" + } + } catch { + Write-Host -ForegroundColor Yellow "WARNING: Failed to download/process schemas from region: $RegionName - $_" + } finally { + # Clean up zip to save disk space + if (Test-Path $zipPath) { + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + } } - catch { - Write-Host -ForegroundColor Yellow "WARNING: Failed to get specs from region: $($_.Key)" - } + } + + # Process us-east-1 first as the baseline (has the most resources) + $baseUrl = 'https://schema.cloudformation.us-east-1.amazonaws.com/CloudformationSchema.zip' + Import-SchemaZip -Url $baseUrl -RegionName 'us-east-1' -TempBasePath $tempPath + + Write-Host "Baseline: $($final['ResourceTypes'].Count) resource types, $($final['PropertyTypes'].Count) property types from us-east-1" + + # Process remaining commercial regions + foreach ($region in $regions | Where-Object { $_ -ne 'us-east-1' }) { + $url = "https://schema.cloudformation.$region.amazonaws.com/CloudformationSchema.zip" + Import-SchemaZip -Url $url -RegionName $region -TempBasePath $tempPath + } + + # Process China regions + foreach ($entry in $chinaRegions) { + $url = "https://schema.cloudformation.$($entry.Region).$($entry.Suffix)/CloudformationSchema.zip" + Import-SchemaZip -Url $url -RegionName $entry.Region -TempBasePath $tempPath + } + + # Process GovCloud regions + foreach ($region in $govRegions) { + $url = "https://schema.cloudformation.$region.amazonaws.com/CloudformationSchema.zip" + Import-SchemaZip -Url $url -RegionName $region -TempBasePath $tempPath + } + + Write-Host -ForegroundColor Cyan "Total: $($final['ResourceTypes'].Count) resource types, $($final['PropertyTypes'].Count) property types across all regions" + + # Clean up temp directory + if (Test-Path $tempPath) { + Remove-Item $tempPath -Recurse -Force -ErrorAction SilentlyContinue } # Clean up the directories with dynamically generated content to ensure no legacy files are included in the module @@ -92,18 +187,18 @@ function Update-VSResourceFunctions { # Regenerate New-VS... and Add-VS... commands Write-Host -ForegroundColor Green 'Generate Resource Type functions' - foreach ($resource in $final['ResourceTypes'].Values | Sort-Object Name) { + foreach ($resource in $final['ResourceTypes'].Values | Sort-Object { $_.Name }) { Write-Verbose "Updating Resource Type [$($resource.Name)]" Convert-SpecToFunction -Resource $resource -ResourceType Resource } - $AfterTypeCount = (Get-ChildItem -Path (Resolve-Path "$vsPath\Public\Resource Types").Path).Count + $AfterTypeCount = (Get-ChildItem -Path $vsTypeFuncPath).Count Write-Host -ForegroundColor Green ('Generated {0} Resource Type functions' -f $AfterTypeCount) Write-Host -ForegroundColor Green 'Generate Resource Property functions' - foreach ($resource in $final['PropertyTypes'].Values | Sort-Object Name) { + foreach ($resource in $final['PropertyTypes'].Values | Sort-Object { $_.Name }) { Write-Verbose "Updating Resource Property [$($resource.Name)]" Convert-SpecToFunction -Resource $resource -ResourceType Property } - $AfterPropCount = (Get-ChildItem -Path (Resolve-Path "$vsPath\Public\Resource Property Types").Path).Count + $AfterPropCount = (Get-ChildItem -Path $vsPropFuncPath).Count Write-Host -ForegroundColor Green ('Generated {0} Resource Property functions' -f $AfterPropCount) } diff --git a/invoke.build.ps1 b/invoke.build.ps1 index be74563f0..a7943f45d 100644 --- a/invoke.build.ps1 +++ b/invoke.build.ps1 @@ -1,5 +1,5 @@ -Param( +param( [Parameter(Mandatory, Position = 0)] [String] $ModuleName, @@ -52,45 +52,44 @@ task Init { $Script:TargetVersionDirectory = [System.IO.Path]::Combine($TargetModuleDirectory, $NextModuleVersion) $Script:TargetManifestPath = [System.IO.Path]::Combine($TargetVersionDirectory, "$($ModuleName).psd1") $Script:TargetPSM1Path = [System.IO.Path]::Combine($TargetVersionDirectory, "$($ModuleName).psm1") - Write-BuildLog "Build System Details:" + Write-BuildLog 'Build System Details:' @( - "" - "~~~~~ Summary ~~~~~" + '' + '~~~~~ Summary ~~~~~' "In CI? : $($IsCI -or (Test-Path Env:\TF_BUILD))" "Project : $ModuleName" "Manifest Version : $ManifestVersion" "Gallery Version : $GalleryVersion" "Next Module Version : $NextModuleVersion" "Engine : PowerShell $($PSVersionTable.PSVersion.ToString())" - "Host OS : $(if($PSVersionTable.PSVersion.Major -le 5 -or $IsWindows){"Windows"}elseif($IsLinux){"Linux"}elseif($IsMacOS){"macOS"}else{"[UNKNOWN]"})" + "Host OS : $(if($PSVersionTable.PSVersion.Major -le 5 -or $IsWindows){'Windows'}elseif($IsLinux){'Linux'}elseif($IsMacOS){'macOS'}else{'[UNKNOWN]'})" "PWD : $PWD" - "" - "~~~~~ Directories ~~~~~" + '' + '~~~~~ Directories ~~~~~' "SourceModuleDirectory : $SourceModuleDirectory" "TargetDirectory : $TargetDirectory" "TargetModuleDirectory : $TargetModuleDirectory" "TargetVersionDirectory : $TargetVersionDirectory" "TargetManifestPath : $TargetManifestPath" "TargetPSM1Path : $TargetPSM1Path" - "" - "~~~~~ Environment ~~~~~" + '' + '~~~~~ Environment ~~~~~' ) | Write-BuildLog - Write-BuildLog "$((Get-ChildItem Env: | Where-Object {$_.Name -match "^(BUILD_|BH)"} | Sort-Object Name | Format-Table Name,Value -AutoSize | Out-String).Trim())" + Write-BuildLog "$((Get-ChildItem Env: | Where-Object {$_.Name -match '^(BUILD_|BH)'} | Sort-Object Name | Format-Table Name,Value -AutoSize | Out-String).Trim())" } -task Clean Init,{ +task Clean Init, { remove 'BuildOutput' } # Synopsis: Updates module functions before compilation Task Update Clean, { - Get-ChildItem (Join-Path $PSScriptRoot 'ci') -Filter '*.ps1' | Where-Object { $_.BaseName -notmatch "(GitHubReleaseNotes|gist\.githubusercontent\.com.*scrthq)" } | ForEach-Object { + Get-ChildItem (Join-Path $PSScriptRoot 'ci') -Filter '*.ps1' | Where-Object { $_.BaseName -ne 'GitHubReleaseNotes' } | ForEach-Object { . $_.FullName } if ($NoUpdate) { Write-BuildLog 'Skipping Spec Sheet update!' - } - else { + } else { Write-BuildLog 'Updating Resource and Property Type functions with current AWS spec sheet...' Update-VSResourceFunctions } @@ -100,7 +99,7 @@ Task Update Clean, { Task DotnetOnly { Write-BuildLog 'Compiling VaporShell.Core.dll' dotnet build .\VaporShell.Core\ - Get-Item ".\VaporShell.Core\obj\Debug\netstandard2.0\VaporShell.Core.dll" | Copy-Item -Destination $TargetVersionDirectory -Recurse -ErrorAction SilentlyContinue -Force + Get-Item '.\VaporShell.Core\obj\Debug\netstandard2.0\VaporShell.Core.dll' | Copy-Item -Destination $TargetVersionDirectory -Recurse -ErrorAction SilentlyContinue -Force } # Synopsis: Compiles module from source @@ -124,13 +123,13 @@ Task Build Update, { ) -join "`n" $psm1Header | Add-Content -Path $psm1 -Encoding UTF8 - foreach ($scope in @('Classes','Private','Public')) { - $gciPath = [System.IO.Path]::Combine($SourceModuleDirectory,$scope) + foreach ($scope in @('Classes', 'Private', 'Public')) { + $gciPath = [System.IO.Path]::Combine($SourceModuleDirectory, $scope) if (Test-Path $gciPath) { Write-BuildLog "Copying contents from files in source folder to PSM1: $($scope)" - Get-ChildItem -Path $gciPath -Filter "*.ps1" -Recurse -File | Where-Object { + Get-ChildItem -Path $gciPath -Filter '*.ps1' -Recurse -File | Where-Object { $_.Name -ne 'PseudoParams.txt' -and - $_.FullName -notlike "*Development Tools*" + $_.FullName -notlike '*Development Tools*' } | ForEach-Object { Write-BuildLog "Working on: $($_.FullName.Replace("$gciPath\",''))" "$(Get-Content $_.FullName -Raw)`n" | Add-Content -Path $psm1 -Encoding UTF8 @@ -142,7 +141,7 @@ Task Build Update, { } } - Get-ChildItem -Path $SourceModuleDirectory -Directory | Where-Object {$_.BaseName -notin @('Classes','Private','Public')} | ForEach-Object { + Get-ChildItem -Path $SourceModuleDirectory -Directory | Where-Object { $_.BaseName -notin @('Classes', 'Private', 'Public') } | ForEach-Object { Write-BuildLog "Copying source folder to target: $($_.BaseName)" Copy-Item $_.FullName -Destination $TargetVersionDirectory -Container -Recurse } @@ -155,10 +154,10 @@ Task Build Update, { Write-BuildLog 'Compiling VaporShell.Core.dll' dotnet build .\VaporShell.Core\ - Get-Item ".\VaporShell.Core\obj\Debug\netstandard2.0\VaporShell.Core.dll" | Copy-Item -Destination $TargetVersionDirectory -Recurse -ErrorAction SilentlyContinue + Get-Item '.\VaporShell.Core\obj\Debug\netstandard2.0\VaporShell.Core.dll' | Copy-Item -Destination $TargetVersionDirectory -Recurse -ErrorAction SilentlyContinue Write-BuildLog 'Copying latest AWSSDK assembly dependencies to output path' - Save-Module 'AWS.Tools.CloudFormation','AWS.Tools.S3' -Path $PSScriptRoot -Repository PSGallery -Force + Save-Module 'AWS.Tools.CloudFormation', 'AWS.Tools.S3' -Path $PSScriptRoot -Repository PSGallery -Force Get-Item 'AWS.Tools.*' | ForEach-Object { Get-ChildItem $_.FullName -Recurse -Filter 'AWSSDK.*.dll' | Copy-Item -Destination $TargetVersionDirectory -Recurse -ErrorAction SilentlyContinue Remove-Item $_.FullName -Recurse -Force @@ -168,15 +167,15 @@ Task Build Update, { Copy-Item -Path "$SourceModuleDirectory\VaporShell.DSL.psm1" -Destination "$TargetVersionDirectory" -Recurse -ErrorAction SilentlyContinue Write-BuildLog 'Creating Variable hash' - $varHash = @("@{") + $varHash = @('@{') Get-Content -Path "$SourceModuleDirectory\Private\PseudoParams.txt" | ForEach-Object { - $name = "_$(($_ -replace "::").Trim())" + $name = "_$(($_ -replace '::').Trim())" $varHash += " '$name' = '$($_.Trim())'" } - $varHash += "}" + $varHash += '}' Write-BuildLog 'Creating Alias hash' - $aliasHash = @("@{") + $aliasHash = @('@{') Get-ChildItem "$SourceModuleDirectory\Public\Intrinsic Functions" | ForEach-Object { $name = ($_.BaseName).Replace('Add-', '') $aliasesToExport += $name @@ -187,10 +186,10 @@ Task Build Update, { $aliasesToExport += $name $aliasHash += " '$name' = '$($_.BaseName.Trim())'" } - $aliasHash += "}" + $aliasHash += '}' Write-BuildLog 'Setting remainder of PSM1 contents' -@" + @" `$aliases = @() `$aliasHash = $($aliasHash -join "`n") foreach (`$key in `$aliasHash.Keys) { @@ -212,11 +211,11 @@ Export-ModuleMember -Variable `$vars -Alias `$aliases "@ | Add-Content -Path $psm1 -Encoding UTF8 # Copy over manifest - Write-BuildLog "Copying source manifest to target folder" + Write-BuildLog 'Copying source manifest to target folder' Copy-Item -Path $SourceManifestPath -Destination $TargetVersionDirectory Write-BuildLog 'Updating manifest' - $dslModuleName = "VaporShell.DSL" + $dslModuleName = 'VaporShell.DSL' Import-Module "$SourceModuleDirectory\$($dslModuleName).psm1" -DisableNameChecking -Force -Verbose:$false $dslFunctions = (Get-Command -Module $dslModuleName).Name Remove-Module $dslModuleName -Force -Verbose:$false -ErrorAction SilentlyContinue @@ -230,18 +229,18 @@ Export-ModuleMember -Variable `$vars -Alias `$aliases } $vars = @() Get-Content -Path "$SourceModuleDirectory\Private\PseudoParams.txt" | ForEach-Object { - $vars += "_$(($_ -replace "::").Trim())" + $vars += "_$(($_ -replace '::').Trim())" } # Update FunctionsToExport and AliasesToExport on manifest $params = @{ - Path = $TargetManifestPath + Path = $TargetManifestPath FunctionsToExport = ($functionsToExport | Sort-Object) VariablesToExport = $vars - AliasesToExport = ($aliasesToExport | Sort-Object) + AliasesToExport = ($aliasesToExport | Sort-Object) } - Write-BuildLog "Updating target manifest file with exports" + Write-BuildLog 'Updating target manifest file with exports' Update-ModuleManifest @params if ($ManifestVersion -ne $NextModuleVersion) { @@ -251,11 +250,11 @@ Export-ModuleMember -Variable `$vars -Alias `$aliases } Write-BuildLog "Created compiled module at [$TargetVersionDirectory]!" Write-BuildLog 'Output version directory contents:' - Get-ChildItem $TargetVersionDirectory | Format-Table -Autosize + Get-ChildItem $TargetVersionDirectory | Format-Table -AutoSize } # Synopsis: Imports the newly compiled module -task Import -If {Test-Path $TargetManifestPath} Build,{ +task Import -If { Test-Path $TargetManifestPath } Build, { Import-Module -Name $TargetModuleDirectory -ErrorAction Stop } @@ -264,7 +263,7 @@ $pesterScriptBlock = { Write-BuildLog "$ModuleName is currently imported. Removing module and cleaning up any leftover aliases" $module | Remove-Module -Force $aliases = @{} - $aliasPath = [System.IO.Path]::Combine($BuildRoot,$ModuleName,"$ModuleName.Aliases.ps1") + $aliasPath = [System.IO.Path]::Combine($BuildRoot, $ModuleName, "$ModuleName.Aliases.ps1") if (Test-Path $aliasPath) { (. $aliasPath).Keys | ForEach-Object { if (Get-Alias "$_*") { @@ -277,12 +276,7 @@ $pesterScriptBlock = { $testModules = @( @{ Name = 'Pester' - MinimumVersion = '4.10.1' - MaximumVersion = '4.99.99' - } - @{ - Name = 'Assert' - MinimumVersion = '0.9.5' + MinimumVersion = '5.0.0' } ) foreach ($testModule in $testModules) { @@ -293,8 +287,7 @@ $pesterScriptBlock = { $imported | Remove-Module } Import-Module @testModule - } - catch { + } catch { Write-BuildLog "[$($testModule.Name)] Installing missing module" Install-Module @testModule Import-Module @testModule @@ -304,26 +297,26 @@ $pesterScriptBlock = { Set-Location -PassThru $TargetModuleDirectory Get-Module $ModuleName | Remove-Module $ModuleName -ErrorAction SilentlyContinue -Verbose:$false Import-Module -Name $TargetModuleDirectory -Force -Verbose:$false - $pesterParams = @{ - OutputFormat = 'NUnitXml' - OutputFile = Join-Path $TargetDirectory "TestResults.xml" - PassThru = $true - Path = Join-Path $BuildRoot "Tests" - } + $pesterConfig = New-PesterConfiguration + $pesterConfig.TestResult.Enabled = $true + $pesterConfig.TestResult.OutputPath = Join-Path $TargetDirectory 'TestResults.xml' + $pesterConfig.TestResult.OutputFormat = 'NUnitXml' + $pesterConfig.Run.Path = Join-Path $BuildRoot 'Tests' + $pesterConfig.Run.PassThru = $true + $pesterConfig.Output.Verbosity = 'Detailed' if ($global:ExcludeTag) { - $pesterParams['ExcludeTag'] = $global:ExcludeTag + $pesterConfig.Filter.ExcludeTag = $global:ExcludeTag Write-BuildLog "Invoking Pester and excluding tag(s) [$($global:ExcludeTag -join ', ')]..." - } - else { + } else { Write-BuildLog 'Invoking Pester...' } - $testResults = Invoke-Pester @pesterParams + $testResults = Invoke-Pester -Configuration $pesterConfig Write-BuildLog 'Pester invocation complete!' if ($testResults.FailedCount -gt 0) { "`nTop-level results:" $testResults | Format-List "`nFailures only:" - $testResults.TestResult | Where-Object {-not $_.Passed} | Format-List + $testResults.Tests | Where-Object { $_.Result -eq 'Failed' } | Format-List Write-BuildError 'One or more Pester tests failed. Build cannot continue!' } } @@ -358,87 +351,13 @@ Task Analyze Init, { $psGalleryConditions = { -not [String]::IsNullOrEmpty($env:NugetApiKey) -and -not [String]::IsNullOrEmpty($NextModuleVersion) -and - $env:BHBuildSystem -eq 'VSTS' -and - ($env:BHCommitMessage -match '!deploy' -or $env:BUILD_REASON -eq 'Schedule') -and - $env:BHBranchName -match "^(master|main)$" -} -$gitHubConditions = { - -not [String]::IsNullOrEmpty($env:GitHubPAT) -and - -not [String]::IsNullOrEmpty($NextModuleVersion) -and - $env:BHBuildSystem -eq 'VSTS' -and - ($env:BHCommitMessage -match '!deploy' -or $env:BUILD_REASON -eq 'Schedule') -and - $env:BHBranchName -match "^(master|main)$" -} -$tweetConditions = { - -not [String]::IsNullOrEmpty($env:TwitterAccessSecret) -and - -not [String]::IsNullOrEmpty($env:TwitterAccessToken) -and - -not [String]::IsNullOrEmpty($env:TwitterConsumerKey) -and - -not [String]::IsNullOrEmpty($env:TwitterConsumerSecret) -and - -not [String]::IsNullOrEmpty($NextModuleVersion) -and - $env:BHBuildSystem -eq 'VSTS' -and - ($env:BHCommitMessage -match '!deploy' -or $env:BUILD_REASON -eq 'Schedule') -and - $env:BHBranchName -match "^(master|main)$" + $env:BHBranchName -match '^(master|main)$' } task PublishToPSGallery -If $psGalleryConditions { Write-BuildLog "Publishing version [$($NextModuleVersion)] to PSGallery" Publish-Module -Path $TargetVersionDirectory -NuGetApiKey $env:NugetApiKey -Repository PSGallery - Write-BuildLog "Deployment successful!" -} - -task PublishToGitHub -If $gitHubConditions { - $commitId = git rev-parse --verify HEAD - Write-BuildLog "Creating Release ZIP..." - $zipPath = [System.IO.Path]::Combine($BuildRoot,"$($ModuleName).zip") - if (Test-Path $zipPath) { - Remove-Item $zipPath -Force - } - Add-Type -Assembly System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::CreateFromDirectory($TargetModuleDirectory,$zipPath) - Write-BuildLog "Publishing Release v$($NextModuleVersion) @ commit Id [$($commitId)] to GitHub..." - - $ReleaseNotes = . .\ci\GitHubReleaseNotes.ps1 -ModuleName $ModuleName -ModuleVersion $NextModuleVersion - - $gitHubParams = @{ - VersionNumber = $NextModuleVersion.ToString() - CommitId = $commitId - ReleaseNotes = $ReleaseNotes - ArtifactPath = $zipPath - GitHubUsername = 'SCRT-HQ' - GitHubRepository = $ModuleName - GitHubApiKey = $env:GitHubPAT - Draft = $false - } - Publish-GitHubRelease @gitHubParams - Write-BuildLog "Release creation successful!" -} - -task PublishToTwitter -If $tweetConditions { - if ($null -eq (Get-Module PoshTwit -ListAvailable)) { - Write-BuildLog "Installing PoshTwit module" - Install-Module PoshTwit -Scope CurrentUser -SkipPublisherCheck -AllowClobber -Repository PSGallery -Force - } - Import-Module PoshTwit -Verbose:$false - Write-BuildLog "Publishing tweet about new release..." - $manifest = Import-PowerShellDataFile -Path $TargetManifestPath - $text = "#$($ModuleName) v$($NextModuleVersion) is now available on the #PSGallery! https://www.powershellgallery.com/packages/$($ModuleName)/$NextModuleVersion #PowerShell" - $manifest.PrivateData.PSData.Tags | Foreach-Object { - $text += " #$($_)" - } - if ($text.Length -gt 280) { - Write-BuildLog "Trimming [$($text.Length - 280)] extra characters from tweet text to get to 280 character limit..." - $text = $text.Substring(0,280) - } - Write-BuildLog "Tweet text: $text" - $publishTweetSplat = @{ - Tweet = $text - ConsumerSecret = $env:TwitterConsumerSecret - ConsumerKey = $env:TwitterConsumerKey - AccessToken = $env:TwitterAccessToken - AccessSecret = $env:TwitterAccessSecret - } - Publish-Tweet @publishTweetSplat - Write-BuildLog "Tweet successful!" + Write-BuildLog 'Deployment successful!' } -task Deploy Init,PublishToPSGallery,PublishToTwitter,PublishToGitHub +task Deploy Init, PublishToPSGallery