Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
466 changes: 466 additions & 0 deletions playground/Stress/Stress.AppHost/CommandResources.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const fs = require("node:fs");

const [backupPath = "sample-backup.dump", containerName = "db-server", databaseName = "mainDb"] = process.argv.slice(2);

console.log("scenario=#12906/#7786 backup-restore");
console.log(`backup-path=${backupPath}`);
console.log(`container=${containerName}`);
console.log(`database=${databaseName}`);

if (!fs.existsSync(backupPath)) {
console.error(`backup-missing=${backupPath}`);
process.exit(2);
}

const backupSize = fs.statSync(backupPath).size;
console.log(`backup-size-bytes=${backupSize}`);
console.log(`docker-cp-shape=docker cp "${backupPath}" "${containerName}:/tmp/restore.dump"`);
console.log(`docker-exec-shape=docker exec "${containerName}" pg_restore -U postgres -d "${databaseName}" /tmp/restore.dump`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const [containerName = "db-server", command = "pg_isready -U postgres", workingDirectory = "/"] = process.argv.slice(2);

console.log("scenario=#10301 container-exec");
console.log(`container=${containerName}`);
console.log(`working-directory=${workingDirectory}`);
console.log(`command=${command}`);
console.log(`docker-exec-shape=docker exec -w "${workingDirectory}" "${containerName}" ${command}`);

if (command.includes("fail")) {
console.error("simulated-container-exec-failure");
process.exit(3);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log(`env=${process.env.PROCESS_COMMAND_SAMPLE ?? ""}`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
for (let i = 1; i <= 20; i++) {
console.log(`line-${i}`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sample backup content for process command restore scenario
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
console.log("stdout-line");
console.error("stderr-line");
process.exit(3);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
let input = "";

process.stdin.setEncoding("utf8");
process.stdin.on("data", chunk => input += chunk);
process.stdin.on("end", () => {
console.log(`stdin-${input.trim()}`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const fs = require("node:fs");

const projectFiles = fs.readdirSync(process.cwd())
.filter(entry => entry.endsWith(".csproj"))
.sort();

for (const projectFile of projectFiles) {
console.log(projectFile);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#:sdk Microsoft.NET.Sdk
var input = await Console.In.ReadToEndAsync();
var name = args.Length > 0 ? args[0] : "unknown";

Console.WriteLine($"csharp-argument-{name}");
Console.WriteLine($"csharp-stdin-{input.Trim()}");
36 changes: 36 additions & 0 deletions playground/Stress/process-command-file-apps/issue-tool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#:sdk Microsoft.NET.Sdk

var mode = args.Length > 0 ? args[0] : "unknown";

Console.WriteLine($"scenario={Environment.GetEnvironmentVariable("ASPIRE_COMMAND_SCENARIO") ?? "manual"}");
Console.WriteLine($"mode={mode}");

if (mode == "seed")
{
var dataset = args.Length > 1 ? args[1] : "small";
var customers = args.Length > 2 ? args[2] : "25";

Console.WriteLine($"seed-dataset={dataset}");
Console.WriteLine($"seed-customers={customers}");
Console.WriteLine($"connection-set={!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ConnectionStrings__mainDb"))}");
}
else if (mode == "test")
{
var filter = args.Length > 1 ? args[1] : "smoke";

Console.WriteLine($"test-filter={filter}");
Console.WriteLine($"configuration={Environment.GetEnvironmentVariable("ASPIRE_TEST_CONFIGURATION") ?? "unknown"}");
}
else if (mode == "job")
{
var jobName = args.Length > 1 ? args[1] : "daily-import";
var stdin = await Console.In.ReadToEndAsync();

Console.WriteLine($"job-name={jobName}");
Console.WriteLine($"job-payload={stdin.Trim()}");
}
else
{
Console.Error.WriteLine($"unknown-mode={mode}");
Environment.Exit(2);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#:sdk Microsoft.NET.Sdk

var projectFiles = Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "*.csproj")
.Select(Path.GetFileName)
.OrderBy(static fileName => fileName);

foreach (var projectFile in projectFiles)
{
Console.WriteLine(projectFile);
}

Console.Error.WriteLine("csharp-stderr-line");
Environment.Exit(4);
24 changes: 24 additions & 0 deletions playground/TypeScriptAppHost/apphost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Run with: aspire run
// Publish with: aspire publish

import { join } from 'node:path';
import {
createBuilder,
refExpr,
Expand All @@ -29,6 +30,7 @@ await builder.addDockerComposeEnvironment("compose");

const dir = await builder.appHostDirectory();
console.log(`AppHost directory: ${dir}`);
const processCommandScriptPath = join(dir, "process-command-scripts", "node-process-check.js");

// Add PostgreSQL server and database
const postgres = await builder.addPostgres("postgres");
Expand Down Expand Up @@ -97,6 +99,28 @@ await cache.withCommand(
}
}
});
await cache.withProcessCommand(
"node-process-check",
"Node process check",
{
executablePath: "node",
arguments: [
processCommandScriptPath,
"from-typescript-apphost"
],
environmentVariables: [
{
name: "TS_PROCESS_COMMAND_SAMPLE",
value: "from-process-command"
}
],
standardInputContent: "hello from TypeScript AppHost",
maxOutputLineCount: 10,
commandOptions: {
description: "Runs a Node process command from the TypeScript AppHost.",
iconName: "WindowConsole"
}
});

console.log("Added Redis cache");

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
let input = "";

process.stdin.setEncoding("utf8");
process.stdin.on("data", chunk => input += chunk);
process.stdin.on("end", () => {
console.log(`ts-process-command-arg=${process.argv[2]}`);
console.log(`ts-process-command-env=${process.env.TS_PROCESS_COMMAND_SAMPLE}`);
console.log(`ts-process-command-stdin=${input.trim()}`);
});
1 change: 1 addition & 0 deletions src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessSpec.cs" Link="Shared\ProcessSpec.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessOutputCapture.cs" Link="Shared\ProcessOutputCapture.cs" />
<Compile Include="..\Aspire.Hosting\Dcp\Process\ProcessUtil.cs" Link="Shared\ProcessUtil.cs" />
<Compile Include="$(SharedDir)PathLookupHelper.cs" LinkBase="Shared" />
<Compile Include="$(SharedDir)ResourceNameComparer.cs" LinkBase="Shared" />
<Compile Include="$(SharedDir)KnownOtelConfigNames.cs" LinkBase="Shared" />
<Compile Include="$(SharedDir)PublishingContextUtils.cs" LinkBase="Shared" />
Expand Down
118 changes: 118 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/ProcessCommandOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Optional configuration for resource process commands added with <see cref="ResourceBuilderExtensions.WithProcessCommand{TResource}(IResourceBuilder{TResource}, string, string, Func{ExecuteCommandContext, ValueTask{ProcessCommandSpec}}, ProcessCommandOptions?)"/>.
/// </summary>
[Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class ProcessCommandOptions : CommandOptions
Comment thread
davidfowl marked this conversation as resolved.
Resolved
Comment thread
davidfowl marked this conversation as resolved.
Resolved
{
private int _maxOutputLineCount = 50;

Comment thread
davidfowl marked this conversation as resolved.
Resolved
internal static new ProcessCommandOptions Default { get; } = new();

/// <summary>
/// Gets or sets the maximum number of stdout and stderr output lines returned as command result data.
/// </summary>
/// <remarks>
/// <para>
/// Standard output and standard error are captured together in the order observed by the process runner. The returned
/// command result contains the retained tail of the combined output as plain text.
/// </para>
/// </remarks>
public int MaxOutputLineCount
{
get => _maxOutputLineCount;
set
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
_maxOutputLineCount = value;
}
}

/// <summary>
/// Gets or sets a value indicating whether returned command output should be displayed immediately in the dashboard.
/// </summary>
/// <remarks>
/// The default value is <see langword="true"/>.
/// </remarks>
public bool DisplayImmediately { get; set; } = true;
Comment thread
davidfowl marked this conversation as resolved.
Resolved
}

/// <summary>
/// ATS-friendly configuration for resource process commands.
/// </summary>
[AspireDto]
internal sealed class ProcessCommandExportOptions
{
/// <summary>
/// The executable path or command name to start.
/// </summary>
public string? ExecutablePath { get; set; }

/// <summary>
/// The command-line arguments for the process.
/// </summary>
public IReadOnlyList<string>? Arguments { get; set; }

/// <summary>
/// The working directory for the process.
/// </summary>
public string? WorkingDirectory { get; set; }

/// <summary>
/// The environment variables to set for the process.
/// </summary>
public IReadOnlyList<ProcessCommandEnvironmentVariable>? EnvironmentVariables { get; set; }

/// <summary>
/// A value indicating whether the process should inherit the current environment variables.
/// </summary>
public bool InheritEnvironmentVariables { get; set; } = true;

/// <summary>
/// Standard input content to write to the process after it starts.
/// </summary>
public string? StandardInputContent { get; set; }

/// <summary>
/// A value indicating whether the entire process tree should be killed when the process is disposed.
/// </summary>
public bool KillEntireProcessTree { get; set; } = true;

Comment thread
davidfowl marked this conversation as resolved.
Resolved
/// <summary>
/// Optional command configuration.
/// </summary>
public CommandOptions? CommandOptions { get; set; }

/// <summary>
/// The maximum number of stdout and stderr output lines returned as command result data.
/// </summary>
public int? MaxOutputLineCount { get; set; }

/// <summary>
/// A value indicating whether returned command output should be displayed immediately in the dashboard.
/// </summary>
public bool? DisplayImmediately { get; set; }
}

/// <summary>
/// Represents an environment variable to set for a resource process command.
/// </summary>
[AspireDto]
internal sealed class ProcessCommandEnvironmentVariable
{
/// <summary>
/// The environment variable name.
/// </summary>
public string? Name { get; set; }

/// <summary>
/// The environment variable value.
/// </summary>
public string? Value { get; set; }
}
64 changes: 64 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/ProcessCommandSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Describes a local process that is started when a process-backed resource command executes.
/// </summary>
/// <param name="executablePath">
/// The executable path or command name to start. Command names are resolved from the AppHost process PATH.
/// </param>
[Experimental("ASPIREPROCESSCOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed class ProcessCommandSpec(string executablePath)
{
/// <summary>
/// Gets the executable path or command name to start.
/// </summary>
/// <remarks>
/// <para>
/// Command names without directory separators are resolved from the AppHost process PATH before starting the process.
/// </para>
/// </remarks>
public string ExecutablePath { get; } = !string.IsNullOrWhiteSpace(executablePath)
? executablePath
: throw new ArgumentException("The executable path cannot be null, empty, or whitespace.", nameof(executablePath));

/// <summary>
/// Gets or sets the working directory for the process.
/// </summary>
public string? WorkingDirectory { get; init; }

/// <summary>
/// Gets or sets the command-line arguments for the process.
/// </summary>
/// <remarks>
/// <para>
/// Arguments are passed using <see cref="System.Diagnostics.ProcessStartInfo.ArgumentList"/> so that each item is
/// escaped according to the current platform's process-start rules.
/// </para>
/// </remarks>
public IReadOnlyList<string> Arguments { get; init; } = [];

/// <summary>
/// Gets or sets the environment variables to set for the process.
/// </summary>
public IDictionary<string, string> EnvironmentVariables { get; init; } = new Dictionary<string, string>();

/// <summary>
/// Gets or sets a value indicating whether the process should inherit the current environment variables.
/// </summary>
public bool InheritEnvironmentVariables { get; init; } = true;

/// <summary>
/// Gets or sets standard input content to write to the process after it starts.
/// </summary>
public string? StandardInputContent { get; init; }
Comment thread
davidfowl marked this conversation as resolved.
Resolved

/// <summary>
/// Gets or sets a value indicating whether the entire process tree should be killed when the process is disposed.
/// </summary>
public bool KillEntireProcessTree { get; init; } = true;
}
24 changes: 24 additions & 0 deletions src/Aspire.Hosting/Dcp/Process/IProcessRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.Dcp.Process;

/// <summary>
/// Runs external processes.
/// </summary>
internal interface IProcessRunner
{
/// <summary>
/// Runs a process with the specified configuration.
/// </summary>
(Task<ProcessResult>, IAsyncDisposable) Run(ProcessSpec processSpec);
}

/// <summary>
/// Runs external processes by delegating to <see cref="ProcessUtil"/>.
/// </summary>
internal sealed class DefaultProcessRunner : IProcessRunner
{
public (Task<ProcessResult>, IAsyncDisposable) Run(ProcessSpec processSpec)
=> ProcessUtil.Run(processSpec);
}
Loading
Loading