From f0bb22d8bf21f68f1bfce02cfeb70a75f44e362f Mon Sep 17 00:00:00 2001 From: Heikki-Jussi Niemi Date: Sun, 23 Jun 2024 00:22:54 +0300 Subject: [PATCH 1/3] Add better CI support and documentation --- .env-sample | 4 ++++ Create-Environment.ps1 | 9 ++++++-- Create-Settings.ps1 | 42 ++++++++++++++++++++++++++++++++++ doc/technical-reasoning.md | 47 ++++++++++++++++++++++++++++++++++++++ infra/functions.bicep | 7 ++++++ infra/main.bicep | 4 ++++ 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 Create-Settings.ps1 create mode 100644 doc/technical-reasoning.md diff --git a/.env-sample b/.env-sample index fd4954a..f81512c 100644 --- a/.env-sample +++ b/.env-sample @@ -1,3 +1,7 @@ +# This is just a sample file. You can use `Create-Settings.ps1` to generate +# these files automatically. However, if the script doesn't work, use this +# as the base .env file for `docker-compose.yml` +# This file is NOT needed if docker is not used. DISCORD_TOKEN= DISCORD_GUILDID= DISCORD_CHANNELID= diff --git a/Create-Environment.ps1 b/Create-Environment.ps1 index f5c7f9a..8207de4 100644 --- a/Create-Environment.ps1 +++ b/Create-Environment.ps1 @@ -7,9 +7,13 @@ .PARAMETER SettinsFile Settings file that contains environment settings. Defaults to 'developer-settings.json' + + .PARAMETER NoDiscord + If set, discord sending is disabled. Useful for CI/CD and testing. #> param( - [Parameter()][string]$SettingsFile = 'developer-settings.json' + [Parameter()][string]$SettingsFile = 'developer-settings.json', + [Parameter()][switch]$NoDiscord ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest @@ -20,7 +24,7 @@ Write-Host "Reading settings from file $SettingsFile" $settingsJson = Get-Content -Raw -Path $SettingsFile | ConvertFrom-Json Write-Host 'Checking if there is an existing installation...' -$webSitePackageLocation = Get-AzWebApp -ResourceGroupName $settingsJson.ResourceGroup -Name "func-$($settingsJson.ApplicationName)" -ErrorAction SilentlyContinue | Get-WebSitePackage +$webSitePackageLocation = Get-AzWebApp -ResourceGroupName $settingsJson.ResourceGroup -Name "func-$($settingsJson.ApplicationName)" -ErrorAction SilentlyContinue | Get-WebSitePackage -ErrorAction SilentlyContinue if ($webSitePackageLocation) { Write-Host "Function app already exist with website package, using it..." } @@ -35,6 +39,7 @@ Write-Host 'Deploying template...' $parameters = @{ baseName = $settingsJson.ApplicationName webSitePackageLocation = $webSitePackageLocation + disableDiscordSending = $NoDiscord ? $true : $false discordSettings = @{ token = $settingsJson.DiscordToken channelId = $settingsJson.DiscordChannelId diff --git a/Create-Settings.ps1 b/Create-Settings.ps1 new file mode 100644 index 0000000..0506d5f --- /dev/null +++ b/Create-Settings.ps1 @@ -0,0 +1,42 @@ +<# + .SYNOPSIS + This script generates envvironmetn variable files based on developer-settings.json + + .DESCRIPTION + Purpose of this is to make starting the development more convenient by generating environment files. + + .PARAMETER SettinsFile + Settings file that contains environment settings. Defaults to 'developer-settings.json' +#> +param( + [Parameter()][string]$SettingsFile = 'developer-settings.json' +) +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +.$PSScriptRoot/scripts/FunctionUtil.ps1 + +Write-Host "Reading settings from file $SettingsFile" +$settingsJson = Get-Content -Raw -Path $SettingsFile | ConvertFrom-Json + +# Docker compose support +$dockerEnvFile = "$PSScriptRoot/.env" +$dockerEnvFileContent = " +# This file was generated by Create-Settings.ps1. Don't commit this to version control. +DISCORD_TOKEN=$($settingsJson.DiscordToken) +DISCORD_GUILDID=$($settingsJson.DiscordGuildId) +DISCORD_CHANNELID=$($settingsJson.DiscordChannelId) +" +Write-Host "Writing $dockerEnvFile" +$dockerEnvFileContent | Out-File -FilePath $dockerEnvFile -Encoding utf8 + +# Function Core Tools support +$funcSettingsFile = "$PSScriptRoot/src/FunctionApp.Isolated/local.settings.json" +$localSettings = @{ + IsEncrypted = $false + Values = @{ + AzureWebJobsStorage = 'UseDevelopmentStorage=true' + FUNCTIONS_WORKER_RUNTIME = 'dotnet-isolated' + } +} +$localSettings | ConvertTo-Json | Out-File -FilePath $funcSettingsFile -Encoding utf8 diff --git a/doc/technical-reasoning.md b/doc/technical-reasoning.md new file mode 100644 index 0000000..862fab6 --- /dev/null +++ b/doc/technical-reasoning.md @@ -0,0 +1,47 @@ +# Technical reasoning + +This document describes reasoning behind different architectural and technical +decisions related to this project. Purpose of this is help with future +refactoring because the technical landscape and the available Azure services +may have changed from the time these decisions where made. This should also +help choosing new implementations I have forgot to think about some aspect +or I haven't known about some available tool etc. + +## Requirements + +This sections lists overall requirements. + + 1. Main function of the software (Image sending) is atomatically activated + periodically. + 2. Software needs to be able to handle HTTP requests from Discord and from + users. These should be relatively rare. + * Discord MAY add a response time requirement. + 1. Software should prefer Managed Identity authentication. + 1. Software should be cheap to run and it shouldn't cost much when it's not + active. + 1. Secrets should be stored securely. + 1. Local development with containers should be supported. + 1. Infrastructure creation must be automatic with a single command. + +## Technical selection + +Azure Function App with dotnet-isolated runtime was chosen. + + * Azure + * Can handle all of the requirements, but mainly because I'm most familiar + with this cloud service provider. + * Azure Function App + * Should be cheap to run. + * Supports HTTP requests + * Supports Managed Identity + * Can be activated periodically (`TimerTrigger`) + * Supports containers for local development. + * Isolated worker model + * Chosen instead of In-process model, because In-Process support is ending. + * Consumption mode for Function App + * Should be the cheapest option for this kind of workload. + * App Service plan + * Chosen instead of Container Apps, because the development experiment with + container app was still quite poor. + * Azure Key Vault + * Is a secure way to store secrets and integrates well with function app. diff --git a/infra/functions.bicep b/infra/functions.bicep index 91129f1..e203e6a 100644 --- a/infra/functions.bicep +++ b/infra/functions.bicep @@ -18,6 +18,9 @@ param location string = resourceGroup().location @description('Web site package location. Leave empty if none is found.') param webSitePackageLocation string = '' +@description('If true, messages are not sent to Discord. This should only be used when testing.') +param disableDiscordSending bool = false + var hostingPlanName = 'asp-${baseName}' var functionAppName = 'func-${baseName}' var storageBlobDataOwnerRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' @@ -112,6 +115,10 @@ resource functionApp 'Microsoft.Web/sites@2021-02-01' = { name: 'APPINSIGHTS_INSTRUMENTATIONKEY' value: reference(applicationInsights.id, '2015-05-01').InstrumentationKey } + { + name: 'FeatureSettings__DisableDiscordSending' + value: '${disableDiscordSending}' + } { name: '${discordSettingsKey}__Token' value: discordSettings.token diff --git a/infra/main.bicep b/infra/main.bicep index 66de37b..eef9776 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -12,6 +12,9 @@ param location string = resourceGroup().location @description('Web site package location. Leave empty if none is found.') param webSitePackageLocation string = '' +@description('If true, messages are not sent to Discord. This should only be used when testing.') +param disableDiscordSending bool = false + module appInsights 'app-insights.bicep' = { name: 'application-insights' params: { @@ -54,5 +57,6 @@ module functions 'functions.bicep' = { discordSettings: discordSettings imageStorageSettings: imageSettings webSitePackageLocation: webSitePackageLocation + disableDiscordSending: disableDiscordSending } } From 7a3a0d077a68eae64d90b2c27bdd0cd1e0668a24 Mon Sep 17 00:00:00 2001 From: Heikki-Jussi Niemi Date: Sun, 23 Jun 2024 01:03:11 +0300 Subject: [PATCH 2/3] Start to migrate to managed identity authentication --- README.md | 34 +++++++++++++++---- infra/functions.bicep | 15 ++++---- infra/main.bicep | 29 +++++++++++++++- infra/types.bicep | 2 +- .../BlobStorageImageSourceOptions.cs | 12 +++++-- src/FunctionApp.Isolated/Program.cs | 7 ++++ 6 files changed, 81 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 28a61fe..81cb386 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,41 @@ Currently this is designed to work with one server and one channel. ## How does it work? -In short, software periodically selects random image from image storage and sends it to discord channel. +In short, software periodically selects random image from image storage and +sends it to discord channel. In depth 1. Image sending is periodically triggered by Function App TimerTrigger. - 1. When Image sending is triggered, software selects a random image from index. Selection is not really random, it prefers less posted images. See [RandomizationService](src/Common/RandomizationService/RandomizationService.cs) for implementation details. - * If there is no index, software builds an index of images available in source. - 1. After image is selected, software downloads the image from storage and sends the image to the chosen Discord channel. - * If Imageis removed, error is logged and image is not sent to channel. + 1. When Image sending is triggered, software selects a random image from + index. Selection is not really random, it prefers less posted images. See + [RandomizationService](src/Common/RandomizationService/RandomizationService.cs) + for implementation details. + * If there is no index, software builds an index of images available in + source. + 1. After image is selected, software downloads the image from storage and + sends the image to the chosen Discord channel. + * If Image is removed, error is logged and image is not sent to channel. Indexing * Indexing is performed automatically if index doesn't exist. * Image index can be regenerated by calling the related function. * Currently changes in storage don't trigger image index regeneration. - * The index contains data of the images and how often those have been posted and other similar metadata. - * Images can also be ignored. Ignored images are not selected by randomization logic. + * The index contains data of the images and how often those have been posted + and other similar metadata. + * Images can also be ignored. Ignored images are not selected by randomization + logic. + +## Tools + +This section lists tools that are used in developing and deploying this +software. Some are not strictly + +Development + * Dotnet 8.0 (or later) + * Azure Powershell + * Bicep with version that supports importing user types (0.28.1 is + enough, earlier may also suffice) + * Docker (and docker compose). ## Deployment and running diff --git a/infra/functions.bicep b/infra/functions.bicep index e203e6a..e86ff08 100644 --- a/infra/functions.bicep +++ b/infra/functions.bicep @@ -89,7 +89,11 @@ resource functionApp 'Microsoft.Web/sites@2021-02-01' = { use32BitWorkerProcess: false ftpsState: 'FtpsOnly' minTlsVersion: '1.2' - + cors: { + allowedOrigins: [ + 'https://portal.azure.com' + ] + } appSettings: [ { name: 'AzureWebJobsStorage__accountName' @@ -132,12 +136,8 @@ resource functionApp 'Microsoft.Web/sites@2021-02-01' = { value: '${discordSettings.channelId}' } { - name: '${blobStorageKey}__ConnectionString' - value: imageStorageSettings.connectionString - } - { - name: '${blobStorageKey}__ContainerName' - value: imageStorageSettings.containerName + name: '${blobStorageKey}__BlobContainerUri' + value: imageStorageSettings.blobContainerUri } { name: '${blobStorageKey}__FolderPath' @@ -170,3 +170,4 @@ resource functionAppFunctionBlobStorageAccess 'Microsoft.Authorization/roleAssig } output functionAppPrincipalId string = functionApp.identity.principalId +output functionAppResourceId string = functionApp.id diff --git a/infra/main.bicep b/infra/main.bicep index eef9776..fbd1923 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -42,9 +42,20 @@ resource imageStorage 'Microsoft.Storage/storageAccounts@2022-05-01' = { } } +var imageContainerName = 'images' +resource blobServices 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = { + parent: imageStorage + name: 'default' +} + +resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = { + parent: blobServices + name: imageContainerName +} + var imageSettings = { connectionString: 'DefaultEndpointsProtocol=https;AccountName=${imageStorage.name};EndpointSuffix=${az.environment().suffixes.storage};AccountKey=${imageStorage.listKeys().keys[0].value}' - containerName: 'images' + blobContainerUri: '${imageStorage.properties.primaryEndpoints.blob}/${imageContainerName}' folderPath: 'root' } @@ -60,3 +71,19 @@ module functions 'functions.bicep' = { disableDiscordSending: disableDiscordSending } } + +// TODO refactor this. this should only require reading permission. Image INDEX requires more permissions and currently these are in same place +// TODO Also this assignment could probably be a separate module etc. +var storageBlobDataOwnerRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' +resource functionAppFunctionBlobStorageAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: imageStorage + name: guid(functions.name, storageBlobDataOwnerRoleDefinitionId, imageStorage.id) + properties: { + principalId: functions.outputs.functionAppPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + storageBlobDataOwnerRoleDefinitionId + ) + } +} diff --git a/infra/types.bicep b/infra/types.bicep index 01c7c67..3bd40d9 100644 --- a/infra/types.bicep +++ b/infra/types.bicep @@ -7,7 +7,7 @@ type DiscordSettings = { @export() type ImageStorageSettings = { + blobContainerUri: string connectionString: string - containerName: string folderPath: string } diff --git a/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs b/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs index 4f76621..16a7e32 100644 --- a/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs +++ b/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Microsoft.Identity.Client; namespace DiscordImagePoster.Common.BlobStorageImageService; @@ -17,8 +18,15 @@ public class BlobStorageImageSourceOptions /// /// The name of the container where the images are stored. /// - [Required] - public required string ContainerName { get; set; } + public required string? ContainerName { get; set; } + + /// + /// The URI to the container where the images are stored. + /// https://{account_name}.blob.core.windows.net/{container_name} + /// + /// If this is used, the ConnectionString is not needed and managed identity is used + /// + public required string? BlobContainerUri { get; set; } /// /// The path to the folder where the images are stored. diff --git a/src/FunctionApp.Isolated/Program.cs b/src/FunctionApp.Isolated/Program.cs index 634805f..faa4dfc 100644 --- a/src/FunctionApp.Isolated/Program.cs +++ b/src/FunctionApp.Isolated/Program.cs @@ -1,3 +1,4 @@ +using Azure.Identity; using Azure.Storage.Blobs; using DiscordImagePoster.Common; using DiscordImagePoster.Common.BlobStorageImageService; @@ -35,7 +36,13 @@ services.AddKeyedTransient(KeyedServiceConstants.ImageBlobContainerClient, (services, _) => { + //https://{account_name}.blob.core.windows.net/{container_name} var options = services.GetRequiredService>().Value; + if (!string.IsNullOrWhiteSpace(options.BlobContainerUri)) + { + return new BlobContainerClient(new Uri(options.BlobContainerUri), new DefaultAzureCredential()); + } + return new BlobContainerClient(options.ConnectionString, options.ContainerName); }); services.AddKeyedTransient(KeyedServiceConstants.ImageIndexBlobContainerClient, (services, _) => From 343c44344135af47fbde9210fe2b882b4a28224b Mon Sep 17 00:00:00 2001 From: Heikki-Jussi Niemi Date: Sun, 23 Jun 2024 13:30:25 +0300 Subject: [PATCH 3/3] Add MSI support for index --- infra/functions.bicep | 8 ++------ infra/main.bicep | 12 +++++------- infra/types.bicep | 1 - .../BlobStorageImageSourceOptions.cs | 1 + .../BlobStorageIndexStorageService.cs | 14 +++++++++----- src/Common/IndexService/ImageIndexOptions.cs | 19 ++++++++++++++++--- src/FunctionApp.Isolated/Program.cs | 5 ++++- 7 files changed, 37 insertions(+), 23 deletions(-) diff --git a/infra/functions.bicep b/infra/functions.bicep index e86ff08..8b88313 100644 --- a/infra/functions.bicep +++ b/infra/functions.bicep @@ -144,12 +144,8 @@ resource functionApp 'Microsoft.Web/sites@2021-02-01' = { value: imageStorageSettings.folderPath } { - name: '${imageIndexStorageKey}__ConnectionString' - value: imageStorageSettings.connectionString - } - { - name: '${imageIndexStorageKey}__ContainerName' - value: 'index' + name: '${imageIndexStorageKey}__BlobContainerUri' + value: '${functionStorageAccount.properties.primaryEndpoints.blob}index' } ] } diff --git a/infra/main.bicep b/infra/main.bicep index fbd1923..e40f61d 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -54,8 +54,7 @@ resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@20 } var imageSettings = { - connectionString: 'DefaultEndpointsProtocol=https;AccountName=${imageStorage.name};EndpointSuffix=${az.environment().suffixes.storage};AccountKey=${imageStorage.listKeys().keys[0].value}' - blobContainerUri: '${imageStorage.properties.primaryEndpoints.blob}/${imageContainerName}' + blobContainerUri: '${imageStorage.properties.primaryEndpoints.blob}${imageContainerName}' folderPath: 'root' } @@ -72,18 +71,17 @@ module functions 'functions.bicep' = { } } -// TODO refactor this. this should only require reading permission. Image INDEX requires more permissions and currently these are in same place // TODO Also this assignment could probably be a separate module etc. -var storageBlobDataOwnerRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' +var storageBlobDataReaderRoleDefinitionId = '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1' resource functionAppFunctionBlobStorageAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: imageStorage - name: guid(functions.name, storageBlobDataOwnerRoleDefinitionId, imageStorage.id) + scope: container + name: guid(functions.name, storageBlobDataReaderRoleDefinitionId, container.id) properties: { principalId: functions.outputs.functionAppPrincipalId principalType: 'ServicePrincipal' roleDefinitionId: subscriptionResourceId( 'Microsoft.Authorization/roleDefinitions', - storageBlobDataOwnerRoleDefinitionId + storageBlobDataReaderRoleDefinitionId ) } } diff --git a/infra/types.bicep b/infra/types.bicep index 3bd40d9..78e415c 100644 --- a/infra/types.bicep +++ b/infra/types.bicep @@ -8,6 +8,5 @@ type DiscordSettings = { @export() type ImageStorageSettings = { blobContainerUri: string - connectionString: string folderPath: string } diff --git a/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs b/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs index 16a7e32..15047cc 100644 --- a/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs +++ b/src/Common/BlobStorageImageService/BlobStorageImageSourceOptions.cs @@ -17,6 +17,7 @@ public class BlobStorageImageSourceOptions /// /// The name of the container where the images are stored. + /// This is not needed (nor used) if BlobContainerUri is used. /// public required string? ContainerName { get; set; } diff --git a/src/Common/IndexService/BlobStorageIndexStorageService.cs b/src/Common/IndexService/BlobStorageIndexStorageService.cs index e063027..c263bd7 100644 --- a/src/Common/IndexService/BlobStorageIndexStorageService.cs +++ b/src/Common/IndexService/BlobStorageIndexStorageService.cs @@ -2,29 +2,32 @@ using Azure.Storage.Blobs; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace DiscordImagePoster.Common.IndexService; public class BlobStorageIndexStorageService : IIndexStorageService { - private ILogger _logger; + private readonly ILogger _logger; + private readonly ImageIndexOptions _options; private readonly BlobContainerClient _blobContainerClient; - private const string IndexBlobName = "index.json"; - public BlobStorageIndexStorageService ( ILogger logger, + IOptions options, [FromKeyedServices(KeyedServiceConstants.ImageIndexBlobContainerClient)] BlobContainerClient blobContainerClient ) { _logger = logger; + _options = options.Value; _blobContainerClient = blobContainerClient; } public async Task GetImageIndexAsync() { - var blobClient = _blobContainerClient.GetBlobClient(IndexBlobName); + _logger.LogTrace("Getting image index from {IndexFileName}", _options.IndexFileName); + var blobClient = _blobContainerClient.GetBlobClient(_options.IndexFileName); var exists = await blobClient.ExistsAsync(); if (!exists) { @@ -38,9 +41,10 @@ public BlobStorageIndexStorageService public async Task UpdateIndexAsync(ImageIndex index) { + _logger.LogTrace("Updating image index to {IndexFileName}", _options.IndexFileName); await _blobContainerClient.CreateIfNotExistsAsync(); var bytes = JsonSerializer.SerializeToUtf8Bytes(index); - var blobClient = _blobContainerClient.GetBlobClient(IndexBlobName); + var blobClient = _blobContainerClient.GetBlobClient(_options.IndexFileName); await blobClient.UploadAsync(new MemoryStream(bytes), true); } } diff --git a/src/Common/IndexService/ImageIndexOptions.cs b/src/Common/IndexService/ImageIndexOptions.cs index 531dee2..e28ca08 100644 --- a/src/Common/IndexService/ImageIndexOptions.cs +++ b/src/Common/IndexService/ImageIndexOptions.cs @@ -11,12 +11,25 @@ public class ImageIndexOptions { /// /// The connection string to the Azure Storage account. + /// Use this only for development. Use managed identity in production. /// public required string ConnectionString { get; set; } /// - /// Container name where the index is stored. + /// The name of the container where the images are stored. + /// This is not needed (nor used) if BlobContainerUri is used. /// - [Required] - public required string ContainerName { get; set; } + public required string? ContainerName { get; set; } + + /// + /// The URI to the container where the images are stored. + /// https://{account_name}.blob.core.windows.net/{container_name} + /// + /// If this is used, the ConnectionString is not needed and managed identity is used + /// + public required string? BlobContainerUri { get; set; } + + /// + /// The path to the index file. Defaults to index.json in root. + public required string IndexFileName { get; set; } = "index.json"; } diff --git a/src/FunctionApp.Isolated/Program.cs b/src/FunctionApp.Isolated/Program.cs index faa4dfc..cc233ff 100644 --- a/src/FunctionApp.Isolated/Program.cs +++ b/src/FunctionApp.Isolated/Program.cs @@ -36,7 +36,6 @@ services.AddKeyedTransient(KeyedServiceConstants.ImageBlobContainerClient, (services, _) => { - //https://{account_name}.blob.core.windows.net/{container_name} var options = services.GetRequiredService>().Value; if (!string.IsNullOrWhiteSpace(options.BlobContainerUri)) { @@ -48,6 +47,10 @@ services.AddKeyedTransient(KeyedServiceConstants.ImageIndexBlobContainerClient, (services, _) => { var options = services.GetRequiredService>().Value; + if (!string.IsNullOrWhiteSpace(options.BlobContainerUri)) + { + return new BlobContainerClient(new Uri(options.BlobContainerUri), new DefaultAzureCredential()); + } return new BlobContainerClient(options.ConnectionString, options.ContainerName); });