diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4215c5d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,48 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": ".NET Sample. Local: Run .NET Sample Locally", + "detail": "Run a .NET sample application locally. Requires .NET SDK 10 to be installed.", + "type": "shell", + "command": "dotnet", + "args": ["run", "sample.cs"], + "options": { + "cwd": "${workspaceFolder}/dotnet/src" + }, + "group": { + "kind": "build" + } + }, + { + "label": ".NET Sample. Docker: Build Docker Image", + "detail": "Build a Docker image for the .NET sample application. Requires Docker to be installed.", + "type": "docker-build", + "dockerBuild": { + "context": "${workspaceFolder}/dotnet/src", + "dockerfile": "${workspaceFolder}/dotnet/Dockerfile", + "tag": "dotnet-s3-example:latest" + } + }, + { + "label": ".NET Sample. Docker: Build an Image and Run Container", + "detail": "Run a .NET sample application in a Docker container. Requires Docker to be installed.", + "type": "process", + "command": "docker", + "args": [ + "run", + "--rm", + "-it", + "--name", + "dotnet-s3-example", + "--env-file", + "${workspaceFolder}/dotnet/src/.env", + "dotnet-s3-example:latest" + ], + "dependsOn": ".NET Sample. Docker: Build Docker Image", + "group": { + "kind": "build" + } + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index cd06801..16d24c8 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,4 @@ - [Node.js](https://github.com/timeweb-cloud/s3-examples/tree/master/nodejs) - [Python](https://github.com/timeweb-cloud/s3-examples/tree/master/python3) - [PHP](https://github.com/timeweb-cloud/s3-examples/tree/master/php) +- [.NET](./dotnet) diff --git a/dotnet/.dockerignore b/dotnet/.dockerignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/dotnet/.dockerignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/dotnet/.gitignore b/dotnet/.gitignore new file mode 100644 index 0000000..08e0b17 --- /dev/null +++ b/dotnet/.gitignore @@ -0,0 +1,57 @@ +## A streamlined .gitignore for modern .NET projects +## including temporary files, build results, and +## files generated by popular .NET tools. If you are +## developing with Visual Studio, the VS .gitignore +## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +## has more thorough IDE-specific entries. +## +## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore + +# Environment files +.env + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg + +# Others +~$* +*~ +CodeCoverage/ + +# MSBuild Binary and Structured Log +*.binlog + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml \ No newline at end of file diff --git a/dotnet/Dockerfile b/dotnet/Dockerfile new file mode 100644 index 0000000..56ecd0b --- /dev/null +++ b/dotnet/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: Convert to regular project and build the app +FROM mcr.microsoft.com/dotnet/sdk:10.0-preview-alpine AS build +WORKDIR /src + +# convert the sample to a regular project to reduce image size +COPY sample.cs . +RUN dotnet project convert sample.cs + +RUN dotnet publish sample/ \ + -c Release + +# Stage 2: Use ~<100mb image and run the app +FROM mcr.microsoft.com/dotnet/runtime:10.0-preview-alpine AS runtime +WORKDIR /app + +COPY --from=build /src/sample/bin/Release/net10.0/publish . + +ENTRYPOINT ["./sample"] \ No newline at end of file diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..e18594d --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,74 @@ +# Использование dotnet-sdk Для Работы с S3 Хранилищем Timeweb Cloud +## Описание +> [!WARNING] +> Версия NuGet пакета `AWS.SDK` в примере `<=4.0.2`, однако, на Июнь 2025 года она не работает полноценно с методами `PutObject` (для работы `PutObject` необходимо либо установить флаг `DisableDefaultChecksumValidation` в `true` во время создания запроса `PutObjectRequest`, либо использовать версию SDK `<=3.5.10.2`), поскольку Timeweb S3 пока что не поддерживает самые последние версии .NET клиентов. Предположительно, это связано с обновлением не самого S3 API, который так и остался в версии `2006-03-01`, но с новыми требованиями к сети (HTTP/2), валидацией checksum (DefaultChecksumValidation), `nullable` поля и др. + +Приложение показывает использование S3 хранилища Timeweb Cloud в `dotnet`, при запуске будут выполнены следующие команды: +1. Создание бакета (если уже существует - вернет детали существующего бакета) +1. Регион бакета +1. Список бакетов +1. Отправка текста в файл +1. Отправка файла +1. Получение списка файловы +1. Скачивание файла +1. Получение метаданных файла +1. Копирование файла +1. Присвоение тега файлу +1. Получение информации о тегах в файле +1. Создание правил жизненного цикла файлов (правила по тегу, по названию папок) +1. Получение правил жизненного цикла файлов +1. Удаление всех тегов файла +1. Удаление правил жизненного цикла файлов +1. Удаление всех файлов +1. Удаление бакета + +> [!CAUTION] +> Во время выполнения этой программы будет выполнено удаление бакета и всех файлов в нем! Будьте аккуратны когда указываете имя существующего бакета в `.env` + +При успешном завершении операции будет выведен ответ от S3 API в формате JSON, при неудаче - детали ошибки. Если в качестве ответа приходит файл, будет выведено его содержимое (поддерживаются только текстовые файлы). + +В качестве клиента к S3 хранилищем Timeweb Cloud используется официальный SDK от Amazon (`AWS.SDK`) версии `4.0.2`, ендпоинт Timeweb Cloud S3 API `https://s3.twcstorage.ru` +В [sample.cs](./src/sample.cs) описаны основные ендпоинты для работы с API, для запуска потребуется переименовать [.env.example](./src/.env.example) в `.env` и вписать настоящие данные для подключения к S3 хранилищу Timeweb Cloud, которые можно найти во вкладке `Дашборд` в разделе `Хранилище S3` в личном кабинете Timeweb Cloud. + +Больше информации об объектном S3 хранилище Timeweb Cloud в [документации](https://timeweb.cloud/docs/s3-storage). + +## Требования +- Linux/Windows/macOS +- dotnet 10/Docker +### Запуск через CLI +Для локального запуска требуется установка [dotnet SDK версии >=10](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) и заполненный `.env` файл. + +После установки достаточно выполнить эту команду в любой консоли (убедитесь что выполняете команду из папки ./dotnet/src): +```console +dotnet run sample.cs +``` + +### Запуск через Docker +Для запуска и сборки через Docker требуется установка [Docker](https://www.docker.com/) и заполненный `.env` файл. + +В проекте присутствует [Dockerfile](./Dockerfile) с двумя стейджами для уменьшения размера docker image: + +**Stage 1**: Использует dotnet 10.0 SDK и конвертирует файл [sample.cs](./src/sample.cs) в классический формат проекта dotnet чтобы иметь возможность собрать исполняемый файл (пока что для direct file запуска требуется SDK, он довольно тяжеловесный). Из-за требований S3 библиотеки к функциям рефлексии, ради экономии времени было решено не идти по пути AOT (размер докер образа можно было бы уменьшить до ~<10mb) +**Stage 2**: Копирует компилированные файлы в исполняемый образ и создает `ENTRYPOINT` + +Для сборки проекта достаточно выполнить следующую CLI команду, находясь в папке `./dotnet`: +```console +docker build -t dotnet-s3-example:latest ./src +``` + +Для запуска приложения после сборки: +```console +docker run --rm -it --name dotnet-s3-example --env-file ./src/.env dotnet-s3-example:latest +``` + +### Запуск через Visual Studio Code +> [!NOTE] +> Так как приложение писалось когда .NET 10 был еще в Preview 4, запуск через F5 с дебаггером на тот момент не был реализован для direct file приложений, поэтому опция с дебагом отсутствует так же как и файл launch.json + +Для Visual Studio Code поддерживаются 2 варианта запуска: +1. [Локальный запуск](#запуск-через-cli) +2. [Запуск через Docker](#запуск-через-docker) + +Так как это просто шорткаты для двух описанных выше вариантов запуска, при выборе определенного варианта все требования необходимо удовлетворить. + +Для удобства в проекте создан файл `tasks.json` благодаря которому программу можно запустить через сочетание клавиш `CTRL + SHIFT + B` для Windows и `CMD + SHIFT + B` для MacOS -> `Run .NET Sample` \ No newline at end of file diff --git a/dotnet/src/.env.example b/dotnet/src/.env.example new file mode 100644 index 0000000..a59fbde --- /dev/null +++ b/dotnet/src/.env.example @@ -0,0 +1,11 @@ +# Данные для подключения к S3 хранилищу Timeweb Cloud +# можно найти во вкладке `Дашборд` в разделе `Хранилище S3` +# в личном кабинете Timeweb Cloud. + +# Ковычки в значениях использовать не следует, так как они плохо распознаются командой docker run. +S3__SERVICEURL=https://s3.twcstorage.ru +S3__REGION=ru-1 +# Имя нового или существующего бакета, лучше всего работает UUID +S3__BUCKETNAME= +S3__ACCESSKEY= +S3__SECRETKEY= \ No newline at end of file diff --git a/dotnet/src/sample.cs b/dotnet/src/sample.cs new file mode 100644 index 0000000..7e59f86 --- /dev/null +++ b/dotnet/src/sample.cs @@ -0,0 +1,327 @@ +#:package AWSSDK.S3@4.0.2 +#:package Microsoft.Extensions.Configuration.EnvironmentVariables@9.0.5 +#:package Microsoft.Extensions.Configuration.Binder@9.0.5 +#:package DotNetEnv@3.1.1 +#:package Spectre.Console.Json@0.50.0 + +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using DotNetEnv; +using Microsoft.Extensions.Configuration; +using Spectre.Console; +using Spectre.Console.Json; +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +var settings = LoadS3Settings(); +var s3Client = CreateS3Client(settings); +Console.WriteLine("S3 Клиент успешно создан и готов к работе."); + +Console.WriteLine($"Создание бакета {settings.BucketName}."); +await TryExecute( + s3Client.PutBucketAsync(new PutBucketRequest { BucketName = settings.BucketName })); + +Console.WriteLine($"Получение информации о регионе бакета {settings.BucketName}."); +await TryExecute( + s3Client.GetBucketLocationAsync(new GetBucketLocationRequest { BucketName = settings.BucketName })); + +Console.WriteLine($"Получение списка бакетов."); +await TryExecute( + s3Client.ListBucketsAsync()); + +Console.WriteLine($"Отправка нового текстового файла через передачу текста в ContentBody."); +await TryExecute( + s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = settings.BucketName, + Key = "example-from-text.txt", + ContentBody = "Привет, это файл созданный из текста!", + // флаг для обратной совместимостью с Timeweb S3, убрать когда Timeweb S3 актуализируется + DisableDefaultChecksumValidation = true + })); + +Console.WriteLine($"Отправка нового текстового файла."); +// Имитация чтения файла через MemoryStream вместо File.OpenRead +await using var file = new MemoryStream(Encoding.UTF8.GetBytes("Привет, это файл переданный напрямую!")); +await TryExecute( + s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = settings.BucketName, + Key = "example-from-file.txt", + InputStream = file, + // флаг для обратной совместимостью с Timeweb S3, убрать когда Timeweb S3 актуализируется + DisableDefaultChecksumValidation = true + })); + +Console.WriteLine($"Получение списка объектов в бакете {settings.BucketName}."); +await TryExecute( + s3Client.ListObjectsV2Async(new ListObjectsV2Request { BucketName = settings.BucketName })); + +Console.WriteLine($"Получение объекта example-from-text.txt."); +await TryRetrieve( + s3Client.GetObjectAsync(new GetObjectRequest + { + BucketName = settings.BucketName, + Key = "example-from-text.txt" + })); + +Console.WriteLine($"Получение метаданных объекта example-from-text.txt."); +await TryExecute( + s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest + { + BucketName = settings.BucketName, + Key = "example-from-text.txt" + })); + +Console.WriteLine($"Копирование объекта example-from-text.txt."); +await TryExecute( + s3Client.CopyObjectAsync(new CopyObjectRequest + { + SourceBucket = settings.BucketName, + SourceKey = "example-from-text.txt", + DestinationBucket = settings.BucketName, + DestinationKey = "example-from-text-copy.txt" + })); + +Console.WriteLine("Присвоение тега `deleted=true` файлу `example-from-text-copy.txt`"); +await TryExecute( + s3Client.PutObjectTaggingAsync(new PutObjectTaggingRequest + { + BucketName = settings.BucketName, + Tagging = new Tagging { TagSet = [new Tag { Key = "deleted", Value = "true" }] }, + Key = "example-from-text-copy.txt" + }) +); + +Console.WriteLine("Получение информации о тегах присвоенных файлу `example-from-text-copy.txt`"); +await TryExecute( + s3Client.GetObjectTaggingAsync(new GetObjectTaggingRequest + { + BucketName = settings.BucketName, + Key = "example-from-text-copy.txt" + }) +); + +Console.WriteLine("Создание правил жизненного цикла файлов: " + + "правила по тегу `deleted=true`, по названию папок `deleted/`"); +LifecycleRule[] rules = +[ + new() + { + Id = "DeletedTagRetention", + Status = LifecycleRuleStatus.Enabled, + Filter = new LifecycleFilter + { + LifecycleFilterPredicate = new LifecycleTagPredicate + { + Tag = new Tag { Key = "deleted", Value = "true" } + } + }, + Expiration = new LifecycleRuleExpiration { Days = 1 } + }, + new() + { + Id = "DeletedFolderRetention", + Status = LifecycleRuleStatus.Enabled, + Filter = new LifecycleFilter + { + LifecycleFilterPredicate = new LifecyclePrefixPredicate { Prefix = "deleted/" } + }, + Expiration = new LifecycleRuleExpiration { Days = 1 } + } +]; +await TryExecute( + s3Client.PutLifecycleConfigurationAsync(new PutLifecycleConfigurationRequest + { + BucketName = settings.BucketName, + Configuration = new LifecycleConfiguration + { + Rules = [.. rules] + } + }) +); + +Console.WriteLine("Получение правил жизненного цикла файлов"); +await TryExecute( + s3Client.GetLifecycleConfigurationAsync(new GetLifecycleConfigurationRequest + { + BucketName = settings.BucketName + }) +); + +Console.WriteLine("Удаление всех тегов присвоенных файлу `example-from-text-copy.txt`"); +await TryExecute( + s3Client.DeleteObjectTaggingAsync(new DeleteObjectTaggingRequest + { + BucketName = settings.BucketName, + Key = "example-from-text-copy.txt" + }) +); + +Console.WriteLine("Удаление всех правил жизненного цикла файлов"); +await TryExecute( + s3Client.DeleteLifecycleConfigurationAsync(new DeleteLifecycleConfigurationRequest + { + BucketName = settings.BucketName + }) +); + +Console.WriteLine($"Удаление всех объектов."); +await TryExecute( + s3Client.DeleteObjectsAsync(new DeleteObjectsRequest + { + BucketName = settings.BucketName, + Objects = new List + { + new KeyVersion { Key = "example-from-text.txt" }, + new KeyVersion { Key = "example-from-file.txt" }, + new KeyVersion { Key = "example-from-text-copy.txt" } + } + })); + +Console.WriteLine($"Удаление бакета {settings.BucketName}."); +await TryExecute( + s3Client.DeleteBucketAsync(new DeleteBucketRequest { BucketName = settings.BucketName })); + +static AmazonS3Client CreateS3Client(S3Settings s3Settings) +{ + var config = new AmazonS3Config + { + ServiceURL = s3Settings.ServiceUrl, + ForcePathStyle = true, + AuthenticationRegion = s3Settings.Region + }; + var credentials = new BasicAWSCredentials(s3Settings.AccessKey, s3Settings.SecretKey); + var client = new AmazonS3Client(credentials, config); + return client; +} + +static S3Settings LoadS3Settings() +{ + Env.Load(); + + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .Build(); + + var settings = configuration.GetSection("S3").Get(); + + if (settings == null) + throw new ArgumentNullException(nameof(settings), "Настройки S3 не могут быть пустыми."); + if (!settings.IsValid()) + throw new ArgumentException("Все поля в настройках S3 должны быть заполнены, включая AccessKey, SecretKey, BucketName и ServiceUrl."); + + return settings; +} + +#region Рендеринг и обработка ошибок +static async Task TryExecute(Task action) where T : AmazonWebServiceResponse +{ + try + { + var response = await action; + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + //твик чтобы показать информацию про правила жизненного цикла объектов + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + ti => + { + if (ti.Type == typeof(LifecycleFilterPredicate)) + { + ti.PolymorphismOptions = new JsonPolymorphismOptions + { + DerivedTypes = + { + new JsonDerivedType(typeof(LifecycleTagPredicate), "tagPredicate"), + new JsonDerivedType(typeof(LifecyclePrefixPredicate), "prefixPredicate") + } + }; + } + } + } + } + }); + Console.WriteLine($"Операция выполнена успешно. Результат: \n"); + // JSON стайлинг в формате Visual Studio Code + AnsiConsole.Write( + new Panel( + new JsonText(json) + .BracesColor(Color.Grey84) + .BracketColor(Color.Grey84) + .ColonColor(Color.Grey84) + .CommaColor(Color.Grey84) + .StringColor(Color.LightSalmon3_1) + .NumberColor(Color.DarkSeaGreen3_1) + .BooleanColor(Color.SkyBlue3) + .NullColor(Color.SkyBlue3) + .MemberColor(Color.SteelBlue1_1) + ) + .Header("JSON") + .Expand() + .Padding(2, 1) + .RoundedBorder() + .BorderColor(Color.Yellow)); + } + catch (Exception ex) + { + Console.WriteLine($"Ошибка: {ex.Message}\n{ex.StackTrace}\nВложенная ошибка: {ex.InnerException?.Message}\n{ex.InnerException?.StackTrace}"); + } +} +static async Task TryRetrieve(Task action) where T : StreamResponse +{ + try + { + var response = await action; + using var reader = new StreamReader(response.ResponseStream); + // Исключительно для текстовых файлов + var text = await reader.ReadToEndAsync(); + Console.WriteLine("Загрузка выполнена успешно. Содержимое объекта: \n"); + AnsiConsole.Write( + new Panel(new Padder(new Text(text)).PadBottom(1).PadTop(1)) + .Header("Объект S3") + .Expand() + .RoundedBorder() + .BorderColor(Color.Yellow)); + } + catch (Exception ex) + { + Console.WriteLine($"Ошибка: {ex.Message}\n{ex.StackTrace}\nВложенная ошибка: {ex.InnerException?.Message}\n{ex.InnerException?.StackTrace}"); + } +} +#endregion + +/// +/// Класс для хранения настроек подключения к Amazon S3. +/// Используется для конфигурации клиента S3. +/// +/// Заполняется автоматически из переменных окружения (файл .env) +/// +/// Данные для подключения к S3 хранилищу Timeweb Cloud +/// можно найти во вкладке `Дашборд` в разделе `Хранилище S3` +/// в личном кабинете Timeweb Cloud. +/// +class S3Settings +{ + public required string ServiceUrl { get; set; } + public required string AccessKey { get; set; } + public required string SecretKey { get; set; } + public required string BucketName { get; set; } + public required string Region { get; set; } + + public bool IsValid() + => !string.IsNullOrEmpty(AccessKey) && + !string.IsNullOrEmpty(SecretKey) && + !string.IsNullOrEmpty(ServiceUrl) && + !string.IsNullOrEmpty(BucketName) && + !string.IsNullOrEmpty(Region); +} \ No newline at end of file