Skip to content

Commit 29ecf01

Browse files
committed
feat(dotnet): implement config options functionality in auto api
This PR adds support for config options in the .NET SDK, matching the functionality available in other language SDKs. The changes include: - Add ConfigOptions class to handle config options settings - Add GetAllConfigOptions class for retrieving all config options - Update Workspace class with config options support - Update WorkspaceStack class with config options support - Add config options to StackSettings - Update tests to verify config options functionality The implementation follows the same pattern as other language SDKs and includes comprehensive test coverage. This change enables users to set and retrieve config options at both workspace and stack levels.
1 parent af68281 commit 29ecf01

File tree

6 files changed

+401
-2
lines changed

6 files changed

+401
-2
lines changed

pulumi

Submodule pulumi updated 1804 files

sdk/Pulumi.Automation.Tests/LocalWorkspaceTests.cs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2513,5 +2513,152 @@ public async Task InstallLanguageVersionToolsRequiresSupportedVersion()
25132513

25142514
await Assert.ThrowsAsync<InvalidOperationException>(async () => await workspace.InstallAsync(installOptions));
25152515
}
2516+
2517+
[Fact]
2518+
public async Task ConfigOptions_PathOption_Works()
2519+
{
2520+
var projectName = "config_options_path_test";
2521+
var projectSettings = new ProjectSettings(projectName, ProjectRuntimeName.NodeJS);
2522+
using var workspace = await LocalWorkspace.CreateAsync(new LocalWorkspaceOptions
2523+
{
2524+
ProjectSettings = projectSettings,
2525+
EnvironmentVariables = new Dictionary<string, string?>
2526+
{
2527+
["PULUMI_CONFIG_PASSPHRASE"] = "test",
2528+
}
2529+
});
2530+
var stackName = $"{RandomStackName()}";
2531+
var stack = await WorkspaceStack.CreateAsync(stackName, workspace);
2532+
try
2533+
{
2534+
var options = new ConfigOptions(path: true);
2535+
await stack.SetConfigWithOptionsAsync("foo.bar", new ConfigValue("baz"), options);
2536+
var value = await stack.GetConfigWithOptionsAsync("foo.bar", options);
2537+
Assert.Equal("baz", value.Value);
2538+
}
2539+
finally
2540+
{
2541+
await workspace.RemoveStackAsync(stackName);
2542+
}
2543+
}
2544+
2545+
[Fact]
2546+
public async Task ConfigOptions_ConfigFileOption_Works()
2547+
{
2548+
var projectName = "config_options_configfile_test";
2549+
var projectSettings = new ProjectSettings(projectName, ProjectRuntimeName.NodeJS);
2550+
var configFile = Path.Combine(Path.GetTempPath(), $"Pulumi.{Guid.NewGuid()}.yaml");
2551+
File.WriteAllText(configFile, "config: { test:key: value }\n");
2552+
using var workspace = await LocalWorkspace.CreateAsync(new LocalWorkspaceOptions
2553+
{
2554+
ProjectSettings = projectSettings,
2555+
EnvironmentVariables = new Dictionary<string, string?>
2556+
{
2557+
["PULUMI_CONFIG_PASSPHRASE"] = "test",
2558+
}
2559+
});
2560+
var stackName = $"{RandomStackName()}";
2561+
var stack = await WorkspaceStack.CreateAsync(stackName, workspace);
2562+
try
2563+
{
2564+
var options = new ConfigOptions(configFile: configFile);
2565+
var value = await stack.GetConfigWithOptionsAsync("test:key", options);
2566+
Assert.Equal("value", value.Value);
2567+
}
2568+
finally
2569+
{
2570+
await workspace.RemoveStackAsync(stackName);
2571+
File.Delete(configFile);
2572+
}
2573+
}
2574+
2575+
[Fact]
2576+
public async Task ConfigOptions_ShowSecretsOption_Works()
2577+
{
2578+
var projectName = "config_options_secrets_test";
2579+
var projectSettings = new ProjectSettings(projectName, ProjectRuntimeName.NodeJS);
2580+
using var workspace = await LocalWorkspace.CreateAsync(new LocalWorkspaceOptions
2581+
{
2582+
ProjectSettings = projectSettings,
2583+
EnvironmentVariables = new Dictionary<string, string?>
2584+
{
2585+
["PULUMI_CONFIG_PASSPHRASE"] = "test",
2586+
}
2587+
});
2588+
var stackName = $"{RandomStackName()}";
2589+
var stack = await WorkspaceStack.CreateAsync(stackName, workspace);
2590+
try
2591+
{
2592+
var config = new Dictionary<string, ConfigValue>
2593+
{
2594+
["secret"] = new ConfigValue("supersecret", isSecret: true),
2595+
};
2596+
await stack.SetAllConfigAsync(config);
2597+
var options = new GetAllConfigOptions(showSecrets: true);
2598+
var values = await stack.GetAllConfigWithOptionsAsync(options);
2599+
Assert.True(values.TryGetValue($"{projectName}:secret", out var secretValue));
2600+
Assert.Equal("supersecret", secretValue!.Value);
2601+
Assert.True(secretValue.IsSecret);
2602+
}
2603+
finally
2604+
{
2605+
await workspace.RemoveStackAsync(stackName);
2606+
}
2607+
}
2608+
2609+
[Fact]
2610+
public async Task ConfigOptions_MultipleOptions_Works()
2611+
{
2612+
var projectName = "config_options_multi_test";
2613+
var projectSettings = new ProjectSettings(projectName, ProjectRuntimeName.NodeJS);
2614+
using var workspace = await LocalWorkspace.CreateAsync(new LocalWorkspaceOptions
2615+
{
2616+
ProjectSettings = projectSettings,
2617+
EnvironmentVariables = new Dictionary<string, string?>
2618+
{
2619+
["PULUMI_CONFIG_PASSPHRASE"] = "test",
2620+
}
2621+
});
2622+
var stackName = $"{RandomStackName()}";
2623+
var stack = await WorkspaceStack.CreateAsync(stackName, workspace);
2624+
try
2625+
{
2626+
var options = new ConfigOptions(path: true, showSecrets: true);
2627+
await stack.SetConfigWithOptionsAsync("foo.bar", new ConfigValue("baz", isSecret: true), options);
2628+
var value = await stack.GetConfigWithOptionsAsync("foo.bar", options);
2629+
Assert.Equal("baz", value.Value);
2630+
}
2631+
finally
2632+
{
2633+
await workspace.RemoveStackAsync(stackName);
2634+
}
2635+
}
2636+
2637+
[Fact]
2638+
public async Task ConfigOptions_InvalidConfigFile_Throws()
2639+
{
2640+
var projectName = "config_options_invalidfile_test";
2641+
var projectSettings = new ProjectSettings(projectName, ProjectRuntimeName.NodeJS);
2642+
var invalidConfigFile = Path.Combine(Path.GetTempPath(), $"Pulumi.invalid.{Guid.NewGuid()}.yaml");
2643+
using var workspace = await LocalWorkspace.CreateAsync(new LocalWorkspaceOptions
2644+
{
2645+
ProjectSettings = projectSettings,
2646+
EnvironmentVariables = new Dictionary<string, string?>
2647+
{
2648+
["PULUMI_CONFIG_PASSPHRASE"] = "test",
2649+
}
2650+
});
2651+
var stackName = $"{RandomStackName()}";
2652+
var stack = await WorkspaceStack.CreateAsync(stackName, workspace);
2653+
try
2654+
{
2655+
var options = new ConfigOptions(configFile: invalidConfigFile);
2656+
await Assert.ThrowsAsync<CommandException>(() => stack.GetConfigWithOptionsAsync("foo", options));
2657+
}
2658+
finally
2659+
{
2660+
await workspace.RemoveStackAsync(stackName);
2661+
}
2662+
}
25162663
}
25172664
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2024, Pulumi Corporation
2+
3+
namespace Pulumi.Automation
4+
{
5+
public class ConfigOptions
6+
{
7+
public bool Path { get; set; }
8+
public string? ConfigFile { get; set; }
9+
public bool ShowSecrets { get; set; }
10+
11+
public ConfigOptions(bool path = false, string? configFile = null, bool showSecrets = false)
12+
{
13+
Path = path;
14+
ConfigFile = configFile;
15+
ShowSecrets = showSecrets;
16+
}
17+
}
18+
19+
public class GetAllConfigOptions
20+
{
21+
public bool Path { get; set; }
22+
public string? ConfigFile { get; set; }
23+
public bool ShowSecrets { get; set; }
24+
25+
public GetAllConfigOptions(bool path = false, string? configFile = null, bool showSecrets = false)
26+
{
27+
Path = path;
28+
ConfigFile = configFile;
29+
ShowSecrets = showSecrets;
30+
}
31+
}
32+
}

