From 96487240a91e7dcc1a2e8e8097e4ffe03bbb1845 Mon Sep 17 00:00:00 2001 From: Aleksander Heintz Date: Fri, 4 Oct 2024 16:03:18 +0200 Subject: [PATCH] feat: create db bootstrap API --- src/Directory.Packages.props | 128 +++++----- .../Altinn.Authorization.DeployApi.csproj | 21 ++ .../BootstrapDatabasePipeline.cs | 138 +++++++++++ .../CreateDatabaseRoleTask.cs | 99 ++++++++ .../CreateDatabaseSchemaTask.cs | 43 ++++ .../BootstrapDatabase/CreateDatabaseTask.cs | 37 +++ .../GrantDatabasePrivilegesTask.cs | 35 +++ .../SaveConnectionStringsTask.cs | 50 ++++ .../Pipelines/Pipeline.cs | 9 + .../Pipelines/PipelineContext.cs | 224 ++++++++++++++++++ .../Altinn.Authorization.DeployApi/Program.cs | 30 +++ .../Properties/launchSettings.json | 41 ++++ .../Tasks/StepTask.cs | 17 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 19 ++ 15 files changed, 831 insertions(+), 68 deletions(-) create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Altinn.Authorization.DeployApi.csproj create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/BootstrapDatabasePipeline.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseRoleTask.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseSchemaTask.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseTask.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/GrantDatabasePrivilegesTask.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/SaveConnectionStringsTask.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/Pipeline.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/PipelineContext.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Program.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Properties/launchSettings.json create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Tasks/StepTask.cs create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.Development.json create mode 100644 src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.json diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8f1ecde5..9cd18e30 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,70 +1,62 @@ - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Altinn.Authorization.DeployApi.csproj b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Altinn.Authorization.DeployApi.csproj new file mode 100644 index 00000000..9a6e887c --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Altinn.Authorization.DeployApi.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/BootstrapDatabasePipeline.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/BootstrapDatabasePipeline.cs new file mode 100644 index 00000000..84f2a8cd --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/BootstrapDatabasePipeline.cs @@ -0,0 +1,138 @@ +using Altinn.Authorization.DeployApi.Pipelines; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.KeyVault; +using Azure.ResourceManager.PostgreSql.FlexibleServers; +using Azure.Security.KeyVault.Secrets; +using Npgsql; +using System.Text.Json.Serialization; + +namespace Altinn.Authorization.DeployApi.BootstrapDatabase; + +internal sealed class BootstrapDatabasePipeline + : Pipeline +{ + [JsonPropertyName("resources")] + public required ResourcesConfig Resources { get; init; } + + [JsonPropertyName("databaseName")] + public required string DatabaseName { get; init; } + + [JsonPropertyName("schemas")] + public required IReadOnlyDictionary Schemas { get; init; } + + protected internal override async Task ExecuteAsync(PipelineContext context, CancellationToken cancellationToken) + { + var cred = context.GetRequiredService(); + var client = new ArmClient(cred, defaultSubscriptionId: Resources.SubscriptionId); + + var subscription = await context.RunTask( + "Get subscription info", + (_, ct) => client.GetDefaultSubscriptionAsync(ct), + cancellationToken); + + var resourceGroup = await context.RunTask( + "Get resource group info", + (_, ct) => subscription.GetResourceGroupAsync(Resources.ResourceGroupName, ct), + cancellationToken); + + var keyVault = await context.RunTask( + "Get key vault info", + (_, ct) => resourceGroup.Value.GetKeyVaultAsync(Resources.KeyVaultName, ct), + cancellationToken); + + var server = await context.RunTask( + "Get server info", + (_, ct) => resourceGroup.Value.GetPostgreSqlFlexibleServerAsync(Resources.ServerName, ct), + cancellationToken); + + var token = await context.RunTask( + "Get db auth token", + (_, ct) => cred.GetTokenAsync(new(["https://ossrdbms-aad.database.windows.net/.default"]), ct).AsTask(), + cancellationToken); + + var secretClient = new SecretClient(keyVault.Value.Data.Properties.VaultUri, cred); + + var serverUrl = server.Value.Data.FullyQualifiedDomainName; + var connStringBuilder = new NpgsqlConnectionStringBuilder() + { + Host = serverUrl, + Database = "postgres", + Username = Resources.User, + Password = token.Token, + Port = 5432, + SslMode = SslMode.Require, + Pooling = false, + }; + + var serverConnString = connStringBuilder.ToString(); + await using var serverConn = new NpgsqlConnection(serverConnString); + await context.RunTask( + "Connecting to database server", + (_, ct) => serverConn.OpenAsync(ct), + cancellationToken); + + var migratorUser = await context.RunTask(new CreateDatabaseRoleTask(secretClient, serverConn, $"{DatabaseName}_migrator", Resources.User), cancellationToken); + var appUser = await context.RunTask(new CreateDatabaseRoleTask(secretClient, serverConn, $"{DatabaseName}_app", Resources.User), cancellationToken); + await context.RunTask(new CreateDatabaseTask(serverConn, DatabaseName), cancellationToken); + await context.RunTask(new GrantDatabasePrivilegesTask(serverConn, DatabaseName, migratorUser.RoleName, "CREATE, CONNECT"), cancellationToken); + await context.RunTask(new GrantDatabasePrivilegesTask(serverConn, DatabaseName, appUser.RoleName, "CONNECT"), cancellationToken); + + connStringBuilder.Database = DatabaseName; + var dbConnString = connStringBuilder.ToString(); + await using var dbConn = new NpgsqlConnection(dbConnString); + await context.RunTask( + $"Connecting to database '[cyan]{DatabaseName}[/]'", + (_, ct) => dbConn.OpenAsync(ct), + cancellationToken); + + foreach (var (schemaName, schemaCfg) in Schemas) + { + await context.RunTask(new CreateDatabaseSchemaTask(dbConn, migratorUser.RoleName, schemaName, schemaCfg), cancellationToken); + } + + connStringBuilder = new NpgsqlConnectionStringBuilder() + { + Host = serverUrl, + Database = DatabaseName, + Username = Resources.User, + Password = token.Token, + Port = 5432, + SslMode = SslMode.Require, + }; + + var connectionStrings = new Dictionary(); + + connStringBuilder.Username = migratorUser.RoleName; + connStringBuilder.Password = migratorUser.Password; + connectionStrings[$"db-{DatabaseName}-migrator"] = connStringBuilder.ToString(); + + connStringBuilder.Username = appUser.RoleName; + connStringBuilder.Password = appUser.Password; + connectionStrings[$"db-{DatabaseName}-app"] = connStringBuilder.ToString(); + + await context.RunTask(new SaveConnectionStringsTask(secretClient, connectionStrings), cancellationToken); + } + + public record ResourcesConfig + { + [JsonPropertyName("subscriptionId")] + public required string SubscriptionId { get; init; } + + [JsonPropertyName("resourceGroup")] + public required string ResourceGroupName { get; init; } + + [JsonPropertyName("serverName")] + public required string ServerName { get; init; } + + [JsonPropertyName("user")] + public required string User { get; init; } + + [JsonPropertyName("keyVaultName")] + public required string KeyVaultName { get; init; } + } + + public record SchemaBootstrapModel + { + } +} \ No newline at end of file diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseRoleTask.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseRoleTask.cs new file mode 100644 index 00000000..f6d353e3 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseRoleTask.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography; +using Altinn.Authorization.DeployApi.Tasks; +using Azure; +using Azure.Security.KeyVault.Secrets; +using Npgsql; +using Spectre.Console; + +namespace Altinn.Authorization.DeployApi.BootstrapDatabase; + +internal sealed class CreateDatabaseRoleTask + : StepTask +{ + private readonly SecretClient _secrets; + private readonly NpgsqlConnection _conn; + private readonly string _roleName; + private readonly string _adminRole; + + public CreateDatabaseRoleTask(SecretClient secrets, NpgsqlConnection conn, string roleName, string adminRole) + { + _secrets = secrets; + _conn = conn; + _roleName = roleName; + _adminRole = adminRole; + } + + public override string Name => $"Creating role '[cyan]{_roleName}[/]'"; + + public override async Task ExecuteAsync(ProgressTask task, CancellationToken cancellationToken) + { + var pw = await GetOrCreatePassword(task, cancellationToken); + var secretName = $"-db-{_roleName.Replace('_', '-')}-pw"; + var secret = await _secrets.GetSecretAsync(secretName, cancellationToken: cancellationToken); + + await using var cmd = _conn.CreateCommand(); + cmd.CommandText = + /*strpsql*/$""" + CREATE ROLE "{_roleName}" + WITH LOGIN + PASSWORD '{pw.Password}' + """; + + try + { + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (PostgresException ex) when (ex.SqlState == "42710") + { + if (pw.Updated) + { + cmd.CommandText = + /*strpsql*/$""" + ALTER ROLE "{_roleName}" + WITH LOGIN + PASSWORD '{pw.Password}' + """; + + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + } + + cmd.CommandText = + /*strpsql*/$""" + GRANT "{_roleName}" TO "{_adminRole}" + """; + + await cmd.ExecuteNonQueryAsync(cancellationToken); + + return new Result(_roleName, pw.Password); + } + + private async Task GetOrCreatePassword(ProgressTask task, CancellationToken cancellationToken) + { + var secretName = $"-db-{_roleName.Replace('_', '-')}-pw"; + Response secret; + bool updated = false; + try + { + secret = await _secrets.GetSecretAsync(secretName, cancellationToken: cancellationToken); + } + catch (RequestFailedException ex) when (ex.ErrorCode == "SecretNotFound") + { + var pw = GenerateRandomPw(); + secret = await _secrets.SetSecretAsync(secretName, pw, cancellationToken: cancellationToken); + updated = true; + } + + return new PasswordResult(secret.Value.Value, updated); + } + + private static string GenerateRandomPw() + { + const string VALID_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"; + return RandomNumberGenerator.GetString(VALID_CHARS.AsSpan(), 64); + } + + private record PasswordResult(string Password, bool Updated); + + internal record Result(string RoleName, string Password); +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseSchemaTask.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseSchemaTask.cs new file mode 100644 index 00000000..b4417e4f --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseSchemaTask.cs @@ -0,0 +1,43 @@ +using Altinn.Authorization.DeployApi.Tasks; +using Npgsql; +using Spectre.Console; + +namespace Altinn.Authorization.DeployApi.BootstrapDatabase; + +internal sealed class CreateDatabaseSchemaTask + : StepTask +{ + private readonly NpgsqlConnection _conn; + private readonly string _ownerRoleName; + private readonly string _schemaName; + private readonly BootstrapDatabasePipeline.SchemaBootstrapModel _schemaCfg; + + public CreateDatabaseSchemaTask(NpgsqlConnection conn, string ownerRoleName, string schemaName, BootstrapDatabasePipeline.SchemaBootstrapModel schemaCfg) + { + _conn = conn; + _ownerRoleName = ownerRoleName; + _schemaName = schemaName; + _schemaCfg = schemaCfg; + } + + public override string Name => $"Creating schema '[cyan]{_schemaName}[/]'"; + + public override async Task ExecuteAsync(ProgressTask task, CancellationToken cancellationToken) + { + await using var cmd = _conn.CreateCommand(); + cmd.CommandText = + /*strpsql*/$""" + CREATE SCHEMA "{_schemaName}" + AUTHORIZATION "{_ownerRoleName}" + """; + + try + { + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (PostgresException ex) when (ex.SqlState == "42P06") + { + // Schema already exists + } + } +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseTask.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseTask.cs new file mode 100644 index 00000000..a8983cd8 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/CreateDatabaseTask.cs @@ -0,0 +1,37 @@ +using Altinn.Authorization.DeployApi.Tasks; +using Npgsql; +using Spectre.Console; + +namespace Altinn.Authorization.DeployApi.BootstrapDatabase; + +internal sealed class CreateDatabaseTask + : StepTask +{ + private readonly NpgsqlConnection _conn; + private readonly string _databaseName; + + public CreateDatabaseTask(NpgsqlConnection conn, string databaseName) + { + _conn = conn; + _databaseName = databaseName; + } + + public override string Name => $"Creating database '[cyan]{_databaseName}[/]'"; + + public override async Task ExecuteAsync(ProgressTask task, CancellationToken cancellationToken) + { + await using var cmd = _conn.CreateCommand(); + cmd.CommandText = + /*strpsql*/$""" + CREATE DATABASE "{_databaseName}" + """; + try + { + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (PostgresException ex) when (ex.SqlState == "42P04") + { + // Database already exists + } + } +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/GrantDatabasePrivilegesTask.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/GrantDatabasePrivilegesTask.cs new file mode 100644 index 00000000..5ad92364 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/GrantDatabasePrivilegesTask.cs @@ -0,0 +1,35 @@ +using Altinn.Authorization.DeployApi.Tasks; +using Npgsql; +using Spectre.Console; + +namespace Altinn.Authorization.DeployApi.BootstrapDatabase; + +internal sealed class GrantDatabasePrivilegesTask + : StepTask +{ + private readonly NpgsqlConnection _conn; + private readonly string _databaseName; + private readonly string _roleName; + private readonly string _privileges; + + public GrantDatabasePrivilegesTask(NpgsqlConnection conn, string databaseName, string roleName, string privileges) + { + _conn = conn; + _databaseName = databaseName; + _roleName = roleName; + _privileges = privileges; + } + + public override string Name => $"Granting db privileges [green]{_privileges}[/] to role '[cyan]{_roleName}[/]'"; + + public override async Task ExecuteAsync(ProgressTask task, CancellationToken cancellationToken) + { + await using var cmd = _conn.CreateCommand(); + cmd.CommandText = + /*strpsql*/$""" + GRANT {_privileges} ON DATABASE "{_databaseName}" TO "{_roleName}" + """; + + await cmd.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/SaveConnectionStringsTask.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/SaveConnectionStringsTask.cs new file mode 100644 index 00000000..dc784561 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/BootstrapDatabase/SaveConnectionStringsTask.cs @@ -0,0 +1,50 @@ +using Altinn.Authorization.DeployApi.Tasks; +using Azure; +using Azure.Security.KeyVault.Secrets; +using Spectre.Console; + +namespace Altinn.Authorization.DeployApi.BootstrapDatabase; + +internal sealed class SaveConnectionStringsTask + : StepTask +{ + private readonly SecretClient _secrets; + private readonly IReadOnlyDictionary _connectionStrings; + + public SaveConnectionStringsTask(SecretClient secrets, IReadOnlyDictionary connectionStrings) + { + _secrets = secrets; + _connectionStrings = connectionStrings; + } + + public override string Name => $"Writing connection string to keyvault"; + + public override async Task ExecuteAsync(ProgressTask task, CancellationToken cancellationToken) + { + foreach (var (name, connStr) in _connectionStrings) + { + await MaybeUpdate(name, connStr, cancellationToken); + } + } + + private async Task MaybeUpdate(string name, string connStr, CancellationToken cancellationToken) + { + bool update = true; + try + { + var secret = await _secrets.GetSecretAsync(name, cancellationToken: cancellationToken); + if (string.Equals(connStr, secret.Value.Value, StringComparison.Ordinal)) + { + update = false; + } + } + catch (RequestFailedException ex) when (ex.ErrorCode == "SecretNotFound") + { + } + + if (update) + { + await _secrets.SetSecretAsync(name, connStr, cancellationToken: cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/Pipeline.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/Pipeline.cs new file mode 100644 index 00000000..dbad5e1e --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/Pipeline.cs @@ -0,0 +1,9 @@ +namespace Altinn.Authorization.DeployApi.Pipelines; + +internal abstract class Pipeline +{ + protected internal abstract Task ExecuteAsync(PipelineContext context, CancellationToken cancellationToken); + + public Task Run(HttpContext context) + => PipelineContext.Run(this, context, context.RequestAborted); +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/PipelineContext.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/PipelineContext.cs new file mode 100644 index 00000000..b307afb8 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Pipelines/PipelineContext.cs @@ -0,0 +1,224 @@ +using System.Text; +using Altinn.Authorization.DeployApi.Tasks; +using Microsoft.AspNetCore.Http.Features; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace Altinn.Authorization.DeployApi.Pipelines; + +public sealed class PipelineContext + : IServiceProvider + , ISupportRequiredService + , IKeyedServiceProvider +{ + internal static async Task Run(Pipeline pipeline, HttpContext context, CancellationToken cancellationToken) + { + var responseBody = context.Features.Get()!; + responseBody.DisableBuffering(); + + var response = context.Response; + response.StatusCode = 200; + response.ContentType = "text/plain; charset=utf-8"; + + await responseBody.StartAsync(cancellationToken); + + await using var textWriter = new StreamWriter(responseBody.Stream, Encoding.UTF8); + var consoleOutput = new ConsoleOutput(textWriter); + var console = new Console( + AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.EightBit, + Out = consoleOutput, + Interactive = InteractionSupport.Yes, + }), + textWriter, + responseBody.Stream); + + console.WriteLine(); + var progress = console + .Progress() + .Columns([ + new TaskOutcomeColumn(), + new TaskDescriptionColumn { Alignment = Justify.Left }, + ]); + + var ctx = new PipelineContext(console, progress, context); + try + { + await pipeline.ExecuteAsync(ctx, cancellationToken); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) + { + } + catch (Exception ex) + { + try + { + console.WriteLine(); + console.WriteLine("::error ::Failed to create database"); + console.WriteException(ex); + } + catch + { + // Ignore exceptions from the exception handler. + } + } + + await responseBody.CompleteAsync(); + } + + private readonly IAnsiConsole _console; + private readonly Progress _progress; + private readonly HttpContext _context; + + private PipelineContext(IAnsiConsole console, Progress progress, HttpContext context) + { + _console = console; + _progress = progress; + _context = context; + } + + object? IServiceProvider.GetService(Type serviceType) + => _context.RequestServices.GetService(serviceType); + + object ISupportRequiredService.GetRequiredService(Type serviceType) + => _context.RequestServices.GetRequiredService(serviceType); + + object? IKeyedServiceProvider.GetKeyedService(Type serviceType, object? serviceKey) + => _context.RequestServices.GetKeyedServices(serviceType, serviceKey); + + object IKeyedServiceProvider.GetRequiredKeyedService(Type serviceType, object? serviceKey) + => _context.RequestServices.GetRequiredKeyedService(serviceType, serviceKey); + + public Task RunTask(StepTask task, CancellationToken cancellationToken) + { + return RunTask(task.Name, task.ExecuteAsync, cancellationToken); + } + + public Task RunTask(StepTask task, CancellationToken cancellationToken) + { + return RunTask(task.Name, task.ExecuteAsync, cancellationToken); + } + + public Task RunTask(string description, Func task, CancellationToken cancellationToken) + { + return RunTask( + description, + async (ctx, ct) => + { + await task(ctx, ct); + return null; + }, + cancellationToken); + } + + public Task RunTask(string description, Func> task, CancellationToken cancellationToken) + { + return _progress.StartAsync(async ctx => + { + var taskCtx = ctx.AddTask(description, autoStart: true); + taskCtx.IsIndeterminate = true; + + var succeeded = false; + try + { + var result = await task(taskCtx, cancellationToken); + succeeded = true; + return result; + } + finally + { + taskCtx.StopTask(); + taskCtx.Value(taskCtx.MaxValue); + taskCtx.State.Update("task:outcome", v => + { + if (v == TaskOutcome.None) + { + return succeeded ? TaskOutcome.Ok : TaskOutcome.Error; + } + + return v; + }); + } + }); + } + + private class ConsoleOutput(TextWriter writer) + : IAnsiConsoleOutput + { + public TextWriter Writer => writer; + + public bool IsTerminal => true; + + public int Width => 80; + + public int Height => 80; + + public void SetEncoding(Encoding encoding) + { + } + } + + private class Console(IAnsiConsole inner, TextWriter writer, Stream stream) + : IAnsiConsole + { + public Profile Profile => inner.Profile; + + public IAnsiConsoleCursor Cursor => inner.Cursor; + + public IAnsiConsoleInput Input => inner.Input; + + public IExclusivityMode ExclusivityMode => inner.ExclusivityMode; + + public RenderPipeline Pipeline => inner.Pipeline; + + public void Clear(bool home) + { + inner.Clear(home); + } + + public void Write(IRenderable renderable) + { + inner.Write(renderable); + writer.Flush(); + stream.Flush(); + } + } + + private class TaskOutcomeColumn + : ProgressColumn + { + private static readonly IRenderable Checkmark = new Markup("[green]✓[/]"); + private static readonly IRenderable Cross = new Markup("[red]✗[/]"); + + private readonly ProgressColumn _spinner; + + public TaskOutcomeColumn() + { + _spinner = new SpinnerColumn + { + Style = Style.Parse("green"), + }; + } + + public override IRenderable Render(RenderOptions options, ProgressTask task, TimeSpan deltaTime) + { + var outcome = task.State.Get("task:outcome"); + + return outcome switch + { + TaskOutcome.Ok => Checkmark, + TaskOutcome.Error => Cross, + _ => _spinner.Render(options, task, deltaTime), + }; + } + } + + private enum TaskOutcome + { + None = default, + Ok, + Error, + } +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Program.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Program.cs new file mode 100644 index 00000000..528aeec8 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Program.cs @@ -0,0 +1,30 @@ +using Altinn.Authorization.DeployApi.BootstrapDatabase; +using Azure.Core; +using Azure.Identity; +using Microsoft.AspNetCore.Server.Kestrel.Core; + +var builder = WebApplication.CreateBuilder(args); + +TokenCredential cred; +if (builder.Environment.IsDevelopment()) +{ + cred = new DefaultAzureCredential(); +} +else +{ + cred = new ManagedIdentityCredential(); +} + +builder.Services.AddSingleton(cred); +builder.Services.AddOptions() + .Configure(o => o.AllowSynchronousIO = true); + +// Add services to the container. +builder.Services.AddAuthentication() + .AddJwtBearer("github", "GitHub", options => { }); + +var app = builder.Build(); + +app.MapPost("/api/v1/database/bootstrap", (BootstrapDatabasePipeline pipeline, HttpContext context) => pipeline.Run(context)); + +app.Run(); diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Properties/launchSettings.json b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Properties/launchSettings.json new file mode 100644 index 00000000..16ef71ac --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:43235", + "sslPort": 44320 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5010", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7269;http://localhost:5010", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Tasks/StepTask.cs b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Tasks/StepTask.cs new file mode 100644 index 00000000..ab6d5578 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/Tasks/StepTask.cs @@ -0,0 +1,17 @@ +using Spectre.Console; + +namespace Altinn.Authorization.DeployApi.Tasks; + +public abstract class StepTask +{ + public abstract string Name { get; } + + public abstract new Task ExecuteAsync(ProgressTask task, CancellationToken cancellationToken); +} + +public abstract class StepTask +{ + public abstract string Name { get; } + + public abstract Task ExecuteAsync(ProgressTask task, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.Development.json b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.json b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.json new file mode 100644 index 00000000..85eb5484 --- /dev/null +++ b/src/apps/Altinn.Authorization.DeployApi/src/Altinn.Authorization.DeployApi/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Authentication": { + "Schemes": { + "github": { + "ValidIssuer": "https://token.actions.githubusercontent.com", + "ValidAudiences": [ + "altinn-authorization:deploy" + ] + } + } + } +}