Initial commit: PSMinIO module with chunked transfer support

This commit is contained in:
PSMinIO Developer
2025-07-10 12:58:44 -04:00
parent a306ade1f3
commit 5fdcd31d5e
40 changed files with 9899 additions and 1 deletions
+283
View File
@@ -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
}
+198
View File
@@ -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>
+258
View File
@@ -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>
+66
View File
@@ -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
View File
@@ -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 = ''
}
+71 -1
View File
@@ -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
View File
@@ -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
}
}
+166
View File
@@ -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.
+401
View File
@@ -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
View File
@@ -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).
+182
View File
@@ -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
+203
View File
@@ -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.");
}
}
}
}
+180
View File
@@ -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;
}
}
}
}
+279
View File
@@ -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));
}
}
}
}
+209
View File
@@ -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;
}
}
}
}
+269
View File
@@ -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]}";
}
}
}
+162
View File
@@ -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));
}
}
}
}
}
+185
View File
@@ -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;
}
}
}
+577
View File
@@ -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}");
}
}
}
}
}
+489
View File
@@ -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}");
}
}
}
}
}
+194
View File
@@ -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));
}
}
}
}
}
+229
View File
@@ -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;
}
}
}
}
+336
View File
@@ -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;
}
}
}
}
+152
View File
@@ -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)";
}
}
}
+201
View File
@@ -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
}
}
+101
View File
@@ -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;
}
}
}
+84
View File
@@ -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;
}
}
}
+202
View File
@@ -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}";
}
}
}
+249
View File
@@ -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());
}
}
}
+90
View File
@@ -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);
}
}
}
}
+250
View File
@@ -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;
}
}
}
+285
View File
@@ -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();
}
}
}
+930
View File
@@ -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;
}
}
}
}
+249
View File
@@ -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;
}
}
}
+156
View File
@@ -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);
}
}
}
+189
View File
@@ -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})";
}
}
}
+236
View File
@@ -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
}
}