pulumi/sdk/dotnet/Pulumi.Automation/WorkspaceStack.cs
Sean Fausett 12217bd0dc
Fix async await warnings (#7537)
* Revert remove redundant async await
* Fix resharper code issues
* Update changelog
2021-07-21 18:44:10 -04:00

777 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2016-2021, Pulumi Corporation
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Runtime.ExceptionServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Pulumi.Automation.Commands;
using Pulumi.Automation.Commands.Exceptions;
using Pulumi.Automation.Events;
using Pulumi.Automation.Exceptions;
using Pulumi.Automation.Serialization;
namespace Pulumi.Automation
{
/// <summary>
/// <see cref="WorkspaceStack"/> is an isolated, independently configurable instance of a
/// Pulumi program. <see cref="WorkspaceStack"/> exposes methods for the full pulumi lifecycle
/// (up/preview/refresh/destroy), as well as managing configuration.
/// <para/>
/// Multiple stacks are commonly used to denote different phases of development
/// (such as development, staging, and production) or feature branches (such as
/// feature-x-dev, jane-feature-x-dev).
/// <para/>
/// Will dispose the <see cref="Workspace"/> on <see cref="Dispose"/>.
/// </summary>
public sealed class WorkspaceStack : IDisposable
{
private readonly Task _readyTask;
/// <summary>
/// The name identifying the Stack.
/// </summary>
public string Name { get; }
/// <summary>
/// The Workspace the Stack was created from.
/// </summary>
public Workspace Workspace { get; }
/// <summary>
/// Creates a new stack using the given workspace, and stack name.
/// It fails if a stack with that name already exists.
/// </summary>
/// <param name="name">The name identifying the stack.</param>
/// <param name="workspace">The Workspace the Stack was created from.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="StackAlreadyExistsException">If a stack with the provided name already exists.</exception>
public static async Task<WorkspaceStack> CreateAsync(
string name,
Workspace workspace,
CancellationToken cancellationToken = default)
{
var stack = new WorkspaceStack(name, workspace, WorkspaceStackInitMode.Create, cancellationToken);
await stack._readyTask.ConfigureAwait(false);
return stack;
}
/// <summary>
/// Selects stack using the given workspace, and stack name.
/// It returns an error if the given Stack does not exist.
/// </summary>
/// <param name="name">The name identifying the stack.</param>
/// <param name="workspace">The Workspace the Stack was created from.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="StackNotFoundException">If a stack with the provided name does not exists.</exception>
public static async Task<WorkspaceStack> SelectAsync(
string name,
Workspace workspace,
CancellationToken cancellationToken = default)
{
var stack = new WorkspaceStack(name, workspace, WorkspaceStackInitMode.Select, cancellationToken);
await stack._readyTask.ConfigureAwait(false);
return stack;
}
/// <summary>
/// Tries to create a new Stack using the given workspace, and stack name
/// if the stack does not already exist, or falls back to selecting an
/// existing stack. If the stack does not exist, it will be created and
/// selected.
/// </summary>
/// <param name="name">The name of the identifying stack.</param>
/// <param name="workspace">The Workspace the Stack was created from.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public static async Task<WorkspaceStack> CreateOrSelectAsync(
string name,
Workspace workspace,
CancellationToken cancellationToken = default)
{
var stack = new WorkspaceStack(name, workspace, WorkspaceStackInitMode.CreateOrSelect, cancellationToken);
await stack._readyTask.ConfigureAwait(false);
return stack;
}
private WorkspaceStack(
string name,
Workspace workspace,
WorkspaceStackInitMode mode,
CancellationToken cancellationToken)
{
this.Name = name;
this.Workspace = workspace;
this._readyTask = mode switch
{
WorkspaceStackInitMode.Create => workspace.CreateStackAsync(name, cancellationToken),
WorkspaceStackInitMode.Select => workspace.SelectStackAsync(name, cancellationToken),
WorkspaceStackInitMode.CreateOrSelect => Task.Run(async () =>
{
try
{
await workspace.CreateStackAsync(name, cancellationToken).ConfigureAwait(false);
}
catch (StackAlreadyExistsException)
{
await workspace.SelectStackAsync(name, cancellationToken).ConfigureAwait(false);
}
}),
_ => throw new InvalidOperationException($"Unexpected Stack creation mode: {mode}")
};
}
/// <summary>
/// Returns the config value associated with the specified key.
/// </summary>
/// <param name="key">The key to use for the config lookup.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public Task<ConfigValue> GetConfigAsync(string key, CancellationToken cancellationToken = default)
=> this.Workspace.GetConfigAsync(this.Name, key, cancellationToken);
/// <summary>
/// Returns the full config map associated with the stack in the Workspace.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
public Task<ImmutableDictionary<string, ConfigValue>> GetAllConfigAsync(CancellationToken cancellationToken = default)
=> this.Workspace.GetAllConfigAsync(this.Name, cancellationToken);
/// <summary>
/// Sets the config key-value pair on the Stack in the associated Workspace.
/// </summary>
/// <param name="key">The key to set.</param>
/// <param name="value">The config value to set.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public Task SetConfigAsync(string key, ConfigValue value, CancellationToken cancellationToken = default)
=> this.Workspace.SetConfigAsync(this.Name, key, value, cancellationToken);
/// <summary>
/// Sets all specified config values on the stack in the associated Workspace.
/// </summary>
/// <param name="configMap">The map of config key-value pairs to set.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public Task SetAllConfigAsync(IDictionary<string, ConfigValue> configMap, CancellationToken cancellationToken = default)
=> this.Workspace.SetAllConfigAsync(this.Name, configMap, cancellationToken);
/// <summary>
/// Removes the specified config key from the Stack in the associated Workspace.
/// </summary>
/// <param name="key">The config key to remove.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public Task RemoveConfigAsync(string key, CancellationToken cancellationToken = default)
=> this.Workspace.RemoveConfigAsync(this.Name, key, cancellationToken);
/// <summary>
/// Removes the specified config keys from the Stack in the associated Workspace.
/// </summary>
/// <param name="keys">The config keys to remove.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public Task RemoveAllConfigAsync(IEnumerable<string> keys, CancellationToken cancellationToken = default)
=> this.Workspace.RemoveAllConfigAsync(this.Name, keys, cancellationToken);
/// <summary>
/// Gets and sets the config map used with the last update.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
public Task<ImmutableDictionary<string, ConfigValue>> RefreshConfigAsync(CancellationToken cancellationToken = default)
=> this.Workspace.RefreshConfigAsync(this.Name, cancellationToken);
/// <summary>
/// Creates or updates the resources in a stack by executing the program in the Workspace.
/// <para/>
/// https://www.pulumi.com/docs/reference/cli/pulumi_up/
/// </summary>
/// <param name="options">Options to customize the behavior of the update.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task<UpResult> UpAsync(
UpOptions? options = null,
CancellationToken cancellationToken = default)
{
var execKind = ExecKind.Local;
var program = this.Workspace.Program;
var logger = this.Workspace.Logger;
var args = new List<string>()
{
"up",
"--yes",
"--skip-preview",
};
if (options != null)
{
if (options.Program != null)
program = options.Program;
if (options.Logger != null)
logger = options.Logger;
if (!string.IsNullOrWhiteSpace(options.Message))
{
args.Add("--message");
args.Add(options.Message);
}
if (options.ExpectNoChanges is true)
args.Add("--expect-no-changes");
if (options.Diff is true)
args.Add("--diff");
if (options.Replace?.Any() == true)
{
foreach (var item in options.Replace)
{
args.Add("--replace");
args.Add(item);
}
}
if (options.Target?.Any() == true)
{
foreach (var item in options.Target)
{
args.Add("--target");
args.Add(item);
}
}
if (options.TargetDependents is true)
args.Add("--target-dependents");
if (options.Parallel.HasValue)
{
args.Add("--parallel");
args.Add(options.Parallel.Value.ToString());
}
}
InlineLanguageHost? inlineHost = null;
try
{
if (program != null)
{
execKind = ExecKind.Inline;
inlineHost = new InlineLanguageHost(program, logger, cancellationToken);
await inlineHost.StartAsync().ConfigureAwait(false);
var port = await inlineHost.GetPortAsync().ConfigureAwait(false);
args.Add($"--client=127.0.0.1:{port}");
}
args.Add("--exec-kind");
args.Add(execKind);
CommandResult upResult;
try
{
upResult = await this.RunCommandAsync(
args,
options?.OnStandardOutput,
options?.OnStandardError,
options?.OnEvent,
cancellationToken).ConfigureAwait(false);
}
catch
{
if (inlineHost != null && inlineHost.TryGetExceptionInfo(out var exceptionInfo))
exceptionInfo.Throw();
// this won't be hit if we have an inline
// program exception
throw;
}
var output = await this.GetOutputsAsync(cancellationToken).ConfigureAwait(false);
var summary = await this.GetInfoAsync(cancellationToken).ConfigureAwait(false);
return new UpResult(
upResult.StandardOutput,
upResult.StandardError,
summary!,
output);
}
finally
{
if (inlineHost != null)
{
await inlineHost.DisposeAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Performs a dry-run update to a stack, returning pending changes.
/// <para/>
/// https://www.pulumi.com/docs/reference/cli/pulumi_preview/
/// </summary>
/// <param name="options">Options to customize the behavior of the update.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task<PreviewResult> PreviewAsync(
PreviewOptions? options = null,
CancellationToken cancellationToken = default)
{
var execKind = ExecKind.Local;
var program = this.Workspace.Program;
var logger = this.Workspace.Logger;
var args = new List<string>() { "preview" };
if (options != null)
{
if (options.Program != null)
program = options.Program;
if (options.Logger != null)
logger = options.Logger;
if (!string.IsNullOrWhiteSpace(options.Message))
{
args.Add("--message");
args.Add(options.Message);
}
if (options.ExpectNoChanges is true)
args.Add("--expect-no-changes");
if (options.Diff is true)
args.Add("--diff");
if (options.Replace?.Any() == true)
{
foreach (var item in options.Replace)
{
args.Add("--replace");
args.Add(item);
}
}
if (options.Target?.Any() == true)
{
foreach (var item in options.Target)
{
args.Add("--target");
args.Add(item);
}
}
if (options.TargetDependents is true)
args.Add("--target-dependents");
if (options.Parallel.HasValue)
{
args.Add("--parallel");
args.Add(options.Parallel.Value.ToString());
}
}
InlineLanguageHost? inlineHost = null;
SummaryEvent? summaryEvent = null;
var onEvent = options?.OnEvent;
void OnPreviewEvent(EngineEvent @event)
{
if (@event.SummaryEvent != null)
{
summaryEvent = @event.SummaryEvent;
}
onEvent?.Invoke(@event);
}
try
{
if (program != null)
{
execKind = ExecKind.Inline;
inlineHost = new InlineLanguageHost(program, logger, cancellationToken);
await inlineHost.StartAsync().ConfigureAwait(false);
var port = await inlineHost.GetPortAsync().ConfigureAwait(false);
args.Add($"--client=127.0.0.1:{port}");
}
args.Add("--exec-kind");
args.Add(execKind);
CommandResult result;
try
{
result = await this.RunCommandAsync(
args,
options?.OnStandardOutput,
options?.OnStandardError,
OnPreviewEvent,
cancellationToken).ConfigureAwait(false);
}
catch
{
if (inlineHost != null && inlineHost.TryGetExceptionInfo(out var exceptionInfo))
exceptionInfo.Throw();
// this won't be hit if we have an inline
// program exception
throw;
}
if (summaryEvent is null)
{
throw new NoSummaryEventException("No summary of changes for 'preview'");
}
return new PreviewResult(
result.StandardOutput,
result.StandardError,
summaryEvent.ResourceChanges);
}
finally
{
if (inlineHost != null)
{
await inlineHost.DisposeAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Compares the current stacks resource state with the state known to exist in the actual
/// cloud provider. Any such changes are adopted into the current stack.
/// </summary>
/// <param name="options">Options to customize the behavior of the refresh.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task<UpdateResult> RefreshAsync(
RefreshOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = new List<string>
{
"refresh",
"--yes",
"--skip-preview",
};
if (options != null)
{
if (!string.IsNullOrWhiteSpace(options.Message))
{
args.Add("--message");
args.Add(options.Message);
}
if (options.ExpectNoChanges is true)
args.Add("--expect-no-changes");
if (options.Target?.Any() == true)
{
foreach (var item in options.Target)
{
args.Add("--target");
args.Add(item);
}
}
if (options.Parallel.HasValue)
{
args.Add("--parallel");
args.Add(options.Parallel.Value.ToString());
}
}
var execKind = Workspace.Program is null ? ExecKind.Local : ExecKind.Inline;
args.Add("--exec-kind");
args.Add(execKind);
var result = await this.RunCommandAsync(args, options?.OnStandardOutput, options?.OnStandardError, options?.OnEvent, cancellationToken).ConfigureAwait(false);
var summary = await this.GetInfoAsync(cancellationToken).ConfigureAwait(false);
return new UpdateResult(
result.StandardOutput,
result.StandardError,
summary!);
}
/// <summary>
/// Destroy deletes all resources in a stack, leaving all history and configuration intact.
/// </summary>
/// <param name="options">Options to customize the behavior of the destroy.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task<UpdateResult> DestroyAsync(
DestroyOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = new List<string>
{
"destroy",
"--yes",
"--skip-preview",
};
if (options != null)
{
if (!string.IsNullOrWhiteSpace(options.Message))
{
args.Add("--message");
args.Add(options.Message);
}
if (options.Target?.Any() == true)
{
foreach (var item in options.Target)
{
args.Add("--target");
args.Add(item);
}
}
if (options.TargetDependents is true)
args.Add("--target-dependents");
if (options.Parallel.HasValue)
{
args.Add("--parallel");
args.Add(options.Parallel.Value.ToString());
}
}
var execKind = Workspace.Program is null ? ExecKind.Local : ExecKind.Inline;
args.Add("--exec-kind");
args.Add(execKind);
var result = await this.RunCommandAsync(args, options?.OnStandardOutput, options?.OnStandardError, options?.OnEvent, cancellationToken).ConfigureAwait(false);
var summary = await this.GetInfoAsync(cancellationToken).ConfigureAwait(false);
return new UpdateResult(
result.StandardOutput,
result.StandardError,
summary!);
}
/// <summary>
/// Gets the current set of Stack outputs from the last <see cref="UpAsync(UpOptions?, CancellationToken)"/>.
/// </summary>
public Task<ImmutableDictionary<string, OutputValue>> GetOutputsAsync(CancellationToken cancellationToken = default)
=> this.Workspace.GetStackOutputsAsync(this.Name, cancellationToken);
/// <summary>
/// Returns a list summarizing all previews and current results from Stack lifecycle operations (up/preview/refresh/destroy).
/// </summary>
/// <param name="options">Options to customize the behavior of the fetch history action.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task<ImmutableList<UpdateSummary>> GetHistoryAsync(
HistoryOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = new List<string>
{
"stack",
"history",
"--json",
"--show-secrets",
};
if (options?.PageSize.HasValue == true)
{
if (options.PageSize!.Value < 1)
throw new ArgumentException($"{nameof(options.PageSize)} must be greater than or equal to 1.", nameof(options.PageSize));
var page = !options.Page.HasValue ? 1
: options.Page.Value < 1 ? 1
: options.Page.Value;
args.Add("--page-size");
args.Add(options.PageSize.Value.ToString());
args.Add("--page");
args.Add(page.ToString());
}
var result = await this.RunCommandAsync(args, null, null, null, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(result.StandardOutput))
return ImmutableList<UpdateSummary>.Empty;
var jsonOptions = LocalSerializer.BuildJsonSerializerOptions();
var list = JsonSerializer.Deserialize<List<UpdateSummary>>(result.StandardOutput, jsonOptions);
return list.ToImmutableList();
}
/// <summary>
/// Exports the deployment state of the stack.
/// <para/>
/// This can be combined with ImportStackAsync to edit a
/// stack's state (such as recovery from failed deployments).
/// </summary>
public Task<StackDeployment> ExportStackAsync(CancellationToken cancellationToken = default)
=> this.Workspace.ExportStackAsync(this.Name, cancellationToken);
/// <summary>
/// Imports the specified deployment state into a pre-existing stack.
/// <para/>
/// This can be combined with ExportStackAsync to edit a
/// stack's state (such as recovery from failed deployments).
/// </summary>
public Task ImportStackAsync(StackDeployment state, CancellationToken cancellationToken = default)
=> this.Workspace.ImportStackAsync(this.Name, state, cancellationToken);
public async Task<UpdateSummary?> GetInfoAsync(CancellationToken cancellationToken = default)
{
var history = await this.GetHistoryAsync(
new HistoryOptions
{
PageSize = 1,
},
cancellationToken).ConfigureAwait(false);
return history.FirstOrDefault();
}
/// <summary>
/// Cancel stops a stack's currently running update. It throws
/// an exception if no update is currently running. Note that
/// this operation is _very dangerous_, and may leave the
/// stack in an inconsistent state if a resource operation was
/// pending when the update was canceled. This command is not
/// supported for local backends.
/// </summary>
public async Task CancelAsync(CancellationToken cancellationToken = default)
=> await this.Workspace.RunCommandAsync(new[] { "cancel", "--stack", this.Name, "--yes" }, cancellationToken).ConfigureAwait(false);
private async Task<CommandResult> RunCommandAsync(
IList<string> args,
Action<string>? onStandardOutput,
Action<string>? onStandardError,
Action<EngineEvent>? onEngineEvent,
CancellationToken cancellationToken)
{
args = args.Concat(new[] { "--stack", this.Name }).ToList();
return await this.Workspace.RunStackCommandAsync(this.Name, args, onStandardOutput, onStandardError, onEngineEvent, cancellationToken).ConfigureAwait(false);
}
public void Dispose()
=> this.Workspace.Dispose();
private static class ExecKind
{
public const string Local = "auto.local";
public const string Inline = "auto.inline";
}
private enum WorkspaceStackInitMode
{
Create,
Select,
CreateOrSelect
}
private class InlineLanguageHost : IAsyncDisposable
{
private readonly TaskCompletionSource<int> _portTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly CancellationToken _cancelToken;
private readonly IHost _host;
private readonly CancellationTokenRegistration _portRegistration;
public InlineLanguageHost(
PulumiFn program,
ILogger? logger,
CancellationToken cancellationToken)
{
this._cancelToken = cancellationToken;
this._host = Host.CreateDefaultBuilder()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder
.ConfigureKestrel(kestrelOptions =>
{
kestrelOptions.Listen(IPAddress.Any, 0, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http2;
});
})
.ConfigureAppConfiguration((context, config) =>
{
// clear so we don't read appsettings.json
// note that we also won't read environment variables for config
config.Sources.Clear();
})
.ConfigureLogging(loggingBuilder =>
{
// disable default logging
loggingBuilder.ClearProviders();
})
.ConfigureServices(services =>
{
// to be injected into LanguageRuntimeService
var callerContext = new LanguageRuntimeService.CallerContext(program, logger, cancellationToken);
services.AddSingleton(callerContext);
services.AddGrpc(grpcOptions =>
{
grpcOptions.MaxReceiveMessageSize = LanguageRuntimeService.MaxRpcMesageSize;
grpcOptions.MaxSendMessageSize = LanguageRuntimeService.MaxRpcMesageSize;
});
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<LanguageRuntimeService>();
});
});
})
.Build();
// before starting the host, set up this callback to tell us what port was selected
this._portRegistration = this._host.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStarted.Register(() =>
{
try
{
var serverFeatures = this._host.Services.GetRequiredService<IServer>().Features;
var addresses = serverFeatures.Get<IServerAddressesFeature>().Addresses.ToList();
Debug.Assert(addresses.Count == 1, "Server should only be listening on one address");
var uri = new Uri(addresses[0]);
this._portTcs.TrySetResult(uri.Port);
}
catch (Exception ex)
{
this._portTcs.TrySetException(ex);
}
});
}
public Task StartAsync()
=> this._host.StartAsync(this._cancelToken);
public Task<int> GetPortAsync()
=> this._portTcs.Task;
public bool TryGetExceptionInfo([NotNullWhen(true)] out ExceptionDispatchInfo? info)
{
var callerContext = this._host.Services.GetRequiredService<LanguageRuntimeService.CallerContext>();
if (callerContext.ExceptionDispatchInfo is null)
{
info = null;
return false;
}
info = callerContext.ExceptionDispatchInfo;
return true;
}
public async ValueTask DisposeAsync()
{
this._portRegistration.Unregister();
await this._host.StopAsync(this._cancelToken).ConfigureAwait(false);
this._host.Dispose();
}
}
}
}