mirror of https://github.com/dotnet/tye.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
387 lines
15 KiB
387 lines
15 KiB
// Licensed to the .NET Foundation under one or more agreements.
|
|
// The .NET Foundation licenses this file to you under the MIT license.
|
|
// See the LICENSE file in the project root for more information.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Tye.Hosting.Model;
|
|
|
|
namespace Microsoft.Tye.Hosting
|
|
{
|
|
public class ProcessRunner : IApplicationProcessor
|
|
{
|
|
private const string ProcessReplicaStore = "process";
|
|
|
|
private readonly ILogger _logger;
|
|
private readonly ProcessRunnerOptions _options;
|
|
|
|
private readonly ReplicaRegistry _replicaRegistry;
|
|
|
|
public ProcessRunner(ILogger logger, ReplicaRegistry replicaRegistry, ProcessRunnerOptions options)
|
|
{
|
|
_logger = logger;
|
|
_replicaRegistry = replicaRegistry;
|
|
_options = options;
|
|
}
|
|
|
|
public async Task StartAsync(Application application)
|
|
{
|
|
await PurgeFromPreviousRun();
|
|
|
|
var tasks = new Task[application.Services.Count];
|
|
var index = 0;
|
|
foreach (var s in application.Services)
|
|
{
|
|
tasks[index++] = s.Value.ServiceType switch
|
|
{
|
|
ServiceType.Executable => LaunchService(application, s.Value),
|
|
ServiceType.Project => LaunchService(application, s.Value),
|
|
|
|
_ => Task.CompletedTask,
|
|
};
|
|
}
|
|
|
|
await Task.WhenAll(tasks);
|
|
}
|
|
|
|
public Task StopAsync(Application application)
|
|
{
|
|
return KillRunningProcesses(application.Services);
|
|
}
|
|
|
|
private async Task LaunchService(Application application, Service service)
|
|
{
|
|
var serviceDescription = service.Description;
|
|
var serviceName = serviceDescription.Name;
|
|
|
|
var path = "";
|
|
var workingDirectory = "";
|
|
var args = "";
|
|
|
|
if (serviceDescription.RunInfo is ProjectRunInfo project)
|
|
{
|
|
path = project.RunCommand;
|
|
workingDirectory = project.ProjectFile.Directory.FullName;
|
|
args = project.Args == null ? project.RunArguments : project.RunArguments + " " + project.Args;
|
|
service.Status.ProjectFilePath = project.ProjectFile.FullName;
|
|
}
|
|
else if (serviceDescription.RunInfo is ExecutableRunInfo executable)
|
|
{
|
|
path = executable.Executable;
|
|
workingDirectory = executable.WorkingDirectory!;
|
|
args = executable.Args ?? "";
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("Unsupported ServiceType.");
|
|
}
|
|
|
|
// If this is a dll then use dotnet to run it
|
|
if (Path.GetExtension(path) == ".dll")
|
|
{
|
|
args = $"\"{path}\" {args}".Trim();
|
|
path = "dotnet";
|
|
}
|
|
|
|
service.Status.ExecutablePath = path;
|
|
service.Status.WorkingDirectory = workingDirectory;
|
|
service.Status.Args = args;
|
|
|
|
var processInfo = new ProcessInfo(new Task[service.Description.Replicas]);
|
|
|
|
if (service.Status.ProjectFilePath != null &&
|
|
service.Description.RunInfo is ProjectRunInfo project2 &&
|
|
project2.Build &&
|
|
_options.BuildProjects)
|
|
{
|
|
// Sometimes building can fail because of file locking (like files being open in VS)
|
|
_logger.LogInformation("Building project {ProjectFile}", service.Status.ProjectFilePath);
|
|
|
|
service.Logs.OnNext($"dotnet build \"{service.Status.ProjectFilePath}\" /nologo");
|
|
|
|
var buildResult = await ProcessUtil.RunAsync("dotnet", $"build \"{service.Status.ProjectFilePath}\" /nologo", throwOnError: false, workingDirectory: workingDirectory);
|
|
|
|
service.Logs.OnNext(buildResult.StandardOutput);
|
|
|
|
if (buildResult.ExitCode != 0)
|
|
{
|
|
_logger.LogInformation("Building {ProjectFile} failed with exit code {ExitCode}: \r\n" + buildResult.StandardOutput, service.Status.ProjectFilePath, buildResult.ExitCode);
|
|
return;
|
|
}
|
|
}
|
|
|
|
async Task RunApplicationAsync(IEnumerable<(int ExternalPort, int Port, string? Protocol)> ports)
|
|
{
|
|
// Make sure we yield before trying to start the process, this is important so we don't block startup
|
|
await Task.Yield();
|
|
|
|
var hasPorts = ports.Any();
|
|
|
|
var environment = new Dictionary<string, string>
|
|
{
|
|
// Default to development environment
|
|
["DOTNET_ENVIRONMENT"] = "Development",
|
|
["ASPNETCORE_ENVIRONMENT"] = "Development",
|
|
// Remove the color codes from the console output
|
|
["DOTNET_LOGGING__CONSOLE__DISABLECOLORS"] = "true",
|
|
["ASPNETCORE_LOGGING__CONSOLE__DISABLECOLORS"] = "true"
|
|
};
|
|
|
|
// Set up environment variables to use the version of dotnet we're using to run
|
|
// this is important for tests where we're not using a globally-installed dotnet.
|
|
var dotnetRoot = GetDotnetRoot();
|
|
if (dotnetRoot is object)
|
|
{
|
|
environment["DOTNET_ROOT"] = dotnetRoot;
|
|
environment["DOTNET_MULTILEVEL_LOOKUP"] = "0";
|
|
environment["PATH"] = $"{dotnetRoot};{Environment.GetEnvironmentVariable("PATH")}";
|
|
}
|
|
|
|
application.PopulateEnvironment(service, (k, v) => environment[k] = v);
|
|
|
|
if (_options.DebugMode && (_options.DebugAllServices || _options.ServicesToDebug.Contains(serviceName, StringComparer.OrdinalIgnoreCase)))
|
|
{
|
|
environment["DOTNET_STARTUP_HOOKS"] = typeof(Hosting.Runtime.HostingRuntimeHelpers).Assembly.Location;
|
|
}
|
|
|
|
if (hasPorts)
|
|
{
|
|
// We need to bind to all interfaces on linux since the container -> host communication won't work
|
|
// if we use the IP address to reach out of the host. This works fine on osx and windows
|
|
// but doesn't work on linux.
|
|
var host = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "*" : "localhost";
|
|
|
|
// These are the ports that the application should use for binding
|
|
|
|
// 1. Configure ASP.NET Core to bind to those same ports
|
|
environment["ASPNETCORE_URLS"] = string.Join(";", ports.Select(p => $"{p.Protocol ?? "http"}://{host}:{p.Port}"));
|
|
|
|
// Set the HTTPS port for the redirect middleware
|
|
foreach (var p in ports)
|
|
{
|
|
if (string.Equals(p.Protocol, "https", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// We need to set the redirect URL to the exposed port so the redirect works cleanly
|
|
environment["HTTPS_PORT"] = p.ExternalPort.ToString();
|
|
}
|
|
}
|
|
|
|
// 3. For non-ASP.NET Core apps, pass the same information in the PORT env variable as a semicolon separated list.
|
|
environment["PORT"] = string.Join(";", ports.Select(p => $"{p.Port}"));
|
|
}
|
|
|
|
while (!processInfo.StoppedTokenSource.IsCancellationRequested)
|
|
{
|
|
var replica = serviceName + "_" + Guid.NewGuid().ToString().Substring(0, 10).ToLower();
|
|
var status = new ProcessStatus(service, replica);
|
|
service.Replicas[replica] = status;
|
|
|
|
service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Added, status));
|
|
|
|
// This isn't your host name
|
|
environment["APP_INSTANCE"] = replica;
|
|
|
|
status.ExitCode = null;
|
|
status.Pid = null;
|
|
status.Environment = environment;
|
|
|
|
if (hasPorts)
|
|
{
|
|
status.Ports = ports.Select(p => p.Port);
|
|
}
|
|
|
|
// TODO clean this up.
|
|
foreach (var env in environment)
|
|
{
|
|
args = args.Replace($"%{env.Key}%", env.Value);
|
|
}
|
|
|
|
_logger.LogInformation("Launching service {ServiceName}: {ExePath} {args}", replica, path, args);
|
|
|
|
try
|
|
{
|
|
service.Logs.OnNext($"[{replica}]:{path} {args}");
|
|
|
|
var result = await ProcessUtil.RunAsync(
|
|
path,
|
|
args,
|
|
environmentVariables: environment,
|
|
workingDirectory: workingDirectory,
|
|
outputDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"),
|
|
errorDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"),
|
|
onStart: pid =>
|
|
{
|
|
if (hasPorts)
|
|
{
|
|
_logger.LogInformation("{ServiceName} running on process id {PID} bound to {Address}", replica, pid, string.Join(", ", ports.Select(p => $"{p.Protocol ?? "http"}://localhost:{p.Port}")));
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("{ServiceName} running on process id {PID}", replica, pid);
|
|
}
|
|
|
|
status.Pid = pid;
|
|
|
|
WriteReplicaToStore(pid.ToString());
|
|
service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Started, status));
|
|
},
|
|
throwOnError: false,
|
|
cancellationToken: processInfo.StoppedTokenSource.Token);
|
|
|
|
status.ExitCode = result.ExitCode;
|
|
|
|
if (status.Pid != null)
|
|
{
|
|
service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Stopped, status));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(0, ex, "Failed to launch process for service {ServiceName}", replica);
|
|
|
|
try
|
|
{
|
|
await Task.Delay(5000, processInfo.StoppedTokenSource.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Swallow cancellation exceptions and continue
|
|
}
|
|
}
|
|
|
|
service.Restarts++;
|
|
|
|
if (status.ExitCode != null)
|
|
{
|
|
_logger.LogInformation("{ServiceName} process exited with exit code {ExitCode}", replica, status.ExitCode);
|
|
}
|
|
|
|
// Remove the replica from the set
|
|
service.Replicas.TryRemove(replica, out _);
|
|
service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Removed, status));
|
|
}
|
|
}
|
|
|
|
if (serviceDescription.Bindings.Count > 0)
|
|
{
|
|
// Each replica is assigned a list of internal ports, one mapped to each external
|
|
// port
|
|
for (int i = 0; i < serviceDescription.Replicas; i++)
|
|
{
|
|
var ports = new List<(int, int, string?)>();
|
|
foreach (var binding in serviceDescription.Bindings)
|
|
{
|
|
if (binding.Port == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ports.Add((binding.Port.Value, binding.ReplicaPorts[i], binding.Protocol));
|
|
}
|
|
|
|
processInfo.Tasks[i] = RunApplicationAsync(ports);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (int i = 0; i < service.Description.Replicas; i++)
|
|
{
|
|
processInfo.Tasks[i] = RunApplicationAsync(Enumerable.Empty<(int, int, string?)>());
|
|
}
|
|
}
|
|
|
|
service.Items[typeof(ProcessInfo)] = processInfo;
|
|
}
|
|
|
|
private Task KillRunningProcesses(IDictionary<string, Service> services)
|
|
{
|
|
static Task KillProcessAsync(Service service)
|
|
{
|
|
if (service.Items.TryGetValue(typeof(ProcessInfo), out var stateObj) && stateObj is ProcessInfo state)
|
|
{
|
|
// Cancel the token before stopping the process
|
|
state.StoppedTokenSource.Cancel();
|
|
|
|
return Task.WhenAll(state.Tasks);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var index = 0;
|
|
var tasks = new Task[services.Count];
|
|
foreach (var s in services.Values)
|
|
{
|
|
var state = s;
|
|
tasks[index++] = KillProcessAsync(state);
|
|
}
|
|
|
|
return Task.WhenAll(tasks);
|
|
}
|
|
|
|
private async Task PurgeFromPreviousRun()
|
|
{
|
|
var processReplicas = await _replicaRegistry.GetEvents(ProcessReplicaStore);
|
|
foreach (var replica in processReplicas)
|
|
{
|
|
if (int.TryParse(replica["pid"], out var pid))
|
|
{
|
|
ProcessUtil.KillProcess(pid);
|
|
_logger.LogInformation("removed process {pid} from previous run", pid);
|
|
}
|
|
}
|
|
|
|
_replicaRegistry.DeleteStore(ProcessReplicaStore);
|
|
}
|
|
|
|
private void WriteReplicaToStore(string pid)
|
|
{
|
|
_replicaRegistry.WriteReplicaEvent(ProcessReplicaStore, new Dictionary<string, string>()
|
|
{
|
|
["pid"] = pid
|
|
});
|
|
}
|
|
|
|
private static string? GetDotnetRoot()
|
|
{
|
|
var entryPointFilePath = GetEntryPointFilePath();
|
|
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
|
Path.GetFileNameWithoutExtension(entryPointFilePath) == "dotnet")
|
|
{
|
|
return Path.GetDirectoryName(entryPointFilePath);
|
|
}
|
|
else if (Path.GetFileName(entryPointFilePath) == "dotnet")
|
|
{
|
|
return Path.GetDirectoryName(entryPointFilePath);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string GetEntryPointFilePath()
|
|
{
|
|
using var process = Process.GetCurrentProcess();
|
|
return process.MainModule.FileName;
|
|
}
|
|
|
|
private class ProcessInfo
|
|
{
|
|
|
|
public ProcessInfo(Task[] tasks)
|
|
{
|
|
Tasks = tasks;
|
|
}
|
|
|
|
public Task[] Tasks { get; }
|
|
|
|
public CancellationTokenSource StoppedTokenSource { get; } = new CancellationTokenSource();
|
|
}
|
|
}
|
|
}
|
|
|