From b600437cc960138025afcc816a85ec1bf01371bc Mon Sep 17 00:00:00 2001 From: Fabio Date: Tue, 6 Jan 2026 15:22:45 +0100 Subject: [PATCH 1/8] Added integration, example and test SFTP projects --- CommunityToolkit.Aspire.slnx | 15 +- Directory.Packages.props | 2 + ...lkit.Aspire.Hosting.Sftp.ApiService.csproj | 29 +++ .../Program.cs | 82 +++++++ .../Properties/launchSettings.json | 41 ++++ .../appsettings.json | 9 + ...Toolkit.Aspire.Hosting.Sftp.AppHost.csproj | 42 ++++ .../Program.cs | 12 + .../Properties/launchSettings.json | 31 +++ .../appsettings.json | 9 + .../etc/sftp/users.conf | 1 + .../etc/ssh/ssh_host_ed25519_key | 7 + .../etc/ssh/ssh_host_ed25519_key.fingerprint | Bin 0 -> 170 bytes .../etc/ssh/ssh_host_rsa_key | 49 ++++ .../home/foo/.ssh/keys/id_ed25519 | 7 + .../home/foo/.ssh/keys/id_ed25519.pub | 1 + .../home/foo/.ssh/keys/id_rsa | 49 ++++ .../home/foo/.ssh/keys/id_rsa.pub | 1 + ...Aspire.Hosting.Sftp.ServiceDefaults.csproj | 21 ++ .../Extensions.cs | 118 ++++++++++ ...ommunityToolkit.Aspire.Hosting.Sftp.csproj | 26 +++ .../KeyType.cs | 17 ++ .../README.md | 51 +++++ .../SftpContainerImageTags.cs | 8 + .../SftpContainerResource.cs | 50 +++++ .../SftpHostingExtensions.cs | 161 +++++++++++++ .../AspireSftpExtensions.cs | 153 +++++++++++++ .../CommunityToolkit.Aspire.Sftp.csproj | 19 ++ src/CommunityToolkit.Aspire.Sftp/README.md | 188 ++++++++++++++++ .../SftpHealthCheck.cs | 94 ++++++++ .../SftpSettings.cs | 54 +++++ .../AppHostTests.cs | 65 ++++++ ...tyToolkit.Aspire.Hosting.Sftp.Tests.csproj | 41 ++++ .../ContainerResourceCreationTests.cs | 42 ++++ .../SftpFunctionalTests.cs | 212 ++++++++++++++++++ .../AspireSftpClientExtensionsTest.cs | 152 +++++++++++++ .../CommunityToolkit.Aspire.Sftp.Tests.csproj | 8 + 37 files changed, 1864 insertions(+), 3 deletions(-) create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/CommunityToolkit.Aspire.Hosting.Sftp.ApiService.csproj create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Properties/launchSettings.json create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/appsettings.json create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/CommunityToolkit.Aspire.Hosting.Sftp.AppHost.csproj create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Program.cs create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Properties/launchSettings.json create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/appsettings.json create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/sftp/users.conf create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_ed25519_key create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_ed25519_key.fingerprint create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_rsa_key create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519 create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519.pub create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa.pub create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults.csproj create mode 100644 examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/Extensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.Sftp/KeyType.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Sftp/README.md create mode 100644 src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj create mode 100644 src/CommunityToolkit.Aspire.Sftp/README.md create mode 100644 src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs create mode 100644 src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/ContainerResourceCreationTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs create mode 100644 tests/CommunityToolkit.Aspire.Sftp.Tests/CommunityToolkit.Aspire.Sftp.Tests.csproj diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index d41f01c22..e55699e0f 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -142,6 +142,11 @@ + + + + + @@ -178,6 +183,7 @@ + @@ -188,7 +194,6 @@ - @@ -198,6 +203,7 @@ + @@ -212,6 +218,7 @@ + @@ -232,7 +239,9 @@ + + @@ -242,7 +251,6 @@ - @@ -251,6 +259,7 @@ + @@ -258,7 +267,6 @@ - @@ -266,6 +274,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index d2b01aa6f..2ce7ee6b7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -93,6 +93,7 @@ + @@ -114,6 +115,7 @@ + diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/CommunityToolkit.Aspire.Hosting.Sftp.ApiService.csproj b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/CommunityToolkit.Aspire.Hosting.Sftp.ApiService.csproj new file mode 100644 index 000000000..2dfaa2187 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/CommunityToolkit.Aspire.Hosting.Sftp.ApiService.csproj @@ -0,0 +1,29 @@ + + + + enable + enable + + + + + Always + + + + Always + + + + + + + + + + + + + + + diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs new file mode 100644 index 000000000..c23757032 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs @@ -0,0 +1,82 @@ +using Renci.SshNet; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddSftpClient("sftp", cfg => +{ + cfg.Username = "foo"; + cfg.Password = "pass"; +}); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +const string fileName = "uploads/hello.txt"; + +app.MapPost("/upload", async (SftpClient client, CancellationToken cancellationToken) => +{ + try + { + var tokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + + await client.ConnectAsync(tokenSource.Token); + + var fileContent = Encoding.UTF8.GetBytes("Hello world!"); + + using var inputStream = new MemoryStream(fileContent); + + await client.UploadFileAsync(inputStream, fileName); + + return Results.File(inputStream.ToArray()); + } + catch (Exception ex) + { + return Results.InternalServerError(ex.ToString()); + } + finally + { + client.Disconnect(); + } +}); + +app.MapGet("/download", async (SftpClient client, CancellationToken cancellationToken) => +{ + try + { + var tokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + + await client.ConnectAsync(tokenSource.Token); + + using var outputStream = new MemoryStream(); + + await client.DownloadFileAsync(fileName, outputStream); + + return Results.File(outputStream.ToArray()); + } + catch (Exception ex) + { + return Results.InternalServerError(ex.ToString()); + } + finally + { + client.Disconnect(); + } +}); + +app.Run(); diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Properties/launchSettings.json b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Properties/launchSettings.json new file mode 100644 index 000000000..08fc43745 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:38959", + "sslPort": 44303 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5279", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7015;http://localhost:5279", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/appsettings.json b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/CommunityToolkit.Aspire.Hosting.Sftp.AppHost.csproj b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/CommunityToolkit.Aspire.Hosting.Sftp.AppHost.csproj new file mode 100644 index 000000000..b31800568 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/CommunityToolkit.Aspire.Hosting.Sftp.AppHost.csproj @@ -0,0 +1,42 @@ + + + + Exe + enable + enable + true + + + + + Always + + + Always + + + Always + + + Always + + + + + + + + + + + Always + + + Always + + + Always + + + + diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Program.cs b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Program.cs new file mode 100644 index 000000000..e63252241 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Program.cs @@ -0,0 +1,12 @@ +using Aspire.Hosting; +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var sftp = builder.AddSftp("sftp").WithEnvironment("SFTP_USERS", "foo:$5$t9qxNlrcFqVBNnad$U27ZrjbKNjv4JkRWvi6MjX4x6KXNQGr8NTIySOcDgi4:e:::uploads"); + +builder.AddProject("api") + .WithReference(sftp) + .WaitForStart(sftp); + +builder.Build().Run(); diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Properties/launchSettings.json b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..b6a0cfc0d --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17120;http://localhost:15112", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21132", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23243", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22008" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15112", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19232", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18121", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20008" + } + } + } +} diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/appsettings.json b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/sftp/users.conf b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/sftp/users.conf new file mode 100644 index 000000000..b939e8b75 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/sftp/users.conf @@ -0,0 +1 @@ +foo:pass:::uploads \ No newline at end of file diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_ed25519_key b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_ed25519_key new file mode 100644 index 000000000..c19badaaa --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_ed25519_key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDBmd2IbiS10rizzMq5LMypJAwCGL3GIOJoBo/qakw1sgAAAJjd/nkN3f55 +DQAAAAtzc2gtZWQyNTUxOQAAACDBmd2IbiS10rizzMq5LMypJAwCGL3GIOJoBo/qakw1sg +AAAECBS/d+BtmY72S3eIi/xvBXn5vd6qub8ARyIL7Vs+4OkcGZ3YhuJLXSuLPMyrkszKkk +DAIYvcYg4mgGj+pqTDWyAAAAEXJvb3RAMzU0ODUxNjU2MjVjAQIDBA== +-----END OPENSSH PRIVATE KEY----- diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_ed25519_key.fingerprint b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_ed25519_key.fingerprint new file mode 100644 index 0000000000000000000000000000000000000000..d69fb3dfe32b4e2bcd9552df29a66f38ea8752b2 GIT binary patch literal 170 zcmXAjO%K6f5QLwz#D8$)hI(6is;KCPMx>HfuLKtn#i9N2bl%8jlbxNJo&9~M7+ePA zL@fCupOywVQ=+mPEm5&*L literal 0 HcmV?d00001 diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_rsa_key b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_rsa_key new file mode 100644 index 000000000..f7ecfcc66 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/etc/ssh/ssh_host_rsa_key @@ -0,0 +1,49 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519 b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519 new file mode 100644 index 000000000..6896629df --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBZajt1nl0rho2+f7dVXWfY/zxsa/skl923gpo/rC6NMgAAAJA1V0TJNVdE +yQAAAAtzc2gtZWQyNTUxOQAAACBZajt1nl0rho2+f7dVXWfY/zxsa/skl923gpo/rC6NMg +AAAEAJQgYtWcdJu0bB6eONaQaPVIA5yEQUy7KUokTmHP23cFlqO3WeXSuGjb5/t1VdZ9j/ +PGxr+ySX3beCmj+sLo0yAAAAC21zbkBMRUdJT041AQI= +-----END OPENSSH PRIVATE KEY----- diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519.pub b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519.pub new file mode 100644 index 000000000..285475226 --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFlqO3WeXSuGjb5/t1VdZ9j/PGxr+ySX3beCmj+sLo0y msn@LEGION5 diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa new file mode 100644 index 000000000..8da15ecfa --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa @@ -0,0 +1,49 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +ziC8op0mYwMAAAALbXNuQExFR0lPTjU= +-----END OPENSSH PRIVATE KEY----- diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa.pub b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa.pub new file mode 100644 index 000000000..1ae4bc62d --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.AppHost/home/foo/.ssh/keys/id_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC2ho46rEDytwDO1l9o3aGNHwTb+QWRGb4BRfM1BIh37w2C1ZZIxGt4vx8E2lNprpqSFlut1HMzb1S5ZzI/kdPeQom5UecBGl1vQyg6eZNbJwx8UegbcHIJVpmQM8cE7REKCzsJulUu8LaaD4SqsYt+VUjaK1PSGCtVSuqJO6yAfK55ybjP9QvfUBBSmhZ4S5nOccdQA5CMw18Ut9OJghcCKUqV1J85YV+isZcAJhMZta3e9xMy08tEjy2oCuXUBe5qVN8fb5DZTvbNeCoiVsEOWSxVzPPMyRkmnpgfQ0hQqHLb76b9D+jgY47x3bm/KQcwnPPsKtW0oNSlFy5sDlSBKvUQs9z+qycqXky3oY5mM3sGJpbEYDcXY3/0iNvai1WXJXE3o68gjOBLui2b/WcC3AMkWMuFiABUcE9BUjrRamMz+EsczZOomPgZO3qufxNaAVWuFvBZ/xP60J6gsFo0FHlLH1bD2vxEqmlBxe4OpO9r2Ab4gI335AfpZs0KeIQGTafKQsO9qloK7T06BIKJNMKsWrdyup9dep63bAmA8NPra7dx7euydk1zxy4scdQEyIFyG58Z641XHsE2fht8kWqBpr9DOtnjdo5Oh3oMmt7yh6RTVWHKBQ35qIHRs9vqit+9MKtqK9G/UQWniOu7dREBDdhPCYCk0vRfHmmnlQ== msn@LEGION5 diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults.csproj b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults.csproj new file mode 100644 index 000000000..caa6344dc --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + enable + enable + true + + + + + + + + + + + + + + + diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/Extensions.cs b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..8cf2bbd1d --- /dev/null +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ServiceDefaults/Extensions.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj b/src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj new file mode 100644 index 000000000..3416b2298 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + Aspire hosting integration for the atmoz SFTP container image. + atmoz sftp hosting + + + + + + + + + + + + + / + true + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/KeyType.cs b/src/CommunityToolkit.Aspire.Hosting.Sftp/KeyType.cs new file mode 100644 index 000000000..da2dc5bb0 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/KeyType.cs @@ -0,0 +1,17 @@ +namespace Aspire.Hosting; + +/// +/// Specifies the types of cryptographic keys supported for digital signatures. +/// +public enum KeyType +{ + /// + /// Specifies the Ed25519 public-key signature algorithm. + /// + Ed25519, + + /// + /// Specifies the RSA public-key signature algorithm. + /// + Rsa +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/README.md b/src/CommunityToolkit.Aspire.Hosting.Sftp/README.md new file mode 100644 index 000000000..551ec123a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/README.md @@ -0,0 +1,51 @@ +# CommunityToolkit.Hosting.Sftp + +## Overview + +This Aspire integration is a wrapper for the [atmoz SFTP server](https://hub.docker.com/r/atmoz/sftp/) image. + + +## Usage + +The SFTP integration exposes a connection string with the format `endpoint=sftp://:`. + + +### Example 1: Add SFTP container with users as an argument or environment variable + +Define users in (1) command arguments or (2) `SFTP_USERS` environment variable (syntax: `user:pass[:e][:uid[:gid[:dir1[,dir2]...]]]`, see below for examples) + +Add `:e` behind password to mark it as encrypted. On Windows, use `wsl mkpasswd -m sha-512` to generate encrypted passwords. + +```csharp +builder.AddSftp("sftp").WithArgs($"foo:pass:::uploads"); + +builder.AddSftp("sftp").WithEnvironment("SFTP_USERS", "foo:pass:::uploads"); + +builder.AddSftp("sftp").WithEnvironment("SFTP_USERS", "foo:$5$t9qxNlrcFqVBNnad$U27ZrjbKNjv4JkRWvi6MjX4x6KXNQGr8NTIySOcDgi4:e:::uploads"); +``` + +### Example 2: Add SFTP container with users in config file + +Define users in file mounted as `/etc/sftp/users.conf` using the `WithUsersFile()` extension method. + +```csharp +builder.AddSftp("sftp").WithUsersFile("users.conf"); +``` + +### Example 3: Add SFTP container with own host key + +This container will generate new SSH host keys at first run. To avoid that your users get a MITM warning when you recreate your container (and the host keys changes), you can mount your own host keys with the `WithHostKeyFile()` extension method. + +```csharp +builder.AddSftp("sftp").WithUsersFile("users.conf").WithHostKeyFile("ssh_host_ed25519_key", KeyType.Ed25519); +``` + +### Example 4: Add SFTP container with user public key + +Mount **public** keys in the user's `.ssh/keys/` directory using the `WithUserKeyFile()` extension method. All keys are automatically appended to `.ssh/authorized_keys` (you can't mount this file directly, because OpenSSH requires limited file permissions). In this example, we do not provide any password, so the user `foo` can only login with the corresponding **private** key. + +```csharp +builder.AddSftp("sftp").WithArgs("foo::::uploads").WithUserKeyFile("foo", "id_rsa.pub", KeyType.Rsa); + +builder.AddSftp("sftp").WithArgs("foo::::uploads").WithUserKeyFile("foo", "id_ed25519.pub", KeyType.Ed25519); +``` diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs new file mode 100644 index 000000000..a6880a6ab --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs @@ -0,0 +1,8 @@ +namespace CommunityToolkit.Aspire.Hosting.Sftp; + +internal static class SftpContainerImageTags +{ + public const string Registry = "docker.io"; + public const string Image = "atmoz/sftp"; + public const string Tag = "latest"; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs new file mode 100644 index 000000000..3db0558ec --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs @@ -0,0 +1,50 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents an SFTP container. +/// +/// The name of the resource. +public class SftpContainerResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + internal const int SftpEndpointPort = 22; + internal const string SftpEndpointName = "sftp"; + + private EndpointReference? _sftpEndpoint; + + /// + /// Gets the primary endpoint for the SFTP server. + /// + private EndpointReference SftpEndpoint => _sftpEndpoint ??= new (this, SftpEndpointName); + + /// + /// Gets the host endpoint reference for this resource. + /// + public EndpointReferenceExpression Host => SftpEndpoint.Property(EndpointProperty.Host); + + /// + /// Gets the port endpoint reference for this resource. + /// + public EndpointReferenceExpression Port => SftpEndpoint.Property(EndpointProperty.Port); + + /// + /// ConnectionString for the atmoz SFTP server in the form of sftp://host:port. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"sftp://{SftpEndpoint.Property(EndpointProperty.Host)}:{SftpEndpoint.Property(EndpointProperty.Port)}"); + + /// + /// Gets the connection URI expression for the atmoz SFTP endpoint. + /// + /// + /// Format: sftp://{host}:{port}. + /// + public ReferenceExpression UriExpression => ConnectionStringExpression; + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Host", ReferenceExpression.Create($"{Host}")); + yield return new("Port", ReferenceExpression.Create($"{Port}")); + yield return new("Uri", UriExpression); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs new file mode 100644 index 000000000..d4dc7f915 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs @@ -0,0 +1,161 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Sftp; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding an SFTP resource to an . +/// +public static class SftpHostingExtensions +{ + /// + /// Adds atmoz SFTP to the application model. + /// + /// The to add the resource to. + /// The name of the resource. + /// The SFTP port number for the atmoz SFTP container. + /// A reference to the . + /// + /// + /// Add an SFTP container to the application model with users configuration in the specified file. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddSftp("sftp"); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder AddSftp(this IDistributedApplicationBuilder builder, + [ResourceName] string name, + int? port = null) + { + ArgumentNullException.ThrowIfNull("Service name must be specified.", nameof(name)); + SftpContainerResource resource = new(name); + + var resourceBuilder = builder.AddResource(resource) + .WithImage(SftpContainerImageTags.Image) + .WithImageTag(SftpContainerImageTags.Tag) + .WithImageRegistry(SftpContainerImageTags.Registry) + .WithEndpoint("sftp", ep => + { + ep.Port = port; + ep.TargetPort = SftpContainerResource.SftpEndpointPort; + ep.UriScheme = "sftp"; + }); + + return resourceBuilder; + } + + /// + /// Adds a bind mount for the users.conf file to an SFTP container resource. + /// + /// The resource builder. + /// The path to the users.conf file. + /// The . + /// + /// + /// Add an SFTP container to the application model with users configuration in the specified file. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddSftp("sftp").WithUsersFile("./etc/sftp/users.conf"); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithUsersFile(this IResourceBuilder builder, string usersFile) + { + ArgumentNullException.ThrowIfNullOrEmpty(usersFile, nameof(usersFile)); + + var fileInfo = new FileInfo(usersFile); + + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"File '{fileInfo.FullName}' not found"); + } + + return builder.WithBindMount(fileInfo.FullName, "/etc/sftp/users.conf", isReadOnly: true); + } + + /// + /// Adds a bind mount for the specified host key file to an SFTP container resource. + /// + /// The resource builder. + /// The path to the host key file. + /// The type of the host key. + /// The . + /// + /// + /// Add an SFTP container to the application model with the host key in the specified file. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddSftp("sftp").WithHostKeyFile("./etc/ssh/ssh_host_ed25519_key", KeyType.Ed25519); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithHostKeyFile(this IResourceBuilder builder, string keyFile, KeyType keyType) + { + ArgumentNullException.ThrowIfNullOrEmpty(keyFile, nameof(keyFile)); + + var fileInfo = new FileInfo(keyFile); + + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"File '{fileInfo.FullName}' not found"); + } + + switch (keyType) + { + case KeyType.Ed25519: + builder.WithBindMount(fileInfo.FullName, "/etc/ssh/ssh_host_ed25519_key"); + break; + + case KeyType.Rsa: + builder.WithBindMount(fileInfo.FullName, "/etc/ssh/ssh_host_rsa_key"); + break; + } + + return builder; + } + + /// + /// Adds a bind mount for the public key file of the specified user to an SFTP container resource. + /// + /// The resource builder. + /// The user whose public key is being bind mounted + /// The public key file of the specified user (will be bind mounted on the server). + /// The type of the host key. + /// The . + /// + /// + /// Add an SFTP container to the application model with the host key in the specified file. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddSftp("sftp").WithUserKeyFile("foo", "./home/foo/.ssh/keys/id_rsa.pub", KeyType.Rsa); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithUserKeyFile(this IResourceBuilder builder, string username, string keyFile, KeyType keyType) + { + ArgumentNullException.ThrowIfNullOrEmpty(username, nameof(username)); + ArgumentNullException.ThrowIfNullOrEmpty(keyFile, nameof(keyFile)); + + var fileInfo = new FileInfo(keyFile); + + if (!fileInfo.Exists) + { + throw new FileNotFoundException($"File '{fileInfo.FullName}' not found"); + } + + return builder.WithBindMount(fileInfo.FullName, $"/home/{username}/.ssh/keys/{fileInfo.Name}"); + } +} diff --git a/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs b/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs new file mode 100644 index 000000000..af8a245b8 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs @@ -0,0 +1,153 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using CommunityToolkit.Aspire.Sftp; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Renci.SshNet; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering SFTP-related services in an . +/// +public static class AspireSftpExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Sftp:Client"; + + /// + /// Registers as a singleton in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + public static void AddSftpClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + AddSftpClient(builder, DefaultConfigSectionName, configureSettings, connectionName, serviceKey: null); + } + + /// + /// Registers as a keyed singleton for the given in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + public static void AddKeyedSftpClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + AddSftpClient(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, connectionName: name, serviceKey: name); + } + + private static void AddSftpClient( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + string connectionName, + string? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + var settings = new SftpSettings(); + builder.Configuration.GetSection(configurationSectionName).Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + if (serviceKey is null) + { + builder.Services.AddSingleton(ConfigureSftpClient); + } + else + { + builder.Services.AddKeyedSingleton(serviceKey, (sp, key) => ConfigureSftpClient(sp)); + } + + if (!settings.DisableTracing) + { + builder.Services.AddOpenTelemetry() + .WithTracing(traceBuilder => traceBuilder.AddSource("Renci.SshNet")); + } + + if (!settings.DisableHealthChecks) + { + var healthCheckName = serviceKey is null ? "Sftp.Client" : $"Sftp.Client_{connectionName}"; + + builder.TryAddHealthCheck(new HealthCheckRegistration( + healthCheckName, + sp => new SftpHealthCheck(settings), + failureStatus: default, + tags: default, + timeout: settings.HealthCheckTimeout)); + } + + SftpClient ConfigureSftpClient(IServiceProvider serviceProvider) + { + if (settings.ConnectionString is not null) + { + var (host, port) = ParseConnectionString(settings.ConnectionString); + + if (string.IsNullOrEmpty(settings.Username)) + { + throw new InvalidOperationException( + $"An SFTP client could not be configured. The '{nameof(settings.Username)}' must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + + ConnectionInfo connectionInfo; + + if (!string.IsNullOrEmpty(settings.PrivateKeyFile)) + { + var privateKeyFile = new PrivateKeyFile(settings.PrivateKeyFile); + connectionInfo = new ConnectionInfo(host, port, settings.Username, new PrivateKeyAuthenticationMethod(settings.Username, privateKeyFile)); + } + else if (!string.IsNullOrEmpty(settings.Password)) + { + connectionInfo = new ConnectionInfo(host, port, settings.Username, new PasswordAuthenticationMethod(settings.Username, settings.Password)); + } + else + { + throw new InvalidOperationException( + $"An SFTP client could not be configured. Either '{nameof(settings.Password)}' or '{nameof(settings.PrivateKeyFile)}' must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + + var client = new SftpClient(connectionInfo); + return client; + } + else + { + throw new InvalidOperationException( + $"An SFTP client could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or " + + $"{nameof(settings.ConnectionString)} must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + } + } + + private static (string Host, int Port) ParseConnectionString(string connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + var host = uri.Host; + var port = uri.Port > 0 ? uri.Port : 22; + return (host, port); + } + + throw new InvalidOperationException($"The connection string '{connectionString}' is not in the correct format. Expected format: 'sftp://host:port'"); + } +} diff --git a/src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj b/src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj new file mode 100644 index 000000000..f40cbb4d8 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj @@ -0,0 +1,19 @@ + + + + SFTP client + An SFTP client that integrates with Aspire, including health checks, logging, and telemetry. + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Sftp/README.md b/src/CommunityToolkit.Aspire.Sftp/README.md new file mode 100644 index 000000000..2d359cfbe --- /dev/null +++ b/src/CommunityToolkit.Aspire.Sftp/README.md @@ -0,0 +1,188 @@ +# CommunityToolkit.Aspire.Sftp + +Registers an [SftpClient](https://github.com/sshnet/SSH.NET) in the DI container for connecting to SFTP servers using SSH.NET. + +## Getting started + +### Prerequisites + +- SFTP server. + +### Install the package + +Install the .NET Aspire SFTP Client library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Sftp +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddSftpClient` extension method to register an `SftpClient` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddSftpClient("sftp"); +``` + +## Configuration + +The .NET Aspire SFTP Client integration provides multiple options to configure the server connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddSftpClient()`: + +```csharp +builder.AddSftpClient("sftp"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "sftp": "sftp://localhost:22" + } +} +``` + +### Use configuration providers + +The .NET Aspire SFTP Client integration supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `SftpSettings` from configuration by using the `Aspire:Sftp:Client` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Sftp": { + "Client": { + "ConnectionString": "sftp://localhost:22", + "Username": "admin", + "Password": "password", + "DisableHealthChecks": false + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline: + +```csharp +builder.AddSftpClient("sftp", settings => +{ + settings.Username = "admin"; + settings.Password = "password"; +}); +``` + +### Authentication options + +The SFTP client supports two authentication methods: + +1. **Password authentication**: Provide `Username` and `Password`. +2. **Private key authentication**: Provide `Username` and `PrivateKeyFile` path. + +```json +{ + "Aspire": { + "Sftp": { + "Client": { + "ConnectionString": "sftp://localhost:22", + "Username": "admin", + "PrivateKeyFile": "/path/to/private/key" + } + } + } +} +``` + +### Keyed services + +The integration also supports keyed services for multiple SFTP connections: + +```csharp +builder.AddKeyedSftpClient("sftp1"); +builder.AddKeyedSftpClient("sftp2"); +``` + +Then inject the keyed client: + +```csharp +public class MyService +{ + private readonly SftpClient _client1; + private readonly SftpClient _client2; + + public MyService( + [FromKeyedServices("sftp1")] SftpClient client1, + [FromKeyedServices("sftp2")] SftpClient client2) + { + _client1 = client1; + _client2 = client2; + } +} +``` + +## AppHost extensions + +In your AppHost project, install the `CommunityToolkit.Aspire.Hosting.Sftp` library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.Sftp +``` + +Then, in the _Program.cs_ file of `AppHost`, register SFTP and consume the connection using the following methods: + +```csharp +var sftp = builder.AddSftp("sftp"); + +var myService = builder.AddProject() + .WithReference(sftp); +``` + +The `WithReference` method configures a connection in the `MyService` project named `sftp`. In the _Program.cs_ file of `MyService`, the SFTP connection can be consumed using: + +```csharp +builder.AddSftpClient("sftp"); +``` + +Then, in your service, inject `SftpClient` and use it to interact with the SFTP server: + +```csharp +public class MyService +{ + private readonly SftpClient _client; + + public MyService(SftpClient client) + { + _client = client; + if (!_client.IsConnected) + { + _client.Connect(); + } + } + + public void UploadFile(string localPath, string remotePath) + { + using var fileStream = File.OpenRead(localPath); + _client.UploadFile(fileStream, remotePath); + } + + public void DownloadFile(string remotePath, string localPath) + { + using var fileStream = File.Create(localPath); + _client.DownloadFile(remotePath, fileStream); + } +} +``` + +## Additional documentation + +- https://github.com/sshnet/SSH.NET +- https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-sftp + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs b/src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs new file mode 100644 index 000000000..f10eed9ff --- /dev/null +++ b/src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Renci.SshNet; + +namespace CommunityToolkit.Aspire.Sftp; + +/// +/// Checks whether a connection can be made to an SFTP server using the supplied connection settings. +/// +public class SftpHealthCheck : IHealthCheck, IDisposable +{ + private readonly SftpClient _client; + + /// + public SftpHealthCheck(SftpSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(settings.ConnectionString); + + var (host, port) = ParseConnectionString(settings.ConnectionString); + + if (string.IsNullOrEmpty(settings.Username)) + { + throw new InvalidOperationException("Username must be provided for SFTP health check."); + } + + ConnectionInfo connectionInfo; + + if (!string.IsNullOrEmpty(settings.PrivateKeyFile)) + { + var privateKeyFile = new PrivateKeyFile(settings.PrivateKeyFile); + connectionInfo = new ConnectionInfo(host, port, settings.Username, new PrivateKeyAuthenticationMethod(settings.Username, privateKeyFile)); + } + else if (!string.IsNullOrEmpty(settings.Password)) + { + connectionInfo = new ConnectionInfo(host, port, settings.Username, new PasswordAuthenticationMethod(settings.Username, settings.Password)); + } + else + { + throw new InvalidOperationException("Either Password or PrivateKeyFile must be provided for SFTP health check."); + } + + _client = new SftpClient(connectionInfo); + } + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + if (!_client.IsConnected) + { + _client.Connect(); + } + + var files = _client.ListDirectory("/"); + + if (files.Any()) + { + return HealthCheckResult.Healthy(); + } + + return HealthCheckResult.Healthy(); + } + catch (Exception exception) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: exception); + } + } + + /// + public virtual void Dispose() + { + if (_client.IsConnected) + { + _client.Disconnect(); + } + _client.Dispose(); + } + + private static (string Host, int Port) ParseConnectionString(string connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + var host = uri.Host; + var port = uri.Port > 0 ? uri.Port : 22; + return (host, port); + } + + throw new InvalidOperationException($"The connection string '{connectionString}' is not in the correct format. Expected format: 'sftp://host:port'"); + } +} diff --git a/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs b/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs new file mode 100644 index 000000000..52388b0f1 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace CommunityToolkit.Aspire.Sftp; + +/// +/// Provides the client configuration settings for connecting to an SFTP server using SSH.NET. +/// +public sealed class SftpSettings +{ + /// + /// Gets or sets the connection string in the format "sftp://host:port". + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets the username for SFTP authentication. + /// + public string? Username { get; set; } + + /// + /// Gets or sets the password for SFTP authentication. + /// + public string? Password { get; set; } + + /// + /// Gets or sets the path to a private key file for SFTP authentication. + /// + public string? PrivateKeyFile { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the SFTP health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets the timeout duration for the health check. + /// + /// + /// The default value is . + /// + public TimeSpan? HealthCheckTimeout { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableTracing { get; set; } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs new file mode 100644 index 000000000..d0c129dda --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs @@ -0,0 +1,65 @@ +using CommunityToolkit.Aspire.Testing; +using Polly; +using Projects; +using Renci.SshNet; +using Xunit.Abstractions; + +namespace CommunityToolkit.Aspire.Hosting.Sftp.Tests; + +public class AppHostTests(ITestOutputHelper log, AspireIntegrationTestFixture fix) + : IClassFixture> +{ + [Fact] + public async Task ApiUploadsAndDownloadsTestFile() + { + await fix.ResourceNotificationService.WaitForResourceAsync("api", "Running"); + + using var client = fix.CreateHttpClient("api"); + + var uploadRequest = new HttpRequestMessage(HttpMethod.Post, "upload"); + + var uploadResponse = await client.SendAsync(uploadRequest); + + uploadResponse.EnsureSuccessStatusCode(); + + var downloadRequest = new HttpRequestMessage(HttpMethod.Get, "download"); + + var downloadResponse = await client.SendAsync(downloadRequest); + + downloadResponse.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task ResourcesStartAndClientConnects() + { + await fix.ResourceNotificationService.WaitForResourceAsync("sftp", "Running"); + + var connectionString = await fix.GetConnectionString("sftp"); + + var uri = new Uri(connectionString!); + + var client = new SftpClient(uri.Host, uri.Port, "foo", "pass"); + + try + { + var retryPolicy = Policy + .Handle() + .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(500)); + + await retryPolicy.ExecuteAsync(async () => + { + log.WriteLine($"Connecting to resource 'sftp' using connection string: {connectionString}"); + + await client.ConnectAsync(CancellationToken.None); + }); + } + catch + { + throw; + } + finally + { + client.Disconnect(); + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests.csproj new file mode 100644 index 000000000..123299e06 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests.csproj @@ -0,0 +1,41 @@ + + + + false + true + + + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/ContainerResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/ContainerResourceCreationTests.cs new file mode 100644 index 000000000..7def3f755 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/ContainerResourceCreationTests.cs @@ -0,0 +1,42 @@ +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Sftp.Tests; + +public class ContainerResourceCreationTests +{ + [Fact] + public void AddSftpBuilderShouldNotBeNull() + { + IDistributedApplicationBuilder builder = null!; + Assert.Throws(() => builder.AddSftp("sftp")); + } + + [Fact] + public void AddSftpBuilderNameShouldNotBeNullOrWhiteSpace() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + Assert.Throws(() => builder.AddSftp(null!)); + } + + [Fact] + public void AddSftpBuilderContainerDetailsSetOnResource() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddSftp("sftp"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + Assert.Equal("sftp", resource.Name); + + Assert.True(resource.TryGetLastAnnotation(out ContainerImageAnnotation? imageAnnotations)); + Assert.Equal(SftpContainerImageTags.Tag, imageAnnotations.Tag); + Assert.Equal(SftpContainerImageTags.Image, imageAnnotations.Image); + Assert.Equal(SftpContainerImageTags.Registry, imageAnnotations.Registry); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs new file mode 100644 index 000000000..04b7de80f --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs @@ -0,0 +1,212 @@ +using Aspire.Hosting; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Polly; +using Renci.SshNet; +using Renci.SshNet.Common; +using Xunit.Abstractions; + +namespace CommunityToolkit.Aspire.Hosting.Sftp.Tests; + +public class SftpFunctionalTests : IDisposable +{ + private readonly IDistributedApplicationTestingBuilder builder; + + private IResourceBuilder? resourceBuilder; + + public SftpFunctionalTests(ITestOutputHelper logger) + { + builder = TestDistributedApplicationBuilder.Create(); + + builder.Services.AddLogging(bld => bld.AddXUnit(logger)); + } + + public void Dispose() + { + builder.Dispose(); + } + + private async Task RunTestAsync(Action configure, EventHandler? hostKeyReceived = null) + { + Assert.NotNull(resourceBuilder); + + var app = builder.Build(); + + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + try + { + await rns.WaitForResourceAsync(resourceBuilder.Resource.Name, "Running", new CancellationTokenSource(TimeSpan.FromSeconds(15)).Token); + } + catch + { + ResourceEvent? resourceEvent = null; + + var res = rns.TryGetCurrentState(resourceBuilder.Resource.Name, out resourceEvent); + + throw; + } + + var hostBuilder = Host.CreateApplicationBuilder(); + + hostBuilder.Configuration[$"ConnectionStrings:{resourceBuilder.Resource.Name}"] = await resourceBuilder.Resource.ConnectionStringExpression.GetValueAsync(default); + + configure(hostBuilder); + + var host = hostBuilder.Build(); + + await host.StartAsync(); + + var client = host.Services.GetRequiredService(); + + client.HostKeyReceived += hostKeyReceived; + + try + { + var retryPolicy = Policy + .Handle() + .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(500)); + + await retryPolicy.ExecuteAsync(async () => + { + await client.ConnectAsync(new CancellationTokenSource(TimeSpan.FromMinutes(1)).Token); + }); + } + catch + { + throw; + } + finally + { + client.Disconnect(); + } + } + + [Fact] + public async Task VerifySftpResourceWithArgs() + { + resourceBuilder = builder + .AddSftp("sftp") + .WithArgs($"foo:pass:::uploads"); + + await RunTestAsync(bld => + { + bld.AddSftpClient(resourceBuilder.Resource.Name, cfg => + { + cfg.Username = "foo"; + cfg.Password = "pass"; + }); + }); + } + + [Fact] + public async Task VerifySftpResourceWithUsersEnvironmentVariable() + { + resourceBuilder = builder + .AddSftp("sftp") + .WithEnvironment("SFTP_USERS", "foo:pass:::uploads"); + + await RunTestAsync(bld => + { + bld.AddSftpClient(resourceBuilder.Resource.Name, cfg => + { + cfg.Username = "foo"; + cfg.Password = "pass"; + }); + }); + } + + [Fact] + public async Task VerifySftpResourceWithEncryptedPassword() + { + resourceBuilder = builder + .AddSftp("sftp") + .WithEnvironment("SFTP_USERS", "foo:$5$t9qxNlrcFqVBNnad$U27ZrjbKNjv4JkRWvi6MjX4x6KXNQGr8NTIySOcDgi4:e:::uploads"); + + await RunTestAsync(bld => + { + bld.AddSftpClient(resourceBuilder.Resource.Name, cfg => + { + cfg.Username = "foo"; + cfg.Password = "pass"; + }); + }); + } + + [Fact] + public async Task VerifySftpResourceWithUsersFile() + { + resourceBuilder = builder + .AddSftp("sftp") + .WithUsersFile("users.conf"); + + await RunTestAsync(bld => + { + bld.AddSftpClient(resourceBuilder.Resource.Name, cfg => + { + cfg.Username = "foo"; + cfg.Password = "pass"; + }); + }); + } + + [Fact] + public async Task VerifySftpResourceWithHostKey() + { + resourceBuilder = builder + .AddSftp("sftp") + .WithUsersFile("users.conf") + .WithHostKeyFile("ssh_host_ed25519_key", KeyType.Ed25519); + + await RunTestAsync(bld => + { + bld.AddSftpClient(resourceBuilder.Resource.Name, cfg => + { + cfg.Username = "foo"; + cfg.Password = "pass"; + }); + }, (obj, args) => + { + Assert.Equal("zfOQDzgMTHSJruZIK37h8L8Gfy3XIJmCXYdqW0OXS7s", args.FingerPrintSHA256); + }); + } + + [Fact] + public async Task VerifySftpResourceWithRsaKeys() + { + resourceBuilder = builder + .AddSftp("sftp") + .WithArgs($"foo::::uploads") + .WithUserKeyFile("foo", "id_rsa.pub", KeyType.Rsa); + + await RunTestAsync(bld => + { + bld.AddSftpClient(resourceBuilder.Resource.Name, cfg => + { + cfg.Username = "foo"; + cfg.PrivateKeyFile = "id_rsa"; + }); + }); + } + + [Fact] + public async Task VerifySftpResourceWithEd25519Keys() + { + resourceBuilder = builder + .AddSftp("sftp") + .WithArgs($"foo::::uploads") + .WithUserKeyFile("foo", "id_ed25519.pub", KeyType.Ed25519); + + await RunTestAsync(bld => + { + bld.AddSftpClient(resourceBuilder.Resource.Name, cfg => + { + cfg.Username = "foo"; + cfg.PrivateKeyFile = "id_ed25519"; + }); + }); + } +} diff --git a/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs b/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs new file mode 100644 index 000000000..c4b0dccff --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Renci.SshNet; +using Xunit; + +namespace CommunityToolkit.Aspire.Sftp.Tests; + +public class AspireSftpClientExtensionsTest +{ + private const string DefaultConnectionName = "sftp"; + private const string DefaultConnectionString = "sftp://localhost:22"; + + [Fact] + public void AddSftpClient_RegistersSingleton() + { + var builder = CreateBuilder(DefaultConnectionString); + + builder.AddSftpClient(DefaultConnectionName, settings => + { + settings.Username = "testuser"; + settings.Password = "testpassword"; + settings.DisableHealthChecks = true; + settings.DisableTracing = true; + }); + + using var host = builder.Build(); + + var client = host.Services.GetRequiredService(); + Assert.NotNull(client); + } + + [Fact] + public void AddKeyedSftpClient_RegistersKeyedSingleton() + { + var builder = CreateBuilder(DefaultConnectionString); + + builder.AddKeyedSftpClient(DefaultConnectionName, settings => + { + settings.Username = "testuser"; + settings.Password = "testpassword"; + settings.DisableHealthChecks = true; + settings.DisableTracing = true; + }); + + using var host = builder.Build(); + + var client = host.Services.GetRequiredKeyedService(DefaultConnectionName); + Assert.NotNull(client); + } + + [Fact] + public void CanAddMultipleKeyedServices() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:sftp1", "sftp://localhost:22"), + new KeyValuePair("ConnectionStrings:sftp2", "sftp://localhost:2222"), + new KeyValuePair("ConnectionStrings:sftp3", "sftp://localhost:2223"), + new KeyValuePair("Aspire:Sftp:Client:Username", "user1"), + new KeyValuePair("Aspire:Sftp:Client:Password", "pass1"), + new KeyValuePair("Aspire:Sftp:Client:DisableHealthChecks", "true"), + new KeyValuePair("Aspire:Sftp:Client:DisableTracing", "true"), + new KeyValuePair("Aspire:Sftp:Client:sftp2:Username", "user2"), + new KeyValuePair("Aspire:Sftp:Client:sftp2:Password", "pass2"), + new KeyValuePair("Aspire:Sftp:Client:sftp2:DisableHealthChecks", "true"), + new KeyValuePair("Aspire:Sftp:Client:sftp2:DisableTracing", "true"), + new KeyValuePair("Aspire:Sftp:Client:sftp3:Username", "user3"), + new KeyValuePair("Aspire:Sftp:Client:sftp3:Password", "pass3"), + new KeyValuePair("Aspire:Sftp:Client:sftp3:DisableHealthChecks", "true"), + new KeyValuePair("Aspire:Sftp:Client:sftp3:DisableTracing", "true"), + ]); + + builder.AddSftpClient("sftp1"); + builder.AddKeyedSftpClient("sftp2"); + builder.AddKeyedSftpClient("sftp3"); + + using var host = builder.Build(); + + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("sftp2"); + var client3 = host.Services.GetRequiredKeyedService("sftp3"); + + Assert.NotSame(client1, client2); + Assert.NotSame(client1, client3); + Assert.NotSame(client2, client3); + } + + [Fact] + public void AddSftpClient_ThrowsWhenMissingUsername() + { + var builder = CreateBuilder(DefaultConnectionString); + + builder.AddSftpClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = true; + settings.DisableTracing = true; + }); + + using var host = builder.Build(); + + Assert.Throws(() => host.Services.GetRequiredService()); + } + + [Fact] + public void AddSftpClient_ThrowsWhenMissingPasswordAndPrivateKey() + { + var builder = CreateBuilder(DefaultConnectionString); + + builder.AddSftpClient(DefaultConnectionName, settings => + { + settings.Username = "testuser"; + settings.DisableHealthChecks = true; + settings.DisableTracing = true; + }); + + using var host = builder.Build(); + + Assert.Throws(() => host.Services.GetRequiredService()); + } + + [Fact] + public void AddSftpClient_HealthCheckRegisteredByDefault() + { + var builder = CreateBuilder(DefaultConnectionString); + + builder.AddSftpClient(DefaultConnectionName, settings => + { + settings.Username = "testuser"; + settings.Password = "testpassword"; + }); + + using var host = builder.Build(); + + var healthCheckService = host.Services.GetRequiredService(); + Assert.NotNull(healthCheckService); + } + + private static HostApplicationBuilder CreateBuilder(string connectionString) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair($"ConnectionStrings:{DefaultConnectionName}", connectionString) + ]); + return builder; + } +} diff --git a/tests/CommunityToolkit.Aspire.Sftp.Tests/CommunityToolkit.Aspire.Sftp.Tests.csproj b/tests/CommunityToolkit.Aspire.Sftp.Tests/CommunityToolkit.Aspire.Sftp.Tests.csproj new file mode 100644 index 000000000..240e8e135 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Sftp.Tests/CommunityToolkit.Aspire.Sftp.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + From 8c208ba242669dcb3f002c34e3f7043bf63611c8 Mon Sep 17 00:00:00 2001 From: Fabio Date: Wed, 7 Jan 2026 14:40:46 +0100 Subject: [PATCH 2/8] Fixed issues raised by copilot PR review (hosting) --- Directory.Packages.props | 3 +-- .../README.md | 2 +- .../SftpContainerImageTags.cs | 2 +- .../SftpContainerResource.cs | 19 ++++++++++------- .../SftpHostingExtensions.cs | 6 +++--- .../AppHostTests.cs | 10 +++------ .../SftpFunctionalTests.cs | 21 +++++-------------- 7 files changed, 26 insertions(+), 37 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2ce7ee6b7..2f95b7472 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,7 +1,6 @@ true - @@ -20,6 +19,7 @@ + @@ -117,7 +117,6 @@ - diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/README.md b/src/CommunityToolkit.Aspire.Hosting.Sftp/README.md index 551ec123a..9de140bc7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Sftp/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/README.md @@ -1,4 +1,4 @@ -# CommunityToolkit.Hosting.Sftp +# CommunityToolkit.Aspire.Hosting.Sftp ## Overview diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs index a6880a6ab..1e2c034a9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerImageTags.cs @@ -4,5 +4,5 @@ internal static class SftpContainerImageTags { public const string Registry = "docker.io"; public const string Image = "atmoz/sftp"; - public const string Tag = "latest"; + public const string Tag = "debian"; } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs index 3db0558ec..91066162b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpContainerResource.cs @@ -1,4 +1,6 @@ -namespace Aspire.Hosting.ApplicationModel; +using System; + +namespace Aspire.Hosting.ApplicationModel; /// /// A resource that represents an SFTP container. @@ -8,13 +10,14 @@ public class SftpContainerResource(string name) : ContainerResource(name), IReso { internal const int SftpEndpointPort = 22; internal const string SftpEndpointName = "sftp"; + internal const string SftpEndpointScheme = "sftp"; private EndpointReference? _sftpEndpoint; /// /// Gets the primary endpoint for the SFTP server. /// - private EndpointReference SftpEndpoint => _sftpEndpoint ??= new (this, SftpEndpointName); + private EndpointReference SftpEndpoint => _sftpEndpoint ??= new(this, SftpEndpointName); /// /// Gets the host endpoint reference for this resource. @@ -29,9 +32,7 @@ public class SftpContainerResource(string name) : ContainerResource(name), IReso /// /// ConnectionString for the atmoz SFTP server in the form of sftp://host:port. /// - public ReferenceExpression ConnectionStringExpression => - ReferenceExpression.Create( - $"sftp://{SftpEndpoint.Property(EndpointProperty.Host)}:{SftpEndpoint.Property(EndpointProperty.Port)}"); + public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{SftpEndpoint.Url}"); /// /// Gets the connection URI expression for the atmoz SFTP endpoint. @@ -43,8 +44,12 @@ public class SftpContainerResource(string name) : ContainerResource(name), IReso IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { - yield return new("Host", ReferenceExpression.Create($"{Host}")); - yield return new("Port", ReferenceExpression.Create($"{Port}")); + if (Uri.TryCreate(SftpEndpoint.Url, UriKind.Absolute, out var uri)) + { + yield return new("Host", ReferenceExpression.Create($"{uri.Host}")); + yield return new("Port", ReferenceExpression.Create($"{uri.Port.ToString()}")); + } + yield return new("Uri", UriExpression); } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs index d4dc7f915..09d27023c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/SftpHostingExtensions.cs @@ -31,18 +31,18 @@ public static IResourceBuilder AddSftp(this IDistributedA [ResourceName] string name, int? port = null) { - ArgumentNullException.ThrowIfNull("Service name must be specified.", nameof(name)); + ArgumentNullException.ThrowIfNullOrEmpty(name, nameof(name)); SftpContainerResource resource = new(name); var resourceBuilder = builder.AddResource(resource) .WithImage(SftpContainerImageTags.Image) .WithImageTag(SftpContainerImageTags.Tag) .WithImageRegistry(SftpContainerImageTags.Registry) - .WithEndpoint("sftp", ep => + .WithEndpoint(SftpContainerResource.SftpEndpointName, ep => { ep.Port = port; ep.TargetPort = SftpContainerResource.SftpEndpointPort; - ep.UriScheme = "sftp"; + ep.UriScheme = SftpContainerResource.SftpEndpointScheme; }); return resourceBuilder; diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs index d0c129dda..dbbe9b813 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs @@ -16,13 +16,13 @@ public async Task ApiUploadsAndDownloadsTestFile() using var client = fix.CreateHttpClient("api"); - var uploadRequest = new HttpRequestMessage(HttpMethod.Post, "upload"); + using var uploadRequest = new HttpRequestMessage(HttpMethod.Post, "upload"); var uploadResponse = await client.SendAsync(uploadRequest); uploadResponse.EnsureSuccessStatusCode(); - var downloadRequest = new HttpRequestMessage(HttpMethod.Get, "download"); + using var downloadRequest = new HttpRequestMessage(HttpMethod.Get, "download"); var downloadResponse = await client.SendAsync(downloadRequest); @@ -38,7 +38,7 @@ public async Task ResourcesStartAndClientConnects() var uri = new Uri(connectionString!); - var client = new SftpClient(uri.Host, uri.Port, "foo", "pass"); + using var client = new SftpClient(uri.Host, uri.Port, "foo", "pass"); try { @@ -53,10 +53,6 @@ await retryPolicy.ExecuteAsync(async () => await client.ConnectAsync(CancellationToken.None); }); } - catch - { - throw; - } finally { client.Disconnect(); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs index 04b7de80f..144aeab5e 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs @@ -37,18 +37,9 @@ private async Task RunTestAsync(Action configure, EventH var rns = app.Services.GetRequiredService(); - try - { - await rns.WaitForResourceAsync(resourceBuilder.Resource.Name, "Running", new CancellationTokenSource(TimeSpan.FromSeconds(15)).Token); - } - catch - { - ResourceEvent? resourceEvent = null; + using var runningTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var res = rns.TryGetCurrentState(resourceBuilder.Resource.Name, out resourceEvent); - - throw; - } + await rns.WaitForResourceAsync(resourceBuilder.Resource.Name, "Running", runningTokenSource.Token); var hostBuilder = Host.CreateApplicationBuilder(); @@ -72,13 +63,11 @@ private async Task RunTestAsync(Action configure, EventH await retryPolicy.ExecuteAsync(async () => { - await client.ConnectAsync(new CancellationTokenSource(TimeSpan.FromMinutes(1)).Token); + using var connectTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + await client.ConnectAsync(connectTokenSource.Token); }); } - catch - { - throw; - } finally { client.Disconnect(); From 53cf3c288c38d92bda21df76e159279b51679f35 Mon Sep 17 00:00:00 2001 From: Fabio Date: Wed, 7 Jan 2026 16:15:03 +0100 Subject: [PATCH 3/8] Replaced custom health check with one in package AspNetCore.HealthChecks.Network --- .../AspireSftpExtensions.cs | 81 +++++++++------- .../CommunityToolkit.Aspire.Sftp.csproj | 1 + .../SftpHealthCheck.cs | 94 ------------------- .../SftpSettings.cs | 5 + .../AppHostTests.cs | 17 ++-- .../AspireSftpClientExtensionsTest.cs | 31 ++++-- 6 files changed, 85 insertions(+), 144 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs diff --git a/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs b/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs index af8a245b8..107a62c81 100644 --- a/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs +++ b/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs @@ -3,6 +3,7 @@ using Aspire; using CommunityToolkit.Aspire.Sftp; +using HealthChecks.Network; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -68,6 +69,23 @@ private static void AddSftpClient( configureSettings?.Invoke(settings); + if (string.IsNullOrEmpty(settings.Username)) + { + throw new InvalidOperationException( + $"An SFTP client could not be configured. The '{nameof(settings.Username)}' must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + + if (settings.ConnectionString is null) + { + throw new InvalidOperationException( + $"An SFTP client could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or " + + $"{nameof(settings.ConnectionString)} must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + + var (host, port) = ParseConnectionString(settings.ConnectionString); + if (serviceKey is null) { builder.Services.AddSingleton(ConfigureSftpClient); @@ -79,63 +97,54 @@ private static void AddSftpClient( if (!settings.DisableTracing) { - builder.Services.AddOpenTelemetry() - .WithTracing(traceBuilder => traceBuilder.AddSource("Renci.SshNet")); + builder.Services.AddOpenTelemetry().WithTracing(traceBuilder => traceBuilder.AddSource("Renci.SshNet")); } if (!settings.DisableHealthChecks) { var healthCheckName = serviceKey is null ? "Sftp.Client" : $"Sftp.Client_{connectionName}"; - builder.TryAddHealthCheck(new HealthCheckRegistration( - healthCheckName, - sp => new SftpHealthCheck(settings), - failureStatus: default, - tags: default, - timeout: settings.HealthCheckTimeout)); - } + builder.Services.AddHealthChecks().AddSftpHealthCheck(Setup, healthCheckName, default, default, settings.HealthCheckTimeout); - SftpClient ConfigureSftpClient(IServiceProvider serviceProvider) - { - if (settings.ConnectionString is not null) + void Setup(SftpHealthCheckOptions opt) { - var (host, port) = ParseConnectionString(settings.ConnectionString); + var bld = new SftpConfigurationBuilder(host, port, settings.Username); - if (string.IsNullOrEmpty(settings.Username)) + if (!String.IsNullOrEmpty(settings.Password)) { - throw new InvalidOperationException( - $"An SFTP client could not be configured. The '{nameof(settings.Username)}' must be provided " + - $"in the '{configurationSectionName}' configuration section."); + bld.AddPasswordAuthentication(settings.Password); } - ConnectionInfo connectionInfo; - - if (!string.IsNullOrEmpty(settings.PrivateKeyFile)) - { - var privateKeyFile = new PrivateKeyFile(settings.PrivateKeyFile); - connectionInfo = new ConnectionInfo(host, port, settings.Username, new PrivateKeyAuthenticationMethod(settings.Username, privateKeyFile)); - } - else if (!string.IsNullOrEmpty(settings.Password)) + if (!String.IsNullOrEmpty(settings.PrivateKeyFile)) { - connectionInfo = new ConnectionInfo(host, port, settings.Username, new PasswordAuthenticationMethod(settings.Username, settings.Password)); - } - else - { - throw new InvalidOperationException( - $"An SFTP client could not be configured. Either '{nameof(settings.Password)}' or '{nameof(settings.PrivateKeyFile)}' must be provided " + - $"in the '{configurationSectionName}' configuration section."); + bld.AddPrivateKeyAuthentication(new PrivateKeyFile(settings.PrivateKeyFile, settings.PrivateKeyPassphrase)); } - var client = new SftpClient(connectionInfo); - return client; + opt.AddHost(bld.Build()); + } + } + + SftpClient ConfigureSftpClient(IServiceProvider serviceProvider) + { + ConnectionInfo connectionInfo; + + if (!string.IsNullOrEmpty(settings.PrivateKeyFile)) + { + var privateKeyFile = new PrivateKeyFile(settings.PrivateKeyFile); + connectionInfo = new ConnectionInfo(host, port, settings.Username, new PrivateKeyAuthenticationMethod(settings.Username, privateKeyFile)); + } + else if (!string.IsNullOrEmpty(settings.Password)) + { + connectionInfo = new ConnectionInfo(host, port, settings.Username, new PasswordAuthenticationMethod(settings.Username, settings.Password)); } else { throw new InvalidOperationException( - $"An SFTP client could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or " + - $"{nameof(settings.ConnectionString)} must be provided " + + $"An SFTP client could not be configured. Either '{nameof(settings.Password)}' or '{nameof(settings.PrivateKeyFile)}' must be provided " + $"in the '{configurationSectionName}' configuration section."); } + + return new SftpClient(connectionInfo); } } diff --git a/src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj b/src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj index f40cbb4d8..119e5613a 100644 --- a/src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj +++ b/src/CommunityToolkit.Aspire.Sftp/CommunityToolkit.Aspire.Sftp.csproj @@ -6,6 +6,7 @@ + diff --git a/src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs b/src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs deleted file mode 100644 index f10eed9ff..000000000 --- a/src/CommunityToolkit.Aspire.Sftp/SftpHealthCheck.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Renci.SshNet; - -namespace CommunityToolkit.Aspire.Sftp; - -/// -/// Checks whether a connection can be made to an SFTP server using the supplied connection settings. -/// -public class SftpHealthCheck : IHealthCheck, IDisposable -{ - private readonly SftpClient _client; - - /// - public SftpHealthCheck(SftpSettings settings) - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(settings.ConnectionString); - - var (host, port) = ParseConnectionString(settings.ConnectionString); - - if (string.IsNullOrEmpty(settings.Username)) - { - throw new InvalidOperationException("Username must be provided for SFTP health check."); - } - - ConnectionInfo connectionInfo; - - if (!string.IsNullOrEmpty(settings.PrivateKeyFile)) - { - var privateKeyFile = new PrivateKeyFile(settings.PrivateKeyFile); - connectionInfo = new ConnectionInfo(host, port, settings.Username, new PrivateKeyAuthenticationMethod(settings.Username, privateKeyFile)); - } - else if (!string.IsNullOrEmpty(settings.Password)) - { - connectionInfo = new ConnectionInfo(host, port, settings.Username, new PasswordAuthenticationMethod(settings.Username, settings.Password)); - } - else - { - throw new InvalidOperationException("Either Password or PrivateKeyFile must be provided for SFTP health check."); - } - - _client = new SftpClient(connectionInfo); - } - - /// - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - try - { - if (!_client.IsConnected) - { - _client.Connect(); - } - - var files = _client.ListDirectory("/"); - - if (files.Any()) - { - return HealthCheckResult.Healthy(); - } - - return HealthCheckResult.Healthy(); - } - catch (Exception exception) - { - return new HealthCheckResult(context.Registration.FailureStatus, exception: exception); - } - } - - /// - public virtual void Dispose() - { - if (_client.IsConnected) - { - _client.Disconnect(); - } - _client.Dispose(); - } - - private static (string Host, int Port) ParseConnectionString(string connectionString) - { - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) - { - var host = uri.Host; - var port = uri.Port > 0 ? uri.Port : 22; - return (host, port); - } - - throw new InvalidOperationException($"The connection string '{connectionString}' is not in the correct format. Expected format: 'sftp://host:port'"); - } -} diff --git a/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs b/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs index 52388b0f1..ee50678c1 100644 --- a/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs +++ b/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs @@ -28,6 +28,11 @@ public sealed class SftpSettings /// public string? PrivateKeyFile { get; set; } + /// + /// Gets or sets the path to a private key file for SFTP authentication. + /// + public string PrivateKeyPassphrase { get; set; } = String.Empty; + /// /// Gets or sets a boolean value that indicates whether the SFTP health check is disabled or not. /// diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs index dbbe9b813..241eb8ee7 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs @@ -16,17 +16,20 @@ public async Task ApiUploadsAndDownloadsTestFile() using var client = fix.CreateHttpClient("api"); + using var healthRequest = new HttpRequestMessage(HttpMethod.Get, "health"); using var uploadRequest = new HttpRequestMessage(HttpMethod.Post, "upload"); - - var uploadResponse = await client.SendAsync(uploadRequest); - - uploadResponse.EnsureSuccessStatusCode(); - using var downloadRequest = new HttpRequestMessage(HttpMethod.Get, "download"); - var downloadResponse = await client.SendAsync(downloadRequest); + await RunAsync(healthRequest); + await RunAsync(uploadRequest); + await RunAsync(downloadRequest); - downloadResponse.EnsureSuccessStatusCode(); + async Task RunAsync(HttpRequestMessage req) + { + var res = await client.SendAsync(req); + + res.EnsureSuccessStatusCode(); + } } [Fact] diff --git a/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs b/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs index c4b0dccff..3f6c53fa4 100644 --- a/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs +++ b/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs @@ -2,11 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Renci.SshNet; -using Xunit; namespace CommunityToolkit.Aspire.Sftp.Tests; @@ -95,15 +93,34 @@ public void AddSftpClient_ThrowsWhenMissingUsername() { var builder = CreateBuilder(DefaultConnectionString); - builder.AddSftpClient(DefaultConnectionName, settings => + var ex = Assert.Throws(() => { - settings.DisableHealthChecks = true; - settings.DisableTracing = true; + builder.AddSftpClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = true; + settings.DisableTracing = true; + }); }); - using var host = builder.Build(); + Assert.Contains("Username", ex.Message); + } - Assert.Throws(() => host.Services.GetRequiredService()); + [Fact] + public void AddSftpClient_ThrowsWhenMissingConnectionString() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var ex = Assert.Throws(() => + { + builder.AddSftpClient(DefaultConnectionName, settings => + { + settings.DisableHealthChecks = true; + settings.DisableTracing = true; + settings.Username = "testuser"; + }); + }); + + Assert.Contains("ConnectionString", ex.Message); } [Fact] From fb7922a5774742a32090666d984b4be87bbab41e Mon Sep 17 00:00:00 2001 From: Fabio Date: Thu, 8 Jan 2026 15:53:27 +0100 Subject: [PATCH 4/8] Refactored to use strongly-typed connection infos --- .../Program.cs | 4 ++-- .../AspireSftpExtensions.cs | 23 ++++++++----------- .../SftpFunctionalTests.cs | 4 ++-- .../AspireSftpClientExtensionsTest.cs | 14 ++++++++--- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs index c23757032..f3ff9e053 100644 --- a/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs +++ b/examples/sftp/CommunityToolkit.Aspire.Hosting.Sftp.ApiService/Program.cs @@ -33,7 +33,7 @@ { try { - var tokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var tokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(2)); await client.ConnectAsync(tokenSource.Token); @@ -59,7 +59,7 @@ { try { - var tokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var tokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(2)); await client.ConnectAsync(tokenSource.Token); diff --git a/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs b/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs index 107a62c81..2e02af83f 100644 --- a/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs +++ b/src/CommunityToolkit.Aspire.Sftp/AspireSftpExtensions.cs @@ -1,12 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire; using CommunityToolkit.Aspire.Sftp; using HealthChecks.Network; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; using Renci.SshNet; namespace Microsoft.Extensions.Hosting; @@ -126,25 +124,24 @@ void Setup(SftpHealthCheckOptions opt) SftpClient ConfigureSftpClient(IServiceProvider serviceProvider) { - ConnectionInfo connectionInfo; - if (!string.IsNullOrEmpty(settings.PrivateKeyFile)) { var privateKeyFile = new PrivateKeyFile(settings.PrivateKeyFile); - connectionInfo = new ConnectionInfo(host, port, settings.Username, new PrivateKeyAuthenticationMethod(settings.Username, privateKeyFile)); + + var connectionInfo = new PrivateKeyConnectionInfo(host, port, settings.Username, privateKeyFile); + + return ActivatorUtilities.CreateInstance(serviceProvider, connectionInfo); } else if (!string.IsNullOrEmpty(settings.Password)) { - connectionInfo = new ConnectionInfo(host, port, settings.Username, new PasswordAuthenticationMethod(settings.Username, settings.Password)); - } - else - { - throw new InvalidOperationException( - $"An SFTP client could not be configured. Either '{nameof(settings.Password)}' or '{nameof(settings.PrivateKeyFile)}' must be provided " + - $"in the '{configurationSectionName}' configuration section."); + var connectionInfo = new PasswordConnectionInfo(host, port, settings.Username, settings.Password ?? String.Empty); + + return ActivatorUtilities.CreateInstance(serviceProvider, connectionInfo); } - return new SftpClient(connectionInfo); + throw new InvalidOperationException( + $"An SFTP client could not be configured. Either '{nameof(settings.Password)}' or '{nameof(settings.PrivateKeyFile)}' must be provided " + + $"in the '{configurationSectionName}' configuration section."); } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs index 144aeab5e..71ae7c610 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs @@ -164,7 +164,7 @@ await RunTestAsync(bld => } [Fact] - public async Task VerifySftpResourceWithRsaKeys() + public async Task VerifySftpResourceWithPrivateRsaKey() { resourceBuilder = builder .AddSftp("sftp") @@ -182,7 +182,7 @@ await RunTestAsync(bld => } [Fact] - public async Task VerifySftpResourceWithEd25519Keys() + public async Task VerifySftpResourceWithPrivateEd25519Key() { resourceBuilder = builder .AddSftp("sftp") diff --git a/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs b/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs index 3f6c53fa4..060cbe745 100644 --- a/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs +++ b/tests/CommunityToolkit.Aspire.Sftp.Tests/AspireSftpClientExtensionsTest.cs @@ -14,7 +14,7 @@ public class AspireSftpClientExtensionsTest private const string DefaultConnectionString = "sftp://localhost:22"; [Fact] - public void AddSftpClient_RegistersSingleton() + public void AddSftpClient_RegistersService() { var builder = CreateBuilder(DefaultConnectionString); @@ -33,7 +33,7 @@ public void AddSftpClient_RegistersSingleton() } [Fact] - public void AddKeyedSftpClient_RegistersKeyedSingleton() + public void AddKeyedSftpClient_RegistersKeyedService() { var builder = CreateBuilder(DefaultConnectionString); @@ -55,18 +55,22 @@ public void AddKeyedSftpClient_RegistersKeyedSingleton() public void CanAddMultipleKeyedServices() { var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ new KeyValuePair("ConnectionStrings:sftp1", "sftp://localhost:22"), new KeyValuePair("ConnectionStrings:sftp2", "sftp://localhost:2222"), new KeyValuePair("ConnectionStrings:sftp3", "sftp://localhost:2223"), + new KeyValuePair("Aspire:Sftp:Client:Username", "user1"), new KeyValuePair("Aspire:Sftp:Client:Password", "pass1"), new KeyValuePair("Aspire:Sftp:Client:DisableHealthChecks", "true"), new KeyValuePair("Aspire:Sftp:Client:DisableTracing", "true"), + new KeyValuePair("Aspire:Sftp:Client:sftp2:Username", "user2"), new KeyValuePair("Aspire:Sftp:Client:sftp2:Password", "pass2"), new KeyValuePair("Aspire:Sftp:Client:sftp2:DisableHealthChecks", "true"), new KeyValuePair("Aspire:Sftp:Client:sftp2:DisableTracing", "true"), + new KeyValuePair("Aspire:Sftp:Client:sftp3:Username", "user3"), new KeyValuePair("Aspire:Sftp:Client:sftp3:Password", "pass3"), new KeyValuePair("Aspire:Sftp:Client:sftp3:DisableHealthChecks", "true"), @@ -131,13 +135,17 @@ public void AddSftpClient_ThrowsWhenMissingPasswordAndPrivateKey() builder.AddSftpClient(DefaultConnectionName, settings => { settings.Username = "testuser"; + settings.ConnectionString = "sftp://localhost:22"; settings.DisableHealthChecks = true; settings.DisableTracing = true; }); using var host = builder.Build(); - Assert.Throws(() => host.Services.GetRequiredService()); + var ex = Assert.Throws(() => host.Services.GetRequiredService()); + + Assert.Contains("Password", ex.Message); + Assert.Contains("PrivateKey", ex.Message); } [Fact] From 9c0f808eea221b4b17c6f7285833aa0962496901 Mon Sep 17 00:00:00 2001 From: Fabio Date: Mon, 12 Jan 2026 11:38:51 +0100 Subject: [PATCH 5/8] Various API and test changes following Aaron's review --- ...ommunityToolkit.Aspire.Hosting.Sftp.csproj | 3 --- .../SftpSettings.cs | 4 ++-- .../AppHostTests.cs | 19 +++++++++++++++---- .../SftpFunctionalTests.cs | 16 ++++++++++++++-- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj b/src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj index 3416b2298..1ae033055 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.Sftp/CommunityToolkit.Aspire.Hosting.Sftp.csproj @@ -1,9 +1,6 @@  - net10.0 - enable - enable Aspire hosting integration for the atmoz SFTP container image. atmoz sftp hosting diff --git a/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs b/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs index ee50678c1..bc031b5c3 100644 --- a/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs +++ b/src/CommunityToolkit.Aspire.Sftp/SftpSettings.cs @@ -29,9 +29,9 @@ public sealed class SftpSettings public string? PrivateKeyFile { get; set; } /// - /// Gets or sets the path to a private key file for SFTP authentication. + /// Gets or sets the passphrase for the private key file for SFTP authentication. /// - public string PrivateKeyPassphrase { get; set; } = String.Empty; + public string PrivateKeyPassphrase { get; set; } = string.Empty; /// /// Gets or sets a boolean value that indicates whether the SFTP health check is disabled or not. diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs index 241eb8ee7..736a9ac55 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs @@ -1,3 +1,4 @@ +using Aspire.Components.Common.Tests; using CommunityToolkit.Aspire.Testing; using Polly; using Projects; @@ -6,13 +7,14 @@ namespace CommunityToolkit.Aspire.Hosting.Sftp.Tests; +[RequiresDocker] public class AppHostTests(ITestOutputHelper log, AspireIntegrationTestFixture fix) : IClassFixture> { [Fact] public async Task ApiUploadsAndDownloadsTestFile() { - await fix.ResourceNotificationService.WaitForResourceAsync("api", "Running"); + await fix.ResourceNotificationService.WaitForResourceHealthyAsync("api"); using var client = fix.CreateHttpClient("api"); @@ -28,14 +30,14 @@ async Task RunAsync(HttpRequestMessage req) { var res = await client.SendAsync(req); - res.EnsureSuccessStatusCode(); + Assert.True(res.IsSuccessStatusCode); } } [Fact] public async Task ResourcesStartAndClientConnects() { - await fix.ResourceNotificationService.WaitForResourceAsync("sftp", "Running"); + await fix.ResourceNotificationService.WaitForResourceHealthyAsync("sftp"); var connectionString = await fix.GetConnectionString("sftp"); @@ -53,8 +55,17 @@ await retryPolicy.ExecuteAsync(async () => { log.WriteLine($"Connecting to resource 'sftp' using connection string: {connectionString}"); - await client.ConnectAsync(CancellationToken.None); + using var connectTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + await client.ConnectAsync(connectTokenSource.Token); }); + + Assert.True(client.IsConnected); + + Assert.NotNull(client.ConnectionInfo); + Assert.Equal(uri.Host, client.ConnectionInfo.Host); + Assert.Equal(uri.Port, client.ConnectionInfo.Port); + Assert.True(client.ConnectionInfo.IsAuthenticated); } finally { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs index 71ae7c610..af82dbc6d 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs @@ -5,6 +5,7 @@ using Polly; using Renci.SshNet; using Renci.SshNet.Common; +using System; using Xunit.Abstractions; namespace CommunityToolkit.Aspire.Hosting.Sftp.Tests; @@ -39,11 +40,13 @@ private async Task RunTestAsync(Action configure, EventH using var runningTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - await rns.WaitForResourceAsync(resourceBuilder.Resource.Name, "Running", runningTokenSource.Token); + await rns.WaitForResourceHealthyAsync(resourceBuilder.Resource.Name, runningTokenSource.Token); var hostBuilder = Host.CreateApplicationBuilder(); - hostBuilder.Configuration[$"ConnectionStrings:{resourceBuilder.Resource.Name}"] = await resourceBuilder.Resource.ConnectionStringExpression.GetValueAsync(default); + var connectionString = await resourceBuilder.Resource.ConnectionStringExpression.GetValueAsync(default); + + hostBuilder.Configuration[$"ConnectionStrings:{resourceBuilder.Resource.Name}"] = connectionString; configure(hostBuilder); @@ -57,6 +60,8 @@ private async Task RunTestAsync(Action configure, EventH try { + var uri = new Uri(connectionString!); + var retryPolicy = Policy .Handle() .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(500)); @@ -67,6 +72,13 @@ await retryPolicy.ExecuteAsync(async () => await client.ConnectAsync(connectTokenSource.Token); }); + + Assert.True(client.IsConnected); + + Assert.NotNull(client.ConnectionInfo); + Assert.Equal(uri.Host, client.ConnectionInfo.Host); + Assert.Equal(uri.Port, client.ConnectionInfo.Port); + Assert.True(client.ConnectionInfo.IsAuthenticated); } finally { From aa45b17e4199d1530e95b4e75857c3e27d30d19d Mon Sep 17 00:00:00 2001 From: Fabio Date: Tue, 13 Jan 2026 09:58:49 +0100 Subject: [PATCH 6/8] Updated test list in github actions workflow --- .github/workflows/tests.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 877d77c82..e0119f5e7 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -32,9 +32,8 @@ jobs: Hosting.Flagd.Tests, Hosting.GoFeatureFlag.Tests, Hosting.Golang.Tests, - Hosting.JavaScript.Extensions.Tests, Hosting.Java.Tests, - Hosting.k6.Tests, + Hosting.JavaScript.Extensions.Tests, Hosting.Keycloak.Extensions.Tests, Hosting.KurrentDB.Tests, Hosting.LavinMQ.Tests, @@ -54,12 +53,14 @@ jobs: Hosting.RavenDB.Tests, Hosting.Redis.Extensions.Tests, Hosting.Rust.Tests, + Hosting.Sftp.Tests, Hosting.Solr.Tests, Hosting.SqlDatabaseProjects.Tests, - Hosting.Sqlite.Tests, Hosting.SqlServer.Extensions.Tests, + Hosting.Sqlite.Tests, Hosting.Stripe.Tests, Hosting.SurrealDb.Tests, + Hosting.k6.Tests, # Client integration tests GoFeatureFlag.Tests, @@ -71,8 +72,9 @@ jobs: Minio.Client.Tests, OllamaSharp.Tests, RavenDB.Client.Tests, - SurrealDb.Tests, - ] + Sftp.Tests, + SurrealDb.Tests + ] steps: - name: Checkout code From 4a7f802b6fb3f7c27e72e30e26cc1856b66e43e4 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 13 Jan 2026 09:34:10 +0000 Subject: [PATCH 7/8] stuffed up the merge --- .github/workflows/tests.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 010160208..1c0c5dd24 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -33,8 +33,8 @@ jobs: Hosting.Flagd.Tests, Hosting.GoFeatureFlag.Tests, Hosting.Golang.Tests, - Hosting.Java.Tests, Hosting.JavaScript.Extensions.Tests, + Hosting.Java.Tests, Hosting.k6.Tests, Hosting.Keycloak.Extensions.Tests, Hosting.KurrentDB.Tests, @@ -58,11 +58,10 @@ jobs: Hosting.Sftp.Tests, Hosting.Solr.Tests, Hosting.SqlDatabaseProjects.Tests, - Hosting.SqlServer.Extensions.Tests, Hosting.Sqlite.Tests, + Hosting.SqlServer.Extensions.Tests, Hosting.Stripe.Tests, Hosting.SurrealDb.Tests, - Hosting.k6.Tests, # Client integration tests GoFeatureFlag.Tests, @@ -75,7 +74,7 @@ jobs: OllamaSharp.Tests, RavenDB.Client.Tests, Sftp.Tests, - SurrealDb.Tests + SurrealDb.Tests, ] steps: From 8f20b941fc2d7235496a78cb1f9dd35e9a0cceff Mon Sep 17 00:00:00 2001 From: Fabio Date: Tue, 13 Jan 2026 14:40:56 +0100 Subject: [PATCH 8/8] Increased retry count and interval for all hosting tests --- .../AppHostTests.cs | 2 +- .../SftpFunctionalTests.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs index 736a9ac55..7d9f08f99 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/AppHostTests.cs @@ -49,7 +49,7 @@ public async Task ResourcesStartAndClientConnects() { var retryPolicy = Policy .Handle() - .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(500)); + .WaitAndRetryAsync(5, _ => TimeSpan.FromMilliseconds(1000)); await retryPolicy.ExecuteAsync(async () => { diff --git a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs index af82dbc6d..c9aa4fc1b 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Sftp.Tests/SftpFunctionalTests.cs @@ -1,3 +1,4 @@ +using Aspire.Components.Common.Tests; using Aspire.Hosting; using Aspire.Hosting.Utils; using Microsoft.Extensions.Hosting; @@ -5,11 +6,11 @@ using Polly; using Renci.SshNet; using Renci.SshNet.Common; -using System; using Xunit.Abstractions; namespace CommunityToolkit.Aspire.Hosting.Sftp.Tests; +[RequiresDocker] public class SftpFunctionalTests : IDisposable { private readonly IDistributedApplicationTestingBuilder builder; @@ -64,7 +65,7 @@ private async Task RunTestAsync(Action configure, EventH var retryPolicy = Policy .Handle() - .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(500)); + .WaitAndRetryAsync(5, _ => TimeSpan.FromMilliseconds(1000)); await retryPolicy.ExecuteAsync(async () => {