diff --git a/Commands/Azure/Add-AzureTable.ps1 b/Commands/Azure/Add-AzureTable.ps1 new file mode 100644 index 0000000..ca8260f --- /dev/null +++ b/Commands/Azure/Add-AzureTable.ps1 @@ -0,0 +1,133 @@ +function Add-AzureTable +{ + <# + .Synopsis + Adds an Azure Table + .Description + Adds a table in Azure Table Storage + .Example + Add-AzureTable ATestTable + .Link + Get-AzureTable + .Link + Remove-AzureTable + .Link + Set-AzureTable + #> + [OutputType([PSObject])] + param( + # The name of the table + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName=$true, Position=0)] + [Alias("Name")] + [string]$TableName, + + # The author name. This is used in table metadata. + [Parameter(ValueFromPipelineByPropertyName = $true, Position=1)] + [string]$Author, + + # The author email. This is used in table metadata. + [Parameter(ValueFromPipelineByPropertyName = $true, Position=2)] + [string]$Email, + + # The storage account + [string]$StorageAccount, + + # The storage key + [string]$StorageKey + ) + + process { + #region check for and cache the storage account + if (-not $StorageAccount) { + $storageAccount = $script:CachedStorageAccount + } + + if (-not $StorageKey) { + $StorageKey = $script:CachedStorageKey + } + + if (-not $StorageAccount) { + Write-Error "No storage account provided" + return + } + + $b64StorageKey = try { [Convert]::FromBase64String("$storageKey") } catch { } + + if (-not $b64StorageKey -and $storageKey) { + $storageKey =Get-SecureSetting -Name $storageKey -ValueOnly + } + + if (-not $StorageKey) { + Write-Error "No storage key provided" + return + } + $script:CachedStorageAccount = $StorageAccount + $script:CachedStorageKey = $StorageKey + #endregion check for and cache the storage account + + #region Construct the Request Body + $requestBody = @" + + + + <updated>$([datetime]::UtcNow.ToString('o'))</updated> + <author> + <name/> + </author> + <id/> + <content type="application/xml"> + <m:properties> + <d:TableName>$TableName</d:TableName> + </m:properties> + </content> +</entry> +"@ + #endregion Construct the Request Body + $uri = "https://$StorageAccount.table.core.windows.net/Tables" + $method = 'POST' + $NowString = [DateTime]::Now.ToUniversalTime().ToString("R", [Globalization.CultureInfo]::InvariantCulture) + + #region Set up Get-Web + $GetWebParams = @{ + Url = $uri + Header = @{ + "x-ms-version" = "2011-08-18" + "DataServiceVersion" = "2.0;NetFx" + "MaxDataServiceVersion" = "2.0;NetFx" + 'content-type' = "application/atom+xml" + 'Accept-Charset' = 'UTF-8' + "x-ms-date" = $NowString + } + RequestBody = $RequestBody + AsXml = $true + ContentType = "application/atom+xml" + HideProgress = $true + Method = 'POST' + SignatureKey = $storageKey + SignaturePrefix = "SharedKey " + $StorageAccount + ":" + UseWebRequest = $true + } + #endregion Set up Get-Web + + # Add the message signature (composed from other parts of the message) + $GetWebParams += @{ + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @($method,"application/atom+xml",$NowString,"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + + $tableList = Get-Web @GetWebParams + foreach ($e in $tableList.entry) { + # Create an output object from the result + $azureTable = New-Object PSObject -Property @{ + "TableName" = $e.content.properties.TableName + "TableID" = $e.Id + } + $azureTable + } + } +} + diff --git a/Commands/Azure/Get-AzureTable.ps1 b/Commands/Azure/Get-AzureTable.ps1 new file mode 100644 index 0000000..7e1a9d1 --- /dev/null +++ b/Commands/Azure/Get-AzureTable.ps1 @@ -0,0 +1,514 @@ +function Get-AzureTable +{ + <# + .Synopsis + Gets data from Azure Table Storage + .Description + Gets or searches data in Azure Table Storage + .Example + # Get all tables + Get-AzureTable + .Example + # Get a specific item in a table + Get-AzureTable -TableName ZTestTable -PartitionKey part -RowKey row + .Example + # Get all items in a table + Get-AzureTable -TableName ZTestTable -PartitionKey part -RowKey row + .Example + # Search a table with PowerShell syntax. The Where clause is converted into an Azure filter and run in the cloud. + Get-AzureTable -TableName ZTestTable -Where { $_.PartitionKey -eq 'part' -and $_.RowKey -eq 'row' } + .Example + # Search a table with Powershell syntax, using a numeric value. The Where clause is converted into an Azure filter and run in the cloud. + Get-AzureTable -TableName ZTestTable -Where { $_.PartitionKey -eq 'part' -and $_.Count -gt 5 } + .Example + # Search a table with OData filter syntax. For more information on supported filters, see [MSDN](https://msdn.microsoft.com/en-us/library/azure/dd894031.aspx) + Get-AzureTable -TableName ZTestTable -Filter "PartitionKey eq 'part' and Count gt 5" + .Example + # Find the first 10 items that match a given condition + Get-AzureTable -TableName ZTestTable -First 10 -Where { $_.PartitionKey -eq 'part' } + .Example + # Get items that match a condition in pages of 10 + $Page = @(Get-AzureTable -TableName ZTestTable -First 10 -Where { $_.PartitionKey -eq 'part' }) + do { + $Page + if ($page[-1].NextRowKey -and $page[-1].NextPartitionKey) { + $page = $page[-1] | + Get-AzureTable -First 10 -Where { $_.PartitionKey -eq 'part' } + } else { + $page = $null + } + } while ($Page) + .Example + # Get everything in a table. + Get-AzureTable -TableName ZTestTable -All + .Example + # Get everything in a table, in batches of 5 (note, smaller batch sizes mean long queries will take even longer) + Get-AzureTable -TableName ZTestTable -All -BatchSize 5 + .Link + Add-AzureTable + .Link + Remove-AzureTable + .Link + Set-AzureTable + .Notes + Get-AzureTable is also aliased with Search-AzureTable. For backwards compatbility, it uses an interesting trick. + + + Calling Get-AzureTable with the alias Search-AzureTable will search a table instead of get it's metadata (if provided only a TableName). + This is the same as passing the -All parameter. + #> + [CmdletBinding(DefaultParameterSetName='AllTables')] + [OutputType([PSObject])] + param( + # The name of the queue + [Parameter(Mandatory=$true,Position=1,ValueFromPipelineByPropertyName=$true,ParameterSetName='SpecificTableItem')] + [Parameter(Mandatory=$true,Position=1,ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Parameter(Mandatory=$true,Position=1,ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithWhere')] + [string]$TableName, + + # The blob prefix + [string]$Prefix, + + # The storage account + [string]$StorageAccount, + + # The storage key + [string]$StorageKey, + + # A shared access signature. If this is a partial URL, the storage account is still required. + [Alias('SAS')] + [string]$SharedAccessSignature, + + # If set, will peek at the messages in the queue, instead of retreiving them + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true,ParameterSetName='SpecificTableItem')] + [Alias('Partition')] + [string] + $PartitionKey, + + # The number of messages to retreive from a queue + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true,ParameterSetName='SpecificTableItem')] + [Alias('Row')] + [string] + $RowKey, + + # A search filter for Azure Table Storage. For more information on filter syntax, see [MSDN](https://msdn.microsoft.com/en-us/library/azure/dd894031.aspx) + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [string] + $Filter, + + # A PowerShell filter for Azure Table Storage. Filters are converted to OData syntax, and only a limited number of operators are supported. Additionally, expect case-sensitivity and type-sensitivty. + [Parameter(Mandatory=$true,ParameterSetName='SearchTableWithWhere',ValueFromPipelineByPropertyName=$true)] + [ScriptBlock[]] + $Where, + + # A list of properties to select from the item. This will omit built-in properties (.Timestamp, .RowKey, .PartitionKey, and .TableName) if they are not included in the Select statement + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithWhere')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SpecificTableItem')] + [string[]] + $Select, + + # If provided, will only get the first N items from a filter. + # If there are more items, the last item will have an additional property containing continuation information. + # Passing this information back into Get-AzureTable allows you to paginate results + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithWhere')] + [Uint32] + [Alias('Top')] + $First, + + # If set, will omit table properties (.RowKey, .PartitonKey, .TableName, and .Timestamp) from the returned objects + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SpecificTableItem')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithWhere')] + [Switch] + $ExcludeTableInfo, + + # If provided, will resume an existing search filter using this NextRowKey + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithWhere')] + [string] + $NextRowKey, + + # If provided, will resume an existing search filter using this NextPartitionKey + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithWhere')] + [string] + $NextPartitionKey, + + # Optionally sets the batch size. If -First is set, it will override this setting. BatchSize should be left to it's default if your objects are small, but smaller batch sizes may be useful for throttling large objects. + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithWhere')] + [ValidateRange(0, 999)] + [Uint32] + $BatchSize, + + # If set, will return all objects from a table. Calling Get-AzureTable with the alias Search-AzureTable achieves the same effect. + [Parameter(ValueFromPipelineByPropertyName=$true,ParameterSetName='SearchTableWithFilter')] + [Switch] + $All + ) + + + begin { + $myInv = $MyInvocation + Add-type -AssemblyName System.Web + + $ConvertEntityToPSObject = { + param([Parameter(ValueFromPipeline=$true,Position=0)]$Entity) + process { + $newObject = New-Object PSObject + + foreach ($prop in $entity.content.properties.childnodes) { + if ($prop.LocalName -eq 'pstypename') { + $newObject.pstypenames.clear() + foreach ($tn in @($prop.'#Text' -split ',')) { + $newObject.pstypenames.add($tn) + } + continue + } + + $notePropType = + if ($prop.type -eq 'Edm.Boolean') { + [bool] + } elseif ($prop.Type -eq 'Edm.Datetime') { + [datetime] + } elseif ($prop.Type -eq 'Edm.Double') { + [double] + } elseif ($prop.Type -eq 'Edm.Int32') { + [int] + } elseif ($prop.Type -eq 'Edm.Int64') { + [long] + } else { + [string] + } + + + + if ($ExcludeTableInfo -and 'PartitionKey', 'RowKey', 'Timestamp' -contains $prop.LocalName) { + continue + } + + $notePropValue = [Management.Automation.LanguagePrimitives]::ConvertTo($prop.'#text', $notePropType); + $newObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty $prop.LocalName,$notePropValue)) + } + + if ((-not $ExcludeTableInfo) -and + ((-not $Select) -or ($Select -contains 'TableName')) -and + -not $newObject.TableName -and + $newObject.PSObject.Properties.Count -gt 0) { + $newObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty 'TableName',$TableName)) + } + + if ($newObject.PSObject.Properties.Count -gt 0) { + $newObject + } + + } + } + $ConvertTableMetadata = { + param([Parameter(ValueFromPipeline=$true,Position=0)]$Entity) + process { + $newObject = New-Object PSObject + $newObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty 'TableID',$entity.id)) + $newObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty 'Updated',($entity.updated -as [DateTime]))) + $newObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty 'TableName',$entity.content.properties.TableName)) + + if ($TableName -and ($TableName.Contains('*') -or $TableName.Contains('?'))) { + if ($newObject.TableName -like $TableName) { + $newObject + } + } elseif ($TableName) { + if ($newObject.TableName -eq $TableName) { + $newObject + } + } else { + $newObject + } + + } + } + } + + + process { + #region Handled Shared Access Signatures + if (-not $SharedAccessSignature) { + $SharedAccessSignature = $script:CachedSharedAccessSignature + } + if ($SharedAccessSignature) { + $script:CachedSharedAccessSignature = $SharedAccessSignature + if ($SharedAccessSignature.StartsWith('https',[StringComparison]::OrdinalIgnoreCase)) { + $StorageAccount = ([uri]$SharedAccessSignature).Host.Split('.')[0] + $SharedAccessSignature = $SharedAccessSignature.Substring($SharedAccessSignature.IndexOf('?')) + } + + if (-not $SharedAccessSignature.StartsWith('?')) { + Write-Error "Shared access signature is an invalid format" + return + } + } + #endregion + #region check for and cache the storage account + if (-not $StorageAccount) { + $storageAccount = $script:CachedStorageAccount + } + + if (-not $StorageKey) { + $StorageKey = $script:CachedStorageKey + } + + if (-not $StorageAccount) { + Write-Error "No storage account provided" + return + } + + $b64StorageKey = try { [Convert]::FromBase64String("$storageKey") } catch { } + + if (-not $b64StorageKey -and $storageKey) { + $storageKey = Get-Secret -Name $storageKey -AsPlainText + } + + if (-not $StorageKey -and -not $SharedAccessSignature) { + Write-Error "No storage key provided" + return + } + + if ($Container) { + $Container = $Container.ToLower() + } + + $script:CachedStorageAccount = $StorageAccount + $script:CachedStorageKey = $StorageKey + #endregion check for and cache the storage account + + if ($PSCmdlet.ParameterSetName -eq 'SearchTableWithWhere') { + + $FilterParts = + foreach ($whereClause in $where) { + $whereTokens = [Management.Automation.PSParser]::Tokenize($whereClause, [Ref]$null) + $LastDollarUnderbar = $null + $NewFilter = "" + for ($whereTokenCounter =0 ; $whereTokenCounter -lt $whereTokens.count; $whereTokenCounter++) { + if ($whereTokens[$whereTokenCounter].Type -eq 'Variable' -and + $whereTokens[$whereTokenCounter].Content -eq '_' -and + $whereTokens[$whereTokenCounter + 1].Type -eq 'Operator' -and + $whereTokens[$whereTokenCounter + 1].Content -eq '.') { + $LastDollarUnderbar = $whereTokens[$whereTokenCounter] + $whereTokenCounter++ + continue + } + + if ($whereTokens[$whereTokenCounter].Type -eq 'Member') { + if (-not $LastDollarUnderbar) { + Write-Error "Can only access members of `$_" + return + } else { + $NewFilter += "$($whereTokens[$whereTokenCounter].Content)" + } + continue + } + + + if ($whereTokens[$whereTokenCounter].Type -eq 'Operator') { + if ($LastDollarUnderbar) { + if ("-gt", "-lt", "-ge", "-le", "-ne", "-eq" -contains $whereTokens[$whereTokenCounter].Content) { + $newFilter += " $($whereTokens[$whereTokenCounter].Content.TrimStart('-')) " + continue + } else { + + + Write-Error "Comparison operator $($whereTokens[$whereTokenCounter].Content) is not supported. Use one of the following supported operators: -gt, -lt, -ge, -le,-ne,-eq" + return + + + } + } else { + if ("-and", "-or" -contains $whereTokens[$whereTokenCounter].content) { + $newFilter += " $($whereTokens[$whereTokenCounter].Content.TrimStart('-')) " + } else { + + Write-Error "Only the -and and -or operator can be used between clauses" + return + + + } + } + } + + if ($whereTokens[$whereTokenCounter].Type -eq 'String') { + $NewFilter += "'$($whereTokens[$whereTokenCounter].Content.Replace("'", "''"))'" + $LastDollarUnderbar = $false + } elseif ($whereTokens[$whereTokenCounter].Type -eq 'Number') { + $NewFilter += "$($whereTokens[$whereTokenCounter].Content)" + $LastDollarUnderbar = $false + } elseif ($whereTokens[$whereTokenCounter].Type -eq 'Variable') { + $varValue = $ExecutionContext.SessionState.PSVariable.Get($whereTokens[$whereTokenCounter].Content).Value + if ($varValue -as [Double] -ne $null) { + $NewFilter += "$($whereTokens[$whereTokenCounter].Content)" + $LastDollarUnderbar = $false + } elseif ($varValue -is [bool]) { + $NewFilter += "$($varValue.ToLower())" + $LastDollarUnderbar = $false + } elseif ($varValue) { + $NewFilter += "'$($varValue.ToString().Replace("'","''"))'" + $LastDollarUnderbar = $false + } + } + } + $NewFilter + } + $filter = $FilterParts -join ' and ' + #endregion + } + + $params = @{} + $PSBoundParameters + + # Store this here, so we can act like it has changed later + $parameterSetName = $PSCmdlet.ParameterSetName + + # MyInvocaiton (cached to $MyInv in begin) will actually tell me the invocation name, which, if it was called with an alias, will be the alias + if ('SearchTableWithFilter', 'SearchTableWithWhere' -contains $parameterSetName -and + $myInv.InvocationName -eq 'Get-AzureTable' -and + -not ($All -or $Filter -or $Where)) { + $parameterSetName = 'AllTables' + } + + $header = @{ + "x-ms-date" = [DateTime]::Now.ToUniversalTime().ToString("R", [Globalization.CultureInfo]::InvariantCulture) + "x-ms-version" = "2011-08-18" + "DataServiceVersion" = "2.0;NetFx" + "MaxDataServiceVersion" = "2.0;NetFx" + } + $GetWebParams = @{ + Method = 'GET' + HideProgress = $true + AsXml = $true + ContentType = 'application/atom+xml' + Header = $header + SignatureKey = $storageKey + SignaturePrefix = "SharedKey " + $StorageAccount + ":" + UseWebRequest = $true + } + + if ($parameterSetName -eq 'AllTables') { + if ($SharedAccessSignature -and -not $StorageKey) { + Write-Error "Cannot list all tables with a shared access signature" + return + } + + $uri = "https://$StorageAccount.table.core.windows.net/Tables/" + $GetWebParams += @{ + Url = $uri + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @($GetWebParams.method,"application/atom+xml",$header.'x-ms-date',"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + $containerList = Get-Web @GetWebParams #-UseWebRequest -Header $header -Url $Uri -Method GET -HideProgress -AsXml -ContentType 'application/atom+xml' + $containerList.feed.entry | + & $ConvertTableMetadata + } elseif ($parameterSetName -eq 'SpecificTableItem') { + + + $uri = "https://$StorageAccount.table.core.windows.net/${TableName}(PartitionKey='$PartitionKey',RowKey='$RowKey')?" + + + $header += @{ + 'If-Match' = '*' + } + + if ($SharedAccessSignature -and -not $StorageKey) { + $getWebParams += @{ + Uri = $uri.TrimEnd('?') + $SharedAccessSignature + } + } else { + $GetWebParams += @{ + Url = $uri + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @($GetWebParams.method,"application/atom+xml",$header.'x-ms-date',"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + } + + $containerList = Get-Web @getWebParams + + $containerList.entry | + & $ConvertEntityToPSObject + } elseif ($parameterSetName -eq 'SearchTableWithFilter' -or + $parameterSetName -eq 'SearchTableWithWhere') { + $uri = "http://$StorageAccount.table.core.windows.net/${TableName}()?" + $uriParts = New-Object Collections.ArrayList + if ($Filter) { + $null =$UriParts.Add("`$filter=$([Uri]::EscapeDataString($Filter))") + } + + if ($Select) { + $null =$UriParts.Add("`$select=$([Web.HttpUtility]::UrlEncode(($Select -join ',')))") + } + + if ($First) { + $null =$UriParts.Add("`$top=$First") + } elseif ($BatchSize) { + $null =$UriParts.Add("`$top=$BatchSize") + } + + if ($NextRowKey) { + $null =$UriParts.Add("NextRowKey=$([Web.HttpUtility]::UrlEncode($NextRowKey))") + } + + if ($NextPartitionKey) { + $null =$UriParts.Add("NextPartitionKey=$([Web.HttpUtility]::UrlEncode($NextPartitionKey))") + } + + $header +=@{ 'Accept' = 'application/atom+xml,application/xml'} + if ($SharedAccessSignature -and -not $StorageKey) { + $GetWebParams += @{ + Url = "$uri".TrimEnd('?') + $SharedAccessSignature + '&' + ($uriParts -join '&') + OutputResponseHeader = $true + } + } else { + $GetWebParams += @{ + Url = "$uri$($uriParts -join '&')" + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @($GetWebParams.method,"application/atom+xml",$header.'x-ms-date',"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + OutputResponseHeader = $true + } + } + + $containerList = Get-Web @getWebParams # -UseWebRequest -Header $header -Url $Uri -Method GET -HideProgress -AsXml -ContentType 'application/atom+xml' -OutputResponseHeader -UserAgent ' ' + $entryList = @($containerList.feed.entry) + for ($entryCounter =0 ; $entryCounter -lt $entryList.Count; $entryCounter++) { + $entry = $entryList[$entryCounter] + $convertedEntry = . $ConvertEntityToPSObject $entry + if ($entryCounter -eq ($entryList.Count - 1)) { + if ($containerList.Headers.'x-ms-continuation-NextRowKey' -and $containerList.Headers.'x-ms-continuation-NextPartitionKey') { + if ($First) { + # Output the continuation data, but don't continue + if (-not $ExcludeTableInfo) { + $convertedEntry.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty 'NextRowKey',$containerList.Headers.'x-ms-continuation-NextRowKey')) + $convertedEntry.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty 'NextPartitionKey',$containerList.Headers.'x-ms-continuation-NextPartitionKey')) + } + $convertedEntry + } else { + $ConvertedEntry + $ToSplat = @{} + $params + $toSplat.Remove('Where') + $ToSplat.Filter = $Filter + $ToSplat.NextRowKey = $containerList.Headers.'x-ms-continuation-NextRowKey' + $ToSplat.NextPartitionKey = $containerList.Headers.'x-ms-continuation-NextPartitionKey' + Get-AzureTable @toSplat + } + } else { + $convertedEntry + } + } else { + $convertedEntry + } + } + } + } +} \ No newline at end of file diff --git a/Commands/Azure/Remove-AzureTable.ps1 b/Commands/Azure/Remove-AzureTable.ps1 new file mode 100644 index 0000000..863d3d6 --- /dev/null +++ b/Commands/Azure/Remove-AzureTable.ps1 @@ -0,0 +1,152 @@ +function Remove-AzureTable +{ + <# + .Synopsis + Removes a table from Azure Storage + .Description + Removes a table or an item in a table from Azure Table Storage + .Example + # Remove the table ZTestTable2, avoiding a prompt for confirmation + Remove-AzureTable -TableName ZTestTable2 + .Example + Remove-AzureTable -TableName ZTestTable2 -PartitionKey Default -RowKey Row + .Link + Add-AzureTable + .Link + Get-AzureTable + .Link + Set-AzureTable + + #> + [CmdletBinding(ConfirmImpact='High', SupportsShouldProcess=$true)] + [OutputType([nullable])] + param( + # The name of the table + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position=0)] + [Alias('Name')] + [string]$TableName, + + # The PartitionKey + [Parameter(ValueFromPipelineByPropertyName = $true, Position=1)] + [Alias('Partition', 'Part')] + [string]$PartitionKey, + + # The RowKey + [Parameter(ValueFromPipelineByPropertyName = $true, Position=2)] + [Alias('Row')] + [string]$RowKey, + + # The storage account + [string]$StorageAccount, + + # The storage key + [string]$StorageKey, + + # A shared access signature. If this is a partial URL, the storage account is still required. + [Alias('SAS')] + [string]$SharedAccessSignature + ) + + process { + #region Handled Shared Access Signatures + if (-not $SharedAccessSignature) { + $SharedAccessSignature = $script:CachedSharedAccessSignature + } + if ($SharedAccessSignature) { + $script:CachedSharedAccessSignature = $SharedAccessSignature + if ($SharedAccessSignature.StartsWith('https',[StringComparison]::OrdinalIgnoreCase)) { + $StorageAccount = ([uri]$SharedAccessSignature).Host.Split('.')[0] + $SharedAccessSignature = $SharedAccessSignature.Substring($SharedAccessSignature.IndexOf('?')) + } + + if (-not $SharedAccessSignature.StartsWith('?')) { + Write-Error "Shared access signature is an invalid format" + return + } + } + #endregion + #region check for and cache the storage account + if (-not $StorageAccount) { + $storageAccount = $script:CachedStorageAccount + } + + if (-not $StorageKey) { + $StorageKey = $script:CachedStorageKey + } + + if (-not $StorageAccount) { + Write-Error "No storage account provided" + return + } + + $b64StorageKey = try { [Convert]::FromBase64String("$storageKey") } catch { } + + if (-not $b64StorageKey -and $storageKey) { + $storageKey =Get-SecureSetting -Name $storageKey -ValueOnly + } + + if (-not $StorageKey -and -not $SharedAccessSignature) { + Write-Error "No storage key provided" + return + } + $script:CachedStorageAccount = $StorageAccount + $script:CachedStorageKey = $StorageKey + + $nowString = [DateTime]::Now.ToUniversalTime().ToString("R", [Globalization.CultureInfo]::InvariantCulture) + $method = 'DELETE' + $GetWebParams = @{ + Header = @{ + "x-ms-date" = $nowString + "x-ms-version" = "2011-08-18" + "DataServiceVersion" = "2.0;NetFx" + "MaxDataServiceVersion" = "2.0;NetFx" + 'content-type' = "application/atom+xml" + } + AsXml = $true + HideProgress = $true + Method = $method + SignatureKey = $storageKey + SignaturePrefix = "SharedKey " + $StorageAccount + ":" + UseWebRequest = $true + } + + if ($PSBoundParameters.TableName -and $PSBoundParameters.PartitionKey -and $PSBoundParameters.RowKey) { + if ($PSCmdlet.ShouldProcess("DELETE $tablename/$PartitionKey/$RowKey")) { + $uri = "https://$StorageAccount.table.core.windows.net/${TableName}(PartitionKey='$partitionKey',RowKey='$rowKey')" + $GetWebParams.Header.'If-Match' = '*' + if ($SharedAccessSignature -and -not $StorageKey) { + $GetWebParams += @{ + Uri = $uri + $SharedAccessSignature + } + } else { + $GetWebParams += @{ + Uri = $uri + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @($method,"application/atom+xml",$NowString,"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + } + + Get-Web @GetWebParams + } + } else { + # Deleting a table + if (-not $StorageAccount -and $SharedAccessSignature) { + Write-Error 'Cannot delete a table with a shared access signature' + return + } + if ($PSCmdlet.ShouldProcess("DELETE $tablename")) { + $uri = "https://$StorageAccount.table.core.windows.net/Tables('$TableName')" + $GetWebParams += @{ + Uri = $uri + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @($method,"application/atom+xml",$NowString,"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + Get-Web @GetWebParams + } + } + } +} \ No newline at end of file diff --git a/Commands/Azure/Set-AzureTable.ps1 b/Commands/Azure/Set-AzureTable.ps1 new file mode 100644 index 0000000..6c5bc5b --- /dev/null +++ b/Commands/Azure/Set-AzureTable.ps1 @@ -0,0 +1,466 @@ +function Set-AzureTable +{ + <# + .Synopsis + Sets data in Azure Table Storage + .Description + Sets or updates data in Azure Table Storage + .Example + New-Object PSObject -Property @{number=1;word='words'} | Set-AzureTable -TableName ZTestTable3 + .Example + New-Object PSObject -Property @{letter='l'} | Set-AzureTable -TableName ZTestTable3 -Merge + .Example + New-Object PSObject -Property @{number=2} | Set-AzureTable -TableName ZTestTable3 -force + .Example + Set-AzureTable @{number=1;letter='l';word='words'} ZTestTable3 Part Row -Force + .Link + Add-AzureTable + .Link + Get-AzureTable + .Link + Remove-AzureTable + .Notes + For backwards compatiblity, Update-AzureTable is aliased to Set-AzureTable with a few changes in parameter defaults. + + If you use the alias Update-AzureTable, it's the same as calling Set-AzureTable with -TryUpdateFirst. + + #> + [OutputType([nullable], [PSObject])] + param( + # The input object. This is the data that will be stored in Azure Table Storage. + [Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)] + [Alias('Value')] + [PSObject] + $InputObject, + + # The name of the table + [Parameter(Mandatory = $true, Position=1,ValueFromPipelineByPropertyName = $true)] + [Alias("Name")] + [string]$TableName, + + # The PartitionKey + [Parameter(ValueFromPipelineByPropertyName = $true, Position=2)] + [Alias("TablePart", "TablePartition", "Partition")] + [string]$PartitionKey, + + + # The RowKey + [Parameter(ValueFromPipelineByPropertyName = $true, Position=3)] + [Alias("TableRow", "Row")] + [string]$RowKey, + + + # If set, will output the object that was inserted into Table Storage. + # Note - if the object was updated or merged, instead of set, this will involve an additional call to Get-AzureTable. + [Switch]$PassThru, + + # If set, will Force an update of an object that already exists + [Switch]$Force, + + # If set, will Merge the input data into an existing item in table storage, instead of overwriting it. + [Switch]$Merge, + + # If set, will try to overwrite an existing object first, and will set a new object if -Force is provided + [Switch]$TryUpdateFirst, + + # If set, will try to merge an existing object first, and will set a new object if -Force is provided + [Switch]$TryMergeFirst, + + # If set, will exclude table info from any outputted object. Unless -Passthru is used, this parameter has no effect. + [Switch]$ExcludeTableInfo, + + # The author name. Used for table storage metadata. + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string]$Author, + + # The author email. Used for table storage metadata. + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string]$Email, + + # If a series of objects are piped in without any Row information, numeric rows will be used. -StartAtRow indicates which row Set-AzureTable should start at under these circumstances. It defaults to 0. + [uint32] + $StartAtRow = 0, + + # The storage account + [string]$StorageAccount, + + # The storage key + [string]$StorageKey, + + # A shared access signature. If this is a partial URL, the storage account is still required. + [Alias('SAS')] + [string]$SharedAccessSignature + ) + + begin { + $myInv = $MyInvocation + + #region Azure Common Code + $InsertEntity = { + param($tableName, $partitionKey, $rowKey, $inputObject, $author, $email, $storageAccount, $storageKey, [switch]$Update, [switch]$Merge, [Switch]$ExcludeTableInfo) + + $propertiesString = new-object Text.StringBuilder + $null= $propertiesString.Append([string]::Format("<d:{0}>{1}</d:{0}> +", "PartitionKey", $partitionKey)) + $null= $propertiesString.Append([string]::Format("<d:{0}>{1}</d:{0}> +", "RowKey", $rowKey)) + $typenames =$inputObject.PSTypenames + if ($typenames[-1] -ne "System.Object" -and $typenames[-1] -ne "System.Management.Automation.PSObject") { + $typenames = $typenames -join ',' + $null = $propertiesString.Append([string]::Format("<d:psTypeName>{0}</d:psTypeName>", [Security.SecurityElement]::Escape($typenames))); + } + + + foreach ($p in $inputObject.PSObject.Properties) + { + try + { + + $valueToInsert = [Management.Automation.LanguagePrimitives]::ConvertTo($p.Value, [string]); + + $TypeString = [String]::Empty + + $valueType = if ($p.Value) { + $p.Value.GetType() + } else { + $null + } + if ($valueType -eq [int]) { + $TypeString = " m:type='Edm.Int32'" + $valueString = $valueToInsert.ToString() + } elseif ($valueType -eq [bool]) { + $TypeString = " m:type='Edm.Boolean'" + $valueString = $valueToInsert.ToString() + } elseif ($valueType -eq [Double] -or $valueType -eq [Float]) { + $TypeString = " m:type='Edm.Double'" + $valueString = $valueToInsert.ToString() + } elseif ($valueType -eq [DateTime]) { + $TypeString = " m:type='Edm.DateTime'" + $valueString = $valueToInsert.ToString('o') + } elseif ($valueType -eq [long]) { + $TypeString = " m:type='Edm.Long'" + $valueString = $valueToInsert.ToString() + } else { + $valueString = [Security.SecurityElement]::Escape($valueToInsert) + } + $null = $propertiesString.Append([string]::Format("<d:{0}$TypeString>{1}</d:{0}> +", $p.Name, $valueString )) + } + catch + { + $null = $null + } + } + + $IdString = if ($Merge -or $Update) { + "http://$storageAccount.table.core.windows.net/$tableName(PartitionKey='$PartitionKey',RowKey='$RowKey')" + } else { + [String]::Empty + } + + $requestBody = "<?xml version='1.0' encoding='utf-8' standalone='yes'?> +<entry xmlns:d='http://schemas.microsoft.com/ado/2007/08/dataservices' + xmlns:m='http://schemas.microsoft.com/ado/2007/08/dataservices/metadata' + xmlns='http://www.w3.org/2005/Atom'> + <title /> + <updated>$([datetime]::UtcNow.ToString('o'))</updated> + <author> + $(if ($Author) { + "<name>$([Security.SecurityElement]::Escape($author))</name>" + } else { + "<name/>" + }) + $(if ($email) { "<email>$([Security.SecurityElement]::Escape($email))</email>"}) + </author> + <id>$IdString</id> + <content type='application/xml'> + <m:properties> + $propertiesString + </m:properties> + </content> +</entry>" + + $uri = "https://$StorageAccount.table.core.windows.net/$tableName(PartitionKey='$partitionKey',RowKey='$rowKey')" + $nowString = [DateTime]::Now.ToUniversalTime().ToString("R", [Globalization.CultureInfo]::InvariantCulture) + $GetWebParams = @{ + Header = @{ + "x-ms-date" = $nowString + "x-ms-version" = "2011-08-18" + "DataServiceVersion" = "2.0;NetFx" + "MaxDataServiceVersion" = "2.0;NetFx" + 'content-type' = "application/atom+xml" + 'Accept-Charset' = 'UTF-8' + } + AsXml = $true + HideProgress = $true + UseWebRequest = $true + RequestBody = $requestBody + SignatureKey = $storageKey + SignaturePrefix = "SharedKey " + $StorageAccount + ":" + ContentType = "application/atom+xml" + } + + if ($merge -or $Update) { + + $getWebParams.Header.'If-Match' = '*' + } + + if ($Merge) { + if ($SharedAccessSignature -and -not $storageKey) { + $getWebParams += @{ + Method = 'MERGE' + Uri = $uri + $SharedAccessSignature + } + } + else { + $getWebParams += @{ + Method = 'MERGE' + Uri = $uri + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @('MERGE',"application/atom+xml",$NowString,"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + } + Get-Web @GetWebParams + } elseif ($Update) { + if ($SharedAccessSignature) { + $getWebParams += @{ + Method = 'PUT' + Uri = $uri + $SharedAccessSignature + } + } + else { + $getWebParams += @{ + Method = 'PUT' + Uri = $uri + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @('PUT',"application/atom+xml",$NowString,"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + } + + Get-Web @GetWebParams + } else { + $uri = "https://$StorageAccount.table.core.windows.net/$tableName" + + if ($SharedAccessSignature) + { + $getWebParams += @{ + Method = 'POST' + Uri = $uri + $SharedAccessSignature + } + } else + { + $getWebParams += @{ + Method = 'POST' + Uri = $uri + Signature = [String]::Format( + "{0}`n`n{1}`n{2}`n{3}", + @('POST',"application/atom+xml",$NowString,"/$StorageAccount$(([uri]$uri).AbsolutePath)") + ) + } + } + + + Get-Web @getWebParams + } +} + + $ConvertEntityToPSObject = { + param([Parameter(ValueFromPipeline=$true,Position=0)]$Entity) + process { + $newObject = New-Object PSObject + + foreach ($prop in $entity.content.properties.childnodes) { + if ($prop.LocalName -eq 'pstypename') { + $newObject.pstypenames.clear() + foreach ($tn in @($prop.'#Text' -split ',')) { + $newObject.pstypenames.add($tn) + } + continue + } + + $notePropType = + if ($prop.type -eq 'Edm.Boolean') { + [bool] + } elseif ($prop.Type -eq 'Edm.Datetime') { + [datetime] + } elseif ($prop.Type -eq 'Edm.Double') { + [double] + } elseif ($prop.Type -eq 'Edm.Int32') { + [int] + } elseif ($prop.Type -eq 'Edm.Int64') { + [long] + } else { + [string] + } + + + + if ($ExcludeTableInfo -and 'PartitionKey', 'RowKey', 'Timestamp' -contains $prop.LocalName) { + continue + } + + $notePropValue = [Management.Automation.LanguagePrimitives]::ConvertTo($prop.'#text', $notePropType); + $newObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty $prop.LocalName,$notePropValue)) + } + + if ((-not $ExcludeTableInfo) -and + ((-not $Select) -or ($Select -contains 'TableName')) -and + -not $newObject.TableName -and + $newObject.PSObject.Properties.Count -gt 0) { + $newObject.PSObject.Properties.Add((New-Object Management.Automation.PSNoteProperty 'TableName',$TableName)) + } + + if ($newObject.PSObject.Properties.Count -gt 0) { + $newObject + } + + } + } +#endregion Azure Common Code + + + } + + process { + #region Handled Shared Access Signatures + if (-not $SharedAccessSignature) { + $SharedAccessSignature = $script:CachedSharedAccessSignature + } + if ($SharedAccessSignature) { + $script:CachedSharedAccessSignature = $SharedAccessSignature + if ($SharedAccessSignature.StartsWith('https',[StringComparison]::OrdinalIgnoreCase)) { + $StorageAccount = ([uri]$SharedAccessSignature).Host.Split('.')[0] + $SharedAccessSignature = $SharedAccessSignature.Substring($SharedAccessSignature.IndexOf('?')) + } + + if (-not $SharedAccessSignature.StartsWith('?')) { + Write-Error "Shared access signature is an invalid format" + return + } + } + #endregion + + #region check for and cache the storage account + if (-not $currentRow) { + $currentRow = $StartAtRow + } + + if (-not $StorageAccount) { + $storageAccount = $script:CachedStorageAccount + } + + if (-not $StorageKey) { + $StorageKey = $script:CachedStorageKey + } + + $b64StorageKey = try { [Convert]::FromBase64String("$storageKey") } catch { } + + if (-not $b64StorageKey -and $storageKey) { + $storageKey =Get-SecureSetting -Name $storageKey -ValueOnly + } + + if (-not $StorageAccount) { + Write-Error "No storage account provided" + return + } + + if (-not $StorageKey -and -not $SharedAccessSignature) { + Write-Error "No storage key provided" + return + } + $script:CachedStorageAccount = $StorageAccount + $script:CachedStorageKey = $StorageKey + #endregion check for and cache the storage account + + + $isReallyUpdate = $myInv.InvocationName -eq 'Update-AzureTable' -or $myInv.InvocationName -eq 'uat' + + if ($isReallyUpdate) { + if ($Merge) { + $TryMergeFirst = $true + } else { + $TryUpdateFirst = $true + } + } + if (-not $psBoundParameters.RowKey) { + $RowKey = $currentRow + $currentRow++ + } + + if (-not $PartitionKey) { + $PartitionKey = "Default" + } + + $WasMergeOrUpdate = $false + + + if ($inputObject -is [Hashtable]) { + $inputObject = New-Object PSObject -Property $inputObject + } + + + $OperationResult = + if ($TryUpdateFirst) { + $tryToSet = & $InsertEntity $TableName $PartitionKey $RowKey $InputObject $Author $Email -ExcludeTableInfo:$ExcludeTableInfo -storageAccount $StorageAccount -storageKey $StorageKey -Update 2>&1 + if ($tryToSet -is [Management.Automation.ErrorRecord]) { + $errorCode = ($tryToSet.Exception.Message -as [xml]).error.code + if ($errorCode -eq 'ResourceNotFound' -and $Force) { + & $InsertEntity $TableName $PartitionKey $RowKey $InputObject $Author $Email -ExcludeTableInfo:$ExcludeTableInfo -storageAccount $StorageAccount -storageKey $StorageKey + } + } else { + $WasMergeOrUpdate = $true + $tryToSet + + } + } elseif ($TryMergeFirst) { + $tryToSet = & $InsertEntity $TableName $PartitionKey $RowKey $InputObject $Author $Email -ExcludeTableInfo:$ExcludeTableInfo -storageAccount $StorageAccount -storageKey $StorageKey -Merge 2>&1 + if ($tryToSet -is [Management.Automation.ErrorRecord]) { + $errorCode = ($tryToSet.Exception.Message -as [xml]).error.code + if ($errorCode -eq 'ResourceNotFound' -and ($Force -or $Merge)) { + & $InsertEntity $TableName $PartitionKey $RowKey $InputObject $Author $Email -ExcludeTableInfo:$ExcludeTableInfo -storageAccount $StorageAccount -storageKey $StorageKey + } else { + Write-Error -ErrorRecord $tryToSet + } + } else { + $WasMergeOrUpdate = $true + $tryToSet + } + } else { + $tryToSet = & $InsertEntity $TableName $PartitionKey $RowKey $InputObject $Author $Email -ExcludeTableInfo:$ExcludeTableInfo -storageAccount $StorageAccount -storageKey $StorageKey 2>&1 + if ($tryToSet -is [Management.Automation.ErrorRecord]) { + $errorCode = ($tryToSet.Exception.Message -as [xml]).error.code + if ($errorCode -eq 'EntityAlreadyExists') { + if ($Force -or $Merge) { + if ($Force) { + $WasMergeOrUpdate = $true + & $InsertEntity $TableName $PartitionKey $RowKey $InputObject $Author $Email -ExcludeTableInfo:$ExcludeTableInfo -storageAccount $StorageAccount -storageKey $StorageKey -Update + } elseif ($Merge) { + $WasMergeOrUpdate = $true + & $InsertEntity $TableName $PartitionKey $RowKey $InputObject $Author $Email -ExcludeTableInfo:$ExcludeTableInfo -storageAccount $StorageAccount -storageKey $StorageKey -Merge + } + } else { + Write-Error -ErrorRecord $tryToSet + return + } + } else { + Write-Error -ErrorRecord $tryToSet + return + } + } else { + $tryToSet + } + } + + if ($OperationResult -and $PassThru) { + $OperationResult.entry | + ForEach-Object $ConvertEntityToPSObject + } elseif ($PassThru -and $WasMergeOrUpdate) { + # Updates and Merges don't actually return a result, so we have to do a Get + Get-AzureTable -TableName $tableName -Partition $partitionKey -Row $rowKey + } + } +}