Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[release/8.0] Implement IResourceWithArgs on ProjectResource #3559

Merged
merged 1 commit into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ namespace Aspire.Hosting.ApplicationModel;
/// A resource that represents a specified .NET project.
/// </summary>
/// <param name="name">The name of the resource.</param>
public class ProjectResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithServiceDiscovery
public class ProjectResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithServiceDiscovery
{
}
4 changes: 2 additions & 2 deletions src/Aspire.Hosting/Dcp/ApplicationExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1138,8 +1138,8 @@ private void PrepareProjectExecutables()
var launchProfile = project.GetEffectiveLaunchProfile();
if (launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs))
{
var cmdArgs = launchProfile.CommandLineArgs.Split((string?)null, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (cmdArgs is not null && cmdArgs.Length > 0)
var cmdArgs = CommandLineArgsParser.Parse(launchProfile.CommandLineArgs);
if (cmdArgs.Count > 0)
{
exeSpec.Args.Add("--");
exeSpec.Args.AddRange(cmdArgs);
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ private static IResourceBuilder<ProjectResource> WithProjectDefaults(this IResou
}
});

// TODO: Process command line arguments here
// NOTE: the launch profile command line arguments will be processed by ApplicationExecutor.PrepareProjectExecutables() (either by the IDE or manually passed to run)
}
else
{
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ private async Task WriteProjectAsync(ProjectResource project)

Writer.WriteString("path", relativePathToProjectFile);

await WriteCommandLineArgumentsAsync(project).ConfigureAwait(false);

await WriteEnvironmentVariablesAsync(project).ConfigureAwait(false);
WriteBindings(project);
}
Expand Down
123 changes: 123 additions & 0 deletions src/Aspire.Hosting/Utils/CommandLineArgsParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;

namespace Aspire.Hosting.Utils;

internal static class CommandLineArgsParser
{
/// <summary>Parses a command-line argument string into a list of arguments.</summary>
public static List<string> Parse(string arguments)
{
var result = new List<string>();
ParseArgumentsIntoList(arguments, result);
return result;
}

/// <summary>Parses a command-line argument string into a list of arguments.</summary>
/// <param name="arguments">The argument string.</param>
/// <param name="results">The list into which the component arguments should be stored.</param>
/// <remarks>
/// This follows the rules outlined in "Parsing C++ Command-Line Arguments" at
/// https://msdn.microsoft.com/en-us/library/17w5ykft.aspx.
/// </remarks>
// copied from https://github.com/dotnet/runtime/blob/404b286b23093cd93a985791934756f64a33483e/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs#L846-L945
private static void ParseArgumentsIntoList(string arguments, List<string> results)
{
// Iterate through all of the characters in the argument string.
for (int i = 0; i < arguments.Length; i++)
{
while (i < arguments.Length && (arguments[i] == ' ' || arguments[i] == '\t'))
{
i++;
}

if (i == arguments.Length)
{
break;
}

results.Add(GetNextArgument(arguments, ref i));
}
}

private static string GetNextArgument(string arguments, ref int i)
{
var currentArgument = new StringBuilder();
bool inQuotes = false;

while (i < arguments.Length)
{
// From the current position, iterate through contiguous backslashes.
int backslashCount = 0;
while (i < arguments.Length && arguments[i] == '\\')
{
i++;
backslashCount++;
}

if (backslashCount > 0)
{
if (i >= arguments.Length || arguments[i] != '"')
{
// Backslashes not followed by a double quote:
// they should all be treated as literal backslashes.
currentArgument.Append('\\', backslashCount);
}
else
{
// Backslashes followed by a double quote:
// - Output a literal slash for each complete pair of slashes
// - If one remains, use it to make the subsequent quote a literal.
currentArgument.Append('\\', backslashCount / 2);
if (backslashCount % 2 != 0)
{
currentArgument.Append('"');
i++;
}
}

continue;
}

char c = arguments[i];

// If this is a double quote, track whether we're inside of quotes or not.
// Anything within quotes will be treated as a single argument, even if
// it contains spaces.
if (c == '"')
{
if (inQuotes && i < arguments.Length - 1 && arguments[i + 1] == '"')
{
// Two consecutive double quotes inside an inQuotes region should result in a literal double quote
// (the parser is left in the inQuotes region).
// This behavior is not part of the spec of code:ParseArgumentsIntoList, but is compatible with CRT
// and .NET Framework.
currentArgument.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}

i++;
continue;
}

// If this is a space/tab and we're not in quotes, we're done with the current
// argument, it should be added to the results and then reset for the next one.
if ((c == ' ' || c == '\t') && !inQuotes)
{
break;
}

// Nothing special; add the character to the current argument.
currentArgument.Append(c);
i++;
}

return currentArgument.ToString();
}
}
79 changes: 79 additions & 0 deletions tests/Aspire.Hosting.Tests/ProjectResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,84 @@ public async Task VerifyManifest(bool disableForwardedHeaders)
Assert.Equal(expectedManifest, manifest.ToString());
}

