diff --git a/hot/Windows/PuppetAgent/PuppetAgent.ps1 b/hot/Windows/PuppetAgent/PuppetAgent.ps1 new file mode 100644 index 00000000..3508e39a --- /dev/null +++ b/hot/Windows/PuppetAgent/PuppetAgent.ps1 @@ -0,0 +1,32 @@ +#ps1_sysnative + +# Copyright 2014 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +$ErrorActionPreference = 'Stop' + +$moduleName = "PuppetAgent.psm1" +$cfnFolder = "C:\cfn" +$modulePath = Join-Path $cfnFolder $moduleName +Import-Module -Name $modulePath -DisableNameChecking -Force + +$puppetMasterServerName = "puppet_master_server_hostname" +$puppetMasterServerIp = "puppet_master_server_ip_address" +$puppetAgent_WaitConditionEndpoint = "puppet_agent_wait_condition_endpoint" +$puppetAgent_WaitConditionToken = "puppet_agent_wait_condition_token" + +Install-PuppetAgent -PuppetMasterServerName $puppetMasterServerName ` + -PuppetMasterServerIp $puppetMasterServerIp ` + -PuppetAgent_WaitConditionEndpoint $puppetAgent_WaitConditionEndpoint ` + -PuppetAgent_WaitConditionToken $puppetAgent_WaitConditionToken diff --git a/hot/Windows/PuppetAgent/PuppetAgent.psm1 b/hot/Windows/PuppetAgent/PuppetAgent.psm1 new file mode 100644 index 00000000..caa32c6e --- /dev/null +++ b/hot/Windows/PuppetAgent/PuppetAgent.psm1 @@ -0,0 +1,95 @@ +#ps1_sysnative + +# Copyright 2014 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +$ErrorActionPreference = 'Stop' + +$modulePath = "heat-powershell-utils.psm1" +$currentLocation = Split-Path -Parent $MyInvocation.MyCommand.Path +$fullPath = Join-Path $currentLocation $modulePath +Import-Module -Name $fullPath -DisableNameChecking -Force + +$heatTemplateName = "PuppetAgent" +$puppetAgentMsiUrl = "https://downloads.puppetlabs.com/windows/" + ` + "puppet-latest.msi" +$puppetAgentMsiPath = Join-Path $ENV:TEMP "puppet_agent.msi" +$puppetAgentInstallLogFile = Join-Path $ENV:TEMP "puppet_agent_msi_log.txt" +$hostsFile = "$ENV:SystemRoot\System32\Drivers\etc\hosts" + +function Log { + param( + $message + ) + LogTo-File -LogMessage $message -Topic $heatTemplateName + Log-HeatMessage $message +} + +function Install-PuppetAgentInternal { + param( + $PuppetMasterServerName, + $PuppetMasterServerIp + ) + + if ($PuppetMasterServerIp) { + $ip = [System.Net.IPAddress]::Parse($PuppetMasterServerIp) + Add-Content -Path $hostsFile ` + -Value "$PuppetMasterServerIp $PuppetMasterServerName" + } + + Download-File $puppetAgentMsiUrl $puppetAgentMsiPath + Execute-ExternalCommand { + param($PuppetMasterServerName, + $PuppetAgentInstallLogFile) + cmd /c start /wait msiexec /qn /i $puppetAgentMsiPath ` + /l*v $PuppetAgentInstallLogFile ` + PUPPET_MASTER_SERVER=$PuppetMasterServerName + } -Arguments @($PuppetMasterServerName, $puppetAgentInstallLogFile) ` + -ErrorMessage "Puppet Agent install failed." +} + +function Install-PuppetAgent { + param( + $PuppetMasterServerName, + $PuppetMasterServerIp, + $PuppetAgent_WaitConditionEndpoint, + $PuppetAgent_WaitConditionToken + ) + + try { + Log "Puppet agent installation started" + Install-PuppetAgentInternal ` + -PuppetMasterServerName $puppetMasterServerName ` + -PuppetMasterServerIp $puppetMasterServerIp + + $successMessage = "Finished Puppet Agent installation" + Log $successMessage + Send-HeatWaitSignal -Endpoint $PuppetAgent_WaitConditionEndpoint ` + -Message $successMessage ` + -Success $true ` + -Token $PuppetAgent_WaitConditionToken + + } catch { + $failMessage = "Installation encountered an error" + Log $failMessage + Log "Exception details: $_.Exception.Message" + Send-HeatWaitSignal -Endpoint $PuppetAgent_WaitConditionEndpoint ` + -Message $_.Exception.Message ` + -Success $false ` + -Token $PuppetAgent_WaitConditionToken + } +} + +Export-ModuleMember -Function Install-PuppetAgent -ErrorAction SilentlyContinue + diff --git a/hot/Windows/PuppetAgent/Tests/PuppetAgent.Tests.ps1 b/hot/Windows/PuppetAgent/Tests/PuppetAgent.Tests.ps1 new file mode 100644 index 00000000..573500f0 --- /dev/null +++ b/hot/Windows/PuppetAgent/Tests/PuppetAgent.Tests.ps1 @@ -0,0 +1,54 @@ +#ps1_sysnative + +<# +Copyright 2014 Cloudbase Solutions Srl + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +#> + +$utilsPath = (Resolve-Path '..\heat-powershell-utils.psm1').Path +$modulePath = (Resolve-Path '..\PuppetAgent.psm1').Path + +Remove-Module PuppetAgent -ErrorAction SilentlyContinue +Remove-Module heat-powershell-utils -ErrorAction SilentlyContinue +Import-Module -Name $modulePath -DisableNameChecking +Import-Module -Name $utilsPath -DisableNameChecking + +InModuleScope PuppetAgent { + Describe "Install-PuppetAgent" { + Context "Puppet Agent installed" { + $puppetMasterServerName = "puppet_master_server_hostname" + $puppetMasterServerIp = "puppet_master_server_ip_address" + $puppetAgent_WaitConditionEndpoint = ` + "puppet_agent_wait_condition_endpoint" + $puppetAgent_WaitConditionToken = ` + "puppet_agent_wait_condition_token" + + Mock Log { return 0 } -Verifiable + Mock Send-HeatWaitSignal { return 0 } -Verifiable + Mock Install-PuppetAgentInternal { return 0 } -Verifiable + + Install-PuppetAgent ` + -PuppetMasterServerName $puppetMasterServerName ` + -PuppetMasterServerIp $puppetMasterServerIp ` + -PuppetAgent_WaitConditionEndpoint ` + $puppetAgent_WaitConditionEndpoint ` + -PuppetAgent_WaitConditionToken $puppetAgent_WaitConditionToken + + It "should verify mocks called" { + Assert-VerifiableMocks + } + + } + } +} diff --git a/hot/Windows/PuppetAgent/heat-powershell-utils.psm1 b/hot/Windows/PuppetAgent/heat-powershell-utils.psm1 new file mode 100755 index 00000000..9fcf06bc --- /dev/null +++ b/hot/Windows/PuppetAgent/heat-powershell-utils.psm1 @@ -0,0 +1,471 @@ +#ps1_sysnative + +# Copyright 2014 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +$rebotCode = 1001 +$reexecuteCode = 1002 +$rebootAndReexecuteCode = 1003 + +# UNTESTABLE METHODS + +function ExitFrom-Script { + param( + [int]$ExitCode + ) + + exit $ExitCode +} + +function Get-LastExitCode () { + return $LASTEXITCODE +} + +function Get-PSMajorVersion () { + return $PSVersionTable.PSVersion.Major +} + +function Open-FileForRead ($FilePath) { + return [System.IO.File]::OpenRead($FilePath) +} + +function Write-PrivateProfileString ($Section, $Key, $Value, $Path) { + return [PSCloudbase.Win32IniApi]::WritePrivateProfileString( + $Section, $Key, $Value, $Path) +} + +function Get-LastError () { + return [PSCloudbase.Win32IniApi]::GetLastError() +} + +function Create-WebRequest ($Uri) { + return [System.Net.WebRequest]::Create($Uri) +} + +function Get-Encoding ($CodePage) { + return [System.Text.Encoding]::GetEncoding($CodePage) +} + +function Execute-Process ($DestinationFile, $Arguments) { + if (($Arguments.Count -eq 0) -or ($Arguments -eq $null)) { + $p = Start-Process -FilePath $DestinationFile ` + -PassThru ` + -Wait + } else { + $p = Start-Process -FilePath $DestinationFile ` + -ArgumentList $Arguments ` + -PassThru ` + -Wait + } + + return $p +} + +# TESTABLE METHODS + +function Log-HeatMessage { + param( + [string]$Message + ) + + Write-Host $Message +} + +function ExecuteWith-Retry { + param( + [ScriptBlock]$Command, + [int]$MaxRetryCount=10, + [int]$RetryInterval=3, + [array]$Arguments=@() + ) + + $currentErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + + $retryCount = 0 + while ($true) { + try { + $res = Invoke-Command -ScriptBlock $Command ` + -ArgumentList $Arguments + $ErrorActionPreference = $currentErrorActionPreference + return $res + } catch [System.Exception] { + $retryCount++ + if ($retryCount -gt $MaxRetryCount) { + $ErrorActionPreference = $currentErrorActionPreference + throw $_.Exception + } else { + Write-Error $_.Exception + Start-Sleep $RetryInterval + } + } + } +} + +function Execute-ExternalCommand { + param( + [ScriptBlock]$Command, + [array]$Arguments=@(), + [string]$ErrorMessage + ) + + $res = Invoke-Command -ScriptBlock $Command -ArgumentList $Arguments + if ((Get-LastExitCode) -ne 0) { + throw $ErrorMessage + } + return $res +} + +function Is-WindowsServer2008R2 () { + $winVer = (Get-WmiObject -Class Win32_OperatingSystem).Version.Split('.') + return (($winVer[0] -eq 6) -and ($winVer[1] -eq 1)) +} + +function Install-WindowsFeatures { + param( + [Parameter(Mandatory=$true)] + [array]$Features, + [int]$RebootCode=$rebootAndReexecuteCode + ) + + if ((Is-WindowsServer2008R2) -eq $true) { + Import-Module -Name ServerManager + } + + $rebootNeeded = $false + foreach ($feature in $Features) { + if ((Is-WindowsServer2008R2) -eq $true) { + $state = ExecuteWith-Retry -Command { + Add-WindowsFeature -Name $feature -ErrorAction Stop + } -MaxRetryCount 13 -RetryInterval 2 + } else { + $state = ExecuteWith-Retry -Command { + Install-WindowsFeature -Name $feature -ErrorAction Stop + } -MaxRetryCount 13 -RetryInterval 2 + } + if ($state.Success -eq $true) { + if ($state.RestartNeeded -eq 'Yes') { + $rebootNeeded = $true + } + } else { + throw "Install failed for feature $feature" + } + } + + if ($rebootNeeded -eq $true) { + ExitFrom-Script -ExitCode $RebootCode + } +} + +function Copy-FileToLocal { + param( + $UNCPath + ) + + $tempLocation = ${ENV:Temp} + $fileName = Split-Path -Path $UNCPath -Leaf + $localPath = Join-Path -Path $tempLocation -ChildPath $fileName + Copy-Item -Path $UNCPath -Destination $localPath -Recurse -Force + + Log-HeatMessage ("Local file path: " + $localPath) + + return $localPath +} + +function Unzip-File { + param( + [Parameter(Mandatory=$true)] + [string]$ZipFile, + [Parameter(Mandatory=$true)] + [string]$Destination + ) + + $shellApp = New-Object -ComObject Shell.Application + $zipFileNs = $shellApp.NameSpace($ZipFile) + $destinationNS = $shellApp.NameSpace($Destination) + $destinationNS.CopyHere($zipFileNs.Items(), 0x4) +} + +function Download-File { + param( + [Parameter(Mandatory=$true)] + [string]$DownloadLink, + [Parameter(Mandatory=$true)] + [string]$DestinationFile + ) + + $webclient = New-Object System.Net.WebClient + ExecuteWith-Retry -Command { + $webclient.DownloadFile($DownloadLink, $DestinationFile) + } -MaxRetryCount 13 -RetryInterval 2 +} + +# Get-FileHash for Powershell versions less than 4.0 (SHA1 algorithm only) +function Get-FileSHA1Hash { + [CmdletBinding()] + param( + [parameter(Mandatory=$true)] + [string]$Path, + [string]$Algorithm = "SHA1" + ) + + process + { + if ($Algorithm -ne "SHA1") { + throw "Unsupported algorithm: $Algorithm" + } + $fullPath = Resolve-Path $Path + $f = Open-FileForRead $fullPath + $sham = $null + try { + $sham = New-Object System.Security.Cryptography.SHA1Managed + $hash = $sham.ComputeHash($f) + $hashSB = New-Object System.Text.StringBuilder ` + -ArgumentList ($hash.Length * 2) + foreach ($b in $hash) { + $sb = $hashSB.AppendFormat("{0:x2}", $b) + } + return [PSCustomObject]@{ Algorithm = "SHA1"; + Hash = $hashSB.ToString().ToUpper(); + Path = $fullPath } + } + finally { + $f.Close() + if($sham) { + $sham.Clear() + } + } + } +} + +function Check-FileIntegrityWithSHA1 { + param( + [Parameter(Mandatory=$true)] + [string]$File, + [Parameter(Mandatory=$true)] + [string]$ExpectedSHA1Hash + ) + + if ((Get-PSMajorVersion) -lt 4) { + $hash = (Get-FileSHA1Hash -Path $File).Hash + } else { + $hash = (Get-FileHash -Path $File -Algorithm "SHA1").Hash + } + if ($hash -ne $ExpectedSHA1Hash) { + $errMsg = "SHA1 hash not valid for file: $filename. " + + "Expected: $ExpectedSHA1Hash Current: $hash" + throw $errMsg + } +} + +function Install-Program { + param( + [Parameter(Mandatory=$true)] + [string]$DownloadLink, + [Parameter(Mandatory=$true)] + [string]$DestinationFile, + [Parameter(Mandatory=$true)] + [string]$ExpectedSHA1Hash, + [array]$Arguments, + [Parameter(Mandatory=$true)] + [string]$ErrorMessage + ) + + Download-File $DownloadLink $DestinationFile + Check-FileIntegrityWithSHA1 $DestinationFile $ExpectedSHA1Hash + $p = Execute-Process $DestinationFile $Arguments + + if ($p.ExitCode -ne 0) { + throw $ErrorMessage + } + + Remove-Item $DestinationFile +} + +function Set-IniFileValue { + [CmdletBinding()] + param( + [parameter(Mandatory=$true, ValueFromPipeline=$true)] + [string]$Key, + [parameter()] + [string]$Section = "DEFAULT", + [parameter(Mandatory=$true)] + [string]$Value, + [parameter(Mandatory=$true)] + [string]$Path + ) + + process + { + $Source = @" + using System; + using System.Text; + using System.Runtime.InteropServices; + + namespace PSCloudbase + { + public sealed class Win32IniApi + { + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern uint GetPrivateProfileString( + string lpAppName, + string lpKeyName, + string lpDefault, + StringBuilder lpReturnedString, + uint nSize, + string lpFileName); + + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool WritePrivateProfileString( + string lpAppName, + string lpKeyName, + StringBuilder lpString, // Don't use string, as Powershell replaces $null with an empty string + string lpFileName); + + [DllImport("Kernel32.dll")] + public static extern uint GetLastError(); + } + } +"@ + Add-Type -TypeDefinition $Source -Language CSharp + $retVal = Write-PrivateProfileString $Section $Key $Value $Path + $lastError = Get-LastError + if (!$retVal -and $lastError) { + throw ("Cannot set value in ini file: " + $lastError) + } + } +} + +function LogTo-File { + param( + $LogMessage, + $LogFile = "C:\cfn\userdata.log", + $Topic = "General" + ) + + $date = Get-Date + $fullMessage = "$date | $Topic | $LogMessage" + Add-Content -Path $LogFile -Value $fullMessage +} + +function Open-Port($Port, $Protocol, $Name) { + Execute-ExternalCommand -Command { + netsh.exe advfirewall firewall add rule ` + name=$Name dir=in action=allow protocol=$Protocol localport=$Port + } -ErrorMessage "Failed to add firewall rule" +} + +function Add-WindowsUser { + param( + [parameter(Mandatory=$true)] + [string]$Username, + [parameter(Mandatory=$true)] + [string]$Password + ) + + Execute-ExternalCommand -Command { + NET.EXE USER $Username $Password '/ADD' + } -ErrorMessage "Failed to create new user" +} + +# Invoke-RestMethod for Powershell versions less than 4.0 +function Invoke-RestMethodWrapper { + param( + [Uri]$Uri, + [Object]$Body, + [System.Collections.IDictionary]$Headers, + [string]$Method + ) + + $request = Create-WebRequest $Uri + $request.Method = $Method + foreach ($key in $Headers.Keys) { + try { + $request.Headers.Add($key, $Headers[$key]) + } catch { + $property = $key.Replace('-', '') + $request.$property = $Headers[$key] + } + } + + if (($Body -ne $null) -and ($Method -eq "POST")) { + $encoding = Get-Encoding "UTF-8" + $bytes = $encoding.GetBytes($Body) + $request.ContentLength = $bytes.Length + $writeStream = $request.GetRequestStream() + $writeStream.Write($bytes, 0, $bytes.Length) + } + + $response = $request.GetResponse() + $requestStream = $response.GetResponseStream() + $readStream = New-Object System.IO.StreamReader $requestStream + $data = $readStream.ReadToEnd() + + return $data +} + +function Invoke-HeatRestMethod { + param( + $Endpoint, + [System.String]$HeatMessageJSON, + [System.Collections.IDictionary]$Headers + ) + + if ((Get-PSMajorVersion) -lt 4) { + $result = Invoke-RestMethodWrapper -Method "POST" ` + -Uri $Endpoint ` + -Body $HeatMessageJSON ` + -Headers $Headers + } else { + $result = Invoke-RestMethod -Method "POST" ` + -Uri $Endpoint ` + -Body $HeatMessageJSON ` + -Headers $Headers + } +} + +function Send-HeatWaitSignal { + param( + [parameter(Mandatory=$true)] + [string]$Endpoint, + [parameter(Mandatory=$true)] + [string]$Token, + $Message, + $Success=$true + ) + + $statusMap = @{ + $true="SUCCESS"; + $false="FAILURE" + } + + $heatMessage = @{ + "status"=$statusMap[$Success]; + "reason"="Configuration script has been executed."; + "data"=$Message; + } + $headers = @{ + "X-Auth-Token"=$Token; + "Accept"="application/json"; + "Content-Type"= "application/json"; + } + $heatMessageJSON = ConvertTo-JSON -InputObject $heatMessage + + Invoke-HeatRestMethod -Endpoint $Endpoint ` + -HeatMessageJSON $heatMessageJSON ` + -Headers $headers +} + +Export-ModuleMember -Function * diff --git a/hot/Windows/PuppetAgent/puppet-agent.yaml b/hot/Windows/PuppetAgent/puppet-agent.yaml new file mode 100644 index 00000000..eef5573a --- /dev/null +++ b/hot/Windows/PuppetAgent/puppet-agent.yaml @@ -0,0 +1,140 @@ +heat_template_version: 2013-05-23 + +description: > + Installs a Puppet Agent. + +parameters: + key_name: + description: Name of an existing keypair to encrypt the Admin password + type: string + + flavor: + description: Id or name of an existing flavor + type: string + default: m1.small + + image: + description: Id or name of an existing Windows image + type: string + + public_network_id: + type: string + description: > + ID of an existing public network where a floating IP will be + allocated. + + private_network_id: + type: string + description: Id of an existing private network + + puppet_master_server: + type: string + constraints: + - length: { min: 3, max: 256 } + description: The Puppet Master server host name or fqdn (no IP address) + + puppet_master_server_ip_address: + type: string + constraints: + - length: { min: 7, max: 45 } + description: > + The Puppet Master server IP address. If provided, a host file record + will be created to map puppet_master_server to this IP address. + + puppet_agent_max_timeout: + type: number + default: 3600 + description: > + The maximum allowed time for the Puppet Agent instalation to finish. + +resources: + server_port: + type: OS::Neutron::Port + properties: + network_id: { get_param: private_network_id } + + server_floating_ip: + type: OS::Neutron::FloatingIP + depends_on: server_port + properties: + floating_network_id: { get_param: public_network_id } + port_id: { get_resource: server_port } + + utils_module: + type: OS::Heat::SoftwareConfig + properties: + group: ungrouped + config: { get_file: heat-powershell-utils.psm1 } + + puppet_agent_module: + type: OS::Heat::SoftwareConfig + properties: + group: ungrouped + config: { get_file: PuppetAgent.psm1 } + + puppet_agent_main: + type: OS::Heat::SoftwareConfig + properties: + group: ungrouped + config: + str_replace: + template: { get_file: PuppetAgent.ps1 } + params: + puppet_master_server_hostname: + { get_param: puppet_master_server } + puppet_master_server_ip_address: + { get_param: puppet_master_server_ip_address } + puppet_agent_wait_condition_endpoint: + { get_attr: [ puppet_agent_wait_condition_handle, endpoint ] } + puppet_agent_wait_condition_token: + { get_attr: [ puppet_agent_wait_condition_handle, token ] } + + puppet_agent_init: + type: OS::Heat::MultipartMime + depends_on: puppet_agent_wait_condition_handle + properties: + parts: + [ { + filename: "heat-powershell-utils.psm1", + subtype: "x-cfninitdata", + config: { get_resource: utils_module } + }, + { + filename: "PuppetAgent.psm1", + subtype: "x-cfninitdata", + config: { get_resource: puppet_agent_module } + }, + { + filename: "cfn-userdata", + subtype: "x-cfninitdata", + config: { get_resource: puppet_agent_main } + } + ] + + puppet_agent: + type: OS::Nova::Server + depends_on: [ server_port, puppet_agent_init ] + properties: + image: { get_param: image } + flavor: { get_param: flavor } + key_name: { get_param: key_name } + networks: + - port: { get_resource: server_port } + user_data_format: RAW + user_data: { get_resource: puppet_agent_init } + + puppet_agent_wait_condition: + type: OS::Heat::WaitCondition + depends_on: puppet_agent_wait_condition_handle + properties: + count: 1 + handle: { get_resource: puppet_agent_wait_condition_handle } + timeout: { get_param: puppet_agent_max_timeout } + + puppet_agent_wait_condition_handle: + type: OS::Heat::WaitConditionHandle + +outputs: + puppet_agent_server_public_ip: + description: The Puppet Agent public IP address + value: { get_attr: [ server_floating_ip, floating_ip_address ] }