Skip to content

Commit

Permalink
Update configure command to support configuring feature flags (#96)
Browse files Browse the repository at this point in the history
* Update configure command to support configuring feature flags

This uses the changes from github/valet#5879 to fetch available flags
via the list-features --json flag. It parses out the possible env vars
and walks the customer through setting any feature flags they desire.

If there are any issues getting the available feature flags JSON, we
don't ask the customer to configure feature flags, as it won't cause any
breaking issues and we don't want to break the configure command.

* Move feature configuration to its own flag

* Add unit tests

* Undo changes to ConfigurationServiceTests

* Code review fixes
  • Loading branch information
Chaseshak authored Apr 5, 2023
1 parent 8d75293 commit e3c8e96
Show file tree
Hide file tree
Showing 12 changed files with 253 additions and 9 deletions.
53 changes: 53 additions & 0 deletions src/ActionsImporter.UnitTests/Models/FeatureTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Text.Json;
using ActionsImporter.Models;
using NUnit.Framework;

namespace ActionsImporter.UnitTests.Models;

[TestFixture]
public class FeatureTests
{
private readonly string featureResult = @"
{
""name"": ""actions/cache"",
""description"": ""Control usage of actions/cache inside of workflows. Outputs a comment if not enabled."",
""enabled"": false,
""ghes_version"": ""ghes-3.5"",
""customer_facing"": true,
""env_name"": ""FEATURE_ACTIONS_CACHE""
}
";

[Test]
public void Initialize()
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, };
var feature = JsonSerializer.Deserialize<Feature>(featureResult, options);

Assert.AreEqual("actions/cache", feature?.Name);
Assert.AreEqual("Control usage of actions/cache inside of workflows. Outputs a comment if not enabled.", feature?.Description);
Assert.AreEqual("FEATURE_ACTIONS_CACHE", feature?.EnvName);
Assert.IsFalse(feature?.Enabled);
}

[Test]
public void EnabledMessage()
{
var enabledFeature = new Feature
{
Enabled = true,
};
Assert.AreEqual("enabled", enabledFeature.EnabledMessage());
}

[Test]
public void DisabledMessage()
{
var disabledFeature = new Feature
{
Enabled = false,
};
Assert.AreEqual("disabled", disabledFeature.EnabledMessage());

}
}
75 changes: 75 additions & 0 deletions src/ActionsImporter.UnitTests/Services/DockerServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using ActionsImporter.Interfaces;
using ActionsImporter.Models;
using ActionsImporter.Services;
using Moq;
using NUnit.Framework;
Expand Down Expand Up @@ -212,6 +214,79 @@ public async Task ExecuteCommandAsync_InvokesDocker_OnLinuxOS_ReturnsTrue()
_processService.VerifyAll();
}

[Test]
public async Task GetFeaturesAsync_ReturnsFeatures()
{
// Arrange
var image = "actions-importer/cli";
var server = "ghcr.io";
var version = "latest";
var arguments = new[] { "list-features", "--json" };
var features = new List<Feature>
{
new Feature
{
Name = "actions/cache",
Description = "Control usage of actions/cache inside of workflows. Outputs a comment if not enabled.",
Enabled = false,
EnvName = "FEATURE_ACTIONS_CACHE",
},
new Feature
{
Name = "composite-actions",
Description = "Minimizes resulting workflow complexity through the use of composite actions. See https://docs.github.com/en/actions/creating-actions/creating-a-composite-action for more information.",
Enabled = true,
EnvName = "FEATURE_COMPOSITE_ACTIONS",
}
};
var featuresJSON = JsonSerializer.Serialize(features);

_processService.Setup(handler =>
handler.RunAndCaptureAsync(
"docker",
$"run --rm -t {server}/{image}:{version} {string.Join(' ', arguments)}",
null,
null,
false,
null
)
).Returns(Task.FromResult((featuresJSON, "", 0)));

// Act
var featuresResult = await _dockerService.GetFeaturesAsync(image, server, version);
var featuresResultJSON = JsonSerializer.Serialize(featuresResult);

// Assert
Assert.AreEqual(featuresJSON, featuresResultJSON);
}

