From 34a40d2b102bd5f68c803775c3f42459074680cc Mon Sep 17 00:00:00 2001 From: Justin Van Patten Date: Tue, 18 May 2021 15:01:57 -0700 Subject: [PATCH] [sdk/dotnet] Warn when a secret config is read as a non-secret (#7079) --- CHANGELOG_PENDING.md | 1 + .../LocalWorkspaceTests.cs | 179 ++++++++++++++++++ .../Runtime/LanguageRuntimeService.cs | 3 +- sdk/dotnet/Pulumi/Config.cs | 113 +++++++---- .../Pulumi/Deployment/Deployment_Config.cs | 43 ++++- .../Pulumi/Deployment/Deployment_Inline.cs | 2 +- .../Pulumi/Deployment/IDeploymentInternal.cs | 1 + .../Deployment/InlineDeploymentSettings.cs | 6 +- sdk/dotnet/cmd/pulumi-language-dotnet/main.go | 26 ++- .../config_secrets_warn/dotnet/.gitignore | 5 + .../dotnet/ConfigSecrets.csproj | 8 + .../config_secrets_warn/dotnet/Program.cs | 69 +++++++ .../config_secrets_warn/dotnet/Pulumi.yaml | 3 + tests/integration/integration_dotnet_test.go | 120 ++++++++++++ 14 files changed, 533 insertions(+), 46 deletions(-) create mode 100644 tests/integration/config_secrets_warn/dotnet/.gitignore create mode 100644 tests/integration/config_secrets_warn/dotnet/ConfigSecrets.csproj create mode 100644 tests/integration/config_secrets_warn/dotnet/Program.cs create mode 100644 tests/integration/config_secrets_warn/dotnet/Pulumi.yaml diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index a43bde0e4..e7e40e2d4 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -17,6 +17,7 @@ - Warn when a secret config is read as a non-secret [#6896](https://github.com/pulumi/pulumi/pull/6896) [#7078](https://github.com/pulumi/pulumi/pull/7078) + [#7079](https://github.com/pulumi/pulumi/pull/7079) ### Bug Fixes diff --git a/sdk/dotnet/Pulumi.Automation.Tests/LocalWorkspaceTests.cs b/sdk/dotnet/Pulumi.Automation.Tests/LocalWorkspaceTests.cs index fa4f25a25..9be3c684b 100644 --- a/sdk/dotnet/Pulumi.Automation.Tests/LocalWorkspaceTests.cs +++ b/sdk/dotnet/Pulumi.Automation.Tests/LocalWorkspaceTests.cs @@ -783,6 +783,185 @@ namespace Pulumi.Automation.Tests } } + [Fact] + public async Task ConfigSecretWarnings() + { + var program = PulumiFn.Create(() => + { + var config = new Config(); + + config.Get("plainstr1"); + config.Require("plainstr2"); + config.GetSecret("plainstr3"); + config.RequireSecret("plainstr4"); + + config.GetBoolean("plainbool1"); + config.RequireBoolean("plainbool2"); + config.GetSecretBoolean("plainbool3"); + config.RequireSecretBoolean("plainbool4"); + + config.GetInt32("plainint1"); + config.RequireInt32("plainint2"); + config.GetSecretInt32("plainint3"); + config.RequireSecretInt32("plainint4"); + + config.GetObject("plainobj1"); + config.RequireObject("plainobj2"); + config.GetSecretObject("plainobj3"); + config.RequireSecretObject("plainobj4"); + + config.Get("str1"); + config.Require("str2"); + config.GetSecret("str3"); + config.RequireSecret("str4"); + + config.GetBoolean("bool1"); + config.RequireBoolean("bool2"); + config.GetSecretBoolean("bool3"); + config.RequireSecretBoolean("bool4"); + + config.GetInt32("int1"); + config.RequireInt32("int2"); + config.GetSecretInt32("int3"); + config.RequireSecretInt32("int4"); + + config.GetObject("obj1"); + config.RequireObject("obj2"); + config.GetSecretObject("obj3"); + config.RequireSecretObject("obj4"); + }); + var projectName = "inline_dotnet"; + var stackName = $"inline_dotnet{GetTestSuffix()}"; + using var stack = await LocalWorkspace.CreateStackAsync(new InlineProgramArgs(projectName, stackName, program) + { + EnvironmentVariables = new Dictionary() + { + ["PULUMI_CONFIG_PASSPHRASE"] = "test", + } + }); + + var config = new Dictionary() + { + { "plainstr1", new ConfigValue("1") }, + { "plainstr2", new ConfigValue("2") }, + { "plainstr3", new ConfigValue("3") }, + { "plainstr4", new ConfigValue("4") }, + { "plainbool1", new ConfigValue("true") }, + { "plainbool2", new ConfigValue("true") }, + { "plainbool3", new ConfigValue("true") }, + { "plainbool4", new ConfigValue("true") }, + { "plainint1", new ConfigValue("1") }, + { "plainint2", new ConfigValue("2") }, + { "plainint3", new ConfigValue("3") }, + { "plainint4", new ConfigValue("4") }, + { "plainobj1", new ConfigValue("{}") }, + { "plainobj2", new ConfigValue("{}") }, + { "plainobj3", new ConfigValue("{}") }, + { "plainobj4", new ConfigValue("{}") }, + { "str1", new ConfigValue("1", isSecret: true) }, + { "str2", new ConfigValue("2", isSecret: true) }, + { "str3", new ConfigValue("3", isSecret: true) }, + { "str4", new ConfigValue("4", isSecret: true) }, + { "bool1", new ConfigValue("true", isSecret: true) }, + { "bool2", new ConfigValue("true", isSecret: true) }, + { "bool3", new ConfigValue("true", isSecret: true) }, + { "bool4", new ConfigValue("true", isSecret: true) }, + { "int1", new ConfigValue("1", isSecret: true) }, + { "int2", new ConfigValue("2", isSecret: true) }, + { "int3", new ConfigValue("3", isSecret: true) }, + { "int4", new ConfigValue("4", isSecret: true) }, + { "obj1", new ConfigValue("{}", isSecret: true) }, + { "obj2", new ConfigValue("{}", isSecret: true) }, + { "obj3", new ConfigValue("{}", isSecret: true) }, + { "obj4", new ConfigValue("{}", isSecret: true) }, + }; + + try + { + await stack.SetAllConfigAsync(config); + + // pulumi preview + await RunCommand(stack.PreviewAsync, "preview"); + + // pulumi up + await RunCommand(stack.UpAsync, "up"); + } + finally + { + await stack.Workspace.RemoveStackAsync(stackName); + } + + static async Task RunCommand(Func> func, string command) + where TOptions : UpdateOptions, new() + { + var expectedWarnings = new string[] + { + "Configuration 'inline_dotnet:str1' value is a secret; use `GetSecret` instead of `Get`", + "Configuration 'inline_dotnet:str2' value is a secret; use `RequireSecret` instead of `Require`", + "Configuration 'inline_dotnet:bool1' value is a secret; use `GetSecretBoolean` instead of `GetBoolean`", + "Configuration 'inline_dotnet:bool2' value is a secret; use `RequireSecretBoolean` instead of `RequireBoolean`", + "Configuration 'inline_dotnet:int1' value is a secret; use `GetSecretInt32` instead of `GetInt32`", + "Configuration 'inline_dotnet:int2' value is a secret; use `RequireSecretInt32` instead of `RequireInt32`", + "Configuration 'inline_dotnet:obj1' value is a secret; use `GetSecretObject` instead of `GetObject`", + "Configuration 'inline_dotnet:obj2' value is a secret; use `RequireSecretObject` instead of `RequireObject`", + }; + + // These keys should not be in any warning messages. + var unexpectedWarnings = new string[] + { + "plainstr1", + "plainstr2", + "plainstr3", + "plainstr4", + "plainbool1", + "plainbool2", + "plainbool3", + "plainbool4", + "plainint1", + "plainint2", + "plainint3", + "plainint4", + "plainobj1", + "plainobj2", + "plainobj3", + "plainobj4", + "str3", + "str4", + "bool3", + "bool4", + "int3", + "int4", + "obj3", + "obj4", + }; + + var events = new List(); + + var result = await func(new TOptions() + { + OnEvent = @event => + { + if (@event.DiagnosticEvent?.Severity == "warning") + { + events.Add(@event.DiagnosticEvent); + } + } + }, CancellationToken.None); + + foreach (var expected in expectedWarnings) + { + Assert.Contains(events, @event => @event.Message.Contains(expected)); + } + + foreach (var unexpected in unexpectedWarnings) + { + Assert.DoesNotContain(events, @event => @event.Message.Contains(unexpected)); + } + + return result; + } + } + private class ValidStack : Stack { [Output("exp_static")] diff --git a/sdk/dotnet/Pulumi.Automation/Runtime/LanguageRuntimeService.cs b/sdk/dotnet/Pulumi.Automation/Runtime/LanguageRuntimeService.cs index ddb268427..345f3df5d 100644 --- a/sdk/dotnet/Pulumi.Automation/Runtime/LanguageRuntimeService.cs +++ b/sdk/dotnet/Pulumi.Automation/Runtime/LanguageRuntimeService.cs @@ -47,7 +47,8 @@ namespace Pulumi.Automation request.Project, request.Stack, request.Parallel, - request.DryRun); + request.DryRun, + request.ConfigSecretKeys); using var cts = CancellationTokenSource.CreateLinkedTokenSource( this._callerContext.CancellationToken, diff --git a/sdk/dotnet/Pulumi/Config.cs b/sdk/dotnet/Pulumi/Config.cs index ddde23fee..f26c2120c 100644 --- a/sdk/dotnet/Pulumi/Config.cs +++ b/sdk/dotnet/Pulumi/Config.cs @@ -1,7 +1,9 @@ // Copyright 2016-2019, Pulumi Corporation using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text.Json; namespace Pulumi @@ -54,30 +56,44 @@ namespace Pulumi => Output.CreateSecret(value); + private string? GetImpl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) + { + var fullKey = FullKey(key); + if (use != null && Deployment.InternalInstance.IsConfigSecret(fullKey)) + { + Debug.Assert(insteadOf != null); + Log.Warn($"Configuration '{fullKey}' value is a secret; use `{use}` instead of `{insteadOf}`"); + } + return Deployment.InternalInstance.GetConfig(fullKey); + } + /// /// Loads an optional configuration value by its key, or if it doesn't exist. /// public string? Get(string key) - => Deployment.InternalInstance.GetConfig(FullKey(key)); + => GetImpl(key, nameof(GetSecret)); /// /// Loads an optional configuration value by its key, marking it as a secret, or if it doesn't exist. /// public Output? GetSecret(string key) - => MakeClassSecret(Get(key)); + => MakeClassSecret(GetImpl(key)); + + private bool? GetBooleanImpl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) + { + var v = GetImpl(key, use, insteadOf); + return v == null ? default(bool?) : + v == "true" ? true : + v == "false" ? false : throw new ConfigTypeException(FullKey(key), v, nameof(Boolean)); + } /// /// Loads an optional configuration value, as a boolean, by its key, or null if it doesn't exist. /// If the configuration value isn't a legal boolean, this function will throw an error. /// public bool? GetBoolean(string key) - { - var v = Get(key); - return v == null ? default(bool?) : - v == "true" ? true : - v == "false" ? false : throw new ConfigTypeException(FullKey(key), v, nameof(Boolean)); - } + => GetBooleanImpl(key, nameof(GetSecretBoolean)); /// /// Loads an optional configuration value, as a boolean, by its key, making it as a secret or @@ -85,15 +101,11 @@ namespace Pulumi /// function will throw an error. /// public Output? GetSecretBoolean(string key) - => MakeStructSecret(GetBoolean(key)); + => MakeStructSecret(GetBooleanImpl(key)); - /// - /// Loads an optional configuration value, as a number, by its key, or null if it doesn't exist. - /// If the configuration value isn't a legal number, this function will throw an error. - /// - public int? GetInt32(string key) + private int? GetInt32Impl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) { - var v = Get(key); + var v = GetImpl(key, use, insteadOf); return v == null ? default(int?) : int.TryParse(v, out var result) @@ -101,23 +113,25 @@ namespace Pulumi : throw new ConfigTypeException(FullKey(key), v, nameof(Int32)); } + /// + /// Loads an optional configuration value, as a number, by its key, or null if it doesn't exist. + /// If the configuration value isn't a legal number, this function will throw an error. + /// + public int? GetInt32(string key) + => GetInt32Impl(key, nameof(GetSecretInt32)); + /// /// Loads an optional configuration value, as a number, by its key, marking it as a secret /// or null if it doesn't exist. /// If the configuration value isn't a legal number, this function will throw an error. /// public Output? GetSecretInt32(string key) - => MakeStructSecret(GetInt32(key)); + => MakeStructSecret(GetInt32Impl(key)); - /// - /// Loads an optional configuration value, as an object, by its key, or null if it doesn't - /// exist. This works by taking the value associated with and passing - /// it to . - /// [return: MaybeNull] - public T GetObject(string key) + private T GetObjectImpl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) { - var v = Get(key); + var v = GetImpl(key, use, insteadOf); try { return v == null ? default : JsonSerializer.Deserialize(v); @@ -128,6 +142,15 @@ namespace Pulumi } } + /// + /// Loads an optional configuration value, as an object, by its key, or null if it doesn't + /// exist. This works by taking the value associated with and passing + /// it to . + /// + [return: MaybeNull] + public T GetObject(string key) + => GetObjectImpl(key, nameof(GetSecretObject)); + /// /// Loads an optional configuration value, as an object, by its key, marking it as a secret /// or null if it doesn't exist. This works by taking the value associated with public Output? GetSecretObject(string key) { - var v = Get(key); + var v = GetImpl(key); if (v == null) return null; - return Output.CreateSecret(GetObject(key)!); + return Output.CreateSecret(GetObjectImpl(key)!); } + private string RequireImpl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) + => GetImpl(key, use, insteadOf) ?? throw new ConfigMissingException(FullKey(key)); + /// /// Loads a configuration value by its given key. If it doesn't exist, an error is thrown. /// public string Require(string key) - => Get(key) ?? throw new ConfigMissingException(FullKey(key)); + => RequireImpl(key, nameof(RequireSecret)); /// /// Loads a configuration value by its given key, marking it as a secret. If it doesn't exist, an error /// is thrown. /// public Output RequireSecret(string key) - => MakeClassSecret(Require(key)); + => MakeClassSecret(RequireImpl(key)); + + private bool RequireBooleanImpl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) + => GetBooleanImpl(key, use, insteadOf) ?? throw new ConfigMissingException(FullKey(key)); /// /// Loads a configuration value, as a boolean, by its given key. If it doesn't exist, or the /// configuration value is not a legal boolean, an error is thrown. /// public bool RequireBoolean(string key) - => GetBoolean(key) ?? throw new ConfigMissingException(FullKey(key)); + => RequireBooleanImpl(key, nameof(RequireSecretBoolean)); /// /// Loads a configuration value, as a boolean, by its given key, marking it as a secret. /// If it doesn't exist, or the configuration value is not a legal boolean, an error is thrown. /// public Output RequireSecretBoolean(string key) - => MakeStructSecret(RequireBoolean(key)); + => MakeStructSecret(RequireBooleanImpl(key)); + + private int RequireInt32Impl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) + => GetInt32Impl(key, use, insteadOf) ?? throw new ConfigMissingException(FullKey(key)); /// /// Loads a configuration value, as a number, by its given key. If it doesn't exist, or the /// configuration value is not a legal number, an error is thrown. /// public int RequireInt32(string key) - => GetInt32(key) ?? throw new ConfigMissingException(FullKey(key)); + => RequireInt32Impl(key, nameof(RequireSecretInt32)); /// /// Loads a configuration value, as a number, by its given key, marking it as a secret. /// If it doesn't exist, or the configuration value is not a legal number, an error is thrown. /// public Output RequireSecretInt32(string key) - => MakeStructSecret(RequireInt32(key)); + => MakeStructSecret(RequireInt32Impl(key)); + + private T RequireObjectImpl(string key, string? use = null, [CallerMemberName] string? insteadOf = null) + { + var v = GetImpl(key); + if (v == null) + throw new ConfigMissingException(FullKey(key)); + + return GetObjectImpl(key, use, insteadOf)!; + } /// /// Loads a configuration value as a JSON string and deserializes the JSON into an object. @@ -191,13 +232,7 @@ namespace Pulumi /// thrown. /// public T RequireObject(string key) - { - var v = Get(key); - if (v == null) - throw new ConfigMissingException(FullKey(key)); - - return GetObject(key)!; - } + => RequireObjectImpl(key, nameof(RequireSecretObject)); /// /// Loads a configuration value as a JSON string and deserializes the JSON into a JavaScript @@ -206,7 +241,7 @@ namespace Pulumi /// JsonSerializerOptions)"/>. an error is thrown. /// public Output RequireSecretObject(string key) - => Output.CreateSecret(RequireObject(key)); + => Output.CreateSecret(RequireObjectImpl(key)); /// /// Turns a simple configuration key into a fully resolved one, by prepending the bag's name. diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment_Config.cs b/sdk/dotnet/Pulumi/Deployment/Deployment_Config.cs index 9373f6e96..263354dbe 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment_Config.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment_Config.cs @@ -14,11 +14,21 @@ namespace Pulumi /// private const string _configEnvKey = "PULUMI_CONFIG"; + /// + /// The environment variable key that the language plugin uses to set the list of secret configuration keys. + /// + private const string _configSecretKeysEnvKey = "PULUMI_CONFIG_SECRET_KEYS"; + /// /// Returns a copy of the full config map. /// internal ImmutableDictionary AllConfig { get; private set; } = ParseConfig(); + /// + /// Returns a copy of the config secret keys. + /// + internal ImmutableHashSet ConfigSecretKeys { get; private set; } = ParseConfigSecretKeys(); + /// /// Sets a configuration variable. /// @@ -28,8 +38,14 @@ namespace Pulumi /// /// Appends all provided configuration. /// - internal void SetAllConfig(IDictionary config) - => AllConfig = AllConfig.AddRange(config); + internal void SetAllConfig(IDictionary config, IEnumerable? secretKeys = null) + { + AllConfig = AllConfig.AddRange(config); + if (secretKeys != null) + { + ConfigSecretKeys = ConfigSecretKeys.Union(secretKeys); + } + } /// /// Returns a configuration variable's value or if it is unset. @@ -37,6 +53,12 @@ namespace Pulumi string? IDeploymentInternal.GetConfig(string key) => AllConfig.TryGetValue(key, out var value) ? value : null; + /// + /// Returns true if the key contains a secret value. + /// + bool IDeploymentInternal.IsConfigSecret(string fullKey) + => ConfigSecretKeys.Contains(fullKey); + private static ImmutableDictionary ParseConfig() { var parsedConfig = ImmutableDictionary.CreateBuilder(); @@ -54,6 +76,23 @@ namespace Pulumi return parsedConfig.ToImmutable(); } + private static ImmutableHashSet ParseConfigSecretKeys() + { + var parsedConfigSecretKeys = ImmutableHashSet.CreateBuilder(); + var envConfigSecretKeys = Environment.GetEnvironmentVariable(_configSecretKeysEnvKey); + + if (envConfigSecretKeys != null) + { + var envObject = JsonDocument.Parse(envConfigSecretKeys); + foreach (var element in envObject.RootElement.EnumerateArray()) + { + parsedConfigSecretKeys.Add(element.GetString()); + } + } + + return parsedConfigSecretKeys.ToImmutable(); + } + /// /// CleanKey takes a configuration key, and if it is of the form "(string):config:(string)" /// removes the ":config:" portion. Previously, our keys always had the string ":config:" in diff --git a/sdk/dotnet/Pulumi/Deployment/Deployment_Inline.cs b/sdk/dotnet/Pulumi/Deployment/Deployment_Inline.cs index 7b16df7ae..c6da4bef9 100644 --- a/sdk/dotnet/Pulumi/Deployment/Deployment_Inline.cs +++ b/sdk/dotnet/Pulumi/Deployment/Deployment_Inline.cs @@ -16,7 +16,7 @@ namespace Pulumi _projectName = settings.Project; _stackName = settings.Stack; _isDryRun = settings.IsDryRun; - SetAllConfig(settings.Config); + SetAllConfig(settings.Config, settings.ConfigSecretKeys); if (string.IsNullOrEmpty(settings.MonitorAddr) || string.IsNullOrEmpty(settings.EngineAddr) diff --git a/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs b/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs index 769385f17..4343f6fab 100644 --- a/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs +++ b/sdk/dotnet/Pulumi/Deployment/IDeploymentInternal.cs @@ -8,6 +8,7 @@ namespace Pulumi internal interface IDeploymentInternal : IDeployment { string? GetConfig(string fullKey); + bool IsConfigSecret(string fullKey); Stack Stack { get; set; } diff --git a/sdk/dotnet/Pulumi/Deployment/InlineDeploymentSettings.cs b/sdk/dotnet/Pulumi/Deployment/InlineDeploymentSettings.cs index dd50131ce..1efd2e61a 100644 --- a/sdk/dotnet/Pulumi/Deployment/InlineDeploymentSettings.cs +++ b/sdk/dotnet/Pulumi/Deployment/InlineDeploymentSettings.cs @@ -10,6 +10,8 @@ namespace Pulumi public IDictionary Config { get; } + public IEnumerable? ConfigSecretKeys { get; } + public string Project { get; } public string Stack { get; } @@ -25,7 +27,8 @@ namespace Pulumi string project, string stack, int parallel, - bool isDryRun) + bool isDryRun, + IEnumerable? configSecretKeys = null) { EngineAddr = engineAddr; MonitorAddr = monitorAddr; @@ -34,6 +37,7 @@ namespace Pulumi Stack = stack; Parallel = parallel; IsDryRun = isDryRun; + ConfigSecretKeys = configSecretKeys; } } } diff --git a/sdk/dotnet/cmd/pulumi-language-dotnet/main.go b/sdk/dotnet/cmd/pulumi-language-dotnet/main.go index fe3ec023b..310ba99fe 100644 --- a/sdk/dotnet/cmd/pulumi-language-dotnet/main.go +++ b/sdk/dotnet/cmd/pulumi-language-dotnet/main.go @@ -488,6 +488,11 @@ func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunReque err = errors.Wrap(err, "failed to serialize configuration") return nil, err } + configSecretKeys, err := host.constructConfigSecretKeys(req) + if err != nil { + err = errors.Wrap(err, "failed to serialize configuration secret keys") + return nil, err + } executable := host.exec args := []string{} @@ -518,7 +523,7 @@ func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunReque cmd := exec.Command(executable, args...) // nolint: gas // intentionally running dynamic program name. cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = host.constructEnv(req, config) + cmd.Env = host.constructEnv(req, config, configSecretKeys) if err := cmd.Run(); err != nil { if exiterr, ok := err.(*exec.ExitError); ok { // If the program ran, but exited with a non-zero error code. This will happen often, since user @@ -547,7 +552,7 @@ func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunReque return &pulumirpc.RunResponse{Error: errResult}, nil } -func (host *dotnetLanguageHost) constructEnv(req *pulumirpc.RunRequest, config string) []string { +func (host *dotnetLanguageHost) constructEnv(req *pulumirpc.RunRequest, config, configSecretKeys string) []string { env := os.Environ() maybeAppendEnv := func(k, v string) { @@ -566,6 +571,7 @@ func (host *dotnetLanguageHost) constructEnv(req *pulumirpc.RunRequest, config s maybeAppendEnv("parallel", fmt.Sprint(req.GetParallel())) maybeAppendEnv("tracing", host.tracing) maybeAppendEnv("config", config) + maybeAppendEnv("config_secret_keys", configSecretKeys) return env } @@ -585,6 +591,22 @@ func (host *dotnetLanguageHost) constructConfig(req *pulumirpc.RunRequest) (stri return string(configJSON), nil } +// constructConfigSecretKeys JSON-serializes the list of keys that contain secret values given as part of +// a RunRequest. +func (host *dotnetLanguageHost) constructConfigSecretKeys(req *pulumirpc.RunRequest) (string, error) { + configSecretKeys := req.GetConfigSecretKeys() + if configSecretKeys == nil { + return "[]", nil + } + + configSecretKeysJSON, err := json.Marshal(configSecretKeys) + if err != nil { + return "", err + } + + return string(configSecretKeysJSON), nil +} + func (host *dotnetLanguageHost) GetPluginInfo(ctx context.Context, req *pbempty.Empty) (*pulumirpc.PluginInfo, error) { return &pulumirpc.PluginInfo{ Version: version.Version, diff --git a/tests/integration/config_secrets_warn/dotnet/.gitignore b/tests/integration/config_secrets_warn/dotnet/.gitignore new file mode 100644 index 000000000..fd544f76a --- /dev/null +++ b/tests/integration/config_secrets_warn/dotnet/.gitignore @@ -0,0 +1,5 @@ +/.pulumi/ +[Bb]in/ +[Oo]bj/ + + diff --git a/tests/integration/config_secrets_warn/dotnet/ConfigSecrets.csproj b/tests/integration/config_secrets_warn/dotnet/ConfigSecrets.csproj new file mode 100644 index 000000000..9acff6d2a --- /dev/null +++ b/tests/integration/config_secrets_warn/dotnet/ConfigSecrets.csproj @@ -0,0 +1,8 @@ + + + + Exe + netcoreapp3.1 + + + diff --git a/tests/integration/config_secrets_warn/dotnet/Program.cs b/tests/integration/config_secrets_warn/dotnet/Program.cs new file mode 100644 index 000000000..af18159a8 --- /dev/null +++ b/tests/integration/config_secrets_warn/dotnet/Program.cs @@ -0,0 +1,69 @@ +// Copyright 2016-2021, Pulumi Corporation. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Pulumi; + +class Program +{ + static Task Main(string[] args) + { + return Deployment.RunAsync(() => + { + var config = new Config(); + + config.Get("plainstr1"); + config.Require("plainstr2"); + config.GetSecret("plainstr3"); + config.RequireSecret("plainstr4"); + + config.GetBoolean("plainbool1"); + config.RequireBoolean("plainbool2"); + config.GetSecretBoolean("plainbool3"); + config.RequireSecretBoolean("plainbool4"); + + config.GetInt32("plainint1"); + config.RequireInt32("plainint2"); + config.GetSecretInt32("plainint3"); + config.RequireSecretInt32("plainint4"); + + config.GetObject("plainobj1"); + config.RequireObject("plainobj2"); + config.GetSecretObject("plainobj3"); + config.RequireSecretObject("plainobj4"); + + config.Get("str1"); + config.Require("str2"); + config.GetSecret("str3"); + config.RequireSecret("str4"); + + config.GetBoolean("bool1"); + config.RequireBoolean("bool2"); + config.GetSecretBoolean("bool3"); + config.RequireSecretBoolean("bool4"); + + config.GetInt32("int1"); + config.RequireInt32("int2"); + config.GetSecretInt32("int3"); + config.RequireSecretInt32("int4"); + + config.GetObject("obj1"); + config.RequireObject("obj2"); + config.GetSecretObject("obj3"); + config.RequireSecretObject("obj4"); + + config.GetObject("parent1"); + config.RequireObject("parent2"); + config.GetSecretObject("parent1"); + config.RequireSecretObject("parent2"); + + config.GetObject("names1"); + config.RequireObject("names2"); + config.GetSecretObject("names1"); + config.RequireSecretObject("names2"); + }); + } +} diff --git a/tests/integration/config_secrets_warn/dotnet/Pulumi.yaml b/tests/integration/config_secrets_warn/dotnet/Pulumi.yaml new file mode 100644 index 000000000..8c1985306 --- /dev/null +++ b/tests/integration/config_secrets_warn/dotnet/Pulumi.yaml @@ -0,0 +1,3 @@ +name: config_secrets_dotnet +description: A simple .NET program that uses configuration secrets. +runtime: dotnet diff --git a/tests/integration/integration_dotnet_test.go b/tests/integration/integration_dotnet_test.go index ad26bd609..9c673decc 100644 --- a/tests/integration/integration_dotnet_test.go +++ b/tests/integration/integration_dotnet_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/pulumi/pulumi/pkg/v3/testing/integration" @@ -120,6 +121,125 @@ func TestConfigBasicDotNet(t *testing.T) { }) } +// Tests that accessing config secrets using non-secret APIs results in warnings being logged. +func TestConfigSecretsWarnDotNet(t *testing.T) { + integration.ProgramTest(t, &integration.ProgramTestOptions{ + Dir: filepath.Join("config_secrets_warn", "dotnet"), + Dependencies: []string{"Pulumi"}, + Quick: true, + Config: map[string]string{ + "plainstr1": "1", + "plainstr2": "2", + "plainstr3": "3", + "plainstr4": "4", + "plainbool1": "true", + "plainbool2": "true", + "plainbool3": "true", + "plainbool4": "true", + "plainint1": "1", + "plainint2": "2", + "plainint3": "3", + "plainint4": "4", + "plainobj1": "{}", + "plainobj2": "{}", + "plainobj3": "{}", + "plainobj4": "{}", + }, + Secrets: map[string]string{ + "str1": "1", + "str2": "2", + "str3": "3", + "str4": "4", + "bool1": "true", + "bool2": "true", + "bool3": "true", + "bool4": "true", + "int1": "1", + "int2": "2", + "int3": "3", + "int4": "4", + "obj1": "{}", + "obj2": "{}", + "obj3": "{}", + "obj4": "{}", + }, + OrderedConfig: []integration.ConfigValue{ + {Key: "parent1.foo", Value: "plain1", Path: true}, + {Key: "parent1.bar", Value: "secret1", Path: true, Secret: true}, + {Key: "parent2.foo", Value: "plain2", Path: true}, + {Key: "parent2.bar", Value: "secret2", Path: true, Secret: true}, + {Key: "names1[0]", Value: "plain1", Path: true}, + {Key: "names1[1]", Value: "secret1", Path: true, Secret: true}, + {Key: "names2[0]", Value: "plain2", Path: true}, + {Key: "names2[1]", Value: "secret2", Path: true, Secret: true}, + }, + ExtraRuntimeValidation: func(t *testing.T, stackInfo integration.RuntimeValidationStackInfo) { + assert.NotEmpty(t, stackInfo.Events) + //nolint:lll + expectedWarnings := []string{ + "Configuration 'config_secrets_dotnet:str1' value is a secret; use `GetSecret` instead of `Get`", + "Configuration 'config_secrets_dotnet:str2' value is a secret; use `RequireSecret` instead of `Require`", + "Configuration 'config_secrets_dotnet:bool1' value is a secret; use `GetSecretBoolean` instead of `GetBoolean`", + "Configuration 'config_secrets_dotnet:bool2' value is a secret; use `RequireSecretBoolean` instead of `RequireBoolean`", + "Configuration 'config_secrets_dotnet:int1' value is a secret; use `GetSecretInt32` instead of `GetInt32`", + "Configuration 'config_secrets_dotnet:int2' value is a secret; use `RequireSecretInt32` instead of `RequireInt32`", + "Configuration 'config_secrets_dotnet:obj1' value is a secret; use `GetSecretObject` instead of `GetObject`", + "Configuration 'config_secrets_dotnet:obj2' value is a secret; use `RequireSecretObject` instead of `RequireObject`", + "Configuration 'config_secrets_dotnet:parent1' value is a secret; use `GetSecretObject` instead of `GetObject`", + "Configuration 'config_secrets_dotnet:parent2' value is a secret; use `RequireSecretObject` instead of `RequireObject`", + "Configuration 'config_secrets_dotnet:names1' value is a secret; use `GetSecretObject` instead of `GetObject`", + "Configuration 'config_secrets_dotnet:names2' value is a secret; use `RequireSecretObject` instead of `RequireObject`", + } + for _, warning := range expectedWarnings { + var found bool + for _, event := range stackInfo.Events { + if event.DiagnosticEvent != nil && event.DiagnosticEvent.Severity == "warning" && + strings.Contains(event.DiagnosticEvent.Message, warning) { + found = true + break + } + } + assert.True(t, found, "expected warning %q", warning) + } + + // These keys should not be in any warning messages. + unexpectedWarnings := []string{ + "plainstr1", + "plainstr2", + "plainstr3", + "plainstr4", + "plainbool1", + "plainbool2", + "plainbool3", + "plainbool4", + "plainint1", + "plainint2", + "plainint3", + "plainint4", + "plainobj1", + "plainobj2", + "plainobj3", + "plainobj4", + "str3", + "str4", + "bool3", + "bool4", + "int3", + "int4", + "obj3", + "obj4", + } + for _, warning := range unexpectedWarnings { + for _, event := range stackInfo.Events { + if event.DiagnosticEvent != nil { + assert.NotContains(t, event.DiagnosticEvent.Message, warning) + } + } + } + }, + }) +} + // Tests that stack references work in .NET. func TestStackReferenceDotnet(t *testing.T) { if runtime.GOOS == WindowsOS {