mirror of
https://github.com/Grace-Solutions/PSMinIO.git
synced 2026-07-26 06:48:13 +00:00
Initial commit: PSMinIO module with chunked transfer support
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build script for PSMinIO PowerShell module
|
||||
|
||||
.DESCRIPTION
|
||||
This script builds the PSMinIO module, copies required files, and prepares the module for distribution.
|
||||
|
||||
.PARAMETER Configuration
|
||||
Build configuration (Debug or Release). Default: Release
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Output path for the built module. Default: Module/PSMinIO
|
||||
|
||||
.PARAMETER Clean
|
||||
Clean the output directory before building
|
||||
|
||||
.PARAMETER Test
|
||||
Run tests after building (if test files exist)
|
||||
|
||||
.PARAMETER Package
|
||||
Create a distributable package after building
|
||||
|
||||
.EXAMPLE
|
||||
.\Build.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Build.ps1 -Configuration Debug -Clean
|
||||
|
||||
.EXAMPLE
|
||||
.\Build.ps1 -Package
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Release',
|
||||
|
||||
[string]$OutputPath = 'Module\PSMinIO',
|
||||
|
||||
[switch]$Clean,
|
||||
|
||||
[switch]$Test,
|
||||
|
||||
[switch]$Package
|
||||
)
|
||||
|
||||
# Set error action preference
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Get script directory
|
||||
$ScriptRoot = $PSScriptRoot
|
||||
$ProjectFile = Join-Path $ScriptRoot 'PSMinIO.csproj'
|
||||
$ModuleManifest = Join-Path $ScriptRoot 'PSMinIO.psd1'
|
||||
|
||||
Write-Host "=== PSMinIO Build Script ===" -ForegroundColor Cyan
|
||||
Write-Host "Configuration: $Configuration" -ForegroundColor Gray
|
||||
Write-Host "Output Path: $OutputPath" -ForegroundColor Gray
|
||||
Write-Host "Script Root: $ScriptRoot" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
# Function to write status messages
|
||||
function Write-Status {
|
||||
param([string]$Message, [string]$Color = 'Yellow')
|
||||
Write-Host ">>> $Message" -ForegroundColor $Color
|
||||
}
|
||||
|
||||
# Function to check if command exists
|
||||
function Test-Command {
|
||||
param([string]$Command)
|
||||
$null -ne (Get-Command $Command -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
try {
|
||||
# Check prerequisites
|
||||
Write-Status "Checking prerequisites..."
|
||||
|
||||
if (!(Test-Command 'dotnet')) {
|
||||
throw ".NET SDK is required but not found. Please install .NET SDK 6.0 or later."
|
||||
}
|
||||
|
||||
$dotnetVersion = dotnet --version
|
||||
Write-Host " .NET SDK Version: $dotnetVersion" -ForegroundColor Green
|
||||
|
||||
if (!(Test-Path $ProjectFile)) {
|
||||
throw "Project file not found: $ProjectFile"
|
||||
}
|
||||
|
||||
if (!(Test-Path $ModuleManifest)) {
|
||||
throw "Module manifest not found: $ModuleManifest"
|
||||
}
|
||||
|
||||
Write-Host " ✓ Prerequisites check passed" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Clean output directory if requested
|
||||
if ($Clean -and (Test-Path $OutputPath)) {
|
||||
Write-Status "Cleaning output directory..."
|
||||
Remove-Item -Path $OutputPath -Recurse -Force
|
||||
Write-Host " ✓ Output directory cleaned" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Ensure output directory exists
|
||||
if (!(Test-Path $OutputPath)) {
|
||||
Write-Status "Creating output directory..."
|
||||
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
|
||||
Write-Host " ✓ Output directory created" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Build the project
|
||||
Write-Status "Building project..."
|
||||
$buildArgs = @(
|
||||
'build'
|
||||
$ProjectFile
|
||||
'--configuration', $Configuration
|
||||
'--verbosity', 'minimal'
|
||||
'--nologo'
|
||||
)
|
||||
|
||||
& dotnet @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Build failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host " ✓ Build completed successfully" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Copy built files to module directory
|
||||
Write-Status "Copying module files..."
|
||||
|
||||
$binPath = Join-Path $OutputPath 'bin'
|
||||
if (!(Test-Path $binPath)) {
|
||||
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
|
||||
}
|
||||
|
||||
# Copy main assembly and dependencies
|
||||
$buildOutputPath = "bin\$Configuration\netstandard2.0"
|
||||
$filesToCopy = @(
|
||||
'PSMinIO.dll',
|
||||
'PSMinIO.pdb',
|
||||
'Minio.dll',
|
||||
'System.Text.Json.dll'
|
||||
)
|
||||
|
||||
foreach ($file in $filesToCopy) {
|
||||
$sourcePath = Join-Path $buildOutputPath $file
|
||||
$destPath = Join-Path $binPath $file
|
||||
|
||||
if (Test-Path $sourcePath) {
|
||||
Copy-Item -Path $sourcePath -Destination $destPath -Force
|
||||
Write-Host " ✓ Copied: $file" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Warning " File not found: $file"
|
||||
}
|
||||
}
|
||||
|
||||
# Copy module manifest
|
||||
$manifestDest = Join-Path $OutputPath 'PSMinIO.psd1'
|
||||
Copy-Item -Path $ModuleManifest -Destination $manifestDest -Force
|
||||
Write-Host " ✓ Copied: PSMinIO.psd1" -ForegroundColor Green
|
||||
|
||||
# Copy type and format files
|
||||
$typesPath = Join-Path $OutputPath 'types'
|
||||
if (!(Test-Path $typesPath)) {
|
||||
New-Item -ItemType Directory -Path $typesPath -Force | Out-Null
|
||||
}
|
||||
|
||||
$typeFiles = Get-ChildItem -Path 'Module\PSMinIO\types\*.ps1xml' -ErrorAction SilentlyContinue
|
||||
foreach ($typeFile in $typeFiles) {
|
||||
$destPath = Join-Path $typesPath $typeFile.Name
|
||||
Copy-Item -Path $typeFile.FullName -Destination $destPath -Force
|
||||
Write-Host " ✓ Copied: $($typeFile.Name)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Validate module
|
||||
Write-Status "Validating module..."
|
||||
|
||||
try {
|
||||
$moduleInfo = Test-ModuleManifest -Path $manifestDest -ErrorAction Stop
|
||||
Write-Host " ✓ Module manifest is valid" -ForegroundColor Green
|
||||
Write-Host " Name: $($moduleInfo.Name)" -ForegroundColor Gray
|
||||
Write-Host " Version: $($moduleInfo.Version)" -ForegroundColor Gray
|
||||
Write-Host " Author: $($moduleInfo.Author)" -ForegroundColor Gray
|
||||
Write-Host " Cmdlets: $($moduleInfo.ExportedCmdlets.Count)" -ForegroundColor Gray
|
||||
} catch {
|
||||
Write-Warning "Module manifest validation failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Run tests if requested
|
||||
if ($Test) {
|
||||
Write-Status "Running tests..."
|
||||
|
||||
# Check if Pester is available
|
||||
if (Test-Command 'Invoke-Pester') {
|
||||
$testPath = Join-Path $ScriptRoot 'Tests'
|
||||
if (Test-Path $testPath) {
|
||||
try {
|
||||
Invoke-Pester -Path $testPath -PassThru
|
||||
Write-Host " ✓ Tests completed" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Warning "Tests failed: $($_.Exception.Message)"
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Test directory not found: $testPath"
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Pester module not found. Install with: Install-Module -Name Pester"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Create package if requested
|
||||
if ($Package) {
|
||||
Write-Status "Creating package..."
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyy.MM.dd.HHmm'
|
||||
$packageDir = Join-Path 'Releases' $timestamp
|
||||
|
||||
if (!(Test-Path $packageDir)) {
|
||||
New-Item -ItemType Directory -Path $packageDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Create ZIP package
|
||||
$packagePath = Join-Path $packageDir "PSMinIO-$timestamp.zip"
|
||||
Compress-Archive -Path $OutputPath -DestinationPath $packagePath -Force
|
||||
|
||||
Write-Host " ✓ Package created: $packagePath" -ForegroundColor Green
|
||||
|
||||
# Copy documentation
|
||||
$docsSource = Join-Path $ScriptRoot 'docs'
|
||||
if (Test-Path $docsSource) {
|
||||
$docsDest = Join-Path $packageDir 'docs'
|
||||
Copy-Item -Path $docsSource -Destination $docsDest -Recurse -Force
|
||||
Write-Host " ✓ Documentation copied" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Copy README and LICENSE
|
||||
$readmePath = Join-Path $ScriptRoot 'README.md'
|
||||
$licensePath = Join-Path $ScriptRoot 'LICENSE'
|
||||
|
||||
if (Test-Path $readmePath) {
|
||||
Copy-Item -Path $readmePath -Destination $packageDir -Force
|
||||
Write-Host " ✓ README copied" -ForegroundColor Green
|
||||
}
|
||||
|
||||
if (Test-Path $licensePath) {
|
||||
Copy-Item -Path $licensePath -Destination $packageDir -Force
|
||||
Write-Host " ✓ LICENSE copied" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Build summary
|
||||
Write-Status "Build Summary" "Green"
|
||||
Write-Host " Configuration: $Configuration" -ForegroundColor Gray
|
||||
Write-Host " Output Path: $OutputPath" -ForegroundColor Gray
|
||||
Write-Host " Module Files: $(Get-ChildItem -Path $OutputPath -Recurse -File | Measure-Object | Select-Object -ExpandProperty Count)" -ForegroundColor Gray
|
||||
|
||||
if ($Package) {
|
||||
Write-Host " Package: $packagePath" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Build Completed Successfully ===" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "To import the module, run:" -ForegroundColor Cyan
|
||||
Write-Host " Import-Module '$OutputPath\PSMinIO.psd1'" -ForegroundColor White
|
||||
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "=== Build Failed ===" -ForegroundColor Red
|
||||
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Configuration>
|
||||
<ViewDefinitions>
|
||||
|
||||
<!-- MinIOBucketInfo Table View -->
|
||||
<View>
|
||||
<Name>PSMinIO.Models.MinIOBucketInfo</Name>
|
||||
<ViewSelectedBy>
|
||||
<TypeName>PSMinIO.Models.MinIOBucketInfo</TypeName>
|
||||
</ViewSelectedBy>
|
||||
<TableControl>
|
||||
<TableHeaders>
|
||||
<TableColumnHeader>
|
||||
<Label>Name</Label>
|
||||
<Width>25</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>Created</Label>
|
||||
<Width>20</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>Region</Label>
|
||||
<Width>15</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
</TableHeaders>
|
||||
<TableRowEntries>
|
||||
<TableRowEntry>
|
||||
<TableColumnItems>
|
||||
<TableColumnItem>
|
||||
<PropertyName>Name</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>CreatedFormatted</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>Region</PropertyName>
|
||||
</TableColumnItem>
|
||||
</TableColumnItems>
|
||||
</TableRowEntry>
|
||||
</TableRowEntries>
|
||||
</TableControl>
|
||||
</View>
|
||||
|
||||
<!-- MinIOObjectInfo Table View -->
|
||||
<View>
|
||||
<Name>PSMinIO.Models.MinIOObjectInfo</Name>
|
||||
<ViewSelectedBy>
|
||||
<TypeName>PSMinIO.Models.MinIOObjectInfo</TypeName>
|
||||
</ViewSelectedBy>
|
||||
<TableControl>
|
||||
<TableHeaders>
|
||||
<TableColumnHeader>
|
||||
<Label>Name</Label>
|
||||
<Width>30</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>Size</Label>
|
||||
<Width>12</Width>
|
||||
<Alignment>Right</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>LastModified</Label>
|
||||
<Width>20</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>ETag</Label>
|
||||
<Width>20</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
</TableHeaders>
|
||||
<TableRowEntries>
|
||||
<TableRowEntry>
|
||||
<TableColumnItems>
|
||||
<TableColumnItem>
|
||||
<PropertyName>Name</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>SizeFormatted</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>LastModifiedFormatted</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>ETag</PropertyName>
|
||||
</TableColumnItem>
|
||||
</TableColumnItems>
|
||||
</TableRowEntry>
|
||||
</TableRowEntries>
|
||||
</TableControl>
|
||||
</View>
|
||||
|
||||
<!-- MinIOConfiguration Table View -->
|
||||
<View>
|
||||
<Name>PSMinIO.Models.MinIOConfiguration</Name>
|
||||
<ViewSelectedBy>
|
||||
<TypeName>PSMinIO.Models.MinIOConfiguration</TypeName>
|
||||
</ViewSelectedBy>
|
||||
<TableControl>
|
||||
<TableHeaders>
|
||||
<TableColumnHeader>
|
||||
<Label>Endpoint</Label>
|
||||
<Width>30</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>AccessKey</Label>
|
||||
<Width>15</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>SecretKey</Label>
|
||||
<Width>15</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>UseSSL</Label>
|
||||
<Width>8</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
</TableHeaders>
|
||||
<TableRowEntries>
|
||||
<TableRowEntry>
|
||||
<TableColumnItems>
|
||||
<TableColumnItem>
|
||||
<PropertyName>EndpointDisplay</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>AccessKeyMasked</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>SecretKeyMasked</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>UseSSL</PropertyName>
|
||||
</TableColumnItem>
|
||||
</TableColumnItems>
|
||||
</TableRowEntry>
|
||||
</TableRowEntries>
|
||||
</TableControl>
|
||||
</View>
|
||||
|
||||
<!-- MinIOStats Table View -->
|
||||
<View>
|
||||
<Name>PSMinIO.Models.MinIOStats</Name>
|
||||
<ViewSelectedBy>
|
||||
<TypeName>PSMinIO.Models.MinIOStats</TypeName>
|
||||
</ViewSelectedBy>
|
||||
<TableControl>
|
||||
<TableHeaders>
|
||||
<TableColumnHeader>
|
||||
<Label>TotalBuckets</Label>
|
||||
<Width>15</Width>
|
||||
<Alignment>Right</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>TotalObjects</Label>
|
||||
<Width>15</Width>
|
||||
<Alignment>Right</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>TotalSize</Label>
|
||||
<Width>15</Width>
|
||||
<Alignment>Right</Alignment>
|
||||
</TableColumnHeader>
|
||||
<TableColumnHeader>
|
||||
<Label>LastUpdated</Label>
|
||||
<Width>20</Width>
|
||||
<Alignment>Left</Alignment>
|
||||
</TableColumnHeader>
|
||||
</TableHeaders>
|
||||
<TableRowEntries>
|
||||
<TableRowEntry>
|
||||
<TableColumnItems>
|
||||
<TableColumnItem>
|
||||
<PropertyName>TotalBuckets</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>TotalObjects</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>TotalSizeFormatted</PropertyName>
|
||||
</TableColumnItem>
|
||||
<TableColumnItem>
|
||||
<PropertyName>LastUpdated</PropertyName>
|
||||
</TableColumnItem>
|
||||
</TableColumnItems>
|
||||
</TableRowEntry>
|
||||
</TableRowEntries>
|
||||
</TableControl>
|
||||
</View>
|
||||
|
||||
</ViewDefinitions>
|
||||
</Configuration>
|
||||
@@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Types>
|
||||
<Type>
|
||||
<Name>PSMinIO.Models.MinIOBucketInfo</Name>
|
||||
<Members>
|
||||
<ScriptProperty>
|
||||
<Name>CreatedFormatted</Name>
|
||||
<GetScriptBlock>
|
||||
$this.Created.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>SizeFormatted</Name>
|
||||
<GetScriptBlock>
|
||||
if ($this.Size -eq $null) { return "N/A" }
|
||||
$sizes = @("B", "KB", "MB", "GB", "TB", "PB")
|
||||
$index = 0
|
||||
$size = $this.Size
|
||||
while ($size -ge 1024 -and $index -lt $sizes.Length - 1) {
|
||||
$size = $size / 1024
|
||||
$index++
|
||||
}
|
||||
return "{0:F2} {1}" -f $size, $sizes[$index]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
</Members>
|
||||
</Type>
|
||||
|
||||
<Type>
|
||||
<Name>PSMinIO.Models.MinIOObjectInfo</Name>
|
||||
<Members>
|
||||
<ScriptProperty>
|
||||
<Name>LastModifiedFormatted</Name>
|
||||
<GetScriptBlock>
|
||||
$this.LastModified.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>SizeFormatted</Name>
|
||||
<GetScriptBlock>
|
||||
if ($this.Size -eq $null) { return "N/A" }
|
||||
$sizes = @("B", "KB", "MB", "GB", "TB", "PB")
|
||||
$index = 0
|
||||
$size = $this.Size
|
||||
while ($size -ge 1024 -and $index -lt $sizes.Length - 1) {
|
||||
$size = $size / 1024
|
||||
$index++
|
||||
}
|
||||
return "{0:F2} {1}" -f $size, $sizes[$index]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>GetFileName</Name>
|
||||
<GetScriptBlock>
|
||||
if ($this.Name -eq $null) { return "" }
|
||||
$parts = $this.Name.Split('/')
|
||||
return $parts[$parts.Length - 1]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>HasPresignedUrl</Name>
|
||||
<GetScriptBlock>
|
||||
return -not [string]::IsNullOrEmpty($this.PresignedUrl)
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>PresignedUrlValid</Name>
|
||||
<GetScriptBlock>
|
||||
if ($this.PresignedUrlExpiration -eq $null) { return $false }
|
||||
return $this.PresignedUrlExpiration -gt [DateTime]::UtcNow
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
</Members>
|
||||
</Type>
|
||||
|
||||
<Type>
|
||||
<Name>PSMinIO.Models.MinIOConfiguration</Name>
|
||||
<Members>
|
||||
<ScriptProperty>
|
||||
<Name>EndpointDisplay</Name>
|
||||
<GetScriptBlock>
|
||||
if ($this.UseSSL) {
|
||||
return "https://$($this.Endpoint)"
|
||||
} else {
|
||||
return "http://$($this.Endpoint)"
|
||||
}
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>AccessKeyMasked</Name>
|
||||
<GetScriptBlock>
|
||||
if ([string]::IsNullOrEmpty($this.AccessKey)) { return "Not Set" }
|
||||
if ($this.AccessKey.Length -le 4) { return $this.AccessKey }
|
||||
return $this.AccessKey.Substring(0, 4) + "*".PadRight($this.AccessKey.Length - 4, '*')
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>SecretKeyMasked</Name>
|
||||
<GetScriptBlock>
|
||||
if ([string]::IsNullOrEmpty($this.SecretKey)) { return "Not Set" }
|
||||
return "*".PadRight($this.SecretKey.Length, '*')
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<Name>SecurityStatus</Name>
|
||||
<GetScriptBlock>
|
||||
if ($this.SkipCertificateValidation) {
|
||||
return "SSL (Certificate Validation Disabled)"
|
||||
} elseif ($this.UseSSL) {
|
||||
return "SSL (Secure)"
|
||||
} else {
|
||||
return "HTTP (Insecure)"
|
||||
}
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
</Members>
|
||||
</Type>
|
||||
|
||||
<Type>
|
||||
<Name>PSMinIO.Models.MinIOStats</Name>
|
||||
<Members>
|
||||
<ScriptProperty>
|
||||
<Name>TotalSizeFormatted</Name>
|
||||
<GetScriptBlock>
|
||||
if ($this.TotalSize -eq $null) { return "N/A" }
|
||||
$sizes = @("B", "KB", "MB", "GB", "TB", "PB")
|
||||
$index = 0
|
||||
$size = $this.TotalSize
|
||||
while ($size -ge 1024 -and $index -lt $sizes.Length - 1) {
|
||||
$size = $size / 1024
|
||||
$index++
|
||||
}
|
||||
return "{0:F2} {1}" -f $size, $sizes[$index]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
</Members>
|
||||
</Type>
|
||||
|
||||
<!-- ChunkedTransferState Type -->
|
||||
<Type>
|
||||
<n>PSMinIO.Models.ChunkedTransferState</n>
|
||||
<Members>
|
||||
<ScriptProperty>
|
||||
<n>ProgressFormatted</n>
|
||||
<GetScriptBlock>
|
||||
return "{0:F1}%" -f $this.ProgressPercentage
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>BytesTransferredFormatted</n>
|
||||
<GetScriptBlock>
|
||||
if ($this.BytesTransferred -eq $null) { return "0 B" }
|
||||
$sizes = @("B", "KB", "MB", "GB", "TB", "PB")
|
||||
$index = 0
|
||||
$size = $this.BytesTransferred
|
||||
while ($size -ge 1024 -and $index -lt $sizes.Length - 1) {
|
||||
$size = $size / 1024
|
||||
$index++
|
||||
}
|
||||
return "{0:F2} {1}" -f $size, $sizes[$index]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>TotalSizeFormatted</n>
|
||||
<GetScriptBlock>
|
||||
if ($this.TotalSize -eq $null) { return "0 B" }
|
||||
$sizes = @("B", "KB", "MB", "GB", "TB", "PB")
|
||||
$index = 0
|
||||
$size = $this.TotalSize
|
||||
while ($size -ge 1024 -and $index -lt $sizes.Length - 1) {
|
||||
$size = $size / 1024
|
||||
$index++
|
||||
}
|
||||
return "{0:F2} {1}" -f $size, $sizes[$index]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>ChunkSizeFormatted</n>
|
||||
<GetScriptBlock>
|
||||
if ($this.ChunkSize -eq $null) { return "0 B" }
|
||||
$sizes = @("B", "KB", "MB", "GB", "TB", "PB")
|
||||
$index = 0
|
||||
$size = $this.ChunkSize
|
||||
while ($size -ge 1024 -and $index -lt $sizes.Length - 1) {
|
||||
$size = $size / 1024
|
||||
$index++
|
||||
}
|
||||
return "{0:F2} {1}" -f $size, $sizes[$index]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>ElapsedTime</n>
|
||||
<GetScriptBlock>
|
||||
if ($this.StartTime -eq $null) { return "Unknown" }
|
||||
$elapsed = [DateTime]::UtcNow - $this.StartTime
|
||||
return $elapsed.ToString("hh\:mm\:ss")
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>TransferStatus</n>
|
||||
<GetScriptBlock>
|
||||
if ($this.IsComplete) {
|
||||
return "Complete"
|
||||
} elseif ($this.CompletedChunkCount -gt 0) {
|
||||
return "In Progress"
|
||||
} else {
|
||||
return "Not Started"
|
||||
}
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>RemainingChunks</n>
|
||||
<GetScriptBlock>
|
||||
return $this.TotalChunks - $this.CompletedChunkCount
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
</Members>
|
||||
</Type>
|
||||
|
||||
<!-- ChunkInfo Type -->
|
||||
<Type>
|
||||
<n>PSMinIO.Models.ChunkInfo</n>
|
||||
<Members>
|
||||
<ScriptProperty>
|
||||
<n>SizeFormatted</n>
|
||||
<GetScriptBlock>
|
||||
if ($this.Size -eq $null) { return "0 B" }
|
||||
$sizes = @("B", "KB", "MB", "GB", "TB", "PB")
|
||||
$index = 0
|
||||
$size = $this.Size
|
||||
while ($size -ge 1024 -and $index -lt $sizes.Length - 1) {
|
||||
$size = $size / 1024
|
||||
$index++
|
||||
}
|
||||
return "{0:F2} {1}" -f $size, $sizes[$index]
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>ChunkRange</n>
|
||||
<GetScriptBlock>
|
||||
return "{0}-{1}" -f $this.StartByte, $this.EndByte
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
<ScriptProperty>
|
||||
<n>Status</n>
|
||||
<GetScriptBlock>
|
||||
if ($this.IsCompleted) {
|
||||
return "Completed"
|
||||
} elseif ($this.RetryCount -gt 0) {
|
||||
return "Failed ({0} retries)" -f $this.RetryCount
|
||||
} else {
|
||||
return "Pending"
|
||||
}
|
||||
</GetScriptBlock>
|
||||
</ScriptProperty>
|
||||
</Members>
|
||||
</Type>
|
||||
</Types>
|
||||
@@ -0,0 +1,66 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<AssemblyName>PSMinIO</AssemblyName>
|
||||
<RootNamespace>PSMinIO</RootNamespace>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<AssemblyTitle>PSMinIO PowerShell Module</AssemblyTitle>
|
||||
<AssemblyDescription>A PowerShell module for MinIO object storage operations</AssemblyDescription>
|
||||
<AssemblyCompany>PSMinIO</AssemblyCompany>
|
||||
<AssemblyProduct>PSMinIO</AssemblyProduct>
|
||||
<AssemblyCopyright>Copyright © 2025</AssemblyCopyright>
|
||||
<AssemblyVersion>2025.07.10.1200</AssemblyVersion>
|
||||
<FileVersion>2025.07.10.1200</FileVersion>
|
||||
<InformationalVersion>2025.07.10.1200</InformationalVersion>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<WarningsAsErrors />
|
||||
<WarningsNotAsErrors />
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<DebugType>full</DebugType>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<Optimize>true</Optimize>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Minio" Version="5.0.0" />
|
||||
<PackageReference Include="PowerShellStandard.Library" Version="5.1.1" />
|
||||
<PackageReference Include="System.Text.Json" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="src\**\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="PSMinIO.psd1">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="Module\PSMinIO\types\*.ps1xml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyModuleFiles" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<ModuleFiles Include="$(OutputPath)\PSMinIO.dll" />
|
||||
<ModuleFiles Include="$(OutputPath)\PSMinIO.pdb" />
|
||||
<ModuleFiles Include="$(OutputPath)\Minio.dll" />
|
||||
<ModuleFiles Include="$(OutputPath)\System.Text.Json.dll" />
|
||||
<ModuleFiles Include="PSMinIO.psd1" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(ModuleFiles)" DestinationFolder="Module\PSMinIO\bin\" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
@{
|
||||
# Script module or binary module file associated with this manifest.
|
||||
RootModule = 'bin\PSMinIO.dll'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '2025.07.10.1200'
|
||||
|
||||
# Supported PSEditions
|
||||
CompatiblePSEditions = @('Desktop', 'Core')
|
||||
|
||||
# ID used to uniquely identify this module
|
||||
GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
# Author of this module
|
||||
Author = 'PSMinIO Team'
|
||||
|
||||
# Company or vendor of this module
|
||||
CompanyName = 'PSMinIO'
|
||||
|
||||
# Copyright statement for this module
|
||||
Copyright = '(c) 2025 PSMinIO Team. All rights reserved.'
|
||||
|
||||
# Description of the functionality provided by this module
|
||||
Description = 'A PowerShell module for MinIO object storage operations built on the Minio .NET SDK'
|
||||
|
||||
# Minimum version of the PowerShell engine required by this module
|
||||
PowerShellVersion = '5.1'
|
||||
|
||||
# Name of the PowerShell host required by this module
|
||||
# PowerShellHostName = ''
|
||||
|
||||
# Minimum version of the PowerShell host required by this module
|
||||
# PowerShellHostVersion = ''
|
||||
|
||||
# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
DotNetFrameworkVersion = '4.7.2'
|
||||
|
||||
# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
CLRVersion = '4.0'
|
||||
|
||||
# Processor architecture (None, X86, Amd64) required by this module
|
||||
# ProcessorArchitecture = ''
|
||||
|
||||
# Modules that must be imported into the global environment prior to importing this module
|
||||
# RequiredModules = @()
|
||||
|
||||
# Assemblies that must be loaded prior to importing this module
|
||||
RequiredAssemblies = @('bin\PSMinIO.dll', 'bin\Minio.dll')
|
||||
|
||||
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
|
||||
# ScriptsToProcess = @()
|
||||
|
||||
# Type files (.ps1xml) to be loaded when importing this module
|
||||
TypesToProcess = @('types\PSMinIO.Types.ps1xml')
|
||||
|
||||
# Format files (.ps1xml) to be loaded when importing this module
|
||||
FormatsToProcess = @('types\PSMinIO.Format.ps1xml')
|
||||
|
||||
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
|
||||
# NestedModules = @()
|
||||
|
||||
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
|
||||
FunctionsToExport = @()
|
||||
|
||||
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
|
||||
CmdletsToExport = @(
|
||||
'Connect-MinIO',
|
||||
'Get-MinIOBucket',
|
||||
'New-MinIOBucket',
|
||||
'Remove-MinIOBucket',
|
||||
'Test-MinIOBucketExists',
|
||||
'Get-MinIOObject',
|
||||
'New-MinIOObject',
|
||||
'New-MinIOObjectChunked',
|
||||
'New-MinIOFolder',
|
||||
'Get-MinIOObjectContent',
|
||||
'Get-MinIOObjectContentChunked',
|
||||
'Remove-MinIOObject',
|
||||
'Get-MinIOBucketPolicy',
|
||||
'Set-MinIOBucketPolicy',
|
||||
'Get-MinIOStats'
|
||||
)
|
||||
|
||||
# Variables to export from this module
|
||||
VariablesToExport = @()
|
||||
|
||||
# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
|
||||
AliasesToExport = @()
|
||||
|
||||
# DSC resources to export from this module
|
||||
# DscResourcesToExport = @()
|
||||
|
||||
# List of all modules packaged with this module
|
||||
# ModuleList = @()
|
||||
|
||||
# List of all files packaged with this module
|
||||
FileList = @(
|
||||
'PSMinIO.psd1',
|
||||
'bin\PSMinIO.dll',
|
||||
'bin\Minio.dll',
|
||||
'types\PSMinIO.Types.ps1xml',
|
||||
'types\PSMinIO.Format.ps1xml'
|
||||
)
|
||||
|
||||
# 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 = @{
|
||||
PSData = @{
|
||||
# Tags applied to this module. These help with module discovery in online galleries.
|
||||
Tags = @('MinIO', 'ObjectStorage', 'S3', 'Cloud', 'Storage', 'Bucket', 'Object')
|
||||
|
||||
# A URL to the license for this module.
|
||||
LicenseUri = 'https://github.com/PSMinIO/PSMinIO/blob/main/LICENSE'
|
||||
|
||||
# A URL to the main website for this project.
|
||||
ProjectUri = 'https://github.com/PSMinIO/PSMinIO'
|
||||
|
||||
# A URL to an icon representing this module.
|
||||
# IconUri = ''
|
||||
|
||||
# ReleaseNotes of this module
|
||||
ReleaseNotes = 'Initial release of PSMinIO module with comprehensive MinIO object storage operations support.'
|
||||
|
||||
# Prerelease string of this module
|
||||
# Prerelease = ''
|
||||
|
||||
# Flag to indicate whether the module requires explicit user acceptance for install/update/save
|
||||
# RequireLicenseAcceptance = $false
|
||||
|
||||
# External dependent modules of this module
|
||||
# ExternalModuleDependencies = @()
|
||||
}
|
||||
}
|
||||
|
||||
# HelpInfo URI of this module
|
||||
# HelpInfoURI = ''
|
||||
|
||||
# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
|
||||
# DefaultCommandPrefix = ''
|
||||
}
|
||||
@@ -1 +1,71 @@
|
||||
# PSMinIO
|
||||
# PSMinIO
|
||||
|
||||
A fully-fledged C# PowerShell binary module built on top of the [Minio](https://www.nuget.org/packages/Minio) .NET SDK for managing MinIO object storage operations.
|
||||
|
||||
## Features
|
||||
|
||||
- **Cross-Platform Compatibility**: Built for .NET Standard 2.0, compatible with PowerShell 5.1+ and PowerShell 7+
|
||||
- **Comprehensive Bucket Operations**: Create, list, delete, and check bucket existence
|
||||
- **Object Management**: Upload, download, list, and delete objects with progress tracking
|
||||
- **Security & Policy Management**: Manage bucket policies and access controls
|
||||
- **Synchronous Operations**: All operations are synchronous for PowerShell compatibility
|
||||
- **Detailed Logging**: Centralized logging with timestamps when `-Verbose` is specified
|
||||
- **Progress Reporting**: Upload/download progress with percentages and time estimates
|
||||
|
||||
## Installation
|
||||
|
||||
```powershell
|
||||
# Import the module
|
||||
Import-Module .\PSMinIO.psd1
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```powershell
|
||||
# Configure connection
|
||||
Set-MinIOConfig -Endpoint 'https://minio.myorg.com' -AccessKey 'AKIA...' -SecretKey 'abc123' -UseSSL
|
||||
|
||||
# Create a bucket
|
||||
New-MinIOBucket -BucketName 'my-bucket' -Verbose
|
||||
|
||||
# Upload a file
|
||||
New-MinIOObject -BucketName 'my-bucket' -ObjectName 'data.txt' -FilePath 'C:\data.txt'
|
||||
|
||||
# List objects
|
||||
Get-MinIOObject -BucketName 'my-bucket' -Prefix '2025/'
|
||||
|
||||
# Download an object
|
||||
Get-MinIOObjectContent -BucketName 'my-bucket' -ObjectName 'data.txt' -FilePath 'C:\downloaded-data.txt'
|
||||
```
|
||||
|
||||
## Cmdlets
|
||||
|
||||
### Bucket Operations
|
||||
- `Get-MinIOBucket` - Lists all buckets
|
||||
- `New-MinIOBucket` - Creates a new bucket
|
||||
- `Remove-MinIOBucket` - Deletes a bucket
|
||||
- `Test-MinIOBucketExists` - Checks if a bucket exists
|
||||
|
||||
### Object Operations
|
||||
- `Get-MinIOObject` - Lists objects in a bucket
|
||||
- `New-MinIOObject` - Uploads a file to a bucket
|
||||
- `Get-MinIOObjectContent` - Downloads an object
|
||||
- `Remove-MinIOObject` - Deletes an object
|
||||
|
||||
### Security & Policy
|
||||
- `Get-MinIOBucketPolicy` - Retrieves bucket policy
|
||||
- `Set-MinIOBucketPolicy` - Sets bucket policy
|
||||
|
||||
### Utility
|
||||
- `Get-MinIOConfig` - Shows current configuration
|
||||
- `Set-MinIOConfig` - Sets connection configuration
|
||||
- `Get-MinIOStats` - Displays statistics and metrics
|
||||
|
||||
## Requirements
|
||||
|
||||
- PowerShell 5.1+ or PowerShell 7+
|
||||
- .NET Framework 4.7.2+ (for PowerShell 5.1) or .NET Core/.NET 5+ (for PowerShell 7+)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Simple test script to verify PSMinIO module functionality
|
||||
|
||||
.DESCRIPTION
|
||||
This script performs basic tests to ensure the PSMinIO module can be built, loaded, and basic functionality works.
|
||||
|
||||
.PARAMETER ModulePath
|
||||
Path to the module manifest file. Default: Module\PSMinIO\PSMinIO.psd1
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Skip the build step and test existing module
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-Module.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-Module.ps1 -SkipBuild
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ModulePath = 'Module\PSMinIO\PSMinIO.psd1',
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Write-Host "=== PSMinIO Module Test ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
function Write-TestResult {
|
||||
param(
|
||||
[string]$TestName,
|
||||
[bool]$Success,
|
||||
[string]$Message = ""
|
||||
)
|
||||
|
||||
$status = if ($Success) { "✓ PASS" } else { "✗ FAIL" }
|
||||
$color = if ($Success) { "Green" } else { "Red" }
|
||||
|
||||
Write-Host "$status - $TestName" -ForegroundColor $color
|
||||
if ($Message) {
|
||||
Write-Host " $Message" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
$testResults = @{}
|
||||
|
||||
try {
|
||||
# Test 1: Build the module (unless skipped)
|
||||
if (!$SkipBuild) {
|
||||
Write-Host "Building module..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
& .\Build.ps1 -Configuration Release
|
||||
$testResults['Build'] = $true
|
||||
Write-TestResult "Build Module" $true
|
||||
} catch {
|
||||
$testResults['Build'] = $false
|
||||
Write-TestResult "Build Module" $false $_.Exception.Message
|
||||
throw "Build failed, cannot continue with tests"
|
||||
}
|
||||
} else {
|
||||
Write-Host "Skipping build step..." -ForegroundColor Yellow
|
||||
$testResults['Build'] = $null
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Test 2: Check if module files exist
|
||||
Write-Host "Checking module files..." -ForegroundColor Yellow
|
||||
|
||||
$moduleExists = Test-Path $ModulePath
|
||||
$testResults['ModuleFiles'] = $moduleExists
|
||||
Write-TestResult "Module Manifest Exists" $moduleExists $ModulePath
|
||||
|
||||
if (!$moduleExists) {
|
||||
throw "Module manifest not found: $ModulePath"
|
||||
}
|
||||
|
||||
# Check for required DLL
|
||||
$dllPath = Join-Path (Split-Path $ModulePath) 'bin\PSMinIO.dll'
|
||||
$dllExists = Test-Path $dllPath
|
||||
$testResults['ModuleDLL'] = $dllExists
|
||||
Write-TestResult "Module DLL Exists" $dllExists $dllPath
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Test 3: Test module manifest
|
||||
Write-Host "Testing module manifest..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
$moduleInfo = Test-ModuleManifest -Path $ModulePath -ErrorAction Stop
|
||||
$testResults['Manifest'] = $true
|
||||
Write-TestResult "Module Manifest Valid" $true "Version: $($moduleInfo.Version)"
|
||||
|
||||
# Check cmdlet count
|
||||
$cmdletCount = $moduleInfo.ExportedCmdlets.Count
|
||||
$expectedCmdlets = 13 # Expected number of cmdlets
|
||||
$cmdletCountOk = $cmdletCount -eq $expectedCmdlets
|
||||
$testResults['CmdletCount'] = $cmdletCountOk
|
||||
Write-TestResult "Cmdlet Count" $cmdletCountOk "Found: $cmdletCount, Expected: $expectedCmdlets"
|
||||
|
||||
} catch {
|
||||
$testResults['Manifest'] = $false
|
||||
Write-TestResult "Module Manifest Valid" $false $_.Exception.Message
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Test 4: Import module
|
||||
Write-Host "Importing module..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
# Remove module if already loaded
|
||||
if (Get-Module PSMinIO -ErrorAction SilentlyContinue) {
|
||||
Remove-Module PSMinIO -Force
|
||||
}
|
||||
|
||||
Import-Module $ModulePath -Force -ErrorAction Stop
|
||||
$testResults['Import'] = $true
|
||||
Write-TestResult "Import Module" $true
|
||||
|
||||
# Check if cmdlets are available
|
||||
$availableCmdlets = Get-Command -Module PSMinIO
|
||||
$cmdletAvailable = $availableCmdlets.Count -gt 0
|
||||
$testResults['CmdletsAvailable'] = $cmdletAvailable
|
||||
Write-TestResult "Cmdlets Available" $cmdletAvailable "Count: $($availableCmdlets.Count)"
|
||||
|
||||
} catch {
|
||||
$testResults['Import'] = $false
|
||||
Write-TestResult "Import Module" $false $_.Exception.Message
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Test 5: Test basic cmdlet functionality
|
||||
Write-Host "Testing basic cmdlet functionality..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
# Test Get-MinIOConfig (should work without configuration)
|
||||
$config = Get-MinIOConfig -ErrorAction Stop
|
||||
$configTest = $config -ne $null
|
||||
$testResults['GetConfig'] = $configTest
|
||||
Write-TestResult "Get-MinIOConfig" $configTest
|
||||
|
||||
# Test configuration validation
|
||||
$configValid = $config.IsValid
|
||||
$testResults['ConfigValid'] = !$configValid # Should be false initially
|
||||
Write-TestResult "Config Initially Invalid" (!$configValid) "Expected: not configured yet"
|
||||
|
||||
} catch {
|
||||
$testResults['GetConfig'] = $false
|
||||
Write-TestResult "Get-MinIOConfig" $false $_.Exception.Message
|
||||
}
|
||||
|
||||
try {
|
||||
# Test Set-MinIOConfig with test parameters (WhatIf)
|
||||
Set-MinIOConfig -Endpoint "test.example.com" `
|
||||
-AccessKey "test-key" `
|
||||
-SecretKey "test-secret" `
|
||||
-WhatIf -ErrorAction Stop
|
||||
$testResults['SetConfig'] = $true
|
||||
Write-TestResult "Set-MinIOConfig (WhatIf)" $true
|
||||
|
||||
} catch {
|
||||
$testResults['SetConfig'] = $false
|
||||
Write-TestResult "Set-MinIOConfig (WhatIf)" $false $_.Exception.Message
|
||||
}
|
||||
|
||||
try {
|
||||
# Test help system
|
||||
$help = Get-Help Get-MinIOBucket -ErrorAction Stop
|
||||
$helpTest = $help -ne $null -and $help.Synopsis -ne $null
|
||||
$testResults['Help'] = $helpTest
|
||||
Write-TestResult "Help System" $helpTest
|
||||
|
||||
} catch {
|
||||
$testResults['Help'] = $false
|
||||
Write-TestResult "Help System" $false $_.Exception.Message
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Test 6: Test parameter validation
|
||||
Write-Host "Testing parameter validation..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
# Test invalid bucket name validation
|
||||
$errorCaught = $false
|
||||
try {
|
||||
New-MinIOBucket -BucketName "" -WhatIf -ErrorAction Stop
|
||||
} catch {
|
||||
$errorCaught = $true
|
||||
}
|
||||
|
||||
$testResults['Validation'] = $errorCaught
|
||||
Write-TestResult "Parameter Validation" $errorCaught "Empty bucket name should fail"
|
||||
|
||||
} catch {
|
||||
$testResults['Validation'] = $false
|
||||
Write-TestResult "Parameter Validation" $false $_.Exception.Message
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Summary
|
||||
Write-Host "=== Test Summary ===" -ForegroundColor Cyan
|
||||
|
||||
$totalTests = $testResults.Count
|
||||
$passedTests = ($testResults.Values | Where-Object { $_ -eq $true }).Count
|
||||
$failedTests = ($testResults.Values | Where-Object { $_ -eq $false }).Count
|
||||
$skippedTests = ($testResults.Values | Where-Object { $_ -eq $null }).Count
|
||||
|
||||
Write-Host "Total Tests: $totalTests" -ForegroundColor Gray
|
||||
Write-Host "Passed: $passedTests" -ForegroundColor Green
|
||||
Write-Host "Failed: $failedTests" -ForegroundColor Red
|
||||
Write-Host "Skipped: $skippedTests" -ForegroundColor Yellow
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# Detailed results
|
||||
foreach ($test in $testResults.GetEnumerator()) {
|
||||
$status = switch ($test.Value) {
|
||||
$true { "PASS" }
|
||||
$false { "FAIL" }
|
||||
$null { "SKIP" }
|
||||
}
|
||||
$color = switch ($test.Value) {
|
||||
$true { "Green" }
|
||||
$false { "Red" }
|
||||
$null { "Yellow" }
|
||||
}
|
||||
Write-Host " $($test.Key): $status" -ForegroundColor $color
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
if ($failedTests -eq 0) {
|
||||
Write-Host "=== All Tests Passed! ===" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "The PSMinIO module is ready for use." -ForegroundColor Cyan
|
||||
Write-Host "To get started, run:" -ForegroundColor Gray
|
||||
Write-Host " Get-Help about_PSMinIO" -ForegroundColor White
|
||||
Write-Host " Get-Command -Module PSMinIO" -ForegroundColor White
|
||||
} else {
|
||||
Write-Host "=== Some Tests Failed ===" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Please review the failed tests and fix any issues before using the module." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "=== Test Execution Failed ===" -ForegroundColor Red
|
||||
Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
exit 1
|
||||
} finally {
|
||||
# Clean up - remove module if loaded
|
||||
if (Get-Module PSMinIO -ErrorAction SilentlyContinue) {
|
||||
Remove-Module PSMinIO -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# PSMinIO Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2025.07.10.1200] - 2025-07-10
|
||||
|
||||
### Added
|
||||
|
||||
#### Core Infrastructure
|
||||
- **MinIOConfiguration**: Singleton configuration management with JSON persistence
|
||||
- **MinIOLogger**: Centralized logging utility with timestamp formatting (yyyy/MM/dd HH:mm:ss.fff)
|
||||
- **MinIOClientWrapper**: Synchronous wrapper for async MinIO operations using `.GetAwaiter().GetResult()`
|
||||
- **MinIOBaseCmdlet**: Base class for all cmdlets with common functionality
|
||||
- **ProgressReporter**: Upload/download progress tracking with percentages and time estimates
|
||||
|
||||
#### Bucket Operations
|
||||
- **Get-MinIOBucket**: List buckets with optional statistics gathering
|
||||
- **New-MinIOBucket**: Create buckets with region support and validation
|
||||
- **Remove-MinIOBucket**: Delete buckets with optional object removal
|
||||
- **Test-MinIOBucketExists**: Check bucket existence with detailed information
|
||||
|
||||
#### Object Operations
|
||||
- **Get-MinIOObject**: List objects with filtering, sorting, and pagination
|
||||
- **New-MinIOObject**: Upload files with progress reporting and content type detection
|
||||
- **Get-MinIOObjectContent**: Download objects with progress reporting
|
||||
- **Remove-MinIOObject**: Delete objects with prefix support for batch operations
|
||||
|
||||
#### Security & Policy Management
|
||||
- **Get-MinIOBucketPolicy**: Retrieve bucket policies as JSON or structured objects
|
||||
- **Set-MinIOBucketPolicy**: Set policies from JSON, files, or predefined canned policies
|
||||
|
||||
#### Configuration & Utilities
|
||||
- **Set-MinIOConfig**: Configure MinIO connection with validation and testing
|
||||
- **Get-MinIOConfig**: View configuration with optional sensitive data masking
|
||||
- **Get-MinIOStats**: Comprehensive statistics with per-bucket details
|
||||
|
||||
#### PowerShell Integration
|
||||
- **Type Definitions**: Custom .ps1xml files for formatted output
|
||||
- **Format Definitions**: Table views for all major object types
|
||||
- **Parameter Validation**: Comprehensive input validation and error handling
|
||||
- **ShouldProcess Support**: All destructive operations support -WhatIf and -Confirm
|
||||
|
||||
### Design Decisions
|
||||
|
||||
#### .NET Standard 2.0 Compatibility
|
||||
- **Target Framework**: .NET Standard 2.0 for maximum compatibility
|
||||
- **PowerShell Support**: Compatible with PowerShell 5.1 (.NET Framework 4.7.2) and PowerShell 7+
|
||||
- **Dependency Management**: Uses PowerShellStandard.Library 5.1.1 for cmdlet base classes
|
||||
|
||||
#### Synchronous Operations Only
|
||||
- **No Async/Await**: All operations are synchronous for PowerShell compatibility
|
||||
- **Wrapper Strategy**: Uses `Task.Run().GetAwaiter().GetResult()` pattern
|
||||
- **Cancellation Support**: Implements CancellationToken for operation cancellation
|
||||
|
||||
#### Logging Strategy
|
||||
- **Conditional Logging**: Only logs when `-Verbose` is specified
|
||||
- **Timestamp Format**: Consistent yyyy/MM/dd HH:mm:ss.fff format
|
||||
- **Centralized Utility**: Single MinIOLogger class for all logging operations
|
||||
- **Error Categorization**: Proper PowerShell ErrorCategory assignment
|
||||
|
||||
#### Progress Reporting
|
||||
- **Upload/Download Progress**: Real-time progress with bytes transferred
|
||||
- **Time Estimates**: Calculates remaining time based on current speed
|
||||
- **Throttled Updates**: Updates every 100ms to avoid console flooding
|
||||
- **Formatted Display**: Human-readable size formatting (B, KB, MB, GB, etc.)
|
||||
|
||||
#### Configuration Management
|
||||
- **Singleton Pattern**: Single configuration instance across the module
|
||||
- **Persistent Storage**: JSON configuration file in user's AppData
|
||||
- **Validation**: Comprehensive validation before client creation
|
||||
- **Security**: Sensitive data masking in display output
|
||||
|
||||
#### Error Handling
|
||||
- **Comprehensive Validation**: Input validation at multiple levels
|
||||
- **Proper Error Categories**: Uses appropriate PowerShell ErrorCategory values
|
||||
- **Graceful Degradation**: Operations continue when possible, warn on failures
|
||||
- **Detailed Error Messages**: Includes context and suggestions for resolution
|
||||
|
||||
#### Performance Considerations
|
||||
- **Lazy Client Creation**: MinIO client created only when needed
|
||||
- **Resource Disposal**: Proper disposal of clients and resources
|
||||
- **Batch Operations**: Support for bulk operations with progress reporting
|
||||
- **Configurable Limits**: MaxObjects parameters to prevent performance issues
|
||||
|
||||
#### PowerShell Best Practices
|
||||
- **Parameter Sets**: Logical grouping of related parameters
|
||||
- **Pipeline Support**: ValueFromPipeline and ValueFromPipelineByPropertyName
|
||||
- **Aliases**: Common aliases for frequently used parameters
|
||||
- **Help Integration**: Comprehensive parameter documentation
|
||||
- **Output Types**: Strongly typed output objects
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
#### Synchronous Wrapper Pattern
|
||||
```csharp
|
||||
public bool BucketExists(string bucketName)
|
||||
{
|
||||
var args = new BucketExistsArgs().WithBucket(bucketName);
|
||||
return Task.Run(async () =>
|
||||
await _client.BucketExistsAsync(args, CancellationToken))
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
```
|
||||
|
||||
#### Progress Reporting Implementation
|
||||
```csharp
|
||||
var progressReporter = new ProgressReporter(
|
||||
this, "Uploading Object", $"Uploading {fileInfo.Name}", fileSize, 1);
|
||||
|
||||
var etag = Client.UploadFile(BucketName, ObjectName, FilePath, ContentType,
|
||||
bytesTransferred => progressReporter.UpdateProgress(bytesTransferred));
|
||||
```
|
||||
|
||||
#### Logging Pattern
|
||||
```csharp
|
||||
MinIOLogger.WriteVerbose(this, "Operation started: {0}", operationName);
|
||||
// ... operation code ...
|
||||
MinIOLogger.WriteVerbose(this, "Operation completed: {0}", operationName);
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Minio**: 5.0.0 - Core MinIO .NET SDK
|
||||
- **PowerShellStandard.Library**: 5.1.1 - PowerShell cmdlet base classes
|
||||
- **System.Text.Json**: 6.0.0 - JSON serialization for configuration and policies
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None - Initial release.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- **Credential Storage**: Configuration file stored in user's AppData directory
|
||||
- **Sensitive Data Masking**: Access keys and secret keys masked in output by default
|
||||
- **SSL by Default**: UseSSL defaults to true for secure connections
|
||||
- **Input Validation**: Comprehensive validation to prevent injection attacks
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Large Bucket Performance**: Object counting can be slow for buckets with many objects
|
||||
- **Synchronous Only**: No async operations available (by design)
|
||||
- **Windows Paths**: File path handling optimized for Windows (cross-platform compatible)
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
- **Multipart Upload Support**: For large files
|
||||
- **Presigned URL Generation**: For temporary access
|
||||
- **Server-Side Encryption**: Configuration and management
|
||||
- **Lifecycle Policies**: Bucket lifecycle management
|
||||
- **Notification Configuration**: Event notification setup
|
||||
|
||||
---
|
||||
|
||||
## Version Numbering
|
||||
|
||||
This project uses a date-based versioning scheme: `YYYY.MM.DD.HHMM`
|
||||
|
||||
- **YYYY**: Year (2025)
|
||||
- **MM**: Month (07)
|
||||
- **DD**: Day (10)
|
||||
- **HHMM**: Hour and minute (1200 = 12:00 PM)
|
||||
|
||||
This ensures chronological ordering and makes it easy to identify when a version was released.
|
||||
@@ -0,0 +1,401 @@
|
||||
# PSMinIO Examples
|
||||
|
||||
This document contains practical examples for using the PSMinIO PowerShell module.
|
||||
|
||||
## Basic Setup and Configuration
|
||||
|
||||
### Example 1: Initial Setup for Local MinIO
|
||||
|
||||
```powershell
|
||||
# Import the module
|
||||
Import-Module .\PSMinIO.psd1
|
||||
|
||||
# Configure for local MinIO instance
|
||||
Set-MinIOConfig -Endpoint "localhost:9000" `
|
||||
-AccessKey "minioadmin" `
|
||||
-SecretKey "minioadmin" `
|
||||
-NoSSL `
|
||||
-TestConnection `
|
||||
-SaveToDisk
|
||||
|
||||
# Verify configuration
|
||||
Get-MinIOConfig -Detailed
|
||||
```
|
||||
|
||||
### Example 2: Production Setup with SSL
|
||||
|
||||
```powershell
|
||||
# Configure for production MinIO cluster
|
||||
Set-MinIOConfig -Endpoint "minio.company.com:9000" `
|
||||
-AccessKey $env:MINIO_ACCESS_KEY `
|
||||
-SecretKey $env:MINIO_SECRET_KEY `
|
||||
-UseSSL `
|
||||
-Region "us-east-1" `
|
||||
-TimeoutSeconds 60 `
|
||||
-TestConnection
|
||||
|
||||
# Test the connection
|
||||
Get-MinIOConfig -TestConnection
|
||||
```
|
||||
|
||||
## Bucket Management Examples
|
||||
|
||||
### Example 3: Creating and Managing Buckets
|
||||
|
||||
```powershell
|
||||
# Create buckets for different purposes
|
||||
New-MinIOBucket -BucketName "company-documents" -Region "us-east-1" -PassThru
|
||||
New-MinIOBucket -BucketName "user-uploads" -Region "us-west-2" -PassThru
|
||||
New-MinIOBucket -BucketName "application-logs" -Region "eu-west-1" -PassThru
|
||||
|
||||
# List all buckets with statistics
|
||||
Get-MinIOBucket -IncludeStatistics
|
||||
|
||||
# Check if specific buckets exist
|
||||
$buckets = @("company-documents", "user-uploads", "temp-bucket")
|
||||
foreach ($bucket in $buckets) {
|
||||
$exists = Test-MinIOBucketExists -BucketName $bucket
|
||||
Write-Host "Bucket '$bucket' exists: $exists"
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Bucket Cleanup
|
||||
|
||||
```powershell
|
||||
# Find and remove temporary buckets
|
||||
Get-MinIOBucket | Where-Object { $_.Name -like "temp-*" } | ForEach-Object {
|
||||
Write-Host "Removing temporary bucket: $($_.Name)"
|
||||
Remove-MinIOBucket -BucketName $_.Name -RemoveObjects -Force
|
||||
}
|
||||
|
||||
# Remove old test buckets (older than 30 days)
|
||||
Get-MinIOBucket | Where-Object {
|
||||
$_.Name -like "test-*" -and $_.Created -lt (Get-Date).AddDays(-30)
|
||||
} | ForEach-Object {
|
||||
Write-Host "Removing old test bucket: $($_.Name) (Created: $($_.Created))"
|
||||
Remove-MinIOBucket -BucketName $_.Name -RemoveObjects -Force
|
||||
}
|
||||
```
|
||||
|
||||
## File Upload and Download Examples
|
||||
|
||||
### Example 5: Bulk File Upload
|
||||
|
||||
```powershell
|
||||
# Upload all PDF files from a directory
|
||||
$sourceDir = "C:\Documents\Reports"
|
||||
$bucketName = "company-documents"
|
||||
|
||||
Get-ChildItem -Path $sourceDir -Filter "*.pdf" -Recurse | ForEach-Object {
|
||||
$relativePath = $_.FullName.Substring($sourceDir.Length + 1).Replace('\', '/')
|
||||
$objectName = "reports/$relativePath"
|
||||
|
||||
Write-Host "Uploading: $($_.Name) -> $objectName"
|
||||
|
||||
try {
|
||||
New-MinIOObject -BucketName $bucketName `
|
||||
-ObjectName $objectName `
|
||||
-FilePath $_.FullName `
|
||||
-Verbose
|
||||
Write-Host "✓ Successfully uploaded: $($_.Name)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "✗ Failed to upload: $($_.Name) - $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 6: Backup Script
|
||||
|
||||
```powershell
|
||||
# Daily backup script
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SourcePath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$BucketName,
|
||||
|
||||
[string]$BackupPrefix = "backups"
|
||||
)
|
||||
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
|
||||
$backupFolder = "$BackupPrefix/$timestamp"
|
||||
|
||||
Write-Host "Starting backup of '$SourcePath' to bucket '$BucketName'"
|
||||
|
||||
# Create a compressed archive
|
||||
$tempZip = "$env:TEMP\backup_$timestamp.zip"
|
||||
Compress-Archive -Path $SourcePath -DestinationPath $tempZip -Force
|
||||
|
||||
try {
|
||||
# Upload the backup
|
||||
$objectName = "$backupFolder/backup.zip"
|
||||
New-MinIOObject -BucketName $BucketName `
|
||||
-ObjectName $objectName `
|
||||
-FilePath $tempZip `
|
||||
-PassThru
|
||||
|
||||
Write-Host "✓ Backup completed successfully" -ForegroundColor Green
|
||||
|
||||
# Clean up old backups (keep last 7 days)
|
||||
$cutoffDate = (Get-Date).AddDays(-7)
|
||||
Get-MinIOObject -BucketName $BucketName -Prefix $BackupPrefix |
|
||||
Where-Object { $_.LastModified -lt $cutoffDate } |
|
||||
ForEach-Object {
|
||||
Write-Host "Removing old backup: $($_.Name)"
|
||||
Remove-MinIOObject -BucketName $BucketName -ObjectName $_.Name -Force
|
||||
}
|
||||
|
||||
} finally {
|
||||
# Clean up temporary file
|
||||
if (Test-Path $tempZip) {
|
||||
Remove-Item $tempZip -Force
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 7: Bulk Download
|
||||
|
||||
```powershell
|
||||
# Download all objects with specific prefix
|
||||
$bucketName = "company-documents"
|
||||
$prefix = "reports/2025/"
|
||||
$downloadDir = "C:\Downloads\Reports"
|
||||
|
||||
# Ensure download directory exists
|
||||
if (!(Test-Path $downloadDir)) {
|
||||
New-Item -ItemType Directory -Path $downloadDir -Force
|
||||
}
|
||||
|
||||
# Get all objects with the prefix
|
||||
$objects = Get-MinIOObject -BucketName $bucketName -Prefix $prefix
|
||||
|
||||
Write-Host "Found $($objects.Count) objects to download"
|
||||
|
||||
foreach ($obj in $objects) {
|
||||
# Skip directories
|
||||
if ($obj.IsDirectory) { continue }
|
||||
|
||||
# Create local file path
|
||||
$relativePath = $obj.Name.Substring($prefix.Length)
|
||||
$localPath = Join-Path $downloadDir $relativePath.Replace('/', '\')
|
||||
$localDir = Split-Path $localPath -Parent
|
||||
|
||||
# Ensure local directory exists
|
||||
if (!(Test-Path $localDir)) {
|
||||
New-Item -ItemType Directory -Path $localDir -Force
|
||||
}
|
||||
|
||||
Write-Host "Downloading: $($obj.Name) -> $localPath"
|
||||
|
||||
try {
|
||||
Get-MinIOObjectContent -BucketName $bucketName `
|
||||
-ObjectName $obj.Name `
|
||||
-FilePath $localPath `
|
||||
-Force
|
||||
Write-Host "✓ Downloaded: $($obj.GetFileName())" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "✗ Failed to download: $($obj.Name) - $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security and Policy Examples
|
||||
|
||||
### Example 8: Setting Up Public Read Access
|
||||
|
||||
```powershell
|
||||
# Create a bucket for public assets
|
||||
New-MinIOBucket -BucketName "public-assets"
|
||||
|
||||
# Set read-only policy for public access
|
||||
Set-MinIOBucketPolicy -BucketName "public-assets" `
|
||||
-CannedPolicy "ReadOnly" `
|
||||
-Prefix "*"
|
||||
|
||||
# Verify the policy
|
||||
Get-MinIOBucketPolicy -BucketName "public-assets" -AsObject
|
||||
```
|
||||
|
||||
### Example 9: Custom Policy for Upload-Only Access
|
||||
|
||||
```powershell
|
||||
$uploadOnlyPolicy = @"
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::user-uploads/uploads/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"@
|
||||
|
||||
Set-MinIOBucketPolicy -BucketName "user-uploads" `
|
||||
-PolicyJson $uploadOnlyPolicy
|
||||
|
||||
# Test the policy
|
||||
Get-MinIOBucketPolicy -BucketName "user-uploads" -PrettyPrint
|
||||
```
|
||||
|
||||
## Monitoring and Statistics Examples
|
||||
|
||||
### Example 10: Storage Usage Report
|
||||
|
||||
```powershell
|
||||
# Generate comprehensive storage report
|
||||
$stats = Get-MinIOStats -IncludeBucketDetails -IncludeObjectCounts
|
||||
|
||||
Write-Host "=== MinIO Storage Report ===" -ForegroundColor Cyan
|
||||
Write-Host "Generated: $(Get-Date)" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Overall Statistics:" -ForegroundColor Yellow
|
||||
Write-Host " Total Buckets: $($stats.TotalBuckets)"
|
||||
Write-Host " Total Objects: $($stats.TotalObjects)"
|
||||
Write-Host " Total Size: $($stats.TotalSizeFormatted)"
|
||||
Write-Host " Average Object Size: $($stats.AverageObjectSizeFormatted)"
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Bucket Details:" -ForegroundColor Yellow
|
||||
$stats.BucketDetails | Sort-Object Size -Descending | ForEach-Object {
|
||||
Write-Host " $($_.Name):"
|
||||
Write-Host " Objects: $($_.ObjectCount)"
|
||||
Write-Host " Size: $($_.SizeFormatted)"
|
||||
Write-Host " Created: $($_.Created)"
|
||||
Write-Host ""
|
||||
}
|
||||
```
|
||||
|
||||
### Example 11: Health Check Script
|
||||
|
||||
```powershell
|
||||
# MinIO health check script
|
||||
function Test-MinIOHealth {
|
||||
param(
|
||||
[string]$TestBucketName = "health-check-$(Get-Date -Format 'yyyyMMdd')"
|
||||
)
|
||||
|
||||
$results = @{
|
||||
ConfigurationValid = $false
|
||||
ConnectionSuccessful = $false
|
||||
BucketOperations = $false
|
||||
ObjectOperations = $false
|
||||
OverallHealth = $false
|
||||
}
|
||||
|
||||
try {
|
||||
# Test 1: Configuration
|
||||
$config = Get-MinIOConfig
|
||||
$results.ConfigurationValid = $config.IsValid
|
||||
Write-Host "✓ Configuration is valid" -ForegroundColor Green
|
||||
|
||||
# Test 2: Connection
|
||||
$connectionTest = Get-MinIOConfig -TestConnection
|
||||
$results.ConnectionSuccessful = $connectionTest.ConnectionStatus -eq "Success"
|
||||
Write-Host "✓ Connection successful" -ForegroundColor Green
|
||||
|
||||
# Test 3: Bucket operations
|
||||
New-MinIOBucket -BucketName $TestBucketName -Force | Out-Null
|
||||
$bucketExists = Test-MinIOBucketExists -BucketName $TestBucketName
|
||||
$results.BucketOperations = $bucketExists
|
||||
Write-Host "✓ Bucket operations working" -ForegroundColor Green
|
||||
|
||||
# Test 4: Object operations
|
||||
$testFile = "$env:TEMP\minio-health-test.txt"
|
||||
"Health check test file - $(Get-Date)" | Out-File -FilePath $testFile
|
||||
|
||||
New-MinIOObject -BucketName $TestBucketName `
|
||||
-ObjectName "health-test.txt" `
|
||||
-FilePath $testFile | Out-Null
|
||||
|
||||
$objects = Get-MinIOObject -BucketName $TestBucketName -ObjectName "health-test.txt"
|
||||
$results.ObjectOperations = $objects.Count -eq 1
|
||||
Write-Host "✓ Object operations working" -ForegroundColor Green
|
||||
|
||||
# Cleanup
|
||||
Remove-MinIOObject -BucketName $TestBucketName -ObjectName "health-test.txt" -Force
|
||||
Remove-MinIOBucket -BucketName $TestBucketName -Force
|
||||
Remove-Item $testFile -Force
|
||||
|
||||
$results.OverallHealth = $results.ConfigurationValid -and
|
||||
$results.ConnectionSuccessful -and
|
||||
$results.BucketOperations -and
|
||||
$results.ObjectOperations
|
||||
|
||||
Write-Host "✓ Overall health check passed" -ForegroundColor Green
|
||||
|
||||
} catch {
|
||||
Write-Host "✗ Health check failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
return $results
|
||||
}
|
||||
|
||||
# Run health check
|
||||
Test-MinIOHealth
|
||||
```
|
||||
|
||||
## Advanced Automation Examples
|
||||
|
||||
### Example 12: Log Rotation and Archival
|
||||
|
||||
```powershell
|
||||
# Automated log rotation script
|
||||
param(
|
||||
[string]$LogBucket = "application-logs",
|
||||
[int]$RetentionDays = 90,
|
||||
[string]$ArchiveBucket = "archived-logs"
|
||||
)
|
||||
|
||||
$cutoffDate = (Get-Date).AddDays(-$RetentionDays)
|
||||
|
||||
Write-Host "Starting log rotation process..."
|
||||
Write-Host "Archiving logs older than: $cutoffDate"
|
||||
|
||||
# Get old log files
|
||||
$oldLogs = Get-MinIOObject -BucketName $LogBucket |
|
||||
Where-Object { $_.LastModified -lt $cutoffDate -and !$_.IsDirectory }
|
||||
|
||||
Write-Host "Found $($oldLogs.Count) log files to archive"
|
||||
|
||||
foreach ($log in $oldLogs) {
|
||||
try {
|
||||
# Download log file
|
||||
$tempFile = "$env:TEMP\$($log.GetFileName())"
|
||||
Get-MinIOObjectContent -BucketName $LogBucket `
|
||||
-ObjectName $log.Name `
|
||||
-FilePath $tempFile
|
||||
|
||||
# Compress the log file
|
||||
$compressedFile = "$tempFile.gz"
|
||||
# Note: You would use a compression library here
|
||||
# For this example, we'll just rename
|
||||
Move-Item $tempFile $compressedFile
|
||||
|
||||
# Upload to archive bucket with date prefix
|
||||
$archiveObjectName = "archived/$(Get-Date $log.LastModified -Format 'yyyy/MM/dd')/$($log.GetFileName()).gz"
|
||||
New-MinIOObject -BucketName $ArchiveBucket `
|
||||
-ObjectName $archiveObjectName `
|
||||
-FilePath $compressedFile
|
||||
|
||||
# Remove original log file
|
||||
Remove-MinIOObject -BucketName $LogBucket -ObjectName $log.Name -Force
|
||||
|
||||
# Clean up temp file
|
||||
Remove-Item $compressedFile -Force
|
||||
|
||||
Write-Host "✓ Archived: $($log.Name)" -ForegroundColor Green
|
||||
|
||||
} catch {
|
||||
Write-Host "✗ Failed to archive: $($log.Name) - $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Log rotation completed"
|
||||
```
|
||||
|
||||
These examples demonstrate the practical usage of PSMinIO for various scenarios including setup, file management, security configuration, monitoring, and automation. Each example includes error handling and best practices for production use.
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
# PSMinIO Usage Guide
|
||||
|
||||
This guide provides comprehensive examples and usage patterns for the PSMinIO PowerShell module.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Bucket Operations](#bucket-operations)
|
||||
- [Object Operations](#object-operations)
|
||||
- [Security and Policies](#security-and-policies)
|
||||
- [Statistics and Monitoring](#statistics-and-monitoring)
|
||||
- [Advanced Usage](#advanced-usage)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- PowerShell 5.1+ or PowerShell 7+
|
||||
- .NET Framework 4.7.2+ (for PowerShell 5.1) or .NET Core/.NET 5+ (for PowerShell 7+)
|
||||
|
||||
### Import the Module
|
||||
|
||||
```powershell
|
||||
# Import the module
|
||||
Import-Module .\PSMinIO.psd1
|
||||
|
||||
# Verify the module is loaded
|
||||
Get-Module PSMinIO
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```powershell
|
||||
# Set up MinIO connection
|
||||
Set-MinIOConfig -Endpoint "minio.example.com:9000" `
|
||||
-AccessKey "your-access-key" `
|
||||
-SecretKey "your-secret-key" `
|
||||
-UseSSL
|
||||
|
||||
# For local development (no SSL)
|
||||
Set-MinIOConfig -Endpoint "localhost:9000" `
|
||||
-AccessKey "minioadmin" `
|
||||
-SecretKey "minioadmin" `
|
||||
-NoSSL
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```powershell
|
||||
# Set configuration with custom region and timeout
|
||||
Set-MinIOConfig -Endpoint "minio.example.com:9000" `
|
||||
-AccessKey "your-access-key" `
|
||||
-SecretKey "your-secret-key" `
|
||||
-UseSSL `
|
||||
-Region "us-west-2" `
|
||||
-TimeoutSeconds 60 `
|
||||
-SaveToDisk `
|
||||
-TestConnection
|
||||
```
|
||||
|
||||
### View Current Configuration
|
||||
|
||||
```powershell
|
||||
# Basic configuration view
|
||||
Get-MinIOConfig
|
||||
|
||||
# Detailed configuration with connection test
|
||||
Get-MinIOConfig -Detailed -TestConnection
|
||||
|
||||
# Show sensitive information (use with caution)
|
||||
Get-MinIOConfig -ShowSensitive
|
||||
```
|
||||
|
||||
## Bucket Operations
|
||||
|
||||
### Create Buckets
|
||||
|
||||
```powershell
|
||||
# Create a simple bucket
|
||||
New-MinIOBucket -BucketName "my-data-bucket"
|
||||
|
||||
# Create bucket with specific region
|
||||
New-MinIOBucket -BucketName "eu-data-bucket" -Region "eu-west-1"
|
||||
|
||||
# Create bucket and return information
|
||||
New-MinIOBucket -BucketName "logs-bucket" -PassThru
|
||||
|
||||
# Force creation (no error if exists)
|
||||
New-MinIOBucket -BucketName "existing-bucket" -Force
|
||||
```
|
||||
|
||||
### List Buckets
|
||||
|
||||
```powershell
|
||||
# List all buckets
|
||||
Get-MinIOBucket
|
||||
|
||||
# Get specific bucket information
|
||||
Get-MinIOBucket -BucketName "my-data-bucket"
|
||||
|
||||
# Include statistics (object count and size)
|
||||
Get-MinIOBucket -IncludeStatistics
|
||||
|
||||
# Get bucket with statistics
|
||||
Get-MinIOBucket -BucketName "my-data-bucket" -IncludeStatistics
|
||||
```
|
||||
|
||||
### Check Bucket Existence
|
||||
|
||||
```powershell
|
||||
# Simple existence check
|
||||
Test-MinIOBucketExists -BucketName "my-data-bucket"
|
||||
|
||||
# Detailed existence information
|
||||
Test-MinIOBucketExists -BucketName "my-data-bucket" -Detailed
|
||||
```
|
||||
|
||||
### Remove Buckets
|
||||
|
||||
```powershell
|
||||
# Remove empty bucket
|
||||
Remove-MinIOBucket -BucketName "old-bucket"
|
||||
|
||||
# Remove bucket and all its objects
|
||||
Remove-MinIOBucket -BucketName "temp-bucket" -RemoveObjects
|
||||
|
||||
# Force removal without confirmation
|
||||
Remove-MinIOBucket -BucketName "test-bucket" -Force
|
||||
```
|
||||
|
||||
## Object Operations
|
||||
|
||||
### Upload Objects
|
||||
|
||||
```powershell
|
||||
# Upload a single file
|
||||
New-MinIOObject -BucketName "my-data-bucket" `
|
||||
-ObjectName "documents/report.pdf" `
|
||||
-FilePath "C:\Reports\monthly-report.pdf"
|
||||
|
||||
# Upload with custom content type
|
||||
New-MinIOObject -BucketName "web-assets" `
|
||||
-ObjectName "images/logo.png" `
|
||||
-FilePath "C:\Assets\logo.png" `
|
||||
-ContentType "image/png"
|
||||
|
||||
# Upload and return object information
|
||||
New-MinIOObject -BucketName "uploads" `
|
||||
-ObjectName "data.csv" `
|
||||
-FilePath "C:\Data\export.csv" `
|
||||
-PassThru
|
||||
|
||||
# Force overwrite existing object
|
||||
New-MinIOObject -BucketName "backups" `
|
||||
-ObjectName "backup.zip" `
|
||||
-FilePath "C:\Backups\latest.zip" `
|
||||
-Force
|
||||
```
|
||||
|
||||
### List Objects
|
||||
|
||||
```powershell
|
||||
# List all objects in a bucket
|
||||
Get-MinIOObject -BucketName "my-data-bucket"
|
||||
|
||||
# List objects with prefix
|
||||
Get-MinIOObject -BucketName "logs" -Prefix "2025/01/"
|
||||
|
||||
# List specific object
|
||||
Get-MinIOObject -BucketName "documents" -ObjectName "report.pdf"
|
||||
|
||||
# List with filtering and sorting
|
||||
Get-MinIOObject -BucketName "media" `
|
||||
-Prefix "images/" `
|
||||
-ObjectsOnly `
|
||||
-SortBy "Size" `
|
||||
-Descending `
|
||||
-MaxObjects 50
|
||||
```
|
||||
|
||||
### Download Objects
|
||||
|
||||
```powershell
|
||||
# Download a single object
|
||||
Get-MinIOObjectContent -BucketName "documents" `
|
||||
-ObjectName "report.pdf" `
|
||||
-FilePath "C:\Downloads\report.pdf"
|
||||
|
||||
# Download with overwrite
|
||||
Get-MinIOObjectContent -BucketName "backups" `
|
||||
-ObjectName "backup.zip" `
|
||||
-FilePath "C:\Restore\backup.zip" `
|
||||
-Force
|
||||
|
||||
# Download and return file information
|
||||
Get-MinIOObjectContent -BucketName "data" `
|
||||
-ObjectName "export.csv" `
|
||||
-FilePath "C:\Import\data.csv" `
|
||||
-PassThru
|
||||
```
|
||||
|
||||
### Remove Objects
|
||||
|
||||
```powershell
|
||||
# Remove a single object
|
||||
Remove-MinIOObject -BucketName "temp" -ObjectName "old-file.txt"
|
||||
|
||||
# Remove all objects with prefix
|
||||
Remove-MinIOObject -BucketName "logs" `
|
||||
-ObjectName "2024/" `
|
||||
-RemovePrefix
|
||||
|
||||
# Force removal without confirmation
|
||||
Remove-MinIOObject -BucketName "cache" `
|
||||
-ObjectName "temp-data.json" `
|
||||
-Force
|
||||
```
|
||||
|
||||
## Security and Policies
|
||||
|
||||
### Get Bucket Policies
|
||||
|
||||
```powershell
|
||||
# Get policy as JSON
|
||||
Get-MinIOBucketPolicy -BucketName "public-bucket"
|
||||
|
||||
# Get policy as structured object
|
||||
Get-MinIOBucketPolicy -BucketName "public-bucket" -AsObject
|
||||
|
||||
# Get pretty-printed JSON
|
||||
Get-MinIOBucketPolicy -BucketName "public-bucket" -PrettyPrint
|
||||
```
|
||||
|
||||
### Set Bucket Policies
|
||||
|
||||
```powershell
|
||||
# Set policy from JSON string
|
||||
$policy = @"
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::public-bucket/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"@
|
||||
|
||||
Set-MinIOBucketPolicy -BucketName "public-bucket" -PolicyJson $policy
|
||||
|
||||
# Set policy from file
|
||||
Set-MinIOBucketPolicy -BucketName "secure-bucket" `
|
||||
-PolicyFilePath "C:\Policies\secure-policy.json"
|
||||
|
||||
# Use canned policies
|
||||
Set-MinIOBucketPolicy -BucketName "readonly-bucket" `
|
||||
-CannedPolicy "ReadOnly"
|
||||
|
||||
Set-MinIOBucketPolicy -BucketName "upload-bucket" `
|
||||
-CannedPolicy "WriteOnly" `
|
||||
-Prefix "uploads/*"
|
||||
|
||||
# Validate policy without setting
|
||||
Set-MinIOBucketPolicy -BucketName "test-bucket" `
|
||||
-PolicyJson $policy `
|
||||
-ValidateOnly
|
||||
```
|
||||
|
||||
## Statistics and Monitoring
|
||||
|
||||
### Basic Statistics
|
||||
|
||||
```powershell
|
||||
# Get basic statistics
|
||||
Get-MinIOStats
|
||||
|
||||
# Include object counts (may be slow)
|
||||
Get-MinIOStats -IncludeObjectCounts
|
||||
|
||||
# Detailed statistics with per-bucket information
|
||||
Get-MinIOStats -IncludeBucketDetails -IncludeObjectCounts
|
||||
|
||||
# Limit object counting for performance
|
||||
Get-MinIOStats -IncludeObjectCounts -MaxObjectsToCount 1000
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Batch Operations
|
||||
|
||||
```powershell
|
||||
# Upload multiple files
|
||||
$files = Get-ChildItem "C:\Data\*.csv"
|
||||
foreach ($file in $files) {
|
||||
New-MinIOObject -BucketName "data-lake" `
|
||||
-ObjectName "csv-files/$($file.Name)" `
|
||||
-FilePath $file.FullName `
|
||||
-Verbose
|
||||
}
|
||||
|
||||
# Download all objects with specific prefix
|
||||
$objects = Get-MinIOObject -BucketName "backups" -Prefix "2025/01/"
|
||||
foreach ($obj in $objects) {
|
||||
$localPath = "C:\Restore\$($obj.Name)"
|
||||
$localDir = Split-Path $localPath -Parent
|
||||
if (!(Test-Path $localDir)) { New-Item -ItemType Directory -Path $localDir -Force }
|
||||
|
||||
Get-MinIOObjectContent -BucketName "backups" `
|
||||
-ObjectName $obj.Name `
|
||||
-FilePath $localPath `
|
||||
-Verbose
|
||||
}
|
||||
```
|
||||
|
||||
### Pipeline Usage
|
||||
|
||||
```powershell
|
||||
# Pipeline bucket operations
|
||||
Get-MinIOBucket | Where-Object { $_.Name -like "temp-*" } | Remove-MinIOBucket -Force
|
||||
|
||||
# Pipeline object operations
|
||||
Get-MinIOObject -BucketName "logs" -Prefix "old/" |
|
||||
ForEach-Object { Remove-MinIOObject -BucketName $_.BucketName -ObjectName $_.Name -Force }
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```powershell
|
||||
try {
|
||||
New-MinIOBucket -BucketName "test-bucket" -ErrorAction Stop
|
||||
Write-Host "Bucket created successfully"
|
||||
} catch {
|
||||
Write-Error "Failed to create bucket: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Using -WhatIf for testing
|
||||
New-MinIOObject -BucketName "test" `
|
||||
-ObjectName "test.txt" `
|
||||
-FilePath "C:\test.txt" `
|
||||
-WhatIf
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Configuration Problems**
|
||||
```powershell
|
||||
# Test configuration
|
||||
Get-MinIOConfig -TestConnection
|
||||
|
||||
# Reset configuration
|
||||
Set-MinIOConfig -Endpoint "localhost:9000" `
|
||||
-AccessKey "minioadmin" `
|
||||
-SecretKey "minioadmin" `
|
||||
-NoSSL `
|
||||
-TestConnection
|
||||
```
|
||||
|
||||
2. **Connection Issues**
|
||||
```powershell
|
||||
# Check endpoint accessibility
|
||||
Test-NetConnection -ComputerName "minio.example.com" -Port 9000
|
||||
|
||||
# Verify SSL settings
|
||||
Get-MinIOConfig -Detailed
|
||||
```
|
||||
|
||||
3. **Permission Issues**
|
||||
```powershell
|
||||
# Check bucket policy
|
||||
Get-MinIOBucketPolicy -BucketName "problem-bucket" -AsObject
|
||||
|
||||
# Test with different credentials
|
||||
Set-MinIOConfig -AccessKey "admin" -SecretKey "admin-password"
|
||||
```
|
||||
|
||||
### Verbose Logging
|
||||
|
||||
```powershell
|
||||
# Enable verbose output for troubleshooting
|
||||
Get-MinIOBucket -Verbose
|
||||
New-MinIOObject -BucketName "test" -ObjectName "test.txt" -FilePath "C:\test.txt" -Verbose
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. **Use appropriate batch sizes for large operations**
|
||||
2. **Limit object counting with `-MaxObjectsToCount` for large buckets**
|
||||
3. **Use `-Force` parameter to avoid confirmation prompts in scripts**
|
||||
4. **Test operations with `-WhatIf` before execution**
|
||||
|
||||
For more information, see the [API Reference](API.md) and [Examples](EXAMPLES.md).
|
||||
@@ -0,0 +1,182 @@
|
||||
# PSMinIO Chunked Operations Examples
|
||||
# This script demonstrates various chunked upload and download scenarios
|
||||
|
||||
# Import the module
|
||||
Import-Module PSMinIO
|
||||
|
||||
# Connect to MinIO (adjust endpoint and credentials as needed)
|
||||
$connection = Connect-MinIO -Endpoint "https://minio.example.com:9000" -AccessKey "your-access-key" -SecretKey "your-secret-key"
|
||||
|
||||
Write-Host "=== PSMinIO Chunked Operations Examples ===" -ForegroundColor Cyan
|
||||
|
||||
# Example 1: Basic chunked upload of large files
|
||||
Write-Host "`n--- Example 1: Basic chunked upload ---" -ForegroundColor Yellow
|
||||
|
||||
# Upload large files with 10MB chunks
|
||||
$largeFiles = Get-ChildItem "C:\LargeFiles\*.zip" | Select-Object -First 3
|
||||
$uploadResults = New-MinIOObjectChunked -BucketName "backup" -Path $largeFiles -ChunkSize (10 * 1024 * 1024)
|
||||
|
||||
foreach ($result in $uploadResults) {
|
||||
Write-Host "Uploaded: $($result.Name) - Size: $($result.SizeFormatted)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Example 2: Chunked upload with resume capability
|
||||
Write-Host "`n--- Example 2: Chunked upload with resume ---" -ForegroundColor Yellow
|
||||
|
||||
# Upload with resume enabled - if interrupted, can be resumed later
|
||||
$videoFiles = Get-ChildItem "C:\Videos\*.mp4"
|
||||
$resumeResults = New-MinIOObjectChunked -BucketName "media" -Path $videoFiles -ChunkSize (20 * 1024 * 1024) -Resume -MaxRetries 5
|
||||
|
||||
Write-Host "Uploaded $($resumeResults.Count) video files with resume capability" -ForegroundColor Green
|
||||
|
||||
# Example 3: Directory upload with chunking and filtering
|
||||
Write-Host "`n--- Example 3: Directory upload with filtering ---" -ForegroundColor Yellow
|
||||
|
||||
# Upload entire directory with filtering
|
||||
$projectDir = Get-Item "C:\Projects\MyProject"
|
||||
$filteredResults = New-MinIOObjectChunked -BucketName "projects" -Directory $projectDir -Recursive -MaxDepth 3 `
|
||||
-InclusionFilter { $_.Extension -in @(".cs", ".js", ".json", ".md") } `
|
||||
-ExclusionFilter { $_.Name -like "*temp*" -or $_.Name -like "*.tmp" } `
|
||||
-ChunkSize (5 * 1024 * 1024) -Resume
|
||||
|
||||
Write-Host "Uploaded $($filteredResults.Count) project files" -ForegroundColor Green
|
||||
|
||||
# Example 4: Upload to specific bucket directory with presigned URLs
|
||||
Write-Host "`n--- Example 4: Upload to bucket directory with URLs ---" -ForegroundColor Yellow
|
||||
|
||||
# Upload files to specific bucket directory and generate presigned URLs
|
||||
$documents = Get-ChildItem "C:\Documents\Reports\*.pdf"
|
||||
$urlResults = New-MinIOObjectChunked -BucketName "storage" -Path $documents -BucketDirectory "reports/2024/Q1" `
|
||||
-ShowURL -Expiration (New-TimeSpan -Hours 24) -ChunkSize (8 * 1024 * 1024)
|
||||
|
||||
foreach ($result in $urlResults) {
|
||||
Write-Host "Uploaded: $($result.Name)" -ForegroundColor Green
|
||||
if ($result.HasPresignedUrl) {
|
||||
Write-Host " URL: $($result.PresignedUrl)" -ForegroundColor Cyan
|
||||
Write-Host " Expires: $($result.PresignedUrlExpiration)" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
# Example 5: Basic chunked download
|
||||
Write-Host "`n--- Example 5: Basic chunked download ---" -ForegroundColor Yellow
|
||||
|
||||
# Download large file with chunking
|
||||
$downloadFile = [System.IO.FileInfo]"C:\Downloads\large-dataset.zip"
|
||||
$downloadResult = Get-MinIOObjectContentChunked -BucketName "data" -ObjectName "datasets/large-dataset.zip" `
|
||||
-FilePath $downloadFile -ChunkSize (15 * 1024 * 1024) -ParallelDownloads 4
|
||||
|
||||
Write-Host "Downloaded: $($downloadResult.Name) - Size: $($downloadResult.Length) bytes" -ForegroundColor Green
|
||||
|
||||
# Example 6: Chunked download with resume
|
||||
Write-Host "`n--- Example 6: Chunked download with resume ---" -ForegroundColor Yellow
|
||||
|
||||
# Download with resume capability - can be interrupted and resumed
|
||||
$resumeDownloadFile = [System.IO.FileInfo]"C:\Downloads\huge-backup.tar.gz"
|
||||
$resumeDownloadResult = Get-MinIOObjectContentChunked -BucketName "backups" -ObjectName "system-backup.tar.gz" `
|
||||
-FilePath $resumeDownloadFile -Resume -ChunkSize (25 * 1024 * 1024) -ParallelDownloads 5 -MaxRetries 3
|
||||
|
||||
Write-Host "Downloaded with resume: $($resumeDownloadResult.Name)" -ForegroundColor Green
|
||||
|
||||
# Example 7: Progress control examples
|
||||
Write-Host "`n--- Example 7: Progress control options ---" -ForegroundColor Yellow
|
||||
|
||||
# Upload with detailed progress (default)
|
||||
New-MinIOObjectChunked -BucketName "test" -Path $largeFiles -ShowDetailedProgress
|
||||
|
||||
# Upload with collection-level progress only
|
||||
New-MinIOObjectChunked -BucketName "test" -Path $largeFiles -ShowCollectionProgressOnly
|
||||
|
||||
# Upload with no progress bars (only verbose logging)
|
||||
New-MinIOObjectChunked -BucketName "test" -Path $largeFiles -ProgressAction SilentlyContinue -Verbose
|
||||
|
||||
# Example 8: Custom chunk sizes for different scenarios
|
||||
Write-Host "`n--- Example 8: Custom chunk sizes ---" -ForegroundColor Yellow
|
||||
|
||||
# Small chunks for slow connections
|
||||
$smallChunkResults = New-MinIOObjectChunked -BucketName "mobile" -Path $documents -ChunkSize (1 * 1024 * 1024) # 1MB chunks
|
||||
|
||||
# Large chunks for fast connections
|
||||
$largeChunkResults = New-MinIOObjectChunked -BucketName "datacenter" -Path $videoFiles -ChunkSize (100 * 1024 * 1024) # 100MB chunks
|
||||
|
||||
# Adaptive chunk size based on file size
|
||||
foreach ($file in $largeFiles) {
|
||||
$chunkSize = if ($file.Length -lt 50MB) { 2MB }
|
||||
elseif ($file.Length -lt 500MB) { 10MB }
|
||||
else { 50MB }
|
||||
|
||||
$adaptiveResult = New-MinIOObjectChunked -BucketName "adaptive" -Path @($file) -ChunkSize $chunkSize
|
||||
Write-Host "Uploaded $($file.Name) with $($chunkSize / 1MB)MB chunks" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Example 9: Resume data management
|
||||
Write-Host "`n--- Example 9: Resume data management ---" -ForegroundColor Yellow
|
||||
|
||||
# Upload with custom resume data location
|
||||
$customResumeResults = New-MinIOObjectChunked -BucketName "important" -Path $videoFiles `
|
||||
-Resume -ResumeDataPath "D:\ResumeData" -ChunkSize (30 * 1024 * 1024)
|
||||
|
||||
# Check for existing resume files
|
||||
$resumeFiles = [PSMinIO.Utils.ChunkedTransferResumeManager]::GetResumeFiles()
|
||||
Write-Host "Found $($resumeFiles.Count) resume files" -ForegroundColor Cyan
|
||||
|
||||
# Clean up old resume files (older than 7 days)
|
||||
$cleanedCount = [PSMinIO.Utils.ChunkedTransferResumeManager]::CleanupOldResumeFiles(7)
|
||||
Write-Host "Cleaned up $cleanedCount old resume files" -ForegroundColor Green
|
||||
|
||||
# Example 10: Error handling and retry strategies
|
||||
Write-Host "`n--- Example 10: Error handling and retry ---" -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
# Upload with aggressive retry settings for unreliable connections
|
||||
$retryResults = New-MinIOObjectChunked -BucketName "unreliable" -Path $largeFiles `
|
||||
-ChunkSize (5 * 1024 * 1024) -MaxRetries 10 -Resume -Verbose
|
||||
|
||||
Write-Host "Upload completed despite network issues" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Upload failed after all retries: $($_.Exception.Message)"
|
||||
|
||||
# Resume data is automatically saved, so you can retry later:
|
||||
# New-MinIOObjectChunked -BucketName "unreliable" -Path $largeFiles -Resume
|
||||
}
|
||||
|
||||
# Example 11: Monitoring transfer state
|
||||
Write-Host "`n--- Example 11: Transfer state monitoring ---" -ForegroundColor Yellow
|
||||
|
||||
# For advanced scenarios, you can access transfer state information
|
||||
# This would typically be done in a custom progress handler or monitoring script
|
||||
|
||||
# Example of what transfer state information is available:
|
||||
Write-Host "Transfer State Properties:" -ForegroundColor Cyan
|
||||
Write-Host " - ProgressPercentage: Shows completion percentage" -ForegroundColor Gray
|
||||
Write-Host " - BytesTransferred: Shows bytes completed" -ForegroundColor Gray
|
||||
Write-Host " - TotalChunks: Total number of chunks" -ForegroundColor Gray
|
||||
Write-Host " - CompletedChunkCount: Number of completed chunks" -ForegroundColor Gray
|
||||
Write-Host " - TransferType: Upload or Download" -ForegroundColor Gray
|
||||
Write-Host " - ElapsedTime: Time since transfer started" -ForegroundColor Gray
|
||||
|
||||
Write-Host "`n=== Examples completed! ===" -ForegroundColor Green
|
||||
|
||||
# Best Practices Summary
|
||||
Write-Host "`n--- Best Practices Summary ---" -ForegroundColor Yellow
|
||||
Write-Host "1. Use appropriate chunk sizes:" -ForegroundColor Cyan
|
||||
Write-Host " - 1-5MB for slow/mobile connections" -ForegroundColor Gray
|
||||
Write-Host " - 10-25MB for typical broadband" -ForegroundColor Gray
|
||||
Write-Host " - 50-100MB for high-speed datacenter connections" -ForegroundColor Gray
|
||||
|
||||
Write-Host "2. Enable resume for large transfers:" -ForegroundColor Cyan
|
||||
Write-Host " - Always use -Resume for files > 100MB" -ForegroundColor Gray
|
||||
Write-Host " - Consider custom -ResumeDataPath for important transfers" -ForegroundColor Gray
|
||||
|
||||
Write-Host "3. Optimize parallel downloads:" -ForegroundColor Cyan
|
||||
Write-Host " - Use 3-5 parallel downloads for most scenarios" -ForegroundColor Gray
|
||||
Write-Host " - Increase for very fast connections, decrease for slow ones" -ForegroundColor Gray
|
||||
|
||||
Write-Host "4. Handle errors gracefully:" -ForegroundColor Cyan
|
||||
Write-Host " - Set appropriate -MaxRetries based on connection reliability" -ForegroundColor Gray
|
||||
Write-Host " - Use try/catch blocks for critical transfers" -ForegroundColor Gray
|
||||
|
||||
Write-Host "5. Monitor progress appropriately:" -ForegroundColor Cyan
|
||||
Write-Host " - Use -ShowDetailedProgress for interactive sessions" -ForegroundColor Gray
|
||||
Write-Host " - Use -ShowCollectionProgressOnly for batch operations" -ForegroundColor Gray
|
||||
Write-Host " - Use -ProgressAction SilentlyContinue with -Verbose for logging" -ForegroundColor Gray
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Establishes a connection to a MinIO server
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommunications.Connect, "MinIO", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
|
||||
[OutputType(typeof(MinIOConnection))]
|
||||
public class ConnectMinIOCmdlet : PSCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// MinIO server URI (e.g., https://minio.example.com:9000)
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true)]
|
||||
[ValidateNotNull]
|
||||
[Alias("Server", "Url")]
|
||||
public Uri Endpoint { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Access key for authentication
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("AccessKeyId")]
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Secret key for authentication
|
||||
/// </summary>
|
||||
[Parameter(Position = 2, Mandatory = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("SecretAccessKey")]
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Region for bucket operations (default: us-east-1)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateNotNullOrEmpty]
|
||||
public string Region { get; set; } = "us-east-1";
|
||||
|
||||
/// <summary>
|
||||
/// Connection timeout in seconds (default: 30)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1, 300)]
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Test the connection after establishing it
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter TestConnection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Store the connection in a session variable for reuse
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateNotNullOrEmpty]
|
||||
public string? SessionVariable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Skip SSL certificate validation (use with caution)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter SkipCertificateValidation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Skip SSL certificate validation (use with caution)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter SkipCertificateValidation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Accept self-signed certificates
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter AcceptSelfSignedCertificates { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Accept certificates with hostname mismatches
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter AcceptHostnameMismatch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
// Extract connection details from URI
|
||||
var useSSL = Endpoint.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase);
|
||||
var endpointHost = $"{Endpoint.Host}:{Endpoint.Port}";
|
||||
|
||||
if (ShouldProcess($"MinIO Server: {Endpoint}", $"Establish connection"))
|
||||
{
|
||||
try
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Connecting to MinIO - Endpoint: {0}, UseSSL: {1}, Region: {2}, Timeout: {3}s, SkipCertValidation: {4}",
|
||||
endpointHost, useSSL, Region, TimeoutSeconds, SkipCertificateValidation.IsPresent);
|
||||
|
||||
// Show warning when certificate validation is skipped
|
||||
if (SkipCertificateValidation.IsPresent)
|
||||
{
|
||||
WriteWarning("SSL certificate validation is disabled. This should only be used in development environments with self-signed certificates.");
|
||||
}
|
||||
|
||||
// Create configuration
|
||||
var configuration = new MinIOConfiguration(
|
||||
endpointHost,
|
||||
AccessKey,
|
||||
SecretKey,
|
||||
useSSL,
|
||||
Region,
|
||||
TimeoutSeconds,
|
||||
SkipCertificateValidation.IsPresent);
|
||||
|
||||
// Create connection
|
||||
var connection = new MinIOConnection(configuration);
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "MinIO connection created successfully");
|
||||
|
||||
// Test connection if requested
|
||||
if (TestConnection.IsPresent)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Testing MinIO connection...");
|
||||
|
||||
var testResult = connection.TestConnection();
|
||||
|
||||
if (testResult.Success)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Connection test successful - found {0} buckets in {1}ms",
|
||||
testResult.BucketCount ?? 0,
|
||||
testResult.ResponseTime?.TotalMilliseconds ?? 0);
|
||||
|
||||
WriteInformation(new InformationRecord(
|
||||
$"Connection test successful. Found {testResult.BucketCount ?? 0} buckets.",
|
||||
"ConnectionTest"));
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteWarning($"Connection test failed: {testResult.Message}");
|
||||
MinIOLogger.WriteVerbose(this, "Connection test failed: {0}", testResult.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Store in session variable if requested
|
||||
if (!string.IsNullOrWhiteSpace(SessionVariable))
|
||||
{
|
||||
SessionState.PSVariable.Set(SessionVariable, connection);
|
||||
MinIOLogger.WriteVerbose(this, "Connection stored in session variable: {0}", SessionVariable);
|
||||
}
|
||||
|
||||
// Return the connection object
|
||||
WriteObject(connection);
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Connect-MinIO completed successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorRecord = new ErrorRecord(
|
||||
ex,
|
||||
"ConnectionFailed",
|
||||
ErrorCategory.ConnectionError,
|
||||
Endpoint);
|
||||
|
||||
WriteError(errorRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins processing - validate parameters
|
||||
/// </summary>
|
||||
protected override void BeginProcessing()
|
||||
{
|
||||
base.BeginProcessing();
|
||||
|
||||
// Validate URI scheme
|
||||
if (!Endpoint.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) &&
|
||||
!Endpoint.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException($"Invalid URI scheme '{Endpoint.Scheme}'. Only 'http' and 'https' are supported."),
|
||||
"InvalidUriScheme",
|
||||
ErrorCategory.InvalidArgument,
|
||||
Endpoint));
|
||||
}
|
||||
|
||||
// Validate port is specified
|
||||
if (Endpoint.Port == -1)
|
||||
{
|
||||
WriteWarning($"No port specified in URI '{Endpoint}'. MinIO typically uses port 9000.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets information about MinIO buckets
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Get, "MinIOBucket", SupportsShouldProcess = true)]
|
||||
[OutputType(typeof(MinIOBucketInfo))]
|
||||
public class GetMinIOBucketCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of a specific bucket to retrieve. If not specified, all buckets are returned.
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string? BucketName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Include additional statistics like object count and total size for each bucket
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[Alias("Stats")]
|
||||
public SwitchParameter IncludeStatistics { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConnection();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(BucketName))
|
||||
{
|
||||
// Get specific bucket
|
||||
GetSpecificBucket(BucketName);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get all buckets
|
||||
GetAllBuckets();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about a specific bucket
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
private void GetSpecificBucket(string bucketName)
|
||||
{
|
||||
ValidateBucketName(bucketName);
|
||||
|
||||
if (ShouldProcess(bucketName, "Get bucket information"))
|
||||
{
|
||||
ExecuteOperation("GetBucket", () =>
|
||||
{
|
||||
// First check if bucket exists
|
||||
var exists = Client.BucketExists(bucketName);
|
||||
if (!exists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{bucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
bucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all buckets and find the specific one
|
||||
var allBuckets = Client.ListBuckets();
|
||||
var bucket = allBuckets.FirstOrDefault(b =>
|
||||
string.Equals(b.Name, bucketName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (bucket != null)
|
||||
{
|
||||
if (IncludeStatistics.IsPresent)
|
||||
{
|
||||
PopulateBucketStatistics(bucket);
|
||||
}
|
||||
|
||||
WriteObject(bucket);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{bucketName}' not found in bucket list"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
bucketName));
|
||||
}
|
||||
}, $"Bucket: {bucketName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about all buckets
|
||||
/// </summary>
|
||||
private void GetAllBuckets()
|
||||
{
|
||||
if (ShouldProcess("All buckets", "Get bucket information"))
|
||||
{
|
||||
ExecuteOperation("ListBuckets", () =>
|
||||
{
|
||||
var buckets = Client.ListBuckets();
|
||||
|
||||
if (IncludeStatistics.IsPresent)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Gathering statistics for {0} buckets", buckets.Count);
|
||||
|
||||
for (int i = 0; i < buckets.Count; i++)
|
||||
{
|
||||
var bucket = buckets[i];
|
||||
|
||||
// Show progress for statistics gathering
|
||||
var progressRecord = new ProgressRecord(1, "Gathering Bucket Statistics",
|
||||
$"Processing bucket: {bucket.Name}")
|
||||
{
|
||||
PercentComplete = (int)((double)(i + 1) / buckets.Count * 100)
|
||||
};
|
||||
WriteProgress(progressRecord);
|
||||
|
||||
PopulateBucketStatistics(bucket);
|
||||
}
|
||||
|
||||
// Complete progress
|
||||
WriteProgress(new ProgressRecord(1, "Gathering Bucket Statistics", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed
|
||||
});
|
||||
}
|
||||
|
||||
// Output all buckets
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
WriteObject(bucket);
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Retrieved information for {0} buckets", buckets.Count);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates additional statistics for a bucket
|
||||
/// </summary>
|
||||
/// <param name="bucket">Bucket to populate statistics for</param>
|
||||
private void PopulateBucketStatistics(MinIOBucketInfo bucket)
|
||||
{
|
||||
try
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Gathering statistics for bucket: {0}", bucket.Name);
|
||||
|
||||
var objects = Client.ListObjects(bucket.Name, recursive: true);
|
||||
|
||||
bucket.ObjectCount = objects.Count;
|
||||
bucket.Size = objects.Sum(obj => obj.Size);
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Bucket '{0}' statistics: {1} objects, {2} bytes total",
|
||||
bucket.Name, bucket.ObjectCount, bucket.Size);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Failed to gather statistics for bucket '{0}': {1}",
|
||||
bucket.Name, ex.Message);
|
||||
|
||||
// Set statistics to null to indicate they couldn't be retrieved
|
||||
bucket.ObjectCount = null;
|
||||
bucket.Size = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
using System.Text.Json;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the policy for a MinIO bucket
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Get, "MinIOBucketPolicy", SupportsShouldProcess = false)]
|
||||
[OutputType(typeof(string), typeof(PSObject))]
|
||||
public class GetMinIOBucketPolicyCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to get the policy for
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Return the policy as a formatted object instead of raw JSON
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter AsObject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pretty-print the JSON output
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter PrettyPrint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConfiguration();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
ExecuteOperation("GetBucketPolicy", () =>
|
||||
{
|
||||
// Check if bucket exists
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Retrieving policy for bucket '{0}'", BucketName);
|
||||
|
||||
try
|
||||
{
|
||||
var policyJson = Client.GetBucketPolicy(BucketName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(policyJson))
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "No policy is set for bucket '{0}'", BucketName);
|
||||
|
||||
if (AsObject.IsPresent)
|
||||
{
|
||||
var emptyPolicy = new PSObject();
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("BucketName", BucketName));
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("HasPolicy", false));
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("Policy", null));
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("RetrievedAt", DateTime.UtcNow));
|
||||
WriteObject(emptyPolicy);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteObject(string.Empty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Retrieved policy for bucket '{0}' ({1} characters)",
|
||||
BucketName, policyJson.Length);
|
||||
|
||||
if (AsObject.IsPresent)
|
||||
{
|
||||
// Parse and return as structured object
|
||||
var policyObject = CreatePolicyObject(policyJson);
|
||||
WriteObject(policyObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Return as JSON string
|
||||
if (PrettyPrint.IsPresent)
|
||||
{
|
||||
var formattedJson = FormatJson(policyJson);
|
||||
WriteObject(formattedJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteObject(policyJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex.Message.Contains("NoSuchBucketPolicy") ||
|
||||
ex.Message.Contains("policy does not exist"))
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "No policy is set for bucket '{0}'", BucketName);
|
||||
|
||||
if (AsObject.IsPresent)
|
||||
{
|
||||
var emptyPolicy = new PSObject();
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("BucketName", BucketName));
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("HasPolicy", false));
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("Policy", null));
|
||||
emptyPolicy.Properties.Add(new PSNoteProperty("RetrievedAt", DateTime.UtcNow));
|
||||
WriteObject(emptyPolicy);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteObject(string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
}, $"Bucket: {BucketName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a structured policy object from JSON
|
||||
/// </summary>
|
||||
/// <param name="policyJson">Policy JSON string</param>
|
||||
/// <returns>PSObject containing policy information</returns>
|
||||
private PSObject CreatePolicyObject(string policyJson)
|
||||
{
|
||||
var policyObject = new PSObject();
|
||||
policyObject.Properties.Add(new PSNoteProperty("BucketName", BucketName));
|
||||
policyObject.Properties.Add(new PSNoteProperty("HasPolicy", true));
|
||||
policyObject.Properties.Add(new PSNoteProperty("RetrievedAt", DateTime.UtcNow));
|
||||
|
||||
try
|
||||
{
|
||||
// Parse the JSON to extract key information
|
||||
using var document = JsonDocument.Parse(policyJson);
|
||||
var root = document.RootElement;
|
||||
|
||||
// Add raw policy
|
||||
policyObject.Properties.Add(new PSNoteProperty("PolicyJson", policyJson));
|
||||
|
||||
// Extract version if present
|
||||
if (root.TryGetProperty("Version", out var versionElement))
|
||||
{
|
||||
policyObject.Properties.Add(new PSNoteProperty("Version", versionElement.GetString()));
|
||||
}
|
||||
|
||||
// Extract statements if present
|
||||
if (root.TryGetProperty("Statement", out var statementsElement))
|
||||
{
|
||||
var statements = new System.Collections.Generic.List<PSObject>();
|
||||
|
||||
if (statementsElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var statement in statementsElement.EnumerateArray())
|
||||
{
|
||||
var statementObj = CreateStatementObject(statement);
|
||||
statements.Add(statementObj);
|
||||
}
|
||||
}
|
||||
else if (statementsElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var statementObj = CreateStatementObject(statementsElement);
|
||||
statements.Add(statementObj);
|
||||
}
|
||||
|
||||
policyObject.Properties.Add(new PSNoteProperty("Statements", statements.ToArray()));
|
||||
policyObject.Properties.Add(new PSNoteProperty("StatementCount", statements.Count));
|
||||
}
|
||||
|
||||
// Add formatted JSON
|
||||
var formattedJson = FormatJson(policyJson);
|
||||
policyObject.Properties.Add(new PSNoteProperty("FormattedPolicy", formattedJson));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this, "Could not parse policy JSON: {0}", ex.Message);
|
||||
policyObject.Properties.Add(new PSNoteProperty("PolicyJson", policyJson));
|
||||
policyObject.Properties.Add(new PSNoteProperty("ParseError", ex.Message));
|
||||
}
|
||||
|
||||
return policyObject;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a statement object from a JSON element
|
||||
/// </summary>
|
||||
/// <param name="statementElement">JSON element representing a statement</param>
|
||||
/// <returns>PSObject containing statement information</returns>
|
||||
private PSObject CreateStatementObject(JsonElement statementElement)
|
||||
{
|
||||
var statementObj = new PSObject();
|
||||
|
||||
try
|
||||
{
|
||||
if (statementElement.TryGetProperty("Effect", out var effectElement))
|
||||
{
|
||||
statementObj.Properties.Add(new PSNoteProperty("Effect", effectElement.GetString()));
|
||||
}
|
||||
|
||||
if (statementElement.TryGetProperty("Principal", out var principalElement))
|
||||
{
|
||||
statementObj.Properties.Add(new PSNoteProperty("Principal", principalElement.ToString()));
|
||||
}
|
||||
|
||||
if (statementElement.TryGetProperty("Action", out var actionElement))
|
||||
{
|
||||
if (actionElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var actions = new System.Collections.Generic.List<string>();
|
||||
foreach (var action in actionElement.EnumerateArray())
|
||||
{
|
||||
actions.Add(action.GetString() ?? string.Empty);
|
||||
}
|
||||
statementObj.Properties.Add(new PSNoteProperty("Actions", actions.ToArray()));
|
||||
}
|
||||
else
|
||||
{
|
||||
statementObj.Properties.Add(new PSNoteProperty("Actions", new[] { actionElement.GetString() ?? string.Empty }));
|
||||
}
|
||||
}
|
||||
|
||||
if (statementElement.TryGetProperty("Resource", out var resourceElement))
|
||||
{
|
||||
if (resourceElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var resources = new System.Collections.Generic.List<string>();
|
||||
foreach (var resource in resourceElement.EnumerateArray())
|
||||
{
|
||||
resources.Add(resource.GetString() ?? string.Empty);
|
||||
}
|
||||
statementObj.Properties.Add(new PSNoteProperty("Resources", resources.ToArray()));
|
||||
}
|
||||
else
|
||||
{
|
||||
statementObj.Properties.Add(new PSNoteProperty("Resources", new[] { resourceElement.GetString() ?? string.Empty }));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this, "Could not parse statement: {0}", ex.Message);
|
||||
statementObj.Properties.Add(new PSNoteProperty("ParseError", ex.Message));
|
||||
}
|
||||
|
||||
return statementObj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats JSON string for pretty printing
|
||||
/// </summary>
|
||||
/// <param name="json">JSON string to format</param>
|
||||
/// <returns>Formatted JSON string</returns>
|
||||
private string FormatJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(document, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If formatting fails, return original
|
||||
return json;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Downloads objects from a MinIO bucket using chunked transfer with resume capability
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Get, "MinIOObjectContentChunked", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
|
||||
[OutputType(typeof(FileInfo))]
|
||||
public class GetMinIOObjectContentChunkedCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to download from
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the object to download
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Object", "Key")]
|
||||
public string ObjectName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// FileInfo object representing where the file should be saved
|
||||
/// </summary>
|
||||
[Parameter(Position = 2, Mandatory = true)]
|
||||
[ValidateNotNull]
|
||||
[Alias("File", "Path")]
|
||||
public FileInfo FilePath { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite existing files without prompting
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of each chunk for download (default: 10MB)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1024 * 1024, 100 * 1024 * 1024)] // 1MB to 100MB
|
||||
public long ChunkSize { get; set; } = 10 * 1024 * 1024; // 10MB default
|
||||
|
||||
/// <summary>
|
||||
/// Enable resume functionality for interrupted downloads
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Resume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for failed chunks
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1, 10)]
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Number of parallel chunk downloads (default: 3)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1, 10)]
|
||||
public int ParallelDownloads { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Custom path for storing resume data (default: %LOCALAPPDATA%\PSMinIO\Resume)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? ResumeDataPath { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Update progress every N bytes transferred (default: 1MB)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1024, 10 * 1024 * 1024)] // 1KB to 10MB
|
||||
public long ProgressUpdateInterval { get; set; } = 1024 * 1024; // 1MB
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConnection();
|
||||
ValidateBucketName(BucketName);
|
||||
ValidateObjectName(ObjectName);
|
||||
ValidateAndPrepareFilePath();
|
||||
|
||||
if (ShouldProcess($"{BucketName}/{ObjectName}", $"Download using chunked transfer to '{FilePath.FullName}'"))
|
||||
{
|
||||
ExecuteOperation("DownloadObjectChunked", () =>
|
||||
{
|
||||
// Check if bucket exists
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get object information
|
||||
var objectInfo = GetObjectInfo();
|
||||
if (objectInfo == null)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Object '{ObjectName}' not found in bucket '{BucketName}'"),
|
||||
"ObjectNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
ObjectName));
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Starting chunked download of object '{0}' from bucket '{1}' (Size: {2}, ChunkSize: {3})",
|
||||
ObjectName, BucketName, SizeFormatter.FormatSize(objectInfo.Size), SizeFormatter.FormatSize(ChunkSize));
|
||||
|
||||
// Download using chunked transfer
|
||||
var downloadedFile = DownloadObjectChunked(objectInfo);
|
||||
|
||||
if (downloadedFile != null)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Successfully downloaded object '{0}' from bucket '{1}' to '{2}'",
|
||||
ObjectName, BucketName, FilePath.FullName);
|
||||
|
||||
// Always return file information
|
||||
FilePath.Refresh(); // Refresh to get updated file info
|
||||
WriteObject(FilePath);
|
||||
}
|
||||
|
||||
}, $"Bucket: {BucketName}, Object: {ObjectName}, File: {FilePath.FullName}, ChunkSize: {SizeFormatter.FormatSize(ChunkSize)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about the object to download
|
||||
/// </summary>
|
||||
/// <returns>Object information or null if not found</returns>
|
||||
private MinIOObjectInfo? GetObjectInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var objects = Client.ListObjects(BucketName, ObjectName, false);
|
||||
return objects.Find(obj => string.Equals(obj.Name, ObjectName, StringComparison.Ordinal));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Could not get object information: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an object using chunked transfer with resume capability
|
||||
/// </summary>
|
||||
/// <param name="objectInfo">Information about the object to download</param>
|
||||
/// <returns>Downloaded file info or null if failed</returns>
|
||||
private FileInfo? DownloadObjectChunked(MinIOObjectInfo objectInfo)
|
||||
{
|
||||
ChunkedTransferState? transferState = null;
|
||||
|
||||
// Try to load existing transfer state for resume
|
||||
if (Resume.IsPresent)
|
||||
{
|
||||
transferState = ChunkedTransferResumeManager.LoadTransferState(
|
||||
BucketName, ObjectName, FilePath.FullName, ChunkedTransferType.Download, ResumeDataPath);
|
||||
|
||||
if (transferState != null && !ChunkedTransferResumeManager.IsResumeDataValid(transferState))
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Resume data for {0} is invalid, starting fresh download", ObjectName);
|
||||
transferState = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new transfer state if none exists or resume is disabled
|
||||
if (transferState == null)
|
||||
{
|
||||
transferState = new ChunkedTransferState
|
||||
{
|
||||
BucketName = BucketName,
|
||||
ObjectName = ObjectName,
|
||||
FilePath = FilePath.FullName,
|
||||
TotalSize = objectInfo.Size,
|
||||
ChunkSize = ChunkSize,
|
||||
ETag = objectInfo.ETag,
|
||||
TransferType = ChunkedTransferType.Download,
|
||||
StartTime = DateTime.UtcNow,
|
||||
LastUpdated = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Resuming download of {0} from {1:P1} complete",
|
||||
ObjectName, transferState.ProgressPercentage / 100);
|
||||
}
|
||||
|
||||
// Calculate total chunks for progress reporting
|
||||
var totalChunks = (int)Math.Ceiling((double)objectInfo.Size / ChunkSize);
|
||||
|
||||
// Create progress reporter (single file, so no collection progress)
|
||||
var progressReporter = new ChunkedSingleFileProgressReporter(
|
||||
this,
|
||||
objectInfo.Size,
|
||||
totalChunks,
|
||||
"Downloading",
|
||||
ProgressUpdateInterval);
|
||||
|
||||
try
|
||||
{
|
||||
// Download file using chunked transfer
|
||||
var result = Client.DownloadFileChunked(transferState, progressReporter, MaxRetries, ParallelDownloads);
|
||||
|
||||
if (result)
|
||||
{
|
||||
// Clean up resume data on successful completion
|
||||
if (Resume.IsPresent)
|
||||
{
|
||||
ChunkedTransferResumeManager.CleanupResumeData(transferState, ResumeDataPath);
|
||||
}
|
||||
|
||||
progressReporter.CompleteDownload();
|
||||
return FilePath;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Save resume data on failure if resume is enabled
|
||||
if (Resume.IsPresent)
|
||||
{
|
||||
try
|
||||
{
|
||||
ChunkedTransferResumeManager.SaveTransferState(transferState, ResumeDataPath);
|
||||
MinIOLogger.WriteVerbose(this, "Saved resume data for {0}", ObjectName);
|
||||
}
|
||||
catch (Exception saveEx)
|
||||
{
|
||||
WriteWarning($"Could not save resume data for {ObjectName}: {saveEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and prepares the file path for download
|
||||
/// </summary>
|
||||
private void ValidateAndPrepareFilePath()
|
||||
{
|
||||
if (FilePath == null)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException("FilePath cannot be null"),
|
||||
"InvalidFilePath",
|
||||
ErrorCategory.InvalidArgument,
|
||||
FilePath));
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if (FilePath.Exists && !Force.IsPresent)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"File '{FilePath.FullName}' already exists. Use -Force to overwrite."),
|
||||
"FileAlreadyExists",
|
||||
ErrorCategory.ResourceExists,
|
||||
FilePath));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the directory exists
|
||||
var directory = FilePath.Directory;
|
||||
if (directory != null && !directory.Exists)
|
||||
{
|
||||
try
|
||||
{
|
||||
directory.Create();
|
||||
MinIOLogger.WriteVerbose(this, "Created directory: {0}", directory.FullName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new InvalidOperationException($"Cannot create directory '{directory.FullName}': {ex.Message}", ex),
|
||||
"DirectoryCreationFailed",
|
||||
ErrorCategory.WriteError,
|
||||
directory));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can write to the target location
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(FilePath.DirectoryName ?? ".", $".psminiotest_{Guid.NewGuid():N}");
|
||||
File.WriteAllText(testFile, "test");
|
||||
File.Delete(testFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new InvalidOperationException($"Cannot write to location '{FilePath.FullName}': {ex.Message}", ex),
|
||||
"LocationNotWritable",
|
||||
ErrorCategory.PermissionDenied,
|
||||
FilePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Downloads an object from a MinIO bucket to a local file
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Get, "MinIOObjectContent", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)]
|
||||
[OutputType(typeof(FileInfo))]
|
||||
public class GetMinIOObjectContentCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket containing the object
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the object to download
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Object", "Key")]
|
||||
public string ObjectName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// FileInfo object representing where the file should be saved
|
||||
/// </summary>
|
||||
[Parameter(Position = 2, Mandatory = true)]
|
||||
[ValidateNotNull]
|
||||
[Alias("File", "Path")]
|
||||
public FileInfo FilePath { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite the file if it already exists
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConfiguration();
|
||||
ValidateBucketName(BucketName);
|
||||
ValidateObjectName(ObjectName);
|
||||
ValidateAndPrepareFilePath();
|
||||
|
||||
if (ShouldProcess($"{BucketName}/{ObjectName}", $"Download to '{FilePath}'"))
|
||||
{
|
||||
ExecuteOperation("DownloadObject", () =>
|
||||
{
|
||||
// Check if bucket exists
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if object exists and get its information
|
||||
var objectInfo = GetObjectInfo();
|
||||
if (objectInfo == null)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Object '{ObjectName}' does not exist in bucket '{BucketName}'"),
|
||||
"ObjectNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
ObjectName));
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Downloading object '{0}' from bucket '{1}' ({2} bytes) to '{3}'",
|
||||
ObjectName, BucketName, objectInfo.Size, FilePath);
|
||||
|
||||
// Create progress reporter
|
||||
var progressReporter = new ProgressReporter(
|
||||
this,
|
||||
"Downloading Object",
|
||||
$"Downloading {objectInfo.GetFileName()}",
|
||||
objectInfo.Size,
|
||||
1);
|
||||
|
||||
try
|
||||
{
|
||||
// Download the file with progress reporting
|
||||
Client.DownloadFile(
|
||||
BucketName,
|
||||
ObjectName,
|
||||
FilePath.FullName,
|
||||
bytesTransferred => progressReporter.UpdateProgress(bytesTransferred));
|
||||
|
||||
// Complete progress reporting
|
||||
progressReporter.Complete();
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Successfully downloaded object '{0}' from bucket '{1}' to '{2}'",
|
||||
ObjectName, BucketName, FilePath.FullName);
|
||||
|
||||
// Always return file information
|
||||
FilePath.Refresh(); // Refresh to get updated file info
|
||||
WriteObject(FilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progressReporter.Complete();
|
||||
throw;
|
||||
}
|
||||
|
||||
}, $"Bucket: {BucketName}, Object: {ObjectName}, File: {FilePath.FullName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and prepares the file path for download
|
||||
/// </summary>
|
||||
private void ValidateAndPrepareFilePath()
|
||||
{
|
||||
if (FilePath == null)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException("FilePath cannot be null"),
|
||||
"InvalidFilePath",
|
||||
ErrorCategory.InvalidArgument,
|
||||
FilePath));
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if (FilePath.Exists && !Force.IsPresent)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"File '{FilePath.FullName}' already exists. Use -Force to overwrite."),
|
||||
"FileAlreadyExists",
|
||||
ErrorCategory.ResourceExists,
|
||||
FilePath));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the directory exists
|
||||
var directory = FilePath.Directory;
|
||||
if (directory != null && !directory.Exists)
|
||||
{
|
||||
try
|
||||
{
|
||||
directory.Create();
|
||||
MinIOLogger.WriteVerbose(this, "Created directory: {0}", directory.FullName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new InvalidOperationException($"Cannot create directory '{directory.FullName}': {ex.Message}", ex),
|
||||
"DirectoryCreationFailed",
|
||||
ErrorCategory.WriteError,
|
||||
directory));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can write to the target location
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(FilePath.DirectoryName ?? ".", $".psminiotest_{Guid.NewGuid():N}");
|
||||
File.WriteAllText(testFile, "test");
|
||||
File.Delete(testFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new InvalidOperationException($"Cannot write to location '{FilePath.FullName}': {ex.Message}", ex),
|
||||
"LocationNotWritable",
|
||||
ErrorCategory.PermissionDenied,
|
||||
FilePath));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about the object to be downloaded
|
||||
/// </summary>
|
||||
/// <returns>Object information or null if not found</returns>
|
||||
private Models.MinIOObjectInfo? GetObjectInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var objects = Client.ListObjects(BucketName, ObjectName, false);
|
||||
return objects.FirstOrDefault(obj =>
|
||||
string.Equals(obj.Name, ObjectName, StringComparison.Ordinal));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Could not retrieve object information for '{0}': {1}", ObjectName, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets statistical information about MinIO storage
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Get, "MinIOStats", SupportsShouldProcess = false)]
|
||||
[OutputType(typeof(MinIOStats))]
|
||||
public class GetMinIOStatsCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Include detailed per-bucket statistics
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter IncludeBucketDetails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Include object count and size calculations (may be slow for large buckets)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter IncludeObjectCounts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of objects to count per bucket (default: unlimited)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1, int.MaxValue)]
|
||||
public int? MaxObjectsToCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConfiguration();
|
||||
|
||||
ExecuteOperation("GetStats", () =>
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Gathering MinIO statistics...");
|
||||
|
||||
var config = Configuration;
|
||||
var stats = new MinIOStats
|
||||
{
|
||||
Endpoint = config.Endpoint,
|
||||
UseSSL = config.UseSSL,
|
||||
ConnectionStatus = "Connected"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Get basic bucket information
|
||||
var buckets = Client.ListBuckets();
|
||||
stats.TotalBuckets = buckets.Count;
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Found {0} buckets", buckets.Count);
|
||||
|
||||
if (IncludeObjectCounts.IsPresent && buckets.Count > 0)
|
||||
{
|
||||
GatherObjectStatistics(buckets, stats);
|
||||
}
|
||||
|
||||
// Create detailed output if requested
|
||||
if (IncludeBucketDetails.IsPresent)
|
||||
{
|
||||
var detailedStats = CreateDetailedStats(stats, buckets);
|
||||
WriteObject(detailedStats);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteObject(stats);
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Statistics gathering completed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stats.ConnectionStatus = $"Error: {ex.Message}";
|
||||
WriteObject(stats);
|
||||
throw;
|
||||
}
|
||||
|
||||
}, "Gathering statistics");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gathers object statistics across all buckets
|
||||
/// </summary>
|
||||
/// <param name="buckets">List of buckets</param>
|
||||
/// <param name="stats">Stats object to update</param>
|
||||
private void GatherObjectStatistics(System.Collections.Generic.List<MinIOBucketInfo> buckets, MinIOStats stats)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Gathering object statistics for {0} buckets...", buckets.Count);
|
||||
|
||||
long totalObjects = 0;
|
||||
long totalSize = 0;
|
||||
|
||||
for (int i = 0; i < buckets.Count; i++)
|
||||
{
|
||||
var bucket = buckets[i];
|
||||
|
||||
// Show progress
|
||||
var progressRecord = new ProgressRecord(1, "Gathering Statistics",
|
||||
$"Processing bucket: {bucket.Name}")
|
||||
{
|
||||
PercentComplete = (int)((double)(i + 1) / buckets.Count * 100)
|
||||
};
|
||||
WriteProgress(progressRecord);
|
||||
|
||||
try
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Processing bucket: {0}", bucket.Name);
|
||||
|
||||
var objects = Client.ListObjects(bucket.Name, recursive: true);
|
||||
|
||||
// Apply limit if specified
|
||||
if (MaxObjectsToCount.HasValue && objects.Count > MaxObjectsToCount.Value)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Limiting object count for bucket '{0}' to {1} objects",
|
||||
bucket.Name, MaxObjectsToCount.Value);
|
||||
objects = objects.Take(MaxObjectsToCount.Value).ToList();
|
||||
}
|
||||
|
||||
var bucketObjectCount = objects.Count;
|
||||
var bucketSize = objects.Sum(obj => obj.Size);
|
||||
|
||||
totalObjects += bucketObjectCount;
|
||||
totalSize += bucketSize;
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Bucket '{0}': {1} objects, {2} bytes",
|
||||
bucket.Name, bucketObjectCount, bucketSize);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Failed to get statistics for bucket '{0}': {1}",
|
||||
bucket.Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Complete progress
|
||||
WriteProgress(new ProgressRecord(1, "Gathering Statistics", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed
|
||||
});
|
||||
|
||||
stats.TotalObjects = totalObjects;
|
||||
stats.TotalSize = totalSize;
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Total statistics: {0} objects, {1} bytes across {2} buckets",
|
||||
totalObjects, totalSize, buckets.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates detailed statistics with per-bucket information
|
||||
/// </summary>
|
||||
/// <param name="stats">Base statistics</param>
|
||||
/// <param name="buckets">List of buckets</param>
|
||||
/// <returns>PSObject with detailed statistics</returns>
|
||||
private PSObject CreateDetailedStats(MinIOStats stats, System.Collections.Generic.List<MinIOBucketInfo> buckets)
|
||||
{
|
||||
var detailedStats = new PSObject();
|
||||
|
||||
// Add basic statistics
|
||||
detailedStats.Properties.Add(new PSNoteProperty("TotalBuckets", stats.TotalBuckets));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("TotalObjects", stats.TotalObjects));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("TotalSize", stats.TotalSize));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("TotalSizeFormatted", FormatBytes(stats.TotalSize)));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("LastUpdated", stats.LastUpdated));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("Endpoint", stats.Endpoint));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("UseSSL", stats.UseSSL));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("ConnectionStatus", stats.ConnectionStatus));
|
||||
|
||||
// Add calculated statistics
|
||||
detailedStats.Properties.Add(new PSNoteProperty("AverageObjectSize", stats.AverageObjectSize));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("AverageObjectSizeFormatted", FormatBytes((long)stats.AverageObjectSize)));
|
||||
detailedStats.Properties.Add(new PSNoteProperty("AverageObjectsPerBucket", stats.AverageObjectsPerBucket));
|
||||
|
||||
// Add bucket details if object counts were gathered
|
||||
if (IncludeObjectCounts.IsPresent)
|
||||
{
|
||||
var bucketDetails = new System.Collections.Generic.List<PSObject>();
|
||||
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var objects = Client.ListObjects(bucket.Name, recursive: true);
|
||||
|
||||
// Apply limit if specified
|
||||
if (MaxObjectsToCount.HasValue && objects.Count > MaxObjectsToCount.Value)
|
||||
{
|
||||
objects = objects.Take(MaxObjectsToCount.Value).ToList();
|
||||
}
|
||||
|
||||
var bucketDetail = new PSObject();
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("Name", bucket.Name));
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("Created", bucket.Created));
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("ObjectCount", objects.Count));
|
||||
|
||||
var bucketSize = objects.Sum(obj => obj.Size);
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("Size", bucketSize));
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("SizeFormatted", FormatBytes(bucketSize)));
|
||||
|
||||
var avgObjectSize = objects.Count > 0 ? (double)bucketSize / objects.Count : 0;
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("AverageObjectSize", avgObjectSize));
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("AverageObjectSizeFormatted", FormatBytes((long)avgObjectSize)));
|
||||
|
||||
bucketDetails.Add(bucketDetail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var bucketDetail = new PSObject();
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("Name", bucket.Name));
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("Created", bucket.Created));
|
||||
bucketDetail.Properties.Add(new PSNoteProperty("Error", ex.Message));
|
||||
bucketDetails.Add(bucketDetail);
|
||||
}
|
||||
}
|
||||
|
||||
detailedStats.Properties.Add(new PSNoteProperty("BucketDetails", bucketDetails.ToArray()));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Just add basic bucket information
|
||||
var bucketSummary = buckets.Select(b => new PSObject(new
|
||||
{
|
||||
Name = b.Name,
|
||||
Created = b.Created,
|
||||
Region = b.Region
|
||||
})).ToArray();
|
||||
|
||||
detailedStats.Properties.Add(new PSNoteProperty("Buckets", bucketSummary));
|
||||
}
|
||||
|
||||
return detailedStats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats bytes into a human-readable string
|
||||
/// </summary>
|
||||
/// <param name="bytes">Number of bytes</param>
|
||||
/// <returns>Formatted string</returns>
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes == 0) return "0 B";
|
||||
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB", "PB" };
|
||||
int order = 0;
|
||||
double size = bytes;
|
||||
|
||||
while (size >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
size = size / 1024;
|
||||
}
|
||||
|
||||
return $"{size:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new MinIO bucket
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.New, "MinIOBucket", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)]
|
||||
[OutputType(typeof(MinIOBucketInfo))]
|
||||
public class NewMinIOBucketCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to create
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Region where the bucket should be created
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateNotNullOrEmpty]
|
||||
public string? Region { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Force creation even if bucket already exists (no error will be thrown)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConnection();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
// Use region from parameter or configuration
|
||||
var region = Region ?? Configuration.Region;
|
||||
|
||||
if (ShouldProcess(BucketName, $"Create bucket in region '{region}'"))
|
||||
{
|
||||
ExecuteOperation("CreateBucket", () =>
|
||||
{
|
||||
// Check if bucket already exists
|
||||
var exists = Client.BucketExists(BucketName);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
if (Force.IsPresent)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Bucket '{0}' already exists, but Force parameter specified", BucketName);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' already exists. Use -Force to suppress this error."),
|
||||
"BucketAlreadyExists",
|
||||
ErrorCategory.ResourceExists,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create the bucket
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Creating bucket '{0}' in region '{1}'", BucketName, region);
|
||||
|
||||
Client.CreateBucket(BucketName, region);
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Successfully created bucket '{0}'", BucketName);
|
||||
}
|
||||
|
||||
// Always return bucket information
|
||||
try
|
||||
{
|
||||
// Get the created bucket information
|
||||
var allBuckets = Client.ListBuckets();
|
||||
var createdBucket = allBuckets.Find(b =>
|
||||
string.Equals(b.Name, BucketName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (createdBucket != null)
|
||||
{
|
||||
createdBucket.Region = region;
|
||||
WriteObject(createdBucket);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: create a basic bucket info object
|
||||
var bucketInfo = new MinIOBucketInfo(BucketName, DateTime.UtcNow, region);
|
||||
WriteObject(bucketInfo);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Bucket created successfully, but failed to retrieve bucket information: {0}",
|
||||
ex.Message);
|
||||
|
||||
// Still return a basic bucket info object
|
||||
var bucketInfo = new MinIOBucketInfo(BucketName, DateTime.UtcNow, region);
|
||||
WriteObject(bucketInfo);
|
||||
}
|
||||
|
||||
}, $"Bucket: {BucketName}, Region: {region}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the bucket name according to MinIO/S3 naming conventions
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Bucket name to validate</param>
|
||||
protected override void ValidateBucketName(string bucketName, string parameterName = "BucketName")
|
||||
{
|
||||
base.ValidateBucketName(bucketName, parameterName);
|
||||
|
||||
// Additional validation for bucket creation
|
||||
if (bucketName.Contains("..") || bucketName.StartsWith(".") || bucketName.EndsWith("."))
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException($"Bucket name '{bucketName}' contains invalid characters or patterns"),
|
||||
"InvalidBucketName",
|
||||
ErrorCategory.InvalidArgument,
|
||||
bucketName));
|
||||
}
|
||||
|
||||
// Check for uppercase letters (not allowed in S3/MinIO)
|
||||
if (bucketName != bucketName.ToLowerInvariant())
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException($"Bucket name '{bucketName}' must be lowercase"),
|
||||
"InvalidBucketName",
|
||||
ErrorCategory.InvalidArgument,
|
||||
bucketName));
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
foreach (char c in bucketName)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(c) && c != '-' && c != '.')
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException($"Bucket name '{bucketName}' contains invalid character '{c}'. Only lowercase letters, numbers, hyphens, and periods are allowed."),
|
||||
"InvalidBucketName",
|
||||
ErrorCategory.InvalidArgument,
|
||||
bucketName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a folder (prefix) in a MinIO bucket
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.New, "MinIOFolder", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
|
||||
[OutputType(typeof(MinIOObjectInfo))]
|
||||
public class NewMinIOFolderCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to create the folder in
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the folder to create (supports nested paths like "folder1/folder2/folder3")
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Folder", "Prefix", "Path")]
|
||||
public string FolderName { get; set; } = string.Empty;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConnection();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
// Sanitize the folder name
|
||||
var sanitizedFolderName = SanitizeFolderPath(FolderName);
|
||||
if (string.IsNullOrEmpty(sanitizedFolderName))
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new ArgumentException("Folder name cannot be empty after sanitization"),
|
||||
"InvalidFolderName",
|
||||
ErrorCategory.InvalidArgument,
|
||||
FolderName));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldProcess($"{BucketName}/{sanitizedFolderName}", "Create folder structure"))
|
||||
{
|
||||
ExecuteOperation("CreateFolder", () =>
|
||||
{
|
||||
// Check if bucket exists
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Creating folder structure '{0}' in bucket '{1}'", sanitizedFolderName, BucketName);
|
||||
|
||||
// Create the complete folder structure
|
||||
var createdFolders = CreateFolderStructure(sanitizedFolderName);
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Successfully created {0} folder level(s) in bucket '{1}'", createdFolders.Count, BucketName);
|
||||
|
||||
// Return information about all created folders
|
||||
foreach (var folder in createdFolders)
|
||||
{
|
||||
WriteObject(folder);
|
||||
}
|
||||
|
||||
}, $"Bucket: {BucketName}, Folder: {sanitizedFolderName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes folder path and ensures proper format
|
||||
/// </summary>
|
||||
/// <param name="folderPath">Raw folder path</param>
|
||||
/// <returns>Sanitized folder path</returns>
|
||||
private string SanitizeFolderPath(string folderPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folderPath))
|
||||
return string.Empty;
|
||||
|
||||
// Remove leading and trailing slashes, then split and clean
|
||||
var cleanPath = folderPath.Trim().Trim('/', '\\');
|
||||
|
||||
// Split by both forward and back slashes, then clean each part
|
||||
var parts = cleanPath.Split(new char[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Clean each part and validate
|
||||
var cleanedParts = new System.Collections.Generic.List<string>();
|
||||
var invalidChars = new char[] { '<', '>', ':', '"', '|', '?', '*' };
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var cleanPart = part.Trim();
|
||||
if (string.IsNullOrEmpty(cleanPart))
|
||||
continue;
|
||||
|
||||
// Check for invalid characters
|
||||
if (cleanPart.IndexOfAny(invalidChars) >= 0)
|
||||
{
|
||||
WriteWarning($"Folder part '{cleanPart}' contains invalid characters and will be skipped");
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanedParts.Add(cleanPart);
|
||||
}
|
||||
|
||||
return string.Join("/", cleanedParts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the complete folder structure, including all intermediate levels
|
||||
/// </summary>
|
||||
/// <param name="folderPath">Complete folder path to create</param>
|
||||
/// <returns>List of created folder objects</returns>
|
||||
private System.Collections.Generic.List<MinIOObjectInfo> CreateFolderStructure(string folderPath)
|
||||
{
|
||||
var createdFolders = new System.Collections.Generic.List<MinIOObjectInfo>();
|
||||
var parts = folderPath.Split('/');
|
||||
var currentPath = string.Empty;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}";
|
||||
var fullFolderPath = $"{currentPath}/";
|
||||
|
||||
try
|
||||
{
|
||||
// Check if this folder level already exists
|
||||
var existingObjects = Client.ListObjects(BucketName, fullFolderPath, false);
|
||||
var folderExists = existingObjects.Any(obj => obj.Name == fullFolderPath);
|
||||
|
||||
if (!folderExists)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Creating folder level: {0}", fullFolderPath);
|
||||
|
||||
// Create the folder by uploading a zero-byte object
|
||||
using var emptyStream = new MemoryStream();
|
||||
var etag = Client.UploadStream(BucketName, fullFolderPath, emptyStream, "application/x-directory");
|
||||
|
||||
var folderInfo = new MinIOObjectInfo(
|
||||
fullFolderPath,
|
||||
0,
|
||||
DateTime.UtcNow,
|
||||
etag,
|
||||
BucketName)
|
||||
{
|
||||
ContentType = "application/x-directory"
|
||||
};
|
||||
|
||||
createdFolders.Add(folderInfo);
|
||||
MinIOLogger.WriteVerbose(this, "Successfully created folder level: {0}", fullFolderPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Folder level already exists: {0}", fullFolderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Could not create folder level '{fullFolderPath}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return createdFolders;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads files or directories to a MinIO bucket using chunked transfer with resume capability
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.New, "MinIOObjectChunked", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
|
||||
[OutputType(typeof(MinIOObjectInfo))]
|
||||
public class NewMinIOObjectChunkedCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to upload to
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Array of FileInfo objects to upload
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipeline = true, ParameterSetName = "Files")]
|
||||
[ValidateNotNull]
|
||||
[Alias("File", "Files")]
|
||||
public FileInfo[]? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional bucket directory path where files should be uploaded (Files parameter set only)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Files")]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Folder", "Prefix")]
|
||||
public string? BucketDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory to upload
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Directory")]
|
||||
[ValidateNotNull]
|
||||
[Alias("Dir", "Folder")]
|
||||
public DirectoryInfo? Directory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload directory contents recursively (only applies to Directory parameter set)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public SwitchParameter Recursive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth for recursive directory upload (0 = unlimited)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
[ValidateRange(0, int.MaxValue)]
|
||||
public int MaxDepth { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Flatten directory structure (upload all files to bucket root)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public SwitchParameter Flatten { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Script block to filter files for inclusion
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public ScriptBlock? InclusionFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Script block to filter files for exclusion
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public ScriptBlock? ExclusionFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite existing objects without prompting
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate presigned URLs for uploaded objects
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter ShowURL { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time for presigned URLs (default: 1 hour)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange("00:01:00", "7.00:00:00")] // 1 minute to 7 days
|
||||
public TimeSpan Expiration { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Size of each chunk for upload (default: 5MB)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1024 * 1024, 1024 * 1024 * 1024)] // 1MB to 1GB
|
||||
public long ChunkSize { get; set; } = 5 * 1024 * 1024; // 5MB default
|
||||
|
||||
/// <summary>
|
||||
/// Enable resume functionality for interrupted uploads
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Resume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for failed chunks
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1, 10)]
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Custom path for storing resume data (default: %LOCALAPPDATA%\PSMinIO\Resume)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? ResumeDataPath { get; set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Update progress every N bytes transferred (default: 1MB)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange(1024, 10 * 1024 * 1024)] // 1KB to 10MB
|
||||
public long ProgressUpdateInterval { get; set; } = 1024 * 1024; // 1MB
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConnection();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
if (ParameterSetName == "Files")
|
||||
{
|
||||
ProcessFiles();
|
||||
}
|
||||
else if (ParameterSetName == "Directory")
|
||||
{
|
||||
ProcessDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes file uploads from FileInfo array
|
||||
/// </summary>
|
||||
private void ProcessFiles()
|
||||
{
|
||||
if (Path == null || Path.Length == 0)
|
||||
{
|
||||
WriteWarning("No files provided for upload");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldProcess(BucketName, $"Upload {Path.Length} file(s) using chunked transfer"))
|
||||
{
|
||||
ExecuteOperation("UploadFilesChunked", () =>
|
||||
{
|
||||
// Check if bucket exists first
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create bucket directory structure if specified
|
||||
if (!string.IsNullOrWhiteSpace(BucketDirectory))
|
||||
{
|
||||
var sanitizedDirectory = SanitizeBucketDirectory(BucketDirectory);
|
||||
if (!string.IsNullOrEmpty(sanitizedDirectory))
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Ensuring bucket directory exists: {0}", sanitizedDirectory);
|
||||
EnsureBucketDirectoryExists(sanitizedDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
UploadFileCollectionChunked(Path!);
|
||||
|
||||
}, $"Bucket: {BucketName}, Files: {Path.Length}, ChunkSize: {SizeFormatter.FormatSize(ChunkSize)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes directory upload
|
||||
/// </summary>
|
||||
private void ProcessDirectory()
|
||||
{
|
||||
if (Directory == null || !Directory.Exists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new DirectoryNotFoundException($"Directory not found: {Directory?.FullName}"),
|
||||
"DirectoryNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
Directory));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldProcess(BucketName, $"Upload directory '{Directory.Name}' using chunked transfer"))
|
||||
{
|
||||
ExecuteOperation("UploadDirectoryChunked", () =>
|
||||
{
|
||||
// Check if bucket exists first
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get files from directory
|
||||
var files = GetDirectoryFiles();
|
||||
if (files.Length == 0)
|
||||
{
|
||||
WriteWarning($"No files found in directory: {Directory.FullName}");
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Found {0} files in directory '{1}'", files.Length, Directory.FullName);
|
||||
UploadFileCollectionChunked(files);
|
||||
|
||||
}, $"Bucket: {BucketName}, Directory: {Directory.FullName}, ChunkSize: {SizeFormatter.FormatSize(ChunkSize)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets files from directory based on filters and recursion settings
|
||||
/// </summary>
|
||||
/// <returns>Array of FileInfo objects</returns>
|
||||
private FileInfo[] GetDirectoryFiles()
|
||||
{
|
||||
var searchOption = Recursive.IsPresent ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
var allFiles = Directory!.GetFiles("*", searchOption);
|
||||
|
||||
// Apply depth filtering if MaxDepth is specified and we're recursive
|
||||
if (Recursive.IsPresent && MaxDepth > 0)
|
||||
{
|
||||
var basePath = Directory.FullName;
|
||||
allFiles = allFiles.Where(f =>
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(basePath, f.FullName);
|
||||
var depth = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Length - 1;
|
||||
return depth <= MaxDepth;
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
// Apply inclusion filter
|
||||
if (InclusionFilter != null)
|
||||
{
|
||||
allFiles = allFiles.Where(f => EvaluateFilter(InclusionFilter, f)).ToArray();
|
||||
}
|
||||
|
||||
// Apply exclusion filter
|
||||
if (ExclusionFilter != null)
|
||||
{
|
||||
allFiles = allFiles.Where(f => !EvaluateFilter(ExclusionFilter, f)).ToArray();
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a script block filter against a file
|
||||
/// </summary>
|
||||
/// <param name="filter">Script block filter</param>
|
||||
/// <param name="file">File to evaluate</param>
|
||||
/// <returns>True if file matches filter</returns>
|
||||
private bool EvaluateFilter(ScriptBlock filter, FileInfo file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = filter.InvokeWithContext(null, new[] { new PSVariable("_", file) });
|
||||
return result.Count > 0 && LanguagePrimitives.IsTrue(result[0]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Filter evaluation failed for {file.Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a collection of files using chunked transfer
|
||||
/// </summary>
|
||||
/// <param name="files">Files to upload</param>
|
||||
private void UploadFileCollectionChunked(FileInfo[] files)
|
||||
{
|
||||
// Filter out files that don't exist
|
||||
var validFiles = files.Where(f => f.Exists).ToArray();
|
||||
var skippedCount = files.Length - validFiles.Length;
|
||||
|
||||
if (skippedCount > 0)
|
||||
{
|
||||
WriteWarning($"Skipped {skippedCount} files that do not exist");
|
||||
}
|
||||
|
||||
if (validFiles.Length == 0)
|
||||
{
|
||||
WriteWarning("No valid files found for upload");
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Starting chunked upload of {0} files to bucket '{1}' (ChunkSize: {2})",
|
||||
validFiles.Length, BucketName, SizeFormatter.FormatSize(ChunkSize));
|
||||
|
||||
// Calculate total size for overall progress
|
||||
var totalSize = validFiles.Sum(f => f.Length);
|
||||
|
||||
// Create progress reporter
|
||||
var progressReporter = new ChunkedCollectionProgressReporter(
|
||||
this,
|
||||
validFiles.Length,
|
||||
totalSize,
|
||||
"Uploading",
|
||||
ProgressUpdateInterval);
|
||||
|
||||
var uploadedObjects = new System.Collections.Generic.List<MinIOObjectInfo>();
|
||||
|
||||
for (int i = 0; i < validFiles.Length; i++)
|
||||
{
|
||||
var fileInfo = validFiles[i];
|
||||
var objectName = GetObjectName(fileInfo);
|
||||
|
||||
try
|
||||
{
|
||||
// Calculate chunks for this file
|
||||
var totalChunks = (int)Math.Ceiling((double)fileInfo.Length / ChunkSize);
|
||||
progressReporter.StartNewFile(fileInfo.Name, fileInfo.Length, totalChunks);
|
||||
|
||||
// Upload file using chunked transfer
|
||||
var uploadedObject = UploadFileChunked(fileInfo, objectName, progressReporter);
|
||||
|
||||
if (uploadedObject != null)
|
||||
{
|
||||
uploadedObjects.Add(uploadedObject);
|
||||
progressReporter.CompleteFile();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
ex,
|
||||
"ChunkedFileUploadFailed",
|
||||
ErrorCategory.WriteError,
|
||||
fileInfo));
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Failed to upload {0}: {1}", fileInfo.Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
progressReporter.CompleteCollection();
|
||||
|
||||
// Always return uploaded objects
|
||||
foreach (var obj in uploadedObjects)
|
||||
{
|
||||
WriteObject(obj);
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Completed chunked upload: {0} files ({1} successful, {2} failed)",
|
||||
validFiles.Length, uploadedObjects.Count, validFiles.Length - uploadedObjects.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a single file using chunked transfer with resume capability
|
||||
/// </summary>
|
||||
/// <param name="fileInfo">File to upload</param>
|
||||
/// <param name="objectName">Object name in bucket</param>
|
||||
/// <param name="progressReporter">Progress reporter</param>
|
||||
/// <returns>Uploaded object info or null if failed</returns>
|
||||
private MinIOObjectInfo? UploadFileChunked(FileInfo fileInfo, string objectName, ChunkedCollectionProgressReporter progressReporter)
|
||||
{
|
||||
ChunkedTransferState? transferState = null;
|
||||
|
||||
// Try to load existing transfer state for resume
|
||||
if (Resume.IsPresent)
|
||||
{
|
||||
transferState = ChunkedTransferResumeManager.LoadTransferState(
|
||||
BucketName, objectName, fileInfo.FullName, ChunkedTransferType.Upload, ResumeDataPath);
|
||||
|
||||
if (transferState != null && !ChunkedTransferResumeManager.IsResumeDataValid(transferState, fileInfo))
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Resume data for {0} is invalid, starting fresh upload", fileInfo.Name);
|
||||
transferState = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new transfer state if none exists or resume is disabled
|
||||
if (transferState == null)
|
||||
{
|
||||
transferState = new ChunkedTransferState
|
||||
{
|
||||
BucketName = BucketName,
|
||||
ObjectName = objectName,
|
||||
FilePath = fileInfo.FullName,
|
||||
TotalSize = fileInfo.Length,
|
||||
ChunkSize = ChunkSize,
|
||||
LastModified = fileInfo.LastWriteTimeUtc,
|
||||
TransferType = ChunkedTransferType.Upload,
|
||||
StartTime = DateTime.UtcNow,
|
||||
LastUpdated = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Resuming upload of {0} from {1:P1} complete",
|
||||
fileInfo.Name, transferState.ProgressPercentage / 100);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Upload file using chunked transfer
|
||||
var result = Client.UploadFileChunked(transferState, progressReporter, MaxRetries);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// Generate presigned URL if requested
|
||||
if (ShowURL.IsPresent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var presignedUrl = Client.GetPresignedUrl(BucketName, objectName, Expiration);
|
||||
result.PresignedUrl = presignedUrl;
|
||||
result.PresignedUrlExpiration = DateTime.UtcNow.Add(Expiration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Could not generate presigned URL for {objectName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up resume data on successful completion
|
||||
if (Resume.IsPresent)
|
||||
{
|
||||
ChunkedTransferResumeManager.CleanupResumeData(transferState, ResumeDataPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Save resume data on failure if resume is enabled
|
||||
if (Resume.IsPresent)
|
||||
{
|
||||
try
|
||||
{
|
||||
ChunkedTransferResumeManager.SaveTransferState(transferState, ResumeDataPath);
|
||||
MinIOLogger.WriteVerbose(this, "Saved resume data for {0}", fileInfo.Name);
|
||||
}
|
||||
catch (Exception saveEx)
|
||||
{
|
||||
WriteWarning($"Could not save resume data for {fileInfo.Name}: {saveEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the object name for a file based on the upload context
|
||||
/// </summary>
|
||||
/// <param name="file">File to get object name for</param>
|
||||
/// <returns>Object name to use in MinIO</returns>
|
||||
private string GetObjectName(FileInfo file)
|
||||
{
|
||||
if (ParameterSetName == "Files")
|
||||
{
|
||||
// For file uploads, optionally prefix with bucket directory
|
||||
var objectName = file.Name;
|
||||
if (!string.IsNullOrWhiteSpace(BucketDirectory))
|
||||
{
|
||||
var sanitizedDirectory = SanitizeBucketDirectory(BucketDirectory);
|
||||
objectName = $"{sanitizedDirectory}/{file.Name}";
|
||||
}
|
||||
return objectName;
|
||||
}
|
||||
else if (ParameterSetName == "Directory")
|
||||
{
|
||||
if (Flatten.IsPresent)
|
||||
{
|
||||
// Flatten: use just the filename
|
||||
return file.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Maintain directory structure relative to the base directory
|
||||
var relativePath = Path.GetRelativePath(Directory!.FullName, file.FullName);
|
||||
return relativePath.Replace('\\', '/'); // Ensure forward slashes for object storage
|
||||
}
|
||||
}
|
||||
|
||||
return file.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes bucket directory path and ensures proper format
|
||||
/// </summary>
|
||||
/// <param name="directory">Raw directory path</param>
|
||||
/// <returns>Sanitized directory path</returns>
|
||||
private string SanitizeBucketDirectory(string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
return string.Empty;
|
||||
|
||||
// Remove leading and trailing slashes, then split and clean
|
||||
var cleanDirectory = directory.Trim().Trim('/', '\\');
|
||||
|
||||
// Split by both forward and back slashes, then clean each part
|
||||
var parts = cleanDirectory.Split(new char[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Clean each part and rejoin with forward slashes
|
||||
var cleanedParts = parts.Select(part => part.Trim()).Where(part => !string.IsNullOrEmpty(part));
|
||||
|
||||
return string.Join("/", cleanedParts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates bucket directory structure if it doesn't exist
|
||||
/// </summary>
|
||||
/// <param name="directoryPath">Directory path to create</param>
|
||||
private void EnsureBucketDirectoryExists(string directoryPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directoryPath))
|
||||
return;
|
||||
|
||||
var parts = directoryPath.Split('/');
|
||||
var currentPath = string.Empty;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}";
|
||||
var folderPath = $"{currentPath}/";
|
||||
|
||||
try
|
||||
{
|
||||
// Check if this directory level already exists by trying to list objects with this prefix
|
||||
var existingObjects = Client.ListObjects(BucketName, folderPath, false);
|
||||
var folderExists = existingObjects.Any(obj => obj.Name == folderPath);
|
||||
|
||||
if (!folderExists)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Creating bucket directory: {0}", folderPath);
|
||||
|
||||
// Create the directory by uploading a zero-byte object
|
||||
using var emptyStream = new MemoryStream();
|
||||
Client.UploadStream(BucketName, folderPath, emptyStream, "application/x-directory");
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Successfully created bucket directory: {0}", folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Could not create bucket directory '{folderPath}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads files or directories to a MinIO bucket
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.New, "MinIOObject", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
|
||||
[OutputType(typeof(MinIOObjectInfo))]
|
||||
public class NewMinIOObjectCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to upload to
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Array of FileInfo objects to upload
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipeline = true, ParameterSetName = "Files")]
|
||||
[ValidateNotNull]
|
||||
[Alias("File", "Files")]
|
||||
public FileInfo[]? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional bucket directory path where files should be uploaded (Files parameter set only)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Files")]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Folder", "Prefix")]
|
||||
public string? BucketDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory to upload
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = "Directory")]
|
||||
[ValidateNotNull]
|
||||
[Alias("Dir", "Folder")]
|
||||
public DirectoryInfo? Directory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload directory contents recursively (only applies to Directory parameter set)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public SwitchParameter Recursive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth for recursive directory upload (0 = unlimited)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
[ValidateRange(0, int.MaxValue)]
|
||||
public int MaxDepth { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Flatten directory structure (upload all files to bucket root)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public SwitchParameter Flatten { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Script block to filter files for inclusion
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public ScriptBlock? InclusionFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Script block to filter files for exclusion
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "Directory")]
|
||||
public ScriptBlock? ExclusionFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite existing objects without prompting
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate presigned URLs for uploaded objects
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter ShowURL { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time for presigned URLs (default: 1 hour)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateRange("00:01:00", "7.00:00:00")] // 1 minute to 7 days
|
||||
public TimeSpan Expiration { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConnection();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
if (ParameterSetName == "Files")
|
||||
{
|
||||
ProcessFiles();
|
||||
}
|
||||
else if (ParameterSetName == "Directory")
|
||||
{
|
||||
ProcessDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes file uploads from FileInfo array
|
||||
/// </summary>
|
||||
private void ProcessFiles()
|
||||
{
|
||||
if (Path == null || Path.Length == 0)
|
||||
{
|
||||
WriteWarning("No files provided for upload");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldProcess(BucketName, $"Upload {Path.Length} file(s)"))
|
||||
{
|
||||
ExecuteOperation("UploadFiles", () =>
|
||||
{
|
||||
// Check if bucket exists first
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create bucket directory structure if specified
|
||||
if (!string.IsNullOrWhiteSpace(BucketDirectory))
|
||||
{
|
||||
var sanitizedDirectory = SanitizeBucketDirectory(BucketDirectory);
|
||||
if (!string.IsNullOrEmpty(sanitizedDirectory))
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Ensuring bucket directory exists: {0}", sanitizedDirectory);
|
||||
EnsureBucketDirectoryExists(sanitizedDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
UploadFileCollection(Path!);
|
||||
|
||||
}, $"Bucket: {BucketName}, Files: {Path.Length}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes directory upload
|
||||
/// </summary>
|
||||
private void ProcessDirectory()
|
||||
{
|
||||
if (Directory == null || !Directory.Exists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new DirectoryNotFoundException($"Directory not found: {Directory?.FullName}"),
|
||||
"DirectoryNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
Directory));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldProcess(BucketName, $"Upload directory '{Directory.Name}'"))
|
||||
{
|
||||
ExecuteOperation("UploadDirectory", () =>
|
||||
{
|
||||
// Check if bucket exists first
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get files from directory
|
||||
var files = GetDirectoryFiles();
|
||||
if (files.Length == 0)
|
||||
{
|
||||
WriteWarning($"No files found in directory: {Directory.FullName}");
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Found {0} files in directory '{1}'", files.Length, Directory.FullName);
|
||||
UploadFileCollection(files);
|
||||
|
||||
}, $"Bucket: {BucketName}, Directory: {Directory.FullName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets files from directory based on filters and recursion settings
|
||||
/// </summary>
|
||||
/// <returns>Array of FileInfo objects</returns>
|
||||
private FileInfo[] GetDirectoryFiles()
|
||||
{
|
||||
var searchOption = Recursive.IsPresent ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
var allFiles = Directory!.GetFiles("*", searchOption);
|
||||
|
||||
// Apply depth filtering if MaxDepth is specified and we're recursive
|
||||
if (Recursive.IsPresent && MaxDepth > 0)
|
||||
{
|
||||
var basePath = Directory.FullName;
|
||||
allFiles = allFiles.Where(f =>
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(basePath, f.FullName);
|
||||
var depth = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Length - 1;
|
||||
return depth <= MaxDepth;
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
// Apply inclusion filter
|
||||
if (InclusionFilter != null)
|
||||
{
|
||||
allFiles = allFiles.Where(f => EvaluateFilter(InclusionFilter, f)).ToArray();
|
||||
}
|
||||
|
||||
// Apply exclusion filter
|
||||
if (ExclusionFilter != null)
|
||||
{
|
||||
allFiles = allFiles.Where(f => !EvaluateFilter(ExclusionFilter, f)).ToArray();
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a script block filter against a file
|
||||
/// </summary>
|
||||
/// <param name="filter">Script block filter</param>
|
||||
/// <param name="file">File to evaluate</param>
|
||||
/// <returns>True if file matches filter</returns>
|
||||
private bool EvaluateFilter(ScriptBlock filter, FileInfo file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = filter.InvokeWithContext(null, new[] { new PSVariable("_", file) });
|
||||
return result.Count > 0 && LanguagePrimitives.IsTrue(result[0]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Filter evaluation failed for {file.Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a collection of files
|
||||
/// </summary>
|
||||
/// <param name="files">Files to upload</param>
|
||||
private void UploadFileCollection(FileInfo[] files)
|
||||
{
|
||||
// Filter out files that don't exist
|
||||
var validFiles = files.Where(f => f.Exists).ToArray();
|
||||
var skippedCount = files.Length - validFiles.Length;
|
||||
|
||||
if (skippedCount > 0)
|
||||
{
|
||||
WriteWarning($"Skipped {skippedCount} files that do not exist");
|
||||
}
|
||||
|
||||
if (validFiles.Length == 0)
|
||||
{
|
||||
WriteWarning("No valid files found for upload");
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Uploading {0} files to bucket '{1}'", validFiles.Length, BucketName);
|
||||
|
||||
// Calculate total size for overall progress
|
||||
var totalSize = validFiles.Sum(f => f.Length);
|
||||
var totalProcessed = 0L;
|
||||
|
||||
// Create overall progress reporter
|
||||
var overallProgress = new ProgressReporter(
|
||||
this,
|
||||
"Uploading Files",
|
||||
$"Uploading {validFiles.Length} files",
|
||||
totalSize,
|
||||
1);
|
||||
|
||||
var uploadedObjects = new System.Collections.Generic.List<MinIOObjectInfo>();
|
||||
|
||||
for (int i = 0; i < validFiles.Length; i++)
|
||||
{
|
||||
var fileInfo = validFiles[i];
|
||||
var objectName = GetObjectName(fileInfo);
|
||||
|
||||
try
|
||||
{
|
||||
// Show file-level progress
|
||||
var fileProgressRecord = new ProgressRecord(2, "Current File",
|
||||
$"Uploading: {fileInfo.Name}")
|
||||
{
|
||||
PercentComplete = (int)((double)(i + 1) / validFiles.Length * 100),
|
||||
ParentActivityId = 1
|
||||
};
|
||||
WriteProgress(fileProgressRecord);
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Uploading file {0}/{1}: {2} -> {3}",
|
||||
i + 1, validFiles.Length, fileInfo.Name, objectName);
|
||||
|
||||
// Upload the file
|
||||
var etag = Client.UploadFile(
|
||||
BucketName,
|
||||
objectName,
|
||||
fileInfo.FullName,
|
||||
null, // Auto-detect content type
|
||||
bytesTransferred =>
|
||||
{
|
||||
overallProgress.UpdateProgress(totalProcessed + bytesTransferred);
|
||||
});
|
||||
|
||||
totalProcessed += fileInfo.Length;
|
||||
overallProgress.UpdateProgress(totalProcessed);
|
||||
|
||||
// Create object info for result
|
||||
var uploadedObject = new MinIOObjectInfo(
|
||||
objectName,
|
||||
fileInfo.Length,
|
||||
DateTime.UtcNow,
|
||||
etag,
|
||||
BucketName);
|
||||
|
||||
// Generate presigned URL if requested
|
||||
if (ShowURL.IsPresent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var presignedUrl = Client.GetPresignedUrl(BucketName, objectName, Expiration);
|
||||
uploadedObject.PresignedUrl = presignedUrl;
|
||||
uploadedObject.PresignedUrlExpiration = DateTime.UtcNow.Add(Expiration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Could not generate presigned URL for {objectName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
uploadedObjects.Add(uploadedObject);
|
||||
MinIOLogger.WriteVerbose(this, "Successfully uploaded: {0}", fileInfo.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
ex,
|
||||
"FileUploadFailed",
|
||||
ErrorCategory.WriteError,
|
||||
fileInfo));
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Failed to upload {0}: {1}", fileInfo.Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Complete progress reporting
|
||||
overallProgress.Complete();
|
||||
WriteProgress(new ProgressRecord(2, "Current File", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed,
|
||||
ParentActivityId = 1
|
||||
});
|
||||
|
||||
// Always return uploaded objects
|
||||
foreach (var obj in uploadedObjects)
|
||||
{
|
||||
WriteObject(obj);
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Completed uploading {0} files ({1} successful, {2} failed)",
|
||||
validFiles.Length, uploadedObjects.Count, validFiles.Length - uploadedObjects.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the object name for a file based on the upload context
|
||||
/// </summary>
|
||||
/// <param name="file">File to get object name for</param>
|
||||
/// <returns>Object name to use in MinIO</returns>
|
||||
private string GetObjectName(FileInfo file)
|
||||
{
|
||||
if (ParameterSetName == "Files")
|
||||
{
|
||||
// For file uploads, optionally prefix with bucket directory
|
||||
var objectName = file.Name;
|
||||
if (!string.IsNullOrWhiteSpace(BucketDirectory))
|
||||
{
|
||||
var sanitizedDirectory = SanitizeBucketDirectory(BucketDirectory);
|
||||
objectName = $"{sanitizedDirectory}/{file.Name}";
|
||||
}
|
||||
return objectName;
|
||||
}
|
||||
else if (ParameterSetName == "Directory")
|
||||
{
|
||||
if (Flatten.IsPresent)
|
||||
{
|
||||
// Flatten: use just the filename
|
||||
return file.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Maintain directory structure relative to the base directory
|
||||
var relativePath = Path.GetRelativePath(Directory!.FullName, file.FullName);
|
||||
return relativePath.Replace('\\', '/'); // Ensure forward slashes for object storage
|
||||
}
|
||||
}
|
||||
|
||||
return file.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes bucket directory path and ensures proper format
|
||||
/// </summary>
|
||||
/// <param name="directory">Raw directory path</param>
|
||||
/// <returns>Sanitized directory path</returns>
|
||||
private string SanitizeBucketDirectory(string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
return string.Empty;
|
||||
|
||||
// Remove leading and trailing slashes, then split and clean
|
||||
var cleanDirectory = directory.Trim().Trim('/', '\\');
|
||||
|
||||
// Split by both forward and back slashes, then clean each part
|
||||
var parts = cleanDirectory.Split(new char[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Clean each part and rejoin with forward slashes
|
||||
var cleanedParts = parts.Select(part => part.Trim()).Where(part => !string.IsNullOrEmpty(part));
|
||||
|
||||
return string.Join("/", cleanedParts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates bucket directory structure if it doesn't exist
|
||||
/// </summary>
|
||||
/// <param name="directoryPath">Directory path to create</param>
|
||||
private void EnsureBucketDirectoryExists(string directoryPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directoryPath))
|
||||
return;
|
||||
|
||||
var parts = directoryPath.Split('/');
|
||||
var currentPath = string.Empty;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
currentPath = string.IsNullOrEmpty(currentPath) ? part : $"{currentPath}/{part}";
|
||||
var folderPath = $"{currentPath}/";
|
||||
|
||||
try
|
||||
{
|
||||
// Check if this directory level already exists by trying to list objects with this prefix
|
||||
var existingObjects = Client.ListObjects(BucketName, folderPath, false);
|
||||
var folderExists = existingObjects.Any(obj => obj.Name == folderPath);
|
||||
|
||||
if (!folderExists)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Creating bucket directory: {0}", folderPath);
|
||||
|
||||
// Create the directory by uploading a zero-byte object
|
||||
using var emptyStream = new MemoryStream();
|
||||
Client.UploadStream(BucketName, folderPath, emptyStream, "application/x-directory");
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Successfully created bucket directory: {0}", folderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteWarning($"Could not create bucket directory '{folderPath}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes a MinIO bucket
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Remove, "MinIOBucket", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
|
||||
public class RemoveMinIOBucketCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to remove
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Force removal without confirmation prompts
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remove all objects in the bucket before removing the bucket itself
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[Alias("Recursive")]
|
||||
public SwitchParameter RemoveObjects { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConfiguration();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
// Override confirmation if Force is specified
|
||||
if (Force.IsPresent)
|
||||
{
|
||||
ConfirmPreference = ConfirmImpact.None;
|
||||
}
|
||||
|
||||
var actionDescription = RemoveObjects.IsPresent
|
||||
? $"Remove bucket '{BucketName}' and all its objects"
|
||||
: $"Remove bucket '{BucketName}'";
|
||||
|
||||
if (ShouldProcess(BucketName, actionDescription))
|
||||
{
|
||||
ExecuteOperation("RemoveBucket", () =>
|
||||
{
|
||||
// Check if bucket exists
|
||||
var exists = Client.BucketExists(BucketName);
|
||||
if (!exists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
// If RemoveObjects is specified, remove all objects first
|
||||
if (RemoveObjects.IsPresent)
|
||||
{
|
||||
RemoveAllObjectsFromBucket();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if bucket is empty before attempting to remove
|
||||
CheckBucketEmpty();
|
||||
}
|
||||
|
||||
// Remove the bucket
|
||||
MinIOLogger.WriteVerbose(this, "Removing bucket '{0}'", BucketName);
|
||||
Client.DeleteBucket(BucketName);
|
||||
MinIOLogger.WriteVerbose(this, "Successfully removed bucket '{0}'", BucketName);
|
||||
|
||||
}, $"Bucket: {BucketName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all objects from the bucket
|
||||
/// </summary>
|
||||
private void RemoveAllObjectsFromBucket()
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Removing all objects from bucket '{0}'", BucketName);
|
||||
|
||||
try
|
||||
{
|
||||
var objects = Client.ListObjects(BucketName, recursive: true);
|
||||
|
||||
if (objects.Count == 0)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Bucket '{0}' is already empty", BucketName);
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Found {0} objects to remove from bucket '{1}'", objects.Count, BucketName);
|
||||
|
||||
// Show progress for object removal
|
||||
for (int i = 0; i < objects.Count; i++)
|
||||
{
|
||||
var obj = objects[i];
|
||||
|
||||
var progressRecord = new ProgressRecord(1, "Removing Objects",
|
||||
$"Removing object: {obj.Name}")
|
||||
{
|
||||
PercentComplete = (int)((double)(i + 1) / objects.Count * 100)
|
||||
};
|
||||
WriteProgress(progressRecord);
|
||||
|
||||
try
|
||||
{
|
||||
Client.DeleteObject(BucketName, obj.Name);
|
||||
MinIOLogger.WriteVerbose(this, "Removed object: {0}", obj.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Failed to remove object '{0}': {1}", obj.Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Complete progress
|
||||
WriteProgress(new ProgressRecord(1, "Removing Objects", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed
|
||||
});
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Finished removing objects from bucket '{0}'", BucketName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Failed to list or remove objects from bucket '{0}': {1}", BucketName, ex.Message);
|
||||
|
||||
if (!Force.IsPresent)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Cannot remove objects from bucket '{BucketName}': {ex.Message}"),
|
||||
"ObjectRemovalFailed",
|
||||
ErrorCategory.InvalidOperation,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the bucket is empty and warns if it's not
|
||||
/// </summary>
|
||||
private void CheckBucketEmpty()
|
||||
{
|
||||
try
|
||||
{
|
||||
var objects = Client.ListObjects(BucketName, recursive: true);
|
||||
|
||||
if (objects.Count > 0)
|
||||
{
|
||||
var message = $"Bucket '{BucketName}' contains {objects.Count} objects. " +
|
||||
"Use -RemoveObjects parameter to remove all objects first, or remove them manually.";
|
||||
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException(message),
|
||||
"BucketNotEmpty",
|
||||
ErrorCategory.InvalidOperation,
|
||||
BucketName));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Could not check if bucket '{0}' is empty: {1}", BucketName, ex.Message);
|
||||
|
||||
if (!Force.IsPresent)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Cannot verify bucket '{BucketName}' is empty: {ex.Message}"),
|
||||
"BucketCheckFailed",
|
||||
ErrorCategory.InvalidOperation,
|
||||
BucketName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes an object from a MinIO bucket
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Remove, "MinIOObject", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
|
||||
public class RemoveMinIOObjectCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket containing the object
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the object to remove
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Object", "Key")]
|
||||
public string ObjectName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Force removal without confirmation prompts
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remove all objects matching the specified prefix (use with caution)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[Alias("Recursive")]
|
||||
public SwitchParameter RemovePrefix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConfiguration();
|
||||
ValidateBucketName(BucketName);
|
||||
ValidateObjectName(ObjectName);
|
||||
|
||||
// Override confirmation if Force is specified
|
||||
if (Force.IsPresent)
|
||||
{
|
||||
ConfirmPreference = ConfirmImpact.None;
|
||||
}
|
||||
|
||||
var actionDescription = RemovePrefix.IsPresent
|
||||
? $"Remove all objects with prefix '{ObjectName}' from bucket '{BucketName}'"
|
||||
: $"Remove object '{ObjectName}' from bucket '{BucketName}'";
|
||||
|
||||
if (ShouldProcess($"{BucketName}/{ObjectName}", actionDescription))
|
||||
{
|
||||
ExecuteOperation("RemoveObject", () =>
|
||||
{
|
||||
// Check if bucket exists
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
if (RemovePrefix.IsPresent)
|
||||
{
|
||||
RemoveObjectsWithPrefix();
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveSingleObject();
|
||||
}
|
||||
|
||||
}, $"Bucket: {BucketName}, Object: {ObjectName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a single object
|
||||
/// </summary>
|
||||
private void RemoveSingleObject()
|
||||
{
|
||||
// Check if object exists
|
||||
var objectExists = CheckObjectExists();
|
||||
if (!objectExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Object '{ObjectName}' does not exist in bucket '{BucketName}'"),
|
||||
"ObjectNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
ObjectName));
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Removing object '{0}' from bucket '{1}'", ObjectName, BucketName);
|
||||
|
||||
Client.DeleteObject(BucketName, ObjectName);
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Successfully removed object '{0}' from bucket '{1}'", ObjectName, BucketName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all objects with the specified prefix
|
||||
/// </summary>
|
||||
private void RemoveObjectsWithPrefix()
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Finding objects with prefix '{0}' in bucket '{1}'", ObjectName, BucketName);
|
||||
|
||||
try
|
||||
{
|
||||
var objects = Client.ListObjects(BucketName, ObjectName, true);
|
||||
|
||||
if (objects.Count == 0)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"No objects found with prefix '{0}' in bucket '{1}'", ObjectName, BucketName);
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Found {0} objects with prefix '{1}' in bucket '{2}'",
|
||||
objects.Count, ObjectName, BucketName);
|
||||
|
||||
// Additional confirmation for prefix removal if not forced
|
||||
if (!Force.IsPresent && objects.Count > 1)
|
||||
{
|
||||
var message = $"This will remove {objects.Count} objects with prefix '{ObjectName}'. Are you sure?";
|
||||
if (!ShouldContinue(message, "Confirm Multiple Object Removal"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove objects with progress reporting
|
||||
for (int i = 0; i < objects.Count; i++)
|
||||
{
|
||||
var obj = objects[i];
|
||||
|
||||
var progressRecord = new ProgressRecord(1, "Removing Objects",
|
||||
$"Removing object: {obj.Name}")
|
||||
{
|
||||
PercentComplete = (int)((double)(i + 1) / objects.Count * 100)
|
||||
};
|
||||
WriteProgress(progressRecord);
|
||||
|
||||
try
|
||||
{
|
||||
Client.DeleteObject(BucketName, obj.Name);
|
||||
MinIOLogger.WriteVerbose(this, "Removed object: {0}", obj.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Failed to remove object '{0}': {1}", obj.Name, ex.Message);
|
||||
|
||||
if (!Force.IsPresent)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Failed to remove object '{obj.Name}': {ex.Message}", ex),
|
||||
"ObjectRemovalFailed",
|
||||
ErrorCategory.InvalidOperation,
|
||||
obj.Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Complete progress
|
||||
WriteProgress(new ProgressRecord(1, "Removing Objects", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed
|
||||
});
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Finished removing objects with prefix '{0}' from bucket '{1}'", ObjectName, BucketName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Failed to list objects with prefix '{0}' in bucket '{1}': {2}",
|
||||
ObjectName, BucketName, ex.Message);
|
||||
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Cannot list objects with prefix '{ObjectName}': {ex.Message}", ex),
|
||||
"ObjectListingFailed",
|
||||
ErrorCategory.InvalidOperation,
|
||||
ObjectName));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the specified object exists
|
||||
/// </summary>
|
||||
/// <returns>True if object exists, false otherwise</returns>
|
||||
private bool CheckObjectExists()
|
||||
{
|
||||
try
|
||||
{
|
||||
var objects = Client.ListObjects(BucketName, ObjectName, false);
|
||||
return objects.Any(obj =>
|
||||
string.Equals(obj.Name, ObjectName, StringComparison.Ordinal));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteWarning(this,
|
||||
"Could not check if object '{0}' exists: {1}", ObjectName, ex.Message);
|
||||
|
||||
// If we can't verify existence, assume it exists and let the delete operation handle it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Management.Automation;
|
||||
using System.Text.Json;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the policy for a MinIO bucket
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Set, "MinIOBucketPolicy", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)]
|
||||
public class SetMinIOBucketPolicyCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to set the policy for
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy JSON string
|
||||
/// </summary>
|
||||
[Parameter(Position = 1, Mandatory = true, ParameterSetName = "PolicyJson")]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Json")]
|
||||
public string? PolicyJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to a file containing the policy JSON
|
||||
/// </summary>
|
||||
[Parameter(Mandatory = true, ParameterSetName = "PolicyFile")]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("File")]
|
||||
public string? PolicyFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Use a predefined canned policy
|
||||
/// </summary>
|
||||
[Parameter(Mandatory = true, ParameterSetName = "CannedPolicy")]
|
||||
[ValidateSet("ReadOnly", "WriteOnly", "ReadWrite", "None")]
|
||||
public string? CannedPolicy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prefix for canned policies (default: *)
|
||||
/// </summary>
|
||||
[Parameter(ParameterSetName = "CannedPolicy")]
|
||||
[ValidateNotNullOrEmpty]
|
||||
public string Prefix { get; set; } = "*";
|
||||
|
||||
/// <summary>
|
||||
/// Validate the policy JSON before setting it
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter ValidateOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Force setting the policy without confirmation
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConfiguration();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
// Get the policy JSON based on the parameter set
|
||||
var policyJson = GetPolicyJson();
|
||||
if (string.IsNullOrWhiteSpace(policyJson))
|
||||
{
|
||||
return; // Error already written
|
||||
}
|
||||
|
||||
// Validate the policy JSON
|
||||
if (!ValidatePolicyJson(policyJson))
|
||||
{
|
||||
return; // Error already written
|
||||
}
|
||||
|
||||
if (ValidateOnly.IsPresent)
|
||||
{
|
||||
WriteObject("Policy JSON is valid");
|
||||
return;
|
||||
}
|
||||
|
||||
// Override confirmation if Force is specified
|
||||
if (Force.IsPresent)
|
||||
{
|
||||
ConfirmPreference = ConfirmImpact.None;
|
||||
}
|
||||
|
||||
if (ShouldProcess(BucketName, "Set bucket policy"))
|
||||
{
|
||||
ExecuteOperation("SetBucketPolicy", () =>
|
||||
{
|
||||
// Check if bucket exists
|
||||
var bucketExists = Client.BucketExists(BucketName);
|
||||
if (!bucketExists)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Bucket '{BucketName}' does not exist"),
|
||||
"BucketNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
BucketName));
|
||||
return;
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Setting policy for bucket '{0}'", BucketName);
|
||||
MinIOLogger.WriteVerbose(this, "Policy JSON ({0} characters): {1}",
|
||||
policyJson.Length, policyJson.Length > 200 ? policyJson.Substring(0, 200) + "..." : policyJson);
|
||||
|
||||
Client.SetBucketPolicy(BucketName, policyJson);
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Successfully set policy for bucket '{0}'", BucketName);
|
||||
|
||||
}, $"Bucket: {BucketName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy JSON based on the current parameter set
|
||||
/// </summary>
|
||||
/// <returns>Policy JSON string or null if error</returns>
|
||||
private string? GetPolicyJson()
|
||||
{
|
||||
switch (ParameterSetName)
|
||||
{
|
||||
case "PolicyJson":
|
||||
return PolicyJson;
|
||||
|
||||
case "PolicyFile":
|
||||
return ReadPolicyFromFile();
|
||||
|
||||
case "CannedPolicy":
|
||||
return GenerateCannedPolicy();
|
||||
|
||||
default:
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException("Unknown parameter set"),
|
||||
"UnknownParameterSet",
|
||||
ErrorCategory.InvalidArgument,
|
||||
null));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads policy JSON from a file
|
||||
/// </summary>
|
||||
/// <returns>Policy JSON string or null if error</returns>
|
||||
private string? ReadPolicyFromFile()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PolicyFilePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(PolicyFilePath);
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new FileNotFoundException($"Policy file not found: {fullPath}"),
|
||||
"PolicyFileNotFound",
|
||||
ErrorCategory.ObjectNotFound,
|
||||
fullPath));
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = File.ReadAllText(fullPath);
|
||||
MinIOLogger.WriteVerbose(this, "Read policy from file '{0}' ({1} characters)", fullPath, content.Length);
|
||||
|
||||
return content;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new InvalidOperationException($"Failed to read policy file '{PolicyFilePath}': {ex.Message}", ex),
|
||||
"PolicyFileReadError",
|
||||
ErrorCategory.ReadError,
|
||||
PolicyFilePath));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a canned policy based on the specified type
|
||||
/// </summary>
|
||||
/// <returns>Policy JSON string</returns>
|
||||
private string GenerateCannedPolicy()
|
||||
{
|
||||
var bucketArn = $"arn:aws:s3:::{BucketName}";
|
||||
var objectArn = $"arn:aws:s3:::{BucketName}/{Prefix}";
|
||||
|
||||
var policy = CannedPolicy?.ToLowerInvariant() switch
|
||||
{
|
||||
"readonly" => CreateReadOnlyPolicy(bucketArn, objectArn),
|
||||
"writeonly" => CreateWriteOnlyPolicy(bucketArn, objectArn),
|
||||
"readwrite" => CreateReadWritePolicy(bucketArn, objectArn),
|
||||
"none" => CreateEmptyPolicy(),
|
||||
_ => throw new ArgumentException($"Unknown canned policy: {CannedPolicy}")
|
||||
};
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "Generated {0} canned policy for bucket '{1}' with prefix '{2}'",
|
||||
CannedPolicy, BucketName, Prefix);
|
||||
|
||||
return JsonSerializer.Serialize(policy, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-only policy
|
||||
/// </summary>
|
||||
private object CreateReadOnlyPolicy(string bucketArn, string objectArn)
|
||||
{
|
||||
return new
|
||||
{
|
||||
Version = "2012-10-17",
|
||||
Statement = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Effect = "Allow",
|
||||
Principal = new { AWS = "*" },
|
||||
Action = new[] { "s3:GetBucketLocation", "s3:ListBucket" },
|
||||
Resource = bucketArn
|
||||
},
|
||||
new
|
||||
{
|
||||
Effect = "Allow",
|
||||
Principal = new { AWS = "*" },
|
||||
Action = new[] { "s3:GetObject" },
|
||||
Resource = objectArn
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a write-only policy
|
||||
/// </summary>
|
||||
private object CreateWriteOnlyPolicy(string bucketArn, string objectArn)
|
||||
{
|
||||
return new
|
||||
{
|
||||
Version = "2012-10-17",
|
||||
Statement = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Effect = "Allow",
|
||||
Principal = new { AWS = "*" },
|
||||
Action = new[] { "s3:GetBucketLocation", "s3:ListBucketMultipartUploads" },
|
||||
Resource = bucketArn
|
||||
},
|
||||
new
|
||||
{
|
||||
Effect = "Allow",
|
||||
Principal = new { AWS = "*" },
|
||||
Action = new[] { "s3:PutObject", "s3:AbortMultipartUpload", "s3:DeleteObject", "s3:ListMultipartUploadParts" },
|
||||
Resource = objectArn
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-write policy
|
||||
/// </summary>
|
||||
private object CreateReadWritePolicy(string bucketArn, string objectArn)
|
||||
{
|
||||
return new
|
||||
{
|
||||
Version = "2012-10-17",
|
||||
Statement = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Effect = "Allow",
|
||||
Principal = new { AWS = "*" },
|
||||
Action = new[] { "s3:GetBucketLocation", "s3:ListBucket", "s3:ListBucketMultipartUploads" },
|
||||
Resource = bucketArn
|
||||
},
|
||||
new
|
||||
{
|
||||
Effect = "Allow",
|
||||
Principal = new { AWS = "*" },
|
||||
Action = new[] { "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts" },
|
||||
Resource = objectArn
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty policy (removes all permissions)
|
||||
/// </summary>
|
||||
private object CreateEmptyPolicy()
|
||||
{
|
||||
return new
|
||||
{
|
||||
Version = "2012-10-17",
|
||||
Statement = new object[0]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the policy JSON
|
||||
/// </summary>
|
||||
/// <param name="policyJson">Policy JSON to validate</param>
|
||||
/// <returns>True if valid, false otherwise</returns>
|
||||
private bool ValidatePolicyJson(string policyJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(policyJson);
|
||||
MinIOLogger.WriteVerbose(this, "Policy JSON is valid");
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
WriteError(new ErrorRecord(
|
||||
new ArgumentException($"Invalid policy JSON: {ex.Message}", ex),
|
||||
"InvalidPolicyJson",
|
||||
ErrorCategory.InvalidArgument,
|
||||
policyJson));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Cmdlets
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests whether a MinIO bucket exists
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsDiagnostic.Test, "MinIOBucketExists", SupportsShouldProcess = false)]
|
||||
[OutputType(typeof(bool))]
|
||||
public class TestMinIOBucketExistsCmdlet : MinIOBaseCmdlet
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket to test
|
||||
/// </summary>
|
||||
[Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
[ValidateNotNullOrEmpty]
|
||||
[Alias("Bucket")]
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Return detailed information instead of just true/false
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SwitchParameter Detailed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Processes the cmdlet
|
||||
/// </summary>
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
ValidateConfiguration();
|
||||
ValidateBucketName(BucketName);
|
||||
|
||||
ExecuteOperation("TestBucketExists", () =>
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Checking if bucket '{0}' exists", BucketName);
|
||||
|
||||
var exists = Client.BucketExists(BucketName);
|
||||
|
||||
if (Detailed.IsPresent)
|
||||
{
|
||||
// Return detailed information
|
||||
var result = new PSObject();
|
||||
result.Properties.Add(new PSNoteProperty("BucketName", BucketName));
|
||||
result.Properties.Add(new PSNoteProperty("Exists", exists));
|
||||
result.Properties.Add(new PSNoteProperty("Endpoint", Configuration.Endpoint));
|
||||
result.Properties.Add(new PSNoteProperty("CheckedAt", System.DateTime.UtcNow));
|
||||
|
||||
if (exists)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to get additional bucket information
|
||||
var allBuckets = Client.ListBuckets();
|
||||
var bucket = allBuckets.Find(b =>
|
||||
string.Equals(b.Name, BucketName, System.StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (bucket != null)
|
||||
{
|
||||
result.Properties.Add(new PSNoteProperty("Created", bucket.Created));
|
||||
result.Properties.Add(new PSNoteProperty("Region", bucket.Region));
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Could not retrieve additional bucket information: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
WriteObject(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Return simple boolean result
|
||||
WriteObject(exists);
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this,
|
||||
"Bucket '{0}' exists: {1}", BucketName, exists);
|
||||
|
||||
}, $"Bucket: {BucketName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom output type for detailed bucket existence information
|
||||
/// </summary>
|
||||
public class BucketExistenceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket that was tested
|
||||
/// </summary>
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bucket exists
|
||||
/// </summary>
|
||||
public bool Exists { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MinIO endpoint that was checked
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the check was performed
|
||||
/// </summary>
|
||||
public System.DateTime CheckedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation date of the bucket (if it exists and information is available)
|
||||
/// </summary>
|
||||
public System.DateTime? Created { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Region of the bucket (if it exists and information is available)
|
||||
/// </summary>
|
||||
public string? Region { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new BucketExistenceInfo instance
|
||||
/// </summary>
|
||||
public BucketExistenceInfo()
|
||||
{
|
||||
CheckedAt = System.DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new BucketExistenceInfo instance with specified values
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="exists">Whether the bucket exists</param>
|
||||
/// <param name="endpoint">MinIO endpoint</param>
|
||||
public BucketExistenceInfo(string bucketName, bool exists, string endpoint)
|
||||
{
|
||||
BucketName = bucketName ?? string.Empty;
|
||||
Exists = exists;
|
||||
Endpoint = endpoint ?? string.Empty;
|
||||
CheckedAt = System.DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the bucket existence info
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Bucket '{BucketName}' exists: {Exists} (checked at {CheckedAt:yyyy-MM-dd HH:mm:ss} UTC)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PSMinIO.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the state of a chunked transfer operation for resume functionality
|
||||
/// </summary>
|
||||
public class ChunkedTransferState
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket
|
||||
/// </summary>
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the object
|
||||
/// </summary>
|
||||
public string ObjectName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Local file path
|
||||
/// </summary>
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total size of the file/object
|
||||
/// </summary>
|
||||
public long TotalSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of each chunk
|
||||
/// </summary>
|
||||
public long ChunkSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of completed chunks
|
||||
/// </summary>
|
||||
public List<ChunkInfo> CompletedChunks { get; set; } = new List<ChunkInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// Last modified time of the source file (for validation)
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ETag of the object (for validation)
|
||||
/// </summary>
|
||||
public string ETag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Transfer operation type
|
||||
/// </summary>
|
||||
public ChunkedTransferType TransferType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the transfer was started
|
||||
/// </summary>
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the transfer state was last updated
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload ID for multipart uploads (MinIO specific)
|
||||
/// </summary>
|
||||
public string? UploadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of chunks
|
||||
/// </summary>
|
||||
public int TotalChunks => (int)Math.Ceiling((double)TotalSize / ChunkSize);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of completed chunks
|
||||
/// </summary>
|
||||
public int CompletedChunkCount => CompletedChunks.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total bytes transferred
|
||||
/// </summary>
|
||||
public long BytesTransferred => CompletedChunks.Sum(c => c.Size);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transfer progress as a percentage
|
||||
/// </summary>
|
||||
public double ProgressPercentage => TotalSize > 0 ? (double)BytesTransferred / TotalSize * 100 : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the transfer is complete
|
||||
/// </summary>
|
||||
public bool IsComplete => CompletedChunkCount >= TotalChunks;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next chunk to transfer
|
||||
/// </summary>
|
||||
/// <returns>Next chunk info or null if complete</returns>
|
||||
public ChunkInfo? GetNextChunk()
|
||||
{
|
||||
for (int i = 0; i < TotalChunks; i++)
|
||||
{
|
||||
if (!CompletedChunks.Any(c => c.ChunkNumber == i))
|
||||
{
|
||||
var startByte = i * ChunkSize;
|
||||
var endByte = Math.Min(startByte + ChunkSize - 1, TotalSize - 1);
|
||||
var size = endByte - startByte + 1;
|
||||
|
||||
return new ChunkInfo
|
||||
{
|
||||
ChunkNumber = i,
|
||||
StartByte = startByte,
|
||||
EndByte = endByte,
|
||||
Size = size,
|
||||
IsCompleted = false
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a chunk as completed
|
||||
/// </summary>
|
||||
/// <param name="chunkInfo">Completed chunk information</param>
|
||||
public void MarkChunkCompleted(ChunkInfo chunkInfo)
|
||||
{
|
||||
chunkInfo.IsCompleted = true;
|
||||
chunkInfo.CompletedTime = DateTime.UtcNow;
|
||||
|
||||
// Remove any existing entry for this chunk number
|
||||
CompletedChunks.RemoveAll(c => c.ChunkNumber == chunkInfo.ChunkNumber);
|
||||
|
||||
// Add the completed chunk
|
||||
CompletedChunks.Add(chunkInfo);
|
||||
|
||||
LastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a single chunk
|
||||
/// </summary>
|
||||
public class ChunkInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Chunk number (0-based)
|
||||
/// </summary>
|
||||
public int ChunkNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Starting byte position
|
||||
/// </summary>
|
||||
public long StartByte { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ending byte position (inclusive)
|
||||
/// </summary>
|
||||
public long EndByte { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the chunk in bytes
|
||||
/// </summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ETag of the uploaded chunk (for uploads)
|
||||
/// </summary>
|
||||
public string ChunkETag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this chunk has been completed
|
||||
/// </summary>
|
||||
public bool IsCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this chunk was completed
|
||||
/// </summary>
|
||||
public DateTime? CompletedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of retry attempts for this chunk
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last error encountered for this chunk
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of chunked transfer operation
|
||||
/// </summary>
|
||||
public enum ChunkedTransferType
|
||||
{
|
||||
Upload,
|
||||
Download
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
|
||||
namespace PSMinIO.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents information about a MinIO bucket
|
||||
/// </summary>
|
||||
public class MinIOBucketInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the bucket
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creation date of the bucket
|
||||
/// </summary>
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Region where the bucket is located
|
||||
/// </summary>
|
||||
public string Region { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total size of all objects in the bucket (optional, calculated separately)
|
||||
/// </summary>
|
||||
public long? Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of objects in the bucket (optional, calculated separately)
|
||||
/// </summary>
|
||||
public long? ObjectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOBucketInfo instance
|
||||
/// </summary>
|
||||
public MinIOBucketInfo()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOBucketInfo instance with specified values
|
||||
/// </summary>
|
||||
/// <param name="name">Bucket name</param>
|
||||
/// <param name="created">Creation date</param>
|
||||
/// <param name="region">Bucket region</param>
|
||||
public MinIOBucketInfo(string name, DateTime created, string region = "")
|
||||
{
|
||||
Name = name ?? string.Empty;
|
||||
Created = created;
|
||||
Region = region ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MinIOBucketInfo from a Minio.DataModel.Bucket
|
||||
/// </summary>
|
||||
/// <param name="bucket">Minio bucket object</param>
|
||||
/// <returns>MinIOBucketInfo instance</returns>
|
||||
public static MinIOBucketInfo FromMinioBucket(Minio.DataModel.Bucket bucket)
|
||||
{
|
||||
if (bucket == null)
|
||||
throw new ArgumentNullException(nameof(bucket));
|
||||
|
||||
return new MinIOBucketInfo
|
||||
{
|
||||
Name = bucket.Name ?? string.Empty,
|
||||
Created = bucket.CreationDate,
|
||||
Region = string.Empty // Region is not available in basic bucket info
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the bucket info
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Bucket: {Name} (Created: {Created:yyyy-MM-dd HH:mm:ss})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified object is equal to the current object
|
||||
/// </summary>
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is MinIOBucketInfo other)
|
||||
{
|
||||
return string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a hash code for the current object
|
||||
/// </summary>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return Name?.ToLowerInvariant().GetHashCode() ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
|
||||
namespace PSMinIO.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration settings for MinIO client connections
|
||||
/// </summary>
|
||||
public class MinIOConfiguration
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// MinIO server endpoint (without protocol)
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Access key for authentication
|
||||
/// </summary>
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Secret key for authentication
|
||||
/// </summary>
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use SSL/TLS for connections
|
||||
/// </summary>
|
||||
public bool UseSSL { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional region for bucket operations
|
||||
/// </summary>
|
||||
public string Region { get; set; } = "us-east-1";
|
||||
|
||||
/// <summary>
|
||||
/// Connection timeout in seconds
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to skip SSL certificate validation
|
||||
/// </summary>
|
||||
public bool SkipCertificateValidation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the configuration is valid for creating a client
|
||||
/// </summary>
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(Endpoint) &&
|
||||
!string.IsNullOrWhiteSpace(AccessKey) &&
|
||||
!string.IsNullOrWhiteSpace(SecretKey);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOConfiguration instance
|
||||
/// </summary>
|
||||
public MinIOConfiguration()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOConfiguration instance with specified values
|
||||
/// </summary>
|
||||
/// <param name="endpoint">MinIO endpoint</param>
|
||||
/// <param name="accessKey">Access key</param>
|
||||
/// <param name="secretKey">Secret key</param>
|
||||
/// <param name="useSSL">Use SSL flag</param>
|
||||
/// <param name="region">Optional region</param>
|
||||
/// <param name="timeoutSeconds">Connection timeout</param>
|
||||
/// <param name="skipCertificateValidation">Whether to skip SSL certificate validation</param>
|
||||
public MinIOConfiguration(string endpoint, string accessKey, string secretKey,
|
||||
bool useSSL = true, string region = "us-east-1", int timeoutSeconds = 30, bool skipCertificateValidation = false)
|
||||
{
|
||||
Endpoint = endpoint?.Trim() ?? string.Empty;
|
||||
AccessKey = accessKey?.Trim() ?? string.Empty;
|
||||
SecretKey = secretKey?.Trim() ?? string.Empty;
|
||||
UseSSL = useSSL;
|
||||
Region = region?.Trim() ?? "us-east-1";
|
||||
TimeoutSeconds = timeoutSeconds;
|
||||
SkipCertificateValidation = skipCertificateValidation;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an active MinIO connection with configuration and client wrapper
|
||||
/// </summary>
|
||||
public class MinIOConnection : IDisposable
|
||||
{
|
||||
private MinIOClientWrapper? _client;
|
||||
private bool _disposed = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration for this connection
|
||||
/// </summary>
|
||||
public MinIOConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MinIO client wrapper instance
|
||||
/// </summary>
|
||||
public MinIOClientWrapper Client
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(MinIOConnection));
|
||||
|
||||
if (_client == null)
|
||||
{
|
||||
_client = new MinIOClientWrapper(Configuration);
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this connection is valid and ready to use
|
||||
/// </summary>
|
||||
public bool IsValid => Configuration.IsValid && !_disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection status
|
||||
/// </summary>
|
||||
public string Status
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_disposed) return "Disposed";
|
||||
if (!Configuration.IsValid) return "Invalid Configuration";
|
||||
return "Ready";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint URL for display purposes
|
||||
/// </summary>
|
||||
public string EndpointUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Configuration.Endpoint))
|
||||
return "Not Set";
|
||||
|
||||
var protocol = Configuration.UseSSL ? "https" : "http";
|
||||
return $"{protocol}://{Configuration.Endpoint}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this connection was created
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOConnection instance
|
||||
/// </summary>
|
||||
/// <param name="configuration">MinIO configuration</param>
|
||||
public MinIOConnection(MinIOConfiguration configuration)
|
||||
{
|
||||
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection by attempting to list buckets
|
||||
/// </summary>
|
||||
/// <returns>Connection test result</returns>
|
||||
public ConnectionTestResult TestConnection()
|
||||
{
|
||||
if (_disposed)
|
||||
return new ConnectionTestResult(false, "Connection is disposed");
|
||||
|
||||
if (!Configuration.IsValid)
|
||||
return new ConnectionTestResult(false, "Configuration is invalid");
|
||||
|
||||
try
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var buckets = Client.ListBuckets();
|
||||
var duration = DateTime.UtcNow - startTime;
|
||||
|
||||
return new ConnectionTestResult(true, "Connection successful")
|
||||
{
|
||||
BucketCount = buckets.Count,
|
||||
ResponseTime = duration,
|
||||
TestedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ConnectionTestResult(false, ex.Message)
|
||||
{
|
||||
TestedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the connection
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"MinIO Connection: {EndpointUrl} (Status: {Status})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the connection and underlying client
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protected dispose method
|
||||
/// </summary>
|
||||
/// <param name="disposing">Whether disposing from Dispose method</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed && disposing)
|
||||
{
|
||||
_client?.Dispose();
|
||||
_client = null;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a connection test
|
||||
/// </summary>
|
||||
public class ConnectionTestResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the connection test was successful
|
||||
/// </summary>
|
||||
public bool Success { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Message describing the test result
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of buckets found (if successful)
|
||||
/// </summary>
|
||||
public int? BucketCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Response time for the test
|
||||
/// </summary>
|
||||
public TimeSpan? ResponseTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the test was performed
|
||||
/// </summary>
|
||||
public DateTime? TestedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ConnectionTestResult
|
||||
/// </summary>
|
||||
/// <param name="success">Whether the test was successful</param>
|
||||
/// <param name="message">Test result message</param>
|
||||
public ConnectionTestResult(bool success, string message)
|
||||
{
|
||||
Success = success;
|
||||
Message = message ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the test result
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
var result = Success ? "SUCCESS" : "FAILED";
|
||||
var details = ResponseTime.HasValue ? $" ({ResponseTime.Value.TotalMilliseconds:F0}ms)" : "";
|
||||
return $"{result}: {Message}{details}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PSMinIO.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents information about a MinIO object
|
||||
/// </summary>
|
||||
public class MinIOObjectInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Name/key of the object
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Size of the object in bytes
|
||||
/// </summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last modified date of the object
|
||||
/// </summary>
|
||||
public DateTime LastModified { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ETag of the object
|
||||
/// </summary>
|
||||
public string ETag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Content type of the object
|
||||
/// </summary>
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Bucket name containing this object
|
||||
/// </summary>
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a directory/prefix (ends with /)
|
||||
/// </summary>
|
||||
public bool IsDirectory => Name.EndsWith("/");
|
||||
|
||||
/// <summary>
|
||||
/// Object metadata/user-defined metadata
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Metadata { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Storage class of the object
|
||||
/// </summary>
|
||||
public string StorageClass { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Version ID of the object (for versioned buckets)
|
||||
/// </summary>
|
||||
public string? VersionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this object is the latest version
|
||||
/// </summary>
|
||||
public bool IsLatestVersion { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this object is a delete marker
|
||||
/// </summary>
|
||||
public bool IsDeleteMarker { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Presigned URL for accessing this object (if generated)
|
||||
/// </summary>
|
||||
public string? PresignedUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time for the presigned URL (if generated)
|
||||
/// </summary>
|
||||
public DateTime? PresignedUrlExpiration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOObjectInfo instance
|
||||
/// </summary>
|
||||
public MinIOObjectInfo()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOObjectInfo instance with specified values
|
||||
/// </summary>
|
||||
/// <param name="name">Object name</param>
|
||||
/// <param name="size">Object size</param>
|
||||
/// <param name="lastModified">Last modified date</param>
|
||||
/// <param name="etag">ETag</param>
|
||||
/// <param name="bucketName">Bucket name</param>
|
||||
public MinIOObjectInfo(string name, long size, DateTime lastModified, string etag, string bucketName)
|
||||
{
|
||||
Name = name ?? string.Empty;
|
||||
Size = size;
|
||||
LastModified = lastModified;
|
||||
ETag = etag ?? string.Empty;
|
||||
BucketName = bucketName ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MinIOObjectInfo from a Minio.DataModel.Item
|
||||
/// </summary>
|
||||
/// <param name="item">Minio item object</param>
|
||||
/// <param name="bucketName">Name of the bucket containing the object</param>
|
||||
/// <returns>MinIOObjectInfo instance</returns>
|
||||
public static MinIOObjectInfo FromMinioItem(Minio.DataModel.Item item, string bucketName)
|
||||
{
|
||||
if (item == null)
|
||||
throw new ArgumentNullException(nameof(item));
|
||||
|
||||
var objectInfo = new MinIOObjectInfo
|
||||
{
|
||||
Name = item.Key ?? string.Empty,
|
||||
Size = (long)(item.Size ?? 0),
|
||||
LastModified = item.LastModifiedDateTime ?? DateTime.MinValue,
|
||||
ETag = item.ETag ?? string.Empty,
|
||||
BucketName = bucketName ?? string.Empty,
|
||||
StorageClass = item.StorageClass ?? string.Empty
|
||||
};
|
||||
|
||||
// Try to extract version information if available
|
||||
// Note: The MinIO .NET SDK Item object may have version properties
|
||||
try
|
||||
{
|
||||
// Use reflection to check for version properties that might exist
|
||||
var itemType = item.GetType();
|
||||
|
||||
var versionIdProperty = itemType.GetProperty("VersionId");
|
||||
if (versionIdProperty != null)
|
||||
{
|
||||
objectInfo.VersionId = versionIdProperty.GetValue(item)?.ToString();
|
||||
}
|
||||
|
||||
var isLatestProperty = itemType.GetProperty("IsLatest");
|
||||
if (isLatestProperty != null && isLatestProperty.GetValue(item) is bool isLatest)
|
||||
{
|
||||
objectInfo.IsLatestVersion = isLatest;
|
||||
}
|
||||
|
||||
var isDeleteMarkerProperty = itemType.GetProperty("IsDeleteMarker");
|
||||
if (isDeleteMarkerProperty != null && isDeleteMarkerProperty.GetValue(item) is bool isDeleteMarker)
|
||||
{
|
||||
objectInfo.IsDeleteMarker = isDeleteMarker;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If reflection fails, just continue without version information
|
||||
// This ensures compatibility even if the SDK doesn't have these properties
|
||||
}
|
||||
|
||||
// Copy metadata if available
|
||||
if (item.MetaData != null)
|
||||
{
|
||||
foreach (var kvp in item.MetaData)
|
||||
{
|
||||
objectInfo.Metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return objectInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file extension of the object
|
||||
/// </summary>
|
||||
public string GetFileExtension()
|
||||
{
|
||||
if (IsDirectory || string.IsNullOrEmpty(Name))
|
||||
return string.Empty;
|
||||
|
||||
var lastDotIndex = Name.LastIndexOf('.');
|
||||
if (lastDotIndex >= 0 && lastDotIndex < Name.Length - 1)
|
||||
{
|
||||
return Name.Substring(lastDotIndex);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path of the object (everything before the last /)
|
||||
/// </summary>
|
||||
public string GetDirectoryPath()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Name))
|
||||
return string.Empty;
|
||||
|
||||
var lastSlashIndex = Name.LastIndexOf('/');
|
||||
if (lastSlashIndex >= 0)
|
||||
{
|
||||
return Name.Substring(0, lastSlashIndex + 1);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file name without the directory path
|
||||
/// </summary>
|
||||
public string GetFileName()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Name) || IsDirectory)
|
||||
return Name;
|
||||
|
||||
var lastSlashIndex = Name.LastIndexOf('/');
|
||||
if (lastSlashIndex >= 0 && lastSlashIndex < Name.Length - 1)
|
||||
{
|
||||
return Name.Substring(lastSlashIndex + 1);
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the object info
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Object: {Name} ({Size} bytes, Modified: {LastModified:yyyy-MM-dd HH:mm:ss})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified object is equal to the current object
|
||||
/// </summary>
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is MinIOObjectInfo other)
|
||||
{
|
||||
return string.Equals(Name, other.Name, StringComparison.Ordinal) &&
|
||||
string.Equals(BucketName, other.BucketName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a hash code for the current object
|
||||
/// </summary>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Name, BucketName?.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
|
||||
namespace PSMinIO.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents statistical information about MinIO storage
|
||||
/// </summary>
|
||||
public class MinIOStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of buckets
|
||||
/// </summary>
|
||||
public int TotalBuckets { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of objects across all buckets
|
||||
/// </summary>
|
||||
public long TotalObjects { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of all objects in bytes
|
||||
/// </summary>
|
||||
public long TotalSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When these statistics were last updated
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MinIO server endpoint
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether SSL is being used
|
||||
/// </summary>
|
||||
public bool UseSSL { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Connection status
|
||||
/// </summary>
|
||||
public string ConnectionStatus { get; set; } = "Unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOStats instance
|
||||
/// </summary>
|
||||
public MinIOStats()
|
||||
{
|
||||
LastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOStats instance with specified values
|
||||
/// </summary>
|
||||
/// <param name="totalBuckets">Total number of buckets</param>
|
||||
/// <param name="totalObjects">Total number of objects</param>
|
||||
/// <param name="totalSize">Total size in bytes</param>
|
||||
/// <param name="endpoint">MinIO endpoint</param>
|
||||
/// <param name="useSSL">Whether SSL is used</param>
|
||||
public MinIOStats(int totalBuckets, long totalObjects, long totalSize, string endpoint, bool useSSL)
|
||||
{
|
||||
TotalBuckets = totalBuckets;
|
||||
TotalObjects = totalObjects;
|
||||
TotalSize = totalSize;
|
||||
Endpoint = endpoint ?? string.Empty;
|
||||
UseSSL = useSSL;
|
||||
LastUpdated = DateTime.UtcNow;
|
||||
ConnectionStatus = "Connected";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average object size in bytes
|
||||
/// </summary>
|
||||
public double AverageObjectSize => TotalObjects > 0 ? (double)TotalSize / TotalObjects : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average objects per bucket
|
||||
/// </summary>
|
||||
public double AverageObjectsPerBucket => TotalBuckets > 0 ? (double)TotalObjects / TotalBuckets : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the statistics
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"MinIO Stats: {TotalBuckets} buckets, {TotalObjects} objects, {Utils.SizeFormatter.FormatBytes(TotalSize)} total";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages 3-layer progress reporting for chunked file collection operations
|
||||
/// Layer 1: Collection Progress (overall files)
|
||||
/// Layer 2: Current File Progress
|
||||
/// Layer 3: Current Chunk Progress
|
||||
/// </summary>
|
||||
public class ChunkedCollectionProgressReporter
|
||||
{
|
||||
private readonly PSCmdlet _cmdlet;
|
||||
private readonly int _totalFiles;
|
||||
private readonly long _totalSize;
|
||||
private readonly DateTime _startTime;
|
||||
private readonly string _operationName;
|
||||
|
||||
// Progress tracking
|
||||
private int _completedFiles = 0;
|
||||
private long _totalBytesTransferred = 0;
|
||||
private string _currentFileName = "";
|
||||
private long _currentFileSize = 0;
|
||||
private long _currentFileBytesTransferred = 0;
|
||||
private int _currentChunk = 0;
|
||||
private int _totalChunks = 0;
|
||||
private long _currentChunkBytesTransferred = 0;
|
||||
private long _currentChunkSize = 0;
|
||||
|
||||
// Activity IDs for progress hierarchy
|
||||
private const int CollectionActivityId = 1;
|
||||
private const int FileActivityId = 2;
|
||||
private const int ChunkActivityId = 3;
|
||||
|
||||
// Progress control
|
||||
private readonly long _progressUpdateInterval;
|
||||
private long _lastProgressUpdate = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new chunked collection progress reporter
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">PowerShell cmdlet for progress reporting</param>
|
||||
/// <param name="totalFiles">Total number of files to process</param>
|
||||
/// <param name="totalSize">Total size of all files</param>
|
||||
/// <param name="operationName">Name of the operation (e.g., "Uploading", "Downloading")</param>
|
||||
/// <param name="progressUpdateInterval">Update progress every N bytes</param>
|
||||
public ChunkedCollectionProgressReporter(
|
||||
PSCmdlet cmdlet,
|
||||
int totalFiles,
|
||||
long totalSize,
|
||||
string operationName = "Processing",
|
||||
long progressUpdateInterval = 1024 * 1024) // 1MB default
|
||||
{
|
||||
_cmdlet = cmdlet;
|
||||
_totalFiles = totalFiles;
|
||||
_totalSize = totalSize;
|
||||
_operationName = operationName;
|
||||
_startTime = DateTime.Now;
|
||||
_progressUpdateInterval = progressUpdateInterval;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts processing a new file
|
||||
/// </summary>
|
||||
/// <param name="fileName">Name of the file being processed</param>
|
||||
/// <param name="fileSize">Size of the file</param>
|
||||
/// <param name="totalChunks">Total number of chunks for this file</param>
|
||||
public void StartNewFile(string fileName, long fileSize, int totalChunks)
|
||||
{
|
||||
_currentFileName = fileName;
|
||||
_currentFileSize = fileSize;
|
||||
_currentFileBytesTransferred = 0;
|
||||
_totalChunks = totalChunks;
|
||||
_currentChunk = 0;
|
||||
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "Starting {0} of file {1}/{2}: {3} ({4})",
|
||||
_operationName.ToLower(), _completedFiles + 1, _totalFiles, fileName, SizeFormatter.FormatSize(fileSize));
|
||||
|
||||
UpdateAllProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts processing a new chunk
|
||||
/// </summary>
|
||||
/// <param name="chunkNumber">Chunk number (1-based for display)</param>
|
||||
/// <param name="chunkSize">Size of the chunk</param>
|
||||
public void StartNewChunk(int chunkNumber, long chunkSize)
|
||||
{
|
||||
_currentChunk = chunkNumber;
|
||||
_currentChunkSize = chunkSize;
|
||||
_currentChunkBytesTransferred = 0;
|
||||
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "File {0}: Starting chunk {1}/{2} ({3})",
|
||||
_currentFileName, chunkNumber, _totalChunks, SizeFormatter.FormatSize(chunkSize));
|
||||
|
||||
UpdateAllProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates chunk progress
|
||||
/// </summary>
|
||||
/// <param name="bytesTransferred">Bytes transferred for current chunk</param>
|
||||
public void UpdateChunkProgress(long bytesTransferred)
|
||||
{
|
||||
var chunkDelta = bytesTransferred - _currentChunkBytesTransferred;
|
||||
_currentChunkBytesTransferred = bytesTransferred;
|
||||
_currentFileBytesTransferred += chunkDelta;
|
||||
_totalBytesTransferred += chunkDelta;
|
||||
|
||||
// Only update progress if we've transferred enough bytes since last update
|
||||
if (_totalBytesTransferred - _lastProgressUpdate >= _progressUpdateInterval)
|
||||
{
|
||||
UpdateAllProgress();
|
||||
_lastProgressUpdate = _totalBytesTransferred;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the current chunk
|
||||
/// </summary>
|
||||
/// <param name="chunkETag">ETag of the completed chunk (optional)</param>
|
||||
public void CompleteChunk(string? chunkETag = null)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "File {0}: Completed chunk {1}/{2}{3}",
|
||||
_currentFileName, _currentChunk, _totalChunks,
|
||||
!string.IsNullOrEmpty(chunkETag) ? $" - ETag: {chunkETag}" : "");
|
||||
|
||||
// Mark chunk as completed (only if progress is enabled)
|
||||
if (_cmdlet.MyInvocation.BoundParameters.ContainsKey("ProgressAction") &&
|
||||
_cmdlet.MyInvocation.BoundParameters["ProgressAction"].ToString() == "SilentlyContinue")
|
||||
return;
|
||||
|
||||
var chunkProgress = new ProgressRecord(ChunkActivityId, "Current Chunk", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed,
|
||||
ParentActivityId = FileActivityId
|
||||
};
|
||||
_cmdlet.WriteProgress(chunkProgress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the current file
|
||||
/// </summary>
|
||||
public void CompleteFile()
|
||||
{
|
||||
_completedFiles++;
|
||||
|
||||
var elapsed = DateTime.Now - _startTime;
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "File {0}: {1} completed in {2} - Total size: {3}",
|
||||
_currentFileName, _operationName.ToLower(), elapsed.ToString(@"hh\:mm\:ss"), SizeFormatter.FormatSize(_currentFileSize));
|
||||
|
||||
// Mark file as completed (only if progress is enabled)
|
||||
if (_cmdlet.MyInvocation.BoundParameters.ContainsKey("ProgressAction") &&
|
||||
_cmdlet.MyInvocation.BoundParameters["ProgressAction"].ToString() == "SilentlyContinue")
|
||||
return;
|
||||
|
||||
var fileProgress = new ProgressRecord(FileActivityId, "Current File", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed,
|
||||
ParentActivityId = CollectionActivityId
|
||||
};
|
||||
_cmdlet.WriteProgress(fileProgress);
|
||||
|
||||
// Complete chunk progress too
|
||||
CompleteChunk();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the entire collection operation
|
||||
/// </summary>
|
||||
public void CompleteCollection()
|
||||
{
|
||||
var elapsed = DateTime.Now - _startTime;
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "Completed {0} {1} files ({2}) in {3}",
|
||||
_operationName.ToLower(), _totalFiles, SizeFormatter.FormatSize(_totalSize), elapsed.ToString(@"hh\:mm\:ss"));
|
||||
|
||||
// Complete all progress records
|
||||
var collectionProgress = new ProgressRecord(CollectionActivityId, $"{_operationName} Files", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed
|
||||
};
|
||||
_cmdlet.WriteProgress(collectionProgress);
|
||||
|
||||
// Complete file progress if enabled
|
||||
if (!(_cmdlet.MyInvocation.BoundParameters.ContainsKey("ProgressAction") &&
|
||||
_cmdlet.MyInvocation.BoundParameters["ProgressAction"].ToString() == "SilentlyContinue"))
|
||||
{
|
||||
CompleteFile();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports an error for the current chunk
|
||||
/// </summary>
|
||||
/// <param name="error">Error that occurred</param>
|
||||
/// <param name="retryAttempt">Current retry attempt</param>
|
||||
/// <param name="maxRetries">Maximum retry attempts</param>
|
||||
public void ReportChunkError(Exception error, int retryAttempt, int maxRetries)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "File {0}: Chunk {1}/{2} failed (attempt {3}/{4}): {5}",
|
||||
_currentFileName, _currentChunk, _totalChunks, retryAttempt, maxRetries, error.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates all progress layers
|
||||
/// </summary>
|
||||
private void UpdateAllProgress()
|
||||
{
|
||||
var elapsed = DateTime.Now - _startTime;
|
||||
var speed = elapsed.TotalSeconds > 0 ? _totalBytesTransferred / elapsed.TotalSeconds : 0;
|
||||
|
||||
// Layer 1: Collection Progress (always shown)
|
||||
var collectionPercent = _totalSize > 0 ? (int)((_totalBytesTransferred * 100) / _totalSize) : 0;
|
||||
var collectionStatus = $"Files: {_completedFiles}/{_totalFiles} | " +
|
||||
$"Size: {SizeFormatter.FormatSize(_totalBytesTransferred)}/{SizeFormatter.FormatSize(_totalSize)} | " +
|
||||
$"Speed: {SizeFormatter.FormatSize((long)speed)}/s | " +
|
||||
$"Elapsed: {elapsed:hh\\:mm\\:ss}";
|
||||
|
||||
var collectionProgress = new ProgressRecord(CollectionActivityId, $"{_operationName} Files", collectionStatus)
|
||||
{
|
||||
PercentComplete = collectionPercent
|
||||
};
|
||||
_cmdlet.WriteProgress(collectionProgress);
|
||||
|
||||
// Check if progress is disabled
|
||||
if (_cmdlet.MyInvocation.BoundParameters.ContainsKey("ProgressAction") &&
|
||||
_cmdlet.MyInvocation.BoundParameters["ProgressAction"].ToString() == "SilentlyContinue")
|
||||
return;
|
||||
|
||||
// Layer 2: Current File Progress
|
||||
if (!string.IsNullOrEmpty(_currentFileName))
|
||||
{
|
||||
var filePercent = _currentFileSize > 0 ? (int)((_currentFileBytesTransferred * 100) / _currentFileSize) : 0;
|
||||
var fileStatus = $"File: {_currentFileName} | " +
|
||||
$"Size: {SizeFormatter.FormatSize(_currentFileBytesTransferred)}/{SizeFormatter.FormatSize(_currentFileSize)}";
|
||||
|
||||
var fileProgress = new ProgressRecord(FileActivityId, "Current File", fileStatus)
|
||||
{
|
||||
PercentComplete = filePercent,
|
||||
ParentActivityId = CollectionActivityId
|
||||
};
|
||||
_cmdlet.WriteProgress(fileProgress);
|
||||
}
|
||||
|
||||
// Layer 3: Current Chunk Progress
|
||||
if (_currentChunk > 0)
|
||||
{
|
||||
var chunkPercent = _currentChunkSize > 0 ? (int)((_currentChunkBytesTransferred * 100) / _currentChunkSize) : 0;
|
||||
var chunkStatus = $"Chunk: {_currentChunk}/{_totalChunks} | " +
|
||||
$"Size: {SizeFormatter.FormatSize(_currentChunkBytesTransferred)}/{SizeFormatter.FormatSize(_currentChunkSize)} | " +
|
||||
$"Speed: {SizeFormatter.FormatSize((long)speed)}/s";
|
||||
|
||||
var chunkProgress = new ProgressRecord(ChunkActivityId, "Current Chunk", chunkStatus)
|
||||
{
|
||||
PercentComplete = chunkPercent,
|
||||
ParentActivityId = FileActivityId
|
||||
};
|
||||
_cmdlet.WriteProgress(chunkProgress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages 2-layer progress reporting for chunked single file operations
|
||||
/// Layer 1: File Progress
|
||||
/// Layer 2: Current Chunk Progress
|
||||
/// </summary>
|
||||
public class ChunkedSingleFileProgressReporter
|
||||
{
|
||||
private readonly PSCmdlet _cmdlet;
|
||||
private readonly long _totalSize;
|
||||
private readonly int _totalChunks;
|
||||
private readonly DateTime _startTime;
|
||||
private readonly string _operationName;
|
||||
|
||||
// Progress tracking
|
||||
private long _totalBytesTransferred = 0;
|
||||
private int _currentChunk = 0;
|
||||
private long _currentChunkBytesTransferred = 0;
|
||||
private long _currentChunkSize = 0;
|
||||
|
||||
// Activity IDs for progress hierarchy
|
||||
private const int FileActivityId = 1;
|
||||
private const int ChunkActivityId = 2;
|
||||
|
||||
// Progress control
|
||||
private readonly long _progressUpdateInterval;
|
||||
private long _lastProgressUpdate = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new chunked single file progress reporter
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">PowerShell cmdlet for progress reporting</param>
|
||||
/// <param name="totalSize">Total size of the file</param>
|
||||
/// <param name="totalChunks">Total number of chunks</param>
|
||||
/// <param name="operationName">Name of the operation (e.g., "Downloading", "Uploading")</param>
|
||||
/// <param name="progressUpdateInterval">Update progress every N bytes</param>
|
||||
public ChunkedSingleFileProgressReporter(
|
||||
PSCmdlet cmdlet,
|
||||
long totalSize,
|
||||
int totalChunks,
|
||||
string operationName = "Processing",
|
||||
long progressUpdateInterval = 1024 * 1024) // 1MB default
|
||||
{
|
||||
_cmdlet = cmdlet;
|
||||
_totalSize = totalSize;
|
||||
_totalChunks = totalChunks;
|
||||
_operationName = operationName;
|
||||
_startTime = DateTime.Now;
|
||||
_progressUpdateInterval = progressUpdateInterval;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts processing a new chunk
|
||||
/// </summary>
|
||||
/// <param name="chunkNumber">Chunk number (1-based for display)</param>
|
||||
/// <param name="chunkSize">Size of the chunk</param>
|
||||
public void StartNewChunk(int chunkNumber, long chunkSize)
|
||||
{
|
||||
_currentChunk = chunkNumber;
|
||||
_currentChunkSize = chunkSize;
|
||||
_currentChunkBytesTransferred = 0;
|
||||
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "Starting chunk {0}/{1} ({2})",
|
||||
chunkNumber, _totalChunks, SizeFormatter.FormatSize(chunkSize));
|
||||
|
||||
UpdateAllProgress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates chunk progress
|
||||
/// </summary>
|
||||
/// <param name="bytesTransferred">Bytes transferred for current chunk</param>
|
||||
public void UpdateChunkProgress(long bytesTransferred)
|
||||
{
|
||||
var chunkDelta = bytesTransferred - _currentChunkBytesTransferred;
|
||||
_currentChunkBytesTransferred = bytesTransferred;
|
||||
_totalBytesTransferred += chunkDelta;
|
||||
|
||||
// Only update progress if we've transferred enough bytes since last update
|
||||
if (_totalBytesTransferred - _lastProgressUpdate >= _progressUpdateInterval)
|
||||
{
|
||||
UpdateAllProgress();
|
||||
_lastProgressUpdate = _totalBytesTransferred;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the current chunk
|
||||
/// </summary>
|
||||
/// <param name="chunkETag">ETag of the completed chunk (optional)</param>
|
||||
public void CompleteChunk(string? chunkETag = null)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "Completed chunk {0}/{1}{2}",
|
||||
_currentChunk, _totalChunks,
|
||||
!string.IsNullOrEmpty(chunkETag) ? $" - ETag: {chunkETag}" : "");
|
||||
|
||||
// Mark chunk as completed (only if progress is enabled)
|
||||
if (_cmdlet.MyInvocation.BoundParameters.ContainsKey("ProgressAction") &&
|
||||
_cmdlet.MyInvocation.BoundParameters["ProgressAction"].ToString() == "SilentlyContinue")
|
||||
return;
|
||||
|
||||
var chunkProgress = new ProgressRecord(ChunkActivityId, "Current Chunk", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed,
|
||||
ParentActivityId = FileActivityId
|
||||
};
|
||||
_cmdlet.WriteProgress(chunkProgress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the entire download operation
|
||||
/// </summary>
|
||||
public void CompleteDownload()
|
||||
{
|
||||
var elapsed = DateTime.Now - _startTime;
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "Completed {0} ({1}) in {2}",
|
||||
_operationName.ToLower(), SizeFormatter.FormatSize(_totalSize), elapsed.ToString(@"hh\:mm\:ss"));
|
||||
|
||||
// Complete all progress records
|
||||
var fileProgress = new ProgressRecord(FileActivityId, $"{_operationName} File", "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed
|
||||
};
|
||||
_cmdlet.WriteProgress(fileProgress);
|
||||
|
||||
// Complete chunk progress if enabled
|
||||
if (!(_cmdlet.MyInvocation.BoundParameters.ContainsKey("ProgressAction") &&
|
||||
_cmdlet.MyInvocation.BoundParameters["ProgressAction"].ToString() == "SilentlyContinue"))
|
||||
{
|
||||
CompleteChunk();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports an error for the current chunk
|
||||
/// </summary>
|
||||
/// <param name="error">Error that occurred</param>
|
||||
/// <param name="retryAttempt">Current retry attempt</param>
|
||||
/// <param name="maxRetries">Maximum retry attempts</param>
|
||||
public void ReportChunkError(Exception error, int retryAttempt, int maxRetries)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(_cmdlet, "Chunk {0}/{1} failed (attempt {2}/{3}): {4}",
|
||||
_currentChunk, _totalChunks, retryAttempt, maxRetries, error.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates all progress layers
|
||||
/// </summary>
|
||||
private void UpdateAllProgress()
|
||||
{
|
||||
var elapsed = DateTime.Now - _startTime;
|
||||
var speed = elapsed.TotalSeconds > 0 ? _totalBytesTransferred / elapsed.TotalSeconds : 0;
|
||||
|
||||
// Layer 1: File Progress (always shown)
|
||||
var filePercent = _totalSize > 0 ? (int)((_totalBytesTransferred * 100) / _totalSize) : 0;
|
||||
var fileStatus = $"Size: {SizeFormatter.FormatSize(_totalBytesTransferred)}/{SizeFormatter.FormatSize(_totalSize)} | " +
|
||||
$"Speed: {SizeFormatter.FormatSize((long)speed)}/s | " +
|
||||
$"Elapsed: {elapsed:hh\\:mm\\:ss}";
|
||||
|
||||
var fileProgress = new ProgressRecord(FileActivityId, $"{_operationName} File", fileStatus)
|
||||
{
|
||||
PercentComplete = filePercent
|
||||
};
|
||||
_cmdlet.WriteProgress(fileProgress);
|
||||
|
||||
// Check if progress is disabled
|
||||
if (_cmdlet.MyInvocation.BoundParameters.ContainsKey("ProgressAction") &&
|
||||
_cmdlet.MyInvocation.BoundParameters["ProgressAction"].ToString() == "SilentlyContinue")
|
||||
return;
|
||||
|
||||
// Layer 2: Current Chunk Progress
|
||||
if (_currentChunk > 0)
|
||||
{
|
||||
var chunkPercent = _currentChunkSize > 0 ? (int)((_currentChunkBytesTransferred * 100) / _currentChunkSize) : 0;
|
||||
var chunkStatus = $"Chunk: {_currentChunk}/{_totalChunks} | " +
|
||||
$"Size: {SizeFormatter.FormatSize(_currentChunkBytesTransferred)}/{SizeFormatter.FormatSize(_currentChunkSize)} | " +
|
||||
$"Speed: {SizeFormatter.FormatSize((long)speed)}/s";
|
||||
|
||||
var chunkProgress = new ProgressRecord(ChunkActivityId, "Current Chunk", chunkStatus)
|
||||
{
|
||||
PercentComplete = chunkPercent,
|
||||
ParentActivityId = FileActivityId
|
||||
};
|
||||
_cmdlet.WriteProgress(chunkProgress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using PSMinIO.Models;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages resume data for chunked transfer operations
|
||||
/// </summary>
|
||||
public static class ChunkedTransferResumeManager
|
||||
{
|
||||
private static readonly string DefaultResumeDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSMinIO", "Resume");
|
||||
|
||||
/// <summary>
|
||||
/// Saves transfer state for resume functionality
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state to save</param>
|
||||
/// <param name="customPath">Custom path for resume data (optional)</param>
|
||||
/// <returns>Path where resume data was saved</returns>
|
||||
public static string SaveTransferState(ChunkedTransferState transferState, string? customPath = null)
|
||||
{
|
||||
var resumeDirectory = customPath ?? DefaultResumeDirectory;
|
||||
|
||||
// Ensure directory exists
|
||||
if (!Directory.Exists(resumeDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(resumeDirectory);
|
||||
}
|
||||
|
||||
// Generate unique filename based on transfer details
|
||||
var fileName = GenerateResumeFileName(transferState);
|
||||
var filePath = Path.Combine(resumeDirectory, fileName);
|
||||
|
||||
// Serialize and save
|
||||
var json = JsonSerializer.Serialize(transferState, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
File.WriteAllText(filePath, json);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads transfer state for resume functionality
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Bucket name</param>
|
||||
/// <param name="objectName">Object name</param>
|
||||
/// <param name="filePath">Local file path</param>
|
||||
/// <param name="transferType">Transfer type</param>
|
||||
/// <param name="customPath">Custom path for resume data (optional)</param>
|
||||
/// <returns>Transfer state if found, null otherwise</returns>
|
||||
public static ChunkedTransferState? LoadTransferState(
|
||||
string bucketName,
|
||||
string objectName,
|
||||
string filePath,
|
||||
ChunkedTransferType transferType,
|
||||
string? customPath = null)
|
||||
{
|
||||
var resumeDirectory = customPath ?? DefaultResumeDirectory;
|
||||
|
||||
if (!Directory.Exists(resumeDirectory))
|
||||
return null;
|
||||
|
||||
// Generate expected filename
|
||||
var tempState = new ChunkedTransferState
|
||||
{
|
||||
BucketName = bucketName,
|
||||
ObjectName = objectName,
|
||||
FilePath = filePath,
|
||||
TransferType = transferType
|
||||
};
|
||||
|
||||
var fileName = GenerateResumeFileName(tempState);
|
||||
var resumeFilePath = Path.Combine(resumeDirectory, fileName);
|
||||
|
||||
if (!File.Exists(resumeFilePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(resumeFilePath);
|
||||
var transferState = JsonSerializer.Deserialize<ChunkedTransferState>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return transferState;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// If we can't deserialize, treat as no resume data
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes resume data after successful completion
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state to clean up</param>
|
||||
/// <param name="customPath">Custom path for resume data (optional)</param>
|
||||
public static void CleanupResumeData(ChunkedTransferState transferState, string? customPath = null)
|
||||
{
|
||||
var resumeDirectory = customPath ?? DefaultResumeDirectory;
|
||||
var fileName = GenerateResumeFileName(transferState);
|
||||
var filePath = Path.Combine(resumeDirectory, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates if resume data is still valid
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state to validate</param>
|
||||
/// <param name="currentFileInfo">Current file information</param>
|
||||
/// <returns>True if resume data is valid</returns>
|
||||
public static bool IsResumeDataValid(ChunkedTransferState transferState, FileInfo? currentFileInfo = null)
|
||||
{
|
||||
// Check if transfer state is reasonable
|
||||
if (transferState == null)
|
||||
return false;
|
||||
|
||||
// For uploads, validate source file hasn't changed
|
||||
if (transferState.TransferType == ChunkedTransferType.Upload && currentFileInfo != null)
|
||||
{
|
||||
if (!currentFileInfo.Exists)
|
||||
return false;
|
||||
|
||||
// Check if file size or last modified time changed
|
||||
if (currentFileInfo.Length != transferState.TotalSize ||
|
||||
currentFileInfo.LastWriteTimeUtc != transferState.LastModified)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if resume data is not too old (e.g., older than 7 days)
|
||||
if (DateTime.UtcNow - transferState.LastUpdated > TimeSpan.FromDays(7))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all resume files in the directory
|
||||
/// </summary>
|
||||
/// <param name="customPath">Custom path for resume data (optional)</param>
|
||||
/// <returns>Array of resume file paths</returns>
|
||||
public static string[] GetResumeFiles(string? customPath = null)
|
||||
{
|
||||
var resumeDirectory = customPath ?? DefaultResumeDirectory;
|
||||
|
||||
if (!Directory.Exists(resumeDirectory))
|
||||
return Array.Empty<string>();
|
||||
|
||||
return Directory.GetFiles(resumeDirectory, "*.psminioResume");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up old resume files
|
||||
/// </summary>
|
||||
/// <param name="olderThanDays">Delete files older than this many days</param>
|
||||
/// <param name="customPath">Custom path for resume data (optional)</param>
|
||||
/// <returns>Number of files cleaned up</returns>
|
||||
public static int CleanupOldResumeFiles(int olderThanDays = 7, string? customPath = null)
|
||||
{
|
||||
var resumeFiles = GetResumeFiles(customPath);
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-olderThanDays);
|
||||
var cleanedCount = 0;
|
||||
|
||||
foreach (var file in resumeFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(file);
|
||||
if (fileInfo.LastWriteTimeUtc < cutoffDate)
|
||||
{
|
||||
File.Delete(file);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique filename for resume data
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state</param>
|
||||
/// <returns>Unique filename</returns>
|
||||
private static string GenerateResumeFileName(ChunkedTransferState transferState)
|
||||
{
|
||||
// Create a hash of the key components to ensure uniqueness
|
||||
var key = $"{transferState.BucketName}|{transferState.ObjectName}|{transferState.FilePath}|{transferState.TransferType}";
|
||||
var hash = key.GetHashCode().ToString("X8");
|
||||
|
||||
// Include readable components for easier identification
|
||||
var safeBucketName = MakeSafeFileName(transferState.BucketName);
|
||||
var safeObjectName = MakeSafeFileName(Path.GetFileName(transferState.ObjectName));
|
||||
var transferType = transferState.TransferType.ToString().ToLower();
|
||||
|
||||
return $"{safeBucketName}_{safeObjectName}_{transferType}_{hash}.psminioResume";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes a string safe for use as a filename
|
||||
/// </summary>
|
||||
/// <param name="input">Input string</param>
|
||||
/// <returns>Safe filename string</returns>
|
||||
private static string MakeSafeFileName(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return "unknown";
|
||||
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var safe = input;
|
||||
|
||||
foreach (var c in invalidChars)
|
||||
{
|
||||
safe = safe.Replace(c, '_');
|
||||
}
|
||||
|
||||
// Limit length and remove leading/trailing dots and spaces
|
||||
safe = safe.Trim(' ', '.');
|
||||
if (safe.Length > 50)
|
||||
safe = safe.Substring(0, 50);
|
||||
|
||||
return string.IsNullOrEmpty(safe) ? "unknown" : safe;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
using PSMinIO.Models;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for all MinIO PowerShell cmdlets
|
||||
/// Provides common functionality including connection management, logging, and client access
|
||||
/// </summary>
|
||||
public abstract class MinIOBaseCmdlet : PSCmdlet
|
||||
{
|
||||
private MinIOConnection? _connection;
|
||||
|
||||
/// <summary>
|
||||
/// MinIO connection to use for operations. Can be provided via parameter or retrieved from session.
|
||||
/// </summary>
|
||||
[Parameter(ValueFromPipeline = true)]
|
||||
[Alias("Connection")]
|
||||
public MinIOConnection? MinIOConnection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of session variable containing the MinIO connection (default: MinIOConnection)
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
[ValidateNotNullOrEmpty]
|
||||
public string SessionVariable { get; set; } = "MinIOConnection";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MinIO connection instance
|
||||
/// </summary>
|
||||
protected MinIOConnection Connection
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_connection == null)
|
||||
{
|
||||
_connection = GetConnection();
|
||||
if (_connection == null)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new InvalidOperationException("No MinIO connection available. Use Connect-MinIO to establish a connection, or provide a connection via the -MinIOConnection parameter."),
|
||||
"NoConnection",
|
||||
ErrorCategory.ConnectionError,
|
||||
null));
|
||||
}
|
||||
|
||||
if (!_connection.IsValid)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new InvalidOperationException($"MinIO connection is not valid. Status: {_connection.Status}"),
|
||||
"InvalidConnection",
|
||||
ErrorCategory.ConnectionError,
|
||||
_connection));
|
||||
}
|
||||
|
||||
MinIOLogger.LogOperationStart(this, "UsingConnection", $"Endpoint: {_connection.Configuration.Endpoint}");
|
||||
}
|
||||
return _connection;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MinIO client wrapper instance
|
||||
/// </summary>
|
||||
protected MinIOClientWrapper Client => Connection.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current MinIO configuration
|
||||
/// </summary>
|
||||
protected MinIOConfiguration Configuration => Connection.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MinIO connection from parameter or session variable
|
||||
/// </summary>
|
||||
/// <returns>MinIO connection or null if not found</returns>
|
||||
private MinIOConnection? GetConnection()
|
||||
{
|
||||
// First, check if connection was provided via parameter
|
||||
if (MinIOConnection != null)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Using MinIO connection from parameter");
|
||||
return MinIOConnection;
|
||||
}
|
||||
|
||||
// Next, check session variable
|
||||
try
|
||||
{
|
||||
var sessionConnection = SessionState.PSVariable.GetValue(SessionVariable) as MinIOConnection;
|
||||
if (sessionConnection != null)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Using MinIO connection from session variable: {0}", SessionVariable);
|
||||
return sessionConnection;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, "Failed to retrieve connection from session variable '{0}': {1}", SessionVariable, ex.Message);
|
||||
}
|
||||
|
||||
MinIOLogger.WriteVerbose(this, "No MinIO connection found in parameter or session variable '{0}'", SessionVariable);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the MinIO connection is available and valid
|
||||
/// </summary>
|
||||
protected void ValidateConnection()
|
||||
{
|
||||
var connection = Connection; // This will throw if invalid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an operation with proper error handling and logging
|
||||
/// </summary>
|
||||
/// <param name="operationName">Name of the operation for logging</param>
|
||||
/// <param name="operation">The operation to execute</param>
|
||||
/// <param name="details">Optional operation details for logging</param>
|
||||
protected void ExecuteOperation(string operationName, Action operation, string? details = null)
|
||||
{
|
||||
if (operation == null)
|
||||
throw new ArgumentNullException(nameof(operationName));
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
MinIOLogger.LogOperationStart(this, operationName, details);
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
var duration = DateTime.UtcNow - startTime;
|
||||
MinIOLogger.LogOperationComplete(this, operationName, duration, details);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.LogOperationFailure(this, operationName, ex, details);
|
||||
|
||||
// Determine appropriate error category
|
||||
var category = GetErrorCategory(ex);
|
||||
|
||||
WriteError(new ErrorRecord(ex, $"{operationName}Failed", category, null));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an operation with return value and proper error handling and logging
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Return type</typeparam>
|
||||
/// <param name="operationName">Name of the operation for logging</param>
|
||||
/// <param name="operation">The operation to execute</param>
|
||||
/// <param name="details">Optional operation details for logging</param>
|
||||
/// <returns>Result of the operation</returns>
|
||||
protected T ExecuteOperation<T>(string operationName, Func<T> operation, string? details = null)
|
||||
{
|
||||
if (operation == null)
|
||||
throw new ArgumentNullException(nameof(operation));
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
MinIOLogger.LogOperationStart(this, operationName, details);
|
||||
|
||||
try
|
||||
{
|
||||
var result = operation();
|
||||
var duration = DateTime.UtcNow - startTime;
|
||||
MinIOLogger.LogOperationComplete(this, operationName, duration, details);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.LogOperationFailure(this, operationName, ex, details);
|
||||
|
||||
// Determine appropriate error category
|
||||
var category = GetErrorCategory(ex);
|
||||
|
||||
ThrowTerminatingError(new ErrorRecord(ex, $"{operationName}Failed", category, null));
|
||||
|
||||
// This line will never be reached, but is required for compilation
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the appropriate PowerShell error category based on the exception type
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception to categorize</param>
|
||||
/// <returns>Appropriate ErrorCategory</returns>
|
||||
protected virtual ErrorCategory GetErrorCategory(Exception exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
ArgumentException => ErrorCategory.InvalidArgument,
|
||||
ArgumentNullException => ErrorCategory.InvalidArgument,
|
||||
UnauthorizedAccessException => ErrorCategory.PermissionDenied,
|
||||
System.Net.WebException => ErrorCategory.ConnectionError,
|
||||
System.Net.Http.HttpRequestException => ErrorCategory.ConnectionError,
|
||||
TimeoutException => ErrorCategory.OperationTimeout,
|
||||
InvalidOperationException => ErrorCategory.InvalidOperation,
|
||||
NotSupportedException => ErrorCategory.NotImplemented,
|
||||
FileNotFoundException => ErrorCategory.ObjectNotFound,
|
||||
DirectoryNotFoundException => ErrorCategory.ObjectNotFound,
|
||||
_ => ErrorCategory.NotSpecified
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a bucket name according to MinIO/S3 naming rules
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Bucket name to validate</param>
|
||||
/// <param name="parameterName">Parameter name for error reporting</param>
|
||||
protected void ValidateBucketName(string bucketName, string parameterName = "BucketName")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException($"{parameterName} cannot be null or empty"),
|
||||
"InvalidBucketName",
|
||||
ErrorCategory.InvalidArgument,
|
||||
bucketName));
|
||||
}
|
||||
|
||||
// Basic bucket name validation (simplified)
|
||||
if (bucketName.Length < 3 || bucketName.Length > 63)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException($"{parameterName} must be between 3 and 63 characters long"),
|
||||
"InvalidBucketName",
|
||||
ErrorCategory.InvalidArgument,
|
||||
bucketName));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an object name
|
||||
/// </summary>
|
||||
/// <param name="objectName">Object name to validate</param>
|
||||
/// <param name="parameterName">Parameter name for error reporting</param>
|
||||
protected void ValidateObjectName(string objectName, string parameterName = "ObjectName")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new ArgumentException($"{parameterName} cannot be null or empty"),
|
||||
"InvalidObjectName",
|
||||
ErrorCategory.InvalidArgument,
|
||||
objectName));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up resources when the cmdlet is disposed
|
||||
/// </summary>
|
||||
protected override void EndProcessing()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Note: We don't dispose the connection here as it may be shared across cmdlets
|
||||
// The connection should be disposed by the user when no longer needed
|
||||
_connection = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, $"Error during cleanup: {ex.Message}");
|
||||
}
|
||||
|
||||
base.EndProcessing();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stopping the cmdlet (Ctrl+C)
|
||||
/// </summary>
|
||||
protected override void StopProcessing()
|
||||
{
|
||||
try
|
||||
{
|
||||
_connection?.Client.CancelOperations();
|
||||
MinIOLogger.WriteVerbose(this, "Operation cancelled by user");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MinIOLogger.WriteVerbose(this, $"Error cancelling operations: {ex.Message}");
|
||||
}
|
||||
|
||||
base.StopProcessing();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Minio;
|
||||
using Minio.DataModel;
|
||||
using Minio.DataModel.Args;
|
||||
using PSMinIO.Models;
|
||||
using PSMinIO.Utils;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Synchronous wrapper for MinIO client operations
|
||||
/// Converts async MinIO operations to synchronous calls for PowerShell compatibility
|
||||
/// </summary>
|
||||
public class MinIOClientWrapper : IDisposable
|
||||
{
|
||||
private readonly IMinioClient _client;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
private bool _disposed = false;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new MinIOClientWrapper instance
|
||||
/// </summary>
|
||||
/// <param name="configuration">MinIO configuration</param>
|
||||
public MinIOClientWrapper(MinIOConfiguration configuration)
|
||||
{
|
||||
if (configuration == null)
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
|
||||
if (!configuration.IsValid)
|
||||
throw new ArgumentException("Invalid MinIO configuration", nameof(configuration));
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
// Create MinIO client with configuration
|
||||
var clientBuilder = new MinioClient()
|
||||
.WithEndpoint(configuration.Endpoint)
|
||||
.WithCredentials(configuration.AccessKey, configuration.SecretKey);
|
||||
|
||||
if (configuration.UseSSL)
|
||||
{
|
||||
clientBuilder = clientBuilder.WithSSL();
|
||||
|
||||
// Configure custom HttpClient for certificate validation if needed
|
||||
if (configuration.SkipCertificateValidation)
|
||||
{
|
||||
var httpClientHandler = new HttpClientHandler()
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
|
||||
};
|
||||
var httpClient = new HttpClient(httpClientHandler);
|
||||
clientBuilder = clientBuilder.WithHttpClient(httpClient);
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration.TimeoutSeconds > 0)
|
||||
{
|
||||
clientBuilder = clientBuilder.WithTimeout(configuration.TimeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
_client = clientBuilder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cancellation token for operations
|
||||
/// </summary>
|
||||
public CancellationToken CancellationToken => _cancellationTokenSource.Token;
|
||||
|
||||
/// <summary>
|
||||
/// Lists all buckets synchronously
|
||||
/// </summary>
|
||||
/// <returns>List of bucket information</returns>
|
||||
public List<MinIOBucketInfo> ListBuckets()
|
||||
{
|
||||
try
|
||||
{
|
||||
var bucketsResult = Task.Run(async () =>
|
||||
await _client.ListBucketsAsync(CancellationToken)).GetAwaiter().GetResult();
|
||||
|
||||
return bucketsResult.Buckets
|
||||
.Select(MinIOBucketInfo.FromMinioBucket)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to list buckets: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a bucket exists synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <returns>True if bucket exists, false otherwise</returns>
|
||||
public bool BucketExists(string bucketName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new BucketExistsArgs().WithBucket(bucketName);
|
||||
return Task.Run(async () =>
|
||||
await _client.BucketExistsAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to check if bucket '{bucketName}' exists: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bucket synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket to create</param>
|
||||
/// <param name="region">Optional region for the bucket</param>
|
||||
public void CreateBucket(string bucketName, string? region = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new MakeBucketArgs().WithBucket(bucketName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(region))
|
||||
{
|
||||
args = args.WithLocation(region);
|
||||
}
|
||||
|
||||
Task.Run(async () =>
|
||||
await _client.MakeBucketAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to create bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a bucket synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket to delete</param>
|
||||
public void DeleteBucket(string bucketName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new RemoveBucketArgs().WithBucket(bucketName);
|
||||
Task.Run(async () =>
|
||||
await _client.RemoveBucketAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to delete bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists objects in a bucket synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="prefix">Optional prefix to filter objects</param>
|
||||
/// <param name="recursive">Whether to list objects recursively</param>
|
||||
/// <param name="includeVersions">Whether to include all versions of objects</param>
|
||||
/// <returns>List of object information</returns>
|
||||
public List<MinIOObjectInfo> ListObjects(string bucketName, string? prefix = null, bool recursive = true, bool includeVersions = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new ListObjectsArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithRecursive(recursive);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
args = args.WithPrefix(prefix);
|
||||
}
|
||||
|
||||
// Add version support if requested
|
||||
if (includeVersions)
|
||||
{
|
||||
args = args.WithVersions(true);
|
||||
}
|
||||
|
||||
var objects = new List<MinIOObjectInfo>();
|
||||
var observable = _client.ListObjectsAsync(args, CancellationToken);
|
||||
|
||||
// Convert async enumerable to synchronous list
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var item in observable.WithCancellation(CancellationToken))
|
||||
{
|
||||
objects.Add(MinIOObjectInfo.FromMinioItem(item, bucketName));
|
||||
}
|
||||
});
|
||||
|
||||
task.GetAwaiter().GetResult();
|
||||
return objects;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to list objects in bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets bucket policy synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <returns>Bucket policy as JSON string</returns>
|
||||
public string GetBucketPolicy(string bucketName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new GetPolicyArgs().WithBucket(bucketName);
|
||||
return Task.Run(async () =>
|
||||
await _client.GetPolicyAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to get policy for bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets bucket policy synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="policy">Policy JSON string</param>
|
||||
public void SetBucketPolicy(string bucketName, string policy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(policy))
|
||||
throw new ArgumentException("Policy cannot be null or empty", nameof(policy));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new SetPolicyArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithPolicy(policy);
|
||||
|
||||
Task.Run(async () =>
|
||||
await _client.SetPolicyAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to set policy for bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an object synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="objectName">Name of the object to delete</param>
|
||||
public void DeleteObject(string bucketName, string objectName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
throw new ArgumentException("Object name cannot be null or empty", nameof(objectName));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new RemoveObjectArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithObject(objectName);
|
||||
|
||||
Task.Run(async () =>
|
||||
await _client.RemoveObjectAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to delete object '{objectName}' from bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes multiple objects synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="objectNames">List of object names to delete</param>
|
||||
public void DeleteObjects(string bucketName, IEnumerable<string> objectNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
if (objectNames == null)
|
||||
throw new ArgumentNullException(nameof(objectNames));
|
||||
|
||||
var objectList = objectNames.ToList();
|
||||
if (objectList.Count == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var deleteObjectsArgs = new RemoveObjectsArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithObjects(objectList);
|
||||
|
||||
var observable = _client.RemoveObjectsAsync(deleteObjectsArgs, CancellationToken);
|
||||
|
||||
// Convert async enumerable to synchronous operation
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var deleteError in observable.WithCancellation(CancellationToken))
|
||||
{
|
||||
if (deleteError.Exception != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to delete object '{deleteError.Key}': {deleteError.Exception.Message}", deleteError.Exception);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
task.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to delete objects from bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file to MinIO synchronously with progress reporting
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="objectName">Name of the object</param>
|
||||
/// <param name="filePath">Path to the file to upload</param>
|
||||
/// <param name="contentType">Content type of the file (optional)</param>
|
||||
/// <param name="progressCallback">Progress callback for reporting upload progress</param>
|
||||
/// <returns>ETag of the uploaded object</returns>
|
||||
public string UploadFile(string bucketName, string objectName, string filePath,
|
||||
string? contentType = null, Action<long>? progressCallback = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
throw new ArgumentException("Object name cannot be null or empty", nameof(objectName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException($"File not found: {filePath}");
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var fileSize = fileInfo.Length;
|
||||
|
||||
// Determine content type if not provided
|
||||
if (string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
contentType = GetContentType(filePath);
|
||||
}
|
||||
|
||||
var args = new PutObjectArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithObject(objectName)
|
||||
.WithFileName(filePath)
|
||||
.WithContentType(contentType);
|
||||
|
||||
// Add progress callback if provided
|
||||
if (progressCallback != null)
|
||||
{
|
||||
args = args.WithProgress(new Progress<ProgressReport>(report =>
|
||||
{
|
||||
progressCallback(report.TotalBytesTransferred);
|
||||
}));
|
||||
}
|
||||
|
||||
var result = Task.Run(async () =>
|
||||
await _client.PutObjectAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
|
||||
return result.Etag ?? string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to upload file '{filePath}' to bucket '{bucketName}' as '{objectName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an object from MinIO synchronously with progress reporting
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="objectName">Name of the object</param>
|
||||
/// <param name="filePath">Path where the file should be saved</param>
|
||||
/// <param name="progressCallback">Progress callback for reporting download progress</param>
|
||||
public void DownloadFile(string bucketName, string objectName, string filePath,
|
||||
Action<long>? progressCallback = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
throw new ArgumentException("Object name cannot be null or empty", nameof(objectName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
throw new ArgumentException("File path cannot be null or empty", nameof(filePath));
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure the directory exists
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var args = new GetObjectArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithObject(objectName)
|
||||
.WithFile(filePath);
|
||||
|
||||
// Add progress callback if provided
|
||||
if (progressCallback != null)
|
||||
{
|
||||
args = args.WithProgress(new Progress<ProgressReport>(report =>
|
||||
{
|
||||
progressCallback(report.TotalBytesTransferred);
|
||||
}));
|
||||
}
|
||||
|
||||
Task.Run(async () =>
|
||||
await _client.GetObjectAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to download object '{objectName}' from bucket '{bucketName}' to '{filePath}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a stream to MinIO synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="objectName">Name of the object</param>
|
||||
/// <param name="data">Stream containing the data to upload</param>
|
||||
/// <param name="contentType">Content type of the data</param>
|
||||
/// <param name="progressCallback">Progress callback for reporting upload progress</param>
|
||||
/// <returns>ETag of the uploaded object</returns>
|
||||
public string UploadStream(string bucketName, string objectName, Stream data,
|
||||
string contentType = "application/octet-stream", Action<long>? progressCallback = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
throw new ArgumentException("Object name cannot be null or empty", nameof(objectName));
|
||||
|
||||
if (data == null)
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new PutObjectArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithObject(objectName)
|
||||
.WithStreamData(data)
|
||||
.WithObjectSize(data.Length)
|
||||
.WithContentType(contentType);
|
||||
|
||||
// Add progress callback if provided
|
||||
if (progressCallback != null)
|
||||
{
|
||||
args = args.WithProgress(new Progress<ProgressReport>(report =>
|
||||
{
|
||||
progressCallback(report.TotalBytesTransferred);
|
||||
}));
|
||||
}
|
||||
|
||||
var result = Task.Run(async () =>
|
||||
await _client.PutObjectAsync(args, CancellationToken)).GetAwaiter().GetResult();
|
||||
|
||||
return result.Etag ?? string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to upload stream to bucket '{bucketName}' as '{objectName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content type for a file based on its extension
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the file</param>
|
||||
/// <returns>Content type string</returns>
|
||||
private static string GetContentType(string filePath)
|
||||
{
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
|
||||
return extension switch
|
||||
{
|
||||
".txt" => "text/plain",
|
||||
".html" => "text/html",
|
||||
".css" => "text/css",
|
||||
".js" => "application/javascript",
|
||||
".json" => "application/json",
|
||||
".xml" => "application/xml",
|
||||
".pdf" => "application/pdf",
|
||||
".zip" => "application/zip",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".svg" => "image/svg+xml",
|
||||
".mp4" => "video/mp4",
|
||||
".mp3" => "audio/mpeg",
|
||||
".wav" => "audio/wav",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists object versions in a bucket synchronously
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="prefix">Optional prefix to filter objects</param>
|
||||
/// <param name="recursive">Whether to list objects recursively</param>
|
||||
/// <param name="maxObjects">Maximum number of objects to return (0 = unlimited)</param>
|
||||
/// <returns>List of object information including versions</returns>
|
||||
private List<MinIOObjectInfo> ListObjectVersions(string bucketName, string? prefix, bool recursive, int maxObjects)
|
||||
{
|
||||
try
|
||||
{
|
||||
// For now, fall back to regular object listing since version listing
|
||||
// may not be available in all MinIO SDK versions
|
||||
// This can be enhanced when the SDK supports it
|
||||
return ListObjects(bucketName, prefix, recursive, maxObjects, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to list object versions in bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a presigned URL for an object
|
||||
/// </summary>
|
||||
/// <param name="bucketName">Name of the bucket</param>
|
||||
/// <param name="objectName">Name of the object</param>
|
||||
/// <param name="expiry">URL expiry time</param>
|
||||
/// <returns>Presigned URL</returns>
|
||||
public string GetPresignedUrl(string bucketName, string objectName, TimeSpan expiry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bucketName))
|
||||
throw new ArgumentException("Bucket name cannot be null or empty", nameof(bucketName));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
throw new ArgumentException("Object name cannot be null or empty", nameof(objectName));
|
||||
|
||||
try
|
||||
{
|
||||
var args = new PresignedGetObjectArgs()
|
||||
.WithBucket(bucketName)
|
||||
.WithObject(objectName)
|
||||
.WithExpiry((int)expiry.TotalSeconds);
|
||||
|
||||
var result = Task.Run(async () =>
|
||||
await _client.PresignedGetObjectAsync(args)).GetAwaiter().GetResult();
|
||||
|
||||
return result ?? string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to generate presigned URL for object '{objectName}' in bucket '{bucketName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file using chunked transfer with resume capability
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state for resume functionality</param>
|
||||
/// <param name="progressReporter">Progress reporter for updates</param>
|
||||
/// <param name="maxRetries">Maximum retry attempts per chunk</param>
|
||||
/// <returns>MinIOObjectInfo of uploaded object or null if failed</returns>
|
||||
public MinIOObjectInfo? UploadFileChunked(
|
||||
ChunkedTransferState transferState,
|
||||
ChunkedCollectionProgressReporter progressReporter,
|
||||
int maxRetries = 3)
|
||||
{
|
||||
if (transferState == null)
|
||||
throw new ArgumentNullException(nameof(transferState));
|
||||
|
||||
try
|
||||
{
|
||||
// Start multipart upload if not already started
|
||||
if (string.IsNullOrEmpty(transferState.UploadId))
|
||||
{
|
||||
var initiateArgs = new NewMultipartUploadArgs()
|
||||
.WithBucket(transferState.BucketName)
|
||||
.WithObject(transferState.ObjectName);
|
||||
|
||||
var initiateResult = Task.Run(async () =>
|
||||
await _client.NewMultipartUploadAsync(initiateArgs, CancellationToken)).GetAwaiter().GetResult();
|
||||
|
||||
transferState.UploadId = initiateResult.UploadId;
|
||||
}
|
||||
|
||||
var completedParts = new List<UploadPartResponse>();
|
||||
|
||||
// Process each chunk
|
||||
while (!transferState.IsComplete)
|
||||
{
|
||||
var nextChunk = transferState.GetNextChunk();
|
||||
if (nextChunk == null)
|
||||
break;
|
||||
|
||||
progressReporter.StartNewChunk(nextChunk.ChunkNumber + 1, nextChunk.Size);
|
||||
|
||||
var uploadResult = UploadChunkWithRetry(transferState, nextChunk, progressReporter, maxRetries);
|
||||
if (uploadResult != null)
|
||||
{
|
||||
completedParts.Add(uploadResult);
|
||||
transferState.MarkChunkCompleted(nextChunk);
|
||||
progressReporter.CompleteChunk(uploadResult.ETag);
|
||||
|
||||
// Save progress for resume
|
||||
ChunkedTransferResumeManager.SaveTransferState(transferState);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to upload chunk {nextChunk.ChunkNumber} after {maxRetries} attempts");
|
||||
}
|
||||
}
|
||||
|
||||
// Complete multipart upload
|
||||
var completeArgs = new CompleteMultipartUploadArgs()
|
||||
.WithBucket(transferState.BucketName)
|
||||
.WithObject(transferState.ObjectName)
|
||||
.WithUploadId(transferState.UploadId)
|
||||
.WithETags(completedParts.OrderBy(p => p.PartNumber).Select(p => new Tuple<int, string>(p.PartNumber, p.ETag)));
|
||||
|
||||
var completeResult = Task.Run(async () =>
|
||||
await _client.CompleteMultipartUploadAsync(completeArgs, CancellationToken)).GetAwaiter().GetResult();
|
||||
|
||||
// Return object information
|
||||
return new MinIOObjectInfo(
|
||||
transferState.ObjectName,
|
||||
transferState.TotalSize,
|
||||
DateTime.UtcNow,
|
||||
completeResult.ETag,
|
||||
transferState.BucketName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Abort multipart upload on failure
|
||||
if (!string.IsNullOrEmpty(transferState.UploadId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var abortArgs = new AbortMultipartUploadArgs()
|
||||
.WithBucket(transferState.BucketName)
|
||||
.WithObject(transferState.ObjectName)
|
||||
.WithUploadId(transferState.UploadId);
|
||||
|
||||
Task.Run(async () =>
|
||||
await _client.AbortMultipartUploadAsync(abortArgs, CancellationToken)).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore abort errors
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Chunked upload failed for object '{transferState.ObjectName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a single chunk with retry logic
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state</param>
|
||||
/// <param name="chunk">Chunk to upload</param>
|
||||
/// <param name="progressReporter">Progress reporter</param>
|
||||
/// <param name="maxRetries">Maximum retry attempts</param>
|
||||
/// <returns>Upload part response or null if failed</returns>
|
||||
private UploadPartResponse? UploadChunkWithRetry(
|
||||
ChunkedTransferState transferState,
|
||||
ChunkInfo chunk,
|
||||
ChunkedCollectionProgressReporter progressReporter,
|
||||
int maxRetries)
|
||||
{
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var fileStream = new FileStream(transferState.FilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
fileStream.Seek(chunk.StartByte, SeekOrigin.Begin);
|
||||
|
||||
var chunkData = new byte[chunk.Size];
|
||||
var bytesRead = fileStream.Read(chunkData, 0, (int)chunk.Size);
|
||||
|
||||
using var chunkStream = new MemoryStream(chunkData, 0, bytesRead);
|
||||
|
||||
var uploadArgs = new UploadPartArgs()
|
||||
.WithBucket(transferState.BucketName)
|
||||
.WithObject(transferState.ObjectName)
|
||||
.WithUploadId(transferState.UploadId)
|
||||
.WithPartNumber(chunk.ChunkNumber + 1) // MinIO uses 1-based part numbers
|
||||
.WithPartSize(bytesRead)
|
||||
.WithStreamData(chunkStream);
|
||||
|
||||
// Add progress callback
|
||||
uploadArgs = uploadArgs.WithProgress(new Progress<ProgressReport>(report =>
|
||||
{
|
||||
progressReporter.UpdateChunkProgress(report.TotalBytesTransferred);
|
||||
}));
|
||||
|
||||
var result = Task.Run(async () =>
|
||||
await _client.UploadPartAsync(uploadArgs, CancellationToken)).GetAwaiter().GetResult();
|
||||
|
||||
chunk.ChunkETag = result.ETag;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxRetries)
|
||||
{
|
||||
progressReporter.ReportChunkError(ex, attempt, maxRetries);
|
||||
|
||||
// Exponential backoff
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
|
||||
Task.Delay(delay, CancellationToken).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progressReporter.ReportChunkError(ex, attempt, maxRetries);
|
||||
chunk.LastError = ex.Message;
|
||||
chunk.RetryCount = attempt;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a file using chunked transfer with resume capability
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state for resume functionality</param>
|
||||
/// <param name="progressReporter">Progress reporter for updates</param>
|
||||
/// <param name="maxRetries">Maximum retry attempts per chunk</param>
|
||||
/// <param name="parallelDownloads">Number of parallel chunk downloads</param>
|
||||
/// <returns>True if download succeeded, false otherwise</returns>
|
||||
public bool DownloadFileChunked(
|
||||
ChunkedTransferState transferState,
|
||||
ChunkedSingleFileProgressReporter progressReporter,
|
||||
int maxRetries = 3,
|
||||
int parallelDownloads = 3)
|
||||
{
|
||||
if (transferState == null)
|
||||
throw new ArgumentNullException(nameof(transferState));
|
||||
|
||||
try
|
||||
{
|
||||
// Create or open the target file
|
||||
using var fileStream = new FileStream(transferState.FilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
fileStream.SetLength(transferState.TotalSize);
|
||||
|
||||
// Get list of chunks to download
|
||||
var chunksToDownload = new List<ChunkInfo>();
|
||||
while (!transferState.IsComplete)
|
||||
{
|
||||
var nextChunk = transferState.GetNextChunk();
|
||||
if (nextChunk == null)
|
||||
break;
|
||||
chunksToDownload.Add(nextChunk);
|
||||
}
|
||||
|
||||
if (chunksToDownload.Count == 0)
|
||||
{
|
||||
return true; // Already complete
|
||||
}
|
||||
|
||||
// Download chunks (with limited parallelism)
|
||||
var semaphore = new SemaphoreSlim(parallelDownloads, parallelDownloads);
|
||||
var downloadTasks = chunksToDownload.Select(chunk =>
|
||||
DownloadChunkAsync(transferState, chunk, fileStream, progressReporter, maxRetries, semaphore)).ToArray();
|
||||
|
||||
var results = Task.WhenAll(downloadTasks).GetAwaiter().GetResult();
|
||||
|
||||
// Check if all chunks downloaded successfully
|
||||
var allSucceeded = results.All(r => r);
|
||||
if (allSucceeded)
|
||||
{
|
||||
// Mark all chunks as completed
|
||||
foreach (var chunk in chunksToDownload)
|
||||
{
|
||||
transferState.MarkChunkCompleted(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return allSucceeded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Chunked download failed for object '{transferState.ObjectName}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a single chunk asynchronously with retry logic
|
||||
/// </summary>
|
||||
/// <param name="transferState">Transfer state</param>
|
||||
/// <param name="chunk">Chunk to download</param>
|
||||
/// <param name="fileStream">Target file stream</param>
|
||||
/// <param name="progressReporter">Progress reporter</param>
|
||||
/// <param name="maxRetries">Maximum retry attempts</param>
|
||||
/// <param name="semaphore">Semaphore for controlling parallelism</param>
|
||||
/// <returns>True if chunk downloaded successfully</returns>
|
||||
private async Task<bool> DownloadChunkAsync(
|
||||
ChunkedTransferState transferState,
|
||||
ChunkInfo chunk,
|
||||
FileStream fileStream,
|
||||
ChunkedSingleFileProgressReporter progressReporter,
|
||||
int maxRetries,
|
||||
SemaphoreSlim semaphore)
|
||||
{
|
||||
await semaphore.WaitAsync(CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
progressReporter.StartNewChunk(chunk.ChunkNumber + 1, chunk.Size);
|
||||
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var getArgs = new GetObjectArgs()
|
||||
.WithBucket(transferState.BucketName)
|
||||
.WithObject(transferState.ObjectName)
|
||||
.WithOffsetAndLength(chunk.StartByte, chunk.Size);
|
||||
|
||||
using var chunkStream = new MemoryStream();
|
||||
|
||||
await _client.GetObjectAsync(getArgs, (stream) =>
|
||||
{
|
||||
stream.CopyTo(chunkStream);
|
||||
}, CancellationToken);
|
||||
|
||||
// Write chunk to file at correct position
|
||||
lock (fileStream)
|
||||
{
|
||||
fileStream.Seek(chunk.StartByte, SeekOrigin.Begin);
|
||||
chunkStream.Seek(0, SeekOrigin.Begin);
|
||||
chunkStream.CopyTo(fileStream);
|
||||
fileStream.Flush();
|
||||
}
|
||||
|
||||
progressReporter.UpdateChunkProgress(chunk.Size);
|
||||
progressReporter.CompleteChunk();
|
||||
|
||||
chunk.IsCompleted = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxRetries)
|
||||
{
|
||||
progressReporter.ReportChunkError(ex, attempt, maxRetries);
|
||||
|
||||
// Exponential backoff
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
|
||||
await Task.Delay(delay, CancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
progressReporter.ReportChunkError(ex, attempt, maxRetries);
|
||||
chunk.LastError = ex.Message;
|
||||
chunk.RetryCount = attempt;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all ongoing operations
|
||||
/// </summary>
|
||||
public void CancelOperations()
|
||||
{
|
||||
_cancellationTokenSource.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the wrapper and underlying client
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protected dispose method
|
||||
/// </summary>
|
||||
/// <param name="disposing">Whether disposing from Dispose method</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed && disposing)
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_client?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized logging utility for PSMinIO module
|
||||
/// </summary>
|
||||
public static class MinIOLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Log levels for different types of messages
|
||||
/// </summary>
|
||||
public enum LogLevel
|
||||
{
|
||||
Verbose,
|
||||
Information,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a verbose message if verbose preference allows it
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="message">Message to log</param>
|
||||
/// <param name="args">Optional format arguments</param>
|
||||
public static void WriteVerbose(PSCmdlet cmdlet, string message, params object[] args)
|
||||
{
|
||||
if (cmdlet == null) return;
|
||||
|
||||
var formattedMessage = FormatLogMessage(LogLevel.Verbose, message, args);
|
||||
cmdlet.WriteVerbose(formattedMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an information message
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="message">Message to log</param>
|
||||
/// <param name="args">Optional format arguments</param>
|
||||
public static void WriteInformation(PSCmdlet cmdlet, string message, params object[] args)
|
||||
{
|
||||
if (cmdlet == null) return;
|
||||
|
||||
var formattedMessage = FormatLogMessage(LogLevel.Information, message, args);
|
||||
|
||||
// Use WriteInformation if available (PowerShell 5.0+), otherwise WriteVerbose
|
||||
try
|
||||
{
|
||||
var infoRecord = new InformationRecord(formattedMessage, "PSMinIO");
|
||||
cmdlet.WriteInformation(infoRecord);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to WriteVerbose for older PowerShell versions
|
||||
cmdlet.WriteVerbose(formattedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a warning message
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="message">Message to log</param>
|
||||
/// <param name="args">Optional format arguments</param>
|
||||
public static void WriteWarning(PSCmdlet cmdlet, string message, params object[] args)
|
||||
{
|
||||
if (cmdlet == null) return;
|
||||
|
||||
var formattedMessage = FormatLogMessage(LogLevel.Warning, message, args);
|
||||
cmdlet.WriteWarning(formattedMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an error message
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="message">Message to log</param>
|
||||
/// <param name="args">Optional format arguments</param>
|
||||
public static void WriteError(PSCmdlet cmdlet, string message, params object[] args)
|
||||
{
|
||||
if (cmdlet == null) return;
|
||||
|
||||
var formattedMessage = FormatLogMessage(LogLevel.Error, message, args);
|
||||
var errorRecord = new ErrorRecord(
|
||||
new InvalidOperationException(formattedMessage),
|
||||
"PSMinIOError",
|
||||
ErrorCategory.InvalidOperation,
|
||||
null);
|
||||
|
||||
cmdlet.WriteError(errorRecord);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an error from an exception
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="exception">Exception to log</param>
|
||||
/// <param name="errorId">Error identifier</param>
|
||||
/// <param name="category">Error category</param>
|
||||
/// <param name="targetObject">Target object that caused the error</param>
|
||||
public static void WriteError(PSCmdlet cmdlet, Exception exception, string errorId,
|
||||
ErrorCategory category = ErrorCategory.InvalidOperation, object? targetObject = null)
|
||||
{
|
||||
if (cmdlet == null) return;
|
||||
|
||||
var errorRecord = new ErrorRecord(exception, errorId, category, targetObject);
|
||||
cmdlet.WriteError(errorRecord);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the start of an operation
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="operation">Name of the operation</param>
|
||||
/// <param name="details">Optional operation details</param>
|
||||
public static void LogOperationStart(PSCmdlet cmdlet, string operation, string? details = null)
|
||||
{
|
||||
var message = string.IsNullOrEmpty(details)
|
||||
? $"Starting operation: {operation}"
|
||||
: $"Starting operation: {operation} - {details}";
|
||||
|
||||
WriteVerbose(cmdlet, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the completion of an operation
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="operation">Name of the operation</param>
|
||||
/// <param name="duration">Optional operation duration</param>
|
||||
/// <param name="details">Optional operation details</param>
|
||||
public static void LogOperationComplete(PSCmdlet cmdlet, string operation, TimeSpan? duration = null, string? details = null)
|
||||
{
|
||||
var message = $"Completed operation: {operation}";
|
||||
|
||||
if (duration.HasValue)
|
||||
{
|
||||
message += $" (Duration: {duration.Value.TotalMilliseconds:F0}ms)";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(details))
|
||||
{
|
||||
message += $" - {details}";
|
||||
}
|
||||
|
||||
WriteVerbose(cmdlet, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs an operation failure
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet instance for context</param>
|
||||
/// <param name="operation">Name of the operation</param>
|
||||
/// <param name="exception">Exception that occurred</param>
|
||||
/// <param name="details">Optional operation details</param>
|
||||
public static void LogOperationFailure(PSCmdlet cmdlet, string operation, Exception exception, string? details = null)
|
||||
{
|
||||
var message = $"Operation failed: {operation} - {exception.Message}";
|
||||
|
||||
if (!string.IsNullOrEmpty(details))
|
||||
{
|
||||
message += $" - {details}";
|
||||
}
|
||||
|
||||
WriteError(cmdlet, message);
|
||||
WriteVerbose(cmdlet, $"Exception details: {exception}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a log message with timestamp and level, automatically formatting byte sizes
|
||||
/// </summary>
|
||||
/// <param name="level">Log level</param>
|
||||
/// <param name="message">Message to format</param>
|
||||
/// <param name="args">Optional format arguments</param>
|
||||
/// <returns>Formatted log message</returns>
|
||||
private static string FormatLogMessage(LogLevel level, string message, params object[] args)
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss.fff");
|
||||
var levelString = level.ToString().ToUpperInvariant();
|
||||
|
||||
// Process arguments to format byte sizes intelligently
|
||||
var processedArgs = ProcessLogArguments(args);
|
||||
var formattedMessage = processedArgs.Length > 0 ? string.Format(message, processedArgs) : message;
|
||||
|
||||
return $"{timestamp} - [{levelString}] - {formattedMessage}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes log arguments to format byte sizes intelligently
|
||||
/// </summary>
|
||||
/// <param name="args">Original arguments</param>
|
||||
/// <returns>Processed arguments with formatted sizes</returns>
|
||||
private static object[] ProcessLogArguments(object[] args)
|
||||
{
|
||||
if (args == null || args.Length == 0)
|
||||
return args;
|
||||
|
||||
var processedArgs = new object[args.Length];
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
|
||||
// Check if this looks like a byte size that should be formatted
|
||||
if (IsLikelyByteSize(arg))
|
||||
{
|
||||
if (long.TryParse(arg.ToString(), out var bytes))
|
||||
{
|
||||
processedArgs[i] = SizeFormatter.FormatBytes(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
processedArgs[i] = arg;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
processedArgs[i] = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return processedArgs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if an argument is likely a byte size that should be formatted
|
||||
/// </summary>
|
||||
/// <param name="arg">Argument to check</param>
|
||||
/// <returns>True if likely a byte size</returns>
|
||||
private static bool IsLikelyByteSize(object arg)
|
||||
{
|
||||
// Only format long integers that are likely byte sizes
|
||||
// We use a heuristic: values >= 1024 are likely byte sizes
|
||||
if (arg is long longValue)
|
||||
{
|
||||
return longValue >= 1024;
|
||||
}
|
||||
|
||||
if (arg is int intValue)
|
||||
{
|
||||
return intValue >= 1024;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Management.Automation;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility class for reporting progress during file operations
|
||||
/// </summary>
|
||||
public class ProgressReporter
|
||||
{
|
||||
private readonly PSCmdlet _cmdlet;
|
||||
private readonly string _activity;
|
||||
private readonly string _statusDescription;
|
||||
private readonly long _totalBytes;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private long _bytesProcessed;
|
||||
private DateTime _lastUpdateTime;
|
||||
private readonly int _activityId;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ProgressReporter instance
|
||||
/// </summary>
|
||||
/// <param name="cmdlet">The cmdlet to report progress to</param>
|
||||
/// <param name="activity">Description of the activity</param>
|
||||
/// <param name="statusDescription">Status description</param>
|
||||
/// <param name="totalBytes">Total number of bytes to process</param>
|
||||
/// <param name="activityId">Unique activity ID for progress reporting</param>
|
||||
public ProgressReporter(PSCmdlet cmdlet, string activity, string statusDescription, long totalBytes, int activityId = 1)
|
||||
{
|
||||
_cmdlet = cmdlet ?? throw new ArgumentNullException(nameof(cmdlet));
|
||||
_activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
||||
_statusDescription = statusDescription ?? throw new ArgumentNullException(nameof(statusDescription));
|
||||
_totalBytes = totalBytes;
|
||||
_activityId = activityId;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
_lastUpdateTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the progress with the number of bytes processed
|
||||
/// </summary>
|
||||
/// <param name="bytesProcessed">Number of bytes processed so far</param>
|
||||
public void UpdateProgress(long bytesProcessed)
|
||||
{
|
||||
_bytesProcessed = bytesProcessed;
|
||||
|
||||
// Only update progress every 100ms to avoid overwhelming the console
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - _lastUpdateTime).TotalMilliseconds < 100 && bytesProcessed < _totalBytes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastUpdateTime = now;
|
||||
|
||||
var percentComplete = _totalBytes > 0 ? (int)((double)bytesProcessed / _totalBytes * 100) : 0;
|
||||
var currentStatus = FormatCurrentStatus(bytesProcessed);
|
||||
|
||||
var progressRecord = new ProgressRecord(_activityId, _activity, currentStatus)
|
||||
{
|
||||
PercentComplete = Math.Min(percentComplete, 100)
|
||||
};
|
||||
|
||||
// Add remaining time estimate if we have enough data
|
||||
if (_stopwatch.ElapsedMilliseconds > 1000 && bytesProcessed > 0 && bytesProcessed < _totalBytes)
|
||||
{
|
||||
var remainingTime = EstimateRemainingTime(bytesProcessed);
|
||||
if (remainingTime.HasValue)
|
||||
{
|
||||
progressRecord.SecondsRemaining = (int)remainingTime.Value.TotalSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
_cmdlet.WriteProgress(progressRecord);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the progress reporting
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
|
||||
var progressRecord = new ProgressRecord(_activityId, _activity, "Completed")
|
||||
{
|
||||
PercentComplete = 100,
|
||||
RecordType = ProgressRecordType.Completed
|
||||
};
|
||||
|
||||
_cmdlet.WriteProgress(progressRecord);
|
||||
|
||||
// Log completion details
|
||||
MinIOLogger.WriteVerbose(_cmdlet,
|
||||
$"Operation completed: {SizeFormatter.FormatBytes(_totalBytes)} processed in {_stopwatch.Elapsed.TotalSeconds:F1} seconds " +
|
||||
$"(Average speed: {SizeFormatter.FormatBytesPerSecond(CalculateAverageSpeed())})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the current status string
|
||||
/// </summary>
|
||||
/// <param name="bytesProcessed">Number of bytes processed</param>
|
||||
/// <returns>Formatted status string</returns>
|
||||
private string FormatCurrentStatus(long bytesProcessed)
|
||||
{
|
||||
var processedFormatted = SizeFormatter.FormatBytes(bytesProcessed);
|
||||
var totalFormatted = SizeFormatter.FormatBytes(_totalBytes);
|
||||
var speed = CalculateCurrentSpeed();
|
||||
var speedFormatted = SizeFormatter.FormatBytesPerSecond(speed);
|
||||
|
||||
return $"{_statusDescription}: {processedFormatted} / {totalFormatted} ({speedFormatted})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the current transfer speed in bytes per second
|
||||
/// </summary>
|
||||
/// <returns>Current speed in bytes per second</returns>
|
||||
private double CalculateCurrentSpeed()
|
||||
{
|
||||
var elapsedSeconds = _stopwatch.Elapsed.TotalSeconds;
|
||||
return elapsedSeconds > 0 ? _bytesProcessed / elapsedSeconds : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the average transfer speed in bytes per second
|
||||
/// </summary>
|
||||
/// <returns>Average speed in bytes per second</returns>
|
||||
private double CalculateAverageSpeed()
|
||||
{
|
||||
var elapsedSeconds = _stopwatch.Elapsed.TotalSeconds;
|
||||
return elapsedSeconds > 0 ? _totalBytes / elapsedSeconds : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the remaining time for the operation
|
||||
/// </summary>
|
||||
/// <param name="bytesProcessed">Number of bytes processed so far</param>
|
||||
/// <returns>Estimated remaining time, or null if cannot be calculated</returns>
|
||||
private TimeSpan? EstimateRemainingTime(long bytesProcessed)
|
||||
{
|
||||
if (bytesProcessed <= 0 || _stopwatch.ElapsedMilliseconds <= 0)
|
||||
return null;
|
||||
|
||||
var remainingBytes = _totalBytes - bytesProcessed;
|
||||
var currentSpeed = CalculateCurrentSpeed();
|
||||
|
||||
if (currentSpeed <= 0)
|
||||
return null;
|
||||
|
||||
var remainingSeconds = remainingBytes / currentSpeed;
|
||||
return TimeSpan.FromSeconds(remainingSeconds);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
|
||||
namespace PSMinIO.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility class for formatting byte sizes into human-readable strings
|
||||
/// </summary>
|
||||
public static class SizeFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Size units in order from smallest to largest
|
||||
/// </summary>
|
||||
private static readonly string[] SizeUnits = { "B", "KB", "MB", "GB", "TB", "PB", "EB" };
|
||||
|
||||
/// <summary>
|
||||
/// Formats bytes into a human-readable string with appropriate units
|
||||
/// </summary>
|
||||
/// <param name="bytes">Number of bytes</param>
|
||||
/// <param name="decimalPlaces">Number of decimal places to show (default: 2)</param>
|
||||
/// <returns>Formatted string with appropriate unit</returns>
|
||||
public static string FormatBytes(long bytes, int decimalPlaces = 2)
|
||||
{
|
||||
if (bytes == 0)
|
||||
return "0 B";
|
||||
|
||||
if (bytes < 0)
|
||||
return $"-{FormatBytes(-bytes, decimalPlaces)}";
|
||||
|
||||
int unitIndex = 0;
|
||||
double size = bytes;
|
||||
|
||||
// Find the appropriate unit
|
||||
while (size >= 1024 && unitIndex < SizeUnits.Length - 1)
|
||||
{
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// Format with specified decimal places
|
||||
var formatString = $"{{0:F{decimalPlaces}}} {{1}}";
|
||||
return string.Format(formatString, size, SizeUnits[unitIndex]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats bytes into a human-readable string with appropriate units (double overload)
|
||||
/// </summary>
|
||||
/// <param name="bytes">Number of bytes</param>
|
||||
/// <param name="decimalPlaces">Number of decimal places to show (default: 2)</param>
|
||||
/// <returns>Formatted string with appropriate unit</returns>
|
||||
public static string FormatBytes(double bytes, int decimalPlaces = 2)
|
||||
{
|
||||
return FormatBytes((long)Math.Round(bytes), decimalPlaces);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats bytes per second into a human-readable string
|
||||
/// </summary>
|
||||
/// <param name="bytesPerSecond">Bytes per second</param>
|
||||
/// <param name="decimalPlaces">Number of decimal places to show (default: 2)</param>
|
||||
/// <returns>Formatted string with appropriate unit and "/s" suffix</returns>
|
||||
public static string FormatBytesPerSecond(double bytesPerSecond, int decimalPlaces = 2)
|
||||
{
|
||||
return $"{FormatBytes(bytesPerSecond, decimalPlaces)}/s";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a transfer rate with context
|
||||
/// </summary>
|
||||
/// <param name="bytesTransferred">Number of bytes transferred</param>
|
||||
/// <param name="elapsedTime">Time elapsed for the transfer</param>
|
||||
/// <param name="decimalPlaces">Number of decimal places to show (default: 2)</param>
|
||||
/// <returns>Formatted transfer rate string</returns>
|
||||
public static string FormatTransferRate(long bytesTransferred, TimeSpan elapsedTime, int decimalPlaces = 2)
|
||||
{
|
||||
if (elapsedTime.TotalSeconds <= 0)
|
||||
return "0 B/s";
|
||||
|
||||
var bytesPerSecond = bytesTransferred / elapsedTime.TotalSeconds;
|
||||
return FormatBytesPerSecond(bytesPerSecond, decimalPlaces);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a progress string showing current/total with percentages
|
||||
/// </summary>
|
||||
/// <param name="current">Current bytes processed</param>
|
||||
/// <param name="total">Total bytes to process</param>
|
||||
/// <param name="decimalPlaces">Number of decimal places to show (default: 2)</param>
|
||||
/// <returns>Formatted progress string</returns>
|
||||
public static string FormatProgress(long current, long total, int decimalPlaces = 2)
|
||||
{
|
||||
var currentFormatted = FormatBytes(current, decimalPlaces);
|
||||
var totalFormatted = FormatBytes(total, decimalPlaces);
|
||||
|
||||
if (total > 0)
|
||||
{
|
||||
var percentage = (double)current / total * 100;
|
||||
return $"{currentFormatted} / {totalFormatted} ({percentage:F1}%)";
|
||||
}
|
||||
|
||||
return $"{currentFormatted} / {totalFormatted}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the appropriate unit for a given byte size without formatting
|
||||
/// </summary>
|
||||
/// <param name="bytes">Number of bytes</param>
|
||||
/// <returns>Appropriate unit string</returns>
|
||||
public static string GetAppropriateUnit(long bytes)
|
||||
{
|
||||
if (bytes == 0)
|
||||
return "B";
|
||||
|
||||
int unitIndex = 0;
|
||||
double size = Math.Abs(bytes);
|
||||
|
||||
while (size >= 1024 && unitIndex < SizeUnits.Length - 1)
|
||||
{
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return SizeUnits[unitIndex];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts bytes to the specified unit
|
||||
/// </summary>
|
||||
/// <param name="bytes">Number of bytes</param>
|
||||
/// <param name="unit">Target unit (B, KB, MB, GB, TB, PB, EB)</param>
|
||||
/// <returns>Value in the specified unit</returns>
|
||||
public static double ConvertToUnit(long bytes, string unit)
|
||||
{
|
||||
var unitIndex = Array.IndexOf(SizeUnits, unit.ToUpperInvariant());
|
||||
if (unitIndex == -1)
|
||||
throw new ArgumentException($"Invalid unit: {unit}. Valid units are: {string.Join(", ", SizeUnits)}");
|
||||
|
||||
if (unitIndex == 0) // Bytes
|
||||
return bytes;
|
||||
|
||||
return bytes / Math.Pow(1024, unitIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a size string back to bytes (e.g., "1.5 GB" -> bytes)
|
||||
/// </summary>
|
||||
/// <param name="sizeString">Size string to parse</param>
|
||||
/// <returns>Number of bytes</returns>
|
||||
public static long ParseSizeString(string sizeString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sizeString))
|
||||
throw new ArgumentException("Size string cannot be null or empty");
|
||||
|
||||
var parts = sizeString.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2)
|
||||
throw new ArgumentException($"Invalid size string format: {sizeString}. Expected format: '1.5 GB'");
|
||||
|
||||
if (!double.TryParse(parts[0], out var value))
|
||||
throw new ArgumentException($"Invalid numeric value: {parts[0]}");
|
||||
|
||||
var unit = parts[1].ToUpperInvariant();
|
||||
var unitIndex = Array.IndexOf(SizeUnits, unit);
|
||||
if (unitIndex == -1)
|
||||
throw new ArgumentException($"Invalid unit: {unit}. Valid units are: {string.Join(", ", SizeUnits)}");
|
||||
|
||||
return (long)(value * Math.Pow(1024, unitIndex));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a size comparison between two values
|
||||
/// </summary>
|
||||
/// <param name="value1">First value in bytes</param>
|
||||
/// <param name="value2">Second value in bytes</param>
|
||||
/// <param name="label1">Label for first value</param>
|
||||
/// <param name="label2">Label for second value</param>
|
||||
/// <param name="decimalPlaces">Number of decimal places to show (default: 2)</param>
|
||||
/// <returns>Formatted comparison string</returns>
|
||||
public static string FormatComparison(long value1, long value2, string label1, string label2, int decimalPlaces = 2)
|
||||
{
|
||||
var formatted1 = FormatBytes(value1, decimalPlaces);
|
||||
var formatted2 = FormatBytes(value2, decimalPlaces);
|
||||
|
||||
var difference = value1 - value2;
|
||||
var diffFormatted = FormatBytes(Math.Abs(difference), decimalPlaces);
|
||||
var diffDirection = difference >= 0 ? "larger" : "smaller";
|
||||
|
||||
return $"{label1}: {formatted1}, {label2}: {formatted2} ({diffFormatted} {diffDirection})";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
# PSMinIO Chunked Operations Test Script
|
||||
# This script tests the chunked upload and download functionality with resume capabilities
|
||||
|
||||
param(
|
||||
[string]$MinIOEndpoint = "https://localhost:9000",
|
||||
[string]$AccessKey = "minioadmin",
|
||||
[string]$SecretKey = "minioadmin",
|
||||
[string]$TestBucket = "chunked-test",
|
||||
[switch]$SkipCertValidation,
|
||||
[switch]$CleanupOnly
|
||||
)
|
||||
|
||||
# Import the module
|
||||
Import-Module "$PSScriptRoot\..\PSMinIO.psd1" -Force
|
||||
|
||||
Write-Host "=== PSMinIO Chunked Operations Test Suite ===" -ForegroundColor Cyan
|
||||
|
||||
# Cleanup function
|
||||
function Cleanup-TestEnvironment {
|
||||
Write-Host "Cleaning up test environment..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
# Remove test bucket if it exists
|
||||
if (Test-MinIOBucketExists -BucketName $TestBucket -ErrorAction SilentlyContinue) {
|
||||
Write-Host "Removing test bucket: $TestBucket"
|
||||
|
||||
# Remove all objects first
|
||||
$objects = Get-MinIOObject -BucketName $TestBucket -ErrorAction SilentlyContinue
|
||||
if ($objects) {
|
||||
foreach ($obj in $objects) {
|
||||
Remove-MinIOObject -BucketName $TestBucket -ObjectName $obj.Name -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Remove-MinIOBucket -BucketName $TestBucket -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Clean up test files
|
||||
$testDir = "$env:TEMP\PSMinIOChunkedTests"
|
||||
if (Test-Path $testDir) {
|
||||
Remove-Item $testDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Clean up resume data
|
||||
$resumeDir = "$env:LOCALAPPDATA\PSMinIO\Resume"
|
||||
if (Test-Path $resumeDir) {
|
||||
Get-ChildItem $resumeDir -Filter "*.psminioResume" | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "Cleanup completed." -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Cleanup failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# If cleanup only, run cleanup and exit
|
||||
if ($CleanupOnly) {
|
||||
Cleanup-TestEnvironment
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Connect to MinIO
|
||||
Write-Host "Connecting to MinIO at $MinIOEndpoint..." -ForegroundColor Yellow
|
||||
|
||||
$connectParams = @{
|
||||
Endpoint = $MinIOEndpoint
|
||||
AccessKey = $AccessKey
|
||||
SecretKey = $SecretKey
|
||||
}
|
||||
|
||||
if ($SkipCertValidation) {
|
||||
$connectParams.SkipCertificateValidation = $true
|
||||
}
|
||||
|
||||
$connection = Connect-MinIO @connectParams
|
||||
Write-Host "Connected successfully!" -ForegroundColor Green
|
||||
|
||||
# Create test directory
|
||||
$testDir = "$env:TEMP\PSMinIOChunkedTests"
|
||||
if (Test-Path $testDir) {
|
||||
Remove-Item $testDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $testDir -Force | Out-Null
|
||||
|
||||
# Test 1: Create test bucket
|
||||
Write-Host "`n=== Test 1: Creating test bucket ===" -ForegroundColor Cyan
|
||||
|
||||
if (Test-MinIOBucketExists -BucketName $TestBucket) {
|
||||
Write-Host "Test bucket already exists, removing it first..."
|
||||
Cleanup-TestEnvironment
|
||||
}
|
||||
|
||||
$bucket = New-MinIOBucket -BucketName $TestBucket
|
||||
Write-Host "Created bucket: $($bucket.Name)" -ForegroundColor Green
|
||||
|
||||
# Test 2: Create test files of various sizes
|
||||
Write-Host "`n=== Test 2: Creating test files ===" -ForegroundColor Cyan
|
||||
|
||||
$testFiles = @()
|
||||
|
||||
# Small file (1MB)
|
||||
$smallFile = "$testDir\small-file.txt"
|
||||
$smallContent = "A" * (1024 * 1024) # 1MB
|
||||
[System.IO.File]::WriteAllText($smallFile, $smallContent)
|
||||
$testFiles += Get-Item $smallFile
|
||||
Write-Host "Created small file: $($testFiles[-1].Name) ($($testFiles[-1].Length) bytes)"
|
||||
|
||||
# Medium file (15MB)
|
||||
$mediumFile = "$testDir\medium-file.bin"
|
||||
$mediumContent = [byte[]]::new(15 * 1024 * 1024) # 15MB
|
||||
(New-Object Random).NextBytes($mediumContent)
|
||||
[System.IO.File]::WriteAllBytes($mediumFile, $mediumContent)
|
||||
$testFiles += Get-Item $mediumFile
|
||||
Write-Host "Created medium file: $($testFiles[-1].Name) ($($testFiles[-1].Length) bytes)"
|
||||
|
||||
# Large file (50MB)
|
||||
$largeFile = "$testDir\large-file.bin"
|
||||
$largeContent = [byte[]]::new(50 * 1024 * 1024) # 50MB
|
||||
(New-Object Random).NextBytes($largeContent)
|
||||
[System.IO.File]::WriteAllBytes($largeFile, $largeContent)
|
||||
$testFiles += Get-Item $largeFile
|
||||
Write-Host "Created large file: $($testFiles[-1].Name) ($($testFiles[-1].Length) bytes)"
|
||||
|
||||
Write-Host "Created $($testFiles.Count) test files totaling $([math]::Round(($testFiles | Measure-Object Length -Sum).Sum / 1MB, 2)) MB" -ForegroundColor Green
|
||||
|
||||
# Test 3: Chunked upload - single file
|
||||
Write-Host "`n=== Test 3: Chunked upload - single file ===" -ForegroundColor Cyan
|
||||
|
||||
$uploadResult = New-MinIOObjectChunked -BucketName $TestBucket -Path @($testFiles[2]) -ChunkSize (5 * 1024 * 1024) -ShowURL -Verbose
|
||||
Write-Host "Uploaded: $($uploadResult.Name) (Size: $($uploadResult.SizeFormatted))" -ForegroundColor Green
|
||||
if ($uploadResult.HasPresignedUrl) {
|
||||
Write-Host "Presigned URL generated successfully" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Test 4: Chunked upload - multiple files
|
||||
Write-Host "`n=== Test 4: Chunked upload - multiple files ===" -ForegroundColor Cyan
|
||||
|
||||
$multiUploadResults = New-MinIOObjectChunked -BucketName $TestBucket -Path $testFiles[0..1] -ChunkSize (2 * 1024 * 1024) -BucketDirectory "multi-upload" -Verbose
|
||||
Write-Host "Uploaded $($multiUploadResults.Count) files to 'multi-upload' directory" -ForegroundColor Green
|
||||
foreach ($result in $multiUploadResults) {
|
||||
Write-Host " - $($result.Name) ($($result.SizeFormatted))" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Test 5: Chunked download - single file
|
||||
Write-Host "`n=== Test 5: Chunked download - single file ===" -ForegroundColor Cyan
|
||||
|
||||
$downloadFile = [System.IO.FileInfo]"$testDir\downloaded-large-file.bin"
|
||||
$downloadResult = Get-MinIOObjectContentChunked -BucketName $TestBucket -ObjectName $uploadResult.Name -FilePath $downloadFile -ChunkSize (8 * 1024 * 1024) -ParallelDownloads 3 -Verbose
|
||||
|
||||
# Verify download
|
||||
$originalHash = (Get-FileHash $testFiles[2].FullName -Algorithm SHA256).Hash
|
||||
$downloadedHash = (Get-FileHash $downloadResult.FullName -Algorithm SHA256).Hash
|
||||
|
||||
if ($originalHash -eq $downloadedHash) {
|
||||
Write-Host "Download verification successful - file integrity maintained" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Error "Download verification failed - file corruption detected"
|
||||
}
|
||||
|
||||
# Test 6: Resume functionality test (simulated)
|
||||
Write-Host "`n=== Test 6: Resume functionality test ===" -ForegroundColor Cyan
|
||||
|
||||
# Create a very large file for resume testing
|
||||
$resumeTestFile = "$testDir\resume-test-file.bin"
|
||||
$resumeContent = [byte[]]::new(30 * 1024 * 1024) # 30MB
|
||||
(New-Object Random).NextBytes($resumeContent)
|
||||
[System.IO.File]::WriteAllBytes($resumeTestFile, $resumeContent)
|
||||
$resumeFileInfo = Get-Item $resumeTestFile
|
||||
|
||||
Write-Host "Created resume test file: $($resumeFileInfo.Name) ($($resumeFileInfo.Length) bytes)"
|
||||
|
||||
# Upload with resume enabled
|
||||
$resumeUploadResult = New-MinIOObjectChunked -BucketName $TestBucket -Path @($resumeFileInfo) -ChunkSize (3 * 1024 * 1024) -Resume -ResumeDataPath "$testDir\resume" -Verbose
|
||||
Write-Host "Resume upload completed: $($resumeUploadResult.Name)" -ForegroundColor Green
|
||||
|
||||
# Test 7: Directory upload with chunking
|
||||
Write-Host "`n=== Test 7: Directory upload with chunking ===" -ForegroundColor Cyan
|
||||
|
||||
# Create a test directory structure
|
||||
$dirTestPath = "$testDir\directory-test"
|
||||
New-Item -ItemType Directory -Path $dirTestPath -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "$dirTestPath\subdir1" -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "$dirTestPath\subdir2" -Force | Out-Null
|
||||
|
||||
# Create files in directory
|
||||
"Content 1" | Out-File "$dirTestPath\file1.txt"
|
||||
"Content 2" | Out-File "$dirTestPath\subdir1\file2.txt"
|
||||
"Content 3" | Out-File "$dirTestPath\subdir2\file3.txt"
|
||||
|
||||
$dirInfo = Get-Item $dirTestPath
|
||||
$dirUploadResults = New-MinIOObjectChunked -BucketName $TestBucket -Directory $dirInfo -Recursive -ChunkSize (1024 * 1024) -Verbose
|
||||
|
||||
Write-Host "Directory upload completed: $($dirUploadResults.Count) files uploaded" -ForegroundColor Green
|
||||
foreach ($result in $dirUploadResults) {
|
||||
Write-Host " - $($result.Name)" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Test 8: List all uploaded objects
|
||||
Write-Host "`n=== Test 8: Listing all uploaded objects ===" -ForegroundColor Cyan
|
||||
|
||||
$allObjects = Get-MinIOObject -BucketName $TestBucket
|
||||
Write-Host "Total objects in bucket: $($allObjects.Count)" -ForegroundColor Green
|
||||
Write-Host "Total size: $([math]::Round(($allObjects | Measure-Object Size -Sum).Sum / 1MB, 2)) MB" -ForegroundColor Green
|
||||
|
||||
foreach ($obj in $allObjects | Sort-Object Name) {
|
||||
Write-Host " - $($obj.Name) ($($obj.SizeFormatted))" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
Write-Host "`n=== All tests completed successfully! ===" -ForegroundColor Green
|
||||
|
||||
# Ask if user wants to cleanup
|
||||
$cleanup = Read-Host "`nDo you want to clean up test data? (y/N)"
|
||||
if ($cleanup -eq 'y' -or $cleanup -eq 'Y') {
|
||||
Cleanup-TestEnvironment
|
||||
} else {
|
||||
Write-Host "Test data preserved. Run with -CleanupOnly to clean up later." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Test failed: $($_.Exception.Message)"
|
||||
Write-Host "Stack trace:" -ForegroundColor Red
|
||||
Write-Host $_.ScriptStackTrace -ForegroundColor Red
|
||||
|
||||
# Cleanup on error
|
||||
Write-Host "`nCleaning up due to error..." -ForegroundColor Yellow
|
||||
Cleanup-TestEnvironment
|
||||
exit 1
|
||||
}
|
||||
finally {
|
||||
# Disconnect if connected
|
||||
if ($connection) {
|
||||
Write-Host "Disconnecting from MinIO..." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user