[Test]
public async Task GetFeaturesAsync_BadJSONReturnsEmptyList()
{
// Arrange
var image = "actions-importer/cli";
var server = "ghcr.io";
var version = "latest";
var arguments = new[] { "list-features", "--json" };

_processService.Setup(handler =>
handler.RunAndCaptureAsync(
"docker",
$"run --rm -t {server}/{image}:{version} {string.Join(' ', arguments)}",
null,
null,
false,
null
)
).Returns(Task.FromResult(("", "", 0)));

// Act
var featuresResult = await _dockerService.GetFeaturesAsync(image, server, version);

// Assert
Assert.IsEmpty(featuresResult);
}

[Test]
public void VerifyDockerRunningAsync_IsRunning_NoException()
{
Expand Down
29 changes: 25 additions & 4 deletions src/ActionsImporter/App.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ActionsImporter.Interfaces;
using System.Collections.Immutable;
using ActionsImporter.Interfaces;
using ActionsImporter.Models;

namespace ActionsImporter;
Expand Down Expand Up @@ -99,14 +100,34 @@ public async Task CheckForUpdatesAsync()
}
}

public async Task<int> ConfigureAsync()
public async Task<int> ConfigureAsync(string[] args)
{
var currentVariables = await _configurationService.ReadCurrentVariablesAsync().ConfigureAwait(false);
var newVariables = _configurationService.GetUserInput();
ImmutableDictionary<string, string>? newVariables;

if (args.Contains($"--{Commands.Configure.OptionalFeaturesOption.Name}"))
{
await _dockerService.VerifyDockerRunningAsync().ConfigureAwait(false);
var availableFeatures = await _dockerService.GetFeaturesAsync(ActionsImporterImage, ActionsImporterContainerRegistry, ImageTag).ConfigureAwait(false);
try
{
newVariables = _configurationService.GetFeaturesInput(availableFeatures);
}
catch (Exception e)
{
await Console.Error.WriteLineAsync(e.Message);
return 1;
}
}
else
{
newVariables = _configurationService.GetUserInput();
}

var mergedVariables = _configurationService.MergeVariables(currentVariables, newVariables);
await _configurationService.WriteVariablesAsync(mergedVariables);

Console.WriteLine("Environment variables successfully updated.");
await Console.Out.WriteLineAsync("Environment variables successfully updated.");
return 0;
}
}
16 changes: 14 additions & 2 deletions src/ActionsImporter/Commands/Configure.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
using ActionsImporter.Handlers;

namespace ActionsImporter.Commands;

public class Configure : BaseCommand
{
private readonly string[] _args;
protected override string Name => "configure";
protected override string Description => "Start an interactive prompt to configure credentials used to authenticate with your CI server(s).";

public static readonly Option<bool> OptionalFeaturesOption = new(new[] { "--features" })
{
Description = "Configure the feature flags for GitHub Actions Importer."
};

public Configure(string[] args)
{
_args = args;
}

protected override Command GenerateCommand(App app)
{
ArgumentNullException.ThrowIfNull(app);

var command = base.GenerateCommand(app);

command.Handler = CommandHandler.Create(app.ConfigureAsync);
command.AddGlobalOption(OptionalFeaturesOption);
command.SetHandler(new ConfigureHandler(app).Run(_args));

return command;
}
Expand Down
4 changes: 3 additions & 1 deletion src/ActionsImporter/Commands/ListFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ public ListFeatures(string[] args) : base(args)
protected override string Name => "list-features";
protected override string Description => "List the available feature flags for GitHub Actions Importer.";

protected override ImmutableArray<Option> Options => ImmutableArray.Create<Option>();
protected override ImmutableArray<Option> Options => ImmutableArray.Create<Option>(
new Option<bool>(new[] { "--json", "-j" }, "Output the list of features in JSON format.")
);
}
13 changes: 13 additions & 0 deletions src/ActionsImporter/Handlers/ConfigureHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace ActionsImporter.Handlers;

public class ConfigureHandler
{
private readonly App _app;

public ConfigureHandler(App app)
{
_app = app;
}

public Func<Task<int>> Run(string[] args) => () => _app.ConfigureAsync(args);
}
2 changes: 2 additions & 0 deletions src/ActionsImporter/Interfaces/IConfigurationService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System.Collections.Immutable;
using ActionsImporter.Models;

namespace ActionsImporter.Interfaces;