sdk/Pulumi.Automation/LocalWorkspace.cs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,5 +1040,198 @@ private void CheckSupportsEnvironmentsCommands()
10401040
}
10411041
}
10421042

1043+
// Config options overloads
1044+
public override async Task<ConfigValue> GetConfigWithOptionsAsync(string stackName, string key, ConfigOptions? options = null, CancellationToken cancellationToken = default)
1045+
{
1046+
var args = new List<string> { "config", "get", key, "--json", "--stack", stackName };
1047+
if (options != null)
1048+
{
1049+
if (options.Path) args.Add("--path");
1050+
if (!string.IsNullOrEmpty(options.ConfigFile)) { args.Add("--config-file"); args.Add(options.ConfigFile!); }
1051+
// Note: --show-secrets flag is not supported for 'config get' command,
1052+
// it's only for 'config' command (get all)
1053+
}
1054+
var result = await this.RunCommandAsync(args, null, null, null, null, cancellationToken).ConfigureAwait(false);
1055+
1056+
if (string.IsNullOrWhiteSpace(result.StandardOutput))
1057+
{
1058+
return new ConfigValue(string.Empty);
1059+
}
1060+
1061+
try
1062+
{
1063+
var jsonOptions = new System.Text.Json.JsonSerializerOptions
1064+
{
1065+
PropertyNameCaseInsensitive = true
1066+
};
1067+
1068+
// Try deserializing as ConfigValue object with value/secret properties
1069+
var configValue = System.Text.Json.JsonSerializer.Deserialize<ConfigValueModel>(
1070+
result.StandardOutput, jsonOptions);
1071+
1072+
if (configValue != null)
1073+
{
1074+
return new ConfigValue(configValue.Value ?? string.Empty, configValue.Secret);
1075+
}
1076+
1077+
// If that fails, might be a plain string value
1078+
return new ConfigValue(result.StandardOutput.Trim('"'));
1079+
}
1080+
catch (System.Text.Json.JsonException)
1081+
{
1082+
// If JSON parsing fails, return the raw value trimmed
1083+
return new ConfigValue(result.StandardOutput.Trim());
1084+
}
1085+
}
1086+
1087+
// Helper class to deserialize JSON response from pulumi config get --json
1088+
private class ConfigValueModel
1089+
{
1090+
public string? Value { get; set; }
1091+
public bool Secret { get; set; }
1092+
}
1093+
1094+
public override async Task SetConfigWithOptionsAsync(string stackName, string key, ConfigValue value, ConfigOptions? options = null, CancellationToken cancellationToken = default)
1095+
{
1096+
var args = new List<string> { "config", "set", key, value.Value, "--stack", stackName };
1097+
if (options != null)
1098+
{
1099+
if (options.Path) args.Add("--path");
1100+
if (!string.IsNullOrEmpty(options.ConfigFile)) { args.Add("--config-file"); args.Add(options.ConfigFile!); }
1101+
}
1102+
if (value.IsSecret) args.Add("--secret");
1103+
await this.RunCommandAsync(args, null, null, null, null, cancellationToken).ConfigureAwait(false);
1104+
}
1105+
1106+
public override async Task RemoveConfigWithOptionsAsync(string stackName, string key, ConfigOptions? options = null, CancellationToken cancellationToken = default)
1107+
{
1108+
var args = new List<string> { "config", "rm", key, "--stack", stackName };
1109+
if (options != null)
1110+
{
1111+
if (options.Path) args.Add("--path");
1112+
if (!string.IsNullOrEmpty(options.ConfigFile)) { args.Add("--config-file"); args.Add(options.ConfigFile!); }
1113+
}
1114+
await this.RunCommandAsync(args, null, null, null, null, cancellationToken).ConfigureAwait(false);
1115+
}
1116+
1117+
public override async Task<ImmutableDictionary<string, ConfigValue>> GetAllConfigWithOptionsAsync(string stackName, GetAllConfigOptions? options = null, CancellationToken cancellationToken = default)
1118+
{
1119+
var args = new List<string> { "config", "--json", "--stack", stackName };
1120+
if (options != null)
1121+
{
1122+
if (options.Path) args.Add("--path");
1123+
if (!string.IsNullOrEmpty(options.ConfigFile)) { args.Add("--config-file"); args.Add(options.ConfigFile!); }
1124+
if (options.ShowSecrets) args.Add("--show-secrets");
1125+
}
1126+
var result = await this.RunCommandAsync(args, null, null, null, null, cancellationToken).ConfigureAwait(false);
1127+
1128+
if (string.IsNullOrWhiteSpace(result.StandardOutput))
1129+
{
1130+
return ImmutableDictionary<string, ConfigValue>.Empty;
1131+
}
1132+
1133+
try
1134+
{
1135+
// Parse JSON response into Dictionary<string, JsonElement> first to handle various value types
1136+
var jsonOptions = new System.Text.Json.JsonSerializerOptions
1137+
{
1138+
PropertyNameCaseInsensitive = true
1139+
};
1140+
1141+
var configValues = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(
1142+
result.StandardOutput, jsonOptions);
1143+
1144+
if (configValues == null)
1145+
{
1146+
return ImmutableDictionary<string, ConfigValue>.Empty;
1147+
}
1148+
1149+
// Convert each entry to a ConfigValue
1150+
var builder = ImmutableDictionary.CreateBuilder<string, ConfigValue>();
1151+
foreach (var entry in configValues)
1152+
{
1153+
if (entry.Value.ValueKind == System.Text.Json.JsonValueKind.Object)
1154+
{
1155+
// For secret values, look for a structure like {"value":"secretvalue","secret":true}
1156+
var isSecret = false;
1157+
var value = string.Empty;
1158+
1159+
if (entry.Value.TryGetProperty("secret", out var secretProp) &&
1160+
secretProp.ValueKind == System.Text.Json.JsonValueKind.True)
1161+
{
1162+
isSecret = true;
1163+
}
1164+
1165+
if (entry.Value.TryGetProperty("value", out var valueProp) &&
1166+
valueProp.ValueKind == System.Text.Json.JsonValueKind.String)
1167+
{
1168+
value = valueProp.GetString() ?? string.Empty;
1169+
}
1170+
1171+
builder.Add(entry.Key, new ConfigValue(value, isSecret));
1172+
}
1173+
else if (entry.Value.ValueKind == System.Text.Json.JsonValueKind.String)
1174+
{
1175+
// Plain string value
1176+
builder.Add(entry.Key, new ConfigValue(entry.Value.GetString() ?? string.Empty));
1177+
}
1178+
else
1179+
{
1180+
// Convert any other value to string
1181+
builder.Add(entry.Key, new ConfigValue(entry.Value.ToString()));
1182+
}
1183+
}
1184+
1185+
return builder.ToImmutable();
1186+
}
1187+
catch (System.Text.Json.JsonException ex)
1188+
{
1189+
throw new InvalidOperationException($"Could not parse config values: {ex.Message}");
1190+
}
1191+
}
1192+
1193+
public override async Task SetAllConfigWithOptionsAsync(string stackName, IDictionary<string, ConfigValue> configMap, ConfigOptions? options = null, CancellationToken cancellationToken = default)
1194+
{
1195+
if (configMap == null || !configMap.Any())
1196+
{
1197+
return;
1198+
}
1199+
1200+
var args = new List<string> { "config", "set-all", "--stack", stackName };
1201+
if (options != null)
1202+
{
1203+
if (options.Path) args.Add("--path");
1204+
if (!string.IsNullOrEmpty(options.ConfigFile)) { args.Add("--config-file"); args.Add(options.ConfigFile!); }
1205+
}
1206+
1207+
// For each config value, add as either secret or plaintext
1208+
foreach (var kvp in configMap)
1209+
{
1210+
args.Add(kvp.Value.IsSecret ? "--secret" : "--plaintext");
1211+
args.Add($"{kvp.Key}={kvp.Value.Value}");
1212+
}
1213+
1214+
await this.RunCommandAsync(args, null, null, null, null, cancellationToken).ConfigureAwait(false);
1215+
}
1216+
1217+
public override async Task RemoveAllConfigWithOptionsAsync(string stackName, IEnumerable<string> keys, ConfigOptions? options = null, CancellationToken cancellationToken = default)
1218+
{
1219+
if (keys == null || !keys.Any())
1220+
{
1221+
return;
1222+
}
1223+
1224+
var args = new List<string> { "config", "rm-all", "--stack", stackName };
1225+
if (options != null)
1226+
{
1227+
if (options.Path) args.Add("--path");
1228+
if (!string.IsNullOrEmpty(options.ConfigFile)) { args.Add("--config-file"); args.Add(options.ConfigFile!); }
1229+
}
1230+
1231+
// Add all keys to remove
1232+
args.AddRange(keys);
1233+
1234+
await this.RunCommandAsync(args, null, null, null, null, cancellationToken).ConfigureAwait(false);
1235+
}
10431236
}
10441237
}

0 commit comments

Comments
 (0)