[Fact]
public async Task VerifyManifestWithArgs()
{
var appBuilder = CreateBuilder();

appBuilder.AddProject<TestProjectWithLaunchSettings>("projectName")
.WithArgs("one", "two");

using var app = appBuilder.Build();

var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();

var projectResources = appModel.GetProjectResources();

var resource = Assert.Single(projectResources);

var manifest = await ManifestUtils.GetManifest(resource);

var expectedManifest = $$"""
{
"type": "project.v0",
"path": "another-path",
"args": [
"one",
"two"
],
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http"
},
"https": {
"scheme": "https",
"protocol": "tcp",
"transport": "http"
}
}
}
""";

Assert.Equal(expectedManifest, manifest.ToString());
}

[Fact]
public async Task AddProjectWithArgs()
{
var appBuilder = DistributedApplication.CreateBuilder();

var c1 = appBuilder.AddContainer("c1", "image2")
.WithEndpoint("ep", e =>
{
e.UriScheme = "http";
e.AllocatedEndpoint = new(e, "localhost", 1234);
});

var project = appBuilder.AddProject<TestProjectWithLaunchSettings>("projectName")
.WithArgs(context =>
{
context.Args.Add("arg1");
context.Args.Add(c1.GetEndpoint("ep"));
});

using var app = appBuilder.Build();

var args = await ArgumentEvaluator.GetArgumentListAsync(project.Resource);

Assert.Collection(args,
arg => Assert.Equal("arg1", arg),
arg => Assert.Equal("http://localhost:1234", arg));
}

private static IDistributedApplicationBuilder CreateBuilder(string[]? args = null, DistributedApplicationOperation operation = DistributedApplicationOperation.Publish)
{
var resolvedArgs = new List<string>();
Expand Down Expand Up @@ -437,6 +515,7 @@ private sealed class TestProjectWithLaunchSettings : IProjectMetadata
["http"] = new()
{
CommandName = "Project",
CommandLineArgs = "arg1 arg2",
LaunchBrowser = true,
ApplicationUrl = "http://localhost:5031",
EnvironmentVariables = new()
Expand Down
30 changes: 30 additions & 0 deletions tests/Aspire.Hosting.Tests/Utils/CommandLineArgsParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Xunit;
using static Aspire.Hosting.Utils.CommandLineArgsParser;

namespace Aspire.Hosting.Tests.Utils;

public class CommandLineArgsParserTests
{
[Theory]
[InlineData("", new string[] { })]
[InlineData("single", new[] { "single" })]
[InlineData("hello world", new[] { "hello", "world" })]
[InlineData("foo bar baz", new[] { "foo", "bar", "baz" })]
[InlineData("foo\tbar\tbaz", new[] { "foo", "bar", "baz" })]
[InlineData("\"quoted string\"", new[] { "quoted string" })]
[InlineData("\"quoted\tstring\"", new[] { "quoted\tstring" })]
[InlineData("\"quoted \"\" string\"", new[] { "quoted \" string" })]
// Single quotes are not treated as string delimiters
[InlineData("\"hello 'world'\"", new[] { "hello 'world'" })]
[InlineData("'single quoted'", new[] { "'single", "quoted'" })]
[InlineData("'foo \"bar\" baz'", new[] { "'foo", "bar", "baz'" })]
public void TestParse(string commandLine, string[] expectedParsed)
{
var actualParsed = Parse(commandLine);

Assert.Equal(expectedParsed, actualParsed.ToArray());
}
}