public interface IConfigurationService
{
Task<ImmutableDictionary<string, string>> ReadCurrentVariablesAsync(string filePath = ".env.local");
ImmutableDictionary<string, string> GetFeaturesInput(List<Feature> features);
ImmutableDictionary<string, string> GetUserInput();
Task WriteVariablesAsync(ImmutableDictionary<string, string> variables, string filePath = ".env.local");

Expand Down
6 changes: 5 additions & 1 deletion src/ActionsImporter/Interfaces/IDockerService.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
namespace ActionsImporter.Interfaces;
using ActionsImporter.Models;

namespace ActionsImporter.Interfaces;

public interface IDockerService
{
Task UpdateImageAsync(string image, string server, string version);

Task ExecuteCommandAsync(string image, string server, string version, params string[] arguments);

Task<List<Feature>> GetFeaturesAsync(string image, string server, string version);

Task VerifyDockerRunningAsync();

Task VerifyImagePresentAsync(string image, string server, string version, bool isPrerelease);
Expand Down
17 changes: 17 additions & 0 deletions src/ActionsImporter/Models/Feature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;

namespace ActionsImporter.Models;

public class Feature
{
public string Name { get; set; } = string.Empty;

public string Description { get; set; } = string.Empty;

[JsonPropertyName("env_name")]
public string EnvName { get; set; } = string.Empty;

public bool Enabled { get; set; }

public string EnabledMessage() => Enabled ? "enabled" : "disabled";
}
2 changes: 1 addition & 1 deletion src/ActionsImporter/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
{
new Update().Command(app),
new Version().Command(app),
new Configure().Command(app),
new Configure(args).Command(app),
new Audit(args).Command(app),
new Forecast(args).Command(app),
new DryRun(args).Command(app),
Expand Down
22 changes: 22 additions & 0 deletions src/ActionsImporter/Services/ConfigurationService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using ActionsImporter.Interfaces;
using ActionsImporter.Models;
using Sharprompt;

namespace ActionsImporter.Services;
Expand Down Expand Up @@ -27,6 +28,27 @@ public async Task<ImmutableDictionary<string, string>> ReadCurrentVariablesAsync
return variables.ToImmutable();
}

public ImmutableDictionary<string, string> GetFeaturesInput(List<Feature> features)
{
ArgumentNullException.ThrowIfNull(features);
if (!features.Any()) throw new ArgumentException(message: "No features were found. Please make sure you have the latest version of GitHub Actions Importer.");

var input = ImmutableDictionary.CreateBuilder<string, string>();
var featureIndices = Prompt.MultiSelect("Which features would you like to configure?", Enumerable.Range(0, features.Count).ToArray(), textSelector: i => features[i].Name);

foreach (var index in featureIndices)
{
var feature = features[index];
var choice = Prompt.Select($"{feature.Name} ({feature.EnabledMessage()})", new[] { true, false }, textSelector: x => x ? "Enable" : "Disable");

if (choice != feature.Enabled)
{
input[feature.EnvName] = choice.ToString().ToUpperInvariant();
}
}

return input.ToImmutable();
}
public ImmutableDictionary<string, string> GetUserInput()
{
var providers = Prompt.MultiSelect(
Expand Down
23 changes: 23 additions & 0 deletions src/ActionsImporter/Services/DockerService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using ActionsImporter.Interfaces;
using ActionsImporter.Models.Docker;
using ActionsImporter.Models;

namespace ActionsImporter.Services;

Expand Down Expand Up @@ -55,6 +56,28 @@ await _processService.RunAsync(
);
}

public async Task<List<Feature>> GetFeaturesAsync(string image, string server, string version)
{
var actionsImporterArguments = new List<string> { "run --rm -t" };
actionsImporterArguments.AddRange(GetEnvironmentVariableArguments());
actionsImporterArguments.Add($"{server}/{image}:{version}");
actionsImporterArguments.AddRange(new[] { "list-features", "--json" });

var (standardOutput, _, _) = await _processService.RunAndCaptureAsync("docker", string.Join(' ', actionsImporterArguments), throwOnError: false);

var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, };
try
{
return JsonSerializer.Deserialize<List<Feature>>(standardOutput, options) ?? new();
}
catch (Exception)
{
// If unable to get the features from the container, return an empty list
// An empty list will result in a message being displayed to the user
return new();
}
}

public async Task VerifyDockerRunningAsync()
{
try
Expand Down

0 comments on commit e3c8e96

Please sign in to comment.